การยืนยันตัวตนของ "ผู้เรียกที่ไม่ใช่คน" — ปกป้องการเรียกชำระเงินระหว่างเซอร์วิสโดยไม่ยื่นเบาะแสให้ผู้โจมตี

セキュリティ 認証 マイクロサービス 設計 分散システム

บทนำ

ในระบบเชื่อมต่อธนาคารที่ฉันสร้างเอง การชำระเงินกระจายอยู่ในหลายเซอร์วิส เซอร์วิสชำระเงินเรียก 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 (ชำระเงินมุ่งถึงลูกค้ารายนี้เท่านั้น)

        การรวมทั้งสองเข้าเป็นทะเบียนเดียว/จุดออกโทเคนเดียว
        อาจทำให้โทเคนที่มีสิทธิ์ภายในหลุดไปถึงฝ่ายภายนอก (ขอบเขตพังทลาย)

ถ้ารวมทั้งสองเข้าเป็นจุดออกโทเคนเดียว ความผิดพลาดเล็กๆ ในโค้ดอาจทำให้ร้านค้าภายนอกได้รับโทเคนที่มีสิทธิ์แรงแบบเซอร์วิสภายในไปโดยไม่ตั้งใจ ขอบเขตจะได้รับการปกป้องด้วยการ แยกทางกายภาพให้เป็นรูปแบบที่สับสนกันไม่ได้ การรวมเป็นหนึ่งเพราะแยกแล้วยุ่งยาก ไม่ใช่ทางเลือกในกรณีนี้


จุดเข้าของการยืนยันตัวตน เลือก “ไม่ยื่นเบาะแส” มากกว่าความใจดี

การออกแบบทั้งสามอย่างมีรากเดียวกัน คือ อย่านำสามัญสำนึกของการล็อกอินมนุษย์มาใช้กับระหว่างเซอร์วิสตรงๆ

  • โทเคนทำให้สั้น ไม่ใช่เพื่อความสะดวก แต่เพื่อรองรับตอนที่มันรั่วไหล
  • ข้อผิดพลาดทำให้แยกแยะไม่ได้ ไม่ใช่เพื่อความชัดเจน แต่เพื่อไม่ยื่นเบาะแส
  • จุดออกโทเคนแยกตามขอบเขตความน่าเชื่อถือ โดยไม่เสียดายความยุ่งยาก

การออกแบบทั้งหมดนี้เลือกโดยการยอมทิ้ง “ความใจดีต่อผู้ใช้” ก่อน สำหรับหน้าจอที่มุ่งไปที่คน ข้อผิดพลาดที่เข้าใจง่ายและเซสชันที่ยาวนานล้วนถูกต้อง แต่ เมื่อฝ่ายตรงข้ามที่จุดเข้าของการยืนยันตัวตนไม่จำเป็นต้องเป็นคน ความใจดีจะกลายเป็นเบาะแสให้ผู้โจมตีตรงๆ สมมติว่าฝ่ายตรงข้ามไม่ใช่คน แล้วกลับน้ำหนักของความสะดวกให้ตรงข้าม — นี่คือสิ่งที่ฉันซึมซับได้จากการยืนยันตัวตนระหว่างเซอร์วิส


บทความที่เกี่ยวข้อง

ส่งข้อความได้ตามสบาย

ไม่ว่าจะเป็นการว่าจ้างงาน แนะนำโปรเจกต์ ความคิดเห็น หรือคำถาม ยินดีรับทั้งหมด ฉันหวังเป็นอย่างยิ่งว่าจะได้เชื่อมต่อกับผู้ที่มีอุดมการณ์อันสูงส่งเช่นเดียวกัน ฉันจะมุ่งมั่นท้าทายในสิ่งที่ทุ่มเททั้งชีวิตต่อไป ขอขอบคุณและฝากเนื้อฝากตัวด้วยครับ/ค่ะ