蝉知 CMS5.6 反射型 XSS 审计复现过程分享
- 2019 年 10 月 7 日
- 筆記
最近在深入学习反射 XSS 时遇到蝉知 CMS5.6 反射型 XSS 这个案列,乍一看网上的漏洞介绍少之又少,也没有详细的审计复现流程。虽然是 17 年的漏洞了,不巧本人正是一个喜欢钻研的人。这个 CMS 引起我极大的兴趣。在基本没有开发经验的前提下,目前只对 MVC 有一点很浅显的了解后我打算啃下这块硬骨头,并且这也是我第一个较完整的审计复现的一个 CMS,前前后后用了接近 3 天的时间才差不多搞懂触发的流程,对我来说可以说是非常艰难了,幸运的是我还是啃了下来。
可能这个漏洞不新鲜,但是我想说的是发现漏洞的过程,漏洞引发的思考价值远远高于漏洞本身,所以我打算将这个不怎么完美的审计流程分享出来,让初学者少走一些弯路。文章中可能会有很多不足的地方,还望各位大佬不要吝啬一一指出。
0x01 初现
本次审计参考《蝉知 CMS v5.6 user-deny 反射型 XSS漏洞》
https://www.seebug.org/vuldb/ssvid-92660)
网易云课堂王松老师的 XSS 课程
复现环境:apache+php5.4
测试工具:vscode+phpstorm
先来看看漏洞描述:
蝉知开源版 CMS v5.6 在
user
模块的deny()
方法中渲染模板文件时,对用户输入的参数进行渲染,且没有正确处理,导致可绕过一些过滤,从而造成反射性 XSS。
具体该deny()
方法在system/module/user/control.php
文件,该模板文件是template/default/user/deny.html.php
文件。
payload:
"><script>alert(1);</script><"
经过三次编码后
%252522%25253e%25253cscript%25253ealert(1)%25253b%25253c%25252fscript%25253e%25253c%252522
放入链接
www.x.com/chanzhi/www/index.php/user-deny-%252522%25253e%25253cscript%25253ealert(1)%25253b%25253c%25252fscript%25253e%25253c%252522

为什么这么神奇导致了 XSS?看我一一道来
遇到 XSS 我们第一步应该看一下源码,看看是输出在什么地方,怎么输出的,以便我们更好的去分析

可以看到在 script 标签中被插入了我们的恶意语句,此时在后面还有很多奇奇怪怪的语句,这到底是怎么回事呢,别急,跟着我一步步去发现
在这之前我们先来了解下什么是MVC模式
M 即模型(Model):模型组件包含应用程序的功能内核,他封装了相应的数据并输出执行特定应用程序处理的过程;模型也提供访问数据的函数。也就是说模型只会负责数据的存取。
V 即视图(View):将信息显示给用户(可以定义多个视图)。你看到的 HTML 页面都是通过视图来进行展示的,也就是说视图只会负责数据的展示。
C 即控制器(Controller):处理用户输入的信息。负责从模型存取数据,然后通过视图来展示,控制用户输入,并向模型发送数据,是应用程序中处理用户交互的部分。负责管理与用户交互交互控制。也就是说控制器本身不生产数据,它只处理数据并充当搬运工的角色。
而蝉知 CMS 正是采用其自家的zentaoPHP
框架使用 MVC 架构二次开发的
在审计的时候应该大致了解下审计程序的结构,采用的框架,目录信息等等,这样在遇到复杂环境时可以知道其到底是做了什么样的工作 :
zentaoPHP 框架手册地址:
http://devel.cnezsoft.com/book/zentaophphelp/about-10.html
蝉知 CMS 目录结构:
https://www.chanzhi.org/book/chanzhieps/150.html
有兴趣可以自己去了解下,因为笔者对框架还不是太熟悉,所以文中涉及结构部分可能很少。但我想说的是,做审计这是必须要懂的,而我也在一步步去了解。
在 PHP 框架中还有一个很重要的功能就是路由,作用是:
1、简化 URL 地址,方便记忆
2、有利于搜索引擎的优化
3、URL 路由处理类进行处理后,转发到逻辑处理类,逻辑处理类将请求结果返回给用户。
手册说明

