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

  • 相关阅读:
    精益敏捷企业的七大核心能力和实施路线图-Scrum中文网
    leangoo思维导图实用过程
    Scrum看板+思维导图联动,高效创新
    Leangoo敏捷开发工具- 6.0.2 版发布
    多项目协同管理软件
    项目管理软件~leangoo
    用Leangoo看板工具做采购流程管理
    Android的简单应用(四)——字符串处理
    Android的简单应用(三)——为你的程序添加监听器
    使用WindowManager添加View——悬浮窗口的基本原理
  • 原文地址:https://www.cnblogs.com/litlife/p/11980530.html
Copyright © 2020-2023  润新知