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

ท่า​โจมตี JWT ที่​ทุก​คน​ท่องจำ ถูก​ปฏิเสธ 36 จาก 36 ช่อง ส่วน token ที่​ไม่มี​วัน​หมด​อายุ ผ่าน​ทุก​ช่อง

คุณ​รับ token ใบ​หนึ่ง​เข้า​มา เรียก verify แล้ว​มัน​ไม่​โยน​อะไร​ออก​มา

คุณ​จึง​เขียน​บรรทัด​ถัด​ไป​ว่า user = claims.getSubject() แล้ว​ทำงาน​ต่อ

หน้า​นี้​ยิง token 8 ใบ​เข้า verifier 9 ทรง บน​สาม library ที่ JVM ใช้​จริง รวม 88 ช่อง แล้ว​นับ​ว่า​ช่อง​ไหน​รับ​ช่อง​ไหน​ปฏิเสธ

ผล​ที่​ออก​มา​แบ่ง​เป็น​สอง​ก้อน​ที่​ขัด​กันเอง

ก้อน​แรก ท่า​โจมตี​ที่​ทุก​บทความ​เรื่อง JWT พูด​ถึง ถูก​ปฏิเสธ 36 จาก 36 ช่อง ไม่มี library ไหน​พลาด​สัก​ช่อง​เดียว

ก้อน​ที่​สอง token ที่​ไม่มี exp เลย ถูก​รับ 9 จาก 9 ช่อง คือ​ทุก​ช่อง​ที่​มี และ​ไม่มี​ใคร​พูด​อะไร​สัก​คำ

ธี​สิ​ส​ของ​หน้า​นี้​อยู่​ตรง​นี้ — สิ่ง​ที่ verify ตรวจ​ให้ คือ​ลาย​เซ็น​กับ​วัน​หมด​อายุ​ที่​มี​อยู่​จริง ส่วน​ที่​เหลือ​มัน​รอ​ให้​คุณ​สั่ง และ​คำ​ว่า “ผ่าน” ที่​คุณ​ได้​กลับ​มา ไม่​ได้​แยก​สอง​อย่าง​นี้​ออก​จาก​กัน

token แปด​ใบ verifier สาม​ทรง library สาม​ตัว และ​แกน​ที่​สี่​ที่​ไม่​ตรวจ​ลาย​เซ็น​เลย

graph LR
  t["token 8 ใบ<br/>ถูก · aud อื่น · iss อื่น<br/>ไม่มี exp · หมดอายุ<br/>แก้ไส้ใน · alg none · alg confusion"] --> v["verifier 3 ทรง<br/>default<br/>withAudience<br/>withIssuer"]
  v --> l["library 3 ตัว<br/>java-jwt · jjwt · nimbus"]
  t --> d["API ที่ decode อย่างเดียว<br/>ไม่ตรวจลายเซ็น"]

default คือ​ทรง​ที่​ตรวจ​ลาย​เซ็น​ด้วย RS256 และ​กุญแจ​สาธารณะ แล้ว​ไม่​สั่ง​อย่าง​อื่น​เพิ่ม · อีก​สอง​ทรง​เติม​คำ​สั่ง​เดียว​เข้าไป คือ​บังคับ aud หรือ​บังคับ iss

token ทั้ง​แปด​ใบ​เซ็น​ด้วย​กุญแจ​ส่วน​ตัว​ใบ​เดียวกัน ยกเว้น​สอง​ใบ​สุดท้าย​ที่​ตั้งใจ​ปลอม

วัด​จาก​อะไร

ทุก​ค่า​ใน​หน้า​นี้​มา​จาก scripts/jwt-claims/spike.py ที่​รัน verifier ตัว​จริง​ของ java-jwt 4.6.1 · jjwt 0.13.0 · nimbus-jose-jwt 10.9.1 บน openjdk version "25.0.4.1" 2026-08-18

