PHP 靶场复现系列(二):一条利用链打穿 XinCMS——总钥匙、参数走私与 UC 命门

2026-10-23

PHP 靶场复现系列(二):一条利用链打穿 XinCMS——总钥匙、参数走私与 UC 命门

系列第一篇的 CyberWeekly,审计和复现之间差了三分之一。这一篇换上 XinCMS 0.1 加 Discuz! X1.5——一套 2010 年代的老组合,落在 PHP 5.6 上跑。结果走向另一个极端:主站 19 个点位,17 个成立、1 个受限成立、只有 1 个被证伪,后台门禁形同虚设,未授权上传、伪造 Cookie、文件包含三步就串出一条完整的 RCE 链。而捆在一起的论坛前台反而收得住,风险都挤到了 UC 接口与后台这条暗线上。这一篇讲两件事:链是怎么拼起来的,以及几个"差一步"的点位该怎么定性。本文所有操作都在本地隔离靶场完成,不涉及任何真实系统。

为什么做这个

还是系列的老规矩:源码审计列点位、逐项复现定性,判据以服务端回显和数据库状态为准,结论分成立、受限、不成立三档,破坏性验证全部预置恢复手段。

但这一篇的靶场和上一篇有个本质区别。CyberWeekly 是教学靶场,漏洞是"设计"出来的;XinCMS 和 Discuz! X1.5 是当年真实流传过的系统代码,漏洞是时代留下的——全局不过滤、鉴权靠自觉、编辑器连接器裸奔。审计这类老系统,单点漏洞不稀奇,稀奇的是它们之间的咬合:一个只看 Cookie 在不在的门禁,会把后面所有后台缺陷的门槛一起拉平。所以这一篇的写法也跟着变:利用链当主线,单点穿插,最后单独留一节给"成立但差一步"的定性问题。

工具环境

工具 用途 版本/说明
XinCMS 0.1 + Discuz! X1.5 被测系统 主站与论坛同机部署,UCenter 1.6
PHP 内置服务器 运行时 PHP 5.6——这个版本本身决定了部分利用形态(如 preg_replace /e)
MySQL 数据库 5.7,容器部署,仅本地访问
curl / Python requests 发包与取证 注入对照、延时测量、状态核查
sqlmap 备用复核 本篇以手工构造为主,sqlmap 只作备用
自研 authcode 脚本 UC 链构造 Discuz 的 authcode 加解密是私有格式,UC 接口调用需要自己实现

先看总账:主站全线失守,论坛风险在暗线

范围 点位 结果
XinCMS 主站 19 成立 17、受限成立 1、证伪 1
Discuz! X1.5(含 UC) 10 成立 6、不适用 1、配置风险 1、疑似 1、核查项 1
合计 29 —

主站成立的点位里,除了下文深挖的几个,还有一批速览:文章页 UNION 注入可完整读出账号哈希(15 列,标题是第 3 列回显位);搜索页关键词未编码原样回显,反射 XSS 成立;后台登录接口无验证码、无锁定、无限速,小字典 19 个候选就命中种子口令,而口令本身是无盐 MD5,弱口令拿到哈希离线即可秒破;整库的 SQL 导出文件(约 23.8 KB,含全部表结构与账号哈希)就躺在 Web 根目录下,谁都能下载;后台配置页的 .old 备份源码可直取;后台留言列表对库内留言不转义输出,存储型 XSS 的落点成立。

论坛侧的账目正好相反:发帖正文的脚本载荷被转义挡下(全局过滤是有效的),公开渠道流传的 faq 注入点在本副本根本不存在。真正成立的六项里,四项属于 UC 体系(三项接口链加 UCenter 后台登录),其余两项是创始人后台的跳板面与日志存储型 XSS。这条 UC 暗线是论坛部分的全部分量所在,后文单讲。

怎么做的

主站后台的全部鉴权,收敛在一行判断上:

// admin/admin.inc.php
if (!isset($_COOKIE['userid'])) { /* 跳转登录页 */ }

