格式化字符串漏洞利用

注意
本文最后更新于 2023-12-07,文中内容可能已过时。

一、总览

Printf 格式化参数用法

这部分内容理应当属于学习 C 语言过程中应该掌握的,但是实际上在漏洞利用过程中会用到一些平常编写程序不常见的内容,所以这里为后文铺垫一下。

格式化字符串以一个 % 开始,以类型字段(d、x、s 等等)结束,完整格式如下(除了 % 和类型之外均为可选字段),接下来会对这些内容分开解析

1
%[参数][标志][宽度][.精度][长度]类型

参数

参数字段为 POSIX 的扩展功能,并非是 C99 标准定义。使用 n$ 指定操作目标位格式化字符串后的第 n 个参数(从 1 开始编号)。

特别的,在 __printf_chk 函数中如果使用参数字段,则你指定的变量和在这之前的变量都至少使用一次,否则会导致程序报错退出。

在 Windows 系统中,对这个特性的支持放在 printf_p 函数中。

标志

CharacterDescription
-左对齐(默认是右对齐)
+给正数附加符号前缀(默认不添加)
空格给整数附加空格前缀(默认不添加)
0指定了宽度字段且为右对齐时,前缀补 0(默认补空格,当指定左对齐时,此标志无效)
#对于 g 和 G 类型,不省略小数点部分最后的 0
对于 f、F、e、E、g、G 类型,总是输出小数点
对于 o、x、X 类型,分别在非零数值钱附加 0、0x、0X 前缀

宽度

宽度字段指定输出字符的最小长度,长度不足的用填充字符补齐,超长的输出不受影响。

当指定宽度字段时,可使用确定的整数值静态指定,如 %5d,也可以使用 * 号来由某个参数动态指定。例如,printf("%0*d", 5, 10) 将输出 00010。

精度

精度指定输出内容的最大长度,对于浮点型来说指定了小数点后的最长有效位数,对于字符串来说,精度字段指定了输出的最大字符串数。

与宽度字段相同,指定精度字段时,也可使用确定的整数值静态确定或 * 号动态确定,为了与宽度字段做区分,精度字段前必须加句点“.”。

长度

CharacterDescription
hh将 char 类型参数转换为 int 类型输出
h将 short 类型参数转换为 int 类型输出
l输出 long 类型参数,对浮点数无效
ll输出 long long 类型参数
L输出 long double 类型参数
z输出 size_t 类型参数
j输出 intmax_t 类型参数
t输出 ptrdiff_t 型参数

类型

CharacterDescription
%原样输出一个 % 符号。此类型不接收任何其它字段,即只能使用 %%
d, i输出十进制 signed int 型数据。二者仅在使用 scanf 输入时有区别(使用 %i 将 0x 开头的数解析为十六进制,将 0 开头的数解析为八进制)
u输出十进制 unsigned int 型数据
f, F以定点数表示法输出 double 型数据。二者区别在于无限小数和 NaN 输出时是全小写的 inf、infinity、nan 还是全大写的 INF、INFINITY、NAN
e, E以指数表示法输出 double 型数据。二者区别在于字符 e 的大小写
g, G根据指数自动选择定点数表示法或指数表示法。二者区别同样在于输出字符的大小写。以定点数表示法输出时与 f 和 F 的区别在于,可能省略小数部分最后的 0 或小数点(数据为整数时)
x, X输出十六进制 unsigned int 型数据。二者区别在于十六进制数的字符大小写
o输出八进制 unsigned int 型数据
s输出以 \0 结束的字符串
c输出一个 char 字符
p输出 void *,输出格式依赖于具体实现,Linux 中是以前导 0x 开头的十六进制数据
a, A输出十六进制 double 型数据,前缀 0x 或 0X
n不打印任何内容,把此 printf 到目前为止写入的字符数写入整数指针参数。
利用这个类型,我们可以利用 printf 写入内容到内存

自定义格式占位符

glibc 的 register_printf_function(),允许程序员为非内置的类型添加自定义的类型的格式化函数(转换说明符字符 spec)。在 House of husk 中就利用到了这个特性。

调用约定

printf 函数的调用过程与 x86 还是 x64 有关,大致遵守函数调用过程。

x86 版本的全部参数通过 push 进入栈空间来获得

x64 版本的参数,前 6 个参数通过寄存器来传递,其他也通过 push 进入栈空间获得。

但是实际上在程序内部,都把寄存器上的数据放回到栈空间中,由此可见 printf 读取的参数都是在栈空间的。

