View Source, คัดลอกคีย์ แล้วเป็นเจ้าของทุกอย่าง
นักวิจัยความปลอดภัยคนหนึ่งเปิดดู page source ของ ClickUp พบ API key ที่ hardcode ไว้ใน JavaScript แล้วใช้คีย์นั้นดึงอีเมล 959 รายการและ feature flag ภายในอีก 3,165 รายการมาได้ในคำขอเดียว คีย์นั้นไม่มี scope ไม่มี rate limit และไม่มีวันหมดอายุ
นักวิจัยความปลอดภัยคนหนึ่งเข้าไปที่ clickup.com เปิดดู page source พบ API key ที่ hardcode อยู่ใน JavaScript คัดลอกมัน แล้วส่ง GET request หนึ่งครั้ง
ได้กลับมาเป็นอีเมล 959 รายการและ feature flag ภายใน 3,165 รายการ เป็นอีเมลของพนักงานจาก Home Depot. Fortinet. Autodesk. Tenable. Rakuten. Mayo Clinic.
สตริงเดียว. คำขอเดียว. ได้ทุกอย่าง.
เหตุการณ์แบบนี้เกิดขึ้นได้อย่างไร
มีคนต้องการให้ frontend เรียก API ตัวหนึ่ง API นั้นต้องมีการยืนยันตัวตน เลยใส่คีย์ลงไปใน JavaScript ปล่อยขึ้น production แล้วก็ไปต่อ สปรินต์ถัดไป
นี่ไม่ใช่การโจมตีที่ซับซ้อน ไม่มี exploit ไม่มี zero-day ไม่มี social engineering มีแค่ view-source: กับ curl เบราว์เซอร์กับเทอร์มินัล เป็นอะไรที่เด็กฝึกงานอยากรู้อยากเห็นก็ทำได้ตั้งแต่วันแรกที่เข้ามาทำงาน
คีย์นั้นไม่มี scope — เข้าถึงได้ทุกอย่างที่ API เปิดเผยออกมา ไม่มี rate limit — คำขอเดียวก็คืนค่าทุกอย่างกลับมา ไม่มีวันหมดอายุ — คีย์ใช้งานได้เรื่อย ๆ จนกว่าจะมีใครสังเกตเห็น ไม่มีปัจจัยที่สอง — การครอบครองสตริงนั้นเป็นด่านเดียว
มุมมองเรื่องมูลค่า
คนมักพูดถึงเรื่องข้อมูลรั่วไหล ลองมาดูว่าข้อมูลชุดนี้มีมูลค่าเท่าไรครับ
อีเมลขององค์กร 959 รายการจากบริษัท Fortune 500 นี่คือรายชื่อเป้าหมายสำหรับ spear-phishing ที่ผู้คุกคามยอมจ่ายเงินซื้อ ชื่อ ตำแหน่ง และข้อเท็จจริงว่าบริษัทเหล่านี้ใช้ ClickUp — นั่นคือบริบทสำหรับ social engineering ที่ทำให้ phishing ได้ผล
feature flag ภายใน 3,165 รายการ นั่นคือ roadmap บอกคู่แข่งได้ว่า ClickUp กำลังสร้างอะไร กำลังทดสอบอะไร และมีอะไรอยู่หลังการเปิด-ปิดฟีเจอร์ บอกผู้โจมตีได้ว่าฟีเจอร์ไหนยังสร้างไม่เสร็จและน่าจะมีช่องโหว่
นี่ไม่ใช่เหตุการณ์ด้านความเป็นส่วนตัว แต่เป็นข้อมูลธุรกิจรั่วไหล
ทำไมเรื่องนี้ถึงเกิดซ้ำอยู่เรื่อย
นี่คือเหตุการณ์ credential อยู่ใน source ครั้งที่สี่ที่เราเขียนถึงในเดือนนี้ Bitwarden's CLI ถูกเก็บ credential ไปได้เพราะเก็บไว้เป็นไฟล์ plaintext Vercel's environment variables ถูกถอดรหัสได้เพราะ flag "sensitive" ไม่ได้เป็นค่าเริ่มต้น นักพัฒนาคนหนึ่งสูญเสียรหัสผ่าน Chrome ไป 634 รายการเพราะคีย์ถอดรหัสอยู่บนดิสก์เดียวกัน
รูปแบบมันเหมือนกันทุกครั้ง: credential ดำรงอยู่ในรูปของสตริง — ในไฟล์ ในตัวแปร ใน page source — แล้วมีบางอย่างอ่านมัน สิ่งที่เปลี่ยนไปคือ "บางอย่าง" นั้น ส่วนรูปแบบไม่เคยเปลี่ยน
API key ใน JavaScript เป็นเวอร์ชันที่เห็นได้ชัดที่สุด เพราะไม่ต้องมีการโจมตีด้วยซ้ำ คีย์นั้นถูกเผยแพร่ออกมา มันถูกส่งไปให้ผู้เยี่ยมชมทุกคน เบราว์เซอร์ดาวน์โหลดมัน แสดงผลมัน แล้วโชว์ให้ใครก็ตามที่คลิกขวาดูได้
ควรทำให้ต่างออกไปอย่างไร
การเรียก API นั้นไม่ควรยืนยันตัวตนด้วย static key จากฝั่ง client เลย ทางเลือกที่ทำได้มีดังนี้ครับ
- Backend proxy. frontend เรียก backend ของคุณเอง ซึ่งเก็บคีย์ไว้ฝั่ง server แล้วทำหน้าที่เป็นตัวกลางเรียก API ให้ คีย์ไม่เคยไปถึงเบราว์เซอร์
- Session-scoped tokens. frontend จะได้ token อายุสั้นและมี scope แคบมากหลังการยืนยันตัวตน มันหมดอายุเอง ทำได้เฉพาะสิ่งที่ผู้ใช้ซึ่งยืนยันตัวตนแล้วได้รับอนุญาตให้ทำเท่านั้น ไม่ใช่ master key
- No key at all. ถ้าข้อมูลเป็นแบบสาธารณะ ก็เสิร์ฟโดยไม่ต้องยืนยันตัวตน ถ้าไม่ใช่ ก็อย่าเสิร์ฟให้ JavaScript ที่ยังไม่ได้ยืนยันตัวตน
การ hardcode API key ไว้ในโค้ดฝั่ง client ก็เหมือนการเอาลูกกุญแจบ้านไปซ่อนใต้พรมเช็ดเท้า แล้วประกาศที่อยู่บ้านตัวเอง
ปัญหาเรื่องวงจรชีวิตของ credential
API key ของ ClickUp ตัวนี้น่าจะถูกสร้างขึ้นครั้งเดียว วางลงในไฟล์ JavaScript commit เข้า repo deploy ขึ้น production แล้วก็ไม่มีใครนึกถึงมันอีกเลย ไม่มีใครหมุนเวียนมัน ไม่มีใครจำกัด scope ไม่มีใครตั้งวันหมดอายุ ไม่มีใครเฝ้าดูว่ามันเข้าถึงอะไรไปบ้าง
นั่นคือวงจรชีวิตของ API key ส่วนใหญ่ในองค์กรส่วนใหญ่ ถูกสร้างอย่างเร่งรีบ วางไว้ตรงที่ต้องใช้ แล้วถูกลืม มันสะสมอยู่ตาม codebase ไฟล์ config ท่อ CI/CD และอย่างที่เห็น คือ page source — แต่ละตัวเป็นประตูที่ไม่เคยล็อก
คำถามไม่ใช่ว่าองค์กรของคุณมีคีย์แบบนี้อยู่หรือเปล่า แต่คือมีอยู่กี่ตัว และคุณจะรู้หรือไม่ ถ้าวันนี้มีคนคัดลอกมันไปสักตัว