非栈上格式化字符串一次利用

collectcrop Lv3

问题发展路径

  • 一开始最基本的格式化字符串漏洞任意地址写,没有什么限制
  • 进阶一点的是非栈上的格式化字符串漏洞利用,需要利用到栈上的指向程序名称的链条以及靠近内核区域的链条,但这种方法正常而言需要多次格式化字符串漏洞的执行利用,对目标地址改动越大需要利用到越多次。
  • 最后是在读取内容非栈上的情况下,实现在一次格式化字符串漏洞的触发中直接进行对一个栈上地址的两个字节的修改,可以用于减少利用格式化字符串漏洞的次数,绕过更多的限制。

实现原理

比如我们假设程序中有后门函数,目标是把栈上的返回地址的后两个字节给覆盖了以实现返回到后门函数中去。其中要利用到的两个链条的偏移分别为15和45。按照一般的多次漏洞的利用来说,是要先通过覆盖偏移15处的0x7ffd991d220e的低两个字节0x220e0x1898,这样以后在r12指向的偏移45处就会是一个指针直接指向要返回到的地址,然后再通过写偏移45处内容的低两个字节实现对返回地址的低两个字节的写入。

这里我们很容易就想到尝试直接把两个并到一起写,就有了如下payload:

1
payload = f"%{part1}c%15$hn" + f"%{part2}c%45$hn"

但在实际调试过后会发现现实的残酷,这个格式化字符串的解析中并不是按照先来后到的顺序,先解析完前一个再解析后一个的。实际上这里改的内容都是原偏移地址处指针指向的内容,最后并不会对返回地址进行修改。

于是就有如下的比较神奇的绕过方法,就可以通过格式化字符串参数解析的特性来实现逐级的赋值。

1
2
payload = "%p"*13
payload = f"%{part1-130-0x8}c%hn" + f"%{part2}c%45$hn"

这里省略掉了%x$n这种组合,而是直接%hn,这样省略以后会根据顺序来确定指定的参数偏移,由于前面有14个%,这里的%实际就会被解析为指向偏移15处的参数,这样一来在%的解析阶段就能把指定内容写到偏移15的指针处,从而与后面的内容进行联动,实现栈上内容的低两个字节内容的修改。这里part1就是我们通过泄露出栈相关地址后,计算出的返回地址在栈上位置的后两个字节。减去130是前面13个%p打印出的内容长度,最后的减0x8是调试后测出来的差值(这个的确不知道是在哪里多的内容)。

源码分析

首先在stdio-common/printf.c中可以找到printf的具体实现。

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
#include <libioP.h>
#include <stdarg.h>
#include <stdio.h>

#undef printf

/* Write formatted output to stdout from the format string FORMAT. */
/* VARARGS1 */
int
__printf (const char *format, ...)
{
va_list arg; //声明一个 va_list 类型的变量 arg,用于存储可变参数列表。
int done; //写入的字符数或其他状态信息。

va_start (arg, format); //初始化 arg,使其指向可变参数列表的第一个参数。
done = __vfprintf_internal (stdout, format, arg, 0);
va_end (arg); //清理 arg,以释放相关资源

return done;
}

#undef _IO_printf
ldbl_strong_alias (__printf, printf);
ldbl_strong_alias (__printf, _IO_printf);

其最核心的功能要到vfprintf.c中去寻找,这里结合gdb带源码调试。

首先会进入ARGCHECK中进行一系列检测,确保格式字符串符合要求。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#define ARGCHECK(S, Format) \
do \
{ \
/* Check file argument for consistence. */ \
CHECK_FILE (S, -1); //检查文件流有效性和状态 \
if (S->_flags & _IO_NO_WRITES) //如果指定文件流不可写就返回错误 \
{ \
S->_flags |= _IO_ERR_SEEN; \
__set_errno (EBADF); \
return -1; \
} \
if (Format == NULL) //如果格式化字符串为空就返回错误 \
{ \
__set_errno (EINVAL); \
return -1; \
} \
} while (0)

然后会检查文件流 s 是否处于无缓冲模式,如果处于无缓冲模式,代码调用一个辅助函数 buffered_vfprintf。这个函数的作用是为该流分配一个局部临时缓冲区,然后重新调用原来的格式化输出函数。这样可以在处理输出时提供一个缓冲层,即使原始流不支持缓冲。这里我们调试时会进到buffered_vfprintf里面,最后实际还会调用回vfprintf

1
2
3
4
if (UNBUFFERED_P (s))
/* Use a helper function which will allocate a local temporary buffer
for the stream and then call us again. */
return buffered_vfprintf (s, format, ap, mode_flags);

然后会判断代码是否支持宽字符的处理,然后查找格式字符串中的第一个格式说明符。

