WordPress · Report #4152553
Unauthenticated Disclosure of Private, Draft, and Password-Protected Post Content via Core Read-Permission Bypass in SCF Reference Fields
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
- 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 rendersacf_form()with a Relationship field (field_poc_def_rel,post_type: post,page) bound to a public target page. - 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. POSTthe same URL withacf[field_poc_def_rel][]=809and those grant inputs. Response is302to the return URL, and the target page's meta now holdspoc_def_rel = ["809"].- 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 returns404to the same caller (step 2). - As user 0,
get_field( 'poc_def_rel', <target page> )returns aWP_Postwithpost_status = "private", the fullpost_content,post_excerpt,post_authorand timestamps (step 6). Submit811instead and the same call returnspost_password = "hunter2"and the protected body (step 8). POST /wp-login.php?action=postpasswithpost_password=hunter2&redirect_to=/?p=811, keep the returned cookie, thenGET /?p=811→200with the protected body. A wrong password returns no body (step 8).- 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()setsposts_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_Postobject, exposing complete body content, excerpts, author IDs, timestamps, slugs, and permalinks. Crucially, this includes the plaintextpost_passwordfor 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
404on the front end and401 rest_forbiddenvia the REST API). Secure Custom Fields (SCF) circumvents these exact core boundaries by returning the data through the unguardedacf_get_posts()function. - Mass Enumeration: The
acf_get_posts()function setsposts_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}.