Wordpress未授权查看私密内容漏洞 分析(CVE-2019-17671)
0x00 前言
没有
0x01 分析
这个漏洞被描述为“未授权访问私密内容”,由此推断是权限判断出了问题。如果想搞懂哪里出问题,必然要先知道wp获取page(页面)/post(文章)的原理,摸清其中权限判断的逻辑,才能知道逻辑哪里会有问题。
这里我们直接从wp的核心处理流程main函数开始看,/wp-includes/class-wp.php:main()
public function main( $query_args = '' ) {
$this->init();//获取当前用户信息
$this->parse_request( $query_args );//解析路由,匹配路由模式,取出匹配的路由中的用户输入参数(比如year,month等)赋值给$this->query_vars。(并将部分用户参数绑定到$this->query_vars中)。然后进行一些过滤操作。
$this->send_headers();//设置HTTP响应头,比如Content-Type等
$this->query_posts();//根据$this->query_vars等参数,获取posts/pages
$this->handle_404();
$this->register_globals();
do_action_ref_array( 'wp', array( &$this ) );
}
$this->init()底层直接调用wp_get_current_user()获取全局变量$current_user,这是一个WP_User类,里面存储当前用户的元信息,未登录时$current_user->ID===0。
然后进入$this->parse_request,这个函数主要用于处理路由,初始化$this->query_vars。主要分为两部分来看,第一部分是处理路由,匹配rewrite路由模式。
public function parse_request( $extra_query_vars = '' ) {
global $wp_rewrite;
...
// Fetch the rewrite rules.
$rewrite = $wp_rewrite->wp_rewrite_rules();//加载所有路由重写规则,用于与当前请求路径进行匹配
if ( ! empty( $rewrite ) ) {
...
if ( empty( $request_match ) ) {
...
} else {
foreach ( (array) $rewrite as $match => $query ) {//匹配路由规则
...
if ( preg_match( "#^$match#", $request_match, $matches ) || preg_match( "#^$match#", urldecode( $request_match ), $matches ) ) {
...
// Got a match.
$this->matched_rule = $match;//找到匹配成功的rewrite规则,立即break
break;
}
}
}
if ( isset( $this->matched_rule ) ) {
...
$query = addslashes( WP_MatchesMapRegex::apply( $query, $matches ) );//规则化用户请求url,以与路由进行完美对应
$this->matched_query = $query;
// Parse the query.
parse_str( $query, $perma_query_vars );
...
}
...
}
第二部分,解析用户参数,配置$this->query_vars的值
class WP{
...
public $public_query_vars = array( 'm', 'p', 'posts', 'w', 'cat',
'withcomments', 'withoutcomments', 's', 'search', 'exact', 'sentence',
'calendar', 'page', 'paged', 'more', 'tb', 'pb', 'author', 'order',
'orderby', 'year', 'monthnum', 'day', 'hour', 'minute', 'second',
'name', 'category_name', 'tag', 'feed', 'author_name', 'static',
'pagename', 'page_id', 'error', 'attachment', 'attachment_id',
'subpost', 'subpost_id', 'preview', 'robots', 'taxonomy', 'term',
'cpage', 'post_type', 'embed' );
...
public function parse_request( $extra_query_vars = '' ) {
...
...
<接上第一部分>
foreach ( $this->public_query_vars as $wpvar ) {
if ( isset( $this->extra_query_vars[ $wpvar ] ) ) {
$this->query_vars[ $wpvar ] = $this->extra_query_vars[ $wpvar ];
} elseif ( isset( $_GET[ $wpvar ] ) && isset( $_POST[ $wpvar ] ) && $_GET[ $wpvar ] !== $_POST[ $wpvar ] ) {
wp_die( __( 'A variable mismatch has been detected.' ), __( 'Sorry, you are not allowed to view this item.' ), 400 );
} elseif ( isset( $_POST[ $wpvar ] ) ) {
$this->query_vars[ $wpvar ] = $_POST[ $wpvar ];
} elseif ( isset( $_GET[ $wpvar ] ) ) {
$this->query_vars[ $wpvar ] = $_GET[ $wpvar ];
} elseif ( isset( $perma_query_vars[ $wpvar ] ) ) {
$this->query_vars[ $wpvar ] = $perma_query_vars[ $wpvar ];
}
...
}
...
}
可以看到,这里遍历$this->public_query_vars成员变量,如果用户传来了与键名相同的参数,则直接赋值给$this->query_vars。这里也就是说,我们只能控制$this->query_vars中在$this->public_query_vars中的键名的值,也就是只能控制这些键:
array( 'm', 'p', 'posts', 'w', 'cat',
'withcomments', 'withoutcomments', 's', 'search', 'exact', 'sentence',
'calendar', 'page', 'paged', 'more', 'tb', 'pb', 'author', 'order',
'orderby', 'year', 'monthnum', 'day', 'hour', 'minute', 'second',
'name', 'category_name', 'tag', 'feed', 'author_name', 'static',
'pagename', 'page_id', 'error', 'attachment', 'attachment_id',
'subpost', 'subpost_id', 'preview', 'robots', 'taxonomy', 'term',
'cpage', 'post_type', 'embed' );
回到最开始的main()函数:
public function main( $query_args = '' ) {
$this->init();//获取当前用户信息
$this->parse_request( $query_args );//解析路由,匹配路由模式,取出匹配的路由中的用户输入参数(比如year,month等)赋值给$this->query_vars。(并将部分用户参数绑定到$this->query_vars中)。然后进行一些过滤操作。
$this->send_headers();//设置HTTP响应头,比如Content-Type等
$this->query_posts();//根据$this->query_vars等参数,获取posts/pages
$this->handle_404();
$this->register_globals();
do_action_ref_array( 'wp', array( &$this ) );
}
接下来的$this->send_headers()用于设置一些HTTP响应头,这里不再跟进,直接跟进到下面一行的$this->query_posts(),这里就是用于显示一些post/page的地方,也就是本次分析的重点。
query_posts()先经过一些设置成员变量的初始化之后进入到/wp-includes/class-wp-query.php:get_posts()。由于这里代码太多,以及本文是针对“未授权查看私密page”漏洞的,所以这里主要盘一下显示post/page以及鉴权的逻辑,其他的细节不再跟入。
这里先是构造SQL语句查询post/page,然后将查询出的结果赋值给$this->posts。
$split_the_query = apply_filters( 'split_the_query', $split_the_query, $this );
if ( $split_the_query ) {
$this->request = "SELECT $found_rows $distinct {$wpdb->posts}.ID FROM {$wpdb->posts} $join WHERE 1=1 $where $groupby $orderby $limits";
...
$ids = $wpdb->get_col( $this->request );//查询数据库,获取post/page的id
if ( $ids ) {
$this->posts = $ids;
$this->set_found_posts( $q, $limits );//通过id获取page/post
_prime_post_caches( $ids, $q['update_post_term_cache'], $q['update_post_meta_cache'] );
} else {
$this->posts = array();
}
} else {
$this->posts = $wpdb->get_results( $this->request );//获取post的内容
$this->set_found_posts( $q, $limits );
}
这里有两种方法获取,由$split_the_query决定使用哪种方法。目前来看两种方法没有什么区别因此先不跟进split_the_query。
第一次我未登录,并请求urlwordpress-5.2.3/index.php,我们来看一下这里构造成的SQL语句
SELECT SQL_CALC_FOUND_ROWS wp_posts.ID FROM wp_posts WHERE 1=1 AND wp_posts.post_type = 'post' AND (wp_posts.post_status = 'publish') ORDER BY wp_posts.post_date DESC LIMIT 0, 10
这里通过wp_posts.post_status = 'publish'限制我们只能看到public状态的post_type='post'的记录,也就是post。
第二次登陆为管理员,访问同样的url,SQL语句变成如下这样
SELECT SQL_CALC_FOUND_ROWS wp_posts.ID FROM wp_posts WHERE 1=1 AND wp_posts.post_type = 'post' AND (wp_posts.post_status = 'publish' OR wp_posts.post_status = 'private') ORDER BY wp_posts.post_date DESC LIMIT 0, 10
除了多了一个OR wp_posts.post_status = 'private'其他部分都一模一样,也就是说管理员账号可以看到状态为private的post(废话),因此这里猜测,构造wp_posts.post_status=?的附近可能做了鉴权操作。
往上找,找到了构建where post_status语句的地方
$q_status = array();
if ( ! empty( $q['post_status'] ) ) {//由于本路由中无法设置post_status的值,因此第一个if语句块不看
$statuswheres = array();
$q_status = $q['post_status'];
...//根据$q_status构造where子句
} elseif ( ! $this->is_singular ) {
$where .= " AND ({$wpdb->posts}.post_status = 'publish'";
...
if ( $this->is_admin ) {
// Add protected states that should show in the admin all list.
$admin_all_states = get_post_stati(
array(
'protected' => true,
'show_in_admin_all_list' => true,
)
);
foreach ( (array) $admin_all_states as $state ) {
$where .= " OR {$wpdb->posts}.post_status = '$state'";
}
}
if ( is_user_logged_in() ) {
// Add private states that are limited to viewing by the author of a post or someone who has caps to read private states.
$private_states = get_post_stati( array( 'private' => true ) );
foreach ( (array) $private_states as $state ) {
$where .= current_user_can( $read_private_cap ) ? " OR {$wpdb->posts}.post_status = '$state'" : " OR {$wpdb->posts}.post_author = $user_id AND {$wpdb->posts}.post_status = '$state'";
}
}
$where .= ')';
}
这里我们只需要看elseif()语句块,里面显示拼接一个public,然后根据is_admin和is_user_logged_in()来添加一些其他的post_status比如private。由于我们的目标是‘未登录用户访问private内容’,这里暂且不考虑是否能绕过is_admin或者is_user_logged_in()底层的缺陷(当然也不太可能),仅从逻辑上看,如果我们不进入这个elseif语句块,不构建这个where岂不是能读到所有的page/post了?
这个elseif的条件是(!$this->is_singular),我们的目标是让$this->is_singular为正逻辑即可(比如true)。回溯这个变量,找到一处
$this->is_singular = $this->is_single || $this->is_page || $this->is_attachment;
我们只要让这三个变量的任何一个值为true即可,向上找,比较明显的是这处:
if ( ( '' != $qv['attachment'] ) || ! empty( $qv['attachment_id'] ) ) {
$this->is_single = true;
$this->is_attachment = true;
} elseif ( '' != $qv['name'] ) {//wp_posts.post_name
$this->is_single = true;
} elseif ( $qv['p'] ) {//wp_posts.ID
$this->is_single = true;
} elseif ( ( '' !== $qv['hour'] ) && ( '' !== $qv['minute'] ) && ( '' !== $qv['second'] ) && ( '' != $qv['year'] ) && ( '' != $qv['monthnum'] ) && ( '' != $qv['day'] ) ) {
$this->is_single = true;
} elseif ( '' != $qv['static'] || '' != $qv['pagename'] || ! empty( $qv['page_id'] ) ) {
$this->is_page = true;
$this->is_single = false;
} else {
...
}
可见我们只要设置$qv的几个键就好了,比如:attachment、name、p、static等。通过回溯$qv,发现$qv=&$this->query_vars;。query_vars中我们能控制的键只有上文中的$this->public_query_vars里的那些也就是
array( 'm', 'p', 'posts', 'w', 'cat',
'withcomments', 'withoutcomments', 's', 'search', 'exact', 'sentence',
'calendar', 'page', 'paged', 'more', 'tb', 'pb', 'author', 'order',
'orderby', 'year', 'monthnum', 'day', 'hour', 'minute', 'second',
'name', 'category_name', 'tag', 'feed', 'author_name', 'static',
'pagename', 'page_id', 'error', 'attachment', 'attachment_id',
'subpost', 'subpost_id', 'preview', 'robots', 'taxonomy', 'term',
'cpage', 'post_type', 'embed' );
可以看到:attachment、name、p、static这几个键我们都能控制,只要在url参数中直接传就好了。可是通过对比可以很明显的发现,除了最后一个elseif语句块里的is_single为false,其余都为true,也就是只取一条post/page/attachment,通过参数名也可以看出来,如果传递p参数,则只在数据库中找wp_posts.ID匹配的数据,传递name参数则只匹配wp_posts.post_name相同的数据。因此经过对比,这里只有传入static=xxx时,既能绕过后面的where private的限制,也能取出所有数据。
下面开始限制请求的数据类型,page/post/attachment。
if ( 'any' == $post_type ) {
$in_search_post_types = get_post_types( array( 'exclude_from_search' => false ) );
if ( empty( $in_search_post_types ) ) {
$where .= ' AND 1=0 ';
} else {
$where .= " AND {$wpdb->posts}.post_type IN ('" . join( "', '", array_map( 'esc_sql', $in_search_post_types ) ) . "')";
}
} elseif ( ! empty( $post_type ) && is_array( $post_type ) ) {
$where .= " AND {$wpdb->posts}.post_type IN ('" . join( "', '", esc_sql( $post_type ) ) . "')";
} elseif ( ! empty( $post_type ) ) {
$where .= $wpdb->prepare( " AND {$wpdb->posts}.post_type = %s", $post_type );
$post_type_object = get_post_type_object( $post_type );
} elseif ( $this->is_attachment ) {
$where .= " AND {$wpdb->posts}.post_type = 'attachment'";
$post_type_object = get_post_type_object( 'attachment' );
} elseif ( $this->is_page ) {
$where .= " AND {$wpdb->posts}.post_type = 'page'";
$post_type_object = get_post_type_object( 'page' );
} else {
$where .= " AND {$wpdb->posts}.post_type = 'post'";
$post_type_object = get_post_type_object( 'post' );
}
可以看到post_type为空时,如果is_page为true则设置post_type为page,因此只能获取page类型的数据。
通过设置static=xxx,调试之后可以看到最终的SQL语句如下,已经没有了post_status是public还是private的限制:
SELECT wp_posts.* FROM wp_posts WHERE 1=1 AND wp_posts.post_type = 'page' ORDER BY wp_posts.post_date DESC


此时所有的page已经全部存储到$this->posts中,下面要看看这些posts是否会渲染出来。以下是相关代码
// Check post status to determine if post should be displayed.
if ( ! empty( $this->posts ) && ( $this->is_single || $this->is_page ) ) {
$status = get_post_status( $this->posts[0] );//获取$this->posts中的第一个元素的post_status
...
$post_status_obj = get_post_status_object( $status );
// If the post_status was specifically requested, let it pass through.
if ( ! $post_status_obj->public && ! in_array( $status, $q_status ) ) {//如果post_status_obj的public属性为true或post_status在$q_status中,则不进入此if。由于本文前面已经分析$q_status不可控且为空,因此主要看第一个条件。
if ( ! is_user_logged_in() ) {
// User must be logged in to view unpublished posts.
$this->posts = array();//无权限查看
} else {
if ( $post_status_obj->protected ) {
...更细的鉴权
} elseif ( $post_status_obj->private ) {
if ( ! current_user_can( $read_cap, $this->posts[0]->ID ) ) {
$this->posts = array();//无权限查看
}
} else {
$this->posts = array();//无权限查看
}
}
}
...
}
由于$this->posts是我们要读的pages,且is_page为true,因此第一个if判断是必进的。接下来就是有意思的地方了,下面获取了$this->posts中的第一篇文章,如果其是public就可以不进入第二个if语句,从而就直接绕过了“回显鉴权”这一部分。所以我们只要保证$this->posts的第一篇文章为public状态的即可。通过order by我们可以把最旧的文章放在最上面,也就是正序asc查询,因为一般来说旧的文章权限为public的可能性大一些。
之前的SQL语句为
SELECT wp_posts.* FROM wp_posts WHERE 1=1 AND wp_posts.post_type = 'page' ORDER BY wp_posts.post_date DESC
通过回溯发现可以通过$this->query_vars['order']来控制升序还是降序,因此我们只要在url中加上order=asc即可。
回顾上面的分析整理一下逻辑,传入static=xxx -> is_page=true -> is_singular=true -> 不使用where子句限定private/public/... -> 获取所有page -> 最后显示前鉴权时仅检查第一个page的权限。
把这个逻辑抽象出来可以知道,在只取得一个page/post时是没问题的,因为最后display之前会进行一次鉴权。我们的主要关注点是获得多条数据,因为这样会绕过最后display之前只验证第一条数据的鉴权操作。保证获得多条数据的同时又要保证$this->is_single,$this->is_page,$this->is_attachment其中一个是true才能绕过where子句的限制。
逻辑出来了,官方补丁是删除了static变量,是否可以绕过这个补丁?首先回顾一下初始化这几个成员变量的地方:
if ( ( '' != $qv['attachment'] ) || ! empty( $qv['attachment_id'] ) ) {
$this->is_single = true;
$this->is_attachment = true;
} elseif ( '' != $qv['name'] ) {//wp_posts.post_name
$this->is_single = true;
} elseif ( $qv['p'] ) {//wp_posts.ID
$this->is_single = true;
} elseif ( ( '' !== $qv['hour'] ) && ( '' !== $qv['minute'] ) && ( '' !== $qv['second'] ) && ( '' != $qv['year'] ) && ( '' != $qv['monthnum'] ) && ( '' != $qv['day'] ) ) {
-$this->is_single = true;
} elseif ( '' != $qv['static'] || '' != $qv['pagename'] || ! empty( $qv['page_id'] ) ) {
$this->is_page = true;
$this->is_single = false;
} else {
...
}
把这几个if条件都带入程序中走一遍发现,除了static这个语句块,其之前的所有if条件都将查询的结果限制到了<=1条,从而不会存在逻辑问题,这也是is_single的含义。官方修复的补丁是将这个static参数去掉,变成了elseif(''!=$qv['pagename'] || !empty($qv['page_id'])),而这个条件也限制了只能取得一页,但是is_single这里是false不知道是什么原因。似乎是安全的?
0x02 思考
经过一番思考之后感觉这个补丁并没有从根本上解决问题,如果可以获得多条数据并且没有where子句的限制仍然可以触发漏洞。刚刚说了,那几个if条件都将查询的结果限制到了<=1条,但是这样真的就安全了?如果程序将这些参数拼接到类似于where ... wp_posts.post_name like $qv['name']还是会出现问题,这里就不展开说了。我大概找了一下,明显的地方没有看到这样的用法,但是还有一些稍微底层的函数没有跟,这里先留了一个坑。
0x03 总结
在分析漏洞时一直在尝试逆推作者的挖洞思路,可是由于我之前分析SQL注入、反序列化这类漏洞比较多,对于这种逻辑漏洞的挖掘还是有些陌生的。对于逻辑漏洞,我认为分析时不适合SQL注入、XSS那种通过漏洞点反推的方式,不够‘自然’,而是应该先通过了解出现逻辑错误的功能模块的实现,然后结合官方diff来做会好一些。
0x04 参考
CVE-2019-17671
受影响版本
Wordpress 5.2.3 未授权页面查看漏洞(CVE-2019-17671)分析
Wordpress未授权查看私密内容漏洞 分析(CVE-2019-17671)的更多相关文章
- CVE-2019-17671:Wordpress未授权访问漏洞复现
0x00 简介 WordPress是一款个人博客系统,并逐步演化成一款内容管理系统软件,它是使用PHP语言和MySQL数据库开发的,用户可以在支持 PHP 和 MySQL数据库的服务器上使用自己的博客 ...
- jboss 未授权访问漏洞复现
jboss 未授权访问漏洞复现 一.漏洞描述 未授权访问管理控制台,通过该漏洞,可以后台管理服务,可以通过脚本命令执行系统命令,如反弹shell,wget写webshell文件. 二.漏洞环境搭建及复 ...
- WordPress Backdoor未授权访问漏洞和信息泄露漏洞
漏洞名称: WordPress Backdoor未授权访问漏洞和信息泄露漏洞 CNNVD编号: CNNVD-201312-497 发布时间: 2013-12-27 更新时间: 2013-12-27 危 ...
- 10.Redis未授权访问漏洞复现与利用
一.漏洞简介以及危害: 1.什么是redis未授权访问漏洞: Redis 默认情况下,会绑定在 0.0.0.0:6379,如果没有进行采用相关的策略,比如添加防火墙规则避免其他非信任来源 ip 访问等 ...
- Redis未授权访问漏洞复现及修复方案
首先,第一个复现Redis未授权访问这个漏洞是有原因的,在 2019-07-24 的某一天,我同学的服务器突然特别卡,卡到连不上的那种,通过 top,free,netstat 等命令查看后发现,CPU ...
- CVE-2019-17671:wrodpress 未授权访问漏洞-复现
0x00 WordPress简介 WordPress是一款个人博客系统,并逐步演化成一款内容管理系统软件,它是使用PHP语言和MySQL数据库开发的,用户可以在支持 PHP 和 MySQL数据库的服务 ...
- (数据库提权——Redis)Redis未授权访问漏洞总结
一.介绍 1.Redis数据库 Redis(Remote Dictionary Server ),即远程字典服务,是一个开源的使用ANSI C语言编写.支持网络.可基于内存亦可持久化的日志型.Key- ...
- 【转+自己研究】新姿势之Docker Remote API未授权访问漏洞分析和利用
0x00 概述 最近提交了一些关于 docker remote api 未授权访问导致代码泄露.获取服务器root权限的漏洞,造成的影响都比较严重,比如 新姿势之获取果壳全站代码和多台机器root权限 ...
- redis 未授权漏洞利用直接登录服务器
在没有查到杀手之前我是先把带宽&端口用iptables 做了限制这样能保证我能远程操作服务器才能查找原因 2 在各种netstat –ntlp 的查看下没有任何异常 在top 下查到了有异常 ...
随机推荐
- 【Auto.js images.matchTemplate() 函数的特点】
Auto.js images.matchTemplate() 函数的特点 官方文档:https://hyb1996.github.io/AutoJs-Docs/#/images?id=imagesm ...
- RocketMQ4.2 最佳实践之集群搭建
学习了RocketMQ的基本概念后,我们来看看RocketMQ最简单的使用场景.RocketMQ的服务器最简单的结构,必须包含一个NameServer和一个Broker.Producer把某个主题的消 ...
- Chrome插件开发(四)
在前面我们编写了三个比较实用的插件,在实际工作中,我们还会使用很多其他的插件,比如掘金,Pocket之类的,我们可能需要经常启用或禁用插件或者删除插件,如果每次都要点到更多工具->扩展程序中去做 ...
- 第二十九章 System V共享内存
共享内存数据结构 共享内存函数 shmget int shmget(key_t key, size_t size, int shmflg); 功能: 用于创建共享内存 参数: key : 这个共享内存 ...
- ssh WARNING:REMOTE HOST IDENTIFICATION HAS CHANGED(警告:远程主机标识已更改)
ssh 192.168.1.88 出现以下警告: @@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@@ WARNING: REMOT ...
- vue学习之深入响应式原理
vue的响应式原理 当你把一个普通的 JavaScript 对象传入 Vue 实例作为 data 选项,Vue 将遍历此对象所有的属性,并使用 Object.defineProperty 把这些属性全 ...
- activeMQ 安装及启动异常处理
一.环境: [root@centos_6 ~]# cat /etc/system-release CentOS release 6.5 (Final) [root@centos_6 ~]# uname ...
- 还不会用FindBugs?你的代码质量很可能令人堪忧
前言 项目中代码质量,往往需要比较有经验的程序员的审查来保证.但是随着项目越来越大,代码审查会变得越来越复杂,需要耗费越来越多的人力.而且程序员的经验和精力都是有限的,能审查出问题必定有限.而在对代码 ...
- 洛谷P5522 【[yLOI2019] 棠梨煎雪】
区间操作考虑用线段树维护. 建\(n*2\)棵线段树,前\(n\)棵线段树维护每个串的第i位是否是0. 后\(n\)棵线段树维护每个串的第i位是否是1. 如果是问号的话,直接跳过就好(通过1和0能看出 ...
- MIT线性代数:10.4个基本子空间