第十八章 文件操作安全:路径遍历、符号链接攻击、安全文件读写
文件操作,听起来简单吧?打开、读写、关闭,三件套。但说实话,我在安全审计中见过太多看似人畜无害的代码,最后栽在文件操作上。你想想看,程序要跟文件系统打交道,就相当于把大门敞开了一条缝。攻击者要是能控制你打开哪个文件,那乐子就大了。
这一章,咱们就聊聊文件操作里的三个大坑:路径遍历、符号链接攻击,以及怎么安全地读写文件。嗯,都是我在实战中踩过的坑,或者帮别人填过的坑。
18.1 路径遍历攻击
路径遍历,说白了就是攻击者利用「../」这种相对路径,跳出你预设的目录,去读写不该碰的文件。比如你的程序允许用户下载文件,用户传个「../../etc/passwd」,要是你没过滤,系统密码文件就被人拿走了。
核心问题:程序信任了用户输入的路径,没有做合法性校验。
我遇到过这样一个案例。一个嵌入式设备的日志查看功能,用户输入文件名,程序就去 /var/log/ 下读取。结果安全测试时,有人传了个「../../../etc/shadow」,直接拿到了密码哈希。你说这项目急不急?
18.1.1 攻击原理
攻击者利用路径分隔符和相对路径,构造出超出预期目录的路径。常见的套路有:
- 使用
../或..\向上跳转 - 使用绝对路径,如
/etc/passwd - 使用编码绕过,如
%2e%2e%2f(URL编码的../) - 使用空字节截断,如
file.txt%00.jpg(老式C语言漏洞)
为什么会这样?因为很多开发者觉得「用户不会这么干」。但现实是,攻击者比你想象的有耐心得多。
18.1.2 防御方案
我个人习惯用这几招来防路径遍历:
- 白名单校验:只允许预定义的合法文件名,不接受用户直接传路径
- 路径规范化:用
realpath()或_fullpath()把路径转成绝对路径,再检查前缀 - 拒绝特殊字符:过滤
../、..\、空字节等
来看一段代码,这是我常用的安全路径校验函数:
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <limits.h>
#include <unistd.h>
/**
* 安全地解析用户输入的文件名
* 返回:0 表示合法,-1 表示非法
*/
int safe_resolve_path(const char *user_input,
const char *base_dir,
char *resolved_path,
size_t path_size) {
char temp[PATH_MAX];
char base_real[PATH_MAX];
// 1. 先规范化基础目录
if (realpath(base_dir, base_real) == NULL) {
return -1;
}
// 2. 拼接用户输入
snprintf(temp, sizeof(temp), "%s/%s", base_real, user_input);
// 3. 规范化拼接后的路径
if (realpath(temp, resolved_path) == NULL) {
return -1;
}
// 4. 检查结果是否以基础目录开头
if (strncmp(resolved_path, base_real, strlen(base_real)) != 0) {
return -1; // 试图跳出基础目录!
}
// 5. 额外检查:确保没有包含 ../ 等
if (strstr(user_input, "..") != NULL) {
return -1;
}
return 0;
}
我的经验:别只用字符串过滤。攻击者会用各种编码绕过,比如双URL编码、Unicode编码。用 realpath() 做规范化是最靠谱的,因为它会解析所有符号链接和相对路径。
18.2 符号链接攻击
符号链接(symlink)在Linux里是个好东西,但在安全场景下,它就是个定时炸弹。攻击者可以创建一个符号链接,指向一个敏感文件,然后诱导你的程序去读写这个链接。
我曾经在审计一个备份工具时发现,程序会在 /tmp/ 下创建临时文件,但没检查文件是否已存在。攻击者提前在 /tmp/ 下创建了一个指向 /etc/shadow 的符号链接,程序一写,就把密码文件覆盖了。嗯,后果你懂的。
18.2.1 攻击场景
典型的符号链接攻击流程是这样的:
- 程序在公共目录(如 /tmp/)创建文件
- 攻击者提前创建同名符号链接,指向目标文件
- 程序写入数据,实际上写到了目标文件
- 或者程序读取数据,读到了攻击者伪造的内容
说白了,就是利用了「时间差」——检查文件是否存在和实际打开文件之间,攻击者有机会做手脚。这叫 TOCTOU(Time of Check, Time of Use)漏洞。
18.2.2 防御方法
我建议用这几个方法来防符号链接攻击:
- 使用 O_NOFOLLOW 标志:打开文件时指定这个标志,如果路径是符号链接,直接报错
- 检查文件所有者:确保文件属于当前用户,不是攻击者创建的
- 使用安全临时文件函数:如
mkstemp(),它原子性地创建文件,不给攻击者留时间差
看代码:
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <sys/stat.h>
/**
* 安全地打开文件,拒绝符号链接
*/
int safe_open_for_write(const char *path) {
int fd;
struct stat st;
// 使用 O_NOFOLLOW 防止符号链接
fd = open(path, O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0600);
if (fd < 0) {
perror("open failed");
return -1;
}
// 额外检查:确认文件不是符号链接
if (fstat(fd, &st) < 0) {
close(fd);
unlink(path);
return -1;
}
// 检查文件所有者
if (st.st_uid != getuid()) {
fprintf(stderr, "文件所有者不匹配,可能被篡改\n");
close(fd);
unlink(path);
return -1;
}
return fd;
}
/**
* 使用 mkstemp 创建安全临时文件
*/
int safe_temp_file(char *template) {
int fd;
fd = mkstemp(template);
if (fd < 0) {
return -1;
}
// mkstemp 已经保证了文件是新建的,不会被符号链接攻击
// 但最好还是设置合适的权限
fchmod(fd, 0600);
return fd;
}
注意:只用 O_NOFOLLOW 还不够。如果攻击者在 open() 调用之后、fstat() 之前替换了文件,还是有风险。所以,我一般会结合 O_EXCL 和 O_NOFOLLOW 一起用,并且检查文件属性。
18.3 安全文件读写实践
前面讲了两个攻击方式,现在咱们系统地说说怎么安全地读写文件。我总结了一套「安全文件操作三板斧」:
18.3.1 原则一:最小权限
文件权限能小就小。程序只需要读,就别给写权限。只需要写,就别给执行权限。我见过太多程序把临时文件设成 0777,这不是请人来搞破坏吗?
| 场景 | 推荐权限 | 说明 |
|---|---|---|
| 配置文件(只读) | 0644 | 所有者可写,其他人只读 |
| 日志文件(追加) | 0644 | 用 O_APPEND 防止覆盖 |
| 临时文件 | 0600 | 只有当前用户可读写 |
| 可执行文件 | 0755 | 不要轻易给 setuid 位 |
18.3.2 原则二:原子操作
能一次做完的事,别分两次做。比如写配置文件,我习惯先写到一个临时文件,然后用 rename() 原子性地替换原文件。这样即使程序在写入过程中崩溃,也不会留下半截文件。
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
/**
* 安全地写入配置文件(原子写入)
*/
int safe_write_config(const char *config_path,
const char *data,
size_t data_len) {
char temp_path[PATH_MAX];
FILE *fp;
int ret = -1;
// 生成临时文件名
snprintf(temp_path, sizeof(temp_path),
"%s.tmp.XXXXXX", config_path);
// 创建临时文件(mkstemp 保证安全性)
int fd = mkstemp(temp_path);
if (fd < 0) {
return -1;
}
// 写入数据
fp = fdopen(fd, "w");
if (fp == NULL) {
close(fd);
unlink(temp_path);
return -1;
}
if (fwrite(data, 1, data_len, fp) != data_len) {
fclose(fp);
unlink(temp_path);
return -1;
}
// 确保数据刷到磁盘
if (fflush(fp) != 0 || fsync(fd) != 0) {
fclose(fp);
unlink(temp_path);
return -1;
}
fclose(fp);
// 原子替换原文件
if (rename(temp_path, config_path) != 0) {
unlink(temp_path);
return -1;
}
return 0;
}
18.3.3 原则三:输入验证
永远不要信任用户提供的文件名。我见过一个项目,用户上传文件时传了「../../../var/www/html/shell.php」,结果直接 getshell 了。你说这项目急不急?
我的做法是:
- 文件名只允许字母、数字、下划线、点
- 拒绝任何路径分隔符
- 限制文件大小,防止磁盘写满
- 使用独立的临时目录,并设置合适的权限
避坑指南:我曾经在做一个文件上传功能时,只过滤了 ../,没过滤 ..\(Windows路径)。结果测试人员在Windows服务器上一测就崩了。从那以后,我都是把路径分隔符全部列出来,一个不漏地过滤。
18.4 知识体系总览
说了这么多,咱们用一张图来总结本章的核心内容。这张图展示了文件操作安全的三个主要方面,以及它们之间的关系。
这张图把咱们讲的三块内容串起来了。你看,路径遍历和符号链接攻击是外部威胁,安全读写是内部防御。三者缺一不可。
最后说一句,文件操作安全没有银弹。你用了 realpath(),还得防符号链接;你用了 O_NOFOLLOW,还得防路径遍历。安全是个系统工程,每一层都做到位了,才能睡得安稳。
公众号:蓝海资料掘金营,微信deep3321