1
2
3
4
5
6
7
#ifdef COMPILE_WPRINTF
/* Find the first format specifier. */
f = lead_str_end = __find_specwc ((const UCHAR_T *) format);
#else
/* Find the first format specifier. */
f = lead_str_end = __find_specmb ((const UCHAR_T *) format);
#endif

后面也有类似的,但是会自增f,用于逐个解析。

1
2
3
4
5
6
7
8
9
10
/* Get current character in format string.  */
JUMP (*++f, step0_jumps);
......
#ifdef COMPILE_WPRINTF
f = __find_specwc ((end_of_spec = ++f));
#else
f = __find_specmb ((end_of_spec = ++f));
#endif
/* Write the following constant string. */
outstring (end_of_spec, f - end_of_spec);
1
2
3
4
5
6
7
8
9
#define outstring(String, Len)						\
do \
{ \
const void *string_ = (String); \
done = outstring_func (s, string_, (Len), done); \
if (done < 0) \
goto all_done; \
} \
while (0)

我们现在关注那个指向程序名的链条,栈上地址低3位16进制为288。

之后在调试时会发现,f会逐渐自增解析各个格式化字符串中的内容,在解析一串%p时其实不会在每一次outstring中打印内容,而是在解析到%xxxc后一并打印内容出来。之后在解析%hn时,会到这里进行跳转。这里对各种类型修饰符进行了解析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
  /* Process 'h' modifier.  There might another 'h' following.  */
LABEL (mod_half):
is_short = 1;
JUMP (*++f, step3a_jumps);

/* Process 'hh' modifier. */
LABEL (mod_halfhalf):
is_short = 0;
is_char = 1;
JUMP (*++f, step4_jumps);

/* Process 'l' modifier. There might another 'l' following. */
LABEL (mod_long):
is_long = 1;
JUMP (*++f, step3b_jumps);

/* Process 'L', 'q', or 'll' modifier. No other modifier is
allowed to follow. */
LABEL (mod_longlong):
is_long_double = 1;
is_long = 1;
JUMP (*++f, step4_jumps);

处理 h 修饰符

  • LABEL (mod_half)
    • 当遇到 h 修饰符时,将 is_short 设置为 1,表示后续的参数应被视为 short int 类型。
    • 然后跳转到下一个处理步骤 step3a_jumps,继续解析后续的格式字符。

处理 hh 修饰符

  • LABEL (mod_halfhalf)
    • 当遇到 hh 修饰符时,设置 is_short 为 0,并将 is_char 设置为 1。这表示后续参数将被视为 unsigned char 类型。
    • 跳转到 step4_jumps,继续后续解析。

处理 l 修饰符

  • LABEL (mod_long)
    • 当遇到 l 修饰符时,将 is_long 设置为 1,表示后续的参数应被视为 long int 类型。
    • 跳转到 step3b_jumps,继续解析。

处理 Lqll 修饰符

  • LABEL (mod_longlong)
    • 当遇到 Lqll 修饰符时,将 is_long_double 设置为 1,并将 is_long 设置为 1。这表明后续参数应被视为 long double 类型或 long long int 类型。
    • 这个标签后不允许有其他修饰符,因此跳转到 step4_jumps,继续后续解析。

解析完成后,我们发现栈上的内容实际已经被修改了,而后面的内容还没有开始解析。所以在%hn这种方式进行解析后会直接写入目标地址。

之后解析到$时会跳转到do_positional进行进一步操作,然后会调用printf_positional进行进一步操作。这边如果步过就能直接完成操作了。

1
2
3
4
if (*f == L_('$'))
/* Oh, oh. The argument comes from a positional parameter. */
goto do_positional;
JUMP (*f, step1_jumps);
1
2
3
4
do_positional:
done = printf_positional (s, format, readonly_format, ap, &ap_save,
done, nspecs_done, lead_str_end, work_buffer,
save_errno, grouping, thousands_sep, mode_flags);

现在我们来看看用payload = f"%{part1}c%15$hn" + f"%{part2}c%45$hn"这个会发生什么。首先在前面%xxxc会直接打印占位符,同样的,我们现在关注指向程序名的链条。

程序在解析%{part1}c%15$hn时,会进入printf_positional进行进一步处理,其中解析的核心函数是 nargs += __parse_one_specmb (f, nargs, &specs[nspecs], &max_ref_arg); 然后会发现解析完前面的内容之后,栈上那个指向程序名的指针并没有被改变。

