HexBrain

WordPress · Report #4152553

Unauthenticated Disclosure of Private, Draft, and Password-Protected Post Content via Core Read-Permission Bypass in SCF Reference Fields

Validstatus
highseverity
27upvotes
5 Nov 2026disclosed

How it was found

Summary

Secure Custom Fields reference fields resolve stored post IDs through acf_get_posts(), which defaults to post_status => 'any' and never checks read_post. An unauthenticated caller therefore receives the full WP_Post — body, excerpt, author, timestamps, permalink and plaintext post_password — for posts that WordPress core answers with 404 or 401. The write step is only delivery: the leak reproduces with no form submission at all (PoC step 7).

Description

Core boundary. For user 0, core drops non-public posts from WP_Query::$posts (wp-includes/class-wp-query.php:3497-3523) and answers 404; REST answers 401 rest_forbidden; a password-protected post renders the password form instead of the body (wp-includes/post-template.php:882). PoC step 2 records all four responses, plus current_user_can( 'read_post', <private> ) === false.

The sink. acf_get_posts() (includes/api/api-helpers.php:1168) sets post_status => 'any' (:1179) and calls get_posts() (:1211) with no current_user_can( 'read_post' ), no is_post_publicly_viewable() and no post_password_required(). In WordPress 7.1.3 draft, pending and private are not exclude_from_search, so 'any' returns them: PoC step 2 shows WP_Query( post_status='publish' ) = 0 results against acf_get_posts() = 1.

The patch gap. 6.9.3 added $enforce_read_permissions to acf_get_grouped_posts() (api-helpers.php:1286,1307,1443) and wired it to the three AJAX call sites only (commit b520d2c). 6.9.6 added the REST gate, including post_password_required() (includes/rest-api/acf-rest-api-functions.php:501-525, gate at :518). 6.9.7 added expose_reference_target() and strip_post_passwords() for block bindings (includes/Blocks/Bindings.php:145,176). The render path (class-acf-field-relationship.php:532), format_value() (:751; class-acf-field-post_object.php:564; class-acf-field-page_link.php:349,563) and the JSON-LD formatter (page_link.php:720,726) still call the unguarded helper.

The delivery. The vulnerability is the read. A reference written by WP-CLI or an editor leaks identically: PoC step 7 writes the meta directly, with no form, and the anonymous page still renders Private: <title> and returns the private body through get_field(). The signed form grant authorises field keys for saving (includes/forms/form-front.php:868-890) and carries no statement about read permission for referenced posts.

Steps to Reproduce

  1. Fresh WordPress 7.1.3 + SCF 6.9.7. Create a private post (ID 809), a draft, and a password-protected post (ID 811, post_password = hunter2). Create a public page that renders acf_form() with a Relationship field (field_poc_def_rel, post_type: post,page) bound to a public target page.
  2. As user 0, GET /?page_id=<form page> with no cookies. Keep the hidden inputs _acf_nonce, _acf_form, _acf_form_meta[], _acf_render_id, _acf_post_id, _acf_screen, _acf_validation.
  3. POST the same URL with acf[field_poc_def_rel][]=809 and those grant inputs. Response is 302 to the return URL, and the target page's meta now holds poc_def_rel = ["809"].
  4. Re-GET /?page_id=<form page>. The field renders <span …>Private: POC-DEF PRIVATE TITLE (private)</span> (PoC step 5). The same post's permalink returns 404 to the same caller (step 2).
  5. As user 0, get_field( 'poc_def_rel', <target page> ) returns a WP_Post with post_status = "private", the full post_content, post_excerpt, post_author and timestamps (step 6). Submit 811 instead and the same call returns post_password = "hunter2" and the protected body (step 8).
  6. POST /wp-login.php?action=postpass with post_password=hunter2&redirect_to=/?p=811, keep the returned cookie, then GET /?p=811 → 200 with the protected body. A wrong password returns no body (step 8).
  7. Submit 50 private IDs in one request (acf[field_poc_def_rel][]=… × 50). Re-fetch the page and count 50 rendered titles: acf_get_posts() sets posts_per_page => -1 (api-helpers.php:1177), so there is no cap (step 9).

AI Disclosure: In accordance with program guidelines, AI tooling was used as an assistant for code analysis and drafting. The vulnerability, the PoC execution, and all manual validations were conducted and verified by me.

Impact

Impact

An unauthenticated attacker can bypass WordPress core's explicit read-permission boundaries to exfiltrate sensitive, non-public data. The impact of this vulnerability is classified as High (7.5) due to the following specific consequences:

  • Confidentiality Breach (C:H): The attacker receives the full WP_Post object, exposing complete body content, excerpts, author IDs, timestamps, slugs, and permalinks. Crucially, this includes the plaintext post_password for password-protected posts, allowing the attacker to fully bypass the intended password protection.
  • WordPress Core Bypass: The vulnerability strictly violates WordPress core's authorization model. The same anonymous caller is explicitly denied access by core (returning 404 on the front end and 401 rest_forbidden via the REST API). Secure Custom Fields (SCF) circumvents these exact core boundaries by returning the data through the unguarded acf_get_posts() function.
  • Mass Enumeration: The acf_get_posts() function sets posts_per_page => -1 (api-helpers.php:1177), meaning there is no pagination cap. An unauthenticated attacker can submit 50+ sequential IDs in a single request to exfiltrate bulk unpublished content at scale.

Attachments

The complete Proof of Concept (PoC) script, including the Core Baseline control (proving the 404/401 denials) and the No-Form Control (proving the leak occurs without a form submission), along with the full transcript, is attached as {F7118016}.

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