Wordpress Image 远程代码执行漏洞分析
Feb 21, 2019
1 minute read

0x01 概述

2月20日,RIPS披露了Wordpress内核Image模块相关的一个高危漏洞,该漏洞由目录穿越和文件包含组成,最终可导致远程代码执行,目前还没有PoC披露。

RIPS描述的细节来看,漏洞出现在wordpress编辑图片时,由于没有过滤Post Meta 值导致可以修改数据库中wp_postmeta表的任意字段,而在加载本地服务器上的文件时没有对路径进行过滤,导致可以传递目录穿越参数,最终保存图片时可以保存至任意目录。当某个主题include了某目录下的文件时,便可以造成代码执行。

0x02 环境搭建

该漏洞影响4.9.9版本以下的wordpress程序,4.9.9引入了过滤函数,对用户输入的post data进行了检查,不合法的参数被过滤,主要修改如下图:

值得注意的是,在安装低版本时,安装过程中会自动更新核心文件,因此旧版本的wp-admin/includes/post.php会更新至最新版本,所以安装过程中可以删除自动更新相关模块,或者离线安装。

0x03 漏洞分析

漏洞一:数据覆盖

漏洞出现在wordpress媒体库裁剪图片的过程,当我们上传图片到媒体库时,图片会被保存至wp-content/uploads/yyyy/mm目录,同时会在数据库中wp_postmeta表插入两个值,分别是_wp_attached_file_wp_attachment_metadata,保存了图片位置和属性相关的序列化信息。

当我们修改图片属性(例如修改标题或者说明)的时候,admin-media-Edit more details 会调用wp-admin/includes/post.phpedit_post()方法,该方法的参数全部来自于$_POST,没有进行过滤

然后会调用到update_post_meta()方法,该方法根据$post_ID修改post meta field,接着调用update_metadata()更新meta数据,完成之后更新post数据,调用wp_update_post()方法

wp_update_post()方法中,如果post_type=attachment,则进入wp_insert_attachment(),接着调用wp_insert_post(),在wp_insert_post()方法中判断了meta_input参数,如果传入了该参数,就遍历数组用来更新post_meta

进入update_post_meta(),调用update_metadata(),在update_metadata()方法中对数据库进行更新操作,而在整个过程中对键值没有任何过滤,意味着我们可以传入指定的key来设置它的值,调用栈如下图所示

于是构造数据包更新数据库中_wp_attached_file的值,插入一个包含../的值,以便在下面触发目录遍历。

这是第一个漏洞——通过参数覆盖了数据库数据,在补丁处正是对meta_input这个参数做了过滤,如果包含则通过对比array舍弃该参数。

漏洞二:目录遍历

接着寻找一个获取_wp_attached_file的值并进行了文件操作相关的方法。

wordpress图片裁剪功能中,有这样的功能:

  1. 图片存在于wp-content\uploads\yyyy\mm目录,则从该目录读取图片,修改尺寸后另存为一张图片;
  2. 如果图片在该目录不存在,则通过本地服务器下载该图片,如从http://127.0.0.1/wordpress/wp-content/uploads/2019/02/admin.jpeg下载,裁剪后重新保存。

这个功能是为了方便一些插件动态加载图片时使用。

然而因为本地读取和通过url读取的差异性,导致可以构造一个带参数的url,如http://127.0.0.1/wordpress/wp-content/uploads/2019/02/admin.jpeg?1.png,在本地读取时会发现找不到admin.jpeg?1.png,而远程获取时会忽略?后面的参数部分,照样获取到admin.jpeg,裁剪后保存。如果构造的url包含路径穿越,例如http://127.0.0.1/wordpress/wp-content/uploads/2019/02/admin.jpeg?../../1/1.pngwordpress将裁减后的图片保存至指定的文件夹,当图片包含恶意代码被引用时,就可能造成代码执行。

图片裁剪功能在wp_crop_image()方法中,但是该方法不能在页面中触发,需要手动更改相应的action

首先在页面裁剪图片,并点击保存

抓取数据包:

action=image-editor&_ajax_nonce=4c354c778b&postid=5&history=%5B%7B%22c%22%3A%7B%22x%22%3A0%2C%22y%22%3A5%2C%22w%22%3A347%2C%22h%22%3A335%7D%7D%5D&target=all&context=edit-attachment&do=save

post body包含了相应的actioncontext,以及供还原文件的历史文件大小,此处需要修改actioncrop-image以便触发wp_crop_image()方法,相关调用如下

wp-admin/admin-ajax.php定义了裁剪图片的操作

判断了用户权限和action名称后调用do_action,最终在apply_filters()中进入wp_crop_image():

进入wp_ajax_crop_image()方法,在这个方法中进行了多项判断,全部符合才能进入裁剪图片方法,如下图注释所示

首先计算nonceexpected值并对比,如果不一致就验证不通过,相关方法是check_ajax_referer()–>wp_verify_nonce()。注意到传入check_ajax_referer()$attachment_id参数,该参数取自$_POST['id'],并参与后面的expected计算,因此当我们直接更改action=crop-image是无法通过校验的,需要传入id的,即为postid的值。

在进入wp_crop_image()时还需要传递裁剪后的图片宽度和高度信息,所以还需要增加cropDetails[dst_width]cropDetails[dst_height]两个参数。

wp_crop_image()方法如下

从数据库取出_wp_attached_file后并没有做检查,形如2019/02/admin.jpeg?../../1.png的文件无法被找到,于是进入_load_image_to_edit_path()通过wp_get_attachment_url()方法生成本地url

随后实例化一个WP_Image_Editor用来裁剪并生成裁剪后的图片,之后调用wp_mkdir_p()方法创建文件夹,含有../的参数进入该方法后同样没有经过过滤,最终执行到mkdir创建文件夹