只查 Cookie 里有没有 userid 这个字段,不查值、不查会话、不查数据库。实测对照非常干净:不带 Cookie 访问后台页,302 跳登录页;带上 Cookie: userid=1——乃至任意值——直接 200,后台全部功能可用。

这行代码是整个主站的骨架:后面要讲的后台文件包含、后台注入群、Cookie 注入,全都以它为前置。它本身不是"漏洞利用",它是把其他所有后台问题的利用门槛统一降到零的那把总钥匙。审计老系统时,这种"门禁语义"比单个注入点更值得先确认:门禁是查存在、查值、还是查会话,决定了整套后台的实际暴露面。

顺带说,后台还有第二条正路可走:登录页的 username 是裸拼的,admin'-- - 注释掉后半段查询,任意口令即可登录(成功 302 并下发 Cookie,错误口令对照是 200)。它和总钥匙的区别只在于一个是伪造身份、一个是绕过校验——殊途同归。

这一篇的核心链条,三步都不是什么惊天漏洞,咬合起来就是未授权远程代码执行。

第一步,传文件。主站集成的 FCKeditor 连接器处于启用状态且不校验会话,未登录就能调用上传接口。它的文件类型白名单里有 txt、不含 php——单看这一步,最多传个文本文件,执行不了。

第二步,进后台。用总钥匙:任意 userid Cookie。

第三步,包含。后台有三处文件包含点(参数分别是 inc、file、include),都是把 GET 参数直接喂给 include。用第二步的身份访问其中一处,把路径指向第一步传上去的 txt:

admin/file.php?include=../data/attachment/file/chain.txt

chain.txt 里写的是 <?php echo "RCE-CHAIN-".(7*7); ?>。直接访问这个 txt,返回的是源码原文;经包含访问,返回 RCE-CHAIN-49——代码被当作 PHP 执行了。txt 不能执行的原因从来不是"txt 安全",而是没有入口去包含它;包含点一到位,扩展名白名单就绕过去了。

这条链的每一环单拎出来都只能算中危:未授权上传(但限类型)、鉴权缺失(但要先进后台)、文件包含(但在后台里)。老系统审计最容易低估的就是这种链:按单点评级逐个"中危"归档,拼起来却是未授权 RCE。评估时先画链、再评级,顺序别反。

总钥匙用的那个 userid Cookie,在改密流程里还有第二重身份:注入载体。改密前的旧口令校验函数把 Cookie 值直接拼进 SQL 的数值位:

$sql = "… where userid=" . $userid . " and password='" . md5($old) . "'";

用 /**/ 代替空格构造延时载荷(Cookie 值里塞空格有被截断的风险),userid=32/**/AND/**/SLEEP(3) 让响应从基线 0.01 秒变成 3.01 秒,注入坐实。再用布尔形态 userid=0 OR userid=32 配合正确旧口令,让校验命中目标行、把管理员口令改成自选值,新口令登录实证成功——验完立即用 SQL 把口令哈希恢复原值并复验。

一个字段,前门是它、后门也是它。这类"身份载体同时是输入点"的问题在审计时值得专门列一张表:所有既当凭证、又进查询的字段,逐个过一遍。

参数走私:把 ORDER BY 塞进关键词

主站有个解析习惯:文章列表函数把参数串先按 | 切开,再逐项按 = 拆成键值,后出现的键覆盖默认配置。排序字段 order 和 orderway 就在这些可覆盖键里,且未经过滤直拼进 ORDER BY。

也就是说,排序参数不需要出现在它"应该在"的位置——把 |order=... 塞进 list 页的 id 参数值、或搜索页的关键词里,就会被解析器当成配置项吃进去,完成走私。走私值有个硬约束:不能含有 =,因为拆分只取等号后的第一段;需要字符串比较时,用 char() 拼字符绕开引号转义。

这个点位最有意思的是它的证伪插曲。第一反应是把条件表达式直给排序字段:

order=(case when (条件) then id else hits end)

结果两个入口都返回空。翻回解析逻辑才明白:拼出来的语句是 order by a.(case ...),表别名直接跟括号,语法无效——不是注入不存在,是表达式形态不对。换成等价的算术形态:

