Break and Harden Python Sessions
Exploit and harden Python sessions against fixation, replay, and CSRF.
Introduction
30 Second Summary
A login screen can say you are signed out while a website still accepts the old sign-in marker. That gap can leave an account open to someone who was never meant to enter it.
In this project, you will build two local Python applications that reveal how login sessions can be stolen or protected. You will reproduce the takeover in Google Chrome before proving that the hardened version rejects it.
What You'll Build
Your finished lab turns the same browser attack into two outcomes: a successful account takeover in the vulnerable app and a return to login in the hardened app.
By the end of this project, you'll have:
- A session fixation demo that lets a normal Chrome window enter the victim's account after the victim signs in through Incognito. You can also show why clearing one browser cookie leaves the shared server record alive.
- A hardened browser retest where the cookie value changes after login. Fixed session IDs, logged-out session IDs, and idle session IDs all send you back to login.
- A cookie review showing HttpOnly, SameSite=Strict, and Path=/ in Chrome DevTools. You can explain why deployed sessions also require HTTPS and Secure.
- Secret Mission: Write a concise OWASP-aligned security finding that captures reproduction steps, impact, remediation, and retest evidence.
Are there any prerequisites?
You need basic familiarity with HTTP requests and browser cookies. The setup step checks your local tools before either server starts.
Before We Start
This checkpoint defines the safety boundary for reproducing session fixation and broken logout. Both educational applications stay bound to 127.0.0.1, so the deliberately vulnerable server never reaches another network.
Set Up the Local Lab
Reliable attack evidence depends on a known Python runtime. An isolated workspace also keeps the deliberately vulnerable code contained on your Mac.
This step verifies Python 3.8 or newer inside Visual Studio Code. It finishes with a starter vulnerable.py file that prints a readiness message.
In this step, get ready to:
- Verify that your Python runtime meets the lab requirement.
- Open the session-lab folder as your VS Code workspace.
- Run vulnerable.py through the Python extension.
Verify the Python runtime
The session servers use standard-library features available in Python 3.8 or newer. A version check confirms that your Mac can run the lab code as written.
- Press Command-Space bar to open Spotlight.
- Type Visual Studio Code into Spotlight.
- Press Return to open VS Code.
- Select Terminal from the menu bar.
- Select New Terminal.
- Check your installed Python version by running this command:
python3 --version
What Does This Check Show?
The command asks the active python3 executable to report its installed version. Use the first two numbers to compare the result with the lab minimum of 3.8.
✔️ My Python version is supported
Your Python runtime meets the lab requirement. That removes the first source of environment-specific surprises.
- Continue to the workspace setup below.
ⓧ I see an older version
Your current Python version is below 3.8. Update it before creating the lab workspace.
- Visit the Python.org macOS downloads page.
- Download the macOS installer for the current stable Python release.
- Run the downloaded installer.
- Complete the installer prompts.
- Quit VS Code after the installation completes.
- Reopen VS Code through Spotlight.
- Open a new integrated terminal from the Terminal menu.
- Check the updated version by running this command:
python3 --version
What Should Change?
The updated output should report Python 3.8 or newer. Restarting VS Code lets its integrated terminal use the updated Python installation.
Still Seeing the Older Version?
Quit every VS Code window before reopening the editor. This refreshes the environment used by new integrated terminals.
If the older version remains active, use help me identify which Python installation VS Code is using.
ⓧ Command not found
The integrated terminal cannot currently find a python3 executable. Install Python before continuing.
- Visit the Python.org macOS downloads page.
- Download the macOS installer for the current stable Python release.
- Run the downloaded installer.
- Complete the installer prompts.
- Quit VS Code after the installation completes.
- Reopen VS Code through Spotlight.
- Open a new integrated terminal from the Terminal menu.
- Confirm that Python is now available by running this command:
python3 --version
What Should Change?
The terminal should now print an installed Python version. Continue when that version is 3.8 or newer.
Still Cannot Find Python?
Confirm that the installer completed before reopening VS Code. A terminal opened before installation can keep the older environment.
Use help me make Python available in the VS Code terminal if the command remains unavailable.
Create the session-lab workspace
A VS Code workspace keeps the lab files together. Placing session-lab on your Desktop also makes the local target easy to find when you need to stop or remove it.
- Click an empty area of your Desktop.
- Open File from the macOS menu bar.
- Select New Folder.
- Enter session-lab as the folder name.
- Press Return.
You should see the session-lab folder on your Desktop.
- Switch back to VS Code.
- Click File in the VS Code menu bar.
- Select Open Folder.
- Choose Desktop in the folder dialog.
- Select the session-lab folder.
- Click Open.
- Confirm that you trust the folder if VS Code displays a workspace trust prompt.
The Explorer sidebar should now show SESSION-LAB as the open workspace.
- Select the Explorer view in the Activity Bar.
- Click the New File... button in the Explorer sidebar.
- Enter vulnerable.py as the file name.
- Press Return.
You should see vulnerable.py listed inside the session-lab workspace. The empty file opens in the editor.
Enable the Python extension and run the starter
The Python extension for Visual Studio Code adds the editor action that runs the active Python file. A one-line starter gives you immediate proof that the extension reaches the integrated terminal.
- Select the Extensions view in the Activity Bar.
- Enter Python in the Extensions search field.
- Select the Python extension from the search results.
- Select Install if the extension is missing.
- Select Enable if the extension is disabled.
- Return to the Explorer view.
- Select vulnerable.py.
- Add the readiness check by pasting the code below:
print("Session lab ready")
What Does This Code Do?
- The print() function writes its text to the terminal.
- The message provides a clear signal that VS Code ran the correct file with Python.
- Save vulnerable.py by pressing Command-S.
Before you run the file, what message do you expect the integrated terminal to print?
- Select Run Python File from the editor toolbar.
You should see Session lab ready in the integrated terminal. That first clean run means your workspace is ready for the vulnerable server.
Run Python File Missing?
Return to the Extensions view. Confirm that the Python extension is installed and enabled.
Check that vulnerable.py is the active editor file before looking for the run action.
Use help me restore Run Python File in VS Code if the action still does not appear.
✔️ Awesome, I've got everything!
Your starter file is saved. The integrated terminal confirms that VS Code can run it with Python.
ⓧ I'd like to double check the full code
Compare your vulnerable.py file with this exact checkpoint.
print("Session lab ready")
What Should Match?
Your file should contain this single print() call. Saving and running it should produce the readiness message in the integrated terminal.
Your local workspace can execute Python files through VS Code. Next, you will replace the starter line with the deliberately vulnerable session server.
Launch the Vulnerable Session App
Your workspace can run Python `3.8` or newer. Now you need a local target that makes session fixation visible in the browser.
This server uses a stateful server-side session to track each user. It deliberately trusts a caller-selected session ID before checking the browser cookie.
In this step, get ready to:
- Replace the starter line with the intentionally vulnerable local server.
- Run the server on the loopback address and load its home page.
- Inspect the caller-selected session cookie in Chrome DevTools.
Build the deliberately weak server
The app uses Python's built-in HTTP server to expose login and account pages. An in-memory dictionary connects each session ID to its current user.
Keep This Target Local
This application is intentionally vulnerable. Its host remains `127.0.0.1`, which keeps the server bound to your own computer.
- Select `vulnerable.py` in the VS Code Explorer sidebar.
- Press Cmd+A (macOS) or Ctrl+A (Windows) while your cursor is inside the editor.
- Delete the starter `print("Session lab ready")` line.
- Add the server imports and shared settings by pasting this code:
import html
import http.cookies
import http.server
import urllib.parse
HOST = "127.0.0.1"
PORT = 8000
COOKIE_NAME = "id"
SESSIONS = {}
What Does This Foundation Do?
- The imports provide HTML escaping, cookie parsing, request handling, and URL parsing.
- `HOST` and `PORT` keep the target on your computer at `http://127.0.0.1:8000`.
- `SESSIONS` stores session records in memory under the cookie name `id`.
- Save `vulnerable.py`.
- Select Run Python File in the editor toolbar to check the current code.
The integrated terminal returns to its prompt without a traceback. Your imports and shared settings are valid.
Seeing an Import or Syntax Error?
- Check that each import occupies its own line at the top of `vulnerable.py`.
- Check that the four shared settings start at the far left with no indentation.
- Ask for help with the first traceback line that points into your file.
- Place your cursor after `SESSIONS = {}`.
- Add the page renderer and demo ID validator by pasting this code:
def render_page(title, body):
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<style>
body {{ font-family: system-ui, sans-serif; max-width: 720px; margin: 48px auto; padding: 0 20px; }}
code {{ background: #f1f3f5; padding: 2px 5px; }}
.warning {{ border-left: 5px solid #c92a2a; padding: 12px; background: #fff5f5; }}
nav a {{ margin-right: 16px; }}
input, button {{ font: inherit; padding: 8px; margin-top: 8px; }}
</style>
</head>
<body>
<h1>{html.escape(title)}</h1>
{body}
</body>
</html>"""
def is_safe_demo_id(value):
return bool(value) and len(value) <= 64 and all(
character.isalnum() or character in "-_" for character in value
)
How Do These Helpers Work?
- `render_page()` places each response inside one consistent HTML document.
- `html.escape()` prevents the page title from being interpreted as HTML markup.
- `is_safe_demo_id()` limits caller-selected IDs to short values containing letters, numbers, hyphens, or underscores.
- Save `vulnerable.py`.
- Select Run Python File to check the helpers.
The integrated terminal returns to its prompt without a traceback. Both helper definitions are ready for the request handler.
Seeing an Error in the HTML String?
- Check that the HTML starts with `f"""<!doctype html>` and ends with `</html>"""`.
- Check that the CSS braces remain doubled inside the formatted string.
- Ask for help locating a missing quote or brace in the page renderer.
- Place your cursor after the closing line of `is_safe_demo_id()`.
- Add the request body reader and cookie parser by pasting this code:
class VulnerableSessionHandler(http.server.BaseHTTPRequestHandler):
def read_form(self):
try:
length = min(int(self.headers.get("Content-Length", "0")), 4096)
except ValueError:
length = 0
raw_body = self.rfile.read(length).decode("utf-8", errors="replace")
return urllib.parse.parse_qs(raw_body, keep_blank_values=True, max_num_fields=10)
def cookie_session_id(self):
raw_cookie = self.headers.get("Cookie", "")
cookie = http.cookies.SimpleCookie()
try:
cookie.load(raw_cookie)
except http.cookies.CookieError:
return None
morsel = cookie.get(COOKIE_NAME)
return morsel.value if morsel else None
What Does the Handler Read?
- `read_form()` reads a bounded request body and parses submitted form fields.
- `cookie_session_id()` parses the incoming cookie header and returns the value stored under `id`.
- Malformed cookies return `None`, which lets the rest of the handler continue safely.
- Save `vulnerable.py`.
- Select Run Python File to check the handler structure.
The integrated terminal returns to its prompt without a traceback. The handler can now read form data and cookies.
Seeing an Indentation Error?
- Check that both method definitions use four spaces inside `VulnerableSessionHandler`.
- Check that each method body uses another four spaces.
- Ask for help comparing the indentation levels in the handler.
- Place your cursor after the final line of `cookie_session_id()`.
- Add the weak session selection and HTML response methods by pasting this code:
def session_for_request(self, parsed_url):
query = urllib.parse.parse_qs(parsed_url.query, max_num_fields=10)
requested_id = query.get("sid", [None])[0]
if not is_safe_demo_id(requested_id):
requested_id = None
session_id = requested_id or self.cookie_session_id() or "guest-session"
SESSIONS.setdefault(session_id, {"user": None})
return session_id, SESSIONS[session_id]
def send_html(self, status, title, body, cookie_header=None):
payload = render_page(title, body).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
if cookie_header:
self.send_header("Set-Cookie", cookie_header)
self.end_headers()
self.wfile.write(payload)
Where Is the Fixation Flaw?
- `session_for_request()` reads the `sid` query parameter before it checks the existing cookie.
- A valid caller-selected value becomes the key for a record in `SESSIONS`.
- `send_html()` returns the rendered page with response headers and an optional cookie.
- Save `vulnerable.py`.
- Select Run Python File to check the new methods.
The integrated terminal returns to its prompt without a traceback. The vulnerable session-selection path now parses correctly.
Seeing a Name or Indentation Error?
- Check that `session_for_request()` remains inside `VulnerableSessionHandler`.
- Check that `SESSIONS` uses the same uppercase spelling as the dictionary near the top of the file.
- Ask for help tracing the first failing name in this section.
- Place your cursor after `self.wfile.write(payload)`.
- Add redirects and the home route by pasting this code:
def redirect(self, location, cookie_header=None):
self.send_response(303)
self.send_header("Location", location)
self.send_header("Cache-Control", "no-store")
if cookie_header:
self.send_header("Set-Cookie", cookie_header)
self.end_headers()
def do_GET(self):
parsed_url = urllib.parse.urlsplit(self.path)
session_id, session = self.session_for_request(parsed_url)
weak_cookie = f"{COOKIE_NAME}={session_id}; Path=/"
if parsed_url.path == "/":
body = """
<p class="warning">This server is intentionally vulnerable and must remain local.</p>
<nav><a href="/login">Log in</a><a href="/account">Account</a></nav>
<p>The app accepts a session ID from the URL and keeps it after login.</p>
"""
self.send_html(200, "Vulnerable Session Lab", body, weak_cookie)
return
What Does the First Route Do?
- `redirect()` sends a `303` response with a new browser location.
- `do_GET()` resolves a session before handling the requested path.
- The home route sends the weak cookie and warns that the target must remain local.
- Save `vulnerable.py`.
- Select Run Python File to check the first route.
The integrated terminal returns to its prompt without a traceback. The home route is now part of the handler.
Seeing a String Error in the Home Route?
- Check that the home page body opens and closes with three double quotes.
- Check that `weak_cookie` remains indented inside `do_GET()`.
- Ask for help finding the malformed string or route indentation.
- Place your cursor after the `return` at the end of the home route.
- Add the login and account routes by pasting this code:
if parsed_url.path == "/login":
body = f"""
<p>Current session ID: <code>{html.escape(session_id)}</code></p>
<form method="post" action="/login">
<label>Username <input name="username" value="victim" required></label><br>
<button type="submit">Log in</button>
</form>
"""
self.send_html(200, "Vulnerable Login", body, weak_cookie)
return
if parsed_url.path == "/account":
if not session["user"]:
self.send_html(
401,
"Login Required",
'<p>No authenticated user is attached to this session.</p><a href="/login">Log in</a>',
weak_cookie,
)
return
body = f"""
<p>Signed in as <strong>{html.escape(session['user'])}</strong>.</p>
<p>Server session ID: <code>{html.escape(session_id)}</code></p>
<a href="/logout">Log out</a>
"""
self.send_html(200, "Victim Account", body, weak_cookie)
return
How Does Authentication Stay Attached?
- The login page displays the current session ID and submits a username.
- The account route reads the user from the existing server-side session record.
- The same `weak_cookie` is returned throughout the flow, so authentication keeps the caller-selected ID.
- Save `vulnerable.py`.
- Select Run Python File to check the login and account routes.
The integrated terminal returns to its prompt without a traceback. The handler now contains the pages needed for login and account access.
Seeing an Error Around the Account Page?
- Check that `session['user']` uses single quotes inside the formatted HTML string.
- Check that both route branches remain inside `do_GET()`.
- Ask for help locating a quote or indentation mismatch in these routes.
- Place your cursor after the `return` at the end of the account route.
- Add the weak logout behavior and login submission handler by pasting this code:
if parsed_url.path == "/logout":
expired_cookie = f"{COOKIE_NAME}=; Path=/; Max-Age=0"
body = """
<p>The browser cookie was cleared, but the server session was not deleted.</p>
<a href="/">Return home</a>
"""
self.send_html(200, "Logged Out?", body, expired_cookie)
return
self.send_html(404, "Not Found", "<p>The requested page does not exist.</p>")
def do_POST(self):
parsed_url = urllib.parse.urlsplit(self.path)
session_id, session = self.session_for_request(parsed_url)
if parsed_url.path == "/login":
form = self.read_form()
username = form.get("username", ["victim"])[0].strip() or "victim"
session["user"] = username
weak_cookie = f"{COOKIE_NAME}={session_id}; Path=/"
self.redirect("/account", weak_cookie)
return
self.send_html(404, "Not Found", "<p>The requested action does not exist.</p>")
Why Is Logout Ineffective?
- The logout route expires the browser cookie with `Max-Age=0`.
- The route never removes the corresponding entry from `SESSIONS`.
- The login handler saves the username under the current session ID and redirects to the account page.
- Save `vulnerable.py`.
- Select Run Python File to check the completed handler.
The integrated terminal returns to its prompt without a traceback. Every request path now parses successfully.
Seeing an Error in Logout or Login Submission?
- Check that `do_POST()` aligns with `do_GET()` inside the handler class.
- Check that the logout branch remains inside `do_GET()`.
- Ask for help identifying which method contains the failing line.
Run the local target
The handler is complete, but it needs a server process to listen for browser requests. The final block creates that process on the local host and keeps it running.
- Place your cursor after the final `self.send_html()` line.
- Add the server entry point by pasting this code:
if __name__ == "__main__":
server = http.server.ThreadingHTTPServer((HOST, PORT), VulnerableSessionHandler)
print(f"Vulnerable lab: http://{HOST}:{PORT}")
server.serve_forever()
How Does the Server Start?
- The main guard runs this block when VS Code starts `vulnerable.py` directly.
- `ThreadingHTTPServer` listens on the configured host and port with `VulnerableSessionHandler`.
- `serve_forever()` keeps the process available for browser requests.
- Save `vulnerable.py`.
Before you start the server, what do you expect the integrated terminal to display when the process begins listening?
- Select Run Python File while `vulnerable.py` is active.
You should see `Vulnerable lab: http://127.0.0.1:8000` in the integrated terminal. The prompt stays occupied because the server is waiting for requests.
Server Fails to Start?
- Check the first traceback line that references `vulnerable.py` for a missed quote or indentation level.
- Stop any previous process using port `8000` before selecting Run Python File again.
- Ask for help interpreting the server startup error.
- Keep the integrated terminal process running.
- Press Cmd+Space (macOS) or the Windows key (Windows) to open your search bar.
- Type `Google Chrome`.
- Press Enter to open Google Chrome.
- Enter `http://127.0.0.1:8000` in the address bar.
Chrome displays `Vulnerable Session Lab`. You should see links for `Log in` and `Account` beneath the local-only warning.
Page Does Not Load?
- Switch back to the VS Code integrated terminal and confirm the server process is still running.
- Check that the browser address uses port `8000`.
- Ask for help connecting Chrome to the local Python server.
Inspect the weak cookie
A session cookie carries the identifier that connects the browser to its server-side record. The vulnerable app copies a valid `sid` query value directly into that cookie.
Before you load the crafted login URL, do you think the app will keep its own session ID or adopt the value supplied in the URL?
- Enter `http://127.0.0.1:8000/login?sid=fixed-demo-session` in the Chrome address bar.
- Press Cmd+Option+I (macOS) or Ctrl+Shift+I (Windows) while Chrome has focus to open Chrome DevTools.
- Select the Application panel.
- Expand Storage in the left sidebar.
- Expand Cookies.
- Select `http://127.0.0.1:8000` under Cookies.
- Find the row whose Name is `id`.
You should see `fixed-demo-session` in the cookie's Value column. Its Path is `/`, while HttpOnly and SameSite do not show the hardened settings.
What Did You Prove?
The browser supplied `fixed-demo-session` through the URL. The server accepted that value as the session ID and returned it in the `id` cookie.
That caller influence creates the fixation condition you will exploit next. The missing `HttpOnly` and `SameSite=Strict` settings also reveal weak cookie controls.
Cookie Missing or Different?
- Reload the exact URL `http://127.0.0.1:8000/login?sid=fixed-demo-session`.
- Confirm that DevTools has `http://127.0.0.1:8000` selected under Cookies.
- Ask for help tracing why the crafted `sid` value did not reach the cookie.
✔️ Awesome, I've got everything!
Your vulnerable server is running locally. Chrome now shows that the URL-selected ID became the browser's session cookie.
ⓧ I'd like to double check the full code
Compare your saved `vulnerable.py` with this complete file. Every route and method should appear in the same order.
import html
import http.cookies
import http.server
import urllib.parse
HOST = "127.0.0.1"
PORT = 8000
COOKIE_NAME = "id"
SESSIONS = {}
def render_page(title, body):
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<style>
body {{ font-family: system-ui, sans-serif; max-width: 720px; margin: 48px auto; padding: 0 20px; }}
code {{ background: #f1f3f5; padding: 2px 5px; }}
.warning {{ border-left: 5px solid #c92a2a; padding: 12px; background: #fff5f5; }}
nav a {{ margin-right: 16px; }}
input, button {{ font: inherit; padding: 8px; margin-top: 8px; }}
</style>
</head>
<body>
<h1>{html.escape(title)}</h1>
{body}
</body>
</html>"""
def is_safe_demo_id(value):
return bool(value) and len(value) <= 64 and all(
character.isalnum() or character in "-_" for character in value
)
class VulnerableSessionHandler(http.server.BaseHTTPRequestHandler):
def read_form(self):
try:
length = min(int(self.headers.get("Content-Length", "0")), 4096)
except ValueError:
length = 0
raw_body = self.rfile.read(length).decode("utf-8", errors="replace")
return urllib.parse.parse_qs(raw_body, keep_blank_values=True, max_num_fields=10)
def cookie_session_id(self):
raw_cookie = self.headers.get("Cookie", "")
cookie = http.cookies.SimpleCookie()
try:
cookie.load(raw_cookie)
except http.cookies.CookieError:
return None
morsel = cookie.get(COOKIE_NAME)
return morsel.value if morsel else None
def session_for_request(self, parsed_url):
query = urllib.parse.parse_qs(parsed_url.query, max_num_fields=10)
requested_id = query.get("sid", [None])[0]
if not is_safe_demo_id(requested_id):
requested_id = None
session_id = requested_id or self.cookie_session_id() or "guest-session"
SESSIONS.setdefault(session_id, {"user": None})
return session_id, SESSIONS[session_id]
def send_html(self, status, title, body, cookie_header=None):
payload = render_page(title, body).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
if cookie_header:
self.send_header("Set-Cookie", cookie_header)
self.end_headers()
self.wfile.write(payload)
def redirect(self, location, cookie_header=None):
self.send_response(303)
self.send_header("Location", location)
self.send_header("Cache-Control", "no-store")
if cookie_header:
self.send_header("Set-Cookie", cookie_header)
self.end_headers()
def do_GET(self):
parsed_url = urllib.parse.urlsplit(self.path)
session_id, session = self.session_for_request(parsed_url)
weak_cookie = f"{COOKIE_NAME}={session_id}; Path=/"
if parsed_url.path == "/":
body = """
<p class="warning">This server is intentionally vulnerable and must remain local.</p>
<nav><a href="/login">Log in</a><a href="/account">Account</a></nav>
<p>The app accepts a session ID from the URL and keeps it after login.</p>
"""
self.send_html(200, "Vulnerable Session Lab", body, weak_cookie)
return
if parsed_url.path == "/login":
body = f"""
<p>Current session ID: <code>{html.escape(session_id)}</code></p>
<form method="post" action="/login">
<label>Username <input name="username" value="victim" required></label><br>
<button type="submit">Log in</button>
</form>
"""
self.send_html(200, "Vulnerable Login", body, weak_cookie)
return
if parsed_url.path == "/account":
if not session["user"]:
self.send_html(
401,
"Login Required",
'<p>No authenticated user is attached to this session.</p><a href="/login">Log in</a>',
weak_cookie,
)
return
body = f"""
<p>Signed in as <strong>{html.escape(session['user'])}</strong>.</p>
<p>Server session ID: <code>{html.escape(session_id)}</code></p>
<a href="/logout">Log out</a>
"""
self.send_html(200, "Victim Account", body, weak_cookie)
return
if parsed_url.path == "/logout":
expired_cookie = f"{COOKIE_NAME}=; Path=/; Max-Age=0"
body = """
<p>The browser cookie was cleared, but the server session was not deleted.</p>
<a href="/">Return home</a>
"""
self.send_html(200, "Logged Out?", body, expired_cookie)
return
self.send_html(404, "Not Found", "<p>The requested page does not exist.</p>")
def do_POST(self):
parsed_url = urllib.parse.urlsplit(self.path)
session_id, session = self.session_for_request(parsed_url)
if parsed_url.path == "/login":
form = self.read_form()
username = form.get("username", ["victim"])[0].strip() or "victim"
session["user"] = username
weak_cookie = f"{COOKIE_NAME}={session_id}; Path=/"
self.redirect("/account", weak_cookie)
return
self.send_html(404, "Not Found", "<p>The requested action does not exist.</p>")
if __name__ == "__main__":
server = http.server.ThreadingHTTPServer((HOST, PORT), VulnerableSessionHandler)
print(f"Vulnerable lab: http://{HOST}:{PORT}")
server.serve_forever()
How to Use This Reference
Compare the reference against your file from top to bottom. Pay particular attention to indentation inside `VulnerableSessionHandler` and the order of the route branches.
You now have a live target that accepts a session ID chosen through the URL. Next, you will reuse that fixed ID across separate browser contexts and watch the account boundary fail.
Exploit Fixation and Broken Logout
Your vulnerable server is live. Its session fixation flaw lets separate browser contexts reach the same server-side session record.
In this step, you will use normal and Incognito Google Chrome windows to reproduce an account takeover. You will then prove that clearing one browser's cookie leaves the authenticated server record alive.
In this step, get ready to:
- Prepare two browsing contexts with the same session ID.
- Use the normal window to take over the victim's authenticated session.
- Replay the server-side session after the victim logs out.
Fix the victim's session
An Incognito window creates a separate browsing session in Chrome. Both windows can still send the same attacker-selected ID to this vulnerable local server.
- Switch back to the normal Chrome window from earlier.
- Confirm that the address bar shows http://127.0.0.1:8000/login?sid=fixed-demo-session.
- Press Command+Shift+N in Chrome to open a separate Incognito window.
- Visit http://127.0.0.1:8000/login?sid=fixed-demo-session in the Incognito window.
You'll see Vulnerable Login in Incognito. The page shows fixed-demo-session as the current session ID.
What Did You Just Prepare?
The normal window represents the attacker. The Incognito window represents the victim.
Both contexts supplied the same sid value. The vulnerable server therefore connected both contexts to the same entry in SESSIONS.
Take over the authenticated session
Authentication updates the server record attached to the current ID. The next check reveals whether the normal window inherits the Incognito login.
- Switch to the Incognito window.
- Leave victim in the Username field.
- Click Log in.
You'll see the Victim Account page. It shows that the Incognito context is signed in as victim.
Before you test the normal window, do you expect it to stay unauthenticated or inherit the victim's session?
- Switch back to the normal Chrome window.
- Visit http://127.0.0.1:8000/account.
You'll see Victim Account with the username victim. That is the takeover reproduced: the attacker context entered the victim's account without logging in.
Why Did the Takeover Work?
- The sid query parameter selected fixed-demo-session in both browsing contexts.
- The login request attached victim to that shared SESSIONS record.
- The server preserved the selected ID after authentication.
- The normal window still possessed the ID needed to access /account.
Normal Window Sent to Login Required?
- Confirm that both login URLs contain sid=fixed-demo-session exactly.
- Return to the Incognito window to confirm that it shows Victim Account.
- Keep the vulnerable Python process running in the VS Code integrated terminal.
- Ask for help if the contexts still behave differently: help me troubleshoot my local session fixation test.
Demonstrate ineffective logout
Effective logout invalidation removes the authenticated record from the server. This vulnerable route expires only the id cookie in the browser that sends the logout request.
The authenticated entry remains in SESSIONS. Any other browser that still holds fixed-demo-session can keep using it.
- Switch to the Incognito window.
- Click Log out.
You'll see the Logged Out? page. It confirms that the Incognito cookie was cleared while the server session remained.
Before you refresh the attacker context, do you expect the old session to reach the protected account?
- Switch back to the normal Chrome window.
- Refresh the /account page.
You'll still see Victim Account with the username victim. You have now exposed the second flaw: the victim's logout did not revoke the attacker's access.
What Does This Prove?
A browser cookie is one copy of a session identifier. Clearing that copy does not remove the corresponding server-side record.
The normal window retained fixed-demo-session. The server continued to associate that ID with victim.
Old Session No Longer Shows the Account?
- Confirm that you refreshed the normal window after logging out in Incognito.
- Confirm that the vulnerable server remained running throughout the test.
- Repeat the Incognito login with fixed-demo-session if the Python process restarted because a restart clears the in-memory SESSIONS dictionary.
- Ask for help if the replay still fails: help me troubleshoot the broken logout replay.
You have reproduced session fixation plus ineffective logout using two isolated browser contexts. Next, you will replace the weak lifecycle with server-issued IDs, login rotation, protected logout, and server-side expiration.
Build Hardened Sessions
Your browser evidence proved that session fixation can turn a normal login into an account takeover. It also proved that clearing one browser cookie leaves the attacker connected when the server keeps the session record.
You will now replace that weak lifecycle with server-side sessions issued by the server. The new Python app rotates the session after login. It also protects state changes with CSRF tokens.
In this step, get ready to:
- Stop the vulnerable server and create the hardened application.
- Add server-issued sessions, cookie controls, CSRF validation, and idle expiration.
- Run the hardened server and prove that login rotates the session ID.
Create the server-issued session foundation
The vulnerable process still holds the authenticated attacker-controlled session in memory. Stopping that process closes the unsafe target before you start the hardened server on a different port.
- Return to the Visual Studio Code integrated terminal from earlier.
- Stop the running vulnerable Python process using the terminal stop control.
- In the Explorer sidebar of the existing session-lab workspace, create a file named secure.py.
- Add the imports and server settings to secure.py by pasting this code:
import html
import http.cookies
import http.server
import secrets
import time
import urllib.parse
HOST = "127.0.0.1"
PORT = 8001
COOKIE_NAME = "id"
IDLE_TIMEOUT_SECONDS = 60
SESSIONS = {}
What Does This Set Up?
- The standard-library imports provide HTML escaping, cookie parsing, HTTP handling, secure token generation, elapsed-time measurement, and URL parsing.
- The server listens only on 127.0.0.1. This keeps the educational server on your own computer.
- The hardened app uses port 8001. The vulnerable app remains saved with port 8000.
- The SESSIONS dictionary stores session state in server memory.
- Add the page renderer below the existing settings by pasting this code:
def render_page(title, body):
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<style>
body {{ font-family: system-ui, sans-serif; max-width: 720px; margin: 48px auto; padding: 0 20px; }}
code {{ background: #f1f3f5; padding: 2px 5px; }}
.success {{ border-left: 5px solid #2b8a3e; padding: 12px; background: #ebfbee; }}
nav a {{ margin-right: 16px; }}
input, button {{ font: inherit; padding: 8px; margin-top: 8px; }}
</style>
</head>
<body>
<h1>{html.escape(title)}</h1>
{body}
</body>
</html>"""
What Does the Renderer Do?
The render_page() function gives every response the same HTML structure. It escapes the page title before inserting it into the document.
The green success style distinguishes the hardened home page from the vulnerable target. This makes it easy to confirm which server you are testing.
- Save secure.py.
- Select Run Python File while secure.py is active.
The integrated terminal returns control without a traceback. That confirms the imports, settings, and page renderer are valid Python.
Seeing a Python Error?
- Check that every import appears on its own line at the top of secure.py.
- Check that the triple quotes around the HTML begin and end exactly as shown.
- Ask for help with the exact line reported in your integrated terminal.
- Add session creation and cookie helpers below render_page() by pasting this code:
def create_session(user=None):
now = time.monotonic()
session_id = secrets.token_urlsafe(32)
SESSIONS[session_id] = {
"user": user,
"csrf": secrets.token_urlsafe(32),
"created": now,
"last_seen": now,
}
return session_id, SESSIONS[session_id]
def active_cookie(session_id):
# Secure is intentionally omitted because this lab uses local HTTP.
# A deployed application must use HTTPS and add Secure.
return f"{COOKIE_NAME}={session_id}; Path=/; HttpOnly; SameSite=Strict"
def expired_cookie():
return f"{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"
How Is a Secure Session Created?
- The create_session() function generates both the session ID and CSRF value with secrets.token_urlsafe(32).
- Each server record stores the authenticated user in user.
- The created and last_seen values use a monotonic clock for elapsed-time checks.
- The active_cookie() helper applies Path=/, HttpOnly, and SameSite=Strict.
Why Is Secure Omitted Locally?
This lab uses local HTTP, so the cookie string intentionally omits Secure. A deployed application requires HTTPS for the complete session and must add the Secure attribute.
The expired_cookie() helper uses Max-Age=0 to remove the browser cookie during logout or rejection.
- Save secure.py.
- Select Run Python File again.
The terminal returns without a traceback again. Your session factory and cookie helpers now load successfully.
Session Helpers Not Loading?
- Check that create_session() aligns with render_page() at the left edge.
- Check that each key inside the SESSIONS record has a comma after its value.
- Ask for help interpreting the session-helper traceback.
Why Use Server-Side Sessions?
The browser receives only a random identifier. The user identity, CSRF value, and timestamps remain inside the server-side record.
This design lets logout delete the authoritative record immediately. It also keeps rotation and idle expiration visible during the lab.
Handle active sessions and protected pages
A random ID only helps when every request follows the same rules. The request handler must read sessions from the cookie, reject expired records, and ignore attacker-supplied session IDs in URLs.
- Add the handler class below expired_cookie() by pasting these form and cookie readers:
class SecureSessionHandler(http.server.BaseHTTPRequestHandler):
def read_form(self):
try:
length = min(int(self.headers.get("Content-Length", "0")), 4096)
except ValueError:
length = 0
raw_body = self.rfile.read(length).decode("utf-8", errors="replace")
return urllib.parse.parse_qs(raw_body, keep_blank_values=True, max_num_fields=10)
def cookie_session_id(self):
raw_cookie = self.headers.get("Cookie", "")
cookie = http.cookies.SimpleCookie()
try:
cookie.load(raw_cookie)
except http.cookies.CookieError:
return None
morsel = cookie.get(COOKIE_NAME)
return morsel.value if morsel else None
How Does the Handler Read Input?
- The read_form() method limits the request body to 4096 bytes before parsing the submitted fields.
- The cookie_session_id() method reads only the cookie named by COOKIE_NAME.
- Malformed cookie data returns None instead of breaking the request handler.
- Add active-session lookup below cookie_session_id() by pasting this method:
def current_session(self):
session_id = self.cookie_session_id()
session = SESSIONS.get(session_id)
if not session:
return None, None
now = time.monotonic()
if now - session["last_seen"] > IDLE_TIMEOUT_SECONDS:
SESSIONS.pop(session_id, None)
return None, None
session["last_seen"] = now
return session_id, session
How Does Idle Expiration Work?
The current_session() method looks up only the cookie value. A sid query parameter never enters this flow.
A record older than IDLE_TIMEOUT_SECONDS is deleted on the server. An active request updates last_seen for the next elapsed-time check.
- Save secure.py.
- Select Run Python File to check the handler methods.
The terminal returns without a traceback. The handler can now parse forms, read cookies, and expire inactive server records.
Handler Class Showing an Error?
- Check that every method inside SecureSessionHandler uses the same four-space indentation.
- Check that the body of each method is indented another four spaces.
- Ask for help checking the class indentation shown by your traceback.
- Add the response helpers below current_session() by pasting this code:
def send_html(self, status, title, body, cookie_header=None):
payload = render_page(title, body).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
if cookie_header:
self.send_header("Set-Cookie", cookie_header)
self.end_headers()
self.wfile.write(payload)
def redirect(self, location, cookie_header=None):
self.send_response(303)
self.send_header("Location", location)
self.send_header("Cache-Control", "no-store")
if cookie_header:
self.send_header("Set-Cookie", cookie_header)
self.end_headers()
How Are Responses Controlled?
The send_html() helper sends HTML with an explicit status and content length. The redirect() helper sends a redirect response.
Both helpers apply Cache-Control with no-store. This prevents session-bearing responses from being retained in caches.
- Add CSRF rejection and the hardened home route below redirect() by pasting this code:
def reject_csrf(self):
self.send_html(
403,
"Request Rejected",
"<p>The CSRF token was missing or invalid.</p>",
)
def do_GET(self):
path = urllib.parse.urlsplit(self.path).path
session_id, session = self.current_session()
cookie_header = None
if not session:
session_id, session = create_session()
cookie_header = active_cookie(session_id)
if path == "/":
body = """
<p class="success">This version uses server-issued IDs and server-side invalidation.</p>
<nav><a href="/login">Log in</a><a href="/account">Account</a></nav>
<p>Session IDs in query parameters are ignored.</p>
"""
self.send_html(200, "Hardened Session Lab", body, cookie_header)
return
What Happens on the First Request?
The handler asks current_session() for an active record. A missing or expired record causes the server to create a fresh anonymous session.
The route parser uses only the request path. Query parameters cannot choose the session ID.
- Save secure.py.
- Select Run Python File to check the response helpers and first route.
The terminal returns without a traceback. The request handler can now issue a new anonymous session and render the hardened home page.
Response Methods Not Loading?
- Check that send_html(), redirect(), reject_csrf(), and do_GET() remain inside SecureSessionHandler.
- Check that the home-page HTML keeps its opening and closing triple quotes.
- Ask for help with the response-method traceback.
- Continue do_GET() with the secure login route by pasting this code below the home route:
if path == "/login":
if session["user"]:
self.redirect("/account", cookie_header)
return
csrf_token = html.escape(session["csrf"], quote=True)
body = f"""
<form method="post" action="/login">
<input type="hidden" name="csrf" value="{csrf_token}">
<label>Username <input name="username" value="victim" required></label><br>
<button type="submit">Log in securely</button>
</form>
"""
self.send_html(200, "Secure Login", body, cookie_header)
return
How Is Login Protected?
The anonymous server session already contains a synchronizer token. The login form places that token in a hidden csrf field.
The token is escaped before it enters the page. An authenticated session is redirected away from the login form.
- Continue do_GET() with the protected account route by pasting this code:
if path == "/account":
if not session["user"]:
self.redirect("/login", cookie_header)
return
csrf_token = html.escape(session["csrf"], quote=True)
username = html.escape(session["user"])
body = f"""
<p>Signed in as <strong>{username}</strong>.</p>
<p>The server will expire this session after {IDLE_TIMEOUT_SECONDS} seconds without a request.</p>
<form method="post" action="/logout">
<input type="hidden" name="csrf" value="{csrf_token}">
<button type="submit">Log out securely</button>
</form>
"""
self.send_html(200, "Protected Account", body, cookie_header)
return
How Is the Account Protected?
An anonymous request to /account is redirected to login. An authenticated request receives the escaped username from the server-side session.
Logout uses a form containing the current CSRF token. This keeps the state-changing action on a validated request path.
- Save secure.py.
- Select Run Python File to check the login and account routes.
The terminal returns without a traceback. The login and protected-account pages now form a valid request flow.
Login Routes Showing an Error?
- Check that both route blocks stay inside do_GET() with eight spaces before each if statement.
- Check that the hidden input uses the same csrf_token name created above each form.
- Ask for help checking the route indentation and form strings.
- Finish do_GET() with the logout restriction and fallback route by pasting this code:
if path == "/logout":
self.send_html(
405,
"Method Not Allowed",
"<p>Logout requires a CSRF-protected POST request.</p>",
cookie_header,
)
return
self.send_html(404, "Not Found", "<p>The requested page does not exist.</p>", cookie_header)
Why Reject a Logout Link?
A direct request to /logout cannot change the session. Logout must arrive through the protected form.
Any unknown path receives a clear missing-page response. The current cookie header is preserved when the request created a fresh anonymous session.
- Save secure.py.
- Select Run Python File to check the completed GET handler.
The terminal returns without a traceback. The complete GET flow is ready for session-aware POST requests.
GET Handler Still Failing?
- Check that the logout route aligns with the login and account route blocks.
- Check that the final missing-page response stays inside do_GET().
- Ask for help with the final GET-handler traceback.
Protect login and verify session rotation
The GET routes create forms, but the POST handler decides whether those forms can change session state. Login must validate the submitted token, destroy the anonymous record, and issue a new authenticated session.
- Add the first part of do_POST() below do_GET() by pasting this code:
def do_POST(self):
path = urllib.parse.urlsplit(self.path).path
session_id, session = self.current_session()
if not session:
self.redirect("/login", expired_cookie())
return
form = self.read_form()
submitted_csrf = form.get("csrf", [""])[0]
if not secrets.compare_digest(submitted_csrf, session["csrf"]):
self.reject_csrf()
return
if path == "/login":
username = form.get("username", ["victim"])[0].strip() or "victim"
SESSIONS.pop(session_id, None)
new_session_id, _ = create_session(user=username)
self.redirect("/account", active_cookie(new_session_id))
return
Where Does Rotation Happen?
The handler compares the submitted token with the server-side value before processing login. A missing or mismatched token receives a rejection.
Successful login removes the anonymous session_id from SESSIONS. The call to create_session(user=username) creates a different authenticated ID.
- Finish do_POST() with logout invalidation and the fallback response by pasting this code:
if path == "/logout":
SESSIONS.pop(session_id, None)
self.send_html(
200,
"Logged Out",
'<p>The server session was deleted. The old ID cannot be replayed.</p><a href="/">Return home</a>',
expired_cookie(),
)
return
self.send_html(404, "Not Found", "<p>The requested action does not exist.</p>")
How Is Logout Invalidation Different?
The vulnerable app removed only the browser cookie. This handler also removes the authoritative server record with SESSIONS.pop().
The expired cookie clears the browser copy. Reusing the old value cannot recover the deleted session record.
- Save secure.py.
- Select Run Python File to check the POST handler.
The terminal returns without a traceback. Login rotation, CSRF validation, and server-side logout invalidation now load successfully.
POST Handler Showing an Error?
- Check that do_POST() aligns with do_GET() inside the handler class.
- Check that the login and logout blocks remain indented inside do_POST().
- Ask for help with the POST-handler traceback.
- Add the server startup block at the bottom of secure.py by pasting this code:
if __name__ == "__main__":
server = http.server.ThreadingHTTPServer((HOST, PORT), SecureSessionHandler)
print(f"Hardened lab: http://{HOST}:{PORT}")
server.serve_forever()
What Starts the Hardened Server?
The startup block creates a threaded local server with SecureSessionHandler. It prints the local address before waiting for browser requests.
- Save secure.py.
Before you run the complete app, do you expect the cookie value to stay the same after login or change?
- Select Run Python File while secure.py is active.
The integrated terminal prints Hardened lab: http://127.0.0.1:8001. The process keeps running while the server waits for requests.
- Switch back to Chrome.
- Open http://127.0.0.1:8001/login.
- Open Chrome DevTools.
- Select Application.
- Expand Storage.
- Select Cookies.
- Select http://127.0.0.1:8001.
You see an id cookie containing a server-generated value. Its table row shows Path=/, HttpOnly, and SameSite=Strict.
- Copy the pre-login id value into a temporary private note.
- Return to the Secure Login page.
- Leave the username as victim.
- Click Log in securely.
- Return to the Cookies table in DevTools.
- Compare the current id value with your temporary note.
- Delete the temporary note after making the comparison.
The browser displays Protected Account for victim. The post-login cookie differs from the anonymous pre-login value.
That rotation closes the privilege-change gap used by the vulnerable app. The attacker-controlled ID no longer survives authentication.
Cookie Value Did Not Change?
- Check that the login branch removes session_id before calling create_session(user=username).
- Check that the redirect uses active_cookie(new_session_id).
- Ask for help tracing the pre-login and post-login session flow without sharing either cookie value.
✔️ Awesome, I've got everything!
- Keep secure.py saved.
- Keep the hardened server running for the attack retests in the next step.
ⓧ I'd like to double check the full code
Compare your saved secure.py with this complete hardened application.
import html
import http.cookies
import http.server
import secrets
import time
import urllib.parse
HOST = "127.0.0.1"
PORT = 8001
COOKIE_NAME = "id"
IDLE_TIMEOUT_SECONDS = 60
SESSIONS = {}
def render_page(title, body):
return f"""<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{html.escape(title)}</title>
<style>
body {{ font-family: system-ui, sans-serif; max-width: 720px; margin: 48px auto; padding: 0 20px; }}
code {{ background: #f1f3f5; padding: 2px 5px; }}
.success {{ border-left: 5px solid #2b8a3e; padding: 12px; background: #ebfbee; }}
nav a {{ margin-right: 16px; }}
input, button {{ font: inherit; padding: 8px; margin-top: 8px; }}
</style>
</head>
<body>
<h1>{html.escape(title)}</h1>
{body}
</body>
</html>"""
def create_session(user=None):
now = time.monotonic()
session_id = secrets.token_urlsafe(32)
SESSIONS[session_id] = {
"user": user,
"csrf": secrets.token_urlsafe(32),
"created": now,
"last_seen": now,
}
return session_id, SESSIONS[session_id]
def active_cookie(session_id):
# Secure is intentionally omitted because this lab uses local HTTP.
# A deployed application must use HTTPS and add Secure.
return f"{COOKIE_NAME}={session_id}; Path=/; HttpOnly; SameSite=Strict"
def expired_cookie():
return f"{COOKIE_NAME}=; Path=/; HttpOnly; SameSite=Strict; Max-Age=0"
class SecureSessionHandler(http.server.BaseHTTPRequestHandler):
def read_form(self):
try:
length = min(int(self.headers.get("Content-Length", "0")), 4096)
except ValueError:
length = 0
raw_body = self.rfile.read(length).decode("utf-8", errors="replace")
return urllib.parse.parse_qs(raw_body, keep_blank_values=True, max_num_fields=10)
def cookie_session_id(self):
raw_cookie = self.headers.get("Cookie", "")
cookie = http.cookies.SimpleCookie()
try:
cookie.load(raw_cookie)
except http.cookies.CookieError:
return None
morsel = cookie.get(COOKIE_NAME)
return morsel.value if morsel else None
def current_session(self):
session_id = self.cookie_session_id()
session = SESSIONS.get(session_id)
if not session:
return None, None
now = time.monotonic()
if now - session["last_seen"] > IDLE_TIMEOUT_SECONDS:
SESSIONS.pop(session_id, None)
return None, None
session["last_seen"] = now
return session_id, session
def send_html(self, status, title, body, cookie_header=None):
payload = render_page(title, body).encode("utf-8")
self.send_response(status)
self.send_header("Content-Type", "text/html; charset=utf-8")
self.send_header("Content-Length", str(len(payload)))
self.send_header("Cache-Control", "no-store")
if cookie_header:
self.send_header("Set-Cookie", cookie_header)
self.end_headers()
self.wfile.write(payload)
def redirect(self, location, cookie_header=None):
self.send_response(303)
self.send_header("Location", location)
self.send_header("Cache-Control", "no-store")
if cookie_header:
self.send_header("Set-Cookie", cookie_header)
self.end_headers()
def reject_csrf(self):
self.send_html(
403,
"Request Rejected",
"<p>The CSRF token was missing or invalid.</p>",
)
def do_GET(self):
path = urllib.parse.urlsplit(self.path).path
session_id, session = self.current_session()
cookie_header = None
if not session:
session_id, session = create_session()
cookie_header = active_cookie(session_id)
if path == "/":
body = """
<p class="success">This version uses server-issued IDs and server-side invalidation.</p>
<nav><a href="/login">Log in</a><a href="/account">Account</a></nav>
<p>Session IDs in query parameters are ignored.</p>
"""
self.send_html(200, "Hardened Session Lab", body, cookie_header)
return
if path == "/login":
if session["user"]:
self.redirect("/account", cookie_header)
return
csrf_token = html.escape(session["csrf"], quote=True)
body = f"""
<form method="post" action="/login">
<input type="hidden" name="csrf" value="{csrf_token}">
<label>Username <input name="username" value="victim" required></label><br>
<button type="submit">Log in securely</button>
</form>
"""
self.send_html(200, "Secure Login", body, cookie_header)
return
if path == "/account":
if not session["user"]:
self.redirect("/login", cookie_header)
return
csrf_token = html.escape(session["csrf"], quote=True)
username = html.escape(session["user"])
body = f"""
<p>Signed in as <strong>{username}</strong>.</p>
<p>The server will expire this session after {IDLE_TIMEOUT_SECONDS} seconds without a request.</p>
<form method="post" action="/logout">
<input type="hidden" name="csrf" value="{csrf_token}">
<button type="submit">Log out securely</button>
</form>
"""
self.send_html(200, "Protected Account", body, cookie_header)
return
if path == "/logout":
self.send_html(
405,
"Method Not Allowed",
"<p>Logout requires a CSRF-protected POST request.</p>",
cookie_header,
)
return
self.send_html(404, "Not Found", "<p>The requested page does not exist.</p>", cookie_header)
def do_POST(self):
path = urllib.parse.urlsplit(self.path).path
session_id, session = self.current_session()
if not session:
self.redirect("/login", expired_cookie())
return
form = self.read_form()
submitted_csrf = form.get("csrf", [""])[0]
if not secrets.compare_digest(submitted_csrf, session["csrf"]):
self.reject_csrf()
return
if path == "/login":
username = form.get("username", ["victim"])[0].strip() or "victim"
SESSIONS.pop(session_id, None)
new_session_id, _ = create_session(user=username)
self.redirect("/account", active_cookie(new_session_id))
return
if path == "/logout":
SESSIONS.pop(session_id, None)
self.send_html(
200,
"Logged Out",
'<p>The server session was deleted. The old ID cannot be replayed.</p><a href="/">Return home</a>',
expired_cookie(),
)
return
self.send_html(404, "Not Found", "<p>The requested action does not exist.</p>")
if __name__ == "__main__":
server = http.server.ThreadingHTTPServer((HOST, PORT), SecureSessionHandler)
print(f"Hardened lab: http://{HOST}:{PORT}")
server.serve_forever()
Your hardened app now controls session creation, privilege-change rotation, CSRF validation, cookie scope, logout invalidation, and idle expiration. Next, you will repeat the original attacks and test whether those controls hold.
Prove the Fixes Hold
A rotated cookie is promising evidence. The original attacks now need to fail against the hardened app.
You will repeat the three original attack paths. Browser evidence will show whether the server denies each stale or attacker-selected ID.
In this step, get ready to:
- Retest the complete session fixation path from caller-selected ID through login rotation.
- Replay a logged-out session ID against the protected account.
- Verify server-side expiration after the 60-second idle timeout.
Retest session fixation
Session fixation succeeds when an application accepts an attacker-selected ID as the victim's authenticated session. This retest checks whether the secure server ignores that ID before replacing the anonymous session during login.
- Return to Chrome DevTools from earlier.
- Select Application.
- Expand Storage.
- Select Cookies.
- Select http://127.0.0.1:8001.
- Select the id cookie row.
- Click Delete selected in the top action bar.
Before you load the fixed-ID URL, do you expect its query-string value to become the new cookie?
- Visit http://127.0.0.1:8001/login?sid=fixed-demo-session.
- Select the new id cookie row in DevTools.
The cookie contains a generated value. It does not contain fixed-demo-session.
- Return to the Secure Login page.
- Enter victim in the Username field.
Before you submit the form, do you expect the anonymous cookie to survive authentication?
- Click Log in securely.
- Return to the cookie table in DevTools.
- Select the id cookie row.
You should see a different id value after login. The Path field shows /.
The HttpOnly field is enabled. The SameSite field shows Strict.
That closes the fixation path: the server controls the session ID before authentication. It replaces that ID when the user's privileges change.
Still Seeing the Fixed ID?
- Check that the address uses port 8001.
- Confirm that secure.py still runs in the VS Code integrated terminal.
- Delete the current id cookie before loading the fixed-ID URL again.
- Help me troubleshoot why the secure session lab is accepting the fixed URL session ID.
Reject a logged-out session replay
A session replay reuses a previously valid ID after the user logs out. A CSRF-protected POST makes logout an intentional state change.
The logout handler removes the authenticated record from SESSIONS. Restoring the browser cookie cannot restore that deleted record.
The cookie value temporarily grants access to this local lab. Keep it on your clipboard only until this replay check ends.
- Select the authenticated id cookie row in DevTools.
- Copy its Value field to your clipboard.
- Return to the Protected Account page.
- Click Log out securely.
You should reach the Logged Out page. The response removes the browser cookie after deleting the server-side session.
- Return to the cookie table in DevTools.
- Double-click an empty row in the table.
- Enter id under Name.
- Paste the copied session value under Value.
- Press Enter.
Before you request the protected page, do you expect the restored browser cookie to recreate the deleted server session?
- Visit http://127.0.0.1:8001/account.
You should end at the Secure Login page. The old ID no longer maps to an authenticated record in SESSIONS.
That replay attempt is rejected. Server-side logout now ends access for every browser context holding the old ID.
Still Reaching the Protected Account?
- Confirm that you copied the authenticated cookie value before clicking Log out securely.
- Confirm that the browser displayed the Logged Out page before you restored the cookie.
- Check that the restored cookie uses the name id.
- Help me troubleshoot why a logged-out session ID still reaches the protected account.
Trigger server-side idle expiration
A server-side idle timeout limits how long an unused session remains valid. The lab uses 60 seconds so you can observe expiration directly.
- Enter victim in the Username field.
- Click Log in securely.
You should return to the Protected Account page with a new authenticated session.
This check needs a quiet wait of just over one minute, so the pause is expected.
- Leave the account page untouched for more than 60 seconds.
Before you refresh, do you expect the browser's existing cookie to keep the expired server session alive?
- Refresh /account.
You should end at the Secure Login page. The current_session method has removed the expired record from SESSIONS.
You have now repeated every original attack against the hardened app. Fixed IDs, logged-out IDs, and expired IDs all lead back to login.
Still Seeing the Protected Account?
- Repeat the test without refreshing the page during the wait.
- Wait longer than 60 seconds before requesting /account again.
- Confirm that secure.py still contains IDLE_TIMEOUT_SECONDS = 60.
- Help me troubleshoot why the secure session remains active after the idle timeout.
Secret mission
Write the Security Finding
Turn your browser evidence into a concise security finding that another tester can reproduce. Trace the attack from root cause to impact. Close with remediation plus retest evidence from the hardened app.
Clean Up Your Resources
Clean Up Your Resources
Everything in this lab stays on your Mac. There are no ongoing costs.
Choose whether to keep the server available, stop it for later, or delete the session-lab folder.
Resources you used:
- The running secure.py process at http://127.0.0.1:8001.
- The session-lab workspace containing the complete local lab.
Keep everything running
No cleanup action is needed. Choose this option if you are still demonstrating the hardened behavior or reviewing your evidence.
- Leave secure.py running at http://127.0.0.1:8001 while you actively use the lab.
- Keep vulnerable.py stopped until you want to repeat the local attack exercise.
- Keep session-report.md with the app files as your browser-based security finding.
Pause - I'll come back to this later
Stop the local server to free its memory. Your source files and security report remain in the workspace.
- Return to the Visual Studio Code integrated terminal running secure.py.
- Stop the active process using the terminal's stop control.
- Refresh http://127.0.0.1:8001 in Chrome.
Chrome can no longer reach the app. The in-memory sessions are now gone.
- Make secure.py active when you are ready to resume.
- Select Run Python File to restart the hardened server.
Delete - I don't want to use this again
Deletion becomes permanent after you empty the Trash. These steps move only the session-lab folder there.
- Return to the VS Code integrated terminal running secure.py.
- Stop the active process using the terminal's stop control.
- Close the session-lab workspace in VS Code.
- Use Finder to move the session-lab folder to Trash.
- Confirm the session-lab folder no longer appears in its previous location.
- Refresh http://127.0.0.1:8001 in Chrome.
Chrome can no longer reach the app. The local server process and workspace are removed from active use.
Nice Work!
Nice Work!
Excellent work! Your local lab exposes session fixation through a real browser takeover. The hardened version rejects the same attack.
You've learned how to:
- Reproduced session fixation with vulnerable.py. Set an attacker-chosen session ID in a normal Google Chrome window. Authenticated the victim in a separate Incognito window. Proved the attacker context could enter the victim's account without logging in.
- Demonstrated broken logout in vulnerable.py. Cleared the victim's browser cookie. Replayed the surviving server session from the attacker context. Proved that effective logout requires server-side invalidation.
- Built secure.py around random server-issued IDs. Added login rotation. Protected forms with CSRF synchronizer tokens. Applied hardened local cookie attributes. Deleted server records during logout. Enforced server-side idle expiration after 60 seconds. Repeated the original tests to confirm every attempt returned to the login page.
- Secret Mission: Produced an OWASP-aligned security finding in session-report.md. The report separates root cause from impact. It documents remediation across the session lifecycle. It records the production requirement for HTTPS with the Secure cookie attribute. It closes with retest evidence that every original ID was rejected.
Ready to quiz yourself?