หน่วย​ของ​หลัก​ฐาน​ทั้ง​หน้า​คือ​ค่า boolean ว่า​รับ​หรือ​ไม่​รับ กับ​ชื่อ class ของ​ข้อ​ยกเว้น ไม่มี​หน่วย​เวลา​สัก​ช่อง เวลา​หมด​อายุ​ที่​ใช้​เป็น​ค่า​ตรึง​ใน​ปี 2099 กับ​ปี 2001 นาฬิกา​ของ​เครื่อง​จึง​เปลี่ยน​คำ​ตอบ​ไม่​ได้

สี่​ท่า​ที่​บทความ​เรื่อง JWT ทุก​บท​เตือน ไม่มี​ท่า​ไหน​ผ่าน​สัก​ช่อง

tokenjava-jwtjjwtnimbus
alg: none ไม่มี​ลาย​เซ็นปฏิเสธปฏิเสธปฏิเสธ
alg confusion · HS256 ด้วย​กุญแจ​สาธารณะปฏิเสธปฏิเสธปฏิเสธ
แก้​ไส้​ใน​แล้ว​คง​ลาย​เซ็น​เดิมปฏิเสธปฏิเสธปฏิเสธ
หมด​อายุ​ไป​แล้วปฏิเสธปฏิเสธปฏิเสธ

ทั้ง 36 ช่อง​ของ​สี่​ท่า​นี้ ถูก​ปฏิเสธ​ครบ และ​เป็น​แบบ​นี้​ใน​ทุก​ทรง​ของ verifier ไม่ใช่​เฉพาะ​ทรง​ที่​ตั้ง​ค่า​ไว้​ดี

ข้อความ​ที่​ได้​กลับ​มา​ต่าง​กัน​ตาม​บ้าน แต่​คำ​ตอบ​เดียวกัน

libraryalg: none ให้​ข้อ​ยกเว้น​ชื่อ
java-jwtAlgorithmMismatchException
jjwtUnsupportedJwtException
nimbusBadJOSEException

ถ้า​หน้า​นี้​จบ​ตรง​นี้ มัน​จะ​เป็น​ข่าวดี แต่​ช่อง​ที่​เหลือ​อีก 52 ช่อง​เล่า​คนละ​เรื่อง

รู​ที่​ยัง​เปิด คือ claim ที่​คุณ​ไม่​ได้​สั่ง​ให้​ตรวจ

หัวข้อ​ที่​มีชื่อ​ว่า “รู​ที่​ยัง​เปิด คือ claim ที่​คุณ​ไม่​ได้​สั่ง​ให้​ตรวจ”

token ของ tenant อื่น ที่​เซ็น​ถูก​ต้อง​ทุก​อย่าง ผ่าน default ของ​ทั้ง​สาม library

token สอง​ใบ​นี้​ถูก​ต้อง​สมบูรณ์​ใน​แง่​ลาย​เซ็น ใบ​หนึ่ง aud เป็น​ของ tenant อื่น อีก​ใบ iss เป็น​คนละ​เจ้า

tokenjava-jwt · defaultjjwt · defaultnimbus · default
aud เป็น​ของ tenant อื่นรับรับรับ
iss เป็น​คนละ​เจ้ารับรับรับ

ทั้ง​หก​ช่อง​รับ​หมด และ​ไม่มี​ช่อง​ไหน​คาย​คำ​เตือน​ออก​มา

นี่​ไม่ใช่​บั๊ก มัน​คือ​สิ่ง​ที่ spec เขียน​ไว้ · aud กับ iss เป็น claim ที่​ผู้รับ​ต้อง​ตัดสิน​เอง ว่า​ค่า​ไหน​ใช้ได้ library จึง​ไม่มี​ทาง​เดา​แทนคุณ

แต่​ผล​ที่​ตก​กับ​คน​อ่าน code คือ​คำ​ว่า verify ผ่าน หมาย​ถึง​สอง​อย่าง​ที่​ต่าง​กัน​มาก ระหว่าง “ลาย​เซ็น​นี้​เป็น​ของ​กุญแจ​ที่​ผม​ถือ​อยู่” กับ “token ใบ​นี้​ออก​ให้​ระบบ​ของ​ผม”

