guest@websec: ~/websec/20-insecure-design/th — zsh — 100×40 เขียนโดย SaKKo ENTH โค้ด:

guest@websec ~ % man 20-insecure-design

20 · การออกแบบที่ไม่ปลอดภัยและ business logic

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

20.1 ข้อบกพร่องในการออกแบบ เทียบกับบั๊กในการเขียนโค้ด

การออกแบบเองไม่ปลอดภัย แม้โค้ดจะสมบูรณ์แบบ:

  • ตะกร้าสินค้าเชื่อราคาที่เบราว์เซอร์ส่งมา
  • คูปองใช้ซ้อนกันได้ หรือใช้ซ้ำได้ด้วยการส่ง request เดียวกันสองครั้งพร้อมกัน
  • จำนวนติดลบได้ ทำให้การซื้อกลายเป็นการคืนเงิน
  • การชำระเงินหลายขั้นตอนให้เรียก "ยืนยัน" ได้โดยไม่ "ชำระเงิน"
  • การทำงานที่มีต้นทุนสูง (export รายงาน, ส่ง SMS) ไม่มีขีดจำกัดต่อผู้ใช้

20.2 Threat modeling ใน 20 นาที

ฟังดูหนัก แต่จริงๆ คือสี่คำถามที่ถามได้ตอนเริ่มทุกฟีเจอร์:

  1. เรากำลังสร้างอะไร? ร่างการไหลของข้อมูล: ใครส่งอะไร เก็บที่ไหน เกี่ยวข้องกับบริการไหน
  2. อะไรผิดพลาดได้บ้าง? ไล่ STRIDE (ด้านล่าง) กับทุกลูกศรและทุกกล่อง
  3. เราจะทำอะไรกับมัน? ตัดสินใจว่าจะลดความเสี่ยง ยอมรับความเสี่ยง หรือเปลี่ยนการออกแบบ
  4. เราทำได้ดีไหม? ทบทวนหลังเปิดตัวและหลังเกิดเหตุ
sim: STRIDE กับฟีเจอร์เลือกฟีเจอร์ แล้วดูภัยคุกคาม

เขียน abuse case คู่กับ user story: "ในฐานะผู้โจมตี ฉันอยากใช้คูปองเดิม 50 ครั้ง" แต่ละข้อกลายเป็นเทสต์

20.3 Race condition

"คูปองยังไม่ถูกใช้ใช่ไหม? ถ้าใช่ก็ใช้" มีช่องว่างระหว่างตรวจกับลงมือ ยิง 20 request พร้อมกัน หลายอันจะหลุดผ่าน บัตรของขวัญถูกใช้สองครั้ง ยอดเงินติดลบ

sim: การแลกพร้อมกันหลาย requestยิง N request พร้อมกัน
protection:

20.4 อย่าให้ client เป็นผู้ถือกฎทางธุรกิจ

sim: request ชำระเงินแก้สิ่งที่เบราว์เซอร์ส่ง

คำสั่งซื้อที่เซิร์ฟเวอร์สร้าง

เป็นหน้าที่เราทั้งหมด ใช้การล็อกแถว (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