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

SCF reference fields resolve post IDs via acf_get_posts() with post_status 'any' and no read_post check, leaking private/draft/password-protected bodies and plaintext post_password to anonymous users.

- Payout: hidden
- Severity: high
- Program: WordPress
- Found by: 0x200notfound
- Disclosed: 2026-11-05
- Write-up quality: 8/10
- Original: https://hackerone.com/reports/4152553
- Library page: https://bugbountylibrary.com/r/h1%3A4152553
- Tags: area:web domain:web impact:data-leak impact:integrity kind:bounty platform:hackerone sev:high tech:php tech:wordpress technique:incomplete-fix technique:source-review vuln:access-control vuln:auth-bypass vuln:info-disclosure

**Target:** WordPress plugin (SCF) Relationship/post_object/page_link reference fields and acf_get_posts() helper
**Root cause:** acf_get_posts() defaults to post_status => 'any' and never calls current_user_can('read_post'), is_post_publicly_viewable() or post_password_required(), so non-public posts are returned to user 0. Prior patches gated only AJAX and REST call sites, leaving render/format_value/JSON-LD paths unguarded.
**Steps:**
1. Fresh WordPress 7.1.3 + SCF 6.9.7; create private post (ID 809), draft, and password-protected post (ID 811, `post_password = hunter2`); create public page rendering `acf_form()` with a Relationship field bound to a public target page.
2. As user 0, `GET /?page_id=<form page>` with no cookies; keep hidden inputs `_acf_nonce`, `_acf_form`, `_acf_form_meta[]`, `_acf_render_id`, `_acf_post_id`, `_acf_screen`, `_acf_validation`.
3. `POST` same URL with `acf[field_poc_def_rel][]=809` and grant inputs; 302 to return URL, target meta now holds `poc_def_rel = ["809"]`.
4. Re-`GET /?page_id=<form page>`; field renders `Private: POC-DEF PRIVATE TITLE (private)` while the same post's permalink returns 404 to the same caller.
5. As user 0, `get_field( 'poc_def_rel', <target page> )` returns a `WP_Post` with `post_status = "private"`, full `post_content`, `post_excerpt`, `post_author`, timestamps; submitting 811 returns `post_password = "hunter2"` and protected body.
6. `POST /wp-login.php?action=postpass` with `post_password=hunter2&redirect_to=/?p=811`, keep cookie, then `GET /?p=811` → 200 with protected body.
7. Submit 50 private IDs in one request (`acf[field_poc_def_rel][]=…` × 50); re-fetch page and count 50 rendered titles (posts_per_page => -1).
**How found:** Code reading of acf_get_posts() defaults (post_status => 'any', no read_post check) contrasted against core WP_Query behavior for user 0; noticed prior patches (6.9.3 AJAX, 6.9.6 REST, 6.9.7 block bindings) left render/format_value/JSON-LD call sites unguarded.
**Exploitation:** Anonymous attacker submits or observes a reference field containing non-public post IDs; the render path and get_field() return the full WP_Post including body, excerpt, author, timestamps and plaintext post_password, bypassing core 404/401 and password form. Bulk enumeration via posts_per_page => -1.
**Escalation:** Mass enumeration of unpublished content by submitting 50+ IDs per request; plaintext post_password enables direct access to protected posts via wp-login.php?action=postpass.
**Insight:** When a plugin patches permission checks at some call sites, enumerate every other caller of the same unguarded helper (render, format_value, JSON-LD formatters) — partial fixes leave the read path open. Also check whether helper defaults like post_status => 'any' and posts_per_page => -1 silently widen scope.

## Proof of concept (copied exactly from the original report)

**POST relationship field with private post ID**

```
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"]`.
```

**get_field returns private post and password**

```
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).
```

**Bulk 50 private IDs in one request**

```
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).
```

