php反射应用场景_蝉知 CMS5.6 反射型 XSS 审计复现过程分享

本文作者:AirSky(信安之路首次投稿作者)

获得奖励:免费加入信安之路+邀请加入信安之路核心群+获得 90sec 论坛邀请码一枚

最近在深入学习反射 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

dae813446d4a58e5af409e310ab0c57f.png

为什么这么神奇导致了 XSS?看我一一道来

遇到 XSS 我们第一步应该看一下源码,看看是输出在什么地方,怎么输出的,以便我们更好的去分析

b4b2b3efdd52d2c69f61a2d211f05748.png

可以看到在 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 路由处理类进行处理后,转发到逻辑处理类,逻辑处理类将请求结果返回给用户。

手册说明

feb8ac257cfc66a1d643dfec5f395d2e.png

所谓的 pathinfo 模式,就是形如这样的 url:xxx.com/index.php/c/index/aa/cc,apache 在处理这个 url 的时候会把 index.php 后面的部分输入到环境变量 $_SERVER['PATH_INFO'],它等于 /c/index/aa/cc。

知道了这么多基础知识后是不是觉得也不是那么难呢。根据框架信息,我们的输入的数据会先进入路由,再通过路由转发到控制器,那么就来找找数据到底是在哪儿被接收的,处理流程是怎么样的。如果通过一般的方法一步步走的话我感觉对于我来说是个不小的挑战,这里就使用一个小技巧,既然知道数据的大致处理地方,我们就来逆推数据,寻找数据最开始的那个点。 

0x02 找寻

c3e456977b31ed99cc5270f9f7b43510.png

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

dc15ee803cb690568478a792467eb8ac.png

可以看到在调度类的 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 

第一个参数为我们构造的恶意脚本,在左边调用堆栈处可以看到整个大致的调用流程。 

点击上一个回到上一步看看进行了怎样的调用 

7614a230557dc5800ae92687caa00385.png

1601741302f1a986398ec483e5f07a1c.png

call_user_func_array(array("user","deny"),$this->params)   // 调用回调函数,并把一个数组参数作为回调函数的参数

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

085f59b1954f4207584f98faf0b2efd8.png

先进入第一处赋值看看 

af3a0cc8fce87a306727c3a877a8c8cf.png

下个断点看看,发现此处赋值点正是我们要找的,$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

53f247bb4992824408870153eae21d1f.png

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

c71b393fdcecadf779449c107aa84624.png

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

ec0aca2c6522e97c30529b44eb8c12ee.png

做个测试,可以看到在整个流程开始$_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

60ac048d9ebd4201dae4853cd8075054.png

18dba328da20151ab4ad3fa74a95aae6.png

传到这里发现 URI 没有变化,说明在前面的处理可能没有命中,所以前面的赋值流程我就省略了

在加载 Module 时解析 URL 调用路由类中的setParamsByPathInfo方法使用explode函数以-对 URI 进行分割得到请求参数

d7f49ba3c98a59a4b9ef0b2d6b164145.png

c1b55d60a6e4077096949b29c44830c0.png

获取参数到params数组 

d8be8001fe12760f4e1694780feefa11.png

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

7b37eb1d5da50acc8d5db5598f6c6242.png

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

2443b3a1ec2dbc86d7f143dd9bbb345f.png

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

7821489b755445e1c0af17c8aa0f3e6b.png

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

c4a48e222d63de7fa2846c4501f8ce7a.png

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

1e331c5029561289a4b867b78281c2d3.png

createLink中使用parse_str函数将 URL 分组 

43f8c329bc6753ba85e98b2fd4e9436e.png

可以看到如果以这样的形式合并到链接里也不会问题,问题就出在这个parse_str函数,坑点就是默认会对传入的字符做一次URLdecode

那么根据这个点,我们再次对payloadURL 编码一次,看看会怎么样 

31e3e42dff50f870bd47272b5601672e.png

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

22d473c96992a2550e31567f92bc8aa8.png

然后对恶意脚本进行了一次urldecode并使用strip_tags进行过滤,这时因为没有完整的 HTML 标签存在,所以绕过了该过滤函数。

可以看到如果以这样的形式最终输出也是是不会形成 XSS 的,那么开发人员可能没想到在经过parse_str函数后会对该值又进行一次urldecode,最后经过拼接直接输出到页面上,就这样巧妙的绕过了过滤函数。 

99b7e7c6dbb274726716592591635d4c.png

parse_str函数分组后 

0770920c98924ed20d1f6f14a0ed4891.png

0771e3516063098fe8df8067bccf6af7.png

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

430e3e6b84919d916b0391d64d886205.png

随后调用display方法渲染模板并输出。

根据漏洞说明在mergeJS()方法处对 js 进行了合并,跟进到mergeJS()方法 

cb91035200e3e37d499b870d0294b14c.png

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

c5ea7e846da716b5c3a41fa4d372c1bf.png

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

4c1269f0143be2a1887701a6248a4590.png

包含 html 头部进行渲染 

ce9c0ae9e897697619727e306d2f8c5a.png

在此文件中对整个 HTML 头部进行渲染,24 行处将带有恶意脚本的链接渲染到了link标签的href属性中,可以看到$mobileURL值正是前面生成的链接,此时只是存入了缓冲区,还不会输出。 

但是这个$mobileURL好像不是前面那个变量,继续看下这个$mobileURL是哪里赋值的,回到控制器类,在ob_start()函数上方发现一个熟悉的函数 

67e259760cac8d85dc7bd1f8b77ae3d6.png

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

2466c8a87a71fd1ca5bd5777c00057a0.png

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

