WordPress 5.2.3 unauthorized page viewing vulnerability

FOREWORD

wordpress5.2.3存在未授权页面访问漏洞,攻击者可以通过这种漏洞观看到受害者得所有敏感文件;包括隐私文件和加密文件;

text

首先声明,此漏洞可以未授权访问页面,但是不能未授权访问文章;本地复现可以通过下载wp源码然后在phpstudy中自行搭建;在new的地方选择page,而不是post;

Vulnerability analysis

首先进入wp-includes/class-wp.php中;有个WP的类;该类是处理框架环境变量的地方,可以看到其定义了公有和私有的查询变量等等,

Dw2GcQ.png

然后在类的结束地方,有一个main函数定义,该main函数调用了各个方法

Dw2tns.png

在环境初始化的时候,会先调用WP类,将其实例化,然后利用main去调用相应的方法;从而去设置环境变量;看到在main中调用了parse_request方法;回溯一下;

Dw2JXj.png

发现class WP中定义了这个方法;其作用是:解析 (GET/POST) 请求以找到正确的WordPress查询。根据请求设置查询变量。 还有很多可用于进一步处理结果的过滤器和操作;

继续审计,进入一个敏感的foreach方法;

1
2
3
4
5
6
7
8
9
10
11
12
13
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 ];
}

发现其遍历了公共变量,因为extra_query_vars最开始赋值的时候是个空数组;所以第一个if自然绕过,然后进入elseif;这个条件;第一个elseif满足的时候,直接die掉,并且返回400;不满足进入下一步,获取我们POST或者GET传入的数据;如果有的话就将其纳入我们的环境参数变量中;

假如我们传入如下的paylaod:http://127.0.0.1/wordpress/?static=0&order=asc&s1mple=test

由我们比对可知,public_query_vars中存在static和order;并且我们GET请求的参数中也有,那么会将我们的这两个变量纳入到我们的环境变量中;也就是将其赋值给query_vars[ static ]query_vars[ order ];因为我们的public_query_vars中没有s1mple变量,所以这里不做处理;(主要点还是在系统自己设置的环境变量中);

Vulnerability trigger point

差不多分析完了环境变量机制之后,来分析下漏洞的成因;

首发点在 \wp-includes\class-wp-query.php

此页中有个parse_query的方法;此方法也是在wp启动的时候被调用的,只不过在parse_requests方法之后,也就是在设置好环境变量之后才被调用;

我们请求如下的payload:http://127.0.0.1/wordpress/?static=0&order=asc

首先分析数据流经过的处理,首先我们之前先进入parse_request函数进行处理;处理完之后我们上面也说到随后才是parse_query的方法;在我们parse_request方法中,我们发现query_vars是被定义成了一个数组,然后再WP_Query中也是顺接着被定义成了一个数组,我们WP_Query类中的query_vars变量因为来自于parse_request方法处理之后,所以其返回的效果应该是一个数组;Array(“static”=>”0”,”order”=>”asc”);

Dw2U7q.png

