guest@websec ~ % man 20-insecure-design
20 · การออกแบบที่ไม่ปลอดภัยและ business logic
บั๊กบางตัวไม่ใช่ความผิดพลาดในการเขียนโค้ด โค้ดทำตามสเปกทุกอย่าง แต่ไม่มีใครถามว่า "ผู้โจมตีจะทำอะไร?" ไม่มีตัวสแกนไหนหาเจอ
20.1 ข้อบกพร่องในการออกแบบ เทียบกับบั๊กในการเขียนโค้ด
การออกแบบเองไม่ปลอดภัย แม้โค้ดจะสมบูรณ์แบบ:
- ตะกร้าสินค้าเชื่อราคาที่เบราว์เซอร์ส่งมา
- คูปองใช้ซ้อนกันได้ หรือใช้ซ้ำได้ด้วยการส่ง request เดียวกันสองครั้งพร้อมกัน
- จำนวนติดลบได้ ทำให้การซื้อกลายเป็นการคืนเงิน
- การชำระเงินหลายขั้นตอนให้เรียก "ยืนยัน" ได้โดยไม่ "ชำระเงิน"
- การทำงานที่มีต้นทุนสูง (export รายงาน, ส่ง SMS) ไม่มีขีดจำกัดต่อผู้ใช้
20.2 Threat modeling ใน 20 นาที
ฟังดูหนัก แต่จริงๆ คือสี่คำถามที่ถามได้ตอนเริ่มทุกฟีเจอร์:
- เรากำลังสร้างอะไร? ร่างการไหลของข้อมูล: ใครส่งอะไร เก็บที่ไหน เกี่ยวข้องกับบริการไหน
- อะไรผิดพลาดได้บ้าง? ไล่ STRIDE (ด้านล่าง) กับทุกลูกศรและทุกกล่อง
- เราจะทำอะไรกับมัน? ตัดสินใจว่าจะลดความเสี่ยง ยอมรับความเสี่ยง หรือเปลี่ยนการออกแบบ
- เราทำได้ดีไหม? ทบทวนหลังเปิดตัวและหลังเกิดเหตุ
เขียน abuse case คู่กับ user story: "ในฐานะผู้โจมตี ฉันอยากใช้คูปองเดิม 50 ครั้ง" แต่ละข้อกลายเป็นเทสต์
20.3 Race condition
"คูปองยังไม่ถูกใช้ใช่ไหม? ถ้าใช่ก็ใช้" มีช่องว่างระหว่างตรวจกับลงมือ ยิง 20 request พร้อมกัน หลายอันจะหลุดผ่าน บัตรของขวัญถูกใช้สองครั้ง ยอดเงินติดลบ
protection:
20.4 อย่าให้ client เป็นผู้ถือกฎทางธุรกิจ
คำสั่งซื้อที่เซิร์ฟเวอร์สร้าง
เป็นหน้าที่เราทั้งหมด ใช้การล็อกแถว (
with_lock) การอัปเดตแบบ atomic unique index และ check constraint ของฐานข้อมูล validation ในโมเดลอย่างเดียวยังเกิด race ได้# atomic: only one request can flip used from false to true claimed = Coupon.where(code: params[:code], used: false).update_all(used: true, used_by_id: Current.user.id) return render(status: :conflict, plain: "Already used") if claimed.zero? # or lock the row for the whole check-and-act coupon.with_lock do raise AlreadyUsed if coupon.used? coupon.update!(used: true) Current.user.credit!(coupon.amount) end # database as the final guard add_index :redemptions, [:coupon_id], unique: true add_check_constraint :order_items, "qty BETWEEN 1 AND 10", name: "qty_range" # never take prices from the request item.price = Product.find_by!(sku: line[:sku]).price