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_NOFOLLOWO_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 固定代表了打开时的对象。


四、各语言示例与详细修复方案

下面分别给出每种语言的:

  1. 易受攻击(vulnerable)示例(代码)
  2. 修复(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() 成功后立即对返回的 fdfstat() —— 这是关键:基于 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)

八、实践中的完整缓解清单(摘除重复,直接可查)

  1. 基于 fd 做检查:打开后用 fstat/File.Stat() 等基于 fd 的操作验证文件类型与属主。避免在打开之后再次用路径名做检查。
  2. 使用文件打开标志:如 O_NOFOLLOW(防止符号链接)、O_EXCL|O_CREAT(原子创建)、O_CLOEXEC(避免 exec 泄露 fd)。
  3. 写临时文件 + 原子替换:写文件到同目录下临时文件,再 rename/move 原子替换目标(Java 推荐使用 ATOMIC_MOVE)。
  4. 避免在不可信目录使用可预测名字:对临时文件使用随机名(mkstemp, createTempFile 等)。
  5. 最小权限:以尽可能低权限运行写入操作,必要时临时提升/降权并严控边界。
  6. 文件系统/挂载点注意事项:NFS 等网络文件系统在某些原语上可能不保证本地 FS 的语义。
  7. 审计与监控:检测异常文件种类变化、频繁失败的打开、权限/属主突然变化。
  8. 在极端需求下调用底层系统接口:如需 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 国际许可协议进行许可。