PHP 靶场复现系列(三):拼错一个字母的过滤,和从未定义过的 UC 密钥

2026-10-23

PHP 靶场复现系列(三):拼错一个字母的过滤,和从未定义过的 UC 密钥

系列第三篇,换上 BlueCMS v1.6(安装包里称 XXCMS),一套地方分类信息站,GBK 编码,跑在 PHP 5.6 上。三套靶场里它的防御基线其实最高:全站输入统一过一遍转义过滤,后台该有的功能也齐全。但它输得最典型——全局过滤的入口文件里,$_COOKIE 被误写成了 $_COOKIES,整条 Cookie 通道从未被过滤过;而 UCenter 接口的密钥 UC_KEY 在出厂配置里压根没有定义,退化成一个任何人都能算出来的固定值。27 个核查条目,20 项成立,危害最高的那条链不需要任何前置条件。本文所有操作都在本地隔离靶场完成,不涉及任何真实系统。

为什么做这个

还是系列的老规矩:源码审计列点位、逐项复现定性,判据以服务端回显和数据库状态为准,结论带形态——成立、受限、不成立之外,这一篇还要多分一类"不可达":代码层风险属实,但被部署形态、数据集或配置状态挡住,三者的处置优先级完全不同,后文单讲。

这一篇的素材还有个特点:条目里带 CVE 编号的不在少数(这套系统在公开漏洞库里挂了名)。复现时对这些编号一视同仁、逐项回源核对——结果有一个公开 CVE 与本副本根本对不上。情报是线索不是结论,这条也写进正文。

篇幅口径先说清:本轮共核查 27 个条目(原清单 13 条 + 交叉补充 12 项,含一组四子项),另有 5 个审计点位因时间与口径取舍未单独复现(连接器未授权开放、发布路径的黑名单绕过、宽字节组其余子点位、变量覆盖疑似、低危信息泄露),如实标注,不冒充全集。

工具环境

工具 用途 版本/说明
BlueCMS v1.6 被测系统 包内称 XXCMS;GBK 编码站点,数据库 29 张表
PHP 内置服务器 运行时 PHP 5.6.40——SAPI 形态后来成了两项"不可达"的决定因素
MySQL 数据库 5.7.44,容器部署,仅本地访问;连接字符集 GBK
curl / Python requests 发包与取证 验证码改用"读会话文件"法获取(见踩坑)
sqlmap 交叉复核 1.7.2,对广告脚本注入点做过独立确认
源码审计 点位定位 本地 grep 全树审计 + 公开 CVE 逐项回源核对

先看总账:20 项成立,三类"不可达"

结果 条目 说明
成立 20 去重后计数(一组为同一漏洞的两个编号,另一组为一项拆出的两个实体)
注入点存在、利用链不成立 1 目标数据表在本数据集缺失
代码成立、本部署不可达 2 PHP 内置服务器的 SAPI 限制,换服务器即活
当前状态不可达 1 需先完成正规配置才激活
不成立 3 含 1 个与本副本对不上的公开 CVE
合计 27 另有 5 个审计点位未单独复现

成立项里,除了下文深挖的几个,还有一批速览:广告脚本的数字型注入免登录可 UNION 回显,sqlmap 独立复核通过(回显里读出数据库版本、库名与首个用户名);留言页的跳转参数未编码拼进 JS 字符串,反射 XSS 成立;留言内容只做换行处理就入库,存储型 XSS 成立;支付参数未做白名单校验可穿越包含任意目录下的 index.php,本地文件包含成立——包含后台与安装目录时是空页,那是目标脚本自己的守卫在静默退出,机制本身由自建探针目录的标记回显坐实(这个判读坑见踩坑)。

全站的防御基线先交代一句,后面每个点位都和它有关:入口文件对 $_GET、$_POST、$_REQUEST 统一做递归转义(deep_addslashes),唯独 Cookie 分支写错了变量名;查询出错时应用会回显完整 SQL;后台登录与前台快捷登录都没有验证码。

怎么做的

一个字母之差:$_COOKIES

全站唯一的过滤盲区,来自一行拼写错误。入口文件本想对 Cookie 也做同样的递归转义:

