diff options
| -rw-r--r-- | README.md | 86 | ||||
| -rw-r--r-- | example.lua | 79 | ||||
| -rw-r--r-- | nginx.conf | 28 | ||||
| -rw-r--r-- | waf/analyzer.lua | 35 | ||||
| -rw-r--r-- | waf/ban.lua | 55 | ||||
| -rw-r--r-- | waf/config.lua | 30 | ||||
| -rw-r--r-- | waf/detector.lua | 65 | ||||
| -rw-r--r-- | waf/filter.lua | 81 | ||||
| -rw-r--r-- | waf/limits.lua | 37 |
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 |
