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

guest@websec ~ % man 02-sql-injection

02 · SQL Injection

SQL injection คือการที่ input ของผู้ใช้กลายเป็นส่วนหนึ่งของโค้ดใน query ฐานข้อมูล โค้ดผิดบรรทัดเดียวอาจทำให้ผู้โจมตีล็อกอินเป็นใครก็ได้ อ่านได้ทุกตาราง หรือแก้ข้อมูลของเรา ทีมเราเคยโดนมาแล้ว บทนี้จึงมาก่อน

อย่าสร้าง SQL ด้วยการต่อสตริง ให้ส่งค่าจากผู้ใช้เป็น parameter แล้วปล่อยให้ Active Record จัดการเรื่อง quote ให้

2.1 SQL injection คืออะไร

แอปคุยกับฐานข้อมูลด้วยการส่งข้อความ SQL ไป ถ้าเราสร้างข้อความนั้นด้วยการแปะสิ่งที่ผู้ใช้พิมพ์ลงไป ผู้ใช้ก็กำลังเขียน query ส่วนหนึ่งของเราอยู่ ฐานข้อมูลแยกไม่ออกว่าส่วนไหนของเรา ส่วนไหนของเขา

ตัวอย่าง: หน้าล็อกอินที่รัน WHERE name = '#{params[:name]}' ผู้ใช้ปกติพิมพ์ alice แต่ผู้โจมตีพิมพ์ ' OR '1'='1 query จึงกลายเป็น WHERE name = '' OR '1'='1' ซึ่งเป็นจริงกับทุกแถว

# VULNERABLE: string interpolation into SQL
User.where("name = '#{params[:name]}' AND password = '#{params[:password]}'").first
Product.find_by_sql("SELECT * FROM products WHERE title LIKE '%#{params[:q]}%'")
Order.joins("JOIN users ON users.id = #{params[:uid]}")
// VULNERABLE: string concatenation
const sql = "SELECT * FROM users WHERE name = '" + req.body.name +
            "' AND password = '" + req.body.password + "' LIMIT 1";
# VULNERABLE: f-string / % formatting
sql = f"SELECT * FROM users WHERE name = '{name}' AND password = '{password}' LIMIT 1"
cursor.execute(sql)
ตัวสำคัญคือเครื่องหมาย ' มันปิดสตริงของเราก่อนเวลา แล้วทุกอย่างหลังจากนั้นถูกอ่านเป็น SQL

2.2 ผู้โจมตีทำอะไรได้บ้าง

ล็อกอินโดยไม่ต้องใช้รหัสผ่าน

ใส่ ' OR '1'='1 ทั้งสองช่อง เงื่อนไขจะเป็นจริงเสมอ และได้เข้าระบบเป็นผู้ใช้คนแรก ซึ่งมักเป็น admin ส่วน admin'-- ก็ใช้ได้: -- คือจุดเริ่ม comment ใน SQL การเช็กรหัสผ่านจึงถูกข้ามไป

อ่านข้อมูลที่ไม่ควรเห็น

UNION SELECT ต่อ query ที่สองเข้ากับ query แรก การค้นหาสินค้าจึงอาจคืนตารางผู้ใช้ออกมา รวมถึง hash ของรหัสผ่านด้วย

ถามคำถามแบบใช่/ไม่ใช่ (blind SQL injection)

การซ่อน error และผลลัพธ์ไม่ได้หยุดมัน ถ้าหน้าเว็บตอบต่างกันแม้นิดเดียว (ล็อกอินได้หรือไม่ได้ มีผลลัพธ์หรือไม่มี ตอบเร็วหรือช้า) แต่ละคำขอก็ตอบคำถามใช่/ไม่ใช่ได้หนึ่งข้อ เช่น "รหัสผ่านของ admin ขึ้นต้นด้วย S ไหม" เครื่องมืออย่าง sqlmap ถามแบบนี้เป็นพันๆ ครั้งโดยอัตโนมัติ แล้วดึงข้อมูลออกมาได้ทั้งตาราง

