| name | hunt-session |
| description | Hunt Session Management vulnerabilities — session fixation (no regeneration on login), insufficient invalidation on logout / password-change / email-change, predictable or low-entropy session IDs, JWT-as-session with no exp/revocation, refresh-token rotation/reuse-detection gaps, OAuth/SSO session linkage, device-bound-session (DBSC) downgrade, and cookie attribute issues (Secure/HttpOnly/SameSite/__Host-). Validate with TWO real sessions (attacker A + victim B), body-diff every 200, and OOB confirmation for theft chains. Medium to Critical (fixation→admin hijack, no-invalidation→persistent ATO). |
| sources | hackerone_public, portswigger_research, owasp_wstg |
| report_count | 18 |
HUNT-SESSION — Session Management
Crown Jewel Targets
Session fixation leading to admin hijack = Critical. Session surviving a password change = High-to-Critical (persistent ATO from a stolen cookie that the victim believes they revoked by resetting their password).
Highest-value chains:
- Session fixation — server accepts a session ID set by the client and does NOT regenerate it on login → attacker pre-plants an ID, victim authenticates, attacker rides the now-authenticated session → persistent ATO.
- No invalidation on logout — old token still works after
/logout → theft window never closes.
- No invalidation on password / email change — a stolen session survives the victim's "I think I was hacked, let me reset" → persistent ATO. This is the single highest-paid session bug class.
- Refresh-token reuse without rotation-detection — a leaked refresh token mints fresh access tokens forever; no reuse-detection means the legitimate user's later refresh does NOT revoke the attacker's branch.
- Predictable / low-entropy session ID — sequential, timestamp- or userId-derived IDs → brute-force or compute other users' sessions.
- JWT-as-session with no
exp / no revocation list — stolen JWT = permanent access; logout is cosmetic.
Grounding — patterns that shaped each phase
No invented CVE/report IDs below. These are the named, publicly-documented patterns this skill encodes:
- Session fixation, login-CSRF, no-regeneration-on-auth — OWASP WSTG-SESS-03 / WSTG-SESS-01; the classic ACROS / Mitja Kolšek session-fixation paper. Highest-impact variant: fixing the session of an SSO/admin user.
- SameSite=Lax sibling-subdomain CSRF reaching session state — Argo CD CVE-2024-22424 (Lax cookies sent on top-level cross-site navigations from a sibling subdomain). Use this when a session cookie relies on
SameSite=Lax as its only CSRF defence.
- Refresh-token rotation & automatic reuse-detection — the Auth0/IETF OAuth-Security-BCP model: a rotated refresh token, if replayed, must invalidate the entire token family. Absence = the core bug to prove.
- Device Bound Session Credentials (DBSC) — the W3C/Chrome DBSC draft binds a session to a TPM/device key. Test the downgrade: does the server still accept a non-bound cookie when the DBSC challenge is stripped?
- Cookie attribute hardening — OWASP WSTG-SESS-02;
__Host-/__Secure- prefixes per RFC 6265bis. Missing HttpOnly is only a finding when a real XSS/DOM sink exists (chain with hunt-xss/hunt-dom).
- Entropy — NIST SP 800-63B requires ≥64 bits of entropy in a session identifier. Treat anything decodable to a counter/timestamp/userId as a finding regardless of length.
Cross-refs: ATO chaining → hunt-ato; JWT alg/kid tampering → hunt-api-misconfig; OAuth code/state flaws → hunt-oauth; CSRF mechanics → hunt-csrf; cookie-theft sinks → hunt-xss / hunt-dom.
Attack Surface Signals
Set-Cookie: session=... # name varies: sid, JSESSIONID, connect.sid,
# PHPSESSID, ASP.NET_SessionId, laravel_session, _csrf
/login /logout /api/login /oauth/token
/auth/refresh /api/token/refresh # refresh-token rotation surface
/account/change-password /settings/email
?sid= ?session= in URL # session-in-URL → leaks via Referer/logs (finding)
# Header signals worth flagging immediately:
Set-Cookie: session=abc; Path=/ # no HttpOnly/Secure/SameSite
Set-Cookie: session=abc; SameSite=None # None without Secure = rejected by modern browsers, but flag
Set-Cookie: __Host-sess=...; Secure; Path=/ # GOOD — hard to fixate
Sec-Session-Registration: ... # DBSC in play → test downgrade
Step-by-Step Hunting Methodology
Two-session rule. Every invalidation/fixation claim is proven with TWO concrete sessions captured by a real flow — attacker A and victim B — never with hardcoded placeholder strings. Helpers below capture real cookies from curl's Netscape jar.
TARGET=target.com
JAR_A=$(mktemp); JAR_B=$(mktemp)
get_cookie () {
local jar="$1" re="${2:-session|sid|sess|JSESSIONID|connect\.sid|PHPSESSID|laravel_session}"
awk -v re="$re" '
/^#HttpOnly_/ { sub(/^#HttpOnly_/,""); } # strip jar HttpOnly marker
/^#/ { next } # skip remaining comments
NF>=7 && $6 ~ re { print $6"="$7 } # field6=name field7=value
' "$jar" | tail -1
}
Phase 1 — Session Fixation (regeneration-on-login)
curl -s -L -c "$JAR_A" "https://$TARGET/login" -o /dev/null
PRE=$(get_cookie "$JAR_A"); echo "pre-auth: $PRE"
FIX="session=AAAAdeadbeefAAAA"
curl -s -L -c "$JAR_A" -b "$JAR_A" -X POST "https://$TARGET/login" \
-d "username=attacker@example.com&password=CorrectHorse1" -o /dev/null
POST=$(get_cookie "$JAR_A"); echo "post-auth: $POST"
AUTH=$(curl -s -L -b "$JAR_A" "https://$TARGET/api/me")
echo "$AUTH" | head -c 200
FP guard: a value change is not automatically safe — some apps rotate the readable cookie but keep a stable server-side session keyed by a second cookie. Diff the FULL Set-Cookie set and confirm the old value is genuinely dead (Phase 2). Also confirm /api/me returns your identity, not a generic 200/landing page.
Phase 2 — Invalidation on Logout
curl -s -L -c "$JAR_A" -X POST "https://$TARGET/api/login" \
-H 'Content-Type: application/json' \
-d '{"email":"attacker@example.com","password":"CorrectHorse1"}' -o /dev/null
A=$(get_cookie "$JAR_A"); echo "A=$A"
BEFORE=$(curl -s -L -b "$JAR_A" "https://$TARGET/api/me")
curl -s -L -b "$JAR_A" -X POST "https://$TARGET/api/logout" -o /dev/null
AFTER=$(curl -s -L -H "Cookie: $A" "https://$TARGET/api/me" -w '\n[%{http_code}]')
echo "AFTER: $AFTER"
FP discipline (mandatory):
- Don't trust the status code. A cached/edge 200 or a generic SPA shell returns 200 for everyone. Body-diff
AFTER against BEFORE — the finding is only real if AFTER still contains A's unique identity marker (email, user-id, CSRF token, account name).
- Confirm with a negative control: a random/garbage cookie value must NOT return the same authenticated body. If garbage also yields 200 with user data, the endpoint isn't session-gated and there's no finding here.
- Re-test after a short delay and from a different IP — some servers lazily expire on next access or pin sessions to IP.
Phase 3 — Invalidation on Password / Email Change (persistent-ATO core)
curl -s -L -c "$JAR_A" -X POST "https://$TARGET/api/login" \
-H 'Content-Type: application/json' \
-d '{"email":"victim@example.com","password":"OldPass!1"}' -o /dev/null
SESSION_A=$(get_cookie "$JAR_A"); echo "SESSION_A=$SESSION_A"
BEFORE=$(curl -s -L -H "Cookie: $SESSION_A" "https://$TARGET/api/profile")
curl -s -L -c "$JAR_B" -X POST "https://$TARGET/api/login" \
-H 'Content-Type: application/json' \
-d '{"email":"victim@example.com","password":"OldPass!1"}' -o /dev/null
curl -s -L -b "$JAR_B" -X POST "https://$TARGET/api/change-password" \
-H 'Content-Type: application/json' \
-d '{"old_password":"OldPass!1","new_password":"BrandNew!2"}' -o /dev/null
AFTER=$(curl -s -L -H "Cookie: $SESSION_A" "https://$TARGET/api/profile" -w '\n[%{http_code}]')
echo "AFTER pw-change: $AFTER"
Decision + FP discipline:
- Finding is confirmed only if
AFTER returns 200 and the body still carries the account's unique data (body-diff vs BEFORE). A bare 200 on a public/SPA route is not proof.
- Run the garbage-cookie negative control again to prove the endpoint is session-gated.
- Repeat the identical flow for email-change (
/settings/email) and for logout-all-devices — apps frequently invalidate the acting session (B) but not sibling sessions (A). That sibling-survival is the exact persistent-ATO primitive hunt-ato chains.
- Severity gate: if the change-password endpoint also lacks a current-password / MFA step-up (per
hunt-mfa-bypass), A can pivot from read-only to full takeover → escalate.
Phase 4 — Cookie Attribute Analysis
curl -sI -L "https://$TARGET/" | grep -i '^set-cookie'
- HttpOnly missing → cookie reachable via
document.cookie. Only a finding chained to a real XSS/DOM sink (hunt-xss/hunt-dom) — note it, don't report standalone as High.
- Secure missing → cookie sent over cleartext HTTP; pair with
hunt-tls-network (downgrade/HSTS-gap) for a network-attacker chain.
- SameSite missing/
None → CSRF reachability; SameSite=Lax is bypassable via sibling-subdomain top-level navigation (Argo CD CVE-2024-22424 class) → hand to hunt-csrf.
__Host- / __Secure- prefix absent → the session can be overwritten/fixated from a subdomain or non-secure context; its presence largely kills cookie-fixation, so flag the absence as the precondition for Phase 1.
Phase 5 — Session-ID Entropy
N=200; SAMP=$(mktemp)
for i in $(seq 1 $N); do
J=$(mktemp)
curl -s -L -c "$J" "https://$TARGET/login" -o /dev/null
get_cookie "$J" | cut -d= -f2- >> "$SAMP"
rm -f "$J"
done
sort "$SAMP" | uniq -d | head
awk '{print length($0)}' "$SAMP" | sort -n | uniq -c
Then analyse, don't eyeball:
- Sequential / monotonic —
sort -n the decoded values; a steady +1/+N delta = predictable.
- Decodable structure —
base64 -d / hex-decode each ID and look for embedded userId, unix timestamps, or PIDs.
- Bit entropy — feed the raw bytes to
ent or dieharder; NIST SP 800-63B wants ≥64 bits. 10 samples is far too few to claim anything — gather hundreds.
- FP guard: a long random-looking token is not proof of strength; only structural decode + a large-sample entropy estimate is. Conversely a short token with high per-char entropy may still be fine — measure, don't count characters.
Phase 6 — JWT-as-Session
JWT="eyJ..."
b64url(){ local s="${1//-/+}"; s="${s//_//}"; printf '%s' "$s===" | base64 -d 2>/dev/null; }
b64url "$(cut -d. -f1 <<<"$JWT")" | jq .
b64url "$(cut -d. -f2 <<<"$JWT")" | jq .
exp missing or years out → no expiry. jti missing → server cannot maintain a revocation list → logout can't truly revoke.
- Revocation test: logout, then replay the same JWT against
/api/me. If it still returns the user → tokens are not server-revocable; this is the JWT-session persistence finding. Body-diff to avoid a cached 200.
- Tampering (alg/kid/key-confusion) is owned by
hunt-api-misconfig — hand off jwt_tool $JWT -T / -X a there rather than duplicating it.
Phase 7 — Refresh-Token Rotation & Reuse-Detection
RT1=$(curl -s -L -X POST "https://$TARGET/api/login" \
-H 'Content-Type: application/json' \
-d '{"email":"victim@example.com","password":"OldPass!1"}' | jq -r '.refresh_token')
R2=$(curl -s -L -X POST "https://$TARGET/auth/refresh" \
-H 'Content-Type: application/json' -d "{\"refresh_token\":\"$RT1\"}")
RT2=$(jq -r '.refresh_token' <<<"$R2"); echo "rotated? RT1!=RT2 -> $([ "$RT1" != "$RT2" ] && echo yes || echo NO-ROTATION)"
REPLAY=$(curl -s -L -X POST "https://$TARGET/auth/refresh" \
-H 'Content-Type: application/json' -d "{\"refresh_token\":\"$RT1\"}" -w '\n[%{http_code}]')
echo "RT1 replay: $REPLAY"
curl -s -L -X POST "https://$TARGET/auth/refresh" \
-H 'Content-Type: application/json' -d "{\"refresh_token\":\"$RT2\"}" -w '\n[%{http_code}]'
Findings: no rotation (RT1==RT2) = a long-lived stealable credential; rotation without reuse-detection (RT1 replay still mints tokens, or RT2 survives the replay) = the leaked-token-persistence bug per the OAuth Security BCP. OOB note: if you suspect a leaked RT via SSRF/log/JS-bundle, confirm the token's reach with hunt-ssrf/hunt-source-leak, not by guessing.
Phase 8 — OAuth/SSO Session Linkage & DBSC Downgrade
curl -s -L -H "Cookie: $A" "https://$TARGET/api/me" -w '\n[%{http_code}]'
Hand OAuth state/redirect_uri/code-injection to hunt-oauth; this phase only covers the session-layer binding.
Chain Table
| Session finding | Chain to | Impact |
|---|
Session fixation (forced __Host--less cookie) | Trick admin/SSO user into authenticating on planted ID | Admin session takeover (Critical) |
| No logout/password-change invalidation | hunt-xss/hunt-dom cookie theft → replay surviving session | Persistent ATO past victim's reset |
| Refresh token, no reuse-detection | Leaked RT (SSRF/log/bundle) → infinite access-token minting | Persistent ATO, survives password change |
SameSite=Lax only | Sibling-subdomain top-level nav (CVE-2024-22424 class) → CSRF | State change / login-CSRF → fixation |
JWT no exp/jti | Stolen token, no server revocation | Permanent access |
| DBSC downgrade accepted | Steal plain cookie despite device-binding | Defeats the only theft mitigation |
| Predictable ID | Compute/brute another user's session | Cross-user ATO |
Validation (house FP discipline)
Before claiming ANY session finding:
- Two real sessions, not placeholders — every fixation/invalidation claim uses A and B captured by the
curl flows above.
- Body-diff, never status-only — a 200 means nothing without the account's unique identity marker present in the body, diffed against the authenticated baseline.
- Negative control — a garbage/random cookie must FAIL where your "surviving" cookie succeeds; otherwise the endpoint isn't session-gated and it's a non-finding.
- Cache/edge check — re-request with a cache-buster and from a second IP; rule out an edge-cached or IP-pinned 200.
- OOB for theft chains — when the impact depends on exfiltrating a cookie/token (XSS, SSRF, log leak), confirm receipt out-of-band (Collaborator) rather than asserting it.
- Static-vs-state —
HttpOnly/Secure/SameSite absence is a policy observation; only report as High once paired with a real exploit primitive (XSS, network-MITM, CSRF). Standalone attribute gaps are Low/Informational.
Severity:
- Session fixation → admin/SSO takeover: Critical
- No invalidation on password/email change, or refresh-token reuse without detection: High → Critical (escalate if MFA/step-up also absent)
- Predictable/duplicate session ID: High
- No invalidation on logout: Medium → High (depends on theft vector)
- Missing
HttpOnly/SameSite standalone: Low/Informational until chained