การตรวจสอบความเป็นเจ้าของที่ป้องกันไว้แล้วในฝั่งอ้างอิงข้อมูล (GET) แต่ลืมใส่ทั้งหมดในฝั่งอัปเดตข้อมูล (POST) — IDOR ที่เกือบหลุดออกไปใน API บัญชีที่สร้างเอง
บทนำ
ฉันกำลังสร้างระบบธนาคารที่จัดการบัญชีด้วยตัวเองคนเดียว จนถึงขั้นที่ใช้งานได้จริง ไม่ใช่แค่ออกแบบเท่านั้น โครงสร้าง หน้าจอ และโค้ดแกนหลักของระบบ ฉันสรุปไว้ใน จริงๆ แล้วระบบธนาคารที่สร้างเองกำลังทำงานอะไรอยู่ การดูยอดคงเหลือและการฝากถอนของบัญชี ทำผ่านหน้าจอเว็บสำหรับลูกค้า (React) ด้านล่างคือหน้าจอรายการบัญชีของตัวเอง

บทความนี้เป็นเรื่องราวเกี่ยวกับช่องโหว่หนึ่งที่อยู่เบื้องหลังหน้าจอนั้น ใน API บัญชี ซึ่งฉันปิดไว้ก่อนนำออกใช้งานจริง
ชื่อของช่องโหว่นี้คือ IDOR (Insecure Direct Object Reference) พูดสั้นๆ คือข้อบกพร่องที่ว่า “ล็อกอินผ่านถูกต้อง แต่ถ้าเปลี่ยนหมายเลขบัญชีใน URL เป็นของคนอื่น ก็สามารถดำเนินการกับบัญชีนั้นได้” การยืนยันตัวตน (authentication คือคุณเป็นใคร) ผ่านอยู่แล้ว สิ่งที่ขาดไปคือการอนุญาตสิทธิ์ (authorization คือคุณได้รับอนุญาตให้แตะบัญชีนี้หรือไม่)
สิ่งที่อาจเกิดขึ้นได้
ใน API นี้ ฉันสร้างฝั่งอ้างอิงข้อมูล (GET) ก่อนตามลำดับการพัฒนา การดูยอดคงเหลือและประวัติธุรกรรมมีการตรวจสอบความเป็นเจ้าของ เพื่อให้แน่ใจว่าจะคืนค่าเฉพาะบัญชีของผู้ใช้ที่ยืนยันตัวตนแล้วเท่านั้น ส่วนนี้ถูกต้อง
ปัญหาอยู่ที่ฝั่งอัปเดตข้อมูล (POST) การออกแบบ (pseudocode) ของการถอนเงินและการโอนเงิน ไม่เคยเขียนการตรวจสอบความเป็นเจ้าของนั้นไว้เลย หากนำ pseudocode ไปพัฒนาตรงๆ ตามที่เขียนไว้ จะกลายเป็นแบบนี้
คำขอที่ยืนยันตัวตนแล้ว (ยืนยันตัวบุคคลเรียบร้อยแล้ว)
│
├─▶ GET /accounts/{id}/balance (ฝั่งอ้างอิง)
│ └─ มีการตรวจสอบความเป็นเจ้าของ ─▶ คืนเฉพาะบัญชีของตัวเอง ○
│
└─▶ POST /accounts/{id}/withdraw (ฝั่งอัปเดต)
└─ ไม่มีการตรวจสอบความเป็นเจ้าของ
│
└─▶ เปลี่ยน {id} เป็นบัญชีของคนอื่น ─▶ ถอนเงินจากบัญชีคนอื่น ×
▲
(ยืนยันตัวตนผ่าน แต่ขาดการอนุญาตสิทธิ์เพียงอย่างเดียว)
การล็อกอินเองถูกต้องตามกฎ ดังนั้นเมื่อมองผิวเผินจะดูเหมือนได้รับการป้องกันอย่างถูกต้อง แต่ endpoint ฝั่งอัปเดตไม่เคยตรวจสอบว่า {id} ใน URL เป็นของผู้เรียกเองหรือไม่ ตราบใดที่ผ่านการยืนยันตัวตนแล้ว ก็สามารถระบุหมายเลขบัญชีของคนอื่นแล้วสั่งถอนเงินหรือโอนเงินได้ ใน API ทางการเงิน นี่นำไปสู่อุบัติเหตุโดยตรง
เมื่อมองในระดับการอิมพลีเมนต์ ช่องว่างคือแบบนี้ ฝั่งอ้างอิง (การดูยอดคงเหลือ) ที่เห็นในบทความก่อนหน้ามีการตรวจสอบความเป็นเจ้าของ แต่เมธอดฝั่งอัปเดตไม่มี
// ฝั่งอัปเดต (ถอนเงิน) — เวอร์ชันที่ไม่มีการตรวจสอบความเป็นเจ้าของ
@PostMapping("/accounts/{id}/withdraw")
public void withdraw(@PathVariable String id,
@RequestBody WithdrawRequest req,
@AuthenticationPrincipal Jwt principal) {
// ได้รับ principal (ตัวบุคคล) มาแล้ว
// แต่ไม่เคยตรวจสอบเลยว่าบัญชี id เป็นของบุคคลนั้นจริงหรือไม่
accounts.withdraw(id, req.amount()); // ← ผ่านได้แม้เป็น id บัญชีของคนอื่น
}
มี principal (ตัวบุคคล) อยู่ในมือแล้ว แต่กลับไม่เคยตรวจสอบว่าบัญชี id เป็นของบุคคลนั้นหรือไม่ การตรวจสอบที่ฝั่งอ้างอิงทำเป็นเรื่องปกติ กลับไม่มีอยู่ตรงนี้
ทำไมจึงเกิดขึ้น
สาเหตุมีสองอย่างซ้อนกันอยู่
อย่างแรก คือความประมาทที่ปฏิบัติต่อการยืนยันตัวตนและการอนุญาตสิทธิ์เป็นสิ่งเดียวกัน การยืนยันตัวตนคือการตรวจสอบว่า “คุณเป็นใคร” การอนุญาตสิทธิ์คือการตรวจสอบว่า “คุณได้รับอนุญาตให้ทำสิ่งนี้กับเป้าหมายนี้หรือไม่” ล็อกอินผ่านไม่ได้แปลว่าปลอดภัย คนที่ล็อกอินแล้วไม่ได้แปลว่าจะแตะบัญชีของคนอื่นได้ นี่คือด่านสองด่านที่แยกจากกัน
อย่างที่สอง คือรูปแบบที่สร้างขึ้นในฝั่งอ้างอิงจะไม่ถูกส่งต่อไปยังฝั่งอัปเดตโดยอัตโนมัติ แม้จะสร้างฝั่งอ้างอิงก่อนและวางการตรวจสอบความเป็นเจ้าของไว้แล้ว แต่ถ้าปล่อยให้การออกแบบฝั่งอัปเดตเป็นแค่ pseudocode การตรวจสอบนั้นก็จะไม่ถูกคัดลอกมาด้วย และถ้านำ pseudocode ไปพัฒนาแบบตรงไปตรงมาโดยคิดว่า “เป็นสเปคแล้ว” ช่องว่างก็จะติดไปกับมันด้วย ลำดับการพัฒนาเอง (ฝั่งอ้างอิงก่อน ฝั่งอัปเดตเป็นแค่ pseudocode) ได้สร้างช่องว่างขึ้นมาอย่างเงียบๆ
กล่าวอีกนัยหนึ่ง นี่ไม่ใช่ความผิดพลาดส่วนบุคคล แต่เป็นช่องโหว่เชิงโครงสร้างที่เกิดจากลำดับขั้นตอนการพัฒนา
ปิดช่องโหว่นี้อย่างไร
ฉันย้ายการตรวจสอบความเป็นเจ้าของแบบเดียวกับฝั่งอ้างอิงมาใส่ในฝั่งอัปเดต หากตัวบุคคลที่ได้จากการยืนยันตัวตน ไม่ตรงกับเจ้าของบัญชีที่กำลังจะดำเนินการ จะปฏิเสธการดำเนินการนั้น
POST /accounts/{id}/withdraw (ฝั่งอัปเดต)
│
├─▶ ยืนยันตัวตน: ดึงตัวบุคคล (sub) ออกจากโทเคน
│
└─▶ อนุญาตสิทธิ์: เจ้าของบัญชี {id} == ตัวบุคคลนั้นหรือไม่?
├─ ตรงกัน ──▶ ดำเนินการถอนเงิน ○
└─ ไม่ตรงกัน ─▶ ปฏิเสธ (403) × ไม่ให้แตะบัญชีของคนอื่น
เมื่อเขียนเป็นโค้ด ก็เพียงแค่ย้ายไม่กี่บรรทัดที่มีอยู่แล้วในฝั่งอ้างอิง มาใส่ในฝั่งอัปเดตเท่านั้น
// ฝั่งอัปเดต (ถอนเงิน) — เวอร์ชันที่ใส่การตรวจสอบความเป็นเจ้าของแล้ว
@PostMapping("/accounts/{id}/withdraw")
public void withdraw(@PathVariable String id,
@RequestBody WithdrawRequest req,
@AuthenticationPrincipal Jwt principal) {
String me = principal.getSubject();
Account account = accounts.findById(id);
if (!account.getCustomerId().equals(me)) { // ย้ายการตรวจสอบความเป็นเจ้าของแบบเดียวกับฝั่งอ้างอิงมาใส่
throw new AccessDeniedException("not your account");
}
accounts.withdraw(id, req.amount());
}
ไม่ใช่มาตรการที่ยาก สิ่งที่ยากคือการสังเกตเห็นความคิดที่ว่า “ป้องกันไว้แล้วในฝั่งอ้างอิง ฝั่งอัปเดตก็ต้องป้องกันไว้แล้วเช่นกัน”
และสิ่งที่แก้ไขไม่ได้มีแค่จุดนี้จุดเดียว ฝั่งอัปเดต (POST / PUT / DELETE) ทุกตัวที่รับ ID ของทรัพยากรผ่านพาธของ URL ถูกจัดเป็นผู้ต้องสงสัยเหมือนกันหมด เพราะการพบช่องโหว่หนึ่งจุด หมายความว่า endpoint อื่นที่สร้างด้วยลำดับเดียวกันก็มีแนวโน้มสูงที่จะมีช่องโหว่แบบเดียวกัน
อีกจุดที่ไม่ลืมทำคือ เขียนเรื่องการอนุญาตสิทธิ์กลับเข้าไปในเอกสารออกแบบ หากแก้แค่การอิมพลีเมนต์แล้วปล่อยเอกสารออกแบบทิ้งไว้ คนที่อ่าน pseudocode เดิมในครั้งถัดไป (รวมถึงตัวฉันเองในอนาคต) ก็จะสร้างช่องว่างแบบเดียวกันขึ้นมาอีก การปิดช่องโหว่กับการแก้ไขพิมพ์เขียวของช่องโหว่ เป็นงานคนละอย่างกัน
อีกข้อหนึ่ง กรณีนี้มีเงื่อนไขต่างจาก API แบบ “อนุญาตตามบทบาท (role)” อย่างเช่นสำหรับพนักงานเคาน์เตอร์ ซึ่งเป็นปกติที่จะจัดการบัญชีของคนอื่นด้วยอำนาจหน้าที่ ส่วน API แบบบริการตนเองของลูกค้าอย่างครั้งนี้ การดำเนินการถูกจำกัดไว้เฉพาะบัญชีของตัวเองเท่านั้น แม้จะเป็น “API ที่ดำเนินการกับบัญชี” เหมือนกัน รูปแบบของการอนุญาตสิทธิ์ก็เปลี่ยนไปตามว่าฟีเจอร์นั้นมีไว้เพื่อใคร การไม่สับสนระหว่างสองแบบนี้ก็เป็นส่วนหนึ่งของการปิดช่องโหว่ด้วย
อย่าเข้าใจผิดว่าการยืนยันตัวตนคือการอนุญาตสิทธิ์
การยืนยันตัวตนไม่ใช่การอนุญาตสิทธิ์ การรู้ว่า “เป็นใคร” ไม่ได้แปลว่า “ทำสิ่งนี้ได้” ในช่วงเวลาที่คิดว่า “ล็อกอินผ่านแล้ว = ปลอดภัย” นั่นคือช่วงเวลาที่ข้ามด่านการอนุญาตสิทธิ์ไปแล้ว
ย้ายการอนุญาตสิทธิ์ที่วางไว้ในฝั่งอ้างอิง มาใส่ในฝั่งอัปเดตด้วยมือ อย่าวางใจแค่เพราะ GET ได้รับการป้องกันแล้ว ต้องตรวจสอบเสมอว่าการออกแบบของ POST / PUT / DELETE มีการตรวจสอบความเป็นเจ้าของแบบเดียวกันเขียนไว้หรือไม่ อ่าน pseudocode ไม่ใช่ในฐานะสเปคที่เสร็จสมบูรณ์ แต่ในฐานะ “ร่างที่อาจยังขาดการอนุญาตสิทธิ์อยู่”
สงสัยฝั่งอัปเดตทุกตัวที่รับ ID ของทรัพยากรผ่าน URL เมื่อพบหนึ่งจุด ให้ตรวจสอบ endpoint พี่น้องที่สร้างด้วยวิธีเดียวกันทั้งหมด ช่องโหว่ไม่เกิดขึ้นแค่จุดเดียวโดยลำพัง
แก้ไขการอนุญาตสิทธิ์ในเอกสารออกแบบไปพร้อมกับการอิมพลีเมนต์ หากแก้แค่การอิมพลีเมนต์ พิมพ์เขียวที่เหลืออยู่จะสร้างช่องโหว่แบบเดียวกันขึ้นมาอีก เก็บร่องรอยของการแก้ไขไว้ในการออกแบบ เพื่อตัดต้นตอของการเกิดซ้ำ
pseudocode ไม่ใช่คำตอบที่ถูกต้องสำหรับนำไปพัฒนาตรงๆ ก่อนลงมือพัฒนา ต้องตรวจสอบบรรทัดนั้นเสมอว่า การอนุญาตสิทธิ์ที่ป้องกันฝั่งอ้างอิงไว้ ถูกเขียนไว้ในฝั่งอัปเดตด้วยหรือไม่
บทความที่เกี่ยวข้อง
- โครงสร้าง หน้าจอ และโค้ดแกนหลักของระบบนี้ สรุปไว้ใน จริงๆ แล้วระบบธนาคารที่สร้างเองกำลังทำงานอะไรอยู่ (เป็นพื้นฐานของบทความนี้)
- ถ้าบทความนี้คือเรื่อง “คนคนหนึ่งแตะได้แค่ทรัพยากรของตัวเองหรือไม่ (การอนุญาตสิทธิ์)” เรื่องก่อนหน้านั้นคือ “ผู้เรียกเป็นใคร (การยืนยันตัวตน)” — เรื่องการปกป้องการเรียกระหว่างเซอร์วิสสำหรับการชำระเงินที่ผู้เรียกไม่ใช่คน โดยไม่ส่งมอบเบาะแสใดๆ อยู่ใน “ยืนยันตัวตนฝ่ายที่ไม่ใช่คน” — ปกป้องการเรียกชำระเงินระหว่างเซอร์วิสโดยไม่ส่งมอบเบาะแส
- กระบวนการออกแบบตั้งแต่การกำหนดความต้องการจนถึง PoC สรุปไว้ใน การสร้าง API ธนาคารที่เริ่มต้นจากการออกแบบ
- เรื่องราวการใช้ API บัญชีนี้เชื่อมต่อการโอนเงินจริงจากการชำระเงินของแพลตฟอร์มที่สร้างเอง อยู่ใน เชื่อมแอปที่สร้างเองกับธนาคารที่สร้างเอง จนเงินขยับจริง — เพิ่มการโอนเข้าบัญชีในการชำระเงิน แล้วพิสูจน์ด้วย E2E
- ในส่วนของการยืนยันตัวตน การออกแบบที่ผ่อนปรนการยืนยันตัวตนเฉพาะใน dev โดยไม่ทำลายโครงสร้างของ production สรุปไว้ใน การออกแบบที่ผ่อนปรนการยืนยันตัวตนเฉพาะ dev ด้วย Spring Profile