แก้ไขหรือทำลายข้อมูล

ขึ้นอยู่กับฐานข้อมูลและสิทธิ์ของผู้ใช้ฐานข้อมูลที่แอปใช้ injection ยังอาจแก้แถวข้อมูล (ตั้งผู้โจมตีเป็น admin) ลบข้อมูล หรืออ่านไฟล์บนเครื่องฐานข้อมูลได้ด้วย

ลองเองได้เลย นี่คือ SQL engine ตัวจิ๋วที่ทำงานจริงในเบราว์เซอร์ของคุณ สลับระหว่างการต่อสตริงกับ query แบบ parameterize แล้วดูว่าอะไรเปลี่ยน ปุ่ม "blind" สองปุ่มถามคำถามใช่/ไม่ใช่เกี่ยวกับรหัสผ่านของ admin: ถ้าล็อกอินได้แปลว่าใช่

sim: SQL engine ตัวจิ๋วที่ทำงานจริงquery ถูก parse และรันกับตารางในหน่วยความจำ
payloads:
payloads:

2.3 ทางแก้: query แบบ parameterize

อย่าต่อ input เข้าไปใน SQL ให้ใช้ parameter ใน query แบบ parameterize ข้อความ SQL กับค่าต่างๆ ถูกส่งไปฐานข้อมูลแยกกัน ฐานข้อมูลอ่าน SQL ก่อน แล้วค่อยใส่ค่าเข้าไป ค่าจึงไม่มีทางกลายเป็นโค้ด ไม่ว่าจะมีตัวอักษรอะไรอยู่ข้างใน

การลบเครื่องหมาย quote เอง (gsub("'", "")) ไม่ใช่ทางแก้ พลาดบางกรณีได้ง่าย และไม่ช่วยอะไรเลยกับตัวเลข ชื่อคอลัมน์ หรือ pattern ของ LIKE

Rails ทำ parameterize ให้เมื่อใช้เงื่อนไขแบบ hash (where(name: x), find_by(name: x)) และ placeholder (? หรือ :name) อันตรายคือสตริงที่เราสร้างเองด้วย #{} แล้วส่งให้ where, order, find_by_sql, joins หรือ execute Brakeman จับได้เกือบหมด
# hash conditions and placeholders are parameterized
User.find_by(name: params[:name])
Product.where("title LIKE ?", "%#{Product.sanitize_sql_like(params[:q])}%")
Product.where("price < :max AND stock > :min", max: params[:max], min: 0)

# identifiers (columns, directions) can't be bound: allowlist them
SORTS = { "newest" => { created_at: :desc }, "price" => { price: :asc } }
Product.order(SORTS.fetch(params[:sort], { id: :asc }))
// node-postgres: $1, $2 placeholders
await db.query("SELECT * FROM users WHERE name = $1 AND active = $2", [name, true]);
// mysql2
await conn.execute("SELECT * FROM products WHERE title LIKE ?", [`%${q}%`]);
// Prisma tagged template (parameterized automatically)
await prisma.$queryRaw`SELECT * FROM users WHERE email = ${email}`;
// identifiers (column/table names) can't be parameters: map them from an allowlist
const col = { newest: "created_at", price: "price" }[req.query.sort] ?? "id";
# DB-API: let the driver bind values (never use % or f-strings yourself)
cursor.execute("SELECT * FROM users WHERE name = %s AND active = %s", (name, True))
# SQLAlchemy
session.execute(text("SELECT * FROM products WHERE title LIKE :q"), {"q": f"%{q}%"})
# Django ORM
User.objects.filter(name=name)
# identifiers can't be parameters: map them from an allowlist
col = {"newest": "created_at", "price": "price"}.get(request.GET.get("sort"), "id")

