เมื่อก่อนผมเคยคิดว่าการเขียนเลเยอร์การยืนยันตัวตน (authentication layer) ขึ้นมาเองเป็นเรื่องที่น่าภูมิใจ ถ้าคุณเข้าใจ JWT และสามารถแฮชรหัสผ่านได้ มันจะยากสักแค่ไหนกันเชียว? ผมเชื่อมต่อ Node backend, ออกโทเคน และคิดว่ามันเสร็จเรียบร้อยแล้ว โค้ดทำงานได้ ผลการทดสอบผ่านฉลุย แต่แล้วผมก็เริ่มอ่านเกี่ยวกับวิธีการที่การโจมตีจริงๆ เกิดขึ้น และนั่นทำให้ผมรู้สึกเหมือนโลกถล่มลงมา ทุกบทที่ว่าด้วยเรื่อง injection, enumeration และ side-channel leaks ทำให้ผมต้องกลับมานั่งทบทวนงานเขียนด้วยความรู้สึกจุกในอก ระบบ auth ของผมไม่ได้มีแค่ช่องโหว่ แต่มันมีประตูที่เปิดกว้างถึงสี่บานที่ผมเป็นคนติดตั้งมันขึ้นมาเอง นี่คือสิ่งที่ผมค้นพบ และสิ่งที่ผมได้เปลี่ยนไปอย่างชัดเจน

SQL Injection ผ่านการต่อสตริง (String Concatenation)

บั๊กแรกคือกลโกงที่เก่าแก่ที่สุดในตำรา ผมนำ input จากผู้ใช้ไปใส่ลงใน SQL strings โดยตรง ใน login route ของผม ผมดึง email จาก request body และนำมาต่อเข้ากับ query อย่างเช่น SELECT * FROM users WHERE email = '${email}' มันดูเหมือนไม่มีอันตรายเพราะผมเป็นคนควบคุม frontend แต่นั่นคือสมมติฐานที่อันตรายมาก ผู้โจมตีไม่จำเป็นต้องใช้ frontend ของคุณเลย แค่ส่ง POST body ที่สร้างขึ้นมาอย่างเฉพาะเจาะจงเพียงครั้งเดียว ก็สามารถเปลี่ยนการตรวจสอบการเข้าสู่ระบบนั้นให้กลายเป็นการรั่วไหลของข้อมูลหรือการล้างฐานข้อมูลทิ้งได้ หากส่ง payload อย่าง ' OR '1'='1 หรือที่แย่กว่านั้นคือ stacked query ที่สั่ง drop tables และถ้าสตริงนั้นถูกรันแบบดิบๆ ข้อมูลของคุณก็หายเกลี้ยง ผมไม่ได้ให้ช่องทางที่ฐานข้อมูลจะแยกแยะระหว่างโค้ดของผมกับข้อมูลของผู้โจมตีได้เลย

วิธีแก้ไขไม่ใช่การเพิ่ม input validation หรือการทำ escaping strings ด้วยตัวเอง แต่การแก้ไขที่แท้จริงคือการใช้ parameterized queries ผมเปลี่ยนไปใช้ node-postgres และเริ่มใช้ placeholder อย่างเช่น $1 ตัว query จะกลายเป็นเทมเพลต: SELECT * FROM users WHERE email = $1 โดยที่ driver จะส่ง SQL และค่าต่างๆ ผ่านช่องทางที่แยกจากกัน ฐานข้อมูลจะปฏิบัติกับ input ในฐานะข้อมูลเท่านั้น ไม่ว่ามันจะมีตัวอักษรอะไรอยู่ก็ตาม การเปลี่ยนแปลงเพียงอย่างเดียวนี้ช่วยปิดช่องโหว่ของการโจมตีประเภท injection ได้ทั้งหมด มันอ่านง่ายกว่า ดูแลรักษาง่ายกว่า และช่วยลดภาระในการต้องเป็นพ่อมด regex ทุกครั้งที่คุณเขียน WHERE clause

การสุ่มหาอีเมล (Email Enumeration) ผ่านข้อความแสดงข้อผิดพลาด

ความผิดพลาดที่สองของผมดูเหมือนจะเป็น UX ที่ดี เมื่อผู้ใช้พิมพ์อีเมลผิด ผมจะส่งข้อความ User not found กลับไป แต่เมื่อพวกเขาพิมพ์อีเมลถูกแต่รหัสผ่านผิด ผมจะส่ง Incorrect password มันดูเหมือนจะเป็นการช่วยเหลือผู้ใช้ แต่มันก็เป็นเครื่องมือในการสอดแนม (reconnaissance) สำหรับผู้โจมตีด้วยเช่นกัน สคริปต์สำหรับ enumeration สามารถระดมยิง login endpoint ของคุณด้วยอีเมลนับพันรายการ หาก response body หรือ status code เปลี่ยนไปตามการมีอยู่ของบัญชี สคริปต์นั้นก็จะสามารถสร้างรายการผู้ใช้ที่ผ่านการตรวจสอบแล้วขึ้นมาได้ รายการนั้นจะกลายเป็นรากฐานสำหรับการโจมตีแบบ credential stuffing, การทำ phishing แบบเจาะจงเป้าหมาย และการพยายาม brute-force ในขั้นต่อไป

ผมต้องยอมรับว่าในบางครั้ง ความเป็นมิตรต่อผู้ใช้ (user-friendliness) จำเป็นต้องพ่ายแพ้ให้กับความปลอดภัย ผมเปลี่ยนทุกเส้นทางที่การเข้าสู่ระบบล้มเหลวให้ส่งข้อความเดียวกันเป๊ะๆ คือ: Invalid credentials ไม่มีการบอกใบ้ ไม่มีการใช้ logic แยกแยะใน error response ไม่ว่าอีเมลจะไม่มีอยู่ รหัสผ่านจะผิด หรือบัญชีจะถูกล็อก ข้อความก็จะยังคงเหมือนเดิม สิ่งนี้ยังรวมไปถึงขั้นตอนการลงทะเบียนและการรีเซ็ตรหัสผ่านด้วย อย่าเปิดเผยว่าอีเมลนั้นมีอยู่ในระบบของคุณแล้วหรือไม่ การใช้ข้อความกลางๆ เพียงข้อความเดียวจะช่วยกำจัดช่องโหว่การรั่วไหลของข้อมูลที่ผู้โจมตีใช้ประโยชน์ได้

การโจมตีด้วยเวลา (Timing Attacks) ในการเปรียบเทียบรหัสผ่าน

บั๊กที่สามนั้นมองไม่เห็นด้วยตาเปล่า ผมกำลัง