安全研究

代码审计:多米 DuomiCms 全局过滤规则绕过

#Web安全#代码审计#SQL注入
近黑深绿蓝背景上的铜橙色断裂链条与安全边界裂缝,象征 DuomiCms 全局 SQL 过滤规则被 payload 穿透

背景

在看 CNVD 漏洞库的时候发现有师傅发了某 CMS 前台 SQL 注入漏洞,通过查阅漏洞描述可以知道存在问题的参数是 cardpwd,便开始尝试对该版本的 CMS 进行审计。

CNVD-2017-22079 漏洞报告页

审计过程

定位 cardpwd

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

Seay 搜索 cardpwd 关键字定位到 mypay.php

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

mypay.php 中 cardpwd 接收 + 第一层正则

访问这个功能需要满足两个条件:注册并登录;在 GET、POST 或 COOKIE 中提交 dm=mypay

第一层正则过滤

注册会员后访问 /member/mypay.php,简单输入 1,2 进行请求,确定可以提交 cardpwd。继续阅读 member/mypay.php 43-63 行对 cardkeycardpwd 的处理,使用了正则对数据进行检测:

[^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 在等着。

mypay.php 中的正则过滤规则 regex101 测试:notallow1 正则在 \s+/\s+ 间无法匹配 /*!50000union*/ 注册会员后访问 /member/mypay.php:cardkey=1&cardpwd=2 的 Burp 拦截

GetOne 与 Execute

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

GetOne 函数:清理末尾 , 与 ; 拼 limit 0,1

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

Execute 函数:SetQuery 后调用 CheckSql

CheckSql:80sec 的全局过滤

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

CheckSql 方法(80sec 提供)的过滤代码 前置过滤:_RunMagicQuotes 80sec 全局 escape

联合查询无法使用,便想到子查询的方法。经过测试仍无法绕过 628 行的正则:

~\([^)]*?select~s

该正则匹配带括号的 select(即子查询),s 修饰符使 . 匹配换行。无括号的子查询在 MySQL 里并不存在(官方文档也要求子查询必须用括号包裹)。

回头看 CNVD 描述

当没有思路或测试进行不下去的时候,就一定要回头看看一路走来所获得的信息,往往有小惊喜遗落在路上。

继续看 CNVD 中的描述——「系统未对变量进行过滤」,我突然觉得下载的是假的源码。难道使用 'or'1 这种用于任意充值的也能拿 CNVD 编号?(这个 CMS 在 /member/mypay.php 提交任意充值卡号,卡号密码处用 'or'1 可以实现任意充值。)

先在后台添加了充值卡,在前台注册并登录后将 cardpwd 的值设置为 'or'1 提交,确实可以任意充值。虽然这算一个漏洞,但在我观念中这种漏洞不太能说服我。

后台生成充值卡 Burp 拦截:cardb=充&&cardpwd=‘or'1 直接绕过充值

绕过思路

灰盒测试的小细节

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

Burp 拦截:union select 触发 Safe Alert Request Error step 1 MySQL 语法错误信息:把完整 select 语句和 cardpwd=全部回显

到这里发现一个小细节——多了一个 ' 导致 SQL 语句报错了?在报错信息中发现了插入的 cardpwd 的值,而不是先提示被过滤,所以此处肯定有问题。根据这个现象可以推测两种可能:

  • SQL 语句在被过滤前就执行了(×)
  • 多了 ' 导致注入语句被绕过(√)

「完整 SQL 检查」代码块

定位问题的方法是在关键位置用 echo 把传入的 cardpwd 数据在处理过程中打印出来。首先在使用 /*!50000union*/ 的情况下提示 Safe Alert: Request Error step 2!——找到这个关键字所在位置:duomiphp/sql.class.php 635 行,刚好在 CheckSql 方法内。

CheckSql 第 635 行 exit(“Safe Alert: Request Error step 2!”) MySQL 版本注释绕过:/*!50000union*/ payload 仍被 step 2 阻断

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

「完整 SQL 检查」代码块——单引号内字符替换为 $s$ CheckSql 中 while 循环:逐段对单引号包裹内容做 $s$ 替换 preg_replace:把 s+/s+ 之间的内容剥离为 $s$

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

echo $db_string:把执行前的最终 SQL 打印出来

数据跟踪与绕过原理

开发人员为避免密码处的关键字符被过滤而设计的方法,反而让过滤规则绕过成为可能。数据流跟踪如下:

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 执行结果(双引号+反引号转义)

利用两个双引号的另一版 payload:

bypass'or"'or extractvalue(1,(select group_concat(0x3a,name,0x3a,password) from duomi_admin))or "'=

payload 执行结果(双引号转义)

写在最后

这个漏洞整个思路很清晰,审计起来也不困难,但因为一开始把重点放在了过滤规则的绕过上,导致花费太多精力在分析正则上。所以当过滤规则有时强绕绕不过,或许可以看下代码的其他上下文相关信息——这次较为灵活地利用「完整 SQL 检查」绕过了全局的 SQL 安全检测。

CNVD 上的描述只涉及 1 个参数(cardpwd'or'1 任意充值),所以提交者的方法和本篇肯定不一样。本篇绕过的是全局检测——凡是使用到 CheckSql() 方法的位置都存在这个问题。

最后感慨一句:黑名单式的全局过滤本质上是在和攻击者打补丁,无论规则写得多严,总会漏掉某个函数或某种编码形式。真正可靠的防护是参数化查询,把数据和 SQL 语法从结构上隔离。感谢师傅们的各种协助和讨论。

(注:本文涉及的 CMS 版本早已停止维护,payload 仅供学习复盘。)