order=id*(case when (substr((select password from cms_users limit 1),1,1)>char(N)) then 1 else 0 end)+hits

以管理员哈希首字符为锚点做真假对照:条件为真和为假时,结果集排序整体翻转,条件排序盲注成立。还有一个小陷阱:搜索入口的关键词段要传 % 做全匹配,传字面字符会被 like 'x%' 把结果过滤干净,看起来像"注入无效",其实是自己把数据关门外了。

直给形式被证伪、等价形式成立——这种点位如果只测第一种形态就会误判成不存在。审计给出的 sink 位置是对的,错的是利用形态;复现的价值有一半在这种地方。

验证码的 null 绕过

留言页的验证码校验用的是松散比较:

if ($_SESSION['cfmcode'] != $_POST['cfmcode']) { /* 拒绝 */ }

问题在于两边都可能为空:不请求验证码图片,session 里这个值就是 null;提交时干脆省略验证码字段,POST 里也是 null。null != null 为假,校验直接放行。实测全新会话、不取码、直接 POST 留言,提交成功且正常展示;对照组填错误验证码则被拒。

同页还有个弱验证码问题:4 位纯数字、字符集只有 2–9(共 4096 种)、随机数生成、校验后不失效可复用。两个问题叠加,这个验证码的实际防护约等于零。留言本身在本副本有个限制:留言走会话模拟存储、不落库,所以留言区那个未转义输出点只能定"受限成立"——sink 真实存在,跨用户持久化链条在本副本不成立。这类定性分寸,和上一篇竞态的"双句结论"是同一个原则:成立到哪一环,就写到哪一环。

时间盲注先学会计数

主站的时间盲注有个读数陷阱。列表页注入点在 cid in (...) 的括号内,闭合括号后接 SLEEP:注入点基线响应 0.01 秒,SLEEP(5) 打进去变成 60 秒。不是系统卡了,是延迟按命中行数叠加——SLEEP 在每一行上都执行一次,12 行就是 12 倍。用 SLEEP(0) 做对照组回到 0.01 秒,才确认延时来自注入而非网络。

后台注入群里也有同类问题:delete 前的查询要先用闭合 in() 的形态 999999) OR SLEEP(3)-- - 才有延时,直写 999999 OR SLEEP(3) 会被常量折叠优化掉、毫无反应,差点误判不存在。时间型判据的通用纪律:先数行数定阈值、永远带零延时对照组、延时形态不对先怀疑表达式而不是怀疑点位。

被证伪的白名单

审计阶段有个很自然的假设:后台上传的允许类型看起来是可传参控制的,如果属实,就能放行 php、直接拿下上传点。实测把这个假设打死了:上传函数内部是硬编码的图片白名单(bmp/gif/jpeg/jpg/png),扩展名转小写后比较,shell.php 和大小写变体 shell.PhP 都被拦截,合法 jpg 正常通过。

这个证伪反而让主站的利用链更清楚了:正因为后台上传这条路是死的,RCE 才必须走"FCKeditor 传 txt + 后台包含"的绕行。假设被证伪不是白测——它替你排除了最短路径,剩下的链才是真链。

论坛:前台收敛,命门在 UC

Discuz! X1.5 的前台是另一副面孔:全局输入过滤普遍生效,发帖正文的脚本载荷以转义形态呈现,公开流传的几个经典注入点在本副本要么不存在、要么被过滤拦下。唯一的前台例外是日志正文:发表日志时正文原样入库,查看页未转义输出,匿名访问也能触发——存储型 XSS 成立,但这是单点,不是主线。

主线在 UC。UCenter 是 Discuz 体系的单点登录中枢,各应用通过 api/uc.php 与它通信,唯一的门票是 UC_KEY。本实例有个关键事实:两处 UC_KEY 不一致——应用侧配置里的 key 和 UCenter 服务端配置里的 key 不是同一个(部署重装时应用侧被重新生成、服务端没同步)。api/uc.php 和数据库备份接口校验的都是应用侧 key;拿错 key 去调,只会得到"请求无效",很容易误判成接口不存在。