// include/common.inc.php
$_GET     = deep_addslashes($_GET);
$_POST    = deep_addslashes($_POST);
$_REQUEST = deep_addslashes($_REQUEST);
$_COOKIES = deep_addslashes($_COOKIE);   // 写错了变量名,Cookie 从未被过滤

多了一个 S,赋值落在一个不存在的变量上,$_COOKIE 全程零过滤。而登录态校验函数 check_cookie() 恰好把 Cookie 里的用户名字段直接拼进查询——一条 COUNT 查询验存在、一条查询验口令哈希。注入流程很直接:用户名位置塞一个引号,响应立刻报错并回显完整 SQL,证明未过滤到达查询;应用只回显 SQL 本身、不回显数据库错误详情,报错型提取没有外带通道,实际利用走时间盲注。

盲注有个短路细节:用户名不存在时,后续 AND 条件不执行,SLEEP 永远不触发——必须以真实存在的用户名为前缀构造,让第一条件为真:<真实用户名>' AND IF(条件,SLEEP(3),0)-- -,条件为真响应 3.03 秒、为假 0.03 秒,逐位读数据成立。这个点位有公开编号(CVE-2022-27962),但编号不重要,重要的是它的成因:防御不是没做,是做漏了一个通道。审计时核对全局过滤,不能只看"有没有",要逐个输入通道点名——GET、POST、REQUEST、COOKIE、请求头,一个都不能少。

从未定义过的密钥:UC_KEY

本篇危害最高的链。BlueCMS 的 UCenter 接口 api/uc.php 用 UC_KEY 给通信数据加解密鉴权。翻出厂配置文件 data/config.php 全文检索:UC 相关的定义出现 0 次——这个密钥从未被写入。PHP 5 里引用未定义常量不报错,它会退化成常量名本身的字符串,于是有效密钥就是 md5('UC_KEY'),任何一个拿到 BlueCMS 源码的人都能算出来。而接口的改密与同步登录两个开关都是开着的。

利用不需要任何会话:本地按源码的 authcode 算法构造请求(明文含动作、目标用户名、新口令与一小时内的时间戳),发给 api/uc.php,返回 1。验证只动自建测试号且全程闭环:改密后新口令可登录、旧口令失效,再用同法改回并复验恢复;免密登录则构造同步登录请求,服务器直接下发一整套可用的登录 Cookie(服务端只按用户名查库),携之访问会员页呈登录态。影响面是全站任意 ASCII 用户名的账号,包括管理员名下的会员号——接管不需要口令、不需要会话、不需要交互。

这个点位和第二篇 XinCMS 的 UC 问题正好是一对:那边是两处密钥不一致、认错钥匙打不开门;这边是钥匙根本不存在、门用的是出厂默认锁。UC 这条线在老系统里反复出事,不是巧合——它同时握着改密和登录两把总钥匙,配置又最容易在部署时被跳过。

GBK 宽字节:转义被字符集吃掉

搜索页的关键词是过了转义才进 LIKE 查询的,单看代码没毛病。问题在连接字符集:数据库连接设的是 GBK,提交 %bf%27 时,转义加上的反斜杠会被前面的字节"吃掉"组成一个宽字节汉字,引号就此逃逸——响应回显 Query error 与完整 SQL,宽字节注入成立(对应 CVE-2023-33734)。

这个点位的利用形态被查询结构卡住了:计数查询是 1 列、列表查询是 8 列,两条查询共用同一个条件变量,UNION 的列数无论取多少都必与其中一条冲突,回显路线走不通。改布尔盲注:借模板原生的 LIKE 结构构造真假两组关键词,结果集有无即判据;读数据位时,载荷后段的引号仍会被转义,比较值改用十六进制字面量(如 =0x72 对应字母 r),实测读出首个用户名的首字母。

前台快捷登录还有一个同原理的点位:登录没有验证码,用户名经同样的转义后在 GBK 连接下宽字节逃逸,拼进两条计数查询。第一轮用 AND 型载荷测时毫无延时,差点判不存在——是 MySQL 的短路求值在作怪(见踩坑);换 OR 型载荷后,两条查询在多行上逐行执行 SLEEP,总延时 21 秒量级,注入坐实。这个点位证的是注入存在,完整的免密登录链没有继续深挖——已经有 SUPP-01 那条更直接的接管链了,没必要在同一系统里重复证明同一件事。

