Danger

เนื้อหาในบทความนี้เปิดเผยขั้นตอนการทำงานทั้งหมดของผู้เขียน ซึ่งเป็นการกระทำที่ไม่ได้รับอนุญาตจากทางเจ้าของเว็บไซต์ การนำไปทำตามเนื้อหาอาจทำให้ท่านกระทำผิดกฎหมาย พรบ. ว่าด้วยการกระทําความผิดทางคอมพิวเตอร์ พ.ศ. 2560

หลังจากที่รอบก่อนเว็บ ezslot โดนเราเล่นท่า Negative Transfer จนต้องแพทซ์แก้ไขกันไป วันนี้เราเลยอยากกลับไปลองดูกันใหม่ว่าจะเจออะไรอีกไหม

Withdraw

  • PATH: https://api.pocketfarm.com/api/v1/bank/withdraw
  • METHOD: POST

Request Body

{
  "channel": "game",
  "amount": "1"
}

Response Body

{
  "error_code": 400,
  "message": "json: cannot unmarshal string into Go struct field ActionRequest.amount of type float64",
  "success": false
}

เราได้ลองเปลี่ยน amount เป็น String แล้วระบบได้ Return กลับมาบอกว่ารับค่า amount เป็น float64 เท่านั้น

ตอนนี้เรารู้แล้วว่า

  • ระบบเขียนด้วย Golang จาก message ที่บอกว่า Go struct field
  • ค่า amount ต้องเป็น float64

ปัญหายอดนิยมของ float64 ที่คอมโบคู่กับระบบยอดเงินแบบทศนิยมสองตำแหน่ง ที่พบได้บ่อยๆก็คือ เจอเลขในรูป scientific notation (เช่น 1e-3, 1e20) ทำให้ precision เพี้ยนหรือหลุด validation หรือเอ๋อทันที่ถ้าเจอเลข 5 ลงท้าย แต่ในเคสนี้เราเจอว่ามันเอ๋อเลข 5

สิ่งที่ float64 เห็น

0.004  →  0.004000000000000000
0.005  →  0.004999999999999999
0.006  →  0.006000000000000000

หมายเหตุ

  • 0.005 ไม่สามารถแทนค่าได้ตรงใน IEEE-754 binary
  • float64 เก็บเป็น base-2 ไม่ใช่ base-10
  • ค่าที่ใกล้ที่สุดของ 0.005 ใน binary คือ 0.004999999999999999…

หมายความว่าถ้าเราส่งยอด 0.005 ไประบบจะเข้าใจว่าส่ง 0.004999999999999999 และเมื่อคอมโบคู่กับทศนิยมสองตำแหน่ง นั่นยิ่งเพี้ยนไปกันใหญ่ เพราะมันจะปัดเศษลงมาเป็น 0.00

สิ่งที่เกิดขึ้นเมื่อถูกคอมโบคู่กับทศนิยมสองตำแหน่งด้วยวิธี truncate/floor/cast

0.004  →  0.00 // ปัดลง
0.005  →  0.00 // ปัดลง
0.006  →  0.01 // ปัดขึ้น

Impact

ผลลัพธ์ของอาการนี้คือ ระบบเชื่อสนิทใจว่ามีการถอนเงินเกิดขึ้นจริง ทั้ง ๆ ที่ยอดเงินจริงแทบไม่ขยับ เพราะมันโดนเศษทศนิยมกินไปเรียบร้อยแล้ว

ถ้าลากพฤติกรรมนี้ไปทำซ้ำ ๆ ระบบก็จะค่อย ๆ แจกเงินฟรีจาก rounding error โดยที่ทุก transaction ผ่าน validation หมด ไม่มีอะไรดูผิดปกติในสายตา backend เลย

Root Cause

ต้นตอของเรื่องทั้งหมดไม่ได้มาจากอะไรซับซ้อน แค่เอา float64 มาจับยอดเงิน แล้วหวังว่ามันจะเชื่อฟังเหมือนเลขฐานสิบ

พอค่าอย่าง 0.005 หลุดเข้า IEEE-754 จริง ๆ มันก็แกล้งลดตัวเองลงนิดนึง แล้วค่อยโดน truncate / cast ทิ้งตอนตัดทศนิยมสองตำแหน่ง สุดท้ายยอดเงินหายไปแบบเงียบ ๆ ไม่มีใครรู้ ไม่มีใครเห็น

Mitigation

กฎเหล็กของระบบการเงินคือ ถ้าเห็น float64 อยู่ใกล้ยอดเงิน แปลว่ามีคนกำลังจะซวย

ทางรอดที่ปลอดภัยกว่าคือเลิกใช้ float กับเงินตั้งแต่ต้น เก็บเป็น integer ไปเลย จะเป็นสตางค์ satoshi หรือหน่วยย่อยอะไรก็ว่ากัน หรือไม่ก็ใช้ decimal library ที่ออกแบบมาให้ไม่เล่นตลกกับเศษทศนิยม

อีกอย่างที่ควรทำคือดักตั้งแต่ด่านหน้า จำกัดทศนิยมไม่เกินสองตำแหน่ง และปัด scientific notation อย่าง 1e-3 ทิ้งไปตั้งแต่ยังไม่เข้าระบบ จะได้ไม่ต้องมานั่งไล่ผี IEEE-754 ทีหลัง