zoukankan      html  css  js  c++  java
  • 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)分析

  • 相关阅读:
    How to change hostname on SLE
    How to install starDIct on suse OS?
    python logging usage
    How to reset password for unknow root
    How to use wget ?
    How to only capute sub-matched character by grep
    How to inspect who is caller of func and who is the class of instance
    How to use groovy script on jenkins
    Vim ide for shell development
    linux高性能服务器编程 (二) --IP协议详解
  • 原文地址:https://www.cnblogs.com/litlife/p/11980530.html
Copyright © 2011-2022 走看看