ท่าโจมตี 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 ทุกบทเตือน ไม่มีท่าไหนผ่านสักช่อง
| token | java-jwt | jjwt | nimbus |
|---|---|---|---|
alg: none ไม่มีลายเซ็น | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ |
| alg confusion · HS256 ด้วยกุญแจสาธารณะ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ |
| แก้ไส้ในแล้วคงลายเซ็นเดิม | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ |
| หมดอายุไปแล้ว | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ |
ทั้ง 36 ช่องของสี่ท่านี้ ถูกปฏิเสธครบ และเป็นแบบนี้ในทุกทรงของ verifier ไม่ใช่เฉพาะทรงที่ตั้งค่าไว้ดี
ข้อความที่ได้กลับมาต่างกันตามบ้าน แต่คำตอบเดียวกัน
| library | alg: none ให้ข้อยกเว้นชื่อ |
|---|---|
| java-jwt | AlgorithmMismatchException |
| jjwt | UnsupportedJwtException |
| nimbus | BadJOSEException |
ถ้าหน้านี้จบตรงนี้ มันจะเป็นข่าวดี แต่ช่องที่เหลืออีก 52 ช่องเล่าคนละเรื่อง
รูที่ยังเปิด คือ claim ที่คุณไม่ได้สั่งให้ตรวจ
หัวข้อที่มีชื่อว่า “รูที่ยังเปิด คือ claim ที่คุณไม่ได้สั่งให้ตรวจ”token ของ tenant อื่น ที่เซ็นถูกต้องทุกอย่าง ผ่าน default ของทั้งสาม library
token สองใบนี้ถูกต้องสมบูรณ์ในแง่ลายเซ็น ใบหนึ่ง aud เป็นของ tenant อื่น
อีกใบ iss เป็นคนละเจ้า
| token | java-jwt · default | jjwt · default | nimbus · default |
|---|---|---|---|
aud เป็นของ tenant อื่น | รับ | รับ | รับ |
iss เป็นคนละเจ้า | รับ | รับ | รับ |
ทั้งหกช่องรับหมด และไม่มีช่องไหนคายคำเตือนออกมา
นี่ไม่ใช่บั๊ก มันคือสิ่งที่ spec เขียนไว้ · aud กับ iss เป็น claim ที่ผู้รับต้องตัดสินเอง
ว่าค่าไหนใช้ได้ library จึงไม่มีทางเดาแทนคุณ
แต่ผลที่ตกกับคนอ่าน code คือคำว่า verify ผ่าน หมายถึงสองอย่างที่ต่างกันมาก ระหว่าง “ลายเซ็นนี้เป็นของกุญแจที่ผมถืออยู่” กับ “token ใบนี้ออกให้ระบบของผม”
สวิตช์แต่ละตัวปิดรูเดียว ไม่ใช่ทั้งสองรู
หัวข้อที่มีชื่อว่า “สวิตช์แต่ละตัวปิดรูเดียว ไม่ใช่ทั้งสองรู”ทีนี้เปิดสวิตช์ที่มีให้ แล้วยิงสองใบเดิมอีกครั้ง
| verifier | aud ของ tenant อื่น | iss คนละเจ้า |
|---|---|---|
default | รับ | รับ |
withAudience | ปฏิเสธ | รับ |
withIssuer | รับ | ปฏิเสธ |
ทั้งสาม library ให้รูปเดียวกันหมด · สวิตช์ที่ปิดรูหนึ่ง ไม่แตะอีกรูหนึ่งเลย
คนที่เติม withAudience เข้าไปเพราะอ่านเจอว่าควรตรวจ aud จะได้ระบบที่ยังรับ token
จากผู้ออกคนอื่น และไม่มีอะไรบอกว่าเหลืออีกข้อ
token ที่ไม่มีวันหมดอายุ ผ่านทุกช่องที่มี
หัวข้อที่มีชื่อว่า “token ที่ไม่มีวันหมดอายุ ผ่านทุกช่องที่มี”ลบ
expทิ้งทั้ง claim แล้วยิงใหม่ · ไม่ใช่ทำให้มันไกล แต่ทำให้มันหายไป
| verifier | java-jwt | jjwt | nimbus |
|---|---|---|---|
default | รับ | รับ | รับ |
withAudience | รับ | รับ | รับ |
withIssuer | รับ | รับ | รับ |
9 จาก 9 ช่อง และช่องที่เหลือคือศูนย์
เทียบกับแถวของ token ที่หมดอายุแล้ว ซึ่งถูกปฏิเสธทุกช่อง จะเห็นเส้นแบ่งชัด การตรวจวันหมดอายุ ตรวจค่าที่มีอยู่ ไม่ได้ตรวจว่าค่านั้นต้องมี
token ที่ไม่มี exp จึงไม่ใช่ token ที่หมดอายุยาก มันคือ token ที่ไม่มีวันหมดอายุ
และถ้ามันหลุดออกไป ไม่มีอะไรในฝั่งผู้รับที่จะทำให้มันตายเองได้
การบังคับให้ exp ต้องมี เป็นคำสั่งแยกจากการตรวจว่า exp ยังไม่ถึง
· ทั้งสาม library ตรวจข้อหลังให้ฟรี และไม่มีตัวไหนทำข้อแรกให้โดยไม่ถูกสั่ง
API ที่อ่าน payload โดยไม่ตรวจลายเซ็นเลย
หัวข้อที่มีชื่อว่า “API ที่อ่าน payload โดยไม่ตรวจลายเซ็นเลย”ทุก library มีทางลัดสำหรับอ่าน claim และมันไม่ได้ตรวจอะไรทั้งสิ้น
| token | java-jwt · decode อย่างเดียว | nimbus · decode อย่างเดียว |
|---|---|---|
| แก้ไส้ในแล้วคงลายเซ็นเดิม | รับ | รับ |
| หมดอายุไปแล้ว | รับ | รับ |
alg: none ไม่มีลายเซ็น | รับ | ปฏิเสธ |
ช่องที่น่าสนใจคือช่องสุดท้าย · nimbus ปฏิเสธ token ที่ alg เป็น none
ไม่ใช่เพราะมันตรวจลายเซ็น แต่เพราะ API ที่ใช้ต้องการสามส่วนครบ และใบนั้นมีส่วนที่สามว่าง
มันจึงปฏิเสธด้วยเหตุผลที่ไม่เกี่ยวกับความปลอดภัยเลย และรับ token ที่ถูกแก้ไส้ในตามปกติ
บรรทัดที่เรียก API กลุ่มนี้ อ่านเหมือนบรรทัดที่เรียก verify มาก ต่างกันที่ชื่อ method และไม่มี compiler ตัวไหนแยกสองอย่างนี้ให้
ตารางรวมทุกช่องที่วัด
หัวข้อที่มีชื่อว่า “ตารางรวมทุกช่องที่วัด”88 ช่อง · รับ 45 ช่อง · ปฏิเสธ 43 ช่อง · ชื่อ class ของข้อยกเว้นที่ไม่ซ้ำกัน 12 ชื่อ
| token | default · java-jwt | default · jjwt | default · nimbus-jose-jwt | withAudience · java-jwt | withAudience · jjwt | withAudience · nimbus-jose-jwt | withIssuer · java-jwt | withIssuer · jjwt | withIssuer · nimbus-jose-jwt |
|---|---|---|---|---|---|---|---|---|---|
good | รับ | รับ | รับ | รับ | รับ | รับ | รับ | รับ | รับ |
otherAudience | รับ | รับ | รับ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | รับ | รับ | รับ |
otherIssuer | รับ | รับ | รับ | รับ | รับ | รับ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ |
noExp | รับ | รับ | รับ | รับ | รับ | รับ | รับ | รับ | รับ |
expired | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ |
tampered | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ |
algNone | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ |
hmacWithPublicKey | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ | ปฏิเสธ |
หนึ่ง ท่าโจมตีเรื่องลายเซ็นที่บทความทุกบทเตือน ปิดไปแล้วใน library รุ่นปัจจุบัน ทั้งสามตัว 36 จาก 36 ช่อง
สอง รูที่เหลือไม่ใช่เรื่องการเข้ารหัส มันคือ claim ที่ไม่มีใครสั่งให้ตรวจ
สาม สวิตช์หนึ่งตัวปิดหนึ่งรู · เปิด aud แล้ว iss ยังเปิดอยู่ และกลับกัน
สี่ การตรวจ exp ตรวจค่าที่มีอยู่ ไม่ได้ตรวจว่าค่านั้นต้องมี
เช็กความเข้าใจ
ข้อ 1 / 3token ใบหนึ่งเซ็นด้วยกุญแจที่ถูกต้อง ยังไม่หมดอายุ แต่ aud เป็นของ tenant อื่น ส่งเข้า verifier ที่ตั้งค่าแค่ตรวจลายเซ็นด้วย RS256 ผลเป็นอย่างไร
วิธีวัดซ้ำ
หัวข้อที่มีชื่อว่า “วิธีวัดซ้ำ”ทุกค่าในหน้านี้สร้างใหม่ได้จากคำสั่งสองบรรทัด
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 แปลว่าวัดไม่ได้ สองอย่างนี้ไม่เหมือนกัน