image.png

其中上半部分是在传递整数型变量的时候使用,下半部分是在传入浮点型的遍历使用,其中浮点型的数量在调用 printf 前会通过 eax 传递,所以在 printf 开始的时候,会检测 al 是否为 0,不为 0 就会把 xmm 寄存器的内容压入栈中。

格式化字符串漏洞

因为有些程序员偷懒,使用 printf 直接输出字符串内容,例如使用 printf(buf),而如果 buf 的内容是我们可控的,那么我们就可以利用 printf 的一些特性来构造 buf 的内容,最后达到控制程序流程的目的。

以下的内容就是讲述如何使用格式化字符串漏洞来控制程序流程。

调试技巧

在调试有格式化字符漏洞的题目中,我们可以对 printf 下断点,然后在程序调用到 printf 函数的时候,我们可以使用 stack 指令来显示当前堆栈上的内容。image.png

找到堆栈上我们想要泄露的位置和想要修改的指针,复制前面的堆栈地址,使用命令 fmtarg addr 即可显示出对应的参数个数(但是有些时候结果有误,需要向前或者向后调整)image.png

利用调试的方法快速得到参数的个数,使我们做题的速度大幅增加。(在定位栈上的格式化字符位置的时候,使用这个功能直接得到 index,结果前面显示的数字 10 就可以直接作为 pwntools 中 fmtstr_payload 函数的第一个参数 offset,后面的内容可以直接用来 leak 信息,适合手动分析的时候使用)

二、泄露

栈上有很多重要的数据,例如程序的 PIE 偏移(当前调用的返回地址),LIBC 偏移(从 start 中调用的__libc_start_main 再调用到的 main 函数),栈偏移,这些数据对于我们 bypass 随机化有很大的帮助。

这一部分的内容通常与调试结合,因为栈上的内容会受到当前函数执行前后的内容和 libc 版本等很多情况的影响,这时候结合调试就可以大致确定栈上的内容。

整数型

由于我们需要泄露的数据通常都是指针,所以一般都是使用 %p 来泄露,但是由于一般情况下格式化字符串的长度有限,所以如果要泄露的数据距离当前栈的位置很远,使用多个 %p 来泄露就显得不那么现实,通常就会考虑使用 n$ 参数来直接定位到某个参数(栈上的位置)的内容并输出。

(如果在单次 printf 中,需要泄露栈上的一个内容,并且同时把他写入到一个栈上的指针中,可以考虑借助宽度字段中的 * ,他从栈上读取一个内容,例如 %*c 会从栈上读取内容并且输出参数长度的字符串,这时候结合 %n 进行写入即可,例题:2020 ciscn 华南分区赛 : same)

浮点型

浮点型泄露的用处主要在于,泄露在 printf 函数内部储存浮点型参数在栈上的位置不同,而对于限制比较严格的题目(限制格式化长度,使用 __printf_chk 等等)又无法使用 %p 进行泄露的情况。

这时候就可以考虑使用 %a 进行泄露地址,由于泄露得到的地址是一个十六进制小数,所以在泄露后还需要进行整理(例如 libc 地址需要在某位增加两个 0,因为小数部分最后的 0 会被省略)。

字符型

我们通常会使用 %s 来打印一个字符串,而这里利用的就是 %s 来输出栈上某个指针所指向的内容,输出的内容直到 \x00 截止。

通常这个可以用在盲打的题目上来泄露出程序的代码。

有些时候即使程序 printf 使用规范,也可以利用这个功能来泄露信息,例如在 glibc2.25 以及以前的版本中,开启站保护的程序检测到栈溢出时,调用__stack_chk_failed 函数处理异常信息,此时会打印出程序名,而从 glibc2.26 开始,不再打印出程序名,而是打印出 unknown 来替代。

所以在 glibc2.25 之前的版本,我们就可以用到 printf 中的 %s 来泄露信息。其中调用的指针位置是可以被溢出覆盖的,如果我们把这个指针覆盖为我们想要泄露的地址(例如 flag),就可以得到关键信息。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
void __attribute__ ((noreturn)) __stack_chk_fail (void)
{
  __fortify_fail ("stack smashing detected");
}
void __attribute__ ((noreturn)) internal_function __fortify_fail (const char *msg)
{
  /* The loop is added only to keep gcc happy.  */
  while (1)
    __libc_message (2, "*** %s ***: %s terminated\n",
                    msg, __libc_argv[0] ?: "<unknown>");
}

