资讯动态

tmpfile() 的 TOCTOU 陷阱:临时文件竞争条件与私有目录修复

发布时间:2026/9/26 14:24:36 来源:尧图企业网站定制
很多 PHPer 提到tmpfile()时都有一个共同的印象这是 PHP 提供的、相对安全的临时文件函数。因为文档明明白白写着“创建一个具有唯一文件名的临时文件并在关闭或脚本结束后自动删除”而且权限默认还是 0600除了自己谁也读不到。我一开始也这么认为直到有一次在生产环境排查一个诡异问题——明明是刚生成的临时文件业务代码按路径读取时内容却完全不对后来把文件对象和路径拆开比对才意识到问题的真相tmpfile()创建文件的那一刻确实是安全的但“到创建为止”的安全不等于“到使用为止”的安全。这个问题的本质是经典的 TOCTOU 竞争条件。CN 圈子里讨论临时文件安全时绝大多数资料都在讲创建时的符号链接攻击很少有人把“文件创建成功后、业务使用前”这段窗口单独拿出来讲。但真实踩坑场景里替换往往不是发生在创建瞬间而是发生在创建之后的使用窗口。这篇就把tmpfile()的竞争条件从头到尾拆一遍讲清楚为什么一个返回句柄、自动删除、权限 0600 的函数会在生产环境里被人从路径上摘了桃子以及怎么改才算真正安全。1. tmpfile() 的安全光环为何一碰路径就碎1.1 tmpfile 的设计本意把文件名藏起来先看它的底层行为。PHP 的tmpfile()在 Linux 上走的是php_open_temporary_file()逻辑可以简化成循环生成一个以php开头的随机文件名然后以O_CREAT | O_EXCL | O_RDWR的方式尝试创建。O_EXCL这个 flag 是关键它要求内核保证“如果文件已经存在这次创建直接失败”从内核层面杜绝了大多数一上来就搞符号链接的常规攻击。创建出来的文件权限通常是 0600在共享目录里别人连读都读不到更不要说改。这也是很多人信任它的原因。注意tmpfile()的返回值是一个文件句柄resource不是路径。它默认并不主动把路径拿给你语法上最简单、最常见的用法是直接拿着句柄做fwrite、fread、fseek这些操作文件完全存在于一个“名字被藏起来”的黑盒里。关闭句柄或脚本结束内核会自动把这条路径 unlink 掉也不给你留垃圾。这套设计在“只用句柄、不接触路径”的代码里确实是稳的。1.2 被所有人忽略的模型缺陷但是这个设计的模型缺陷藏得很深所谓的“匿名性”只对懒人有效对路径本身来说没有一秒钟是秘密。文件创建之后它的名字会真实存在于临时目录中任何能列出目录的人、任何能用 inotify 监控目录的人都能知道它叫什么、什么时候出现。说得更直白一点tmpfile()不给你路径不等于攻击者拿不到路径。它把文件名藏起来了但文件本身还是躺在共享的临时目录里。Linux 的/tmp是全局可写目录每个用户都能往里丢文件、列目录、建 watch。只要攻击者愿意在窗口期内反复扫描目录或等着监听事件拿到“刚被创建出来、即将被业务代码使用”的临时文件路径并不是一件很难的事。问题的根子在于一个所有所谓“安全临时文件”函数都绕不开的经典术语TOCTOUTime of Check to Time of Use检查和使用之间存在的时间差。tmpfile的O_EXCL只能保证创建那一刻不踩别人地盘它保证不了创建之后文件内容不被替换、路径不被重定向。一旦你的业务代码把路径拿出去走了一圈就不会再读原来的文件。这不是 PHP 的 bug是“创建文件”和“使用文件”这两个动作天然存在的时间窗口。2. 抓窗口临时文件被替换的完整链路拆解2.1 文件名不是秘密可预测的命名与目录扫描攻击者首先要解决“临时文件叫什么”的问题。tmpfile()生成的文件名看起来是php加一长串随机字符好像很难猜。但如果你把思考坐标系放到攻击者角度会发现他们根本不需要提前算出名字只需要等着目录里出现新文件就行。在 Linux 上最通用的观察手段是 inotify。攻击者只需要在/tmp上注册一个监听监听IN_CREATE、IN_MOVED_TO、IN_OPEN这些事件一旦 PHP 进程调用tmpfile()创建了新文件事件立刻就被推送到攻击者进程文件名也跟着暴露。整个过程毫秒级完成甚至不需要持续轮询扫目录。如果你的应用是在共享主机、多租户环境、或者经常被反复入侵的低配加固环境里跑这个前提很容易满足。历史上还有个更恶毒的点老版本 PHP 生成的临时文件名随机性并不可观。看过 php-src 源码的应该有印象php_open_temporary_file()早期用时间戳配合内部随机函数拼后缀种子的随机强度远不如现代random_bytes这类加密安全随机源。只要能摸清楚 PHP 进程启动时间、请求频率的规律部分版本的文件名甚至是可预测的。现代版本随机源改强了但“可被 inotify 观察”这一点永远存在。2.2 替换瞬间发生什么路径与句柄的分裂拿到文件名之后替换窗口极短但攻击逻辑非常简单在业务代码读取之前使用rename()把原文件挪走或直接 unlink 掉原位然后立刻在原名位置创建攻击者自己的文件。这么做的时候PHP 进程手里原来的文件句柄并不会失效。这里必须把 Unix 文件模型讲透。tmpfile()创建的句柄本质上是打开了一个 inode句柄与 inode 建立了引用关系。当你用rename()或unlink()处理目录项时改的只是“目录项到 inode 的映射”。目录项是文件名可以随便换成别人句柄却始终指向最初那个 inode。于是同一个瞬间系统里出现两个“同名但不同物”的东西路径/tmp/phpABC已经是攻击者构造的新文件PHP 手里的句柄读到的仍然是原 inode 里的内容。对 PHP 业务代码来说绝大多数场景根本不会继续用句柄而是把从stream_get_meta_data()拿到的 uri也就是/tmp/phpABC这个路径丢给后续逻辑。这个动作等于把“读哪个文件”的决策权从文件句柄转交给了路径。路径被替换后续读到的自然就是攻击者内容。为了方便理解可以这么类比文件句柄是一张进房间的门禁卡门禁卡始终绑定真实房间路径则是贴在墙上的门牌号。攻击者负责把门牌号换到一间新房间门口门禁卡还认原来的房间但业务代码已经不看门禁卡了它只看门牌号——后面的事情就顺理成章了。2.3 CTF 与真实业务里这条链路都以什么形态出现在 CTF 赛题里大家应该见过一类“临时文件包含”的题目尤其是 [极客大挑战 2019]php 这类经典题上传过程生成的临时文件生命周期极短题目要求猜上传临时文件路径并触发 include。原理其实就是这个竞争条件的一个变体程序在较劲“临时文件消失和被包含”之间的微小时间差。只不过赛题里窗口期被放大到秒级、还给了暴力遍历的提示真实业务里没有提示窗口也被压缩得更狠。真实业务中竞争条件接管的场景通常是这些批量导入模块把上传内容写入tmpfile()下一条代码立刻按 meta 里的 uri 做解析缓存系统用临时文件做原子写写完把临时文件路径发给 worker 进程继续处理或者框架的响应对象把临时文件流吐给客户端时持有的是保存在文件系统中的路径字符串。这些地方只要缺少对“路径归属”的保护攻击者很容易在中途把文件替换成任意内容。当然还有一个容易被忽略的点某些业务代码为了“防止磁盘垃圾”用完tmpfile()后会先fclose()句柄再继续基于之前的路径读文件。fclose()一旦执行内核会立刻 unlink 这个临时文件路径原地变空洞。此时攻击者只要随便往上放一个普通文件后续基于路径的读操作读到的一定是攻击者文件。这种“自己主动放弃句柄把自己变成纯路径依赖”的写法比竞争窗口本身威胁更大。3. 最容易踩雷的写法复盘都病在“路径依赖”3.1 把 uri 传给 include / require最危险的代码写法随手一抓一大把?php $tmp tmpfile(); fwrite($tmp, $userInput); $uri stream_get_meta_data($tmp)[uri]; include $uri;这段代码的意图可能是“把用户输入当作模板内容包含渲染”。如果攻击者在 include 执行前把$uri对应的文件替换成自己的 PHP payloadinclude 会直接把攻击内容当 PHP 执行轻则输出垃圾重则直接拿到权限。注意这里的危险并不仅仅来自竞态——如果业务本来就有把用户可控内容 include 的需求即使没有竞态也要重新审视设计。include 是执行代码的入口不是读取文本的通道。要做模板渲染应该用模板引擎去读字符串而不是把临时文件路径喂给 include。如果确实必须在同一进程内读句柄对应文件的内容Linux 上还有个技巧是/proc/self/fd/N给 fd 编号通过这个编号访问句柄引用的原 inode。即使外部路径已被替换/proc/self/fd/N仍能定位到最初的文件。但说实话这是剑走偏锋的玩法能不用就不用生产代码不要因为追求特殊流程而加入这类奇技。3.2 关闭句柄后继续用路径再来看一个常见的假清理?php $tmp tmpfile(); fwrite($tmp, $data); $uri stream_get_meta_data($tmp)[uri]; fclose($tmp); // 文件已被自动删除 // 之后某个函数拿着 $uri 继续做 file_get_contents / readfile $content file_get_contents($uri);fclose()之后临时文件被自动 unlink但这个 uri 字符串还在。这里的关键点是路径没有再绑定任何 inode它只是一个空壳。攻击者只要抢在file_get_contents()之前把同名文件创建出来读到的就是攻击者内容由于没有句柄要求路径必须在“某段时间内有效”攻击者甚至可以慢慢等、慢慢创建窗口被无限放大。写这种代码的人通常逻辑是tmpfile()应该已经把内容写到磁盘了所以我先关掉句柄释放资源然后基于路径继续处理。可他们忽略了一个事实tmpfile()的自动删除机制正是因为“文件生命周期绑定句柄”才成立的把句柄关了生命周期也随之结束。你要么完整持有句柄直到读完要么就换一种创建策略。3.3 跨进程传递路径或交给框架响应第三个典型场景是把临时文件路径交给另一个进程或框架层。比如PHP 进程 A 用tmpfile()写入内容然后把路径传给进程 B 去消费。这时候进程 A 的句柄对进程 B 毫无意义进程 B 只能靠路径找文件于是又回到路径信任问题。还有一种情况是把tmpfile()创建的文件路径丢给 Symfony 的StreamedResponse、或 Laravel 的 streamed download 这一类响应组件组件内部往往也是按路径去 fopen 和 fpassthru而不是直接使用原始句柄。这里我看到过不少人这样“变通”创建完tmpfile()后担心文件权限只有 0600、对方进程可能读不了于是 chmod 一下然后传路径。结果是权限开了路径暴露了句柄也没人用了一整条链路全部建立在脆弱的路径上差不多是把tmpfile()最核心的安全机制亲手拆掉。也许你会觉得 chmod 到 0644 无害但在攻击者眼里这是把门牌号从一个隐蔽胡同搬到了大马路正中间。3.4 常见临时文件 API 的安全模型对比为了梳理这几种临时文件 API 的差异我做了一张简表。前提是 Linux 环境函数/方式返回形式自动删除路径是否暴露主要风险tmpfile()文件句柄句柄关闭后自动删除默认不暴露但可通过元数据拿到路径被拿到后存在替换窗口tempnam()路径字符串不自动删除直接暴露文件创建后没人持有句柄路径可被替换unlink fopen 组合句柄但先删路径取决于实现路径已删重新创建同名路径可能被劫持新路径不再是原 inode私有目录 random_bytes xb句柄及受控路径需要自行清理路径存在但目录不可枚举/不可写风险最低唯一注意是别把路径泄露出去表格里最关键的分水岭不是“返回句柄还是返回路径”而是“路径是否落在其他用户也能观察、也能操作的地方”。tmpfile返回句柄看起来安全一旦路径被别人观察到、或自己主动把路径暴露出去它实质上就会退化成和tempnam一样的风险模型。4. 修复的正确姿势从“匿名文件”到“私有目录”4.1 原则一能用句柄绝不碰路径最省心的修复方式是从头到尾不把路径拿出来。整个文件生命周期内所有操作都基于句柄完成用fread、fwrite、fseek、rewind去读写不调用stream_get_meta_data()。这可能让人不理解tmpfile本来就能做到有什么好说的但实际问几个用过tmpfile的开发者就会发现很多人拿到句柄后的第一反应就是去拿 uri这是习惯性问题。之所以强调“绝不碰路径”是因为路径一旦离开函数作用域就会进入各种不可控的环节——日志打印、参数传递、错误信息展示、序列化保存都可能让路径被动泄露。而句柄本身是进程内的资源不进日志、不留痕。所以可以给自己立一条约定除非确有需求否则不要写stream_get_meta_data哪怕是用来 debug 也是一样加临时 debug 代码后容易忘了删最后变成无声的安全隐患。4.2 原则二必须暴露路径时把整个临时空间私有化如果业务确实需要路径——比如跨进程消费、响应流下载——就不要死守tmpfile()了这时候应该换一种策略建立一个不会被其他用户观察和操作的“私有临时目录”再在这个目录里创建文件。目录创建后设置 0700并保证属主是当前 PHP 运行用户这样其他用户根本进不去目录也就不存在列目录、读文件、inotify 监控文件变化的可能。目录名也不能可预测。路径生成代码里可以这样处理基于sys_get_temp_dir()再拼接bin2hex(random_bytes(16))这样的强随机子目录。攻击者即使知道你的前缀是safe_tmp_也不可能遍历出 2 的 128 次方空间里的实际目录名。这一步相当于把“门牌号”放进了只有自己能进入的院落里。在私有目录内创建文件时仍要使用xb这个模式。fopen的x模式对应O_CREAT | O_EXCL和tmpfile()创建时的逻辑一致保证目录内的文件不会被预置的符号链接劫持。注意目录权限已经挡掉了绝大多数攻击者但O_EXCL作为第二道锁仍然值得保留这是一个安全设计应该有的纵深。4.3 一个可直接抄写的安全 TempFile 工具类下面给一个可以直接放进项目的工具类。功能等价于tmpfile()创建临时文件、返回句柄、可选择性返回路径析构或销毁时自动清理整个私有目录。?php final class SafeTempFile { private string $path; private $handle; public function __construct(string $prefix safe_) { $base sys_get_temp_dir(); if (!is_dir($base) || !is_writable($base)) { throw new RuntimeException(临时目录不可用: . $base); } $dir $base . DIRECTORY_SEPARATOR . $prefix . bin2hex(random_bytes(16)); if (!mkdir($dir, 0700) !is_dir($dir)) { throw new RuntimeException(无法创建私有临时目录); } $this-path $dir . DIRECTORY_SEPARATOR . data; $this-handle fopen($this-path, xb); if ($this-handle false) { $this-cleanupDir(); throw new RuntimeException(无法创建私有临时文件); } } public function stream() { return $this-handle; } public function path(): string { return $this-path; } public function __destruct() { if (is_resource($this-handle)) { fclose($this-handle); } $this-cleanupDir(); } private function cleanupDir(): void { if (is_file($this-path)) { unlink($this-path); } $dir dirname($this-path); if (is_dir($dir)) { rmdir($dir); } } }使用方式?php $safe new SafeTempFile(); fwrite($safe-stream(), $content); $path $safe-path(); // 只在必须暴露路径时调用 // 跨进程消费时传 $path自行保证消费完成后对象被释放这个类做了什么先在共享临时目录里创建一个 0700 的强随机子目录然后在子目录里用xb模式创建文件。外部进程进不了子目录路径无法被枚举、无法被替换进程结束后析构函数关闭句柄并递归清理目录。需要注意析构顺序先fclose句柄再 unlink 文件最后 rmdir 目录顺序反了会导致 Windows 上文件被占用删除失败。4.4 环境级防御与代码评审自检清单代码层面的修复已经基本封死这个漏洞但很多业务不是一个人能改完的环境层面也得加保险。open_basedir是一个可考虑的加固手段把 PHP 允许访问的目录白名单收紧临时目录如果不在白名单里file_get_contents、include 这些函数会直接拒绝访问攻击者想通过路径读取或写入会碰壁。但它对内存内的 include 不生效且可能误伤框架对/tmp的正常使用需要在测试环境充分回归。系统层面/tmp的 sticky bit 是 Linux 默认配置它可以防止用户删除不属于自己的文件但防不住“创建新同名文件”和“rename 自己可控文件过去”这类操作。因此 sticky bit 不能作为防御方案只能作为底线。有条件的话给 FPM 进程配置 systemd PrivateTmp或直接使用独立容器运行每个服务拿到彼此隔离的/tmp跨用户攻击面会小很多。最后整理一份代码评审自检清单我每次 code review 都会带这几个问题这个临时文件的路径字符串是否出现在日志、参数、序列化数据里创建时是否使用了O_EXCL/x模式目录是否是 0700 且属主正确删除临时文件时是基于句柄操作还是基于外部可控路径 unlink是否存在“关闭句柄后继续用路径读文件”的代码临时文件内容如果会被 include它的来源是否可控include 的语义是否真的业务必需这五条全部回答通过临时文件的信任边界基本就稳了。5. 延伸思考临时文件竞争不是 PHP 的问题是信任边界的问题5.1 不同语言同样中招见过一些朋友把这个锅完全甩给 PHP说“我们改用 Go/Python 就不会有这种事”。这个观点值得聊两句。Go 的os.CreateTemp和 Python 的tempfile.NamedTemporaryFile设计得很接近tmpfile()随机名、默认私有权限、共享目录。它们在“创建文件”这一步都做了正确的事但只要代码从 API 里把Name()或name属性拿出来再按路径做后续操作同样的 TOCTOU 问题就会原封不动地出现。所以临时文件安全的核心不是语言而是你写的代码是否在某个瞬间把“文件身份”从 inode 引用转变成了路径字符串。语言只是提供了好工具工具替你挡住第一个坑后面的坑都得靠写代码的人自己绕过。5.2 最小权限与目录所有权顺着这个思路再延伸一层凡是涉及共享目录的文件操作都应该默认套用最小权限模型。临时文件权限给 0600临时文件目录权限给 0700目录属主必须是当前服务账号。不要为了“方便 worker 读取”或“方便用户下载”而随意放宽权限。权限一旦放宽等于把门牌号张贴到公告栏接下来说什么都晚了。我在公司内部推动过几轮临时文件专项治理最常听到的理由永远是“这台机器只有我们自己在用”“临时文件存在时间很短不会被发现”。但从安全角度讲威胁模型里永远不应该假设“攻击者不在场”。共享临时目录里的 inotify 监控、频繁的目录扫描、定时任务里的清理脚本这些都不是罕见的极端情况。把安全寄托在“没人注意”上是最不牢靠的防线。5.3 如果业务里有大量临时文件最该先改什么最后的实际建议是如果你的项目里有大量临时文件读写逻辑先把涉及“路径跨进程传递”的场景全部找出来。这类场景是竞争条件的最优温床因为它天然破坏句柄的唯一所有权。具体来说优先排查这几类代码上传文件先落到tmpfile()再把 uri 传给队列 worker 或子进程导出功能生成 CSV/ZIP 临时文件交给下载响应组件时用的是路径缓存系统“写临时文件 rename 原子替换”的组合rename 源文件是否可被外部控制任何在fclose()之后继续引用$uri的代码全部重写。把这些场景统一替换成上面给的SafeTempFile或等价方案临时文件竞争这个主题里最危险的覆盖面就已经处理得差不多了。我在实际排查中还发现一个反直觉的现象出过问题的地方往往不是安全基础最差的团队反而是那些特别相信框架“自带防护”的团队。框架确实能在大多数场景替你兜底但像临时文件路径这种语义模糊的小东西框架的默认行为常常是帮你把路径暴露出去而不是帮你藏起来。所以不管用什么框架把临时文件这条链路拉出来做一次专项检查花不了多少时间收益却比想象中大得多。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