所谓的 pathinfo 模式,就是形如这样的 url:xxx.com/index.php/c/index/aa/cc,apache 在处理这个 url 的时候会把 index.php 后面的部分输入到环境变量 $_SERVER['PATH_INFO'],它等于 /c/index/aa/cc。
知道了这么多基础知识后是不是觉得也不是那么难呢。根据框架信息,我们的输入的数据会先进入路由,再通过路由转发到控制器,那么就来找找数据到底是在哪儿被接收的,处理流程是怎么样的。如果通过一般的方法一步步走的话我感觉对于我来说是个不小的挑战,这里就使用一个小技巧,既然知道数据的大致处理地方,我们就来逆推数据,寻找数据最开始的那个点。
0x02 找寻

根据漏洞描述,关键点在 deny 方法中对模块的处理处,那么我们就找到 deny 方法来下个断点

可以看到在调度类的 deny 方法中调用了 createLink 方法
官方手册说明
$this->createLink('blog', 'view', 'id=17&cat=123')
第一个参数是模块名称,第二个参数是方法名,第三个参数是参数,使用 key1=value1&key2=value2 这种方式来进行传参。
如果运行方式为 PATH_INFO,这样会生成 blog-view-17-123.html 这样的链接。
如果运行方式为 GET,则生成 ?m=blog&f=view&id=17&cat=123&t=html 的链接。
也就是说传入的三个参数会构造这样一个链接 user-deny-1-2-3
第一个参数为我们构造的恶意脚本,在左边调用堆栈处可以看到整个大致的调用流程。
点击上一个回到上一步看看进行了怎样的调用


call_user_func_array(array("user","deny"),$this->params) // 调用回调函数,并把一个数组参数作为回调函数的参数
通过左边的变量名监视,可以看到通过该函数调用了 user 类的 deny 方法,并将模块信息等作为参数传入了该方法。知道了存储恶意脚本的属性$this->params
就好办了。我们直接搜索$this->params
,查找对其赋值的地方

先进入第一处赋值看看