在 glibc2.26 以及之前的版本中,malloc_printerr 会根据 check_action 的值执行不同的分支,在 action & 1 为 true 时,就会使用 printf 打印出程序名称,我们可以用类似上面的方法,修改程序名称的指针为关键信息,用来泄露。从 glibc2.27 开始,这个函数溢出了 leak 程序名的代码。(例题:第二届祥云杯 pwn_lenmon)

三、写入

写入一般考虑使用 n 这个类型配合上长度说明符来写入。

内容写入字节数
n4
hn2
hhn1

由于 n 写入的本质是写入当前已经输出的字符个数,所以当想要写入一些过大的数据(例如想要一次性写入一个四字节数据),就需要考虑攻击远程的时候由于需要输出的字符数过多,内容无法完全输出,导致代码卡在 IO 部分。

所以一般我们仅仅使用 hn 或 hhn 每次来写入两字节或一字节的数据,然后分成多个 n 来写入(可以在一次 printf 内)

在栈上的格式化字符串攻击

在绝大部分情况时,格式化字符串攻击的第一个参数都是位于栈上的一个位置。这个时候我们可以考虑先泄露出一些地址(例如栈上的指针,和 libc 的指针),所以格式化字符串在栈上的好处就是,我们可以针对我们已知的地址进行任意读写,而任意读写常常是 getshell 所需的条件。

注意:在布局格式化字符串内容的时候,因为 printf 函数的第一个参数是一个字符串,读取到 \x00 的时候就会截断,所以一定要优先考虑把要写入的指针内容放在整个参数的最后面,防止指针中存在的 \x00 字节把参数截断。如果在布局的时候指针地址没有按照 8 字节对齐,那么就需要填充一些字符使其对齐,否则无法利用。

利用思想

利用栈上的指针,我们可以计算出当前 printf 函数的返回地址,并且在栈上布局相应的指针,对返回地址进行修改。

利用 libc 的指针,我们可以计算出__malloc_hook 和__free_hook 的地址,通过把 one_gadget 等数据写入这两个 hook,再利用 %1000000c 类似的代码创建非常大的输出内容,在 printf 函数的内部就会因此而创建堆块调用 malloc,并且在使用完毕后 free 掉堆块。

在 printf 内部还有一些调用链,可以分析源码和汇编进行挖掘,这里不进行深入的阐述。

通常对于一些简单的题目,我们可以直接使用 fmtstr_payload 来进行计算,并且可以通过参数的设定来完成一些比较复杂的题目

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
def fmtstr_payload(offset, writes, numbwritten=0, write_size='byte', write_size_max='long', overflows=16, strategy="small", badbytes=frozenset(), offset_bytes=0):
    r"""fmtstr_payload(offset, writes, numbwritten=0, write_size='byte') -> str

    Makes payload with given parameter.
    It can generate payload for 32 or 64 bits architectures.
    The size of the addr is taken from ``context.bits``

    The overflows argument is a format-string-length to output-amount tradeoff:
    Larger values for ``overflows`` produce shorter format strings that generate more output at runtime.

    Arguments:
        offset(int): the first formatter's offset you control
        writes(dict): dict with addr, value ``{addr: value, addr2: value2}``
        numbwritten(int): number of byte already written by the printf function
        write_size(str): must be ``byte``, ``short`` or ``int``. Tells if you want to write byte by byte, short by short or int by int (hhn, hn or n)
        overflows(int): how many extra overflows (at size sz) to tolerate to reduce the length of the format string
        strategy(str): either 'fast' or 'small' ('small' is default, 'fast' can be used if there are many writes)
    Returns:
        The payload in order to do needed writes

    Examples:
        >>> context.clear(arch = 'amd64')
        >>> fmtstr_payload(1, {0x0: 0x1337babe}, write_size='int')
        b'%322419390c%4$llnaaaabaa\x00\x00\x00\x00\x00\x00\x00\x00'
        >>> fmtstr_payload(1, {0x0: 0x1337babe}, write_size='short')
        b'%47806c%5$lln%22649c%6$hnaaaabaa\x00\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00'
        >>> fmtstr_payload(1, {0x0: 0x1337babe}, write_size='byte')
        b'%190c%7$lln%85c%8$hhn%36c%9$hhn%131c%10$hhnaaaab\x00\x00\x00\x00\x00\x00\x00\x00\x03\x00\x00\x00\x00\x00\x00\x00\x02\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00'
        >>> context.clear(arch = 'i386')
        >>> fmtstr_payload(1, {0x0: 0x1337babe}, write_size='int')
        b'%322419390c%5$na\x00\x00\x00\x00'
        >>> fmtstr_payload(1, {0x0: 0x1337babe}, write_size='short')
        b'%4919c%7$hn%42887c%8$hna\x02\x00\x00\x00\x00\x00\x00\x00'
        >>> fmtstr_payload(1, {0x0: 0x1337babe}, write_size='byte')
        b'%19c%12$hhn%36c%13$hhn%131c%14$hhn%4c%15$hhn\x03\x00\x00\x00\x02\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00'
        >>> fmtstr_payload(1, {0x0: 0x00000001}, write_size='byte')
        b'%1c%3$na\x00\x00\x00\x00'
        >>> fmtstr_payload(1, {0x0: b"\xff\xff\x04\x11\x00\x00\x00\x00"}, write_size='short')
        b'%327679c%7$lln%18c%8$hhn\x00\x00\x00\x00\x03\x00\x00\x00'
    """