ชื่อคอลัมน์และทิศทางการเรียงใช้เป็น parameter ไม่ได้ ให้แปลงตัวเลือกของผู้ใช้เป็นรายการที่กำหนดไว้ล่วงหน้า (allowlist) เหมือนตัวอย่าง SORTS ด้านบน

2.4 จุดที่ SQL injection ซ่อนอยู่ในโค้ด Rails

Active Record ปลอดภัยเมื่อเราส่ง ค่า ให้ และไม่ปลอดภัยเมื่อเราส่ง สตริง SQL ที่สร้างเอง นี่คือ pattern ที่ต้องมองหาตอน code review:

methodไม่ปลอดภัย (สตริงที่มี input ผู้ใช้)ปลอดภัย
where, not, rewhere, find_bywhere("email = '#{params[:email]}'")where(email: params[:email])
where("email = ?", params[:email])
order, reorderorder("#{params[:sort]} #{params[:dir]}")allowlist: order(SORTS.fetch(params[:sort], { id: :asc }))
select, pluck, group, having, from, joins, lockpluck(params[:column])symbol ที่เราเขียนเอง: pluck(:name)
sum, average, minimum, maximum, calculateOrder.sum(params[:field])Order.sum(:total)
update_all, delete_by, destroy_byupdate_all("status = '#{s}'")update_all(status: s)
find_by_sql, count_by_sqlfind_by_sql("… WHERE id = #{x}")find_by_sql(["… WHERE id = ?", x])
connection.execute, exec_query, select_allexecute("DELETE … #{x}")สร้างด้วย sanitize_sql_array(["… ?", x]) หรือใช้ API ของ model
Arel.sqlArel.sql(params[:anything]) บอก Rails ว่า "นี่คือ SQL ที่เชื่อถือได้"ใช้กับสตริงคงที่ที่เราเขียนเองเท่านั้น

กับดักอื่นๆ:

  • รายการ: where("id IN (#{ids.join(',')})") โดน inject ได้ ให้ใช้ where(id: ids)
  • การค้นหาด้วย LIKE: % และ _ เป็น wildcard ให้ bind ค่าแล้วครอบด้วย sanitize_sql_like การค้นหาคำว่า % จะได้ไม่ตรงกับทุกแถว
  • Second-order injection: ข้อมูลที่บันทึกอย่างปลอดภัยแล้วยังอันตรายได้ทีหลัง ถ้า background job หรือรายงานสร้าง SQL จากค่าที่เก็บในฐานข้อมูล (ตัวกรองที่บันทึกไว้ ชื่อผู้ใช้) ให้ถือว่าค่านั้นเป็น input จากผู้ใช้ด้วย
Rails รุ่นใหม่ปฏิเสธ SQL ดิบส่วนใหญ่ใน order และ pluck ถ้าไม่ครอบด้วย Arel.sql นั่นเป็นแค่ตาข่ายรองรับ ไม่ใช่เหตุผลให้ข้าม allowlist

2.5 การค้นหาในโค้ดของเรา

  1. รัน Brakeman แล้วอ่านคำเตือน "SQL Injection" ทุกข้อ รวมถึงข้อที่ระดับความมั่นใจต่ำ แอป Rails ใหม่มี bin/brakeman มาให้ แอปเก่าเพิ่ม gem เองได้ ให้รันใน CI เพื่อให้คำเตือนใหม่ทำให้ build ล้ม
  2. ค้นหาการแทรกค่าใน method ที่สร้าง query ทุก #{ ที่อยู่ในสตริง SQL ควรถูกตรวจซ้ำ
  3. กฎการรีวิว: ห้าม merge ถ้า pull request สร้าง SQL ด้วย #{} หรือ + ยกเว้นค่ามาจาก allowlist คงที่ในไฟล์เดียวกัน
# only the SQL injection check, all confidence levels
bin/brakeman -t SQL -w1

