文件操作安全:路径遍历、符号链接与临时文件

文件操作,是C语言里最基础也最容易出事的环节。我见过太多项目,功能跑得飞起,结果在文件路径上翻了车。今天咱们就聊聊三个高频坑:路径遍历、符号链接攻击,还有临时文件的安全创建。

说白了,这三个问题都跟「信任」有关——你信任了用户输入的路径,信任了文件系统的状态,信任了临时文件的名字。嗯,这些信任,往往就是漏洞的入口。

核心原则:永远不要信任外部输入,永远不要假设文件系统是安全的。

路径遍历攻击(Path Traversal)

路径遍历,也叫目录穿越。攻击者通过构造 ../ 这样的路径,跳出你限定的目录,去读写不该碰的文件。

举个例子,你的程序允许用户指定一个文件名,然后从 /data/files/ 目录下读取。正常请求是 report.txt,但攻击者传个 ../../etc/passwd,就能把你的密码文件读走。

我在项目中遇到过类似的事。一个嵌入式设备的管理后台,允许用户下载日志文件。开发同学直接拼接路径:sprintf(path, "/var/log/%s", user_input)。结果呢?渗透测试时,一个 ../../../etc/shadow 就把系统账户哈希全拿走了。修起来倒不难,但面子挂不住啊。

防御手段

  • 路径规范化:realpath() 把用户输入的路径转成绝对路径,然后检查前缀是否在允许的范围内。
  • 白名单校验:只允许特定的文件名,拒绝任何包含 ../..\ 的输入。
  • 最小权限原则:程序本身不要以 root 运行,即使被绕过了,也读不了敏感文件。
// 不安全的做法
void read_file(const char *user_input) {
    char path[256];
    sprintf(path, "/data/files/%s", user_input);  // 危险!
    FILE *fp = fopen(path, "r");
    // ...
}

// 安全的做法
void read_file_safe(const char *user_input) {
    // 先拒绝明显恶意输入
    if (strstr(user_input, "..") != NULL) {
        fprintf(stderr, "非法路径\n");
        return;
    }

    char full_path[PATH_MAX];
    snprintf(full_path, sizeof(full_path), "/data/files/%s", user_input);

    char resolved[PATH_MAX];
    if (realpath(full_path, resolved) == NULL) {
        perror("realpath");
        return;
    }

    // 检查解析后的路径是否以允许的前缀开头
    if (strncmp(resolved, "/data/files/", strlen("/data/files/")) != 0) {
        fprintf(stderr, "路径越权\n");
        return;
    }

    FILE *fp = fopen(resolved, "r");
    // ...
}
注意:realpath() 本身也可能有竞态条件(TOCTOU),但作为第一道防线已经比直接拼接强太多了。

符号链接攻击(Symlink Attack)

符号链接攻击,说白了就是「狸猫换太子」。攻击者在你的程序要写文件的地方,提前放一个符号链接,指向系统关键文件。你的程序一写,就把人家的文件给覆盖了。

你想想看,如果你的程序以 root 权限运行,往 /tmp/app_config 写配置。攻击者先创建 /tmp/app_config -> /etc/passwd。你程序一写,好嘛,密码文件被覆盖了,系统直接瘫痪。

我曾经在审计一个日志写入模块时发现,程序每次启动都会在 /var/run/app.pid 写进程ID。代码里直接 fopen(path, "w"),没有任何检查。攻击者只要提前创建符号链接指向 /etc/shadow,下次服务重启时,shadow 文件就被清空了。嗯,这个漏洞评级是「严重」。

防御手段

  • 使用 O_NOFOLLOW 标志:open() 时加上 O_NOFOLLOW,如果路径是符号链接,直接返回错误。
  • 先检查再操作:lstat() 检查文件类型,拒绝符号链接。
  • 使用安全的临时目录:避免在公共可写目录(如 /tmp)直接创建固定名称的文件。
#include <fcntl.h>
#include <unistd.h>
#include <stdio.h>