非栈上的格式化字符串利用

这里的非栈上,可以是在 bss 段上,也可以是在 malloc 申请的堆块上。

总之由于我们无法在栈上布局我们的指针,所以就需要借助和控制栈上一些本来就存在的指针进行读写,这个过程由于就是在利用栈上的一个二重指针,所以一般叫做二重指针攻击(成链攻击)。由于对于这方面的内容比较难懂,所以这里考虑把它单独拿出来讲解。

四、成链攻击

多次 printf 利用

对于一般的成链攻击来说,主要是在栈上寻找一个二重指针,如下图位置image.png

以下为了描述方便,把以上的链内容命名为

a -> b -> c -> d(0x7ffdc9017e28 —▸ 0x7ffdc9017ef8 —▸ 0x7ffdc90190b3 ◂— ‘./hard_printf’)

我们首先要明确,n 可以修改的是一个指针指向的内容,而不是指针位置本身。

也就是在这个栈上我有 b 这个指针,我才能够修改 c 这个变量,我有 c 这个指针才能修改 d 这个变量。

而恰好我们利用这一的二重指针,通过修改 b 指针所指向位置的末尾 2 字节或者 1 字节(由于 4 字节太大不方便写入),通过这样的部分写入我们很容易修改 c 这个指针使其指向栈上的另一个地方,例如对于我们控制程序流程有益的返回地址处。

而通过这样的方法,我们只需要不断的利用 b 指针修改 c 指针的位置,就可以使其遍历栈上 0x10000 字节的空间,同时再利用其进行对目标位置修改,最终可以控制栈上的数据。在控制栈上数据后,我们可以考虑以下几种利用。

  1. 让在非栈上的利用转化为栈上的利用,这时候我们就可以直接在栈上写我们想要修改的目标地址,布置好多个指针后,我们就可以一次性的完整修改我们的返回地址,因为如果不是一次性的修改,而是分为多个 printf 修改,那么就会遇到第一次 printf 返回的位置出错,程序奔溃。所以我更推荐下面的方法。
  2. 对__libc_start_main 的返回地址进行修改,因为这个是最外层的 main 函数返回的地址,所以如果我们能够多次利用 printf,那么只需要控制程序在完整修改后再返回即可。

image.png

  1. 如果以上的思路还不能满足要求,还有一种方法就是在 printf 返回地址的下方写入返回地址,然后在最后一次 printf 修改完成后,同时把 printf 的返回地址部分覆盖到某个 retn 处,甚至还可以借助 ret2csu 所利用的 gadget 的位置,多 pop 几个堆栈数据出来,使堆栈满足 one_gadget 条件。

以上的思路只是我一些简单的想法,实际上做题遇到的情况错综复杂,需要具体情况具体分析才行。

我认为,其实这里的关键就是抓住,寻找一个开关来控制未完全修改的数据是否访问这个核心,像上面的例子中,第一个方法就是在避免未完全修改数据,实现在一次 printf 中完整修改;第二个方法就是利用控制主函数是否返回来确定是否要执行我们写入部分的数据;第三个访问就是抓住栈上一些不会被访问到的数据,修改他们,并在最后一次调试时控制 printf 的返回地址,使不会被访问的数据成为关键的返回地址数据。

