126 lines
5.4 KiB
Python
126 lines
5.4 KiB
Python
# 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)},
|
|
])
|