# query methods that receive an interpolated string
grep -rnE '\.(where|not|find_by|order|reorder|group|having|select|pluck|joins|from|lock|update_all|delete_by|destroy_by|find_by_sql|count_by_sql|execute|exec_query|select_all|sum|calculate)[ (].*#\{' app lib

# raw SQL that someone marked as trusted
grep -rn 'Arel.sql' app lib

2.6 การจำกัดความเสียหาย

query แบบ parameterize คือทางแก้ ส่วนข้างล่างนี้เป็นชั้นป้องกันเสริมสำหรับวันที่บั๊กหลุดไปได้อยู่ดี:

  • ผู้ใช้ฐานข้อมูลที่มีสิทธิ์น้อยที่สุด ผู้ใช้ฐานข้อมูลของแอปไม่ควรเป็น superuser หรือเป็นเจ้าของ schema บน production ให้รัน migration ด้วยผู้ใช้อีกตัว และให้เครื่องมือทำรายงานใช้ผู้ใช้แบบอ่านอย่างเดียว
  • อย่าแสดง error ของฐานข้อมูลให้ผู้ใช้เห็น ข้อความ error ช่วยให้ผู้โจมตีรู้โครงสร้างตารางของเรา ตั้ง consider_all_requests_local = false บน production (หัวข้อ 19)
  • แจ้งเตือนเมื่อมี SQL error error ActiveRecord::StatementInvalid ที่พุ่งขึ้นผิดปกติมักแปลว่ามีคนกำลังลองเจาะ
  • เข้ารหัสคอลัมน์ที่อ่อนไหวที่สุด เพื่อให้ข้อมูลที่ถูก dump ออกไปเห็นแค่ ciphertext (หัวข้อ 16)
  • WAF ช่วยได้ แต่ไม่ใช่ทางแก้ ผู้โจมตีมีหลายวิธีอำพราง payload
Rails เลือกสิทธิ์ฐานข้อมูลหรือระบบแจ้งเตือนแทนเราไม่ได้ ส่วนนี้คือการตั้งค่าและกระบวนการของเราเอง

2.7 ถ้าเกิดขึ้นอีก

เช็กลิสต์สั้นๆ ทำตามลำดับ หัวข้อ 21 พูดถึงการรับมือเหตุการณ์แบบทั่วไป

  1. ควบคุม: บล็อก IP ที่โจมตี หรือปิดฟีเจอร์ที่มีช่องโหว่ไว้ก่อน hotfix แบบเร็วๆ ก็ได้
  2. แก้บั๊ก ด้วย parameter แล้วค้นทั้ง codebase หา pattern เดียวกัน (ใช้คำสั่งด้านบน) บั๊ก injection หนึ่งตัวมักมีพี่น้อง
  3. หาว่าข้อมูลอะไรถูกอ่านหรือถูกแก้ ค้น log ของเว็บและฐานข้อมูลหา URL ที่มีช่องโหว่ และ payload อย่าง ', UNION, --, SLEEP หรือ pg_sleep
  4. หมุนเวียนทุกอย่างในฐานข้อมูลที่ใช้เข้าถึงระบบได้: API token, OAuth token, token รีเซ็ตรหัสผ่าน และรหัสผ่านของฐานข้อมูลเอง
  5. ปกป้องผู้ใช้: ถ้า hash ของรหัสผ่านอาจรั่ว ให้บังคับรีเซ็ตรหัสผ่าน bcrypt ทำให้ผู้โจมตีช้าลง แต่รหัสผ่านที่อ่อนก็ยังแตกได้
  6. แจ้ง: ข้อมูลส่วนบุคคลที่รั่วอาจต้องแจ้ง สคส. ภายใน 72 ชั่วโมง (PDPA) ดึงฝ่ายกฎหมายเข้ามาตั้งแต่เนิ่นๆ
  7. เรียนรู้: เพิ่ม regression test ที่ส่ง payload ของการโจมตี และเพิ่ม Brakeman ใน CI ถ้ายังไม่มี