AI-Powered Application Penetration Testing—Scale Security Without Compromise Learn More

CVE-2026-27886: Unauthenticated Boolean-Oracle Exfiltration of Administrator Secrets in Strapi

CVE-2026-27886: Unauthenticated Boolean-Oracle Exfiltration of Administrator Secrets in Strapi

Share

TL;DR Bishop Fox confirmed CVE-2026-27886, a critical sanitization bypass in Strapi 4.0.0 through 5.36.1 that lets an unauthenticated attacker read administrator data and seize the most privileged account on the system. Shodan currently surfaces the CMS on over 20,000 internet-facing hosts. The bug turns a normal-looking API request into a one-bit oracle that leaks an admin's password-reset token character by character, which can then be used through Strapi's normal forgot-password endpoint to take over the account and return a Super Admin session. The traffic profile looks like ordinary API browsing, and the bracket-syntax filter at the heart of the bug is not flagged by default WAF rule sets.

Strapi shipped the fix in version 5.37.0 on February 26, 2026, and the advisory followed on May 13, 2026; administrators running anything older should upgrade, rotate admin credentials, and invalidate outstanding password-reset tokens.

Summary

Strapi versions 4.0.0 through 5.36.1 contain an unauthenticated parameter sanitization bypass in the Content API that lets a remote attacker extract administrator secrets one character at a time and pivot to full account takeover. The framework's query sanitizer at node_modules/@strapi/utils/dist/sanitize/index.js:78 only processes the filters, sort, fields, and populate keys and silently preserves every other top-level key on the request. Downstream in node_modules/@strapi/utils/dist/convert-query-params.js:498, the transformQueryParams reducer ferries those unrecognized keys through a { ...rest, ...query } spread, and they land verbatim on strapi.db.query(uid).findMany(...) at node_modules/@strapi/core/dist/services/document-service/repository.js:87 as the SQL WHERE clause. A request shaped like GET /api/<collection>?where[updatedBy][resetPasswordToken][$startsWith]=a therefore joins the admin_users table through the updatedBy creator relation and turns the response's meta.pagination.total field into a one-bit content oracle.

