aboutsummaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorТатьяна Фарбер <alexandr@asustem.ru>2026-10-22 00:10:41 +0400
committerТатьяна Фарбер <alexandr@asustem.ru>2026-10-22 00:10:41 +0400
commit327b9128fea47c2d8db622933d79ee8cf0c1f86f (patch)
treeb3f5c1cb0925967d61e612804c636ba2370eff2b
php2shell add
-rw-r--r--README.md86
-rw-r--r--example.lua79
-rw-r--r--nginx.conf28
-rw-r--r--waf/analyzer.lua35
-rw-r--r--waf/ban.lua55
-rw-r--r--waf/config.lua30
-rw-r--r--waf/detector.lua65
-rw-r--r--waf/filter.lua81
-rw-r--r--waf/limits.lua37
9 files changed, 496 insertions, 0 deletions
diff --git a/README.md b/README.md
new file mode 100644
index 0000000..fbb8d6b
--- /dev/null
+++ b/README.md
@@ -0,0 +1,86 @@
+# Минималистичный Web Application Firewall (WAF) на OpenResty
+
+UPDATE: добавлена работа со спецификой уязвимости PHP2Shell:
+
+1. Бан по Multipart-границам формы
+2. Глубине вложенности JSON-структур
+3. Странным параметрам PHPSESSID
+
+## Архитектура
+
+[ Входящий запрос ]
+ │
+ ├──► 0. Белый список IP и Глобальный Бан-лист (L1/L2 кэш)
+ │
+ ├──► 1. waf.detector (Эвристика заголовков, Smuggling и сессии)
+ │
+ ├──► 2. waf.limits (Двухпороговый Token Bucket на POST-динамику)
+ │
+ ├──► 3. waf.analyzer (Анализатор Query String URL на SQLi/LFI)
+ │
+ └──► 4. waf.filter (Потоковый стриминг Multipart и JSON скобок)
+
+Структура файлов в репозитории:
+
+• example.lua — Пример реализации на фазе access_by_lua 7 уровня OSI (фаза блока http).
+• waf/config.lua — Единая точка конфигурации лимитов, белых списков и штрафных матриц.
+• waf/ban.lua — Двухуровневый кэш блокировок (Локальная L1 таблица воркера ➡️ L2 shared_dict).
+• waf/detector.lua — Анализатор аномалий HTTP-заголовков и валидатор формата куки PHPSESSID.
+• waf/limits.lua — Двухпороговый ограничитель частоты (Rate Limiter) для тяжелых POST-запросов.
+• waf/analyzer.lua — Высокоскоростной чистильщик параметров URL-строки (ngx.var.args).
+• waf/filter.lua — Низкоуровневый потоковый парсер сырого сокета тела запроса (Multipart и JSON).
+
+## Примеры использования
+
+1. На уровне location
+
+```http {
+
+ lua_shared_dict banned_ips 10m;
+ lua_shared_dict limits 2m;
+
+ map $http_user_agent $is_bot {
+ default 0;
+ ~*googlebot|yandex|bingbot|baiduspider|mail.ru_bot|ia_archiver|facebot|facebookexternalhit|twitterbot|://google.com|ahrefsbot|semrushbot|telegrambot|vkshare|whatsapp 1;
+ }
+
+ server {
+ listen 443 ssl;
+ server_name test.ru;
+ server_tokens off;
+
+ location ~* /(wp-content|wp-includes|wp-admin|wp-login\.php|xmlrpc\.php|\.git|\.env|vendor|phpmyadmin|adminer\.php|\.htaccess|config\.init) {
+ access_log off;
+ log_not_found off;
+ return 444;
+ }
+
+ location / {
+ access_log off;
+ access_by_lua_file /home/example.lua;
+ }
+ }
+
+}```
+
+2. На уровне server
+
+```
+http {
+
+ lua_shared_dict banned_ips 10m;
+ lua_shared_dict limits 2m;
+
+ map $http_user_agent $is_bot {
+ default 0;
+ ~*googlebot|yandex|bingbot|baiduspider|mail.ru_bot|ia_archiver|facebot|facebookexternalhit|twitterbot|://google.com|ahrefsbot|semrushbot|telegrambot|vkshare|whatsapp 1;
+ }
+
+ server {
+ listen 443 ssl;
+ server_name test.ru;
+ access_by_lua_file /home/example.lua;
+ }
+}
+
+```
diff --git a/example.lua b/example.lua
new file mode 100644
index 0000000..91f4893
--- /dev/null
+++ b/example.lua
@@ -0,0 +1,79 @@
+local detector = require('waf.detector')
+local analyzer = require('waf.analyzer')
+local filter = require('waf.filter')
+local limits = require('waf.limits')
+local config = require('waf.config')
+local ban = require('waf.ban')
+local log = require('utils.log')
+
+local default_code = config.code
+local duration = config.duration
+local valid_ips = config.ips
+local ban_add = ban.add
+local ban_check = ban.check
+local headers
+local meta
+local client_ip = ngx.var.remote_addr
+
+local function answer(code)
+ code = code or default_code
+ return ngx.exit(444)
+end
+
+local function valid(ip)
+ for k=1,#valid_ips do
+ if valid_ips[k] == ip then return end
+ end
+ return false
+end
+
+log.name = 'waf'
+log.level = 'trace'
+
+if valid(client_ip) then return true end
+if ban_check(client_ip) or ngx.var.is_bot == '1' then return answer() end
+
+if not detector then
+ log.error('Не найден детектор')
+else
+ headers = ngx.req.get_headers()
+ meta = {
+ method = ngx.var.request_method,
+ uri = ngx.var.uri
+ }
+ local res,level = detector.check(headers,meta)
+ if not res then
+ ban_add(client_ip, duration/level)
+ if level==1 then return answer() end
+ end
+end
+
+if not limits then
+ log.error('Не найден лимитизатор')
+else
+ local res,level = limits.check(meta,client_ip)
+ if not res then
+ ban_add(client_ip, duration/level)
+ if level==1 then return answer() end
+ end
+end
+
+if not analyzer then
+ log.error('Не найден анализатор')
+else
+ local res,level = analyzer.check()
+ if not res then
+ ban_add(client_ip, duration/level)
+ if level==1 then return answer() end
+ end
+end
+
+if not filter then
+ log.error('Не найден фильтр')
+else
+ local res = filter.check()
+ if not res then
+ ban_add(client_ip, duration)
+ return answer()
+ end
+end
diff --git a/nginx.conf b/nginx.conf
new file mode 100644
index 0000000..2f87043
--- /dev/null
+++ b/nginx.conf
@@ -0,0 +1,28 @@
+http {
+
+ lua_shared_dict banned_ips 10m;
+ lua_shared_dict limits 2m;
+
+ map $http_user_agent $is_bot {
+ default 0;
+ ~*googlebot|yandex|bingbot|baiduspider|mail.ru_bot|ia_archiver|facebot|facebookexternalhit|twitterbot|://google.com|ahrefsbot|semrushbot|telegrambot|vkshare|whatsapp 1;
+ }
+
+ server {
+ listen 443 ssl;
+ server_name test.ru;
+ server_tokens off;
+
+ location ~* /(wp-content|wp-includes|wp-admin|wp-login\.php|xmlrpc\.php|\.git|\.env|vendor|phpmyadmin|adminer\.php|\.htaccess|config\.init) {
+ access_log off;
+ log_not_found off;
+ return 444;
+ }
+
+ location / {
+ access_log off;
+ access_by_lua_file /home/example.lua;
+ }
+ }
+
+}
diff --git a/waf/analyzer.lua b/waf/analyzer.lua
new file mode 100644
index 0000000..95ebdf2
--- /dev/null
+++ b/waf/analyzer.lua
@@ -0,0 +1,35 @@
+local config = require('waf.config')
+
+local level1 = config.analyzer and config.analyzer[1] or 10 -- Подозрительно
+local level2 = config.analyzer and config.analyzer[2] or 1 -- Критично (SQLi/LFI)
+
+local _M = {}
+
+local regex_traversal = ngx.re.compile([[(?:\.\.\/|\.\.\\|%2e%2e%2f|%2e%2e%5c)]], 'jo')
+local regex_sqli = ngx.re.compile([[(?:union\s+select|select\s+.*\s+from|insert\s+into|delete\s+from|drop\s+table|update\s+.*\s+set|information_schema|or\s+\d+=\d+|--|/\*)]], 'jo')
+
+--[[
+ Анализ строки URL-аргументов (Query String) на наличие веб-атак
+ Возвращает: boolean (true — чисто, false — атака), number (коэффициент из конфига)
+--]]
+function _M.check()
+ local args_str = ngx.var.args
+ if not args_str or args_str == '' then
+ return true
+ end
+ local args_lower = string.lower(args_str)
+ -- 1. Защита от Directory Traversal (попытки выйти из корня сайта в систему)
+ local match_traversal = ngx.re.find(args_lower, regex_traversal)
+ if match_traversal then
+ return false, level2
+ end
+ -- 2. Защита от SQL-инъекций в параметрах поиска/фильтрации Битрикса
+ local match_sqli = ngx.re.find(args_lower, regex_sqli)
+ if match_sqli then
+ return false, level2
+ end
+ return true
+end
+
+return _M
+
diff --git a/waf/ban.lua b/waf/ban.lua
new file mode 100644
index 0000000..819eced
--- /dev/null
+++ b/waf/ban.lua
@@ -0,0 +1,55 @@
+local config = require('waf.config')
+
+local _M = {}
+
+local l1_cache = {}
+local l2_cache = ngx.shared.banned_ips
+local L1_TTL = config.ttl
+
+--[[
+ Проверка IP-адреса на наличие в черных списках (L1 -> L2)
+ Принимает: client_ip (строка)
+ Возвращает: boolean (true — забанен, false — чист)
+--]]
+function _M.check(client_ip)
+ if not client_ip then return false end
+ local now = ngx.time()
+ local l1_record = l1_cache[client_ip]
+ if l1_record and l1_record.expire > now then
+ return l1_record.verdict
+ end
+ local l2_verdict = l2_cache:get(client_ip)
+ if l2_verdict then
+ l1_cache[client_ip] = {
+ verdict = true,
+ expire = now + L1_TTL
+ }
+ return true
+ end
+ l1_cache[client_ip] = {
+ verdict = false,
+ expire = now + L1_TTL
+ }
+ return false
+end
+
+--[[
+ Внесение IP-адреса в черный список (L2 + инвалидация L1)
+ Принимает: client_ip (строка), duration (число секунд бана)
+--]]
+function _M.add(client_ip, duration)
+ if not client_ip then return end
+ local ban_duration = duration or config.duration
+ local now = ngx.time()
+
+ -- Записываем в глобальный L2-кэш для всех воркеров
+ l2_cache:set(client_ip, true, ban_duration)
+
+ -- Мгновенно обновляем локальный L1-кэш текущего воркера
+ l1_cache[client_ip] = {
+ verdict = true,
+ expire = now + ban_duration
+ }
+end
+
+return _M
diff --git a/waf/config.lua b/waf/config.lua
new file mode 100644
index 0000000..407e342
--- /dev/null
+++ b/waf/config.lua
@@ -0,0 +1,30 @@
+local _M = {
+ -- Время жизни записи в локальном кэше воркера (в секундах)
+ ttl = 3,
+ -- Продолжительность бана IP по умолчанию
+ duration = 3600,
+ -- Уровни опасности при детектировании заголовков - чем ниже, тем быстрее баним IP, при 1 баним сразу
+ -- 1 значение - нет заголовков accept и accept-encoding
+ -- 2 значение - в user-agent есть curl|wget|python|perl|libwww|lwp|pycurl|scan|grab|fetch|stagefright|cloudmapping
+ -- 3 значение - наличие дублированных критических заголовков
+ detector = {10,5,1},
+ -- коэффициенты опасности для анализатора
+ -- чем меньше цифра, тем жестче наказание
+ analyzer = {10,1},
+ -- Код выхода при неудачной проверке
+ code = 444,
+ -- Валидные IP, которых пропускаем сразу
+ ips = {},
+ -- Лимит на максимальное количество Multipart-частей в одном запросе
+ max_parts = 200,
+ -- Максимально разрешенная глубина вложенности JSON
+ max_json_depth,
+ -- Лимиты для Rate Limiting на POST-запросы
+ post_limit_rps = 5, -- Разрешено запросов в секунду с одного IP
+ post_burst = 10, -- Максимальный кратковременный всплеск (накопитель)
+ post_max_burst = 100, -- Критический всплеск
+ limits = {5,1}, -- Уровни опасности для частоты POST запросов
+
+}
+
+return _M
diff --git a/waf/detector.lua b/waf/detector.lua
new file mode 100644
index 0000000..b6814fd
--- /dev/null
+++ b/waf/detector.lua
@@ -0,0 +1,65 @@
+local config = require('waf.config')
+
+local level1 = config.detector[1] or 3
+local level2 = config.detector[2] or 2
+local level3 = config.detector[3] or 1
+
+local _M = {}
+
+local regex_bad_agents = ngx.re.compile([[(?:curl|wget|python|perl|libwww|lwp|pycurl|scan|grab|fetch|stagefright|cloudmapping)]], 'jo')
+local regex_browser_ua = ngx.re.compile([[mozilla]], 'jo')
+-- Валидатор PHPSESSID: кука должна состоять строго из латинских букв (a-z, A-Z), цифр (0-9), дефиса и запятой
+local regex_valid_session = ngx.re.compile([[^[a-zA-Z0-9,-]+$]], 'jo')
+
+--[[
+ Эвристический анализ HTTP-заголовков запроса
+ Принимает: headers (таблица OpenResty), meta (таблица метаданных - method и uri)
+ Возвращает: boolean (true — запрос легитимный, false — обнаружена аномалия/бот)
+--]]
+function _M.check(headers, meta)
+ if not headers then return true end
+ local user_agent = headers['user-agent']
+ if not user_agent or user_agent == '' then return false end
+ local match_bad, err = ngx.re.find(user_agent, regex_bad_agents)
+ if match_bad then return false,level2 end
+ local is_browser = ngx.re.find(user_agent, regex_browser_ua)
+ if is_browser then
+ -- У десктопного браузера обязаны быть заголовки accept и accept-encoding
+ -- Консольные хакерские скрипты, подменяющие только UA, часто забывают их передать
+ local accept = headers['accept']
+ local accept_encoding = headers['accept-encoding']
+ if not accept or accept == '' or accept == '*/*' then return false,level1 end
+ if not accept_encoding or accept_encoding == '' then return false,level1 end
+ end
+ -- Защита от HTTP Request Smuggling (дублирование критических заголовков)
+ -- Если заголовок дублируется, OpenResty возвращает его как массив (таблицу) вместо строки
+ local content_length = headers['content-length']
+ if type(content_length) == 'table' then return false,level3 end
+ local host = headers['host']
+ if type(host) == 'table' then return false,level3 end
+ -- =================================================================
+ -- НОВЫЙ РУБЕЖ: Валидация сессионного вектора атак (Session Fixation)
+ -- =================================================================
+ local cookie_header = headers['cookie']
+ if cookie_header and cookie_header ~= '' then
+ -- Точечно извлекаем значение PHPSESSID без парсинга всей строки кук в таблицу
+ local regex_phpsessid = ngx.re.compile([[PHPSESSID\s*=\s*([^;]+)]], 'j')
+ local res_cookie = ngx.re.match(cookie_header, regex_phpsessid)
+ if res_cookie and res_cookie[1] then
+ local session_id = res_cookie[1]
+ local session_len = #session_id
+ -- 1. Контроль длины (Защита от переполнения буфера в Си-структурах PHP)
+ if session_len > 64 or session_len < 16 then
+ return false, level2 -- Средняя угроза, короткий бан
+ end
+ -- 2. Строгий алфавитно-цифровой фильтр (Защита от бинарных инъекций)
+ local is_valid_session = ngx.re.find(session_id, regex_valid_session)
+ if not is_valid_session then
+ return false, level3 -- Критическая угроза
+ end
+ end
+ end
+ return true
+end
+
+return _M
diff --git a/waf/filter.lua b/waf/filter.lua
new file mode 100644
index 0000000..be46dcc
--- /dev/null
+++ b/waf/filter.lua
@@ -0,0 +1,81 @@
+local config = require('waf.config')
+
+local MAX_PARTS = config.max_parts or 200
+local MAX_JSON_DEPTH = config.max_json_depth or 20
+-- Размер буфера для чтения сокета
+local CHUNK_SIZE = 8192
+
+local _M = {}
+
+local regex_boundary = ngx.re.compile([[boundary\s*=\s*(["']?)([^\s";]+)\1]], 'jo')
+
+--[[
+ Потоковый (Stream) анализ Multipart-тела POST-запросов
+ Возвращает: boolean (true — чисто, false — атака), number (коэффициент из конфига)
+--]]
+function _M.check()
+ if ngx.var.request_method ~= 'POST' then return true end
+ local content_type = ngx.var.content_type if not content_type or content_type == '' then return true end
+ local match, err = ngx.re.match(content_type, regex_boundary)
+ if not match or not match[2] then return true end
+ -- Извлекаем чистую строку границы.
+ local boundary = '--' .. match[2]
+ local regex_search_bound = ngx.re.compile(boundary, 'j')
+ -- Проверяем тип контента (Multipart или JSON)
+ local is_json = false
+ local regex_search_bound
+ local chunk_callback
+ -- Проверяем на multipart
+ local match, err = ngx.re.match(content_type, regex_boundary)
+ if match and match[2] then
+ local boundary = '--' .. match[2]
+ regex_search_bound = ngx.re.compile(boundary, 'j')
+ -- Если не multipart, проверяем, не JSON ли это
+ elseif string.find(content_type, 'application/json', 1, true) then
+ is_json = true
+ else
+ return true -- Другие типы контента (например, url-encoded) пропускаем
+ end
+ local sock, err_sock = ngx.req.socket(true)
+ if not sock then return true end
+ local part_count = 0 -- Используется для multipart
+ local json_depth = 0 -- Используется для JSON
+ while true do
+ local chunk, err_read = sock:receive(CHUNK_SIZE)
+ if not chunk and err_read == 'closed' then break end
+ if chunk and chunk ~= '' then
+ if is_json then
+ for i = 1, #chunk do
+ local byte = string.byte(chunk, i)
+ -- Байты 123 ('{') и 91 ('[') увеличивают глубину вложенности
+ if byte == 123 or byte == 91 then
+ json_depth = json_depth + 1
+ -- Критерий блокировки: стек вложенности превысил лимит безопасности
+ if json_depth > MAX_JSON_DEPTH then
+ return false
+ end
+ -- Байты 125 ('}') и 93 (']') уменьшают глубину вложенности
+ elseif byte == 125 or byte == 93 then
+ json_depth = json_depth - 1
+ end
+ end
+ else
+ -- Ищем границы в текущем куске данных
+ local from = 1
+ while true do
+ local match_from, to = ngx.re.find(chunk, regex_search_bound, 'j', { from = from })
+ if not match_from then break end
+ part_count = part_count + 1
+ -- Если количество частей превысило лимит — это явная OOM-атака хакеров
+ if part_count > MAX_PARTS then return false end
+ from = to + 1
+ end
+ end
+ end
+ if err_read and err_read ~= 'closed' then break end
+ end
+ return true
+end
+
+return _M
+
diff --git a/waf/limits.lua b/waf/limits.lua
new file mode 100644
index 0000000..73dcdcc
--- /dev/null
+++ b/waf/limits.lua
@@ -0,0 +1,37 @@
+local config = require('waf.config')
+
+local post_burst = config.post_burst or 10
+local post_max_burst = config.post_max_burst or 100
+local post_limit_rps = config.post_limit_rps or 5
+local level1 = config.limits[1] or 5
+local level2 = config.limits[2] or 1
+
+local _M = {}
+
+function _M.check(meta,client_ip)
+ if not meta or meta.method ~= 'POST' then return true end
+ local limits = ngx.shared.limits
+ local now = ngx.now()
+ local rate_key = 'post:' .. client_ip
+ local rate_data = limits:get(rate_key)
+ local count = 0
+ local last_update = now
+ if rate_data then
+ local sep = string.find(rate_data, ':', 1, true)
+ if sep then
+ count = tonumber(string.sub(rate_data, 1, sep - 1)) or 0
+ last_update = tonumber(string.sub(rate_data, sep + 1)) or now
+ end
+ end
+ local elapsed = now - last_update
+ count = count - (elapsed * post_limit_rps)
+ if count < 0 then count = 0 end
+ count = count + 1
+ local new_rate_data = string.format('%.2f:%.4f', count, now)
+ limits:set(rate_key, new_rate_data, 60)
+ if count > post_max_burst then return false, level2 end
+ if count > post_burst then return false, level1 end
+ return true
+end
+
+return _M