TOCTTOU - 时间差中的漏洞
Posted on Thu 23 October 2025 in Journal
| Abstract | TOCTTOU - 时间差中的漏洞 |
|---|---|
| Authors | Walter Fan |
| Category | learning note |
| Status | v1.0 |
| Updated | 2025-10-23 |
| License | CC-BY-NC-ND 4.0 |
TOCTTOU - 时间差中的漏洞
TOCTTOU(Time-Of-Check → Time-Of-Use) 是一类竞态条件漏洞:程序在“检查(check)”与“使用(use)”之间被攻击者利用时间窗口篡改资源,导致基于过时或不可信的信息执行不安全操作。
一、先看结论(TL;DR)
TOCTTOU = 先检查 -> 再使用。如果这两步之间能被外部改变,就有漏洞。- 核心防御思路:把检查与使用合并为一次原子操作,或基于打开后的文件描述符(fd)进行验证。
- 实用技术:
O_NOFOLLOW、O_EXCL|O_CREAT、基于 fd 的fstat、写临时文件 + 原子rename/move、在 Java 中使用createFile()/ 临时文件 +ATOMIC_MOVE。
二、攻击流程(PlantUML 时序图)
下面的 PlantUML 脚本展示了一个典型的 TOCTTOU 攻击时序:
@startuml
title TOCTTOU 漏洞时序图
actor Attacker
participant Program
participant FileSystem as FS
Program -> FS: stat(/tmp/data) \n(Time-Of-Check)
FS --> Program: file exists, owner UID=1001
note right of Attacker: 在这里攻击者有机会介入
Attacker -> FS: remove /tmp/data
Attacker -> FS: create symlink /tmp/data -> /etc/passwd
FS --> Attacker: symlink created
Program -> FS: open(/tmp/data) \n(Time-Of-Use)
FS --> Program: open returns fd for /etc/passwd
Program -> FS: write(fd, "malicious content")
FS --> Program: write OK (but wrote to /etc/passwd)
@enduml
三、为什么会发生?(直观理解)
文件系统路径解析是多步骤的:stat(path)、目录查找、符号链接解析、权限检查等。每一步都可能被并发进程或有权限的用户在瞬间改变。所以,只依赖路径层面的多次检查无法保证在下一步使用时对象未被替换。
结论:路径上的任何再次解析都可能被攻击者利用。可信的做法应基于打开时得到的文件句柄(fd),因为 fd 固定代表了打开时的对象。
四、各语言示例与详细修复方案
下面分别给出每种语言的:
- 易受攻击(vulnerable)示例(代码)
- 修复(safe)示例与逐行解释
1) C++(POSIX 环境)
易受攻击示例(不要在生产中这样写)
// vulnerable.cpp
#include <sys/stat.h>
#include <cstdio>
#include <iostream>
#include <string>
int unsafeWrite(const std::string& path, const std::string& data) {
struct stat st;
if (stat(path.c_str(), &st) == 0) {
// 基于 stat 的检查
}
// Time-of-use: 再次通过路径打开
FILE* f = fopen(path.c_str(), "w");
if (!f) return -1;
fwrite(data.data(), 1, data.size(), f);
fclose(f);
return 0;
}
为什么有漏洞:stat() 与 fopen() 之间的窗口允许攻击者替换路径目标(软链接、移动等)。
安全修复(基于 open() + fstat())
// safe.cpp
#include <sys/types.h>
#include <sys/stat.h>
#include <fcntl.h>
#include <unistd.h>
#include <cstdio>
#include <cerrno>
#include <cstring>
#include <iostream>
int safeWrite(const std::string& path, const std::string& data) {
int flags = O_WRONLY | O_CREAT | O_TRUNC | O_NOFOLLOW | O_CLOEXEC;
mode_t mode = 0644;
int fd = open(path.c_str(), flags, mode);
if (fd == -1) {
std::cerr << "open error: " << strerror(errno) << "\n";
return -1;
}
struct stat st;
if (fstat(fd, &st) == -1) {
std::cerr << "fstat error: " << strerror(errno) << "\n";
close(fd);
return -1;
}
if (!S_ISREG(st.st_mode)) {
std::cerr << "not a regular file\n";
close(fd);
return -1;
}
ssize_t wrote = write(fd, data.data(), data.size());
if (wrote == -1) {
std::cerr << "write error: " << strerror(errno) << "\n";
close(fd);
return -1;
}
fsync(fd);
close(fd);
return 0;
}
逐行解释:
- 使用
open()并设置O_NOFOLLOW防止软链接被跟随(平台支持前提)。 - 在
open()成功后立即对返回的fd做fstat()—— 这是关键:基于 fd 的检查不会再触发路径解析,从而避免 TOCTTOU。 - 检查是否为常规文件(
S_ISREG)。 - 写入并
fsync(如需持久化保证)。
注意事项:
O_NOFOLLOW在某些文件系统或老旧内核上语义可能不同;针对 NFS 等网络文件系统需特别测试。- 若要“仅当不存在时创建”,使用
O_CREAT | O_EXCL以获得创建时的原子性。
2) Go(类 Unix 环境)
易受攻击示例
// vulnerable_go.go
package main
import (
"fmt"
"os"
)
func unsafeWrite(filename string, data []byte) error {
if _, err := os.Stat(filename); err == nil {
// 做一些基于 stat 的判断
}
f, err := os.OpenFile(filename, os.O_WRONLY|os.O_CREATE|os.O_TRUNC, 0644)
if err != nil {
return err
}
defer f.Close()
_, err = f.Write(data)
return err
}
func main() {
err := unsafeWrite("/tmp/myapp.cfg", []byte("important config\n"))
if err != nil { fmt.Println("err:", err) } else { fmt.Println("written") }
}
问题同样是 os.Stat() 与 os.OpenFile() 之间的窗口。
安全修复(使用 syscall.Open + 基于 fd 的检查)
// safe_go.go
package main
import (
"fmt"
"os"
"syscall"
)
func safeWrite(filename string, data []byte) error {
flags := syscall.O_WRONLY | syscall.O_CREAT | syscall.O_TRUNC | syscall.O_NOFOLLOW | syscall.O_CLOEXEC
mode := uint32(0644)
fd, err := syscall.Open(filename, flags, mode)
if err != nil {
return fmt.Errorf("open: %w", err)
}
f := os.NewFile(uintptr(fd), filename)
if f == nil {
syscall.Close(fd)
return fmt.Errorf("NewFile failed")
}
defer f.Close()
info, err := f.Stat()
if err != nil {
return fmt.Errorf("stat after open: %w", err)
}
if !info.Mode().IsRegular() {
return fmt.Errorf("not a regular file")
}
_, err = f.Write(data)
return err
}
func main() {
if err := safeWrite("/tmp/myapp.cfg", []byte("config\n")); err != nil {
fmt.Println("err:", err)
} else {
fmt.Println("written safely")
}
}
要点解释:
- 使用低层
syscall.Open以便设置O_NOFOLLOW等标志(标准库的os.OpenFile没法设置O_NOFOLLOW)。 - 打开后将 fd 包装为
*os.File,并基于f.Stat()做检查。 - 这样验证基于 fd,而非路径,避免再次解析引发的 TOCTTOU。
注意:
syscall包在不同 Go 版本/平台上可能有差异,生产代码需注意兼容性;可使用golang.org/x/sys/unix获得更好跨平台支持。
3) Java(JDK)
Java 在文件层面有更高层的抽象,但同样会遇到 TOCTTOU。下面给出常见错误与推荐修复。
不安全示例(常见反模式)
// VulnerableJava.java
File file = new File("/tmp/data.txt");
if (file.exists() && file.canRead()) {
try (FileInputStream fis = new FileInputStream(file)) {
// read...
}
}
exists() 与 FileInputStream 之间有窗口,攻击者可替换路径。
Java 推荐修复方案 A:原子创建(仅当不存在时)
Path p = Paths.get("/tmp/myapp_new.cfg");
try {
Files.createFile(p); // 若已存在抛 FileAlreadyExistsException
Files.write(p, contentBytes, StandardOpenOption.WRITE);
} catch (FileAlreadyExistsException e) {
// 处理已存在情况
}
解释:Files.createFile() 在底层执行原子创建操作(若已存在则失败),比 exists()+create 更安全。
Java 推荐修复方案 B(更通用、推荐):写临时文件 -> 原子替换
这是最常见也最可靠的模式:在目标目录创建临时文件,写入后用 Files.move(..., ATOMIC_MOVE, REPLACE_EXISTING) 做原子替换。
Path target = Paths.get("/tmp/myapp.cfg");
Path dir = target.getParent();
Path tmp = Files.createTempFile(dir, ".tmp-", ".tmp");
Files.write(tmp, contentBytes);
try {
Files.move(tmp, target, StandardCopyOption.ATOMIC_MOVE, StandardCopyOption.REPLACE_EXISTING);
} catch (AtomicMoveNotSupportedException e) {
Files.move(tmp, target, StandardCopyOption.REPLACE_EXISTING);
}
优势:
- 在多数文件系统内
rename/move是原子的,因此不会留下半写入的文件。 - 临时文件写入期间即便被替换,最终
move操作决定最终内容,减少风险。
注意:
ATOMIC_MOVE依赖底层文件系统;若不支持则会抛AtomicMoveNotSupportedException,需有降级方案。- Java 标准库没有直接在打开文件时强制
O_NOFOLLOW的跨平台 API;需要极端保证时考虑 JNI/JNA 调用底层open(2)。
八、实践中的完整缓解清单(摘除重复,直接可查)
- 基于 fd 做检查:打开后用
fstat/File.Stat()等基于 fd 的操作验证文件类型与属主。避免在打开之后再次用路径名做检查。 - 使用文件打开标志:如
O_NOFOLLOW(防止符号链接)、O_EXCL|O_CREAT(原子创建)、O_CLOEXEC(避免 exec 泄露 fd)。 - 写临时文件 + 原子替换:写文件到同目录下临时文件,再
rename/move原子替换目标(Java 推荐使用ATOMIC_MOVE)。 - 避免在不可信目录使用可预测名字:对临时文件使用随机名(
mkstemp,createTempFile等)。 - 最小权限:以尽可能低权限运行写入操作,必要时临时提升/降权并严控边界。
- 文件系统/挂载点注意事项:NFS 等网络文件系统在某些原语上可能不保证本地 FS 的语义。
- 审计与监控:检测异常文件种类变化、频繁失败的打开、权限/属主突然变化。
- 在极端需求下调用底层系统接口:如需
O_NOFOLLOW或更细粒度语义,考虑通过 JNI/JNA(Java)或直接使用 POSIX 接口(C/Go)调用。
九、常见误区(Quick FAQ)
-
Q:rename 一定是原子吗? A:POSIX 保证在同一挂载点内
rename是原子,但跨挂载点或特殊文件系统可能不保证。 -
Q:我只在本地机器上跑,应该不需要担心吧? A:多进程、多用户或第三方插件/脚本都可能在短时间内修改文件,TOCTTOU 与是否“远程”无关。
-
Q:使用高级语言(Java/Go)能否避免这些问题? A:抽象不能替代对底层语义的理解。高级语言提供了便利 API,但一些低层控制(如
O_NOFOLLOW)需要额外手段或原语。
十、结语
TOCTTOU 看起来是“底层”的问题,但它能触发极具破坏力的攻击:覆盖配置、写入系统文件、权限提升等。把“检查”与“使用”合并、基于 fd 检查、使用原子替换策略,能大幅降低被利用的风险。
如果你有现成的代码片段(尤其是涉及文件创建/更新的关键路径),贴出来我可以:
- 快速审查并指出 TOCTTOU 风险点;
- 按你当前运行环境(是否 NFS、内核版本、Java/Go 版本)给出可执行修复补丁。
本作品采用知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议进行许可。