文件操作安全:路径遍历、符号链接与临时文件
文件操作,是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(),问题解决。
知识体系总览
下面这张图,把今天讲的三个知识点串起来了。你可以看到,它们都围绕着同一个核心:文件系统状态不可信。
你看,这三个问题虽然表现形式不同,但根子是一样的:攻击者利用了程序对文件系统的「过度信任」。路径遍历是信任了用户输入的路径,符号链接是信任了文件路径指向的内容,临时文件攻击是信任了文件名唯一性。
我个人习惯是,只要涉及文件操作,先问自己三个问题:
- 这个路径我能完全控制吗?——如果不能,加校验。
- 这个文件会不会被别人动过手脚?——如果可能,用原子操作。
- 临时文件泄露了会怎样?——如果后果严重,用完立刻删。
养成这个习惯,文件操作相关的漏洞能防住九成以上。剩下的那一成,靠代码审查和渗透测试来补。嗯,今天就聊到这儿。
公众号:蓝海资料掘金营,微信deep3321