ข้าม​ไป​ยัง​เนื้อหา

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 เพื่อ​วัด​เอง

probe login อีก​ครั้ง​ด้วย​ชื่อ​ผู้​ใช้​เดิม แล้ว​ส่ง CSRF token ของ session ใบ​ใหม่​ไป​กับ​คำขอ​ของ session ใบ​เก่า

probe.py — คำขอ​ที่​ถาม​ว่า​ค่า​นั้น​เป็น​ของ​ใคร
# 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 และ​ไม่มี​แถว​ลง

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 ใบ​เดียว ไม่​เท่า​กัน

probe.py — อ่าน /csrf สอง​ครั้ง​ติด​กัน แล้ว​เทียบ
# 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 ซึ่ง​เป็น​ความ​ยาว​ของ​ค่าที่​อ่าน​ครั้ง​แรก

สิ่ง​ที่ probe บรรทัด​นี้​ไม่​ได้​วัด

len(t1["token"]) วัด​ความ​ยาว​ของ​ค่า​แรก​ค่า​เดียว ส่วน​ค่าที่​สอง​ถูก​ใช้​ใน​การ​เทียบ​เท่านั้น และ​ไม่​เคย​ถูก​วัด​ความ​ยาว

ค่าที่​สอง​ก็​ไม่​เคย​ถูก​ส่ง​กลับ​ไป​ที่ server ด้วย บท​นี้​จึง​ไม่​อ้าง​ว่า​ค่าที่​สอง​ยาว​เท่าไร และ​ไม่​อ้าง​ว่า​มัน​ใช้ได้ ทั้ง​สอง​ข้อ​อยู่​นอก​สิ่ง​ที่ probe วัด

ค่าที่​อ่าน​ครั้ง​แรก​ถูก​ยิง​สอง​ครั้ง และ​ทั้ง​สอง​ครั้ง​เขียน​แถว​ลง

probe.py — ค่า​เดียวกัน ยิง​สอง​ครั้ง​ติด​กัน
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 และ​ผู้​อ่าน​ส่วน​ใหญ่​เดา​ว่า​เป็น เพราะ​ชื่อ​ของ​กลไก​ฟัง​ดู​เหมือน​ของ​ที่​ใช้​แล้ว​ทิ้ง

ผล​ข้าง​เคียง​ที่​ตาม​มา​คือ ผู้​ที่​ดัก​ค่า​นั้น​ไป​ได้​หนึ่ง​ใบ ยิง​มัน​ได้​มากกว่า​หนึ่ง​ครั้ง บท​นี้​วัด​สอง​ครั้ง และ​ไม่​ได้​วัด​ว่า​ค่า​ใบ​นั้น​หมด​อายุ​ตอน​ไหน

ค่าที่​คาย​ออก​มา​เปลี่ยน​ทุก​ครั้ง แต่​ค่าที่​เก็บ​ไว้​ไม่​เปลี่ยน​ตาม

token maskingtoken maskingการ​ที่ CSRF token ถูก​คาย​ออก​มา​เป็น​ค่า​ใหม่​ทุก​ครั้ง​ที่​อ่าน · ทรง csrf-on วัด​แล้ว​ว่า​อ่าน​สอง​ครั้ง​ติด​กัน​ได้​คนละ​ค่า และ​ค่าที่​อ่าน​ครั้ง​แรก​ยิง​ซ้ำ​ได้ ค่า​นี้​จึง​ไม่ใช่ nonce ที่​ใช้ได้​ครั้ง​เดียว · probe ไม่​เคย​ส่ง​ค่าที่​สอง​กลับ​ไป​ที่ server คือ​ชื่อ​ของ​สิ่ง​ที่​ทำให้​สอง​หัวข้อ​ข้าง​บน​อยู่​ด้วย​กัน​ได้ เอกสาร​ของ Spring Security อธิบาย​กลไก​นี้​ไว้​สาม​ข้อ

  1. handler ตั้ง​ต้น​คือ XorCsrfTokenRequestAttributeHandler
  2. มัน​ผสม​ค่า​สุ่ม​ลง​ไป​ใน​ค่าที่​คาย​ออก​มา ค่าที่​คืน​กลับ​ไป​จึง​เปลี่ยน​ทุก​คำขอ
  3. ตอน​ค่า​นั้น​เดิน​ทาง​กลับ​มา​ทาง header หรือ​ทาง parameter มัน​ถูก​ถอด​กลับ​เป็น​ค่า​ดิบ แล้ว​เทียบ​กับ​ค่าที่​เก็บ​ไว้

เหตุผล​ที่​เอกสาร​ยก​มา​คือ​กัน BREACH ซึ่ง​เป็นการ​โจมตี​ที่​อาศัย​ขนาด​ของ​คำ​ตอบ​ที่​ถูก​บีบ​อัด เพื่อ​อ่าน​ค่า​ลับ​ที่​อยู่​ใน​คำ​ตอบ​นั้น ค่าที่​เปลี่ยน​ทุก​ครั้ง​ทำให้​การ​อ่าน​แบบ​นั้น​ไม่​ได้​ผล

สิ่ง​ที่​มัน​ไม่​ได้​ทำ​มี​สอง​ข้อ และ​ผล​ของ​ทั้ง​สอง​ข้อ​วัด​ได้​ใน​บท​นี้

  • มัน​ไม่​ได้​ทำให้​ค่า​นั้น​ใช้ได้​ครั้ง​เดียว ค่า​ใบ​เดิม​ยิง​ซ้ำ​ได้ ได้ 200 และ​แถว​ลง​ทั้ง​สอง​ครั้ง
  • มัน​ไม่​ได้​ทำให้​ค่า​นั้น​ข้าม session ได้ ค่า​ของ​อีก session ได้ 403 และ​ไม่มี​แถว​ลง

ที่ probe วัด​คือ​รหัส​กับ​จำนวน​แถว​สอง​คู่​นั้น ส่วน​ขั้น​ถอด​ค่า​กลับ​แล้ว​เทียบ​กับ​ค่าที่​เก็บ​ไว้ เป็น​สิ่ง​ที่​เอกสาร​อธิบาย บท​นี้​ไม่​ได้​เปิด​ดู​ขั้น​นั้น

masking ทำงาน​ที่​ชั้น​การนำ​เสนอ​ค่า ไม่ใช่​ชั้น​การ​ผูก​ค่า​กับ​ผู้​เรียก สอง​ชั้น​นี้​แยก​กัน และ​การ​เดา​ว่า​เป็น​ชั้น​เดียวกัน​คือ​ที่มา​ของ​คำ​ว่า nonce ที่​ผู้​อ่าน​เติม​เข้า​มา​เอง

login ของ probe รายงาน​สาม​ช่อง และ​ทั้ง​สาม​ช่อง​วัด​ได้​เป็น true ใน​ทรง csrf-on

probe.py — สาม​ช่อง​ที่ login คืน​กลับ​มา​เป็น​หลัก​ฐาน
# **มี 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 / 3

probe แนบ CSRF token ของอีก session หนึ่งไปกับคำขอ โดยทั้งสอง session login ด้วยผู้ใช้คนเดียวกัน ผลที่วัดได้คืออะไร