1. tmpfile()到底干了什么:从机制上理解这个坑
1.1 tmpfile()的工作流程与安全假设
PHP的tmpfile()函数是一个很常用的临时文件生成工具,它做的事情是:在系统临时目录下创建一个唯一的临时文件,返回一个文件句柄,并且当这个句柄被关闭或者脚本执行结束时,文件会自动删除。看似很方便,很多开发者处理上传、缓存、导出数据时会直接用它。
但这里藏着一个容易被人忽略的安全假设:tmpfile()创建文件时使用的是系统默认临时目录(在Linux下通常是/tmp),文件名通过随机算法生成,函数本身确实做了随机化处理,不同PHP版本使用的随机源和强度不一致,但重点不在文件名强度上,而在于另外两个问题。
第一个问题是,tmpfile()不会帮你校验临时目录的安全性。/tmp目录是个全局可写的目录,任何用户都能在这个目录里创建文件,这就意味着攻击者可以提前在/tmp下做手脚。第二个问题是,你拿到的是文件句柄(resource),而不是文件路径。很多人以为拿到句柄就安全了,却不知道文件句柄和文件路径之间存在一个可以被利用的时间窗口。
这个函数的安全模型是:随机文件名足够强,攻击者无法预测文件名,因此无法提前对文件进行操作。这个模型在理论上是成立的,但在实际部署环境中会因为文件系统行为和目录权限设置被打破。当/tmp目录没有开启粘滞位(sticky bit),或者攻击者能通过其他途径预测文件名的生成规律时,这个安全假设就会崩塌。
1.2 竞争条件为什么会发生
竞争条件(Race Condition)在并发场景下才会出现,但PHP脚本本身常常被认为是单线程的,很多人因此忽视了它。实际上PHP-FPM是并发处理请求的,多个请求可以同时执行到同一段代码。即便不考虑PHP并发,攻击者也可以通过另一段独立代码(比如命令行脚本、其他语言写的守护进程)来与目标PHP脚本并行执行,竞争窗口依然存在。
tmpfile()和fopen()这类函数遵循的操作流程是:生成随机文件名 -> 创建文件 -> 返回句柄。这个三步操作之间并不是原子的。攻击者的核心做法是:在第一步和第二步之间插入自己的操作,提前创建一个同名的符号链接(symlink),把目标路径指向攻击者控制的文件内容。
一旦tmpfile()在第二步尝试创建文件时,发现目标路径已经存在,它会如何处理?这取决于PHP的底层实现和系统调用方式。在绝大多数Linux发行版上,tmpfile()底层使用open(O_CREAT | O_EXCL)的方式来创建文件,这个标志位要求文件必须是不存在的,如果存在则创建失败。但有些异常情况下,如果文件创建成功后、还没写入内容的时候,你获得了句柄,攻击者完全无法操作这个句柄,因为句柄对应的inode已经固定了。
所以真正危险的场景不是tmpfile()自己创建的文件被替换,而是:攻击者通过符号链接预测文件名,让tmpfile()创建失败或者创建到攻击者指定的位置。还有一种情况更容易被忽略,就是PHP配置里改了sys_temp_dir,指向了一个不安全的目录,导致tmpfile()的行为发生变化,这里面的细节我在后面的修复方案里会展开讲。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 攻击路径复现:临时文件被替换的完整推演
2.1 攻击第一步:预创建符号链接
我来演示一个实际可运行的攻击验证流程。假设目标系统上运行着一段使用tmpfile()处理用户上传数据的PHP代码,攻击者的目标是让PHP脚本读取到的内容来自攻击者伪造的文件,而不是真正的临时文件。
攻击者的第一步是创建大量符号链接,指向自己控制的文件。这里的难点在于文件名是随机生成的,攻击者并不知道tmpfile()会产生什么文件名。但有几种情况会让文件名变得可预测:一是PHP版本偏老,随机数生成强度不足;二是系统熵源不足,导致随机数重复;三是某些PHP框架或开发者在tmpfile()外层包了一层自定义封装,改变了文件名的生成规则。
实际的攻击脚本通常长这样:
bash复制#!/bin/bash
# 预先创建符号链接,指向攻击者控制的伪造文件
for i in $(seq 1 10000); do
ln -s /tmp/attacker_controlled_content /tmp/phpXXXXXX$i 2>/dev/null
done
这段bash脚本的做法是,在/tmp目录下批量创建大量的符号链接。当PHP的tmpfile()尝试创建一个恰好落在这个文件名的文件时,由于路径已经存在(符号链接也属于已存在的路径),创建动作会被拒绝,此时PHP会产生一个Warning,tmpfile()返回false,脚本逻辑可能走向攻击者期望的异常分支。
如果你觉得这个攻击方式过于依赖运气,那换个场景就清晰了:当开发者使用tmpfile()生成一个文件,然后又把文件路径传给了另一个函数进行处理,比如在Windows环境下遇到需要传递路径的情
