Wordpress is not authorized to view private content analysis of vulnerability (CVE-2019-17671)

0x00 Foreword

No

0x01 analysis

This vulnerability is described as "anonymous users can access private page", infer a right judgment out of the question. If you want to get to know where the problem is, you must first know wp get page (page) principle / post (article), and find out the logic of right judgment and logic in order to know where to be a problem.

Here we begin to see directly from the core processing flow of the main function wp, / 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 () call directly underlying wp_get_current_user () $ CURRENT_USER global variables, which is a WP_User class, which stores the current user's meta information, not signed $ current_user-> ID === 0.

Then enter the $ this-> parse_request, this routing function is mainly used for processing, initialize $ this-> query_vars. Point of view divided into two parts, the first part is a processing route, the route matching rewrite mode.

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 );

            ...
        }

        ...
    }

A second portion, parse the user parameters, arranged $ this-> value of 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 ];
        }
        ...
    }
    ...
}

It can be seen here traverse $ this-> public_query_vars member variables, if you came with the key parameters of the same name, is assigned directly to the $ this-> query_vars. Here is to say, we can only control $ this-> name query_vars key value in the $ this-> public_query_vars in, which is the only control these keys:

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' );

Back to the beginning of the main () function:

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 ) );
}

The next $ this-> send_headers () is used for setting the HTTP response header, there is no longer follow, the following line to directly follow the $ this-> query_posts (), this is useful to display post / page where , which is the focus of this analysis.

query_posts () first set after some member variable initialization into /wp-includes/class-wp-query.php:get_posts (). Because the code here too, and this article is for "not authorized to view private page" vulnerability, so here the main dish at the display logic post / page and authentication, not with the other details.

Here, first construct SQL statements to query post / page, and then assign the result to check out the $ 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 );
}

There are two ways to get, up to a $ split_the_query which method to use. Now therefore no difference between the two methods to not follow split_the_query.

The first time I logged in, and request url wordpress-5.2.3/index.php, here we look at the structure into a SQL statement

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

Here by wp_posts.post_status = 'publish'restrictions we can only see post_type public status = 'post' records, which is the post.

Second landing administrators access the same url, SQL statements become the following manner

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

In addition to one more OR wp_posts.post_status = 'private'other parts are exactly the same, that is to say for the administrator account can see the status of private post (crap), so guess where construction wp_posts.post_status=?nearby made possible authentication operation.

Looking up, I found a place to build where post_status statement

$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 .= ')';
}

Here we only need to look elseif () statement block, which shows a splice public, then according to is_admin and is_user_logged_in () to add some other post_status such as private. Since our goal is to 'not logged in users to access private content', this would not consider whether to bypass is_admin or is_user_logged_in () the underlying defect (of course, not likely), only from the logical point of view, if we do not enter this elseif statement block, do not build this where would not be able to read all of the page / post up?

This condition is elseif (! $ This-> is_singular), our goal is to make $ this-> is_singular positive logic can (such as true). This variable backtracking to find a

$this->is_singular = $this->is_single || $this->is_page || $this->is_attachment;

As long as we make these three variables can be of any value is true, up to find, more obvious it is that this place:

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 {
    ...
}

This shows that we just set a few key $ qv just fine, such as: attachment, name, p, static and so on. By backtracking $ qv, it was found $qv=&$this->query_vars;. Key query_vars we can control only in above $ this-> public_query_vars in those is

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' );

We can see: attachment, name, p, static control these keys we can, as long as the direct transfer in the url parameter just fine. But can clearly be found by comparing, except for the last elseif statement block in the is_single is false, the rest are true, that is, just take a post / page / attachment, can also be seen through the parameter name, if you pass p parameter, wp_posts.ID data only to find matches in the database, the name parameter passed only wp_posts.post_name data match the same. Accordingly By comparison, where only incoming static = xxx, bypassing both limits where private behind, all the data can be extracted.

Following the start of the requested data type restrictions, 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' );
}

When you can see post_type empty, if is_page post_type is set to true for the page, you can only get page types of data.

By setting static = xxx, after commissioning you can see the end of the SQL statement is as follows, has no post_status is to limit the public or private:

SELECT   wp_posts.* FROM wp_posts  WHERE 1=1  AND wp_posts.post_type = 'page'  ORDER BY wp_posts.post_date DESC 

At this point all the page have all been stored in $ this-> posts, the following to see whether these posts will be rendered. The following is the relevant code


// 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();//无权限查看
            }
        }
    }

    ...
}

Since $ this-> posts are we to read the pages, and is_page is true, so the first if the judge will enter. Then there is the interesting thing, the following acquired $ this-> posts in the first article, if it is public can not enter the second if statement, thus directly bypassing the "echo authentication" It portion. So long as we guarantee $ this-> posts first article for the public to state. By order by oldest we can put the article on the top, which is the positive sequence asc inquiry because, in general older posts public authority for the possibility of larger.

Before the SQL statement

SELECT   wp_posts.* FROM wp_posts  WHERE 1=1  AND wp_posts.post_type = 'page'  ORDER BY wp_posts.post_date DESC 

Be> query_vars [ 'order'] for controlling ascending or descending order by $ this- found by backtracking, so long as we can add order = asc in the url.

Recalling the above analysis tidy logic, the incoming static = xxx -> is_page === true -> is_singular === true -> no where clause limited private / public / ... -> Get all page -> Last check permissions only the first page is displayed before authentication.

This logic abstracted know, when obtaining only a page / post is no problem, because it will perform an authentication prior to final display. Our main concern is to obtain a plurality of data, because it will only verify authentication bypass operation before the first data of the last display. At the same time guarantee access to multiple data but also to ensure $ this-> is_single, $ this-> is_page, $ this-> is_attachment one of which is true to circumvent restrictions where clause.

Logic out the official patch is removed static variable, can bypass this patch? First look initialize member variables of these places:

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 {
    ...
}

Some if these conditions are brought into the program to go again found, in addition to the static statement block, if all of its conditions before the results of the query are limited to <= 1, so there will be no logical problem, which is the is_single meaning. Official repair patch is to remove the static parameters, into elseif(''!=$qv['pagename'] || !empty($qv['page_id'])), and this condition also limits can only get one, but this is false is_single not know why. It appears to be safe?

0x02 thinking

After some thought feel this patch does not solve the problem fundamentally, if a plurality of data can be obtained and there is no limit where clause can still trigger the vulnerability. Just said, that if several conditions are the result of the query is limited to a <= 1, but this really safe? If the program is similar to splice these parameters where ... wp_posts.post_name like $qv['name']still have problems, do not start here say. I probably look a bit, did not see the obvious place such usage, but there are some slightly with the bottom of the function does not, first left a hole here.

0x03 summary

In analyzing vulnerabilities have been trying to dig Backward thinking of, but because of my analysis of SQL injection, de-serialization of these vulnerabilities are more for digging this logic flaw or some strange before. For a logical flaw, I think it is not suitable for the analysis of SQL injection, XSS vulnerabilities that thrust reversers by a point system, not 'natural', but realized the error of logic function module should first appear through understanding, and then combined to do the official diff It will be better.

0x04 Reference

CVE-2019-17671
affected versions of
analysis Wordpress 5.2.3 Unauthorized page to view vulnerability (CVE-2019-17671)

Guess you like

Origin www.cnblogs.com/litlife/p/11980530.html