สวิตช์​แต่ละ​ตัว​ปิด​รู​เดียว ไม่ใช่​ทั้ง​สอง​รู

หัวข้อ​ที่​มีชื่อ​ว่า “สวิตช์​แต่ละ​ตัว​ปิด​รู​เดียว ไม่ใช่​ทั้ง​สอง​รู”

ทีนี้​เปิด​สวิตช์​ที่​มี​ให้ แล้ว​ยิง​สอง​ใบ​เดิม​อีก​ครั้ง

verifieraud ของ tenant อื่นiss คนละ​เจ้า
defaultรับรับ
withAudienceปฏิเสธรับ
withIssuerรับปฏิเสธ

ทั้ง​สาม library ให้​รูป​เดียวกัน​หมด · สวิตช์​ที่​ปิด​รู​หนึ่ง ไม่​แตะ​อีก​รู​หนึ่ง​เลย

คน​ที่​เติม withAudience เข้าไป​เพราะ​อ่าน​เจอ​ว่า​ควร​ตรวจ aud จะ​ได้​ระบบ​ที่​ยัง​รับ token จาก​ผู้​ออก​คน​อื่น และ​ไม่มี​อะไร​บอก​ว่า​เหลือ​อีก​ข้อ

ลบ exp ทิ้ง​ทั้ง claim แล้ว​ยิง​ใหม่ · ไม่ใช่​ทำให้​มัน​ไกล แต่​ทำให้​มัน​หาย​ไป

verifierjava-jwtjjwtnimbus
defaultรับรับรับ
withAudienceรับรับรับ
withIssuerรับรับรับ

9 จาก 9 ช่อง และ​ช่อง​ที่​เหลือ​คือ​ศูนย์

เทียบ​กับ​แถว​ของ token ที่​หมด​อายุ​แล้ว ซึ่ง​ถูก​ปฏิเสธ​ทุก​ช่อง จะ​เห็น​เส้น​แบ่ง​ชัด การ​ตรวจ​วัน​หมด​อายุ ตรวจ​ค่าที่​มี​อยู่ ไม่​ได้​ตรวจ​ว่า​ค่า​นั้น​ต้อง​มี

token ที่​ไม่มี exp จึง​ไม่ใช่ token ที่​หมด​อายุ​ยาก มัน​คือ token ที่​ไม่มี​วัน​หมด​อายุ และ​ถ้า​มัน​หลุด​ออก​ไป ไม่มี​อะไร​ใน​ฝั่ง​ผู้รับ​ที่​จะ​ทำให้​มัน​ตาย​เอง​ได้

สิ่ง​ที่​ข้อ​นี้​แปล​ว่า​ใน​ทาง​ปฏิบัติ

การ​บังคับ​ให้ exp ต้อง​มี เป็น​คำ​สั่ง​แยก​จาก​การ​ตรวจ​ว่า exp ยัง​ไม่​ถึง · ทั้ง​สาม library ตรวจ​ข้อ​หลัง​ให้​ฟรี และ​ไม่มี​ตัว​ไหน​ทำ​ข้อ​แรก​ให้​โดย​ไม่​ถูก​สั่ง

ทุก library มี​ทาง​ลัด​สำหรับ​อ่าน claim และ​มัน​ไม่​ได้​ตรวจ​อะไร​ทั้ง​สิ้น

tokenjava-jwt · decode อย่าง​เดียวnimbus · decode อย่าง​เดียว
แก้​ไส้​ใน​แล้ว​คง​ลาย​เซ็น​เดิมรับรับ
หมด​อายุ​ไป​แล้วรับรับ
alg: none ไม่มี​ลาย​เซ็นรับปฏิเสธ

ช่อง​ที่​น่า​สนใจ​คือ​ช่อง​สุดท้าย · nimbus ปฏิเสธ token ที่ alg เป็น none ไม่ใช่​เพราะ​มัน​ตรวจ​ลาย​เซ็น แต่​เพราะ API ที่​ใช้​ต้องการ​สาม​ส่วน​ครบ และ​ใบ​นั้น​มี​ส่วน​ที่​สาม​ว่าง

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

