代码审计:多米 DuomiCms 全局过滤规则绕过
背景
在看 CNVD 漏洞库的时候发现有师傅发了某 CMS 前台 SQL 注入漏洞,通过查阅漏洞描述可以知道存在问题的参数是 cardpwd,便开始尝试对该版本的 CMS 进行审计。

审计过程
定位 cardpwd
下载好源码后在本地部署,然后使用 Seay 源代码审计系统加载源码文件,查找关键字 cardpwd,定位到 member/mypay.php——该参数以 POST 方式接收。

进入 member/mypay.php(26 行和 38 行),在接收 cardpwd 之前还需要登录,并且满足 $dm=='mypay'。继续跟进 $dm,在 duomiphp/common.php(52-55 行)发现它接收了 GET、POST 以及 COOKIE 中的参数和值,并创建相应变量——这里其实还潜藏变量覆盖的问题,本文暂不展开。

访问这个功能需要满足两个条件:注册并登录;在 GET、POST 或 COOKIE 中提交 dm=mypay。
第一层正则过滤
注册会员后访问 /member/mypay.php,简单输入 1,2 进行请求,确定可以提交 cardpwd。继续阅读 member/mypay.php 43-63 行对 cardkey、cardpwd 的处理,使用了正则对数据进行检测:
[^0-9a-z@\._-]{1,}(union|sleep|benchmark|load_file|outfile)[^0-9a-z@\.-]{1,}
分析并对这个正则测试,发现使用 /*!50000 xxxx*/ 便可以绕过——这是 MySQL 的版本注释语法,版本 ≥ 5.00.00 时注释内的 SQL 会被执行,既绕过基于 /* 出现即拦截的简单正则,又让关键字 union 等被 MySQL 解析。但这只过了 mypay.php 这一层,后面还有 CheckSql 在等着。

GetOne 与 Execute
本以为过滤规则如此简单,继续往下分析发现 member/mypay.php 在 63 行执行 SQL 时还使用了 GetOne 函数。定位到 duomiphp/sql.class.php 277-300 行,GetOne 先清理掉字符串末尾的 , 和 ;,再拼接 limit 0,1; 使查询只返回一行(代码注释写着「执行一个 SQL 语句,返回前一条记录或仅返回一条记录」)。

拼接后在 288 行执行 SetQuery,290 行执行 Execute。跟进 Execute(234-269 行),其中 CheckSql 是关键方法,上方注释明确写着「用于 SQL 安全检查」。

CheckSql:80sec 的全局过滤
跟进 CheckSql(duomiphp/sql.class.php 537-642 行),注释提示「SQL 语句过滤程序,由 80sec 提供,这里作了适当的修改」。经过测试,这个过滤规则在 598 行硬匹配了 /*(字符串匹配而非正则),所以无法绕过——第 5 步的 /*!50000 xxxx*/ payload 在这里失效。

联合查询无法使用,便想到子查询的方法。经过测试仍无法绕过 628 行的正则:
~\([^)]*?select~s
该正则匹配带括号的 select(即子查询),s 修饰符使 . 匹配换行。无括号的子查询在 MySQL 里并不存在(官方文档也要求子查询必须用括号包裹)。
回头看 CNVD 描述
当没有思路或测试进行不下去的时候,就一定要回头看看一路走来所获得的信息,往往有小惊喜遗落在路上。
继续看 CNVD 中的描述——「系统未对变量进行过滤」,我突然觉得下载的是假的源码。难道使用 'or'1 这种用于任意充值的也能拿 CNVD 编号?(这个 CMS 在 /member/mypay.php 提交任意充值卡号,卡号密码处用 'or'1 可以实现任意充值。)
先在后台添加了充值卡,在前台注册并登录后将 cardpwd 的值设置为 'or'1 提交,确实可以任意充值。虽然这算一个漏洞,但在我观念中这种漏洞不太能说服我。