回到parse_query方法中如上图,发现qv变量获取了query_vars的值,在此之前query_vars的值经过了fill_query_vars函数的处理;回溯一下;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
public function fill_query_vars( $array ) {
$keys = array(
'error',
'm',
'p',
'post_parent',
'subpost',
'subpost_id',
'attachment',
'attachment_id',
'name',
'static',
'pagename',
'page_id',
'second',
'minute',
'hour',
'day',
'monthnum',
'year',
'w',
'category_name',
'tag',
'cat',
'tag_id',
'author',
'author_name',
'feed',
'tb',
'paged',
'meta_key',
'meta_value',
'preview',
's',
'sentence',
'title',
'fields',
'menu_order',
'embed',
);

foreach ( $keys as $key ) {
if ( ! isset( $array[ $key ] ) ) {
$array[ $key ] = '';
}
}

发现fill_query_vars方法回显遍历数组中的值,如果没有,就将其值置为空;但是我们这里请求的两个query;所以会给我们两个参数进行赋值;最后的效果就会是:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
$keys = array(
'order'='asc',
'static'='0',
'error',
'm',
'p',
'post_parent',
'subpost',
'subpost_id',
'attachment',
'attachment_id',
'name',
'static',
'pagename',
'page_id',
'second',
'minute',
'hour',
'day',
'monthnum',
'year',
'w',
'category_name',
'tag',
'cat',
'tag_id',
'author',
'author_name',
'feed',
'tb',
'paged',
'meta_key',
'meta_value',
'preview',
's',
'sentence',
'title',
'fields',
'menu_order',
'embed',
);

经过fill_query_vars函数处理之后,最后赋值给qv变量,所以最后qv变量就为上处代码内容;即如果没有query变量得话,就会调用init方法,进行初始化,然后最后经过fill

继续向下审计:进入if函数;经过很多的if判断和处理,最后关键的代码在805行;如下图:

Dw281g.png

我们传入了static参量使其不为空;所以我们会将is_page赋值为true;将is_single赋值为false;这里page就是页面的意思;所以这个if长语句就是用static,pagename,page_id这几个变量来判断是否需要进行page操作;这里我们传入static为0;显然是让程序将is_page赋值为true从而进行页面操作;继续向下审计;有关键的代码:

Dw2dA0.png

这里审计下if语句,因为我们之前已经通过操作让is_page为true,让is_single为false;所以$this->is_single || $this->is_page恒为真;所以我们只需要操作让posts不为空,那么就可以进入if下的函数;先来回溯下posts的来源:看如下代码:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
$this->request = $old_request = "SELECT $found_rows $distinct $fields FROM {$wpdb->posts} $join WHERE 1=1 $where $groupby $orderby $limits";

if ( ! $q['suppress_filters'] ) {
/**
* Filters the completed SQL query before sending.
*
* @since 2.0.0
*
* @param string $request The complete SQL query.
* @param WP_Query $this The WP_Query instance (passed by reference).
*/
$this->request = apply_filters_ref_array( 'posts_request', array( $this->request, &$this ) );
}

/**
*
* @since 4.6.0
*
* @param array|null $posts Return an array of post data to short-circuit WP's query,
* or null to allow WP to run its normal queries.
* @param WP_Query $this The WP_Query instance (passed by reference).
*/
$this->posts = apply_filters_ref_array( 'posts_pre_query', array( null, &$this ) );

if ( 'ids' == $q['fields'] ) {
if ( null === $this->posts ) {
$this->posts = $wpdb->get_col( $this->request );
}

$this->posts = array_map( 'intval', $this->posts );
$this->post_count = count( $this->posts );
$this->set_found_posts( $q, $limits );

return $this->posts;
}

这里分析下。直接一个sql查询将我们的回显结果给了request;然后经过一个if判断,我们查询的语句中查询参数是没有suppress_filters参量的,所以我们这里直接过if判断,进入下面的处理;下面我们可以简单的理解为创建了一个过滤器钩子;然后接着进入下面的if判断语句,因为我们没有fields参量,所以我们直接过if;进入下面,array_map函数处理之后就赋值给了psots;下面的处理也可以暂时不用管,最后发现是return了posts;然后这里分析一下request的查询语句;

这里看到了这个sql语句是经过了很多变量拼接而成,那么我们回溯下相关的变量,$where在2406行;如下:

Dw2NBn.png

因为我们之前经过传入static使is_page为true,所以这里直接进行$where赋值;看到语句中是否有一处无法理解?{$wpdb->posts};这个变量,如果我们仔细观察,会发现是wordpressdatabase的一个简写;那么猜想和wordpress的数据库有关系,实际上是确实有关系的,其表达的是一个表名;看如下mysql数据库中的查询结果结合上述的sql语句就可知道;

Dw2sc4.png

不难理解;是一个wp_posts的表名;那么where变量就很好理解了;审计代码会发现代码对$q的属性进行了多个if判断,如果$q的属性存在的话,就将该属性用相应的sql语句进行赋值给where,每个属性对应的sql语句都不一样,具体的师傅们可以自己审计源码,我审计的结果是其对应得sql语句不一样;那么我们对应得is_page属性对应的where参量的sql语句处理的是在wp_posts表下的post_type,即我们page的类型;这里直接看sql语句就可懂(type);那么继续回溯我们的orderby参数;可以追溯到如下函数:

1
2
3
if ( ! empty( $orderby ) ) {   ////在2882行
$orderby = 'ORDER BY ' . $orderby;
}
1
2
3
4
if ( empty( $orderby ) ) {    ////在2330行
$orderby = "{$wpdb->posts}.post_date " . $q['order'];
} elseif ( ! empty( $q['order'] ) ) {
$orderby .= " {$q['order']}";

所以我们可以看到首先给其赋值为wp_posts.post_data+’xxxx’;然后再后续对其进行ORDER BY 拼接;又因为我们传入的参数中有个order=asc,已经被写入了环境变量中,所以这里直接调用;这之所以会直接调用order实际上也是因为$q的原因,我在代码的一处注释中发现了如下信息:

1
* @param array  $q      Query variables.

这里不难发现,我们的$q 是一个数组类型,并且是我们查询变量的”汇总”,那么我们调用查询变量中的order也就可以完全自动调用我们传入的order参数,即asc;

所以综上的回溯,我们request的最后拼接语句就为:

SELECT wp_posts.* FROM wp_posts WHERE 1=1 and post.type= 'page'ORDER BY post.data ASC;

下面继续从我们的数据库中的wp_posts中研究,我将其全部爆库,发现里面存在的内容为我们文章的信息和我们的文章类型还有一些其他的信息;当我们同时查询guid和post_type的时候,我们发现数据库中页面和类型是同时存储得;如下图:

Dw20hT.png

这里post是指的是我们的文章,那么我们创建的页面也就是我们后面type对应的page类型;当我们三个一起列出来的时候;如下图:

Dw2lh8.png

已经很显然了;我们的文章或者页面的信息,是按照type进行分类的,后面是跟着我们的status,也就是我们页面设置的状态,可以看到我们里面有些设置了private;这属于私有的页面,我们其他用户在网站上是无法进行直接访问的;说到这里后我们再次回顾我们之前的审计情况,有一处is_page的地方,我们在之前的赋值statuc和order使程序将其赋值为true;又因为其是true,再次导致了程序直接将其按照page整体来带入sql语句进行查询,那么就会反观上面的sql语句,就会列出我们所有的page页面;直接看sql语句也可以,我也在本地进行了一次复现查询;如下图:

Dw2D9U.png

sql语句直接列出了我们page在数据库中的内容;也即是列出了我们所有的page;实验如下图,因为我页面有数据,所以这里sql查询出来的语句不太好观察;具体的可以看之前的几个图,列出了ID,也就是自然列处了我们的page页面的内容,并将其排列;自然的情况下如果我们不加order by的话,那么是按照我们的设置的页面隐私性来由隐私到公共排列的,通过排序是3 6 2 12的顺序就可以证明,我3的页面设置为需要密码,6为私有,其他两个为公共;如下图:

Dw2wNV.png

同样我列出了时间和page的关系,也证明了不是按照时间顺序排列的;(结合之前得查询ID得结果得到得结论)

Dw239S.png

但是我们这里漏洞要利用的sql是需要按照date进行升序排列,具体原因后面细说;

分析完了posts的结果,我们继续回到漏洞点;

因为我们已经回溯清楚posts的内容,对于我来说这里就是四个页面,不同的人要根据不同页面的情况来视;这里页面的post[0]自然是我们的页面首位,因为最早发布的页面,肯定是我们的原始页面也就是 HelloWord;其属性为public;剩下的页面也就是我们自己设置的,属性也是由我们自己定义的;继续向下审计代码;

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
if ( ! empty( $this->posts ) && ( $this->is_single || $this->is_page ) ) {
$status = get_post_status( $this->posts[0] );
if ( 'attachment' === $this->posts[0]->post_type && 0 === (int) $this->posts[0]->post_parent ) {
$this->is_page = false;
$this->is_single = true;
$this->is_attachment = true;
}
$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 ) ) {

if ( ! is_user_logged_in() ) {
// User must be logged in to view unpublished posts.
$this->posts = array();
} else {
if ( $post_status_obj->protected ) {
// User must have edit permissions on the draft to preview.
if ( ! current_user_can( $edit_cap, $this->posts[0]->ID ) ) {
$this->posts = array();
} else {
$this->is_preview = true;
if ( 'future' != $status ) {
$this->posts[0]->post_date = current_time( 'mysql' );
}
}
} elseif ( $post_status_obj->private ) {
if ( ! current_user_can( $read_cap, $this->posts[0]->ID ) ) {
$this->posts = array();
}
} else {
$this->posts = array();
}
}
}

有个关键的代码块;审计程序判断了我们的首位也页面是不是public;如果不是的话,就需要验证登陆,是public的话,就不会验证登陆;所以我们按照时间顺序进行升序排列,那么初始页面就为public,即是posts[0];那么public自然可以绕过最开始的if判断;也就可以直接把所有页面列出来;