不仅仅是在格式化字符串中,在很多利用中都是这个道理,如果我们只能够按部就班的根据模版来做题,那么当题目稍微改动的时候就无法做出,这正是因为缺少了这样一个思考过程,把模版的利用思想转化为自己的。所以我更喜欢在我的 wp 中不止止写要“怎么做?”,同时也要写“为什么这样做?”,最后引导读者深入到“如何想到这么做?”。我认为只有学会一个一个利用方法和模版背后的思想,转化问题的方法,才能够挖掘发现出属于自己的东西,而不是原地踏步,只能做到跟在最新的公开的研究后面走。而打 CTF 的过程,就是你学会如何去学习这些利用方法思想内涵的过程。

单次 printf 利用

如果只有一次调用 printf 的机会,那有什么办法可以控制程序流程呢?

这其实才是本文的关键,我想要修正大家一个长期以来可能错误认识的想法——成链攻击无法在单次 printf 下利用。

这是我在 Ex 师傅的博客中所看到的

image.png

我相信这也是大家对于成链攻击长久以来的认识,知道成链攻击至少需要两次 printf 才行,但是不知道是什么东西限制了,也不清楚为什么不可以。

我也长期以来对这方面的内容保持疑惑,但是由于实践操作符合这里说法,而且 printf 的代码里面有大量的宏实在是难以阅读,故一直没有搞懂具体的原因。这次就带着各位读者来试着分析一下,为什么我们长期以来的实践结果告诉我们是不可以的。

源码分析

我这里分析使用的是 glibc2.34 中的源码(由于最近在研究,所以桌面上正好有一份),首先通过调试可以得知,printf 函数的内部实现就是 vfprintf 函数中使用类型 n 写入那一块的内容,我们可以借此来缩小研究范围。

首先我们要知道,vfprintf 对于各种各样的类型,其处理方式就是借助一个跳转表(jump_table),里面放了各种偏移的值,从空格开始到字母 z 结束,不能处理则值为 0,能处理则偏移如表。

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/* This table maps a character into a number representing a class.  In
   each step there is a destination label for each class.  */
static const uint8_t jump_table[] =
  {
    /* ' ' */  1,            0,            0, /* '#' */  4,
	       0, /* '%' */ 14,            0, /* '\''*/  6,
	       0,            0, /* '*' */  7, /* '+' */  2,
	       0, /* '-' */  3, /* '.' */  9,            0,
    /* '0' */  5, /* '1' */  8, /* '2' */  8, /* '3' */  8,
    /* '4' */  8, /* '5' */  8, /* '6' */  8, /* '7' */  8,
    /* '8' */  8, /* '9' */  8,            0,            0,
	       0,            0,            0,            0,
	       0, /* 'A' */ 26,            0, /* 'C' */ 25,
	       0, /* 'E' */ 19, /* F */   19, /* 'G' */ 19,
	       0, /* 'I' */ 29,            0,            0,
    /* 'L' */ 12,            0,            0,            0,
	       0,            0,            0, /* 'S' */ 21,
	       0,            0,            0,            0,
    /* 'X' */ 18,            0, /* 'Z' */ 13,            0,
	       0,            0,            0,            0,
	       0, /* 'a' */ 26,            0, /* 'c' */ 20,
    /* 'd' */ 15, /* 'e' */ 19, /* 'f' */ 19, /* 'g' */ 19,
    /* 'h' */ 10, /* 'i' */ 15, /* 'j' */ 28,            0,
    /* 'l' */ 11, /* 'm' */ 24, /* 'n' */ 23, /* 'o' */ 17,
    /* 'p' */ 22, /* 'q' */ 12,            0, /* 's' */ 21,
    /* 't' */ 27, /* 'u' */ 16,            0,            0,
    /* 'x' */ 18,            0, /* 'z' */ 13
  };

#define NOT_IN_JUMP_RANGE(Ch) ((Ch) < L_(' ') || (Ch) > L_('z'))
#define CHAR_CLASS(Ch) (jump_table[(INT_T) (Ch) - L_(' ')])

下面的宏就是方便程序查表的

其次就可以看到下面的几个数组,分别是 step0_jumps,step1_jumps,step2_jumps,step3a_jumps,step3b_jumps,step4_jumps,其中不同的内容对应的不同的处理阶段,根据注释我们可以得知。

表名用于
step0_jumps初始化内容
step1_jumps处理完宽度后
step2_jumps处理完精度后
step3a_jumps处理完第一个 h 后
step3b_jumps处理完第一个 l 后
step4_jumps处理类型

结合这个表格和全局搜索,我们很容易就找到了我们 n 类型条件时候的目标“form_number”

