使用 access(F_OK) 检查文件是否存在会引入 TOCTOU 漏洞吗?
结论
access(path, F_OK) 检查后再打开同一路径,仍然存在检查与使用之间的竞态。它是否构成安全漏洞,取决于攻击者能否改变路径解析结果,以及程序是否把检查结果当作后续操作的安全保证。
如果只是显示文件当前是否存在,并允许结果随时过期,通常可以使用这种检查。但程序不能据此认定,之后打开的一定是刚才检查的文件。
fopen(path, "w") 在文件不存在时会尝试创建文件,在文件存在时会截断文件。因此,即使文件被删除,它也不一定失败。POSIX fopen() 手册
F_OK 为什么仍然存在竞态
TOCTOU 的原因是两次操作分别解析路径,而路径对应的对象可能在两次操作之间改变。real UID 与 effective UID 的差异会让某些特权程序面临更严重的后果,但这种差异并非产生竞态的必要条件。
例如:
access(path, F_OK)检查成功,此时路径指向文件 A。- 攻击者在具有相应目录操作权限的条件下,将该路径替换为指向文件 B 的符号链接。
fopen(path, "w")再次解析路径,打开并截断文件 B。
如果程序有权写入 B,而攻击者无权直接写入 B,就可能突破权限边界。能否利用还取决于目录权限、进程权限及系统对符号链接的限制。
攻击者可以替换文件,而不只是删除文件。替换后,fopen() 可能成功打开错误的对象,处理打开失败无法覆盖这种情况。
即使没有权限提升,读取被替换的配置、覆盖其他业务文件或破坏数据,也可能造成安全后果。
access(F_OK) 实际保证了什么
成功返回只表示在检查时,路径可以按 access() 的规则解析到一个存在的对象。它不会锁定路径,也不会保留对象供后续调用使用。
还需要注意:
F_OK不检查目标文件是否可读或可写,但路径中目录的搜索权限仍会影响检查结果。access()会跟随符号链接;悬空符号链接通常会因目标不存在而检查失败。- 返回
-1不一定表示文件不存在,也可能是权限不足或其他错误,应结合errno判断。
这些行为见 access(2) 手册。用 stat() 替换 access(),同样不能消除两次路径操作之间的竞态。
pick_file() 应如何设计
原函数只能返回检查时存在的候选路径索引。它无法保证调用方稍后还能打开该路径,也无法保证多次打开得到同一个文件。
如果需要选择一个可以立即读取的候选文件,可以直接尝试打开并返回文件描述符。下面的 POSIX 示例仅跳过路径不存在的候选项,遇到其他错误时交给调用方处理:
#include <errno.h>
#include <fcntl.h>
/* 成功时由调用方负责 close();index 必须指向有效的 int。 */
int pick_open_file(const char **paths, int count, int *index)
{
*index = -1;
for (int i = 0; i < count; ++i) {
int fd = open(paths[i], O_RDONLY | O_CLOEXEC | O_NOFOLLOW);
if (fd >= 0) {
*index = i;
return fd;
}
if (errno != ENOENT && errno != ENOTDIR)
return -1;
}
errno = ENOENT;
return -1;
}
这个示例改变了接口语义:它选择的是能够按指定标志打开的候选项。如果业务希望跳过权限不足等错误,需要明确增加相应策略。
成功打开后,保留文件描述符可以让后续操作继续引用已打开的对象,即使原路径后来被删除或替换。但文件内容不会因此冻结,其他有权限的进程仍可能修改它。open(2) 手册
代码中的 O_NOFOLLOW 只拒绝路径最后一个分量是符号链接的情况,中间目录中的符号链接仍可能被跟随。示例也没有限制文件类型,如果候选路径指向 FIFO,打开操作可能阻塞。因此,它适合候选路径及目录结构受信任的场景,不能直接作为处理任意不可信路径的完整安全方案。
写文件时,把约束放进实际操作
应先明确需要哪一种行为:
- 只打开已经存在的文件:使用不带
O_CREAT的open()。例如,open(path, O_WRONLY | O_CLOEXEC)不会因为目标不存在而创建文件。 - 只创建新文件,已有文件时失败:使用
O_CREAT | O_EXCL,并提供创建权限参数,让存在性检查与创建由同一次操作完成。 - 需要检查已打开对象的属性:使用
fstat(fd, ...)检查文件描述符对应对象的类型、所有者等是否符合业务要求,再执行写入。如果验证前不能破坏内容,就不要在首次打开时使用O_TRUNC;需要清空时,在验证通过后调用ftruncate()。
这些标志各自解决对应的约束,不能单独保证文件可信。O_EXCL 应与 O_CREAT 配合使用,不能用来表示“只打开现有文件”。相关语义见 POSIX open() 手册。
如果安全策略要求操作受普通调用用户的权限限制,还需要正确设计打开操作执行时的凭据。先调用 access(),再用更高权限打开,不能替代这一要求。
什么时候可以保留存在性检查
当检查只是提示、候选路径探测或允许过期的状态查询时,可以保留 access(F_OK)。后续操作仍应独立处理错误,不能依赖检查结果证明对象身份或授权。
如果 pick_file() 的用途是让程序以后反复操作同一个文件,应尽量返回并保留已打开的句柄。若业务必须每次重新打开路径,就要接受对象可能变化,并在每次打开时重新落实所需的路径限制、权限和对象验证。
备注:内容仅供参考。