บรรทัด​ที่​เรียก API กลุ่ม​นี้ อ่าน​เหมือน​บรรทัด​ที่​เรียก verify มาก ต่าง​กัน​ที่​ชื่อ method และ​ไม่มี compiler ตัว​ไหน​แยก​สอง​อย่าง​นี้​ให้

88 ช่อง · รับ 45 ช่อง · ปฏิเสธ 43 ช่อง · ชื่อ class ของ​ข้อ​ยกเว้น​ที่​ไม่​ซ้ำ​กัน 12 ชื่อ

tokendefault · java-jwtdefault · jjwtdefault · nimbus-jose-jwtwithAudience · java-jwtwithAudience · jjwtwithAudience · nimbus-jose-jwtwithIssuer · java-jwtwithIssuer · jjwtwithIssuer · nimbus-jose-jwt
goodรับรับรับรับรับรับรับรับรับ
otherAudienceรับรับรับปฏิเสธปฏิเสธปฏิเสธรับรับรับ
otherIssuerรับรับรับรับรับรับปฏิเสธปฏิเสธปฏิเสธ
noExpรับรับรับรับรับรับรับรับรับ
expiredปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธ
tamperedปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธ
algNoneปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธ
hmacWithPublicKeyปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธปฏิเสธ
สิ่ง​ที่​ควร​จำ​จาก​หน้า​นี้

หนึ่ง ท่า​โจมตี​เรื่อง​ลาย​เซ็น​ที่​บทความ​ทุก​บท​เตือน ปิด​ไป​แล้ว​ใน library รุ่น​ปัจจุบัน ทั้ง​สาม​ตัว 36 จาก 36 ช่อง

สอง รู​ที่​เหลือ​ไม่ใช่​เรื่อง​การ​เข้า​รหัส มัน​คือ claim ที่​ไม่มี​ใคร​สั่ง​ให้​ตรวจ

สาม สวิตช์​หนึ่ง​ตัว​ปิด​หนึ่ง​รู · เปิด aud แล้ว iss ยัง​เปิด​อยู่ และ​กลับ​กัน

สี่ การ​ตรวจ exp ตรวจ​ค่าที่​มี​อยู่ ไม่​ได้​ตรวจ​ว่า​ค่า​นั้น​ต้อง​มี

เช็กความเข้าใจ

ข้อ 1 / 3

token ใบหนึ่งเซ็นด้วยกุญแจที่ถูกต้อง ยังไม่หมดอายุ แต่ aud เป็นของ tenant อื่น ส่งเข้า verifier ที่ตั้งค่าแค่ตรวจลายเซ็นด้วย RS256 ผลเป็นอย่างไร

ทุก​ค่า​ใน​หน้า​นี้​สร้าง​ใหม่​ได้​จาก​คำ​สั่ง​สอง​บรรทัด

Terminal window
scripts/jwt-claims/up.sh # อุ่น repo ที่แยกไว้ · ต้องใช้เน็ตรอบแรก
npm run verify:jwt-claims # วัดทุกช่องสามรอบ แล้วเทียบกับข้ออ้างทุกข้อ

probe ไม่​แตะ ~/.m2 ของ​เครื่อง​คุณ มัน​วาง repo ของ​ตัว​เอง​ไว้​ที่ scripts/jwt-claims/.tools/

คู่​กุญแจ​ที่​ใช้​ตรึง​ไว้​ใน scripts/jwt-claims/probe/src/main/java/jwtprobe/Keys.java เพื่อ​ให้​ผล​ซ้ำ​ได้ที​ละ​อักขระ · มัน​เป็น​กุญแจ​สำหรับ​วัด​เท่านั้น ไม่​ได้​ป้องกัน​อะไร​อยู่

รหัส​ออก 1 แปล​ว่า​ข้อ​อ้าง​ใน​หน้า​นี้​ผิด ส่วน​รหัส​ออก 2 แปล​ว่า​วัด​ไม่​ได้ สอง​อย่าง​นี้​ไม่​เหมือน​กัน