PwnHunting (HTB pwn)
the flag is moved to a random page and the disk copy wiped, so the shellcode has to hunt it down in memory under seccompA 32-bit PIE binary relocates its own flag string to a randomly chosen page, zeroes the .data copy, installs a seccomp filter that kills open/openat/creat/execve/fork, then mmaps a RWX page and runs 60 bytes of my shellcode. open is dead and the address is random, so the solve is an egghunter that walks memory for the HTB{ egg and writes it to stdout.
the challenge
PwnHunting hands over a single file, hunting, and a remote instance. My old notes said to load it in Ghidra, find the HTB{...} string sitting at a fixed address, and “redirect execution to a win function.” That premise is wrong. There is no overflow, no win function, and no leak. The flag is referenced from main, which always runs, and main deliberately erases the disk copy and hides the live copy at a random address before it ever hands control to me. The name is the hint. The flag is already in memory, somewhere, and the job is to hunt it down.
recon
hunting: ELF 32-bit LSB pie executable, Intel i386, version 1 (SYSV),
dynamically linked, interpreter /lib/ld-linux.so.2,
BuildID[sha1]=801f10407444c1390cae5755d9e952f3feadf3eb,
for GNU/Linux 3.2.0, stripped
32-bit, PIE, and stripped. The mitigations:
RELRO: Full RELRO
Stack: No canary found
PIE: PIE enabled
pwntools also printed NX unknown - GNU_STACK missing, but that is a parsing quirk. readelf -l shows the stack segment is present and marked executable:
Type Offset VirtAddr PhysAddr FileSiz MemSiz Flg Align
GNU_STACK 0x000000 0x00000000 0x00000000 0x00000 0x00000 RWE 0x10
The executable stack does not matter here, because main builds its own RWX page to run code out of. The strings give the shape of the challenge up front:
prctl(PR_SET_NO_NEW_PRIVS)
prctl(PR_SET_SECCOMP)
/dev/urandom
HTB{XXXXXXXXXXXXXXXXXXXXXXXXXXXXXXX}
So the binary seeds itself from /dev/urandom, installs a seccomp filter, and ships the flag inline. The distributed binary carries a placeholder where the flag goes: 36 bytes reading HTB{ then 31 X and a closing brace. The hexdump puts it in .data at virtual address 0x4020:
00003020: 4854 427b 5858 5858 5858 5858 5858 5858 HTB{XXXXXXXXXXXX
00003030: 5858 5858 5858 5858 5858 5858 5858 5858 XXXXXXXXXXXXXXXX
00003040: 5858 587d 0000 0000 0000 0000 0000 0000 XXX}............
what main does
The PIE entry point passes a main pointer loaded from [ebx+0x50]. That slot holds a relative relocation, and its addend settles the address:
$ xxd -s 0x2ff8 -l 4 hunting
00002ff8: 6014 0000 `...
main is at 0x1460. Reading it top to bottom, it does six things in order. First it builds a random page address, through the helper at 0x13d0:
13e9: push 0x0
13eb: lea eax,[ebx-0x1f6f] ; "/dev/urandom"
13f2: call 1180 <open@plt>
...
1409: call 1130 <read@plt> ; read(fd, &seed, 8)
1429: call 1190 <srand@plt> ; srand(seed)
...
143a: call 11e0 <rand@plt>
143f: shl eax,0x10 ; rand() << 16, so 64K-aligned
1445: cmp DWORD PTR [ebp-0xc],0x5fffffff
144c: jle 143a ; signed: regenerate if <= 0x5fffffff
1451: cmp eax,0xf7000000
1456: ja 143a ; regenerate if > 0xf7000000
The value is rand() shifted left 16, so the low 16 bits are zero and the result is 64K-aligned. The jle at 0x144c is a signed compare, so any candidate with the top bit set reads as negative, falls under 0x5fffffff, and gets thrown back. That caps the accepted window at the 2GB line. The address that survives both checks lands in 0x60000000 to 0x7fff0000, 64K-aligned.
Back in main, that random address becomes a fixed mapping, and the flag is copied into it:
14b0: push 0x0 ; offset
14b2: push 0xffffffff ; fd = -1
14b4: push 0x31 ; MAP_SHARED|MAP_FIXED|MAP_ANONYMOUS
14b6: push 0x3 ; PROT_READ|PROT_WRITE
14b8: push 0x1000
14bd: push eax ; addr = the random address
14be: call 11a0 <mmap@plt>
...
14dc: lea eax,[ebx+0x78] ; 0x4020 = the flag in .data
14e3: push DWORD PTR [ebp-0x10] ; dest = the random RW page
14e6: call 1170 <strcpy@plt> ; strcpy(page, flag)
14f1: push 0x25 ; 37 bytes
14f3: push 0x0
14f5: lea eax,[ebx+0x78] ; 0x4020 again
14fc: call 11c0 <memset@plt> ; memset(flag_in_.data, 0, 37)
This is the whole trick. The flag is strcpy‘d from 0x4020 into the random page, then the original .data copy is zeroed with memset(..., 0, 0x25). After main runs, the only copy of the flag in the process is sitting at an address I was never told. Then the sandbox goes up, via the helper at 0x133d:
136d: push 0x26 ; PR_SET_NO_NEW_PRIVS
136f: call 11d0 <prctl@plt>
...
139e: push 0x2 ; SECCOMP_MODE_FILTER
13a0: push 0x16 ; PR_SET_SECCOMP
13a2: call 11d0 <prctl@plt>
Finally main maps a RWX page, reads 60 bytes of my input into it, and jumps to it:
1517: push 0x21 ; MAP_SHARED|MAP_ANONYMOUS
1519: push 0x7 ; PROT_READ|PROT_WRITE|PROT_EXEC
151b: push 0x1000
1522: call 11a0 <mmap@plt> ; mmap(0, 0x1000, RWX, ...)
...
1530: push 0x3c ; 60
1532: push DWORD PTR [ebp-0x14] ; the RWX page
1535: push 0x0 ; fd = stdin
1537: call 1130 <read@plt> ; read(0, page, 60)
...
1547: mov eax,DWORD PTR [ebp-0x14]
154a: call eax ; run the shellcode
There is also a signal(SIGALRM, exit) and alarm(10) near the top of main. The relocation table confirms the handler slot at 0x3ff4 resolves to exit, so the process self-terminates after ten seconds. It is an anti-hang timeout, nothing more.
the seccomp filter
The filter is a 14-entry BPF program in .data at 0x4060. It loads the syscall number and kills a blacklist, letting everything else through. Decoding the sock_filter entries (JEQ nr ? KILL) against the i386 syscall table:
code k syscall (i386)
JGE 40000000 x32 / high range -> KILL
JEQ 0b (11) execve -> KILL
JEQ 166(358) execveat -> KILL
JEQ 127(295) openat -> KILL
JEQ 05 (5) open -> KILL
JEQ 06 (6) close -> KILL
JEQ 08 (8) creat -> KILL
JEQ 56 (86) uselib -> KILL
JEQ 02 (2) fork -> KILL
JEQ be (190) vfork -> KILL
(fall through) -> ALLOW
It is a “no new files, no new processes” sandbox. Every way to open a file descriptor is gone (open, openat, creat), so I cannot re-read the flag off disk, and the .data copy is already zeroed anyway. Every way to spawn a process is gone (execve, execveat, fork, vfork), so there is no shell. What is left is enough: read, write, mmap, and exit all fall through to ALLOW. So does access (syscall 33), which matters for the scan.
the solve
The flag is already mapped and readable. I just do not know where. So the shellcode is an egghunter: walk memory looking for the four-byte egg 48 54 42 7b (HTB{, the little-endian dword 0x7b425448), and when it hits, write the 37 bytes starting there to stdout.
The constants all come from the binary:
- the egg is
HTB{, bytes48 54 42 7b, from the.datahexdump at0x4020. - the flag is 37 bytes (36 plus the terminator), from
push 0x25before thememsetat0x14f1. - the shellcode budget is 60 bytes, from
push 0x3cbefore thereadat0x1530. writeis allowed andopen/openat/creatare killed, from the filter above.
The one wrinkle is that a blind linear scan across 0x60000000 upward will dereference unmapped pages and eat a SIGSEGV long before it reaches the flag. The usual fix is a syscall that returns EFAULT on a bad pointer instead of faulting, used to validate each page before touching it. access survives the filter, so probing each candidate page with access(addr, ...) and checking for EFAULT gives a safe walk. Once a page validates, scan it for the egg, then write(1, hit, 37).
the flag
Against the distributed binary there is nothing real to recover. The strcpy relocates the placeholder, so an egghunter run locally finds and prints HTB{ followed by the 31 X the file ships with, which the hexdump above already shows. On the live instance the placeholder is swapped for the real flag before the binary is built, and the same egghunter writes those 37 bytes back over the connection. The flag itself is not in anything I was given, so I am not going to invent one. The interesting part of this challenge was never the string. It was that main quietly moves the flag to a random page and wipes the original, turning a “read the flag” problem into a memory hunt under a syscall sandbox.