Add Team 14 CSRF team project materials (정보보호, Korea Univ.)

This commit is contained in:
flyemoji committed 2026-10-08 00:37:07 +09:00
1 parent f53f19e2ab
commit 4b914f8c4a
22 files changed
+405

No files matched your search

+5
View File
@@ -0,0 +1,5 @@
team-project-csrf/**/*.pptx filter=lfs diff=lfs merge=lfs -text
team-project-csrf/**/*.docx filter=lfs diff=lfs merge=lfs -text
team-project-csrf/**/*.pdf filter=lfs diff=lfs merge=lfs -text
team-project-csrf/**/*.zip filter=lfs diff=lfs merge=lfs -text
team-project-csrf/**/*.png filter=lfs diff=lfs merge=lfs -text
+26
View File
@@ -0,0 +1,26 @@
# team-project-csrf
Korea University · 정보보호 (Information Security) — **Team 14** team project, topic: **Cross-Site Request Forgery (CSRF)**.
Archive of project materials, copied as-is from the original files (no content edited). Original filenames used an em dash (—); they are renamed here to ASCII-safe names.
> **Deadline:** Final Report due **2026-10-08 23:59 KST**.
## File index
Dates are the original files' last-modified times (KST).
| Path | Original filename | Date |
|---|---|---|
| `report/Team14-CSRF-Presentation-Materials-Report.docx` | `Team 14 — CSRF Presentation Materials (Report).docx` | 2026-10-07 17:25 |
| `report/Cross-Site-Request-Forgery_Team14.pdf` | `Cross-Site Request Forgery_Team14.pdf` | 2026-10-07 09:58 |
| `slides/Team14-CSRF-Revised-Slides.pptx` | `Team 14 — CSRF (Revised Slides).pptx` (current slides) | 2026-10-07 17:25 |
| `slides/archive/Cross-Site-Request-Forgery_Team14_original-2026-09-30.pptx` | `Cross-Site Request Forgery_Team14.pptx` (original slides) | 2026-09-30 23:23 |
| `figures/fig1..fig9_annotated.png`, `figures/annotate.py` | contents of `figures_annotated.zip` | 2026-10-07 17:24 (zip) |
| `figures/fig9_experiment_c.png` | `fig9_experiment_c.png` | 2026-10-07 10:58 |
| `experiments/experiment_c/` (`csrf_gate.py`, `replay.sh`, `render_fig9.py`, `client.log`, `server.log`) | contents of `experiment_c.zip` | 2026-10-07 10:57 (zip) |
## Notes
- Binary files (`*.pptx *.docx *.pdf *.zip *.png`) are stored with Git LFS.
- 本仓库仅作归档 / archive only.
@@ -0,0 +1,11 @@
client: replay.sh
----------------------------------------------------------------------------------------
C0 control: genuine form, same-origin, token present -> wiener.new@ -> HTTP 200
A1 §3.1 GET /change-email?email=attacker, token dropped, cross-site -> HTTP 405
A2 §3.2B GET …&_method=POST (method override), cross-site -> HTTP 405
A3 §3.2A POST, token absent, cross-site form -> HTTP 403
A4 POST from evil.example.com (same-site sibling), token absent -> HTTP 403
A5 POST with allowed Origin, token absent (layer 3 alone must hold) -> HTTP 403
A6 POST with allowed Origin, token from another session -> HTTP 403
----------------------------------------------------------------------------------------
final state: current email: [email protected]
@@ -0,0 +1,87 @@
# csrf_gate.py — Experiment C: one gate, every unsafe method, nothing routes around it
# Run: python3 csrf_gate.py (HTTPS on 127.0.0.1:8443, self-signed cert.pem/key.pem)
# Then: bash replay.sh (replays the attack shapes of §3.1 / §3.2 and a control)
import hmac, logging, secrets, sys
from flask import Flask, abort, request, session, render_template_string
app = Flask(__name__)
app.secret_key = secrets.token_bytes(32) # per-process for the lab; persist in production
app.config.update(
SESSION_COOKIE_SAMESITE="Lax", # layer 1 — enforced by the browser (experiment B)
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True,
)
SAFE = {"GET", "HEAD", "OPTIONS"}
ALLOWED_ORIGINS = {"https://app.example.com"} # exact origins, never bare domains
logging.basicConfig(stream=sys.stdout, level=logging.INFO, format="%(message)s")
log = logging.getLogger("gate")
def reject(status, layer, reason):
log.info(f"[GATE] REJECT {status} {request.method:<4} {request.full_path.rstrip('?'):<48} layer={layer:<2} {reason}")
abort(status)
@app.before_request
def csrf_gate():
if request.method in SAFE: # safe methods never change state (routing enforces it)
return
# layer 2a — browser-asserted provenance; page script cannot set this header
if request.headers.get("Sec-Fetch-Site") == "cross-site":
reject(403, "2a", "Sec-Fetch-Site: cross-site")
# layer 2b — exact-origin allow-list; catches sibling subdomains that are same-site
origin = request.headers.get("Origin")
if origin is None or origin not in ALLOWED_ORIGINS:
reject(403, "2b", f"Origin {origin} not in allow-list")
# layer 3 — session-bound synchronizer token, constant-time compare
expected = session.get("csrf", "")
supplied = request.form.get("csrf") or request.headers.get("X-CSRF-Token", "")
if not expected or not hmac.compare_digest(expected.encode(), supplied.encode()):
reject(403, "3", "token missing or mismatched")
def csrf_token(): # called by the template that renders the form
return session.setdefault("csrf", secrets.token_urlsafe(32))
@app.errorhandler(405)
def method_not_allowed(_e): # layer 0 — the route itself refuses GET
log.info(f"[GATE] REJECT 405 {request.method:<4} {request.full_path.rstrip('?'):<48} layer=0 method not allowed on state-changing route")
return "Method Not Allowed\n", 405
# ---- scaffolding for the experiment (login, account page, state) — not part of the gate ----
USERS = {"wiener": {"email": "[email protected]"}}
@app.get("/login")
def login():
session["user"] = "wiener"
csrf_token()
return "logged in as wiener\n"
@app.get("/my-account")
def my_account():
if "user" not in session:
abort(401)
return render_template_string(
'<form method="POST" action="/change-email">'
'<input type="hidden" name="csrf" value="{{ t }}">'
'<input name="email"><button>Update email</button></form>\n'
'current email: {{ e }}\n',
t=csrf_token(), e=USERS["wiener"]["email"])
@app.post("/change-email") # layer 0: POST only → GET receives 405 from Flask
def change_email():
if "user" not in session:
abort(401)
USERS[session["user"]]["email"] = request.form["email"]
log.info(f"[APP ] ACCEPT 200 POST /change-email{'':<36} layers 0,2a,2b,3 passed email={request.form['email']}")
return f"email changed to {request.form['email']}\n"
if __name__ == "__main__":
app.run(host="127.0.0.1", port=8443, ssl_context=("cert.pem", "key.pem"))
@@ -0,0 +1,48 @@
# render_fig9.py — turn client.log + server.log into one terminal-style PNG (Fig. 9)
import re
from PIL import Image, ImageDraw, ImageFont
MONO = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono.ttf"
BOLD = "/usr/share/fonts/truetype/dejavu/DejaVuSansMono-Bold.ttf"
font = ImageFont.truetype(MONO, 22)
bold = ImageFont.truetype(BOLD, 22)
client = open("client.log", encoding="utf-8").read().rstrip().splitlines()
server = [l for l in open("server.log", encoding="utf-8").read().splitlines()
if l.startswith(("[GATE]", "[APP ]", " * Running"))]
BG, FG, DIM, OK, BAD, HEAD = (24, 26, 36), (220, 223, 228), (130, 135, 150), (120, 220, 140), (255, 120, 120), (140, 180, 255)
def color_for(line):
if "ACCEPT" in line or "HTTP 200" in line:
return OK
if "REJECT" in line or "HTTP 4" in line:
return BAD
if line.startswith(("client:", "server:")):
return HEAD
if set(line) <= {"-"} or line.startswith(" *"):
return DIM
return FG
blocks = [("client: replay.sh (victim's browser, cookie attached in every request)", client[1:]),
("server: csrf_gate.py (gate decisions)", server)]
lines = []
for title, body in blocks:
lines.append((title, HEAD, bold))
for l in body:
lines.append((l, color_for(l), bold if ("ACCEPT" in l or "REJECT" in l or "HTTP" in l) else font))
lines.append(("", FG, font))
LH, PAD = 32, 36
width = max(int(d.textlength(t, font=f)) for t, _, f in lines for d in [ImageDraw.Draw(Image.new("RGB", (1, 1)))]) + 2 * PAD
height = LH * len(lines) + 2 * PAD + 40
img = Image.new("RGB", (width, height), BG)
d = ImageDraw.Draw(img)
d.text((PAD, 18), "Experiment C — layered CSRF gate vs. the attack shapes of §3.1 / §3.2 (Flask 3.1, curl, 2026-10-07)", fill=DIM, font=bold)
y = PAD + 40
for t, c, f in lines:
d.text((PAD, y), t, fill=c, font=f)
y += LH
img.save("fig9_experiment_c.png")
print(img.size)
@@ -0,0 +1,42 @@
#!/usr/bin/env bash
# replay.sh — Experiment C: replay the attack shapes of §3.1 / §3.2 against csrf_gate.py
# Each request carries the headers a browser would send for that situation. The session
# cookie is attached in every case, deliberately: the browser-side SameSite layer is
# exercised in experiment B; here we test whether the SERVER holds on its own.
set -u
B=https://127.0.0.1:8443
J=$(mktemp) # cookie jar = the victim's logged-in browser
C="curl -sk -b $J -c $J -o /dev/null -w %{http_code}"
curl -sk -c "$J" "$B/login" >/dev/null # victim logs in
TOKEN=$(curl -sk -b "$J" "$B/my-account" | grep -o 'name="csrf" value="[^"]*"' | cut -d'"' -f4)
run() { printf '%-3s %-64s -> HTTP %s\n' "$1" "$2" "$3"; }
echo "client: replay.sh"
echo "----------------------------------------------------------------------------------------"
run C0 "control: genuine form, same-origin, token present -> wiener.new@" \
"$($C -X POST "$B/change-email" -H "Origin: https://app.example.com" -H "Sec-Fetch-Site: same-origin" -H "Sec-Fetch-Mode: navigate" --data "csrf=$TOKEN&[email protected]")"
run A1 "§3.1 GET /change-email?email=attacker, token dropped, cross-site" \
"$($C "$B/[email protected]" -H "Sec-Fetch-Site: cross-site" -H "Sec-Fetch-Mode: navigate")"
run A2 "§3.2B GET …&_method=POST (method override), cross-site" \
"$($C "$B/[email protected]&_method=POST" -H "Sec-Fetch-Site: cross-site" -H "Sec-Fetch-Mode: navigate")"
run A3 "§3.2A POST, token absent, cross-site form" \
"$($C -X POST "$B/change-email" -H "Origin: https://evil.net" -H "Sec-Fetch-Site: cross-site" -H "Sec-Fetch-Mode: navigate" --data "[email protected]")"
run A4 "POST from evil.example.com (same-site sibling), token absent" \
"$($C -X POST "$B/change-email" -H "Origin: https://evil.example.com" -H "Sec-Fetch-Site: same-site" -H "Sec-Fetch-Mode: navigate" --data "[email protected]")"
run A5 "POST with allowed Origin, token absent (layer 3 alone must hold)" \
"$($C -X POST "$B/change-email" -H "Origin: https://app.example.com" -H "Sec-Fetch-Site: same-origin" -H "Sec-Fetch-Mode: navigate" --data "[email protected]")"
run A6 "POST with allowed Origin, token from another session" \
"$($C -X POST "$B/change-email" -H "Origin: https://app.example.com" -H "Sec-Fetch-Site: same-origin" -H "Sec-Fetch-Mode: navigate" --data "csrf=AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA&[email protected]")"
echo "----------------------------------------------------------------------------------------"
printf 'final state: %s\n' "$(curl -sk -b "$J" "$B/my-account" | grep -o 'current email: .*')"
rm -f "$J"
@@ -0,0 +1,19 @@
* Serving Flask app 'csrf_gate'
* Running on https://127.0.0.1:8443
127.0.0.1 - - [07/Oct/2026 10:16:42] "GET /login HTTP/1.1" 200 -
127.0.0.1 - - [07/Oct/2026 10:16:42] "GET /my-account HTTP/1.1" 200 -
[APP ] ACCEPT 200 POST /change-email layers 0,2a,2b,3 passed [email protected]
127.0.0.1 - - [07/Oct/2026 10:16:42] "POST /change-email HTTP/1.1" 200 -
[GATE] REJECT 405 GET /[email protected] layer=0 method not allowed on state-changing route
127.0.0.1 - - [07/Oct/2026 10:16:42] "GET /[email protected] HTTP/1.1" 405 -
[GATE] REJECT 405 GET /[email protected]&_method=POST layer=0 method not allowed on state-changing route
127.0.0.1 - - [07/Oct/2026 10:16:42] "GET /[email protected]&_method=POST HTTP/1.1" 405 -
[GATE] REJECT 403 POST /change-email layer=2a Sec-Fetch-Site: cross-site
127.0.0.1 - - [07/Oct/2026 10:16:42] "POST /change-email HTTP/1.1" 403 -
[GATE] REJECT 403 POST /change-email layer=2b Origin https://evil.example.com not in allow-list
127.0.0.1 - - [07/Oct/2026 10:16:42] "POST /change-email HTTP/1.1" 403 -
[GATE] REJECT 403 POST /change-email layer=3 token missing or mismatched
127.0.0.1 - - [07/Oct/2026 10:16:42] "POST /change-email HTTP/1.1" 403 -
[GATE] REJECT 403 POST /change-email layer=3 token missing or mismatched
127.0.0.1 - - [07/Oct/2026 10:16:42] "POST /change-email HTTP/1.1" 403 -
127.0.0.1 - - [07/Oct/2026 10:16:42] "GET /my-account HTTP/1.1" 200 -
+125
View File
@@ -0,0 +1,125 @@
# annotate.py — draw numbered red boxes + dark callout labels on the lab screenshots.
# Coordinates below are in a 2000x1200 reference space; S scales them to the real 2880x1728.
from PIL import Image, ImageDraw, ImageFont
S = 2880 / 2000
RED = (229, 50, 45)
NAVY = (27, 29, 58)
WHITE = (255, 255, 255)
FONT = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 38)
BADGE = ImageFont.truetype("/usr/share/fonts/truetype/dejavu/DejaVuSans-Bold.ttf", 34)
STROKE = 6
def sc(v):
return int(round(v * S))
def annotate(src, dst, items):
im = Image.open(src).convert("RGBA")
ov = Image.new("RGBA", im.size, (0, 0, 0, 0))
d = ImageDraw.Draw(ov)
for n, it in enumerate(items, 1):
x1, y1, x2, y2 = map(sc, it["box"])
d.rounded_rectangle((x1, y1, x2, y2), radius=10, outline=RED, width=STROKE)
# badge at the box's top-left corner
bx, by, r = x1, y1, 26
d.ellipse((bx - r, by - r, bx + r, by + r), fill=RED)
tw = d.textlength(str(n), font=BADGE)
d.text((bx - tw / 2, by - 24), str(n), fill=WHITE, font=BADGE)
# callout label
lx, ly = map(sc, it["label_at"])
text = f"{n} {it['label']}"
tw = d.textlength(text, font=FONT)
pad = 16
lbox = (lx, ly, lx + tw + 2 * pad, ly + 38 + 2 * pad)
# leader line from label to the nearest box edge
cx = min(max((lbox[0] + lbox[2]) / 2, x1), x2)
cy = y1 if lbox[3] < y1 else (y2 if lbox[1] > y2 else (y1 + y2) / 2)
ax = lbox[0] if cx < lbox[0] else (lbox[2] if cx > lbox[2] else cx)
ay = lbox[3] if cy > lbox[3] else (lbox[1] if cy < lbox[1] else (lbox[1] + lbox[3]) / 2)
if it.get("leader", True):
d.line((ax, ay, cx, cy), fill=RED, width=5)
d.rounded_rectangle(lbox, radius=12, fill=NAVY + (235,))
d.text((lx + pad, ly + pad - 2), text, fill=WHITE, font=FONT)
out = Image.alpha_composite(im, ov).convert("RGB")
out.save(dst, optimize=True)
print(dst, out.size)
annotate("fig1_original_post.png", "fig1_annotated.png", [
{"box": (576, 594, 1424, 619), "label": "Genuine POST /change-email -> 302 (email changed)",
"label_at": (640, 300)},
{"box": (14, 709, 562, 733), "label": "Session cookie: attached by the browser automatically",
"label_at": (600, 900)},
{"box": (1436, 644, 1780, 690), "label": "csrf token in the form body",
"label_at": (1445, 720)},
])
annotate("fig2_wrong_token_400.png", "fig2_annotated.png", [
{"box": (8, 1060, 566, 1092), "label": "Same POST resent with the csrf value altered",
"label_at": (14, 1112)},
{"box": (576, 844, 1424, 869), "label": "Server rejects it: 400",
"label_at": (640, 960)},
{"box": (1436, 678, 1780, 702), "label": "\"Invalid CSRF token\": the check works for POST",
"label_at": (600, 1040)},
])
annotate("fig3_get_no_token_302.png", "fig3_annotated.png", [
{"box": (20, 949, 1424, 974), "label": "GET /change-email?email=... with NO token -> 302 accepted",
"label_at": (300, 898)},
{"box": (1436, 676, 1780, 836), "label": "302 -> Location: /my-account",
"label_at": (1445, 850)},
{"box": (398, 262, 664, 294), "label": "Email changed - the token was never checked on GET",
"label_at": (690, 262)},
{"box": (20, 699, 1424, 723), "label": "Control: same GET on a lab WITHOUT the flaw -> 405",
"label_at": (300, 648)},
])
annotate("fig4_solved.png", "fig4_annotated.png", [
{"box": (398, 110, 802, 157), "label": "Lab solved by the delivered page",
"label_at": (840, 112)},
{"box": (406, 858, 1292, 982), "label": "A form with no method attribute (= GET) and no csrf field",
"label_at": (414, 1000)},
])
annotate("fig5_no_token_302.png", "fig5_annotated.png", [
{"box": (8, 972, 566, 1000), "label": "Resent POST: csrf parameter DELETED, not altered",
"label_at": (14, 1030)},
{"box": (576, 593, 1424, 617), "label": "Accepted: 302 with no csrf parameter at all",
"label_at": (640, 720)},
{"box": (398, 246, 662, 278), "label": "Email changed with no token at all",
"label_at": (690, 244)},
])
annotate("fig6_labA_solved.png", "fig6_annotated.png", [
{"box": (398, 248, 800, 286), "label": "Victim's email changed by a POST form carrying only email",
"label_at": (406, 192)},
{"box": (1398, 108, 1598, 150), "label": "Lab status: Solved",
"label_at": (1640, 170)},
])
annotate("fig7_plain_get_405.png", "fig7_annotated.png", [
{"box": (20, 367, 1800, 392), "label": "Plain GET /change-email?email=... (no _method) -> 405",
"label_at": (300, 460)},
{"box": (8, 150, 220, 176), "label": "Method Not Allowed: the route refuses GET",
"label_at": (260, 148)},
])
annotate("fig8_method_override_302.png", "fig8_annotated.png", [
{"box": (330, 54, 1330, 80), "label": "Same GET with &_method=POST appended",
"label_at": (660, 190)},
{"box": (20, 417, 1800, 442), "label": "Accepted: 302 - the override turns the GET into a POST",
"label_at": (300, 700)},
{"box": (398, 238, 662, 266), "label": "Email changed",
"label_at": (690, 236)},
{"box": (20, 367, 1800, 392), "label": "Control: no _method -> 405 (Fig. 7)",
"label_at": (1300, 700)},
])
annotate("fig9_labB_solved.png", "fig9_annotated.png", [
{"box": (398, 104, 800, 143), "label": "Lab solved",
"label_at": (830, 103)},
{"box": (406, 850, 1350, 932), "label": "Delivered page: a top-level navigation to the GET URL carrying _method=POST",
"label_at": (414, 955)},
])
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.