int safe_write_pid(const char *path) {
    // 使用 O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW
    // O_EXCL 确保文件不存在时才创建,O_NOFOLLOW 拒绝符号链接
    int fd = open(path, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0644);
    if (fd == -1) {
        perror("open");
        return -1;
    }

    // 写入 PID
    dprintf(fd, "%d\n", getpid());
    close(fd);
    return 0;
}
小技巧:如果必须使用已存在的文件,可以先 lstat() 检查,确认不是符号链接后再 open()。但要注意 TOCTOU 问题——检查和使用之间,攻击者可能已经替换了文件。

临时文件安全创建(mkstemp)

临时文件,看着简单,其实坑最多。很多新手喜欢这么写:

// 极度不安全的做法
char tmpname[] = "/tmp/myapp_XXXXXX";
mktemp(tmpname);  // 已废弃!有竞态条件
FILE *fp = fopen(tmpname, "w");

为什么不行?mktemp() 只生成一个不存在的文件名,但生成和打开之间有时间差。攻击者可以预测文件名,提前创建符号链接。这就是经典的 TOCTOU(Time of Check, Time of Use)漏洞。

正确的做法是用 mkstemp()。它原子性地创建并打开文件,不给攻击者留机会。

#include <stdlib.h>
#include <unistd.h>
#include <stdio.h>

int safe_tempfile() {
    char template[] = "/tmp/myapp_XXXXXX";
    
    // mkstemp 会修改 template 字符串,替换 XXXXXX 为随机字符
    // 它返回一个已打开的文件描述符,文件权限为 0600
    int fd = mkstemp(template);
    if (fd == -1) {
        perror("mkstemp");
        return -1;
    }

    // 如果需要 FILE* 流,可以用 fdopen
    FILE *fp = fdopen(fd, "w");
    if (fp == NULL) {
        close(fd);
        return -1;
    }

    // 使用完毕后关闭
    fprintf(fp, "敏感数据\n");
    fclose(fp);

    // 记得清理临时文件
    unlink(template);
    return 0;
}
关键点:mkstemp() 创建的临时文件默认权限是 0600(仅所有者可读写),而且文件名是随机生成的,不可预测。这两个特性共同防止了符号链接攻击和文件名预测攻击。

临时文件安全最佳实践

做法 说明
使用 mkstemp() 而非 mktemp()/tmpnam() 原子创建,避免 TOCTOU
设置 umask 为 077 确保创建的文件权限最小化
使用后立即 unlink 即使程序崩溃,文件也会被清理
避免在 /tmp 使用固定文件名 固定文件名容易被预测和劫持
考虑使用 tmpfile() 自动创建并删除,但无法获取文件名
避坑指南:我曾经见过一个项目,用 tmpnam() 生成临时文件名,然后 fopen() 打开。测试环境一切正常,上线后频繁出现「文件已存在」错误。查了半天,发现是多个进程同时运行时,文件名冲突了。更可怕的是,攻击者可以故意创建同名文件来劫持数据。后来全部改成 mkstemp(),问题解决。

知识体系总览

下面这张图,把今天讲的三个知识点串起来了。你可以看到,它们都围绕着同一个核心:文件系统状态不可信。

文件操作安全核心知识体系 文件操作安全 路径遍历攻击 符号链接攻击 临时文件攻击 realpath() 规范化 白名单校验 O_NOFOLLOW 标志 lstat() 检查 mkstemp() 原子创建 umask 最小权限 核心原则:永远不要信任文件系统状态

你看,这三个问题虽然表现形式不同,但根子是一样的:攻击者利用了程序对文件系统的「过度信任」。路径遍历是信任了用户输入的路径,符号链接是信任了文件路径指向的内容,临时文件攻击是信任了文件名唯一性。

我个人习惯是,只要涉及文件操作,先问自己三个问题:

  1. 这个路径我能完全控制吗?——如果不能,加校验。
  2. 这个文件会不会被别人动过手脚?——如果可能,用原子操作。
  3. 临时文件泄露了会怎样?——如果后果严重,用完立刻删。

养成这个习惯,文件操作相关的漏洞能防住九成以上。剩下的那一成,靠代码审查和渗透测试来补。嗯,今天就聊到这儿。


公众号:蓝海资料掘金营,微信deep3321