拿到正确的 key 之后,UC 链的三个动作逐个坐实:

改名接口的字符串逃逸。 renameuser 会把新旧用户名未转义地拼进约 40 张表的 UPDATE 里。用 authcode 把载荷包好发过去:良性改名先行验证(测试用户改名再改回,数据库落实);再把新用户名构造成带引号逃逸的形态,错误页回显了完整 SQL——注入的 email 赋值已经被解析成独立的 SET 项,逃逸实锤。但改邮箱这个具体目标没达成:循环的第一张表没有 email 列,数据库遇错即停,后面的会员表根本没执行到;另一侧参数的逃逸载荷则被 Discuz 自己的查询安全检查拦下。所以定性是:注入成立、任意改名可达,单次调用改邮箱受表结构与拦截限制。"差一步"要写清楚差在哪一步,而不是含糊地报成功或失败。

写配置入口。 updateapps 会把提交的应用信息数组以 var_export 的形式写进客户端缓存文件,并能回写应用的 UC_API 配置。实测提交带可控标记的应用信息后,缓存文件里出现标记、配置文件 md5 变化,证明回写链真实执行;验前先备份两个文件并记录 md5,验后恢复、md5 与改前一致。传恶意回调地址就是经典的"写配置→代码执行"前置,本轮只验证可控性,没有做 getshell 串联。

整库备份导出。 api/db 下的备份接口同样只认 UC_KEY:解码通过后,export 把全库结构和数据按 2000 KB 分卷写进 Web 可达目录。实测导出生成约 957 KB 的第一卷文件,跟随分页请求完成整库导出;import 分支只验证了入口可达(不存在的路径返回对应错误码),没有真导。生成的备份目录验后整目录删除。注意这个接口的参数串必须带时间戳且在一小时内,缺了它接口只回一句过期提示、不产生任何文件——第一次测的时候差点以为接口是假的。

还有个收尾细节:UCenter 后台登录本身有图形验证码,但验证码的校验值是以 md5(服务端侧那把 UC_KEY 加上浏览器标识与 IP)为钥的 authcode 密文——注意这里用的恰是服务端侧 key,与接口认的应用侧 key 不是一把;对持钥者来说,验证码可以离线解出,不构成屏障。创始人后台里数据库导出/恢复、模板编辑、插件导入三个已知的 getshell 跳板入口逐项确认可达(只确认、未执行)。整条 UC 暗线的结论收敛成一句话:这个版本里 UC_KEY 就是王,保管好它比修任何单点都重要;它一旦泄露,改名、写配置、整库导出、后台跳板会连成一片。

论坛账上还有两项只记账、不展开:安装目录残留但安装锁在位、重装被拦截,定性为配置风险;模板解析函数里留着 preg_replace 的 /e 修正符家族,PHP 5.6 下替换串会被当作代码求值,入口定位到了,但本轮只标疑似、未构造绕过——这也是工具环境里要单独标注 PHP 5.6 的原因。

踩过的坑

1. 改邮箱载荷失败,差点把注入点一起判死。 UC 改名接口的预制载荷没改动邮箱,第一反应是"注入不成立"。逐项拆解后定位到:循环首表没有 email 列,触发数据库错误后整个循环遇错即停,会员表没被执行。改以错误页回显的完整 SQL 作为逃逸证据,定性修正为"注入成立、改邮箱受限"。教训:链式利用失败时,先定位断在哪一环,再谈点位成不成立。

2. 验证脚本误删了种子数据。 后台留言 XSS 的验证脚本引号没处理好,插入失败的同时误删了一条种子留言。恢复靠的是一份"泄露文件":躺在 Web 根目录的整库导出 cms.sql 里留着种子行原文,按原文精确恢复并逐字段复核。教训有两层:动数据之前先保全(这次是运气好,靶场自己的缺陷文件救了场);验证脚本对种子数据要有护栏,宁可多写两行校验。

