CSRF token ใบนั้นเป็นของ session ไม่ใช่ของผู้ใช้ และอ่านสองครั้งได้คนละค่า
บทที่ 1 ทิ้งคำถามไว้ข้อเดียว ทรง csrf-on รับคำขอที่แนบ CSRF token มา และปฏิเสธคำขอที่ไม่แนบ
แต่ยังไม่มีข้อไหนบอกว่า server เอาค่านั้นไปเทียบกับอะไร
บทนี้ถามด้วยคำขอสามชุด ชุดแรกถามว่าค่านั้นเป็นของใคร ชุดที่สองถามว่าหยิบค่านั้นสองครั้ง ได้ค่าเดิมหรือไม่ ชุดที่สามถามว่าค่าใบเดิมยิงซ้ำได้หรือไม่
ทรงที่วัดคือ csrf-on ทรงเดียวตลอดบท คือรูปที่ไม่มีบรรทัด http.csrf { it.disable() }
อยู่ใน SecConfig.kt
หน่วยของหลักฐานยังเป็นสองหน่วยเดิม คือรหัส HTTP ที่ผู้เรียกได้รับกลับมา
และจำนวนแถวที่เพิ่มขึ้นในตาราง csrf_visit สั่ง scripts/clinic-cross-origin/up.sh
แล้วตามด้วย python3 scripts/clinic-cross-origin/probe.py เพื่อวัดเอง
ถามด้วย session ใบที่สอง ของผู้ใช้คนเดิม
หัวข้อที่มีชื่อว่า “ถามด้วย session ใบที่สอง ของผู้ใช้คนเดิม”probe login อีกครั้งด้วยชื่อผู้ใช้เดิม แล้วส่ง CSRF token ของ session ใบใหม่ไปกับคำขอของ session ใบเก่า
# B · token ของ session อื่น other = login(port, csrf_on) out["postForeignToken"] = slim(probe_post( s, {tok["headerName"]: other["token"]["token"]}))s คือ session ใบแรก ส่วน other คือผลของการ login รอบที่สอง คำขอใบนี้จึงพก cookie
ของใบแรก และพก CSRF token ของใบที่สอง
คำขอที่ยิงใส่ทรง csrf-on | รหัส | แถวใน csrf_visit |
|---|---|---|
POST /visits · แนบ CSRF token ของอีก session หนึ่ง | 403 | +0 |
ทั้งสอง session login ด้วยผู้ใช้ kaen-a คนเดียวกัน ตัวตนของผู้เรียกจึงเท่ากันทุกช่อง
ช่องเดียวที่ต่างกันคือค่านั้นถูกคายออกมาให้ session ใบไหน
ค่านั้นไม่ได้ผูกกับผู้ใช้ และไม่ได้ผูกกับคำขอ มันผูกกับ session
ผู้โจมตีที่ login บัญชีของตัวเองแล้วหยิบค่าของตัวเองไปใช้ จึงได้ 403 และไม่มีแถวลง
ที่เก็บค่านั้นอยู่ใน session
หัวข้อที่มีชื่อว่า “ที่เก็บค่านั้นอยู่ใน session”
403ข้างบนคือสิ่งที่วัดได้ ส่วนที่ว่าค่าที่คาดหวังถูกเก็บไว้ที่ไหน มาจากเอกสารของ Spring Security
HttpSessionCsrfTokenRepositoryHttpSessionCsrfTokenRepositoryที่เก็บ CSRF token ตั้งต้นของ Spring Security ซึ่งวางค่านั้นไว้ใน HttpSession · ค่าที่ผู้เรียกแนบมาจึงถูกเทียบกับค่าที่ session ใบนั้นถืออยู่ ไม่ใช่เทียบกับตัวตนของผู้ใช้ คือที่เก็บตั้งต้น เอกสารของ Spring Security อธิบายว่า
Spring Security เก็บ CSRF token ที่คาดหวังไว้ใน HttpSession ด้วยที่เก็บตัวนี้เป็นค่าตั้งต้น
โดยไม่ต้องประกาศอะไรเพิ่ม
นี่เป็นสองข้ออ้างคนละชนิดกัน และบทนี้ไม่ปนมันเข้าด้วยกัน
- ที่วัดได้ คือค่าของอีก session หนึ่งได้
403และไม่มีแถวลง probe เป็นคนยิงและเป็นคนนับ - ที่เอกสารกำหนด คือที่เก็บตั้งต้นวางค่านั้นไว้ใน
HttpSessionบทนี้ไม่ได้เปิดดูข้างในHttpSession
ข้อแรกอธิบายได้ด้วยข้อที่สอง แต่ข้อที่สองไม่ได้ถูกพิสูจน์ด้วยข้อแรก อีกหลายกลไกก็ให้ 403 ใบเดียวกันได้
อ่านสองครั้ง ได้คนละค่า
หัวข้อที่มีชื่อว่า “อ่านสองครั้ง ได้คนละค่า”ค่าที่
/csrfคายออกมาสองครั้งติดกัน ด้วย session ใบเดียว ไม่เท่ากัน
# token ใบเดียวกันใช้ซ้ำได้ไหม · ค่าที่ /csrf คืน สองครั้งติดกัน เท่ากันไหม t1 = token_of(s.send("GET", "/csrf")) t2 = token_of(s.send("GET", "/csrf")) out["tokenValueStableAcrossTwoReads"] = t1["token"] == t2["token"] out["tokenLength"] = len(t1["token"])ช่อง tokenValueStableAcrossTwoReads วัดได้เป็น false สองคำขอนี้ยิงติดกัน
พก cookie ใบเดียวกัน และไม่มีการ login คั่นกลาง
ช่อง tokenLength วัดได้ 96 ซึ่งเป็นความยาวของค่าที่อ่านครั้งแรก
len(t1["token"]) วัดความยาวของค่าแรกค่าเดียว ส่วนค่าที่สองถูกใช้ในการเทียบเท่านั้น
และไม่เคยถูกวัดความยาว
ค่าที่สองก็ไม่เคยถูกส่งกลับไปที่ server ด้วย บทนี้จึงไม่อ้างว่าค่าที่สองยาวเท่าไร และไม่อ้างว่ามันใช้ได้ ทั้งสองข้ออยู่นอกสิ่งที่ probe วัด
ค่าเดิมยิงซ้ำได้ จึงไม่ใช่ nonce
หัวข้อที่มีชื่อว่า “ค่าเดิมยิงซ้ำได้ จึงไม่ใช่ nonce”ค่าที่อ่านครั้งแรกถูกยิงสองครั้ง และทั้งสองครั้งเขียนแถวลง
out["postValidTokenTwice"] = slim(probe_post( s, {tok["headerName"]: t1["token"]})) out["postSameTokenAgain"] = slim(probe_post( s, {tok["headerName"]: t1["token"]}))สองบรรทัดนี้ส่ง t1["token"] ค่าเดียวกันทั้งคู่ ไม่ได้อ่านค่าใหม่คั่นกลาง
คำขอที่ยิงใส่ทรง csrf-on | รหัส | แถวใน csrf_visit |
|---|---|---|
| ยิงค่าที่อ่านครั้งแรก ครั้งที่หนึ่ง | 200 | +1 |
| ยิงค่าเดิมใบนั้นอีกครั้ง | 200 | +1 |
nonce แปลว่าค่าที่ใช้ได้ครั้งเดียว ค่านี้ไม่ใช่ nonce และผู้อ่านส่วนใหญ่เดาว่าเป็น เพราะชื่อของกลไกฟังดูเหมือนของที่ใช้แล้วทิ้ง
ผลข้างเคียงที่ตามมาคือ ผู้ที่ดักค่านั้นไปได้หนึ่งใบ ยิงมันได้มากกว่าหนึ่งครั้ง บทนี้วัดสองครั้ง และไม่ได้วัดว่าค่าใบนั้นหมดอายุตอนไหน
masking กันอะไร และไม่กันอะไร
หัวข้อที่มีชื่อว่า “masking กันอะไร และไม่กันอะไร”ค่าที่คายออกมาเปลี่ยนทุกครั้ง แต่ค่าที่เก็บไว้ไม่เปลี่ยนตาม
token maskingtoken maskingการที่ CSRF token ถูกคายออกมาเป็นค่าใหม่ทุกครั้งที่อ่าน · ทรง csrf-on วัดแล้วว่าอ่านสองครั้งติดกันได้คนละค่า และค่าที่อ่านครั้งแรกยิงซ้ำได้ ค่านี้จึงไม่ใช่ nonce ที่ใช้ได้ครั้งเดียว · probe ไม่เคยส่งค่าที่สองกลับไปที่ server คือชื่อของสิ่งที่ทำให้สองหัวข้อข้างบนอยู่ด้วยกันได้ เอกสารของ Spring Security อธิบายกลไกนี้ไว้สามข้อ
- handler ตั้งต้นคือ
XorCsrfTokenRequestAttributeHandler - มันผสมค่าสุ่มลงไปในค่าที่คายออกมา ค่าที่คืนกลับไปจึงเปลี่ยนทุกคำขอ
- ตอนค่านั้นเดินทางกลับมาทาง header หรือทาง parameter มันถูกถอดกลับเป็นค่าดิบ แล้วเทียบกับค่าที่เก็บไว้
เหตุผลที่เอกสารยกมาคือกัน BREACH ซึ่งเป็นการโจมตีที่อาศัยขนาดของคำตอบที่ถูกบีบอัด เพื่ออ่านค่าลับที่อยู่ในคำตอบนั้น ค่าที่เปลี่ยนทุกครั้งทำให้การอ่านแบบนั้นไม่ได้ผล
สิ่งที่มันไม่ได้ทำมีสองข้อ และผลของทั้งสองข้อวัดได้ในบทนี้
- มันไม่ได้ทำให้ค่านั้นใช้ได้ครั้งเดียว ค่าใบเดิมยิงซ้ำได้ ได้
200และแถวลงทั้งสองครั้ง - มันไม่ได้ทำให้ค่านั้นข้าม session ได้ ค่าของอีก session ได้
403และไม่มีแถวลง
ที่ probe วัดคือรหัสกับจำนวนแถวสองคู่นั้น ส่วนขั้นถอดค่ากลับแล้วเทียบกับค่าที่เก็บไว้ เป็นสิ่งที่เอกสารอธิบาย บทนี้ไม่ได้เปิดดูขั้นนั้น
masking ทำงานที่ชั้นการนำเสนอค่า ไม่ใช่ชั้นการผูกค่ากับผู้เรียก สองชั้นนี้แยกกัน และการเดาว่าเป็นชั้นเดียวกันคือที่มาของคำว่า nonce ที่ผู้อ่านเติมเข้ามาเอง
การ login หมุนทั้งสองค่าพร้อมกัน
หัวข้อที่มีชื่อว่า “การ login หมุนทั้งสองค่าพร้อมกัน”
loginของ probe รายงานสามช่อง และทั้งสามช่องวัดได้เป็นtrueในทรงcsrf-on
# **มี session อยู่ก่อน login หรือไม่ ต้องรายงานแยก** · ถ้าไม่มี # คำถาม "session id เปลี่ยนไหม" ไม่มีคำตอบ · ค่า False ตรงนั้นจะอ่านเหมือน # "ไม่เปลี่ยน" ทั้งที่ของจริงคือ "ไม่มีอะไรให้เปลี่ยน" "sessionExistedBeforeLogin": bool(sid_before), "sessionIdChanged": (s.sid != sid_before) if sid_before else None, "tokenChangedOnLogin": ( None if not (pre_tok and post_tok) else pre_tok["token"] != post_tok["token"]),ช่องที่ login วัด | ทรง csrf-on |
|---|---|
| มี session อยู่ก่อน login ไหม | true |
| session id เปลี่ยนใบตอน login ไหม | true |
| CSRF token เปลี่ยนใบตอน login ไหม | true |
แถวแรกไม่ใช่ของแถม ถ้าไม่มี session อยู่ก่อน คำถามในแถวที่สองไม่มีคำตอบ
และค่า false ตรงนั้นจะอ่านเหมือน ไม่เปลี่ยน ทั้งที่ของจริงคือ ไม่มีอะไรให้เปลี่ยน
session fixationsession fixationการที่ผู้โจมตีปักค่าประจำ session ไว้ก่อน แล้วรอให้เหยื่อ login บนค่าเดียวกัน · วัดแล้วว่าการ login หมุนทั้ง session id และ CSRF token พร้อมกัน คือการที่ผู้โจมตีปักค่าประจำ session ไว้ก่อน แล้วรอให้เหยื่อ login บนค่าเดียวกัน การหมุน JSESSIONIDJSESSIONIDcookie ที่ server คายออกมาเพื่อชี้ session ของผู้เรียก · เป็นหน่วยที่นับได้ และคอร์สนี้นับมันเป็นจำนวน ไม่ใช่เป็นอายุ ตอน login สำเร็จคือคำตอบของเรื่องนั้น
การหมุน CSRF token พร้อมกันเป็นผลที่ตามมา ค่าที่หยิบมาก่อน login ผูกกับ session ใบเก่า ซึ่งไม่ใช่ใบที่ผู้เรียกถืออยู่หลัง login แล้ว
ค่าหนึ่งค่า สามเส้นทาง
หัวข้อที่มีชื่อว่า “ค่าหนึ่งค่า สามเส้นทาง”ค่าดิบอยู่ใน session หนึ่งใบ ส่วนค่าที่คายออกมาเปลี่ยนทุกครั้ง และถูกถอดกลับตอนเดินทางกลับ
flowchart TB R["ค่าดิบของ session A · เก็บอยู่ใน HttpSession ใบเดียว"] R --> M1["อ่าน /csrf ครั้งที่ 1 · ได้ค่าที่ผสมค่าสุ่มแล้ว · ยาว 96 ตัวอักษร"] R --> M2["อ่าน /csrf ครั้งที่ 2 · ได้อีกค่าหนึ่ง · ไม่เท่ากับครั้งที่ 1"] M1 --> P1["ยิงค่าครั้งที่ 1 กลับมา · ถอดแล้วตรงกับค่าดิบ · 200 · เพิ่ม 1 แถว"] M1 --> P2["ยิงค่าใบเดิมนั้นอีกครั้ง · ถอดแล้วยังตรง · 200 · เพิ่ม 1 แถว"] B["ค่าดิบของ session B · ผู้ใช้คนเดียวกัน แต่คนละใบ"] --> F["ส่งมากับ cookie ของ session A · ถอดแล้วไม่ตรง · 403 · เพิ่ม 0 แถว"]
คำบรรยายภาพ: รหัสกับจำนวนแถวในภาพมาจาก probe ส่วนขั้นถอดค่ากลับมาจากเอกสาร probe ยิงกลับไปเฉพาะค่าของครั้งที่ 1 เส้นของครั้งที่ 2 จึงจบที่การอ่าน และไม่มีผลให้วัด
ที่วัดได้ กับที่เอกสารกำหนด
หัวข้อที่มีชื่อว่า “ที่วัดได้ กับที่เอกสารกำหนด”ตัวเลขทุกตัวมาจาก probe ฝั่ง server ส่วนคำอธิบายกลไกมาจากเอกสาร และบทนี้แยกสองอย่างนี้ไว้
probe ยิงทุกคำขอด้วย http.client ของ Python และตั้ง Cookie กับ header ของ CSRF token เอง
ไม่มีเบราว์เซอร์อยู่ในการวัดของบทนี้สักคำขอเดียว
403 200 +0 +1 false true และเลข 96 คือสิ่งที่ probe วัด
ส่วนชื่อ XorCsrfTokenRequestAttributeHandler ที่เก็บใน HttpSession และเหตุผลเรื่อง BREACH
คือสิ่งที่เอกสารของ Spring Security อธิบายไว้ บทนี้ไม่ได้วัดสามข้อหลัง
ผลทั้งหมดในบทนี้วัดบน Spring Boot 4.1.1 · Kotlin 2.4.10 · JDK 25 รุ่นอื่นเป็นคนละการวัด และบทนี้ไม่ได้อ้างถึงมัน
ความยาว 96 ตัวอักษรเป็นค่าที่ทรงนี้คายออกมาบนรุ่นเหล่านี้ ไม่ใช่ค่าคงที่ของ Spring ที่อ่านได้จากที่อื่น สิ่งที่ยืนอยู่เหนือรุ่นคือรูปของข้ออ้าง คือค่าที่คายออกมาเปลี่ยนทุกครั้ง ส่วนค่าที่เก็บไว้ไม่เปลี่ยนตาม
คำถามที่บทนี้ทิ้งไว้
หัวข้อที่มีชื่อว่า “คำถามที่บทนี้ทิ้งไว้”รู้แล้วว่าค่านั้นผูกกับอะไร ยังไม่รู้ว่าอะไรเป็นคนเลือกรหัสที่ผู้เรียกได้รับกลับมา
คำขอที่แนบค่าของอีก session มาถูกปฏิเสธด้วย 403 ซึ่งเป็นรหัสเดียวกับที่บทที่ 1 วัดได้
จากคำขอที่ถูกกลไกนี้ปฏิเสธ รหัสนั้นจึงดูเหมือนเป็นคำตอบตายตัวของการกันปลอม
มันไม่ใช่ ทรงนี้ยืนยันตัวด้วยฟอร์มและ session ถ้าเปลี่ยนวิธียืนยันตัว โดยไม่แตะการกันปลอมเลยสักบรรทัด คำขอรูปเดิมจะได้รหัสคนละตัว
บทที่ 3 ยิงคำขอรูปเดียวกันใส่ทรงที่ยืนยันตัวคนละแบบ แล้วชี้ว่าตัวที่เลือกรหัสนั้น ไม่ได้อยู่ในค่าที่ประกาศไว้ แต่อยู่ในลำดับที่คำขอเดินผ่าน
-
Spring Security — Cross Site Request Forgery (CSRF) (เข้าถึง 2026-09-05) — ที่มาของสามข้อเรื่อง
XorCsrfTokenRequestAttributeHandlerการเก็บค่าที่คาดหวังไว้ในHttpSessionและเหตุผลเรื่อง BREACH · บทนี้อ้างมันเป็นเอกสาร ไม่ได้วัดมัน -
BREACH (เข้าถึง 2026-09-05) — การโจมตีที่เอกสาร ข้างบนลิงก์ไปหา · อยู่นอกสิ่งที่คอร์สนี้วัด
-
ตัวเลขทุกตัวในบทนี้มาจาก
scripts/clinic-cross-origin/probe.pyข้อB4ถึงB7และจากช่องtokenLengthของทรงcsrf-on· ต้องสั่งscripts/clinic-cross-origin/up.shก่อน ซึ่งยก container ของคอร์สนี้เองขึ้นที่ port 55437
เช็กความเข้าใจ — บทที่ 2
ข้อ 1 / 3probe แนบ CSRF token ของอีก session หนึ่งไปกับคำขอ โดยทั้งสอง session login ด้วยผู้ใช้คนเดียวกัน ผลที่วัดได้คืออะไร