头像上传:零校验直达 RCE

上传类的处理函数对文件不做任何扩展名或类型校验:拿到什么存什么,落盘名是时间戳拼一位随机数再加原始扩展名,目录对外可达。会员在改资料页传一个 PHP 探针文件,落盘后直接访问,命令以 Web 进程身份执行、回显正常,验证完立即删除。

三篇的上传点位正好是三种输法:第一篇只信浏览器自报的类型声明,第二篇白名单限住了扩展名、要靠包含绕行,这一篇干脆零校验。审计时问上传只需要三个问题:查不查、查声明还是查内容、落盘目录能不能执行——这套系统三个问题全军覆没。

一个编辑入口,三个问题

帖子的编辑处理(do_info_edit)是本篇前台的另一个重灾区,三个问题共用一个入口:

无归属校验。 查看版的编辑页会校验帖子是不是本人的,但真正落库的 POST 处理全程不校验归属——任意登录会员可以改任意用户的帖子,包括管理员发的。用甲账号改乙账号的帖子,页面返回"编辑信息成功",详情页出现甲写入的标记,验后把原帖全量恢复。

存储型 XSS。 同一字段两条写入路径过滤不一致:发布路径对正文做了 HTML 转义,编辑路径只做修剪和格式替换(空格变 &nbsp;、换行变 <br/>),不转义。经编辑路径存入的脚本在详情页裸输出。但第一发带空格的经典载荷被空格变换打散了,入库变形、页面不触发;改用无空格的 <svg/onload=...> 形态后,数据库里原样存储、详情页原样输出,触发成立。过滤不一致加上变换副作用,这个点位测了两轮才定案。

任意文件删除。 同一处理里,帖子图片字段被直接喂给 unlink 拼接站点根路径,字段完全可控。把值指向自建的探针文件提交,文件应声消失——站内任意文件删除成立。还要提醒这个处理的一个副作用:它会先删掉帖子的图片关联记录再按提交重建,空提交等于清空,测试时必须带齐原帖全部字段(这个坑的完整版见踩坑)。

二阶注入:存的时候没事,看的时候爆炸

发帖时有个联系电话字段,提交时过了 HTML 转义——但 PHP 5.6 的这个函数默认不转单引号,载荷原样入库。详情页有一处统计查询,会把帖子自己的这个字段从库里取出来再拼进 SQL,全程不再转义。存储点和触发点就这样被分开了。

实测把载荷帖发出去后,访问它的详情页:响应 45 秒(统计查询在十几行数据上逐行执行 SLEEP(3)),正常帖是 0.03 秒。二阶注入的审计难点在于,单看写入点会觉得"处理过了",单看查询点会觉得"数据是自己库里的"——必须把字段的完整生命周期连起来看:谁写入、存什么形态、谁再取出来拼。

后台组合链:读配置、写文件、种备份

后台的单点不少,串起来是一条完整的接管链。先是注入组:四个后台入口(广告、会员、文章、模型)参数裸拼,统一用时间盲注坐实;其中两个细节值得记——文章删除入口的注入藏在删除前的预查里(删除本身反而做了整数过滤),载荷用"0 前缀 + OR SLEEP"构造,既让预查全表逐行延时(24 秒量级),又保证随后的删除命中不存在的 id、不伤真实数据;附件删除入口同文件其他分支都用了整数过滤、唯独删除分支漏掉,构造 9 OR att_id=10 一条请求删掉两条自建附件行。后台写操作还普遍没有 CSRF 防护——上面这条删除就是用仅带管理员 Cookie 的裸 GET 完成的,按口径即构成 CSRF 证据。

