1. 程式人生 > >Wordpress未授權檢視私密內容漏洞 分析(CVE-2019-17671)

Wordpress未授權檢視私密內容漏洞 分析(CVE-2019-17671)

目錄

  • 0x00 前言
  • 0x01 分析
  • 0x02 思考
  • 0x03 總結
  • 0x04 參考

0x00 前言

沒有

0x01 分析

這個漏洞被描述為“匿名使用者可訪問私密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不知道是什麼原因。似乎是安全的?

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)