格式化字符串漏洞攻击实战

来源:香依香偎@闻道解惑

cheatingCTF 里的一道 PWN 题。主要攻击点就在于格式化字符串漏洞的利用。

一、陷阱

题目中布置了一个陷阱。如果用 IDA 6.8 来分析就很容易陷入陷阱,用 IDA 7.0 分析就会发现一些不一样的地方。

IDA-strcmp

IDA-puts

可以发现,IDA 6.8 识别出的 strcmp、puts 被 IDA 7.0 识别为了 strncmp、printf。用 readelf -r 查看,和 IDA 6.8 的结果一致。

readelf-r

为什么 IDA 6.8readelf 会显示出错误的库函数?原因在于 cheating 文件中的 .dynstr section 进行了特殊处理,布置了一个陷阱。用 readelf -S 看下。

readelf-S

可以看出,cheating 文件的 .dynstr 需要被加载到内存的 0x400490 地址,对应在文件中的 offset 为 0xf91。看下这个 string table 的内容。

dynstr

这个 string table 写的确实是 strcmpputs。细心一点会发现,这两个函数名后面都有多余的0x00,出题者还是留下了一点篡改的痕迹:)

但事实上,加载 ELF 文件时,并不会加载 0xF91string table,而是会加载位于 0x490 位置的 string table,这里才是对应 .dynstr 目标地址 0x400490 的真命天子 。

490

400490

好了,现在我们知道,可以关掉被误导的 IDA 6.8,继续用 IDA 7.0 来分析程序吧。

二、主流程和 sub_400ACC() 的输入检查

首先看一下防御情况。

checksec

cheating 的主函数 sub_400BC0() 如下。

main

函数的逻辑是:

  • 1、调用 sub_400ACC() 进行输入检查
    • 1.1、如果检查不通过,goto 2
    • 1.2、如果检查通过,接收用户输入并传递给 printf 输出,触发格式化字符串漏洞
  • 2、输出bye并退出

如果要触发格式化字符串漏洞,首先需要通过 sub_400ACC() 的检查。看一下这个检查函数。

validation

主要逻辑是:

  • 1、生成一个64字节的字符串,其中前十个字节固定为 “cheating U”, 后54个字节为0-9随机字符。
  • 2、用户输入字符串,与这个随机字符串进行 strncmp,相同则检查通过。

处理了陷阱之后,我们会发现这里的检查函数用的是 strncmp,只比较了十一个字节。排除掉固定前缀 cheating U 的十个字节,也就只剩下一个字节,范围在 0-9。我们选定一个值(比如0),进行多次碰撞就可以了。如果没有识别出陷阱,把这里误以为是 strcmp,发现必须碰撞54个字节的随机值,就只能一头雾水地发呆啦。

攻击脚本如下。

retry-validation

接收到 slogan: 字符串,顺利通过检查!

retry-success

三、格式化字符串漏洞

通过校验之后,回到主函数 sub_400BC0()if 分支内,这里是很明显的格式化字符串漏洞。

main

标准做法,分三步来实现 get shell

  • 解决程序的退出问题
  • 泄漏 system/bin/sh 的地址
  • 执行 sytem(“/bin/sh”)get shell

3.1 程序退出问题

很显然,printf 执行完成之后,程序就不再接收用户输入,而是继续执行并且退出。我们需要让程序不退出,而是重新回到触发格式化字符串漏洞的地方,以便于进一步的利用。

因此,我们需要找一个地址来改写,修改代码流程。看一下程序段,发现代码段是不能修改的,不过可以修改got表。

sections

很容易想到,把 exitgot 表地址改掉,改到 if 分支里,就可以在调用 exit 的时候回到主流程中。

exit-change

exitgot 表地址是 0x602078,默认值是 plt 表中 exit 表项中jmp指令的下一条指令地址 0x400846,我们要将这个值,修改为目标地址 0x400BE9。也就是说,需要修改两个字节,将 0x602078地址的两个字节从 0x846 修改为 0xBE9

exit-got

exit-plt

exit-change-addr

所以,我们构造如下代码。

repeat2main

执行之后,再次接收到 slogan: 字符串,成功将代码流程劫持,可以进入下一步攻击了。

repeat2main_succ

需要注意的是,我们将 exit()got 地址修改为 0x400BE9 之后,实际上是通过一次 call 指令重入了当前函数,也就意味着栈被抬高了一层(call 指令用于保存函数返回地址)。后续继续使用 printf 的格式化字符串漏洞时,每次都会多偏移一个参数的位置,这一点需要注意。

3.2 泄漏 system/bin/sh 的地址

get shell,我们需要泄漏出 glibcsystem()/bin/sh 的地址。在环境提供了 libc.so.6 文件的条件下,我们只需要泄漏出任何一个库函数的地址,都可以通过文件中的偏移来计算出我们想要的符号地址。

看一下 got 表,我们选择 read 函数来泄漏地址。为什么选择 read?回看一下主函数。

main

在存在漏洞的 printf 函数执行前,read 函数已经被调用了,所以此时 got 表中 read 函数的表项中已经保存了它在 glibc 库中的真实地址。

got

也就是说,我们需要泄漏出 0x602050 地址的内容。用 “%s” 就好了。

read_addr

calc_system

成功获取到 system()/bin/sh 的内存地址!

leak-succ

3.3 执行 sytem(“/bin/sh”)get shell

我们已经拿到 system 的地址,还有任意地址写的漏洞,也能布置栈空间。接下来就是看怎样调用 system(“/bin/sh”) 最方便了。

有很多方法可以实现这一步。最常用的方法是,利用 x64 程序的万能 gadgetinit()函数,通过 ROP 来实现。

init-func

有没有更轻松的方法呢?回看一眼主函数。

main

咦? printf 的入参就是用户输入的 buf。这就意味着,只要我们把 printfgot 表改成 system 的地址,下一轮迭代时再发送 “/bin/sh” 的字符串,就可以直接执行 system(“/bin/sh”) 了,很简单是不是:)

查一下 got 表。printf 的地址是 0x602030,我们的目标是将这个地址的内容改写为前面获取到的 system 函数的真实地址。

got-printf

攻击脚本如下:

call-system

执行一下,成功 Get Shell

get-shell

四、One More Thing

LazyIDAIDA Pro 的一个插件,其中有一个功能是“扫描格式化字符串漏洞 Scan String Format Vulnerabilities”。

scan-string-format

扫描一下看看,很快就找到了漏洞点。

scan-string-format-result

附:

原始程序下载:cheating

攻击脚本链接:pwn_cheating.py

LazyIDA 下载:LazyIDA