WordPress在5.2.3版本之前,存在着一处未授权页面查看漏洞,攻击者可以在未授权的情况下,查看所有私密页面或是已经删除至回收站的页面

这个漏洞,请注意我的描述用字,是私密页面查看,而非私密文章查看,这点很关键,在wordpress中,POST指的是文章,Page指的是页面,这两个不是同一个概念,如下图

MJZS78.png

页面:

用户可以单独建立一个固定页面,可以作为留言板,或者通知的单页面,发布之后是固定的网址。页面并不能被分类、亦不能拥有标签,但是它们可以有层级关系。可将页面附属在另一个页面之下

文章:

文章可以通过标签实现相关文章的链接,可以放评论和评论框来实现与用户的互动,页面没有。文章有栏目可以归档,还有标签,页面没有。编辑文章时可选不同的形式,页面没有。

利用这个漏洞,攻击者并不能查看未发布的文章,只能有一定几率查看私密或已以至回收站的页面

漏洞分析

我们将自上而下,从wordpress入口到漏洞触发点,来分析该漏洞

首先来看位于\wp-includes\class-wp.php 中的WP类,该类为WordPress环境设置类

在WP类中,存在$public_query_vars数组,该数组用来定义公共查询变量,如下图

MYH2hF.png

在WP类中,存在main方法,该方法用来设置WordPress环境所需的所有变量,如下图

MY73IU.png

Wordpress启动时,会调用WP类中的main方法,进行环境遍历赋值,位于上图main方法737行处,可见调用parse_request方法

parse_request方法的作用是,解析请求(GET/POST)以找到正确的WordPress查询,根据请求设置查询变量。如下图

MJVO1A.png

该方法中存在一处foreach循环,遍历WP类中定义的$public_query_vars数组值,如下图

MJZeBV.png

该处循环的作用,是寻找$public_query_vars数组中的值,是否存在于GET/POST请求的参数中,如过在请求的参数中找到,就将其参数键与值赋值到WP环境变量中去,$public_query_vars数组见上文

例如有如下payload请求

http://127.0.0.1/wordpress/?static=0&order=asc&kumamon=test

$public_query_vars数组中存在”order”与” static”

MJVI0K.png

而GET请求的参数中也存在这两个参数,于是程序会将GET中order与static的值赋值给$this->query_vars[‘order’]与$this->query_vars[‘static],如下图

MJV7kD.png

$public_query_vars数组中并无”kumamon”,因此GET请求中的kumamon变量不做处理

Wordpress的环境变量机制了解完毕后,接下来看下漏洞触发点

首先来看下

\wp-includes\class-wp-query.php中的parse_query方法

该方法也是在wordpress启动时入口处被一系列的调用加载进来的,执行顺序位于parse_request方法之后,也就是环境变量赋值之后

MY7YRJ.png

我们重点关注下$qv变量,如下图红框处

MJZm7T.png

该变量为$this->query_vars引用而来的,而$this->query_vars则是$this->query_vars经过fill_query_vars方法处理之后的值

当我们的请求为http://127.0.0.1/wordpress/?static=0&order=asc

$this->query_vars值如下,该值由parse_request方法得来

MJVHte.png

而fill_query_vars方法,是将其他并未从请求中传递与赋值的环境变量用空值赋值

我们这里仅仅通过请求赋值了static与order两个环境变量,因此$this->query_vars值如下

MJVjXt.png

$qv为$this->query_vars引用,因此其中值与上图一致

接下来,位于805行处,有如下if-else条件

MJVX6I.png

由于我们通过GET请求传入static变量,已经将$qv[‘static’]赋值为0,因此可以进入上图条件分支,使得$this->is_page=true,
$this->is_single=false

还记得之前所说,Page指的是页面吗?因此上文的这个if条件,是为了通过检查请求中是否有’static’、’pagename’、’page_id’值,来判断是否要进行页面(Page)处理,如果是,则将$this->is_page设置为true

继续向下看,位于3043行处,存在如下if条件分支

MJVbfH.png

此时$this->is_page=true, $this->is_single=false,所以if中(
$this->is_single || $this->is_page )处的值为true。

只要使得$this->posts不为空,则可以进入此处if分支

$this->posts值为如下sql语句的查询值

MY7Krq.png

这里解释下,为什么sql语句WHERE中wp_posts.post_type = ‘page’ 且ORDER BY
wp_posts.post_date ASC

首先看下wp_posts.post_type = ‘page’

由于上文设置了$this->is_page=true,因此进入如下条件分支

MJZAcn.png

此处设置了查询条件为wp_posts.post_type =
‘page’。显然,$this->is_page=true,是要在库中查找page类型的发布

ORDER BY wp_posts.post_date
ASC的原因是由于我们GET请求中传入的order参数为ASC,其order为环境变量,在这里被直接拿来拼接sql语句了

在了解了wordpress此时的查询语句,我们对照后台数据库中wp_posts表的内容分析下

wp_posts表中存储了wordpress所有发布的内容,并同过post_type对其进行类型区分

MJVLpd.png

Post_type 为post的,代表其为文章(POST);而page,代表这是一个页面

因此我们的$this->is_page=true,使得程序从该中查询所有page类型的发布内容,也就是说,把所有的页面都提取出来了

MJZZn0.png

注意这里:这条查询,把所有的page都查询出来,不管其状态是发布(publish)或是private(私密)甚至是回收站(trash)。并且通过发布时间升序排列,至于需要设置升序排列的原因,后文会介绍。

继续回到漏洞点

MJZuAU.png

此时我们已经搞清楚$this->posts是什么了,如上文所说,$this->posts存放着从wp_posts表中取出的所有页面(Page),并且通过时间顺序升序排列

MJZEXq.png

这时候$this->post[0]即为存放在数据库中最早发布的那篇页面(Page),由于我的示范页面没有删,所以这里的$this->post[0]就是那篇示范页面

MJVz0f.png

而$this->post[2]就是我们的私密的页面,如下图,可见post_status为private

MJVxnP.png

为什么要用ASC将我们最早发布的页面放在$this->post[0]位置呢?原因如下

MY71aT.png

程序会检查$this->post[0]的发布状态,如上图前两个红框,并判断$this->post[0]的发布状态是否是public,若$this->post[0]的发布状态不为public,则接下来进行登陆与身份验证

因此通过ASC升序排列,尽可能的把最早发布的页面排在$this->post[0],这个$this->post[0]大概率是wordpress示例页面或者是网站自行添加的说明页面,其状态大概率是public,因此通过这个技巧绕过了后续登陆校验的环节,直接把所有页面显示出来

我们新建一个私密测试页面,如下图

MJV5m6.png

建立一个回收站页面测试,并把它丢到回收站,如下图

MY7JG4.png

通过我们的payload,可见私密以及回收站的Page都可被查看

MJVoTO.png

若我们没有设置order=asc,则$this->post[0]为我最后丢掉回收站里的Page(默认是用发布时间降序排序,而丢到回收站的那篇是我测试时最后新建的),它的状态是trash而非public,因此wordpress触发登陆校验,如下图

MJZ9AS.png

漏洞修复

MY7tz9.png

漏洞修复其实很容易理解:

开发者把$public_query_vars数组中的static给删了,这样就算请求中传入static的值,也会被忽略,记得上文http://127.0.0.1/wordpress/?static=0&order=asc&kumamon=test

中的kumamon参数吗?

其次,开发者把’’
!=$qv[‘static’]这个条件也删除了,这样的话,只能通过pagename或者page_id查询单条page了,然而单条page在显示时,是需要验证其状态的,非public的单条page是不予显示的