下个断点看看,发现此处赋值点正是我们要找的,$params
为可控输入,并在上方发现对$params
的赋值
public function setParamsByPathInfo($defaultParams = array()) { /* 分割URI。 Spit the URI. */ $items = explode($this->config->requestFix, $this->URI);//explode分割URI到$items $itemCount = count($items); $params = array(); /** * 前两项为模块名和方法名,参数从下标2开始。 * The first two item is moduleName and methodName. So the params should begin at 2. **/ for($i = 2; $i < $itemCount; $i ++) { $key = key($defaultParams); // Get key from the $defaultParams. $params[$key] = str_replace('.', '-', $items[$i]);//循环$items元素替换.为-赋值给$params数组 next($defaultParams); }
所以这里的的大致赋值流程为:
$this->URI=>$items[$i]=>$params[$key]=>$this->params
接下来继续寻找$this->URI

赋值点为$this->getPathInfo();
跟进这个方法

在该方法里发现了数据的最初赋值点,之前可能做了很多初始化工作,但对URI的赋值是在这里进行的。最后使用strpos
判断是否有?形式的参数传递,这里不存在,所以直接使用trim
处理返回了

做个测试,可以看到在整个流程开始$_SERVER['PATH_INFO']
就已经带上了路径信息,是不是发现也不是那么困难呢,只要肯动手。
所以$module
的大致流程就是:
$_SERVER['PATH_INFO'] => return $value => $pathInfo => $this->URI => $items => $params => $this->params => $module
0x03 解构
知道数据的大致流向,对于漏洞的理解会更深刻一些,而且还可能发现意想不到的东西,当然最重要的还是学习啦。
这里我先选择一个不会触发 XSS 的 payload,在结合会触发 XSS 的 payload 来学习,这样印象会比较深刻,也比较容易理解。
经过二次编码的 payload:
http://www.x.com/chanzhi/www/index.php/user-deny-%2522%253e%253cscript%253ealert(1)%253b%253c%252fscript%253e%253c%2522
整个流程为:
1、浏览器发送到服务器的时候会对 URL 进行一次 decode
2、服务端接收到%22%3e%3cscript%3ealert(1)%3b%3c%2fscript%3e%3c%22


传到这里发现 URI 没有变化,说明在前面的处理可能没有命中,所以前面的赋值流程我就省略了
在加载 Module 时解析 URL 调用路由类中的setParamsByPathInfo
方法使用explode
函数以-对 URI 进行分割得到请求参数


获取参数到params
数组

在 1680 行处调用mergeParams
方法,$params
作为$passedParams
参数传入

1723 行处对使用array_values
返回了一个带序号的数组,随后在foreach
中遍历$params
数组进行过滤合并请求的参数和默认参数到defaultParams
数组,关键点来了,在 1929 行处先使用urldecode
对恶意脚本进行解码,再使用strip_tags
去除恶意脚本中的 HTML 标记 最后返回给$this->params

可以看到合并后恶意脚本 script 标签被去除

紧接着使用call_user_func_array
回调控制器中的user
类的deny
方法生成拒绝页面,$this->params
数组中的三个值作为参数传入

deny
方法中调用了createLink
方法生成链接。

createLink
中使用parse_str
函数将 URL 分组

可以看到如果以这样的形式合并到链接里也不会问题,问题就出在这个parse_str
函数,坑点就是默认会对传入的字符做一次URLdecode
那么根据这个点,我们再次对payload
URL 编码一次,看看会怎么样

首先传入的 URI 被浏览器解码一次,根据前面的步骤取到 URI 中的恶意脚本

然后对恶意脚本进行了一次urldecode
并使用strip_tags
进行过滤,这时因为没有完整的 HTML 标签存在,所以绕过了该过滤函数。
可以看到如果以这样的形式最终输出也是是不会形成 XSS 的,那么开发人员可能没想到在经过parse_str
函数后会对该值又进行一次urldecode
,最后经过拼接直接输出到页面上,就这样巧妙的绕过了过滤函数。

parse_str
函数分组后


可以看到createLink
方法返回的链接中包含了恶意脚本,那么它最终又是怎么输出到页面上的呢,我们继续跟踪下去。

随后调用display
方法渲染模板并输出。
根据漏洞说明在mergeJS()
方法处对 js 进行了合并,跟进到mergeJS()
方法

preg_match_all
处理的数据为$this->output
,查找赋值点

在控制器类 391 行找到赋值点,388 行使用ob_start
打开了输出缓冲区,此方法经常在生成 HTML,或者整页缓存中使用,这时所有的输出都会保存到缓冲区。紧接着包含视图文件对模板进行渲染

包含 html 头部进行渲染

在此文件中对整个 HTML 头部进行渲染,24 行处将带有恶意脚本的链接渲染到了link
标签的href
属性中,可以看到$mobileURL
值正是前面生成的链接,此时只是存入了缓冲区,还不会输出。
但是这个$mobileURL
好像不是前面那个变量,继续看下这个$mobileURL
是哪里赋值的,回到控制器类,在ob_start()
函数上方发现一个熟悉的函数

相信做过 CTF 题目的小伙伴对这个函数应该不陌生,那就是extract
函数,在变量覆盖漏洞中经常用到,该函数从数组中将变量导入到当前的符号表,使用数组键名作为变量名,使用数组键值作为变量值。

继续渲染完页面后回到控制器类,接下来使用了ob_get_contents
函数获取到了输出缓冲区的所有内容

紧接着在控制器类的mergeJS
方法中将页面中带有<script>
标签的内容拼接合成为一个<script>
标签


将带有恶意脚本的内容合成到了一起

在 605 行从$this->output
的第 946 个位置开始替换,将带有恶意语句的拼接 script 标签插入了模板中

最后在控制器中调用了控制器类的 display 方法


在display
方法的结尾输出了带恶意脚本的页面模板造成了 XSS
0x04 重现
第二个 XSS 漏洞由于 vscode 显示$this->output
变量不全,无法跟踪页面完整渲染过程,所以接下来使用了 phpstorm 进行调试。
payload :
http://www.x.com/chanzhi/www/index.php/user-deny-1-2-aHR0cDovL3d3dy5iYWlkdS5jb20nPHNjcmlwdD5hbGVydCgzKTs8L3NjcmlwdD4n.html


恶意脚本输出在了页尾

和前面一样,从 URI 中截取出了第三个参数referer
,也就是 base64 编码的恶意脚本

通过call_user_func_array
回调deny
方法,传入参数并赋值到view
对象$refererBeforeDeny
属性

在控制器类 386 行转换stdClass
对象为数组,并生成变量

在渲染拒绝页面时使用 html 类 a 方法对参数进行了base64decode
生成了一个 a 标签并且输出到了页面(存储到了缓冲区),因为被base64
编码了,所以绕过了前面的过滤

之后会调用mergeJS()
取到 js 脚本合并到页面

最后输出造成了 XSS
0x05 深思
为什么会对参数 base64 编码?导致过滤被绕过。相信小伙伴们也同样困惑,那么就一起来看看吧

在登录页面点击注册功能发现网址由


跳转到了

很奇怪是吧,在注册页面应该有做权限认证,未通过认证所以调用了 user 模块的 deny 方法渲染输出了一个拒绝页面,后面三个是作为参数传入用来生成不同的页面,其中返回前一页按钮链接正是由传入 deny 方法的第三个参数refererBeforeDeny
决定的。因为使用了 URL 传参,并且值为 URL,所以进行了 base64 编码,不然会被过滤分割。
那么我们就来跟踪一下注册页面的调用流程,重点关注一下 refererBeforeDeny 是怎么来的
现在我们知道全局的 base64 编码都是使用的工具类中的safe64Encode
方法,先来搜索该方法的调用点

在搜索后发现一个可疑调用,在 model 中使用该方法编码了来源页面HTTP_REFERER
,下个断点测试一下

果然断下来了,该调用在 deny 方法中,在调用栈中可以看到在这之前调用了checkPriv()
方法检查权限
回退一下看看checkPriv()
的大致流程

在index.php
第 43 行调用了checkPriv()
,下个断点,

在checkPriv
方法中 147 行调用isOpenMethod
判断user
模块的register
方法是否开放
public function isOpenMethod($module, $method) { $module = strtolower($module); $method = strtolower($method); if($module == 'user' and strpos(',login|logout|deny|resetpassword|checkresetkey|yangconglogin|oauthbind|', $method)) return true; if($module == 'mail' and $method == 'sendmailcode') return true; if($module == 'guarder' and $method == 'validate') return true; if($module == 'misc' and $method == 'ajaxgetfingerprint') return true; if($module == 'wechat' and $method == 'response') return true; if($module == 'sitemap' and $method == 'index') return true; if($module == 'yangcong') return true; if(RUN_MODE == 'admin' and $this->app->user->admin != 'no' and isset($this->config->rights->admin[$module][$method])) return true; if(RUN_MODE == 'admin' and $module == 'farm' and $method == 'register') return true; if(RUN_MODE == 'admin' and $module == 'farm' and (strpos($method, 'api') !== false)) return true; if($module == 'widget' and RUN_MODE == 'admin') return true; if($this->loadModel('user')->isLogon() and stripos($method, 'ajax') !== false) return true; return false; }
在isOpenMethod
方法中可以看到默认开启的模块和方法,并且对运行模式做了限制。

结果为 false,177 行进入到了hasPriv
鉴权函数检查当前用户是否有权使用user
模块的register
方法

在鉴权函数中的 212 行调用isAvailable
检测了当前模块是否可用

可以看到该模块不在设置模块中,所以返回了 false

hasPriv
鉴权未通过。调用deny
方法在 299 行对referer
进行了编码拼接

308 行调用createLink
生成了一个链接


最后调用js:locate
生成了 js 跳转脚本

之后就是跳转页面调用 user 模块的 deny 方法展示拒绝页面了。
到这里整个流程大概清晰了,deny 方法的第三个参数 refererBeforeDeny 应该是作为拒绝页面和跳转页面前一页的接口,用于生成返回前一页按钮链接
测试一下 在不同域的根目录新建一个链接页面

点击注册跳转注册页面

无权限跳转拒绝页面并编码传入referer

referer
由 URL 传入deny
方法用于生成返回前一页按钮链接
最后测试一下如果直接传入未编码的 URL:

在调度类 200 行调用了seo
类的parseURI
方法对 URI 进行处理

47 行被 '/' 分割赋值给module

回到调度类,http:
字段会经过 URI 分割最终作为refererBeforeDeny
传入deny
方法,最后渲染到页面就是这样

0x06 总结
第一个 XSS 和第二个 XSS 说白了都是由于对数据过滤的不充分,在多场景下没有结合着实际对可控数据做处理,这也从侧面反映出每一个点对于我们来说都是不能放过。这是个枯燥的过程,但也是提升的过程。
这两个 XSS 审计复现下来发现自己懂的还是太少了,要学的很多。但是看到自己从一个懵懵懂懂什么都不会的脚本小子,一路走来,看到那个遥远的梦在一步步实现,真的会觉得自己在成长,在改变,这就够了。
我就想这样坚持下去,我觉得这也是我们不得不过的坎。我认为没有什么解决不了的问题,缺的就是耐心和时间。文中可能有很多错误,写出来的目的还是希望能给初入代码审计的小伙伴一个思路。最后希望各位做安全的小伙伴在成功的道路上能够越走越远!