The end-to-end takeover chain requires no authentication and no user interaction. An attacker calls POST /admin/forgot-password for any admin email (also recoverable via the same oracle on the email field), extracts the 40-character hex resetPasswordToken through a few hundred sequential character probes, and POSTs the recovered token to /admin/reset-password with a password of their choosing. The endpoint returns a valid Super Admin JWT that grants full control of the instance. Strapi rates the bug Critical at a CVSS score of 9.3. The traffic profile is hard to spot in standard logging: requests look like ordinary API browsing, and the only telltale signals are the where[ bracket syntax and a fast cadence of single-character probes against the same endpoint, neither of which is covered by default WAF rule sets at the time of writing.

Bishop Fox's Threat Enablement & Analysis team crafted Strapi fingerprints and CVE-2026-27886 detections to identify vulnerable instances across customer attack surfaces shortly after the GHSA advisory was published. Any hits are routed to Adversarial Operations for manual validation, where affected Cosmos customers are notified through the Bishop Fox Portal, so they can patch as soon as possible.

Background

Strapi is an open-source headless CMS written in TypeScript on top of Koa, used as the API and content-management layer behind production websites, mobile backends, and internal tools. A typical deployment exposes two HTTP surfaces from the same Node process: an /admin/* tree that serves the React admin SPA and its supporting REST endpoints, and an /api/* tree that serves the Content API. The /api/* tree is the framework's intended public surface; per-route access for unauthenticated callers (the "Public" role) and for JWT-bearing end users (the "Authenticated" role) is controlled by the users-permissions plugin and configured by the administrator. In a default install, the Public role holds only auth plumbing actions (auth.callback, auth.forgotPassword, auth.register, etc.) and zero find actions on content types, so the bug is unreachable until an administrator grants at least one Public find. In practice, granting at least one Public find is common on public-facing Strapi installations because the frontend the CMS feeds needs to read content unauthenticated.

The vulnerability affects @strapi/strapi versions 4.0.0 through 5.36.1 inclusive. Strapi released the fix in version 5.37.0 on February 26, 2026. The GHSA-rjg2-95x7-8qmx advisory was published on May 13, 2026, and the vulnerability was discovered by James Doll of WildWest CyberSecurity. The advisory rates the bug Critical at a CVSS score of 9.3.

The root cause lives at the boundary between Strapi's query parser and its database layer. Strapi accepts a rich query DSL on every Content API route, conventionally documented as filters, sort, fields, populate, and pagination. The framework's hand-rolled sanitizer pipeline walks the request body against the schema of the target content type to enforce field-level privacy (for example, resetPasswordToken is marked private: true on admin::user and must never leave the server). The sanitizer only knows about the documented keys; anything else passes through untouched. That gap has existed since the v4 query-layer rewrite and survived the v5 document-service refactor, which is why the affected range spans two major versions. The fix in 5.37.0 closes the gap by allowlisting where and routing it through the same sanitization pipeline as filters, so unrecognized top-level keys can no longer reach the database layer with attacker-controlled relation paths. 

Vulnerability Analysis

Throughout this section, GET /api/products stands in for any Content API collection endpoint. The path on any given Strapi deployment is administrator-defined (Strapi maps the pluralName from the content-type schema into GET /api/<pluralName>) and is only reachable to an unauthenticated caller when the Public role holds the find action for that content type. The precise reachability conditions are catalogued in the next section, Exposure Conditions.

A request to that endpoint enters the framework through the core-api collection controller at node_modules/@strapi/core/dist/core-api/controller/collection-type.js:15. The find action runs three steps in order:

async find(ctx) { 
    await this.validateQuery(ctx); 
    const sanitizedQuery = await this.sanitizeQuery(ctx); 
    const { results, pagination } = await strapi.service(uid).find(sanitizedQuery);

Both validateQuery and sanitizeQuery delegate to helpers in @strapi/utils. Both helpers share the same fundamental shape: they branch on a hardcoded list of keys and ignore everything else.

validateQuery at node_modules/@strapi/utils/dist/validate/index.js:78:

const validateQuery = async (query, schema, { auth } = {}) => { 
    if (!schema) throw new Error('Missing schema in validateQuery'); 
    const { filters, sort, fields, populate } = query; 
    if (filters)  await validateFilters(filters, schema, { auth }); 
    if (sort)     await validateSort(sort, schema, { auth }); 
    if (fields)   await validateFields(fields, schema); 
    if (populate && populate !== '*') await validatePopulate(populate, schema); 
};

sanitizeQuery at node_modules/@strapi/utils/dist/sanitize/index.js:78 follows the identical pattern: destructure the same four keys, run the per-key sanitizer on each, then return. There is no allowlist enforcement on the input object as a whole. A request carrying a where key (or any other key the framework does not document) passes through both helpers untouched.

After sanitizeQuery returns, the service layer hands the query to transformQueryParams at node_modules/@strapi/utils/dist/convert-query-params.js:459. This is where the unknown keys are gathered up and re-emitted into the object that becomes the database query:

const transformQueryParams = (uid, params) => { 
    const schema = getModel(uid); 
    const query = {}; 
    const { _q, sort, filters, fields, populate, page, pageSize, start, limit, status, ...rest } = params; 
    // ... known keys are converted into `query.orderBy`, `query.where`, `query.select`, etc. 
    return { 
        ...rest, 
        ...query 
    }; 
};

The destructure names eleven keys, everything else falls into rest. The function then builds a clean query object from the documented keys and merges rest into the return value with a spread. Because rest is spread before query, a documented key such as filters (converted to query.where) wins over a same-named entry in rest. An attacker-supplied where, however, has no documented counterpart on query and survives the merge unchanged. The function returns an object whose top-level where field is whatever shape the attacker put on the wire.

The transformed object is consumed at node_modules/@strapi/core/dist/services/document-service/repository.js:85:

async function findMany(params = {}) { 
    const query$1 = await async.pipe(...transforms)(params); 
    return strapi.db.query(uid).findMany(query$1); 
}

strapi.db.query(uid).findMany(...) is the framework's internal query-builder entry point. It accepts a where field shaped as a tree of relation traversals and operator clauses and compiles it into SQL with appropriate joins. There is no further sanitization at this layer because the contract above it is supposed to have done that work. With Knex's query debugging enabled on the lab instance, a single request of:

GET /api/articles?where[updatedBy][resetPasswordToken][$startsWith]=d HTTP/1.1 
Host: 127.0.0.1:1337

produced the following query against the SQLite backend (bindings ['d%', 25]):

select distinct `t0`.* from `articles` as `t0` 
left join `admin_users` as `t1` on `t0`.`updated_by_id` = `t1`.`id` 
where ( 
  `t0`.`published_at` is not null 
  and `t1`.`reset_password_token` like ? 
  and `t0`.`published_at` is not null 
) 
limit ?;

A companion count(distinct t0.id) query runs in parallel to populate meta.pagination.total. reset_password_token is marked private: true on the admin::user schema, so the column is never serialized into a response body and the attacker cannot read it directly. They do not need to. The presence or absence of matching rows in the response is enough. meta.pagination.total returns 1 when the prefix is correct and 0 when it is not, giving the attacker a one-bit answer to the question "does the admin's reset token start with this string?" Iterating the alphabet character by character recovers the full forty-character hex token in a few hundred requests.

The sanitization gap is the same pattern repeated across three helpers. The fix in 5.37.0 adds an allowlist, which is covered later in this post.

Exposure Conditions

The vulnerable code path is identical for every Content API endpoint that returns a paginated collection of documents. The HTTP path is whatever name the administrator gave the content type in their admin panel. Strapi's REST plugin maps pluralName from the content-type schema into GET /api/<pluralName>, so a product content type with pluralName: "products" is served at GET /api/products, an article content type at GET /api/articles, and so on. There is no framework-level guarantee about which path exists on any given instance.

Two administrator-controlled preconditions must hold for a given endpoint to be exploitable:

  1. The Public role must hold the find action for that content type. This is a deliberate grant in the admin UI under Settings → Users & Permissions Plugin → Roles → Public. The default install has no find actions granted to Public; the bug is unreachable until an administrator opts at least one collection in. In practice, any Strapi instance feeding an unauthenticated public website grants find on at least one collection so the frontend can read content.
  2. At least one row in the underlying table must have a non-NULL updated_by_id or created_by_id column. Strapi populates these foreign keys automatically when an admin creates or edits an entry through the admin UI. They point at admin_users.id. Without at least one populated row, the LEFT JOIN admin_users in the generated query returns no matching rows for any prefix, and the oracle bit is stuck at 0.

A common misreading of the advisory is that options.populateCreatorFields: true on the content-type schema is required. It is not. That option only controls whether the createdBy / updatedBy relations are serialized into response bodies. The columns and foreign keys exist on every content type regardless of the flag, because Strapi's framework-level lifecycle hooks set them on every write. The where[updatedBy][...] filter operates on the join, not on response serialization, so the bypass works against any content type whose rows have been touched through the admin UI.

End-to-end extraction was exercised in the lab against GET /api/articles, with a seeded articles row carrying updated_by_id = 1 and a deterministic forty-character hex resetPasswordToken set on the admin row. The full extraction loop recovered the token in a single run.

The path-discovery problem on real targets is the administrator-defined nature of pluralName. A wordlist of common English plurals (articles, posts, products, pages, events, news, etc.) catches most installs but misses anything localized or domain-specific. The reliable discovery surface is the consumer-facing frontend that the CMS feeds, since its code typically references /api/<x> paths verbatim. The detection tooling we built and the trade-offs in path discovery are covered in the Detecting Vulnerable Instances section.

From Oracle to Account Takeover

Recovering the reset token through the oracle is only half the attack. The attacker still needs a way to convert that token into an authenticated session, and Strapi's own password-recovery flow provides it. The chain to a Super Admin JWT relies on composing the leak with two unauthenticated admin-side endpoints that already exist for legitimate password recovery: POST /admin/forgot-password and POST /admin/reset-password. Both are reachable on every Strapi instance without authentication.

Before any token can be extracted, the chain needs a token to exist. On a freshly installed Strapi instance, the admin_users.reset_password_token column is NULL for every administrator. The attacker triggers Strapi's normal password-reset flow to populate it:

POST /admin/forgot-password HTTP/1.1 
Host: target 
Content-Type: application/json 
 
{"email":"[email protected]"}

Strapi responds with HTTP 204 No Content whether or not the email matches a real administrator (this is the framework's anti-enumeration behavior). If the email does match, the side effect is a freshly generated forty-character hex token written to admin_users.reset_password_token for that row and a reset email queued for delivery. The email body is irrelevant to the chain because the attacker has another way to read the token. The address itself can be discovered through the same oracle by probing the email field rather than resetPasswordToken, so prior knowledge of an administrator's email is not required.

Once a token exists, the oracle is used to read it one character at a time by probing where[updatedBy][resetPasswordToken][$startsWith]=<prefix> against any vulnerable Content API endpoint. The token alphabet is [0-9a-f] and the length is forty characters, so the worst-case probe count is sixteen times forty (640) with the average closer to half that. Each probe is a single HTTP GET that returns either meta.pagination.total: 1 (prefix is correct) or meta.pagination.total: 0 (prefix is wrong). The traffic is indistinguishable from a frontend that polls the same collection many times.

With the full token recovered, the attacker submits it to the framework's reset endpoint along with a password of their choosing:

POST /admin/reset-password HTTP/1.1 
Host: target 
Content-Type: application/json 
 
{"resetPasswordToken":"<40-char hex>","password":"<new password>"}

The response is HTTP 200 with a JSON body containing a signed JWT and the user record:

{ 
  "data": { 
    "token": "eyJhbGciOiJIUzI1NiIs...", 
    "user": { 
      "id": 1, 
      "email": "[email protected]", 
      "roles": [{"id":1,"code":"strapi-super-admin","name":"Super Admin"}] 
    } 
  } 
}

/admin/reset-password does not differentiate between Super Admin, Editor, or Author roles. Whichever administrator's token was extracted is the role the attacker now controls. On the typical single-administrator install (the default after running npx create-strapi-app), that administrator is the bootstrap Super Admin, and the returned JWT carries the strapi-super-admin role code shown above. The JWT is honored by every endpoint under /admin/* for the configured JWT_SECRET lifetime, granting full control over content types, users, permissions, plugins, and the database connection configuration.

The full chain involves four unauthenticated HTTP request types: one GET /api/<collection> for the email-prefix oracle, one POST /admin/forgot-password, several hundred more GET /api/<collection> probes for the token-prefix oracle, and a final POST /admin/reset-password. All four are documented Strapi endpoints behaving exactly as specified. The novel attacker capability is the boolean oracle, which is what the patch in 5.37.0 takes away.

Detecting Vulnerable Instances

To support administrators auditing their own deployments, Bishop Fox is releasing a Python detection tool that confirms whether a Strapi instance is vulnerable to CVE-2026-27886 without performing the full account-takeover. The tool extracts only the administrator's email address through the boolean oracle. The email value is sufficient to prove the bypass is reachable against a real administrator row and stops short of any action that would compromise the account.

The underlying check is two unauthenticated GET requests against any Public-readable Content API collection. The first establishes a baseline:

GET /api/<collection> HTTP/1.1 
Host: target

The second adds an always-false predicate that exercises the same code path the email enumeration uses:

GET /api/<collection>?where[id][$lt]=-1 HTTP/1.1 
Host: target

On a vulnerable instance, both requests return HTTP 200 with the Strapi pagination envelope. The baseline reports a non-zero meta.pagination.total; the predicate request reports meta.pagination.total: 0 because the where clause was honored at the database layer and collapsed the result set with an always-false condition. On a patched 5.37.0 or later instance, the unknown where key is dropped by the sanitizer allowlist and the predicate request returns the same total as the baseline. The same outcome occurs when an intermediate WAF strips bracket-syntax query keys before they reach Strapi.

Once the differential confirms the instance is vulnerable, the tool moves to email enumeration. It iterates the alphabet @.+-abcdefghijklmnopqrstuvwxyz0123456789 and probes:

GET /api/<collection>?where[updatedBy][email][$startsWith]=<prefix> HTTP/1.1 
Host: target

A prefix that matches an administrator's email returns meta.pagination.total > 0; a prefix that does not returns total == 0. The loop terminates when no character extends the prefix, at which point the recovered string is the full address. A typical administrator address recovers in roughly five hundred unauthenticated GET requests on the default alphabet.

Sample output on a vulnerable Strapi 5.36.1 instance:

[+] Target: https://target.example.com/api/articles 
[+] Differential confirmed: baseline total=12, where-test total=0 -> VULNERABLE 
[+] Enumerating admin email 
    admin email = [email protected] 
[+] Done. To remediate, upgrade to Strapi 5.37.0 or later.

Sample output on a patched 5.37.0 instance:

[+] Target: https://target.example.com/api/articles 
[+] Differential check: baseline total=12, where-test total=12 -> NOT VULNERABLE

The detection tool is published at https://github.com/BishopFox/CVE-2026-27886-check.

The path-discovery problem deserves a closing note. The tool accepts a candidate collection URL because pluralName is administrator-defined and varies by deployment. For self-audits, the easiest input is a URL that the administrator already knows their consumer frontend uses. For broader attack-surface monitoring, the practical discovery surface is the consumer-facing application that the CMS feeds, whose code typically references /api/<x> paths verbatim and yields the actual collection names without a wordlist.

The Patch

The fix in Strapi 5.37.0 introduces an explicit allowlist of permitted Content API query keys and applies it at both the validate and sanitize layers. The choice was to harden the input boundary rather than rewrite the downstream query construction. transformQueryParams at packages/core/utils/src/convert-query-params.ts:696 continues to gather unknown keys into rest and spread them into the returned object on line 748, but with the new allowlist in place upstream, any disallowed keys are removed from the sanitized query before that spread is reached.

The allowlist lives in a new module, packages/core/utils/src/content-api-constants.ts:

export const SHARED_QUERY_PARAM_KEYS = [ 
  'filters', 'sort', 'fields', 'populate', 'status', 'locale', 
  'page', 'pageSize', 'start', 'limit', '_q', 'hasPublishedVersion', 
] as const; 
 
export const ALLOWED_QUERY_PARAM_KEYS = [ 
  ...SHARED_QUERY_PARAM_KEYS, 
  'pagination', 'count', 'ordering', 
] as const;

where is conspicuously absent. The legitimate way to express a filter on the Content API has always been the filters parameter, which is funneled into query.where by convertFiltersQueryParams after schema-aware sanitization. The attacker-supplied where parameter bypassed that funnel; the new allowlist makes the funnel mandatory.

Two helpers in @strapi/utils gained a new strictParams option to enforce the allowlist. validateQuery at packages/core/utils/src/validate/index.ts:161 now iterates the request's top-level keys and throws a ValidationError for any key not in the allowlist:

if (strictParams) { 
  const extraQueryKeys = getExtraQueryKeysFromRoute(route); 
  const allowedKeys = [...ALLOWED_QUERY_PARAM_KEYS, ...extraQueryKeys]; 
  for (const key of Object.keys(query)) { 
    if (!allowedKeys.includes(key)) { 
      throwInvalidKey({ key, path: null }); 
    } 
  } 
  // ... per-route Zod validation for extra keys 
}

sanitizeQuery at packages/core/utils/src/sanitize/index.ts:180 adds the symmetric strip: after running the per-key sanitizers, when strictParams is true, it calls lodash/fp/pick(allowedKeys, sanitizedQuery) so that anything outside the allowlist is removed even if validation was bypassed:

if (strictParams) { 
  const allowedKeys = [...ALLOWED_QUERY_PARAM_KEYS, ...extraQueryKeys]; 
  return pick(allowedKeys, sanitizedQuery) as Record<string, unknown>; 
}

Two design decisions in the patch are worth surfacing. First, strictParams defaults to false for backward compatibility. The Content API's collection and single-type controllers now opt in by passing strictParams: true to both helpers, but external callers (plugins, custom routes that bypass the framework's controllers) keep the lax behavior unless they explicitly opt in. Plugin authors who construct queries by hand are now responsible for either using the documented filters shape or invoking the sanitizer with strictParams: true themselves.

Second, the allowlist is extensible per route. Routes that legitimately need to accept additional query parameters register them through contentAPI.addQueryParams, and the sanitizer expects each to carry a Zod schema for explicit per-key validation (route.request.query). This is how the patch avoids breaking plugin authors who had added custom query parameters that worked under the old "anything passes" regime. They now have a structured way to opt those parameters back in, with type safety on the input.

Conclusion

CVE-2026-27886 is an instance of a recurring class of bug: a sanitizer that enumerates known-good keys and falls through silently on everything else. The gap persisted across two major versions of Strapi because no single helper owned the contract for which query keys are even permitted at the Content API boundary. validateQuery, sanitizeQuery, and transformQueryParams each took the same approach of recognizing four documented keys and ignoring the rest, and the responsibility for the overall input shape lived between them rather than in any of them.

For administrators running affected versions, the immediate actions are to upgrade to Strapi 5.37.0 or later, rotate every administrator password, and clear any outstanding password-reset tokens (UPDATE admin_users SET reset_password_token = NULL) on instances that were potentially reachable from the internet before patching. Reviewing the Public role's find grants in the admin UI is the second-order action: the bug is unreachable on any content type the Public role cannot list, so the surface should be the minimum the consumer frontend actually needs. Administrators who run third-party plugins that construct queries against strapi.db.query directly should confirm with each plugin's maintainers that those paths now route through sanitizeQuery with strictParams: true enabled.

The broader lesson for framework authors is that an input allowlist applied at the boundary is the design that survives unanticipated extensions to the input format. The patch in 5.37.0 chooses to harden the boundary rather than the sink, which is a defensible architectural decision for an internal query builder intended for trusted callers, and it relies on every code path going through the documented Content API controllers. Code that bypasses those controllers keeps the lax behavior until it opts in. The framework now provides the building block; using it consistently is a discipline that has to be enforced by code review and plugin audit, not by the runtime.


Nate Robb

By Nate Robb

Senior Operator

Nate Robb is a Senior Operator on the Threat Enablement Team at Bishop Fox. Prior to coming to Bishop Fox, he held roles as a security consultant and spent time as a full-time bug bounty hunter, where he worked to secure Fortune 500 companies, state and Federal Agencies, and small and medium-sized businesses.

Subscribe to our blog

Be first to learn about latest tools, advisories, and findings.

This site uses cookies to provide you with a great user experience. By continuing to use our website, you consent to the use of cookies. To find out more about the cookies we use, please see our Privacy Policy.