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 

Wordpress未授权查看私密内容漏洞 分析(CVE-2019-17671)-LMLPHP

Wordpress未授权查看私密内容漏洞 分析(CVE-2019-17671)-LMLPHP

此时所有的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)分析

12-04 14:37