การยืนยันตัวตนของ "ผู้เรียกที่ไม่ใช่คน" — ปกป้องการเรียกชำระเงินระหว่างเซอร์วิสโดยไม่ยื่นเบาะแสให้ผู้โจมตี
บทนำ
ในระบบเชื่อมต่อธนาคารที่ฉันสร้างเอง การชำระเงินกระจายอยู่ในหลายเซอร์วิส เซอร์วิสชำระเงินเรียก internal API ของเซอร์วิสบัญชีเพื่อโอนเงิน ในกรณีนี้ ผู้เรียกไม่ใช่คน แต่คือตัวเซอร์วิสเอง
การล็อกอินของมนุษย์จะยืนยันตัวตนด้วย ID และรหัสผ่านหรือ passkey แต่ระหว่างเซอร์วิส ฝ่ายตรงข้ามที่ต้องยืนยันไม่ใช่คน หากนำสามัญสำนึกของการล็อกอินมนุษย์มาใช้ตรงๆ ตรงนี้ จะเปิดช่องให้ผู้โจมตีได้เบาะแส บทความนี้เป็นเรื่องราวของการออกแบบสามอย่างที่ฉันใส่เข้าไปเพื่อปิดช่องโหว่นั้น
หมายเหตุ นี่คือเรื่องของ การยืนยันตัวตน (authentication) ที่ยืนยันว่าผู้เรียกคือใคร ซึ่งเป็นคนละชั้นกับ การให้สิทธิ์ (authorization) ที่ว่าคนนั้นแตะได้แค่บัญชีของตัวเองหรือไม่ กับดักที่ฉันเคยเหยียบในฝั่งการให้สิทธิ์ (ข้อบกพร่องที่ทำให้แตะบัญชีของคนอื่นได้) เขียนไว้ใน การตรวจสอบตัวตนที่ GET ปกป้องไว้ แต่ POST ทำหล่นทั้งหมด — IDOR ที่เกือบสร้างไว้ใน API บัญชี
จะยืนยันตัวตนผู้เรียกที่ไม่ใช่คนได้อย่างไร
การที่เซอร์วิสชำระเงินจะเรียก internal API ของเซอร์วิสบัญชีได้ ไม่ใช่โทเคนล็อกอินของมนุษย์ แต่ต้องใช้ โทเคนของตัวเซอร์วิสเอง กลไกเป็นแบบนี้ เซอร์วิสชำระเงินระบุตัวตนต่อเซอร์วิสยืนยันตัวตนด้วย ID และรหัสลับที่แชร์กันไว้ล่วงหน้า แล้วรับโทเคนอายุสั้นกลับมาหนึ่งใบ จากนั้นแนบโทเคนนั้นไปเรียก internal API ของเซอร์วิสบัญชี
วิธีนี้ — “โปรแกรม ไม่ใช่คน ยืนยันตัวตนด้วย ID และรหัสลับที่แชร์ไว้ล่วงหน้า” — เรียกว่า client_credentials (การยืนยันตัวตนแบบไคลเอนต์) โทเคนที่ได้รับมามีเพียงเครื่องหมายว่า “นี่คือการเรียกจากเซอร์วิสภายใน” (role: service) และเซอร์วิสบัญชีจะเห็นเครื่องหมายนี้แล้วอนุญาตให้ใช้ internal API
เซอร์วิสชำระเงิน ──① ระบุตัวตนด้วย ID+รหัสลับ──▶ เซอร์วิสยืนยันตัวตน
เซอร์วิสชำระเงิน ◀─② โทเคนอายุสั้น(role:service)─┘
เซอร์วิสชำระเงิน ──③ แนบโทเคน──▶ internal API ของเซอร์วิสบัญชี (ต้องการ role:service)
ดูเหมือนตรงไปตรงมา ปัญหาอยู่ที่วิธีสร้างจุดเข้า “ระบุตัวตนแล้วรับโทเคน” นี้ หากสร้างด้วยความรู้สึกแบบเดียวกับการล็อกอินของมนุษย์ จะเปิดช่องโหว่สามจุด
ข้อที่หนึ่ง: ทำให้โทเคนมีอายุสั้น
อันดับแรก ฉันตัดอายุของโทเคนที่ออกให้เหลือ 5 นาที
การล็อกอินของมนุษย์มักมีอายุนานหลายสิบนาทีถึงหลายชั่วโมงเพื่อความสะดวก แต่ถ้านำความรู้สึกนั้นมาใช้กับโทเคนระหว่างเซอร์วิส โทเคนที่รั่วไหลแม้เพียงครั้งเดียวจะยังใช้งานได้ต่อไปนาน หากโทเคนหลุดออกไปแม้แต่ใบเดียวผ่านช่องว่างของ log หรือการสื่อสาร ผู้โจมตีจะสามารถเรียก internal API ได้ตลอดช่วงเวลานั้น
ถ้าอายุสั้น แม้จะรั่วไหล ช่วงเวลาที่เสียหายก็สั้น ฝั่งเซอร์วิสชำระเงินจะถือโทเคนไว้ตามอายุของมัน และขอใหม่ก่อนที่จะหมดอายุ ไม่ใช่ “นานเพราะสะดวก” แต่เป็น “สั้นเพราะถ้ารั่วไหลจะลำบาก” — ให้น้ำหนักตรงกันข้ามกับการล็อกอินของมนุษย์
ข้อที่สอง: อย่าแยกความแตกต่างระหว่าง “ไม่มี” กับ “ผิด”
ข้อที่สองคือสิ่งที่ฉันอยากสื่อสารมากที่สุดในบทความนี้ จุดเข้าของการยืนยันตัวตน ต้องไม่ยื่นเบาะแสใดๆ ให้ผู้โจมตีเด็ดขาด
หากสร้างแบบไร้เดียงสา ถ้า ID ที่ระบุมายังไม่ได้ลงทะเบียนก็จะตอบว่า “ID นั้นไม่มี” ถ้าลงทะเบียนแล้วแต่รหัสลับผิดก็จะตอบว่า “รหัสลับผิด” — คำตอบที่แตกต่างกันสองแบบ ดูเหมือนใจดี แต่นี่คือการ บอกผู้โจมตีว่า ID ไหนมีอยู่จริง เมื่อรู้ว่ามี ID จริงอยู่ ที่เหลือก็แค่ไล่เดารหัสลับ ความแตกต่างของคำตอบนั่นเองที่กลายเป็นเบาะแส (oracle)
ดังนั้น ไม่ว่าจะเป็น ID ที่ไม่ได้ลงทะเบียนหรือรหัสลับไม่ตรงกัน ต้องตอบกลับเป็นความล้มเหลวแบบเดียวกันทั้งหมด ผู้โจมตีจะไม่รู้ด้วยซ้ำว่า ID ที่ใส่เข้ามามีอยู่จริงหรือไม่
【แบบไร้เดียงสา】ID ไม่ได้ลงทะเบียน → "ID ไม่มี" ┐ คำตอบต่างกัน
รหัสลับผิด → "รหัสลับผิด" ┘ → รั่วไหลว่า ID ไหนมีอยู่จริง (ฐานสำหรับไล่เดา)
【แก้ไขแล้ว】ID ไม่ได้ลงทะเบียน → ความล้มเหลวเดียวกัน ┐
รหัสลับผิด → ความล้มเหลวเดียวกัน ┘ → ไม่รู้ด้วยซ้ำว่า ID มีอยู่จริงหรือไม่ (ไม่มีเบาะแสเลย)
ยังมีอีกชั้นหนึ่ง หากตรวจสอบรหัสลับด้วยการเปรียบเทียบสตริงแบบธรรมดา (เทียบทีละตัวอักษรจากต้น หยุดทันทีที่พบความต่าง) เวลาที่ใช้เปรียบเทียบจะยืดยาวตามจำนวนตัวอักษรที่ตรงกัน หากวัดความต่างของเวลานี้ได้ ก็จะไล่เดารหัสลับได้ทีละตัวอักษร (การโจมตีแบบ timing) ดังนั้นการเปรียบเทียบจึง ต้องใช้วิธีที่ใช้เวลาเท่ากันเสมอไม่ว่าผลจะเป็นอย่างไร (timing-safe comparison)
// ไม่ว่า ID จะไม่ได้ลงทะเบียนหรือรหัสลับไม่ตรงกัน ให้ตอบความล้มเหลวเดียวกัน (ปิดบังว่า ID ไหนมีอยู่จริง)
var matched = clients.stream()
.filter(c -> c.clientId().equals(clientId))
.findFirst()
.orElseThrow(InvalidInternalServiceClientException::new);
// การตรวจสอบรหัสลับเป็นแบบ timing-safe (ไม่ให้เดารหัสลับได้จากเวลาที่ใช้เปรียบเทียบ)
boolean ok = MessageDigest.isEqual(
matched.clientSecret().getBytes(UTF_8),
clientSecret.getBytes(UTF_8));
if (!ok) throw new InvalidInternalServiceClientException(); // exception เดียวกับด้านบน
ความใจดีกับความปลอดภัยชี้ไปคนละทางที่จุดเข้าของการยืนยันตัวตน ข้อผิดพลาดที่ผู้ใช้ทั่วไปเข้าใจง่าย ก็เข้าใจง่ายสำหรับผู้โจมตีเช่นกัน
ข้อที่สาม: อย่าปนกัน “ทะเบียน” ภายในกับภายนอก
ข้อที่สามคือการแยก ขอบเขตความน่าเชื่อถือ ของฝ่ายที่รับโทเคน
ระบบนี้มีผู้รับโทเคนสองประเภท หนึ่งคือ เซอร์วิสภายในอื่น (เช่นเซอร์วิสชำระเงิน ซึ่งเป็นสมาชิกในครอบครัว banklink) อีกหนึ่งคือ ร้านค้าภายนอก (ธุรกิจภายนอกที่ใช้บริการชำระเงิน) สองสิ่งนี้ต้องไม่จัดการผ่านจุดออกโทเคนเดียวกันหรือทะเบียนเดียวกัน
ฉันจึงแยกทะเบียนออกจากกัน ทะเบียนเซอร์วิสภายในและทะเบียนร้านค้าภายนอกเก็บแยกกันเป็นคนละอย่าง ลักษณะของโทเคนที่ออกก็แยกกันด้วย — โทเคนสำหรับภายในมีเพียงเครื่องหมายว่า “นี่คือเซอร์วิสภายใน” (role: service) ส่วนโทเคนสำหรับร้านค้าภายนอกมีเครื่องหมายอีกแบบว่า “นี่คือโทเคนสำหรับชำระเงินร้านค้า มุ่งถึงลูกค้ารายนี้” (purpose: merchant_payment และลูกค้าเป้าหมาย) แม้จะเป็น “การออกโทเคน” เหมือนกัน แต่ฉันตั้งใจแยกทะเบียนและคลาสของสิ่งที่ออกให้คนในครอบครัวกับสิ่งที่ออกให้ภายนอก
┌─ ทะเบียนเซอร์วิสภายใน ──▶ role:service (เรียก internal API ได้)
การออกโทเคน ─────┤
└─ ทะเบียนร้านค้าภายนอก ──▶ purpose:merchant_payment (ชำระเงินมุ่งถึงลูกค้ารายนี้เท่านั้น)
▲
การรวมทั้งสองเข้าเป็นทะเบียนเดียว/จุดออกโทเคนเดียว
อาจทำให้โทเคนที่มีสิทธิ์ภายในหลุดไปถึงฝ่ายภายนอก (ขอบเขตพังทลาย)
ถ้ารวมทั้งสองเข้าเป็นจุดออกโทเคนเดียว ความผิดพลาดเล็กๆ ในโค้ดอาจทำให้ร้านค้าภายนอกได้รับโทเคนที่มีสิทธิ์แรงแบบเซอร์วิสภายในไปโดยไม่ตั้งใจ ขอบเขตจะได้รับการปกป้องด้วยการ แยกทางกายภาพให้เป็นรูปแบบที่สับสนกันไม่ได้ การรวมเป็นหนึ่งเพราะแยกแล้วยุ่งยาก ไม่ใช่ทางเลือกในกรณีนี้
จุดเข้าของการยืนยันตัวตน เลือก “ไม่ยื่นเบาะแส” มากกว่าความใจดี
การออกแบบทั้งสามอย่างมีรากเดียวกัน คือ อย่านำสามัญสำนึกของการล็อกอินมนุษย์มาใช้กับระหว่างเซอร์วิสตรงๆ
- โทเคนทำให้สั้น ไม่ใช่เพื่อความสะดวก แต่เพื่อรองรับตอนที่มันรั่วไหล
- ข้อผิดพลาดทำให้แยกแยะไม่ได้ ไม่ใช่เพื่อความชัดเจน แต่เพื่อไม่ยื่นเบาะแส
- จุดออกโทเคนแยกตามขอบเขตความน่าเชื่อถือ โดยไม่เสียดายความยุ่งยาก
การออกแบบทั้งหมดนี้เลือกโดยการยอมทิ้ง “ความใจดีต่อผู้ใช้” ก่อน สำหรับหน้าจอที่มุ่งไปที่คน ข้อผิดพลาดที่เข้าใจง่ายและเซสชันที่ยาวนานล้วนถูกต้อง แต่ เมื่อฝ่ายตรงข้ามที่จุดเข้าของการยืนยันตัวตนไม่จำเป็นต้องเป็นคน ความใจดีจะกลายเป็นเบาะแสให้ผู้โจมตีตรงๆ สมมติว่าฝ่ายตรงข้ามไม่ใช่คน แล้วกลับน้ำหนักของความสะดวกให้ตรงข้าม — นี่คือสิ่งที่ฉันซึมซับได้จากการยืนยันตัวตนระหว่างเซอร์วิส
บทความที่เกี่ยวข้อง
- กับดักอีกด้านหนึ่ง — “ใครคือผู้เรียก” (การยืนยันตัวตน) เทียบกับ “คนนั้นแตะได้แค่ทรัพยากรของตัวเองหรือไม่” (การให้สิทธิ์) — เขียนไว้ใน การตรวจสอบตัวตนที่ GET ปกป้องไว้ แต่ POST ทำหล่นทั้งหมด — IDOR ที่เกือบสร้างไว้ใน API บัญชี
- ที่ที่โทเคนระหว่างเซอร์วิสนี้ถูกใช้งานจริง เรื่องราวความสอดคล้องที่เซอร์วิสชำระเงินข้ามไปยังเซอร์วิสบัญชีเพื่อทำให้การโอนสำเร็จ อยู่ใน ไม่ให้เงินหายไปแม้เซอร์วิสจะล่มระหว่างการโอน — ปกป้องความสอดคล้องระหว่างบัญชีด้วย Saga และ Outbox