OverTheWire - Utumno
utumno
utumno.labs.overthewire.org 2227
Utumno: 10 levels (0-9). Harder than Behemoth — creative exploitation: LD_PRELOAD hooking, argv/envp manipulation, integer overflows, negative index writes, setjmp/longjmp interference with pointer mangling.
Each user gets /tmp/utumno<N>/ for temp files.
1 | SSH Information |
level 0 → level 1
Initial password: utumno0
Level 0: The binary is exec-only (no read permission). Calls
puts() with the password string embedded in
.rodata. Use LD_PRELOAD to hook
puts() and dump the binary's data section from within the
process:
1 | utumno0@utumno:/tmp/tmp.1hNZplWoeJ$ gcc -Wall -shared -fPIC -ldl -m32 -o hook32.so ld-preload-hooks.c -DENABLE_ALL |
1 | utumno0@utumno:/tmp/tmp.1hNZplWoeJ$ vim hook-memdump.c |
you can get the hook script from my scripts repo
ctf-tool
or use this script
1 | // gcc -m32 -fPIC -shared preload.c -o preload.so |
1 | $ gcc -m32 -fPIC -shared preload.c -o preload.so |
The password sits at 0x0804a010 in the binary's data
section as a literal string.
level 1 → level 2
Level 1: Binary reads filenames from a directory and executes the
part after sh_ as raw machine code.
Binary logic:
- Check argv[1] != NULL, else exit(1)
- mmap(NULL, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) → RWX region
- opendir(argv[1]), loop readdir()
- For each entry: strncmp(dname, "sh", 3) == 0 → match
- run(d_name + 3) → chdir(argv[1]), copy shellcode to RWX region, jmp there
So the shellcode IS the filename (after "sh_"). Constraints:
- No null bytes (strcpy stops at \0)
- No
/(0x2f) — path separator, kernel rejects it in the filename - Max 252 bytes (Linux filename limit 255 − 3 for "sh_")
1 | r2 -A -q -c "pdf @ sym.main" /utumno/utumno1 |
1 | # Build the shellcode, verify it has no nulls or / |
The shellcode does setreuid(16002,16002) (utumno2 UID =
0x3e82) then execve("code", ["code"], NULL) where "code" is
a symlink to /bin/sh. The binary's run() calls
chdir(argv[1]) before executing the shellcode, so the
relative path "code" resolves correctly under /tmp/x/.
level 2 → level 3
Level 2: Binary checks argc == 0 then does
strcpy(local_buf, envp[9]). Need argc == 0 via
execve(path, NULL, envp). The 10th envp entry
(envp[9]) overflows the 12-byte buffer, overwriting saved
EBP and return address. Put NOP sled + shellcode in an earlier envp
entry and point the return address there.
Write a Python script using pwntools + ctypes to call
execve() with crafted envp:
1 | #!/usr/bin/env python3 |
envp layout:
- indices 0-7: empty strings (filler)
- index 8:
NOP×52 + shellcode(return address points here) - index 9:
"AAAA×4" + p32(ret_addr)— overflow data, strcpy'd into buffer
Find the NOP sled address in GDB, update ret_addr, then
run:
1 | $ python3 exploit.py |
level 3 → level 4
Level 3: Byte-by-byte return address overwrite. Binary reads pairs of
bytes (position, value) via getchar() in a loop. The
position byte is XOR'd with (iteration * 3) before being
used as an offset from [ebp - 0x24] (or
[ebp - 0x20] on the current binary version — check the
offset with GDB). Need to compute position bytes that target EIP after
the XOR transform.
The loop runs up to 24 iterations. We send 4 pairs for the 4 bytes of the return address, then fill remaining slots with harmless writes.
Shellcode goes in EGG environment variable with a NOP
sled. Find the sled address with GDB.
1 | #!/usr/bin/env python3 |
1 | $ python3 exploit.py |
level 4 → level 5
Level 4: Integer overflow in memcpy(). Arg1 is converted
with atoi(), checked as 16-bit ≤ 63, but the actual
memcpy size uses the full 32-bit value. Pass
65536 as arg1 → 16-bit truncation yields 0 (≤ 63), but
memcpy copies 65536 bytes.
Offset to EIP: 65286 bytes. Put NOP sled + shellcode in the buffer itself (second argument), with return address pointing into the NOP sled.
1 | #!/usr/bin/env python3 |
1 | $ python3 exploit.py |
level 5 → level 6
Level 5: Requires argc == 0 (or argc == 1
with argv[0][0] == 0). Accesses argv[10] which
equals envp[9] (since argv[0]=NULL for argc=0). The
hihi() function does strlen(envp[9]); if >
19 chars, uses strncpy(buf, envp[9], 20) overwriting
12-byte buffer + saved EBP + return address. Shellcode goes in
envp[8].
Critical: On this server,
execve(path, NULL, envp) sets argc=1 with
argv[0]="". So argv[10] = envp[8], not
envp[9]. Need to swap: envp[8] = overflow
data, envp[9] = shellcode with NOP sled.
1 | #!/usr/bin/env python3 |
1 | $ python3 exploit.py |
level 6 → level 7
Level 6: Table-based key-value store with 3 args: position (base10),
value (base16), description (string). A write at
[ebp + pos*4 - 0x30] with position = -1 overwrites the
malloc pointer at [ebp - 0x34]. Then
strcpy(corrupted_malloc_ptr, argv[3]) copies description to
the overwritten address — a controlled write to anywhere.
Attack: Position -1 overwrites the
malloc pointer at [ebp - 0x34] with the return
address as an integer. Then strcpy(corrupted_ptr, argv[3])
copies the packed shellcode address to the return slot — hijacking
EIP.
Write primitive chain:
[ebp - 0x34] = ret_addrvia the table write (pos=-1, value=0xffffda9c)strcpy(0xffffda9c, argv[3])— writes 4 bytes of packed NOP address to the return slot- Function returns → EIP = NOP sled in EGG → shellcode
1 | #!/usr/bin/env python3 |
Address finding: Use GDB on the server to get EBP and NOP location. GDB vs non-GDB stack shift is ~0x60 on gibson-1 (from extra env vars GDB adds). Run the exploit directly (not in GDB) with addresses found via GDB + known offset.
VHOuCx7iA5level 7 → utumno8
Level 7: Stack BOF with setjmp/longjmp.
Binary allocates a 288-byte buffer at [ebp-0x120], a
jmp_buf at [ebp-0xa0] (128 bytes into buffer),
calls _setjmp, strcpy from argv[1], then
longjmp.
glibc 2.39 PTR_MANGLE: longjmp uses
pointer mangling on ESP and EIP (XOR with thread-local secret + rotate
left 9). Only EBP is stored/restored raw. Direct EIP overwrite via
jmp_buf doesn't work.
Strategy: Overwrite jmp_buf[3] (EBP,
NOT mangled) at buffer offset 140 with the buffer address. After longjmp
restores EBP = buffer_addr, the leave; ret sequence at
vuln+84 pivots there.
Payload (144 bytes, null at byte 144 preserves mangled ESP/EIP):
1 | buf[0-3]: junk (popped into EBP by leave) |
Two critical details:
- Null-free shellcode: The shellcode must NOT contain
null bytes (strcpy stops at the first null). Use
mov bl, val; mov bh, valinstead ofmov ebx, val32which embeds nulls in the high bytes. - Avoid /bin/sh: Dash drops EUID to RUID on startup
(privilege sanitization). Use
execve("/bin/cat", ["/bin/cat", "/etc/utumno_pass/utumno8", NULL], NULL)instead — no shell, no privilege drop. - Buffer address finding: Since
randomize_va_space=0but GDB subtly shifts the stack, use a test shellcode (exit(42)) to brute-force the address. On gibson-1 with full SSH environment, buffer =0xffffda2c.
Exploit script (exploit.py):
1 | #!/usr/bin/env python3 |
1 | $ python3 exploit.py ffffda2c |