然后是模板管理这条线:模板编辑的读取动作参数可穿越,直接读出数据库配置文件;写入动作参数只做修剪、不限扩展名,穿越两级把 PHP 探针写到站点根目录,匿名访问回显标记,写入与执行成立,验后即删。最后是备份线:备份导入会逐条执行备份文件里的 SQL——用模板写先把一份仿真实文件头的伪造备份"种"进备份目录,再触发导入,预置探针表的值被 SQL 从 0 改到 42,任意 SQL 执行成立;而备份文件本身按日期命名放在 Web 可达目录,无任何会话直接下载,字节数与源文件全等,整库备份等于公开。读配置定目标、写文件拿执行、种备份执行 SQL、直链拖整库——后台这条链的每一步都有现成入口,缺的只是把它们连起来看的视角。

三类"不可达",三种完全不同的处置

这一篇有四项没有走到"成立",但它们的"不行"各不相同,混为一谈会误事:

部署形态型(2 项)。 取 IP 的函数用 getenv() 读 X-Forwarded-For 等请求头,返回值零过滤拼进多处写库语句——代码层注入面成立,同通道的存储型 XSS 落点(后台留言列表裸输出 IP 字段)也经源码确认。但实测时留言里的 IP 字段始终存的是 127.0.0.1:本部署用的是 PHP 内置服务器,这个 SAPI 下 getenv() 读不到请求头(而 $_SERVER 里明明有)。换成 Apache 或 php-fpm,这两条链立即复活。处置:按成立修,只是利用验证要换环境。

数据集型(1 项)。 一个删除入口的参数未过滤进 SQL、后面还跟着文件删除——报错回显证明注入确实到达查询。但它要删的那张表在本数据集里根本不存在(只有两个近名表),查询必然报错、脚本被错误处理中止,后面的文件删除永远执行不到。处置:点位记录在案,换一份数据集就要重测;它要证明的任意文件删除能力,已由编辑入口那条实证,不依赖它。

状态前置型(1 项)。 后台的 UC 配置保存会把提交值拼进 PHP 配置文件的定义行,值里的引号可以打破字符串边界注入任意代码,配置文件又被全站包含——机制是实打实的写配置 RCE 面。但它靠正则替换已有的定义行来写回,而本副本的配置里 UC 定义行是 0 条(呼应前文:UC 从未配置过),替换永不命中,文件被原样写回。需先经正规流程完成一次 UC 配置、生成定义行,这个面才被激活。处置:当前不可达,但任何一次正规配置都会把它点亮,记录必须带上激活条件。

另外还有三项彻底不成立的:一是公开 CVE 称某后台页面的删除点可注入,逐行核对本副本源码,该参数在文件开头就被整数过滤、删除用的也是过滤后的值,行号与形态都对不上——公开情报与副本不符,以源码为准;二是搜索结果的关键词高亮替换机制上能嵌 XSS,但前提是载荷完整出现在某条帖子标题原文里,现有标题不含、自行发帖的标题又被过滤,条件凑不齐;三是后台带 login 动作参数访问各页确实能让鉴权分支短路,但各页没有对应的处理分支、落点是空响应,短路成立、利用不成立。

踩过的坑

1. 图形验证码过不去,改读会话文件。 会员与留言的验证码无法自动识别。解法是同会话先请求验证码生成脚本,再到服务器上读这个会话对应的会话文件,正则提取里面的验证码值再提交——验证码本来就存在服务端会话里,绕开识别环节,全程稳定可用。本轮所有登录态验证都靠它。

2. MySQL 的 AND 短路,两次差点误判。 Cookie 注入里用户名不存在时后段条件根本不执行,延时载荷毫无反应;登录宽字节的第一轮 AND 型载荷同样无声无息。一度以为点位不存在,换成"真值前缀 + AND SLEEP"或"OR SLEEP"形态后立刻坐实。时间盲注的载荷形态要跟着查询的短路语义走,并用报错回显做交叉判断,别单靠延时一锤定音。

3. UNION 列数死结。 搜索宽字节的计数查询 1 列、列表查询 8 列共用同一条件,UNION 列数怎么取都冲突。死磕了几轮才放弃回显路线,改布尔盲注加十六进制字面量比较。两条查询共用条件变量时,先把列数账算清再选利用形态。

