Step 134. Cookie and Session Attacks — Shaking the ID Card the Server Trusts
Level 2 — Introduction to Security and Attack Skill Basics | Difficulty ★★★☆☆ | Estimated time: 3 hours
Prerequisites: SQL and Python integration from Steps 92–93, cookie manipulation from Steps 102–103, and building the Flask web app from Step 131.
- What you need: Python 3 + Flask, curl, and your browser’s developer tools (Application tab). All of today’s practice runs on
localhost. - ⚠️ All exercises in this chapter are for your own lab and legal platforms only. Applying them to unauthorized systems is a crime.
- Caution: the server we launch ourselves today is educational, made vulnerable on purpose. Like the warning comment at the top of the code, it must never be deployed anywhere.
A web server can’t remember you. HTTP makes every request independent, so to the server you’re "a first-time visitor" every time. Enter the cookie — the ID card the server hands the browser when login succeeds. Later requests carry this ID card automatically, and the server judges "ah, it’s alice" by looking at it alone. The problem is that this ID card sits in the client’s hands. Today you’ll launch a deliberately vulnerable session server on your own computer and reproduce the entire process yourself: copying that ID card, forging it, and predicting its number to enter someone else’s account.
1. Learning Objectives
By the end of this chapter, you will be able to:
- Explain the structure by which cookies and sessions maintain login state, at the request/response header level
- Demonstrate the danger of predictable session IDs with a curl experiment
- Explain the conditions under which a session fixation attack works
- Distinguish, from response headers, what HttpOnly / Secure / SameSite flags each block
- Confirm with a tampering experiment why a signed cookie (Flask session) is safe against simple modification
2. Background Knowledge — Today’s Tools and Concepts
Today’s Tools at a Glance
| Category | Details |
|---|---|
| Language/environment | Python 3 + Flask (vulnerable practice server), curl (reproducing requests), browser developer tools |
| Today’s commands/code | curl -i (view response headers), curl -H "Cookie: ..." (send a request with a cookie attached), the httponly/secure/samesite options of resp.set_cookie(), base64 decoding |
| Concepts needed | Cookie vs. session, session IDs, the three attacks (tampering/theft/fixation), cookie flags, signatures |
| Today’s artifact | lab134.py (vulnerable session server) + a reproduction record of the three attacks |
2-1. Cookies and Sessions — The ID Card and the Roster
The two are often conflated, but their roles differ. A cookie is a value the server plants in the browser via a response’s Set-Cookie header; a session is the server’s device for remembering "who is logged in." The textbook structure goes like this.
- Login succeeds → the server records
sessionID → alicein the session store (the roster) - The response carries
Set-Cookie: session_id=randomvalue— the browser receives an ID card - On every later request, the browser automatically attaches
Cookie: session_id=randomvalue - The server looks up that number in the roster and processes it as "alice’s request"
In other words, what the server trusts is not a password but a single session-ID string. That’s why stealing it (theft), swapping it (tampering), or fixing it in advance (fixation) is the center of the attacks.
2-2. The Three Session Attacks
- Tampering: you rewrite your own cookie’s value. If the value passes the server’s verification, you gain someone else’s privileges. What you did in Steps 102–103 — editing a cookie in developer tools to pass — is of this family.
- Theft: you take someone else’s cookie and attach it to your own requests. The representative case is reading and exfiltrating
document.cookievia XSS (Step 138). Today we only reproduce that "a copied cookie passes as is." - Fixation: the attacker makes the victim use a session ID the attacker already knows, and once the victim logs in, the attacker enters with that same ID. It works in apps that don’t issue a fresh session ID at the moment of login.
2-3. Cookie Flags — Padlocks on the ID Card
The Set-Cookie header can carry attributes (flags) beyond just the value.
| Flag | What it blocks |
|---|---|
HttpOnly |
Reading via JavaScript’s document.cookie — locks the channel of XSS theft |
Secure |
Transmission over non-HTTPS plaintext connections — protects the ID card in network-eavesdropping segments |
SameSite |
The cookie riding along on requests initiated by other sites — mitigates CSRF |
Flags are a promise with the browser: the server writes them in the header and the browser honors them. Today you’ll confirm what they actually look like in response headers.
2-4. Why You Can’t Just Rewrite a Flask Session Cookie
Flask’s default session puts the whole data into the cookie but adds a signature with a secret key. The form is contents.timestamp.signature. Change even one character of the contents and the signature no longer matches, so the server discards it. What’s important is that it’s not encryption but forgery prevention — anyone can read the contents. You’ll confirm this yourself today too.
3. Follow Along
3-1. Launching the Vulnerable Practice Server
Write lab134.py (educational vulnerable code — never deploy it anywhere):
from flask import Flask, request, make_response
app = Flask(__name__)
SESSIONS = {} # session ID -> username (the server-side 'roster')
NEXT_ID = [1000] # deliberately predictable IDs, incrementing by 1
@app.route("/login")
def login():
user = request.args.get("user", "guest")
sid = str(NEXT_ID[0])
NEXT_ID[0] += 1
SESSIONS[sid] = user
resp = make_response(f"Login complete: {user} (issued session ID: {sid})")
resp.set_cookie("session_id", sid)
return resp
@app.route("/dashboard")
def dashboard():
sid = request.cookies.get("session_id")
if sid in SESSIONS:
return f"[Dashboard] Welcome, {SESSIONS[sid]}. Balance: $1,000,000"
return "Login required", 401
if __name__ == "__main__":
app.run(port=5134)
Input (launch the server in a new terminal and leave it running):
python lab134.py
This server has two vulnerabilities. Session IDs are issued sequentially as 1000, 1001, 1002… (predictable), and the cookie value carries no signature (tamperable). This isn’t a made-up mistake — old homegrown session implementations really looked like this.
3-2. Login and Observing the ID Card
Input (in another terminal):
curl -i "http://127.0.0.1:5134/login?user=alice"
Output (measured 2026-09-09, partial headers):
HTTP/1.1 200 OK
Set-Cookie: session_id=1000; Path=/
Login complete: alice (issued session ID: 1000)
How to read it: -i shows the response headers too. Set-Cookie: session_id=1000 — the moment the server hands the browser (here, curl) its ID card. From this request on, alice’s browser attaches Cookie: session_id=1000 to every request.
Log bob in too — he gets 1001. And you, the attacker, log in as well.
Set-Cookie: session_id=1002; Path=/
Login complete: attacker (issued session ID: 1002)
Stop here and think. If I’m number 1002, what numbers did the people who logged in before me get?
3-3. Reusing a Cookie — The ID Card Is Me
First confirm normal behavior. Go to the dashboard with bob’s cookie.
Input:
curl -H "Cookie: session_id=1001" "http://127.0.0.1:5134/dashboard"
Output (measured 2026-09-09):
[Dashboard] Welcome, bob. Balance: $1,000,000
Go without a cookie and you get 401 UNAUTHORIZED / Login required (same measurement). In other words, on this server, the only thing that proves "me" is this single string. If you steal this value from someone (via XSS, etc.), the thief becomes that person — the structure of why theft is fatal.
3-4. Tampering and Prediction — Lowering the Number by One
The attacker (number 1002) changes the number on the ID card. You could edit it in developer tools; with curl you just present a different value.
Input:
curl -H "Cookie: session_id=1000" "http://127.0.0.1:5134/dashboard"
Output (measured 2026-09-09):
[Dashboard] Welcome, alice. Balance: $1,000,000
How to read it: without knowing a single character of the password, alice’s dashboard opened. There were two success conditions — the IDs were predictable (incrementing by 1), and the server didn’t verify "is this a value I issued?" (no signature). Present a number not yet issued (e.g., anything after 1002 at that point) and it fails with 401 — the attack works only if you guess "an existing number belonging to someone else."
Why: this is the classic failure of homegrown sessions. The defense sums up in two lines — draw session IDs cryptographically random and long (Python’s secrets.token_hex()), and if possible use the framework’s verified session.
3-5. Session Fixation — An App Whose Number Doesn’t Change on Login
Add two routes to lab134.py and restart the server.
@app.route("/visit")
def visit():
sid = request.cookies.get("anon_id")
if sid and sid in SESSIONS:
return f"Welcome back. Your session ID: {sid}"
sid = str(NEXT_ID[0]); NEXT_ID[0] += 1
SESSIONS[sid] = "anonymous"
resp = make_response(f"First visit. Session ID issued: {sid}")
resp.set_cookie("anon_id", sid)
return resp
@app.route("/upgrade")
def upgrade():
"""An app that logs you in but does not reissue the session ID (= vulnerable to fixation)."""
sid = request.cookies.get("anon_id")
if sid in SESSIONS:
SESSIONS[sid] = request.args.get("user", "victim")
return f"Login processed. Session ID unchanged: {sid} -> {SESSIONS[sid]}"
return "Visit /visit first", 400
Input and output (measured 2026-09-09):
curl -c fix.txt "http://127.0.0.1:5134/visit" # save the cookie to a file
→ First visit. Session ID issued: 1003
curl -b fix.txt "http://127.0.0.1:5134/upgrade?user=victim"
→ Login processed. Session ID unchanged: 1003 -> victim
How to read it: the number received at visit time stays exactly 1003 after login. Now the attack scenario comes into view. If the attacker plants a pre-issued ID on the victim — "connect through this link" (a phishing link, cookie planting, etc.) — the moment the victim logs in, that ID becomes a logged-in session. The attacker just enters with the number they already know. In the measurement too, reconnecting with 1003 brought back the "welcome back" greeting.
Why: the defense is one line — always reissue the session ID on successful login. If the ID is the same before and after login, the door to fixation attacks is open. It’s the #1 item to check when auditing a real app.
3-6. Comparing Flags — The Response Headers Differ
Add two more routes and compare the headers.
@app.route("/login_plain")
def login_plain():
resp = make_response("Issuing a cookie without flags")
resp.set_cookie("token_plain", "abc123")
return resp
@app.route("/login_guarded")
def login_guarded():
resp = make_response("Issuing a cookie with flags")
resp.set_cookie("token_guarded", "abc123",
httponly=True, secure=True, samesite="Lax")
return resp
Output (measured 2026-09-09, the Set-Cookie lines from curl -i):
Set-Cookie: token_plain=abc123; Path=/
Set-Cookie: token_guarded=abc123; Secure; HttpOnly; Path=/; SameSite=Lax
How to read it: the value is abc123 in both, but the promises differ. A cookie with HttpOnly cannot be read by JavaScript in the browser — if you can open a browser, compare by typing document.cookie in the console. Only cookies without HttpOnly appear in the list (the browser screen is an example — the header difference is proven by the measurement above). It’s this one word that makes cookie theft fail even when XSS fires.
Why: flags don’t make attacks "impossible," but they reliably close one theft channel. Defense is built layer upon layer.
3-7. The Signed Cookie — A Tampering Experiment on Flask’s Default Session
Finally, compare with the framework’s textbook session. Set app.secret_key and add routes that use Flask’s session.
from flask import session
app.secret_key = "lab-secret-key-for-demo"
@app.route("/flask_login")
def flask_login():
session["user"] = "alice"
return "Flask signed session issued (user=alice)"
@app.route("/flask_dashboard")
def flask_dashboard():
if "user" in session:
return f"[Flask Dashboard] Welcome, {session['user']}"
return "Login required", 401
Input and output (measured 2026-09-09):
curl -c fs.txt "http://127.0.0.1:5134/flask_login"
# saved cookie: eyJ1c2VyIjoiYWxpY2UifQ.aqED4Q.YiJt_SqG9Fjby4ehWW2k5PFLtp8
curl -H "Cookie: session=eyJ1c2VyIjoiYWxpY2UifQ.aqED4Q.YiJt_SqG9Fjby4ehWW2k5PFLtp8" ".../flask_dashboard"
→ [Flask Dashboard] Welcome, alice
Now change just the last character to X and send it.
→ Login required (measured 2026-09-09 — tampering voids it instantly)
Yet base64-decoding the cookie’s first chunk reveals the contents as is (same measurement):
echo "eyJ1c2VyIjoiYWxpY2UifQ" | python -c "import sys,base64; s=sys.stdin.read().strip(); s+='='*(-len(s)%4); print(base64.urlsafe_b64decode(s))"
→ b'{"user":"alice"}'
How to read it: two facts hold at the same time. The contents are readable by anyone (not encryption), and altering them voids the cookie (thanks to the signature). Hence two rules — never put secrets like passwords into a signed cookie (they can be read), and never leak the secret_key (whoever knows the key can make signatures).
Why: the server you passed in 3-4 by just changing a number versus the Flask session just now — this contrast is today’s conclusion: a session must be not "a number ticket" but "a verifiable proof."
4. Missions & Exercises
Mission — A Reproduction Report of the Three Session Attacks
- Complete all of
lab134.py, and capture the commands and outputs of the three experiments: tampering (3-4), fixation (3-5), and failed signature tampering (3-7). - Next to each experiment, write two lines on "why this attack succeeded/failed."
- Write
session-attacks.mdin your wiki — organize the definitions, success conditions, and corresponding defenses of the three attacks (tampering/theft/fixation) in a table. - End with a one-sentence conclusion: write your answer to "Flask sessions are safe against tampering — so what kind of app is vulnerable?"
Exercises
Exercise 1. Explain the difference between a cookie and a session using only technical terms — no "ID card and roster" analogy.
Exercise 2. Name the two conditions that made the 3-4 attack succeed, and write one line of defense corresponding to each.
Exercise 3. In a session fixation attack, why must the attacker know the session ID in advance, and why does reissuing the session ID at login thwart the attack?
Exercise 4. Decoding a Flask session cookie reveals its contents, yet we call it safe — why? Also name one thing that must never go into this cookie.
5. Model Answers & Completion Criteria
Mission Model Answer
How to verify: ① in the tampering experiment, is there output of session_id=1000 opening alice’s dashboard (per the 2026-09-09 measurement)? ② in the fixation experiment, did /visit and /upgrade print the same ID value? ③ does the request with the signed cookie’s last character changed fall to Login required? ④ does the summary table’s defense column include "random long IDs / HttpOnly / reissue ID at login / use framework sessions"?
A model one-sentence conclusion: "Vulnerable apps are homegrown sessions that trust the session ID as is, without signatures or verification, and apps that issue IDs predictably. Framework sessions like Flask’s void tampering with signatures, but key management and flag settings remain the developer’s job."
Exercise Answers
Answer 1. A cookie is a name-value pair the server makes the browser store via a Set-Cookie response header, a client-side store automatically attached to later requests via the Cookie header. A session is a server-side store where the server remembers login state; usually only the session ID is exchanged as a cookie while the actual state stays on the server. The cookie is the transport; the session is the server’s memory.
Answer 2. Condition ① the session IDs were predictable (1000, 1001…) → defense: issue sufficiently long random IDs with the secrets module or similar. Condition ② the server didn’t verify the value’s origin (no signature) → defense: use signed session cookies or a server-side session store. If even one of the two collapses, the attack fails.
Answer 3. The core of a fixation attack is making the victim’s login happen on an ID the attacker knows. If the server issues a new ID at the moment of login, the old ID the attacker planted remains an anonymous session, and the real login session becomes a new number the attacker doesn’t know — the attack falls apart. That’s why "reissue the session ID on successful login" is the standard defense.
Answer 4. Because "safe" means tamper-proof, not secret-keeping. Alter the contents and the signature mismatches, so the server discards it (3-7 measurement) — it can’t be used for privilege escalation. But anyone can decode the contents, so secret values like passwords or API keys must never go into this cookie.
Completion Criteria Checklist
- [ ] I can explain the roles of the
Set-CookieandCookieheaders in the request/response flow - [ ] I reproduced accessing the dashboard without a login by attaching a cookie with curl
- [ ] I succeeded in the predictable-session-ID tampering attack and can state its 2 success conditions
- [ ] I confirmed experimentally the condition for session fixation (ID unchanged across login)
- [ ] I can state what HttpOnly / Secure / SameSite each block
- [ ] I confirmed that tampering with a signed cookie fails, and can also explain that "it can still be read"
- [ ] Mission: I wrote the three-attack reproduction report
6. Common Pitfalls & Fixes
Wall 1. I attached a cookie in curl but got a 401
Symptom: I clearly logged in, but Login required comes back.
Cause: the cookie name is wrong (session_id vs session), or you didn’t quote the value so the shell read ; as a command separator.
Fix: wrap the whole header in double quotes, like curl -H "Cookie: session_id=1001". You must use the name exactly as written in the login response’s Set-Cookie line — with a different name, the server can’t find it.
Wall 2. The value I read from the cookie file (-c) looks wrong
Symptom: the value I pulled with grep is empty or has line breaks mixed in.
Cause: curl’s cookie file is tab-separated, and HttpOnly cookies are stored on lines with an #HttpOnly_ prefix.
Fix: extract by field number, like awk '$6=="session_id"{print $NF}' cookiefile. The last field is the value.
Wall 3. I changed the server but it behaves the same
Symptom: I added a route but get a 404.
Cause: the Flask development server doesn’t restart automatically on file changes (unless in debug mode).
Fix: stop the running server with Ctrl+C and run python lab134.py again. If the port isn’t free, an old server is alive in another terminal — shut it down first.
Wall 4. I decoded the signed cookie and got garbage
Symptom: base64 decoding throws an error or produces junk.
Cause: Flask cookies use URL-safe base64 with - and _, and the trailing padding (=) is omitted.
Fix: use urlsafe_b64decode and fix the padding, as in 3-7’s one-liner — s += '=' * (-len(s) % 4) is that fix.
Wall 5. "I set HttpOnly but curl still reads it"
Symptom: the header has HttpOnly, yet the cookie value is visible from curl/scripts.
Cause: not a confusion — it’s normal. HttpOnly is a promise about the browser’s JavaScript, not a feature that hides the value itself.
Fix: confirm HttpOnly’s effect by the cookie being absent from document.cookie in the browser console. The value is visible on the network segment anyway, which is why Secure (HTTPS) is needed separately.
7. Summary
Today’s Concepts
| Concept | One-line explanation |
|---|---|
| Cookie | A name-value pair the server plants in the browser — auto-attached to every request |
| Session | The server-side "who’s logged in" store — the cookie is its pass |
| Cookie tampering | Rewriting the value to fool the server — passes if there’s no signature/verification |
| Cookie theft | Taking someone else’s cookie and becoming them — XSS is the main channel |
| Session fixation | Making the victim log in on an ID the attacker knows — thwarted by reissuing at login |
| HttpOnly / Secure / SameSite | Block JS reading / HTTPS-only / block other sites’ requests |
| Signed cookie | Contents readable but tampering void — the Flask session’s way |
Today’s Commands
| Command | What it does |
|---|---|
curl -i URL |
View response headers too (check Set-Cookie) |
curl -H "Cookie: name=value" URL |
Send a request with a cookie attached (present the ID card) |
curl -c file / -b file |
Save cookies to a file / read from the file and send |
resp.set_cookie("name", value, httponly=True, ...) |
Issue a cookie with flags in Flask |
base64.urlsafe_b64decode(...) |
Read the contents part of a signed cookie |
An Instinct More Important Than Commands
The server doesn’t remember a password — it trusts a single session-ID string. So every attack aims at that string, and defense spans four places: issuing it (random, long enough), transporting it (Secure, HttpOnly), renewing it (reissue at login), and verifying it (signature). Today you launched a vulnerable server yourself and saw how the four holes get punched. If the Application tab of developer tools comes to mind first the next time you see a site with a login, today’s goal is achieved.
Once every box is checked, Step 134 is complete. Click the checkbox in the sidebar to save your progress.