maccarty 2019-12-04
目录
没有
这个漏洞被描述为“匿名用户可访问私密page”,由此推断是权限判断出了问题。如果想搞懂哪里出问题,必然要先知道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不知道是什么原因。似乎是安全的?
经过一番思考之后感觉这个补丁并没有从根本上解决问题,如果可以获得多条数据并且没有where子句的限制仍然可以触发漏洞。刚刚说了,那几个if条件都将查询的结果限制到了<=1条,但是这样真的就安全了?如果程序将这些参数拼接到类似于where ... wp_posts.post_name like $qv[‘name‘]
还是会出现问题,这里就不展开说了。我大概找了一下,明显的地方没有看到这样的用法,但是还有一些稍微底层的函数没有跟,这里先留了一个坑。
在分析漏洞时一直在尝试逆推作者的挖洞思路,可是由于我之前分析SQL注入、反序列化这类漏洞比较多,对于这种逻辑漏洞的挖掘还是有些陌生的。对于逻辑漏洞,我认为分析时不适合SQL注入、XSS那种通过漏洞点反推的方式,不够‘自然’,而是应该先通过了解出现逻辑错误的功能模块的实现,然后结合官方diff来做会好一些。
CVE-2019-17671
受影响版本
Wordpress 5.2.3 未授权页面查看漏洞(CVE-2019-17671)分析