当解析完最后一个之后,会进入如下两个switch分支:

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
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
for (cnt = 0; cnt < nspecs; ++cnt)
{
/* If the width is determined by an argument this is an int. */
if (specs[cnt].width_arg != -1)
args_type[specs[cnt].width_arg] = PA_INT;

/* If the precision is determined by an argument this is an int. */
if (specs[cnt].prec_arg != -1)
args_type[specs[cnt].prec_arg] = PA_INT;

switch (specs[cnt].ndata_args)
{
case 0: /* No arguments. */
break;
case 1: /* One argument; we already have the
type and size. */
args_type[specs[cnt].data_arg] = specs[cnt].data_arg_type;
args_size[specs[cnt].data_arg] = specs[cnt].size;
break;
default:
/* We have more than one argument for this format spec.
We must call the arginfo function again to determine
all the types. */
(void) (*__printf_arginfo_table[specs[cnt].info.spec])
(&specs[cnt].info,
specs[cnt].ndata_args, &args_type[specs[cnt].data_arg],
&args_size[specs[cnt].data_arg]);
break;
}

--------------------------------------------------------------------------------------------------
for (cnt = 0; cnt < nargs; ++cnt)
switch (args_type[cnt])
{
#define T(tag, mem, type) \
case tag: \
args_value[cnt].mem = va_arg (*ap_savep, type); \
break

T (PA_WCHAR, pa_wchar, wint_t);
case PA_CHAR: /* Promoted. */
case PA_INT|PA_FLAG_SHORT: /* Promoted. */
#if LONG_MAX == INT_MAX
case PA_INT|PA_FLAG_LONG:
#endif
T (PA_INT, pa_int, int);
#if LONG_MAX == LONG_LONG_MAX
case PA_INT|PA_FLAG_LONG:
#endif
T (PA_INT|PA_FLAG_LONG_LONG, pa_long_long_int, long long int);
#if LONG_MAX != INT_MAX && LONG_MAX != LONG_LONG_MAX
# error "he?"
#endif
case PA_FLOAT: /* Promoted. */
T (PA_DOUBLE, pa_double, double);
case PA_DOUBLE|PA_FLAG_LONG_DOUBLE:
if (__glibc_unlikely ((mode_flags & PRINTF_LDBL_IS_DBL) != 0))
{
args_value[cnt].pa_double = va_arg (*ap_savep, double);
args_type[cnt] &= ~PA_FLAG_LONG_DOUBLE;
}
#if __HAVE_FLOAT128_UNLIKE_LDBL
else if ((mode_flags & PRINTF_LDBL_USES_FLOAT128) != 0)
args_value[cnt].pa_float128 = va_arg (*ap_savep, _Float128);
#endif
else
args_value[cnt].pa_long_double = va_arg (*ap_savep, long double);
break;
case PA_STRING: /* All pointers are the same */
case PA_WSTRING: /* All pointers are the same */
T (PA_POINTER, pa_pointer, void *);
#undef T
default:
if ((args_type[cnt] & PA_FLAG_PTR) != 0)
args_value[cnt].pa_pointer = va_arg (*ap_savep, void *);
else if (__glibc_unlikely (__printf_va_arg_table != NULL)
&& __printf_va_arg_table[args_type[cnt] - PA_LAST] != NULL)
{
args_value[cnt].pa_user = alloca (args_size[cnt]);
(*__printf_va_arg_table[args_type[cnt] - PA_LAST])
(args_value[cnt].pa_user, ap_savep);
}
else
memset (&args_value[cnt], 0, sizeof (args_value[cnt]));
break;
case -1:
/* Error case. Not all parameters appear in N$ format
strings. We have no way to determine their type. */
assert ((mode_flags & PRINTF_FORTIFY) != 0);
__libc_fatal ("*** invalid %N$ use detected ***\n");
}

第一个 switch:解析格式说明符并确定各个参数的类型和大小,建立参数类型映射。

第二个 switch:根据映射提取实际参数,确保能够正确处理可变参数列表,确保每个参数的类型和大小都被正确使用。

最后会统一处理格式化说明符。

1
2
3
4
/* Now walk through all format specifiers and process them.  */
for (; (size_t) nspecs_done < nspecs; ++nspecs_done)
{
..............................

这里步过一次后会跳转到这个位置,我们能发现rcx被指向了./pwn,也就是第一次15偏移处的位置,执行两次后就把原来指向程序名的指针修改了。

之后再处理后,会发现改的是原来的的内容,./被改成了\x08\x12

分析到这其实大体原理已经清晰了,如果硬要从源码分析的话也定位到了相关函数,但感觉再分析下去效率太低了,以后有研究的需求再深入分析吧。

  • 标题: 非栈上格式化字符串一次利用
  • 作者: collectcrop
  • 创建于 : 2024-10-02 23:29:00
  • 更新于 : 2025-01-25 00:39:59
  • 链接: https://collectcrop.github.io/2024/10/02/非栈上格式化字符串一次利用/
  • 版权声明: 本文章采用 CC BY-NC-SA 4.0 进行许可。
目录
非栈上格式化字符串一次利用