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)分析