mkdir( $target, $dir_perms, true)

此时的target值是这个样子,穿越目录后在2019目录下创建1文件夹,并生成cropped-1.png文件

D:\phpStudy\PHPTutorial\WWW\wordpress-4.9.8/wp-content/uploads/2019/02/admin.jpeg?../../../1

注意:此处有一个坑,我们观察上面的url,在mkdir的时候会把admin.jpeg?../作为一个目录,而在Windows下的目录不能出现?,所以上面的payload在Windows下无法成功,经过测试,#可以存在于Windows目录,因此在Windows下的payload如下所示:

meta_input[_wp_attached_file]=2019/02/admin.jpeg#../../../1/1.png

写入数据库中即为2019/02/admin.jpeg#../../../1/1.png

最终构造第二个数据包触发裁剪图片并保存:

最终在指定目录下生成裁剪后的图片文件,以cropped-作为前缀

这样子我们可以制作一张图片马,在主题文件夹下生成,或者指定任意目录,被include后即可造成代码执行。

0x04 LFI to RCE

到目前为止我们可以把含有恶意代码的图片写入任意目录,下一步就是想办法包含这个文件。

Wordpress中,访问一篇文章或者任意页面,都需要从数据库取出相应的模板文件位置并由浏览器渲染出来。注意到上面截图,wp_postmeta数据库中有个字段名称为_wp_page_template,这个字段用来保存加载页面所需要的模板文件,默认为defaultwordpress程序根据需要加载的页面类型从当前主题下选择需要的模板,例如访问一篇单独的文章,这个过程会拼凑出文件名并检查主题下的这些文件是否存在,如果存在则包含进来,相关方法是locate_template()load_template()

搜索发现实现从数据库取出_wp_page_template变量的方法是get_page_template_slug()

接着发现调用get_page_template_slug()方法的get_single_template()方法,其最后返回的是查找模板函数,即get_query_template()

而正是在get_query_template()中,执行了定位模板文件的操作

至此一条利用链就串起来了,利用第一个漏洞覆盖数据库中的_wp_page_template值,修改为包含恶意代码的图片所在路径,在页面加载的过程中wordpress查询并定位该文件,包含后造成代码执行。

Wordpress中处理图片相关的库有两个,分别是ImagickGD,优先选择使用Imagick,而Imagick处理图片时不处理EXIF信息,因此可以把恶意代码设置在EXIF部分,经过裁剪后会保留EXIF信息,此时再进行包含就能造成代码执行。

在选择相应图片库处理图片时,如果此时加载的是Imagick,在$editor->load()时会创建Imagick()对象,然后尝试读取远程图片地址。此时需要注意的是,高版本的Imagick库不支持远程链接,测试Imagick-6.9.7版本正常创建并写入图片

$implementation = _wp_image_editor_choose( $args );

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

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

    return $editor;
}
$this->image = new Imagick();
//...
$this->image->readImage( $filename );

复现:

1.上传图片,更新描述信息并保存,抓包修改meta_input[_wp_attached_file],目录穿越至当前主题文件夹

2.裁剪图片并在主题文件夹下生成裁剪后图片

3.上传一个附件,更新描述信息并抓包,修改meta_input[_wp_page_template],加载模板的时候自动包含该图片,代码执行成功

0x05 关于mkdir

在漏洞调试过程中最后一步$editor->save( $dst_file )过程,最终执行到的是wp_mkdir_p()方法中的mkdir函数

mkdir( $target, $dir_perms, true)

关于mkdir()函数,需要注意的是mode参数和recursive参数,分别代表了创建的文件夹权限和是否递归创建,这两个参数的不同导致在Linux平台和Windows平台的结果不一致

在上面漏洞链中,进入最终mkdir()的参数是这样的

mkdir( 'D:\phpStudy\PHPTutorial\WWW\wordpress-4.9.8/wp-content/uploads/2019/02/admin.jpeg?../../../1', 511, true)

单独把path拿出来测试,在第三个参数recursive分别为truefalse时,测试结果如下

这里导致结果不一致是因为Windows下文件夹对?的处理,当指定递归创建模式时,系统会尝试创建名为admin.jpeg?..的目录,又因为Windows下的目录不能含有?,因此recursive=true时是创建失败的,导致wordpress最终生成图片也无法成功。而在Linux下可以没有?的限制,payload可以成功触发。

要想在Windows下利用漏洞,一个技巧是利用#字符,#url中表示为网页位置指定标识符,只在浏览器中起作用,对解析资源时是忽略后面的字符的,因此在wordpress中两个方式尝试获取图片资源时同样会出现不一致,导致漏洞产生。

更新:此处是否检查?等不合法字符与php的线程安全模式相关,具体如下。

Windows thread-safe non-thread safe
recursive=false fail (No error) success
recursive=true fail (Invalid path) fail (Invalid path)

关于这块的详细分析可以参考 对PHP中的mkdir()函数的研究

0x06 PoC

见上面分析

0x07 总结

在分析过程中踩了不少坑,每一个都浪费了不少时间,简单记录避免再次踩中。主要的有这么几个:

  1. Wordpress自动更新;
  2. 需要手动修改触发裁剪函数的action
  3. mkdir创建文件夹时特殊字符的问题;
  4. Imagick读取远程文件的问题;

这个漏洞主要成因在于我们可以通过参数传递任意值覆盖数据库中的字段,从而引入../构成目录穿越,在裁剪图片后保存文件时并没有对文件目录做检查,造成目录穿越漏洞,最终可以写入恶意图片被包含或者通过Imagick漏洞触发远程代码执行,利用链挺巧妙,值得学习。

参考:


Back to posts


comments powered by Disqus