image.png

我们重点关注下半部分,在 fspec 为 NULL 的时候,会使用 va_arg 读取栈上下一个参数的内容,否则会使用 args_value 中的内容进行写入。这个 fspec 实际上是这个宏(从行末的斜杠也可以看出)所传入的参数

image.png

这个宏的名字就叫做 process_arg,我们下一步的目标就是看哪里条件了他,分别什么时候会对应不同的写入情况。

很容易就能找出两处调用的位置(都在 vfprintf-internal.c 文件中,可以通过搜索关键字找到)

传入 NULL

image.png

传入 specs

image.png

我们重点分析下者,因为其传入的参数是导致我们必须分两次利用的重要原因。

这个位置不断往上翻,可以找到当前所调用的这个函数是

image.png

我们再看它在哪里被用到,就不难找到这个地方,这里的备注告诉我们,这个函数是用于切换参数的位置的。

image.png

由于这里有一个标签,所以一定是有地方使用 goto 跳转到此处

image.png

简单的看一下这段代码,含义大概就是读取 $ 前的参数内容,也就是 n$ 中的 n 的值,读取完毕后就会跳转去执行 printf_positional 函数。

大致了解是从哪里调用到后,我们再看看 printf_positional 函数到底干了些啥。

image.png

在函数开始,就会去遍历整个字符串参数,并且去调用 __parse_one_specmb 这个函数来解析内容。(printf-parsemb.c 中)

image.png

根据注释我们可以知道,这个函数是用于解析一个 % 开头的格式串的,而且如果这个格式串有参数这个选项,则会通过 max_ref_arg 传递返回。

接下来就会分析可能会使用到的空间大小,并且申请相应的空间(scratch_buffer_set_array_size 调用了 malloc 函数)

image.png

再接着就是对于所有参数(以 n$ 中最大的 n 作为 nspecs 进行遍历)

image.png

所以我们分析上述内容,得出的总结就是,当 vfprintf 处理到某个存在参数的数据时,就会调用 printf_positional 去做解析,在解析的过程中会预处理所有会用到的参数,再把参数对应的信息存入到 args_value 中

image.png在这其中可以放所有可能会遇到的类型。

在经过预处理后,再调用类型 n 进行修改,所修改的内容指针都是来源于预处理的内容 args_value 中,而不是从栈上进行读取。

所以如果我们在一次调用中,尝试着利用二重指针,先修改 b 指向的 c,再通过 c 修改 d,在修改 c 的过程前由于使用了 n$,所以就会对后续所有的参数进行预处理,也就是这时候我们后续会调用到的指针 c 的内容也被保存在了 args_value 中,而在处理 c 修改 d 的情况时,此时的 c 的确已经被修改,但是实际上使用的确实 c 的一个 copy,并且这个 c_copy 在对 c 修改之前就已经复制。所以这时候去修改,实际上修改的是 c_copy 所指向的值,给人一种修改失败的假象。

绕过预处理

现在我们知道了,是预处理导致的不能二次写入,所以我们就需要想办法来绕过预处理使得我们可以二次写入。当前我想到的办法就是,在第一次写入的时候不使用 n$ ,在第二次写入的时候再使用。这样可以让第二次写入的时候预处理的值是第一次写入后修改后的值,从而实现链上二次写入的效果。

通常来说,a 的位置都在 b 的位置之上,而且 a 的位置是比较靠近参数的,通常在第 11、12 参数的位置,我们可以利用 %c 等类型来往后找一个参数,直到我们要修改的位置时就直接使用 %hn 修改,可以修改为指针指向返回地址,第二次修改使用 n$ 直接到 b 的位置来修改 c 指针对应的内容(此时 c 指针指向第一次修改后的返回地址)。

然后在第二次修改的时候,部分写入返回地址,使其返回到 printf 函数调用前,最终可以达到二次乃至多次利用的目的。

在 printf 函数利用转化为多次后,做法就可以参照之前的来操作了,如果是非栈上的利用,则考虑再找一条其他的链来修改。

但是,这种方法的缺陷是,如果只是单次的 printf 攻击,并且没有办法得到栈地址等相关的数据,大概爆破概率需要 1/4096,因为 c 的地址和返回地址相差位置过大且栈空间没有低 12 位不会变化的说法。

参考资料

[1] printf format string https://en.wikipedia.org/wiki/Printf_format_string

[2] printf 成链攻击 http://blog.eonew.cn/archives/1196

0%