绕过思路
灰盒测试的小细节
使用灰盒方式测试 member/mypay.php 的 cardpwd 参数:输入被 43-63 行的正则过滤了,被 CheckSql(537-642 行)过滤了,未进行 ' 的闭合导致 SQL 语句报错。

到这里发现一个小细节——多了一个 ' 导致 SQL 语句报错了?在报错信息中发现了插入的 cardpwd 的值,而不是先提示被过滤,所以此处肯定有问题。根据这个现象可以推测两种可能:
- SQL 语句在被过滤前就执行了(×)
- 多了
'导致注入语句被绕过(√)
「完整 SQL 检查」代码块
定位问题的方法是在关键位置用 echo 把传入的 cardpwd 数据在处理过程中打印出来。首先在使用 /*!50000union*/ 的情况下提示 Safe Alert: Request Error step 2!——找到这个关键字所在位置:duomiphp/sql.class.php 635 行,刚好在 CheckSql 方法内。

在 561-586 行有个「完整 SQL 检查」代码块,之前一直关注过滤规则没在意它。因为这个 CMS 都是明文传输,密码处可能有关键字符触发 SQL 检测规则,开发人员便写了这个代码块,用于把 SQL 语句中两个单引号包裹的数据进行替换处理。

直接在 589 行插入 echo $clean;,将经过这个代码块的数据打印出来,发现确实将单引号内的字符变成了 $s$。在 640 行插入 echo $db_string; 将通过检测的数据打印出来。

数据跟踪与绕过原理
开发人员为避免密码处的关键字符被过滤而设计的方法,反而让过滤规则绕过成为可能。数据流跟踪如下:
cardpwd → $pwd → GetOne() → Execute() → CheckSql() → $clean → $db_string
$clean → $db_string 的过程是:先把数据处理(「完整 SQL 检查」)后赋给 $clean,然后把 $clean 传到各种 SQL 注入检测规则中,全部通过后返回原始数据 $db_string。幸运的是各种过滤规则中没有过滤 `、'、" 号。
因此只需利用「完整 SQL 检查」中「被单引号包裹的字符会被替换为 $s$」这个功能——把敏感关键字放进单引号对内,过滤时它们被替换成 $s$(检测通过),执行时 $db_string 仍是原始内容。
构造 Payload
要利用单引号将字符包裹且不影响 SQL 语义,单引号就必需被「转义」(让单引号配对错位,使含关键字的片段落在单引号对内)。转义单引号的方法:
- SQL 注释法:
/*'*/或/*!60000'*/ - 反引号方法:
` ' ` - 双引号方法:
" ' "
因此构造如下 payload(利用双引号和反引号):
bypass'or"'or extractvalue(1,(select group_concat(0x3a,name,0x3a,password) from duomi_admin`'`))or '1
处理后的结果($db_string)打印出来便于阅读:

利用两个双引号的另一版 payload:
bypass'or"'or extractvalue(1,(select group_concat(0x3a,name,0x3a,password) from duomi_admin))or "'=

写在最后
这个漏洞整个思路很清晰,审计起来也不困难,但因为一开始把重点放在了过滤规则的绕过上,导致花费太多精力在分析正则上。所以当过滤规则有时强绕绕不过,或许可以看下代码的其他上下文相关信息——这次较为灵活地利用「完整 SQL 检查」绕过了全局的 SQL 安全检测。
CNVD 上的描述只涉及 1 个参数(cardpwd 的 'or'1 任意充值),所以提交者的方法和本篇肯定不一样。本篇绕过的是全局检测——凡是使用到 CheckSql() 方法的位置都存在这个问题。
最后感慨一句:黑名单式的全局过滤本质上是在和攻击者打补丁,无论规则写得多严,总会漏掉某个函数或某种编码形式。真正可靠的防护是参数化查询,把数据和 SQL 语法从结构上隔离。感谢师傅们的各种协助和讨论。
(注:本文涉及的 CMS 版本早已停止维护,payload 仅供学习复盘。)