4. UC 改密的"假成功"。 改密接口返回 1,第一反应是成了——但接口不校验更新影响行数;而且中文用户名在库里以 UTF-8 字节存储,与 GBK 连接下的字面量比较对不上,更新可能命中 0 行。判定改密是否生效,唯一标准是新口令能否实际登录,返回值只作参考。

5. 编辑入口的必填与副作用。 编辑帖子的处理会先删掉帖子的图片关联记录、再按提交内容重建,字段带不齐会被必填校验拦下,带齐了也要小心:空提交等于清空关联。测试后逐项核对并从初始备份补回被删的关联行,才算收尾干净。 破坏性的编辑类入口做验证前,先摸清它的写入顺序。

6. LFI 的空页不等于没包含。 支付包含点测后台与安装目录时返回空页,一度以为包含没发生。实际是应用关掉了警告输出、目标脚本又自带守卫静默退出。用自建探针目录(里面放个只输出标记的 index.php)一测,标记正常回显——包含机制成立与目标脚本无输出是两回事,要用可控目标把它们分开判读。

注意事项

  • 中文字段(用户名、标题等)提交时须按 GBK 编码字节发送;库内中文以 UTF-8 字节存储,普通中文关键词搜索直接报 Query error 是环境特性、不是注入信号,别误判
  • 删除类验证只打自建探针(文件、附件行、备份文件、探针表);时间盲注用"不存在 id + OR SLEEP"或"真值前缀 + AND SLEEP"构造,避免真删真改,事后按行数核对复原
  • 备份类判据用"无会话下载与源文件字节全等",备份内容不外传、不落公开文档
  • 一切带环境依赖的结论(SAPI、字符集、数据集)都要标注成立形态,换环境先重测再引用
  • UC 类验证只动自建账号,改密后必须改回并复验,管理员与种子账号只做只读核查

和另外两个靶场比

三套系统放在一起,几条线正好成组。UC 这条线:第一篇没有 UCenter,第二篇两处密钥不一致,第三篇干脆从未定义、退化成默认锁——同一个组件三种坏法,配置核查的问法也应该是三个:有没有、一致吗、是不是默认值。

过滤的坏法也在递进:第一篇几乎不过滤,坏在明处;第二篇后台只查 Cookie 是否存在,坏在语义;这一篇全站过滤做了、做对了三个通道,坏在一个字母的拼写。防御失效从"没做"到"做错"再到"做漏",审计的颗粒度必须跟着细下去。

上传则是三种形态的标本:只信声明、限扩展名靠包含绕行、零校验直落可执行目录——同一类问题,利用链长度完全不同。

复盘

第一,全局过滤要逐通道点名。GET、POST、REQUEST、COOKIE、请求头,五个通道逐个验证过滤是否真的生效,"全站统一过滤"这种覆盖面叙事最容易掩盖单通道漏网——这次漏的恰好是承载登录态的 Cookie 通道。

第二,"未定义"比"弱口令"更隐蔽。弱口令至少是个值、扫得出来;未定义常量在 PHP 5 里不报错、退化成固定字符串,配置审计只查"值强不强"会整项漏过。凡是参与鉴权的配置项,先查它存不存在,再查它是什么。

第三,"不可达"要分型记录。部署形态型换环境即活、数据集型换数据即活、状态前置型做一次正规配置即活——三类的复测条件和处置优先级完全不同。笼统写一句"利用未成功",等于把三颗雷混成一颗留给下一个人。

总结

  • 过滤坏在一个字母:$_COOKIES 拼写错误让 Cookie 通道零过滤,全站基线被单通道漏网击穿——全局过滤逐通道点名核对
  • 出厂未定义的 UC_KEY 退化成公开可算的固定值,未授权改密与免密登录无前置条件,是本篇最高危链
  • 宽字节与二阶注入都赢在生命周期:转义要连字符集一起看,字段要连"写入—存储—再取用"一起看
  • 后台是一条完整链:模板读配置、穿越写文件、备份种 SQL、直链拖整库,加上注入组与无 CSRF 的写操作
  • 定性带形态:三类不可达分别标注激活条件;公开 CVE 回源核对,对不上即弃

以上,全文完。

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

← 返回文章列表