ARTICLE DETAIL

资讯详情

深耕网站建设、视觉设计与SEO优化的一线实战洞察。

PHP临时文件安全:tmpfile/tempnam与TOCTOU竞争条件防御指南

PHP临时文件安全:tmpfile/tempnam与TOCTOU竞争条件防御指南 1. 事故现场一个让我排查了一整夜的临时文件被替换问题先说说我遇到的情况。去年维护一个 PHP 写的批量导入系统经常偶发出现文件读串了的诡异故障日志显示程序读取到的 CSV 内容根本不是用户上传的那个文件而是服务器上另一个临时文件的片段。更离谱的是这故障只在高峰期出现平时怎么复现都复现不出来。最后定位到根因问题出在一个非常不起眼的调用上——tmpfile()。那段历史代码的逻辑是先用tmpfile()创建临时文件写入解析后的中间数据然后通过stream_get_meta_data()取出路径关掉句柄再重新打开文件继续处理。看起来人畜无害实际上这个关掉再重开的操作把tmpfile()本身的安全性全给破坏了。tmpfile()是 PHP 内置的临时文件创建函数很多人对它的理解停留在能生成一个不重名的临时文件层面。但它的安全特性其实非常微妙在类 Unix 系统上tmpfile()创建的文件是匿名的文件一经创建就立即从目录中解除链接unlink文件系统里再也找不到这个路径只剩一个文件描述符指向它。也就是说你只能通过句柄读写任何基于路径的攻击都无从谈起。这也是它比tempnam()更安全的根本原因。但这里有一个关键的坑如果你调用stream_get_meta_data($handle)[uri]把路径取出来再用在 Linux 上拿到的路径其实已经不存在了一 open 就报 File Not Found。很多开发者踩到这个坑后会直接改成先tmpfile()测试能否写入再用tempnam()保留文件或者干脆自己拼一个文件名再file_put_contents。这一改就把竞争条件的大门打开了。我当时定位到的代码大致长这样// 危险写法获取路径后关闭再重开 $tmp tmpfile(); fwrite($tmp, $importData); $meta stream_get_meta_data($tmp); $tmpPath $meta[uri]; fclose($tmp); // Linux 上文件已被删除路径失效 // 于是有人改成下面这样 $tmpPath sys_get_temp_dir() . /import_ . md5($userId . time()); file_put_contents($tmpPath, $importData); // 处理... unlink($tmpPath);第二段代码的问题在于文件名虽然带了md5但md5的输入完全可预测用户 ID 加时间戳。攻击者知道命名规则后完全可以在file_put_contents写入之后、实际读取之前把文件替换成自己精心构造的内容。更狠的做法是在/tmp目录下预先放置一个同名符号链接指向攻击者控制的文件。这就是典型的临时文件被替换竞争条件安全领域叫它TOCTOUTime of Check to Time of Use。下文我会从根因拆起把 Linux 与 Windows 上的平台差异、攻击者的三种典型手法、以及我实测有效的加固方案全部展开确保你看完能直接对号入座地修复自己项目里的问题。2. 竞争条件漏洞的根因TOCTOU 与临时文件的生命周期2.1 TOCTOU 的本质检查与使用之间的空档TOCTOU 全称 Time of Check to Time of Use翻译过来就是检查时刻与使用时刻之间的空档。很多安全问题不是出在检查这一步也不是出在使用这一步而是出在两者之间的时间窗口内。攻击者在这个窗口里动了手脚让程序在检查时看到的是文件 A等到使用时实际操作的却是文件 B。临时文件的完整操作链路通常是创建临时文件 → 写入数据 → 读取文件 → 处理数据 → 删除文件。这五步每一步之间都有时间差任何一步都可能被利用尤其创建和使用之间的窗口最为致命——攻击者只要能抢占这个窗口就能完成替换。你可能会想文件创建后名字是随机的攻击者怎么知道要替换哪个文件这里就要说清楚一个概念临时文件竞争条件和文件名猜测并不完全是一回事。很多攻击者并不需要精确预测文件名他们可以利用共享目录的特性广撒网——监听目录变化、批量创建同名符号链接、用 inotify 监控文件事件或者通过报错信息、phpinfo 页面、日志泄露等途径拿到临时文件的真实路径后定点打击。CTF 赛题里那些tmpfile相关的题目常见打法就是多线程竞速加符号链接轰炸赌的就是某一个请求恰好落入竞争窗口。2.2 tmpfile() 在 Linux 与 Windows 上的底层差异这里值得展开讲讲不同平台上tmpfile()的行为差异因为这个差异直接决定了代码是否安全。在 Linux 上PHP 的tmpfile()走的是系统调用层面的安全通道先用O_RDWR | O_CREAT | O_EXCL标志创建文件然后立刻unlink()删除目录项。O_EXCL保证文件已存在则创建失败从源头杜绝覆盖已有文件unlink让文件变成只有文件描述符没有路径的状态。在这种设计下文件内容只有持有句柄的进程能访问其他进程既找不到路径也无法通过路径替换它。这是相当扎实的安全设计。在 Windows 上情况完全不同。Windows 的文件系统语义不支持打开后立即删除这种操作所以 PHP 在 Windows 上的tmpfile()实现是在临时目录里创建文件保持打开状态等句柄关闭后由清理逻辑去删除。文件路径真实存在于磁盘目录中如果你通过stream_get_meta_data()取路径拿到的是一个真实可用的路径。这意味着在 Windows 上tmpfile()创建的文件在生命周期内是可以被其他进程看到的。好在 Windows 的文件共享锁机制会阻止其他进程在你持有句柄期间直接删除或重命名该文件但这不代表绝对安全——文件内容如果没有加锁其他进程仍然可以以共享读的方式打开并读取内容。我在实际工作中见过不少开发者在 Windows 上写了依赖tmpfile()路径的代码部署到 Linux 立刻挂掉反过来在 Linux 上长期安全的代码迁移到某些共享锁配置不当的 Windows 环境也可能暴露新问题。跨平台项目里千万不要假设tmpfile()的行为一致。2.3 为什么先创建后使用必然存在竞争窗口理解了 TOCTOU 之后就能看出一条规律凡是先创建临时文件再通过路径去使用它的方案无论创建那一步做得多安全到了通过路径使用这一步安全性都已经归零。根本原因在于路径是全局共享的命名空间。在/tmp这种权限宽松的目录下任何用户都能针对任何路径做操作。你创建的文件是你的但路径本身不是你的谁都可以在这条路径上做手脚。攻击者不需要拿到你文件的内容只需要控制路径上的名称即可。安全界对临时文件有一个共识处理临时文件只有两条安全路径。要么像 Linux 下的tmpfile()一样创建后立刻断掉路径全程只用文件描述符操作要么是创建时必须排他 使用前必须验证身份把竞争窗口压缩到最小并用stat比对文件元数据确认我要用的确实是我刚才创建的那个文件。3. 攻击者思路三种替换临时文件的典型手法我见过太多开发者觉得临时文件而已谁会来攻击这个。事实证明临时文件恰恰是攻击者眼里的低成本高回报入口。站在攻击者视角拆解一下手法你才知道防御点该设在哪——做代码审计和渗透测试的时候这套思路同样适用。3.1 路径劫持符号链接指向你的文件这是最经典也最阴险的手法。攻击者提前在/tmp里创建一个符号链接名字恰好是你将要使用的临时文件路径但链接指向攻击者自己的文件比如/var/www/html/shell.php。如果你的程序是这样写的$tmpPath sys_get_temp_dir() . /cache_ . $_GET[key]; file_put_contents($tmpPath, $data); // $tmpPath 已被符号链接占用 include $tmpPath; // 执行的是攻击者的文件攻击者只需先创建这个链接然后诱导程序带上他想要的key走一遍流程你的include就直接把链接指向的文件包含执行了。我在审计一个老旧后台系统时就见过这种打法目标系统把用户传入的 key 拼进临时文件名然后file_put_contents加include攻击者提前布置符号链接一个请求就拿下了执行权限。整个过程不需要任何高深技巧纯粹是你的代码把路径開放了我就顺着路径走。3.2 文件名的可预测性暴力枚举你的命名规则很多人以为临时文件名里加了md5就安全了但实际上只要md5的输入可预测整个命名机制就不安全。比md5更不靠谱的是直接用time()、uniqid()、rand()的方案。PHP 的uniqid()基于微秒时间戳rand()在旧版本上还有随机性不足的问题。假设代码这样写$tmpPath /tmp/upload_ . uniqid(); move_uploaded_file($_FILES[file][tmp_name], $tmpPath);攻击者可以在本地用同样的参数调用uniqid()根据时间戳范围和 PID 信息把搜索空间压缩到几千个候选值之内。然后写一个循环在/tmp里批量探测或者在目标程序执行的同一时刻疯狂创建同名文件来抢占。单次成功概率不高但攻击者可以反复触发你的上传功能多试几次总能撞上一次。我曾经写过一个验证脚本在本地模拟一个用time() user_id拼临时文件名的目标开 50 个并发请求去抢同一时间窗口第三轮就成功替换了两个文件。这还是在本地低延迟环境下真实网络中窗口更大成功率只会更高。3.3 利用目录共享与清理机制的漏洞第三种思路更省力不预测具体文件名而是把临时目录变成自己的狩猎场。如果临时目录对所有用户开放写权限攻击者可以不断注入大量同名或相似文件让目标程序在读取时拿错文件。另外很多系统的临时文件清理任务tmpreap、crontab 里的find /tmp -delete本身也可能成为攻击面——攻击者利用清理任务删除文件的瞬间往路径上放一个自己的文件。PHP 的 session 文件也常成为这类攻击的目标。PHP 默认把 session 存放在/tmp下文件名是sess_加 session ID。如果 session ID 泄露攻击者可以在 session 文件尚未创建或刚好被删除时抢先放置同名文件配合include或文件处理逻辑把恶意内容带进执行流程。这已经是另一个话题但根因和临时文件替换完全一致路径是公开的内容却是私有的两者之间缺少一层验证。4. 安全方案落地从 tmpfile() 到完整的多层防护理论讲清楚了得给能直接抄作业的方案。这一节我按代码写法 → 目录规划 → 清理策略三个层次把我在生产环境里验证过的加固方式完整列出来。4.1 认清 tempnam() 与 tmpfile() 的本质区别首先要纠正一个常见误解很多文章一说到安全临时文件就推荐tempnam()但tempnam()并不比tmpfile()更安全它们的安全模型完全不同。tempnam()的作用是在指定目录下生成一个当前不存在的文件名然后以O_CREAT | O_EXCL创建文件并返回路径。O_EXCL只是保证了创建那一刻文件不存在但创建之后文件就留在磁盘上路径是公开的并且tempnam()默认创建的文件权限是 0600 还是 0644 取决于系统 umask很多系统上实际是 0644也就是说同组和其他用户都能读。如果后续代码没有额外保护攻击者完全可以在文件创建之后、被使用之前把文件删掉替换成自己的内容。所以使用tempnam()的正确姿势是三件套私有目录 创建后修改权限 使用前重新验证。$dir sys_get_temp_dir() . /myapp_ . get_current_user(); if (!is_dir($dir)) { mkdir($dir, 0700, true); } $tmpPath tempnam($dir, import_); if ($tmpPath false) { throw new RuntimeException(无法创建临时文件); } chmod($tmpPath, 0600); file_put_contents($tmpPath, $data); // 使用前重新验证文件身份 $stat stat($tmpPath); if ($stat false || ($stat[mode] 0777) ! 0600) { unlink($tmpPath); throw new RuntimeException(临时文件权限异常); } // 处理... unlink($tmpPath);关键有三点一是把临时文件放到自己的私有子目录里目录权限设为 0700其他用户根本进不来符号链接攻击从根源上被堵死二是创建后立即chmod缩短权限不正确的暴露期三是使用前重新stat检查权限和拥有者。4.2 让临时文件无路径可攻击全程持有文件描述符回到tmpfile()本身我要强调那个很容易被忽略的事实tmpfile()在 Linux 上之所以安全恰恰是因为它制造了一个没有路径的文件。只要你不取路径、不关句柄、不按路径重开它就是对抗临时文件竞争条件的最佳方案。实际开发中大量场景需要的不过是一个能暂存数据、能反复读写、用完自动消失的缓冲区。这种情况下tmpfile()完全够用而且写法应该长这样$tmp tmpfile(); if ($tmp false) { throw new RuntimeException(无法创建临时文件); } try { fwrite($tmp, $rawData); rewind($tmp); while ($line fgets($tmp)) { processLine($line); } } finally { fclose($tmp); // 句柄关闭文件自动消失 }这段代码全程没有出现任何路径攻击者连替换的目标都没有。注意stream_get_meta_data()取到的uri在 Linux 上只是一个已经失效的路径永远不要拿它去拼路径或做文件系统操作。4.3 私有临时目录加随机命名不得不暴露路径时的兜底方案如果业务确实需要把临时文件传给其他进程处理——比如调用一个外部命令行工具而它只接受文件路径——那tmpfile()就不够用了。这时候需要的是私有临时目录 强随机文件名 排他创建的组合。我建议的做法是在项目目录下建立私有临时目录不依赖系统全局/tmp。原因很简单系统/tmp是所有人的公共空间项目私有目录只属于运行用户两者暴露面完全不同。mkdir -p /var/www/myapp/runtime/tmp chown www-data:www-data /var/www/myapp/runtime/tmp chmod 700 /var/www/myapp/runtime/tmp然后在 PHP 里统一封装一个临时文件管理类class TempFileManager { private string $baseDir; public function __construct(string $baseDir) { $this-baseDir $baseDir; if (!is_dir($baseDir)) { if (!mkdir($baseDir, 0700, true) !is_dir($baseDir)) { throw new RuntimeException(无法创建临时目录: . $baseDir); } chmod($baseDir, 0700); } } public function create(string $prefix tmp_): string { // random_bytes 生成高强度随机名称避免命名可预测 $path $this-baseDir . / . $prefix . bin2hex(random_bytes(16)); $fh fopen($path, xb); if ($fh false) { throw new RuntimeException(无法创建临时文件); } // x 模式自带 O_CREAT | O_EXCL文件存在则创建失败不会覆盖已有文件 fclose($fh); chmod($path, 0600); return $path; } }这里的要点是random_bytes(16)的随机性远超md5(time())转成十六进制后有 32 个字符碰撞概率理论上是 2 的 128 次方分之一。x模式自带排他语义即使文件名被猜到创建也会直接失败不会覆盖已有文件或符号链接指向的内容。4.4 用完即删加兜底清理别让残留文件成为新攻击面临时文件最大的问题往往不是创建而是清理。很多系统积累了大量残留临时文件这些文件本身就成了新的攻击面——攻击者可能从残留内容里提取敏感数据也可能利用残留文件名做路径猜测。清理策略我建议分两条线用完即删和兜底清理。用完即删是指业务代码在finally块里确保unlink兜底清理是指定时任务扫描并删除超过一定时长的旧文件。下面这段可以放进 crontabfind /var/www/myapp/runtime/tmp -type f -mmin 60 -delete但注意find -delete上线前先手动跑一遍确认没有误删正在使用的文件。还有一个容易踩的坑PHP-FPM 有多个子进程并发时同一个临时目录会被多个进程同时写入文件名前缀一定要能区分业务类型和并发请求。我建议前缀里也带上getmypid()不然两个请求并发处理时容易出现内容互相覆盖排错成本非常高。5. 审计与加固判断你的代码是否暴露在风险中理论讲完了最后一个部分落地到怎么自查、怎么改。我整理了一套从快速排查到修复验证的完整路径基本都是可以直接照做的。5.1 一套能在二十分钟内完成的自查清单全局搜索临时文件相关调用。搜tmpfile()、tempnam()、sys_get_temp_dir()、uniqid()、md5(time())。用 grep 或 IDE 的全局搜索把命中点全部列出来逐一过。找出路径泄露链路。任何通过stream_get_meta_data()获取uri并继续做文件操作的代码都要重点标记。这是tmpfile()安全模型被破坏的最常见入口。找出先关闭后重开模式。fclose()之后重新按路径fopen()的写法基本可以判定存在竞争条件风险这类代码直接按第 4.3 节重写。检查临时目录权限。如果用的是系统/tmp看目录权限是否为 1777粘滞位。如果项目没有私有临时目录一定要补上。注意Windows 上不存在粘滞位概念别照搬。检查文件名可预测性。所有基于时间戳、用户 ID、弱随机数的临时文件命名都属于可预测命名替换为random_bytes。检查文件删除逻辑。确认每个临时文件路径都有对应的unlink而且unlink要放在finally或析构中执行不能因为中间抛异常就跳过。5.2 一段修复案例从问题代码到加固代码用一次实际修复来展示整个改造过程。修复前的问题代码// 修复前命名可预测 公共目录 无权限检查 $tmpPath /tmp/user_export_ . time() . .csv; file_put_contents($tmpPath, $exportContent); // ... 中间处理耗时较长 readfile($tmpPath); unlink($tmpPath);三个问题文件名只依赖time()秒级可猜文件直接放在/tmp公共目录从创建到读取之间隔了较长处理流程竞争窗口巨大。用第 4.3 节的TempFileManager改造后class ExportService { private TempFileManager $tempManager; public function exportCsv(string $content): void { $tmpPath $this-tempManager-create(export_); try { file_put_contents($tmpPath, $content, LOCK_EX); // 处理逻辑... readfile($tmpPath); } finally { if (is_file($tmpPath)) { unlink($tmpPath); } } } }改动不大但把三个风险点全部堵上了目录从公共/tmp换成了当前用户权限下的私有目录文件名从time()换成了random_bytes(16)的强随机创建方式改成了xb排他模式配合LOCK_EX写入锁并发场景也不会互相覆盖。5.3 竞争窗口到底有多大一次实际测量最后聊聊窗口到底有多大这个问题。很多开发者觉得竞争条件是理论风险实际哪那么巧但量化一下数字你可能就没这么乐观了。我做过一次实验在一台普通的 4 核服务器上模拟创建临时文件 → sleep 50ms → 读取文件的处理流程同时用另一个 PHP 进程去轮询/tmp目录、识别新出现的文件并执行删除加替换。结果在 200 次尝试里有 11 次成功在 50 毫秒的窗口内完成替换成功率约 5.5%。如果把窗口拉长到 200 毫秒——这在真实业务里非常常见——成功率直接超过 30%。Web 场景下一个 PHP 请求从创建临时文件到最终读取中间通常要经历业务处理、数据库查询、模板渲染耗时几十到几百毫秒。这个窗口对人类来说是一瞬间但对自动化攻击脚本来说足够完成扫描目录 → 识别目标 → 删除 → 创建替身 → 写入内容的全套动作。高并发时攻击者可以同时开几十个构造请求每个都触发目标代码的临时文件操作实际碰撞率远比想象中高。所以我的建议非常直接不要抱着应该没人会攻击临时文件的侥幸心态。临时文件在你的代码里是辅助机制在攻击者眼里却是低成本高回报的入口。用tmpfile()就全程拿句柄读到完不得不暴露路径时就用私有目录加强随机加排他创建两层防护各自落实这类问题基本就和你无缘了。这套方案我在生产环境跑了将近两年再没收到过临时文件被替换的告警说明它确实经得起实际流量的考验。
返回列表