3. 排序注入的直给形式返回空。 前文已述:order by a.(case ...) 语法无效,换算术等价形式才通。这类"审计定位准、利用形态错"的假阴性,在参数解析奇葩的老系统里特别常见。

4. 搜索入口传字面词把自己关在门外。 走私验证时关键词传了普通字符,被 like 'x%' 过滤成空集,看起来像注入无效;改用 % 全匹配后对照才成立。做对照实验先确认基线数据真的在结果集里,再看载荷效果。

5. 拿错 UC_KEY,差点误判接口不存在。 先用 UCenter 服务端侧的 key 调 api/uc.php,只得到请求无效的回应。核对两侧配置才发现两处 key 不一致、接口认的是应用侧。老系统多端配置不一致是常态,接口"调不通"先查钥匙,别先怀疑接口。

注意事项

  • UC 链的所有调用一律用应用侧 UC_KEY;两处 key 的完整值只存本地证据,不写进任何公开文档
  • 主站各页面的报错抑制不一致:文章页、列表页可报错,搜索页、留言页全局关报错——选判据前先摸清每个入口的回显脾气,关报错的入口用时间、排序、状态差异定性
  • 时间型注入的延时按命中行数叠加,判读阈值先数行数,并常备零延时对照组
  • 破坏性验证只用不存在的 id 探针或自建数据;改密、改名、删数据类验证必须预置恢复手段,本轮的改密、改名、留言、帖子均已逐项恢复或清理并复核终态
  • 写配置、写缓存类验证(updateapps、备份导出)动手前先备份目标文件并记录 md5,验后恢复并校验一致

和另外两个靶场比

登录注入这一件事,三个系统的解法各不相同,根源在校验的位置。上一篇的 CyberWeekly,口令校验在应用层拿查询首行做明文比对,注释截断没用,必须 UNION 伪造一整行;这一篇的 XinCMS,口令经 md5 后拼进查询条件,注释把条件截断就够了。同一个漏洞类别,校验写在哪一层,决定了利用手法完全不同。

后台门禁的差距更直白:CyberWeekly 好歹查会话、个别功能还认会话里的角色字段,XinCMS 只查 Cookie 里有没有 userid 这个名字。下一篇的 BBS(BlueCMS)又是另一种画风:注入点藏进 Cookie 和 GBK 宽字节里,后台模板编辑能直接往下写 PHP 文件——老系统各有各的塌法。

复盘

第一,先画链再评级。这一篇里最危险的不是任何单点,而是"未授权上传(限类型)+ 鉴权缺失 + 后台包含"这条三环链。按单点逐个评级归档,会系统性地低估老系统的真实风险;审计报告里应该有一张链路图,评级跟着链走。

第二,身份载体就是攻击面。userid Cookie 既是门禁凭证、又是注入载体;UC_KEY 既是接口门票、又是验证码的加密钥匙。审计时把所有"凭证类"的字段和密钥单独拉一张清单逐项过:它被校验吗、它进查询吗、它能推出别的东西吗——三个问题问完,暗线基本就浮出来了。

第三,定性写到环为止。改邮箱差在首表没有 email 列、留言 XSS 差在留言不入库、上传放行 php 的假设被硬编码白名单证伪——把"差在哪一环"写清楚,结论才可复用:换一个副本、换一处部署,差的那一环可能就不存在了。只写成立或不成立,下次遇到同类系统还得从头再测一遍。

总结

  • 门禁语义决定暴露面:只查 Cookie 是否存在,等于后台没有门——先确认门禁查的是存在、值还是会话
  • 链大于点:三个中危咬合出未授权 RCE,老系统评估先画链、再评级
  • 身份载体攻防一体:当凭证用的字段和密钥(userid Cookie、UC_KEY)往往同时是注入点和推导其他秘密的钥匙
  • 定性要有分寸:成立、受限、证伪逐项写明卡点,链式利用失败先定位断裂环,再下结论
  • 老系统的防线收敛在少数关键点上:会话校验做实、密钥统一保管、写操作留备份——比逐个补注入点更优先

以上,全文完。

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

← 返回文章列表