5dce23ca24e54363930a0263d69ca772.png

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

92675e0edf12c77da2e5709f43d78042.png

fb51e1a18459f8c5f4fec27ee5776e9e.png

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

da04caa9bc6ff9ca508d481c690ecbc5.png

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

0f96fbfc53ec1e78ac585a44fcfe35d1.png

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

11d1c3273f8e535c5006413a53e34ef0.png

5419f21c07058f0f2c6aeb09a11847e4.png

display方法的结尾输出了带恶意脚本的页面模板造成了 XSS 

0x04 重现

第二个 XSS 漏洞由于 vscode 显示$this->output变量不全,无法跟踪页面完整渲染过程,所以接下来使用了 phpstorm 进行调试。 

payload :

http://www.x.com/chanzhi/www/index.php/user-deny-1-2-aHR0cDovL3d3dy5iYWlkdS5jb20nPHNjcmlwdD5hbGVydCgzKTs8L3NjcmlwdD4n.html

fa6246f01330c13e4ae264938e02a805.png

55f164eb71d8e3d98167019f4c3a20a1.png

恶意脚本输出在了页尾 

eeda0ae2100e7a92334e817a717bf26e.png

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

0f67a6700e934efff5d1b056d274de9e.png

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

d875a4b31205b8762d7ec87f540ad08d.png

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

313d8ca629ab5eec18024ebc062320a9.png

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

b84d7042b36ad7dd2dd105afe8be2654.png

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

b0dac15d7925abfff5365006901c7de7.png

最后输出造成了 XSS 

0x05 深思

为什么会对参数 base64 编码?导致过滤被绕过。相信小伙伴们也同样困惑,那么就一起来看看吧 

8e868d5b167ffdcf0a351385572285c9.png

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

6684d917b866f1429924d80e9a451518.png

a214e86f8e634a091a413354b1ab59cf.png

跳转到了 

254cb65b4a22f0a5089f5590766b05da.png

很奇怪是吧,在注册页面应该有做权限认证,未通过认证所以调用了 user 模块的 deny 方法渲染输出了一个拒绝页面,后面三个是作为参数传入用来生成不同的页面,其中返回前一页按钮链接正是由传入 deny 方法的第三个参数refererBeforeDeny决定的。因为使用了 URL 传参,并且值为 URL,所以进行了 base64 编码,不然会被过滤分割。 

那么我们就来跟踪一下注册页面的调用流程,重点关注一下 refererBeforeDeny 是怎么来的 

现在我们知道全局的 base64 编码都是使用的工具类中的safe64Encode方法,先来搜索该方法的调用点 

f5c2192c1951b3005171812f627e36c0.png

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

7275da2fc4c36da73b6d648c5c7496e9.png

果然断下来了,该调用在 deny 方法中,在调用栈中可以看到在这之前调用了checkPriv()方法检查权限 

回退一下看看checkPriv()的大致流程 

9393b8870bbdcad999040ac376421e21.png

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

02455af9c92dbbbcfad481c8d604b3d3.png

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方法中可以看到默认开启的模块和方法,并且对运行模式做了限制。 

889e178081ffd3be55660c60f41dc22a.png

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

cfc6858f59d0c59272e26bb9454afc01.png

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

60abad52544e3fc77495484fcad83142.png

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

950b4d17adcd7f828ad9fe7229e7fdf9.png

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

c8fd48ac8de60a0d537caa303cd88c13.png

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

3de696b1d383a61dc0e01e1c4806b22b.png

9113244477c8fe120fd60f3c79b0a0a7.png

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

9f58cee6f58c683693974845d53d4a10.png

之后就是跳转页面调用 user 模块的 deny 方法展示拒绝页面了。 

到这里整个流程大概清晰了,deny 方法的第三个参数 refererBeforeDeny 应该是作为拒绝页面和跳转页面前一页的接口,用于生成返回前一页按钮链接

测试一下 在不同域的根目录新建一个链接页面 

dd5f6a0e9a85f2659b3d76d9f2986bc6.png

点击注册跳转注册页面 

7912cb1d2dd613df42d4232d2ba6aaf1.png

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

8daa7bf02313fee9d1776c75c657c94b.png

referer由 URL 传入deny方法用于生成返回前一页按钮链接 

最后测试一下如果直接传入未编码的 URL:

79e160938185929130a48681f558aa1e.png

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

3e55303605facdb48506840c9c163de7.png

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

4974f49617ebdb4b1ca6a4a23f98c201.png

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

e720b06a47ae8b3d0a88e5b8fbb97706.png

0x06 总结

第一个 XSS 和第二个 XSS 说白了都是由于对数据过滤的不充分,在多场景下没有结合着实际对可控数据做处理,这也从侧面反映出每一个点对于我们来说都是不能放过。这是个枯燥的过程,但也是提升的过程。

这两个 XSS 审计复现下来发现自己懂的还是太少了,要学的很多。但是看到自己从一个懵懵懂懂什么都不会的脚本小子,一路走来,看到那个遥远的梦在一步步实现,真的会觉得自己在成长,在改变,这就够了。

我就想这样坚持下去,我觉得这也是我们不得不过的坎。我认为没有什么解决不了的问题,缺的就是耐心和时间。文中可能有很多错误,写出来的目的还是希望能给初入代码审计的小伙伴一个思路。最后希望各位做安全的小伙伴在成功的道路上能够越走越远!

cb6bb1238c976c66ebf16cf81765b229.png

  • 0
    点赞
  • 0
    收藏
    觉得还不错? 一键收藏
  • 0
    评论
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包
实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值