蝉知CMS5.6反射型XSS审计复现
本文首发于信安之路:https://mp.weixin.qq.com/s/RTuWGGgssE5vOr8i7cARPA
0x00 源起
最近在深入学习反射XSS时遇到蝉知CMS5.6反射型XSS这个案列,乍一看网上的漏洞介绍少之又少,也没有详细的审计复现流程。虽然是17年的漏洞了,不巧本人正是一个喜欢钻研的人。这个CMS引起我极大的兴趣。在基本没有开发经验的前提下,目前只对MVC有一点很浅显的了解后我打算啃下这块硬骨头,并且这也是我第一个较完整的审计复现的一个CMS,前前后后用了接近3天的时间才差不多搞懂触发的流程,对我来说可以说是非常艰难了,幸运的是我还是啃了下来。
可能这个漏洞不新鲜,但是我想说的是发现漏洞的过程,漏洞引发的思考价值远远高于漏洞本身,所以我打算将这个不怎么完美的审计流程分享出来,让初学者少走一些弯路。文章中可能会有很多不足的地方,还望各位大佬不要吝啬一一指出。
0x01 初现
本次审计参考蝉知CMS v5.6 user-deny 反射型XSS漏洞、网易云课堂王松老师的XSS课程
-
复现环境:apache+php5.4
-
测试工具:vscode
先来看看漏洞描述
蝉知开源版CMS v5.6在
user
模块的deny()
方法中渲染模板文件时,对用户输入的参数进行渲染,且没有正确处理,导致可绕过一些过滤,从而造成反射性XSS。具体该
deny()
方法在system/module/user/control.php
文件,该模板文件是template/default/user/deny.html.php
文件。
payload
"><"
经过三次编码后
%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架构二次开发的
在审计的时候应该大致了解下审计程序的结构,采用的框架,目录信息等等,这样在遇到复杂环境时可以知道其到底是做了什么样的工作
有兴趣可以自己去了解下,因为笔者对框架还不是太熟悉,所以文中涉及结构部分可能很少。但我想说的是,做审计这是必须要懂的,而我也在一步步去了解。
在PHP框架中还有一个很重要的功能就是路由,作用是:
- 简化URL地址,方便记忆
- 有利于搜索引擎的优化
- 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
整个流程为:
-
浏览器发送到服务器的时候会对URL进行一次decode
-
服务端接收到
%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审计复现下来发现自己懂的还是太少了,要学的很多。但是看到自己从一个懵懵懂懂什么都不会的脚本小子,一路走来,看到那个遥远的梦在一步步实现,真的会觉得自己在成长,在改变,这就够了。
我就想这样坚持下去,我觉得这也是我们不得不过的坎。我认为没有什么解决不了的问题,缺的就是耐心和时间。文中可能有很多错误,写出来的目的还是希望能给初入代码审计的小伙伴一个思路。最后希望各位做安全的小伙伴在成功的道路上能够越走越远!