m0leCon 2022 Beginner CTF Writeup
まえがき
少し前からCTFを勉強し始めていて、今回初めて大会に参加しました。
同日開催されていたSECCON2022は全く解けなかったのが悔しかったです。
さて、参加した大会は
m0leCon 2022 Beginner CTFというもので、イタリアで開かれるカンファレンスの企画のひとつみたいです。
全15問中7問正解し、タイムアップ後に1問正解しました。
順位は570チーム中38位でした。
では、ここからWriteUpです。正解した問題のみになります。
Sanity Check
miscカテゴリです。
cgz{j3yp0zr_g0_gu3_o3t1aa3e_pgs}
この文字だけ与えられています。
なんとなくシーザー暗号っぽいので、CyberChefにかけてみます。

当たりでした。
Begginerだけあって最初は簡単ですね。
m0leadventures
reverseカテゴリです。
adventureというバイナリが与えられています。
バイナリなのでGhidraにかけてみました。

関数をなめていくと、どうやらPyinstallerでPythonを固めたバイナリであることが分かりました。
Pythonのデコンパイル方法を調べたら、こんなツールがありました。
python pyinstxtractor.py adventure
リポジトリをクローンして、上記のコマンドを実行します。
Python3.10だとエラーが発生しましたが、3.7だとうまくいきました。

成功すると、このように、pycファイルが生成されます。
これをデコンパイルします。
デコンパイルにはuncompyle6ライブラリを使用するようです。
pip install uncompyle6
pipでインストールすると、uncompyle6コマンドが使えるようになります。
さきほどのフォルダにadventure.pycファイルが生成されているので、これをデコンパイルしましょう。
>uncompyle6 adventure.pyc
~~~ 中略 ~~~
def givePiece(player, piece):
key = b'cp\xc8\xec\xb0\xcc\x8e\x13\xa7\xcb0b\xd5\xf6sz\x13\x0eK\xed\xd52\x14\xe8e\x05\xb8\x93\x03\xf7QB\xfb\xde\xc9\xc4\x12\xeb'
enc = b'\x13\x04\xa5\x97\xc7\xa4\xbeL\xc1\xfa^\x06\xa6\xa9G%ub\x7f\x8a\x8aT%\x86\x01v\xe7\xa7\\\xc0#q\xcf\xad\xbc\xb6!\x96'
if piece == 1:
player.inventory['First piece'] = [
1, 'legendary artifact', xor(key, enc).decode()[:13]]
else:
if piece == 2:
player.inventory['Second piece'] = [
1, 'legendary artifact', xor(key, enc).decode()[13:]]
givePiece関数の中に怪しげなバイト列があります。
legendary artifactなんてワクワクしますね。
xor(~~)を実行してみましょう。
>>> key = b'cp\xc8\xec\xb0\xcc\x8e\x13\xa7\xcb0b\xd5\xf6sz\x13\x0eK\xed\xd52\x14\xe8e\x05\xb8\x93\x03\xf7QB\xfb\xde\xc9\xc4\x12\xeb'
>>> enc = b'\x13\x04\xa5\x97\xc7\xa4\xbeL\xc1\xfa^\x06\xa6\xa9G%ub\x7f\x8a\x8aT%\x86\x01v\xe7\xa7\\\xc0#q\xcf\xad\xbc\xb6!\x96'
>>> def xor(ba1, ba2):
... return bytes([_a ^ _b for _a, _b in zip(ba1, ba2)])
...
>>> xor(key, enc).decode()[:13]
'ptm{wh0_f1nds'
>>> xor(key, enc).decode()[13:]
'_4_fl4g_f1nds_4_7r34sur3}'
>>> xor(key, enc).decode()[:13] + xor(key, enc).decode()[13:]
'ptm{wh0_f1nds_4_fl4g_f1nds_4_7r34sur3}'
unrecognizeable
cryptoカテゴリです。
2つの画像が与えられています。


似たような画像に、一部色がついていますね。
減算フィルタ的なものをかけてみると何かわかるかもしれません。

合成モードを差の絶対値にすると、文字が浮き上がってきました。
自分はPhotoshop使いましたが、Gimpでもいけるはずです。
Floppy Bird
webカテゴリです。

こういうゲームで1000点を目指します。
スコアが1000点以上になったらフラグが表示されますが、直接1000点を書き込もうとするとエラーになってしまいます。
なので、地道にスコアを稼ぐ方法を考えないといけません。
function updateScore(score) {
if (!token) {
let xhr = new XMLHttpRequest();
xhr.open("GET", "http://" + window.location.host + "/get-token");
xhr.send();
xhr.onload = function() {
let res = JSON.parse(xhr.response);
if (!res.ok) {
window.location.href = "/error.html?error=" + res.error;
return;
}
token = res.token;
sendUpdateScore(score);
};
} else {
sendUpdateScore(score);
}
}
トークンを生成するREST APIが書かれています。
function sendUpdateScore(score) {
let xhr = new XMLHttpRequest();
xhr.open("POST", "http://" + window.location.host + "/update-score");
xhr.setRequestHeader("Content-Type", "application/json");
xhr.send(JSON.stringify({
token: token,
score: score
}));
xhr.onload = function() {
res = JSON.parse(xhr.response);
if (!res.ok) {
window.location.href = "/error.html?error=" + res.error;
return;
} else if (res.flag) {
window.location.href = "/flag.html?flag=" + res.flag;
return
}
};
}
こちらはトークンを使って点数を更新するREST APIが書かれています。
最初に
GET http://floppybird.challs.m0lecon.it/get-token
すると、以下の応答が返ってきます
{
"ok": true,
"token": "b73113bb5b0bea2598b91e140ea49e8a"
}
次に、
POST http://floppybird.challs.m0lecon.it/update-score
します。
ボディを
{
"token": "b73113bb5b0bea2598b91e140ea49e8a",
"score": 1024
}
このようにするのですが、いきなり大きなscoreを指定するとエラーになってしまいます。
ここで、サーバーサイドのコードを見てみましょう。
def get_token():
token = os.urandom(16).hex()
db.execute('INSERT INTO scores VALUES (?, ?)', (token, 0))
db.commit()
return {'ok': True, 'token': token}
def update_score():
# 中略
if client_score == db_score + 1 or client_score == db_score * 2:
db.execute(
'UPDATE scores SET score = ? WHERE token = ?',
(client_score, request.json['token'])
)
elif client_score > db_score:
return {'ok': False, 'error': 'An invalid score was given, are you trying to hack me?'}, 400
get_tokenで、DBにscore:0がセットされ、
update_scoreで、更新されるスコアがDBのスコアに1足したものか、2倍の場合は、スコアを更新できるみたいです。(なんでこんな仕様!?)
なので、スコア更新のAPIのボディを、
1→2→4→8→・・・→1024まで変えながら実行すると、フラグをゲットできます。

ptmSafe
reverseカテゴリです。
ゆっくり解いてたら時間切れになってて悔しかった問題です。
バイナリが与えられてるのでghidraにかけます。

まずmain関数です。
scanfの文字列がptm{.+}の形式だったらcheckPassword()を実行するようです

さすがに見にくいので整形します。
undefined8 checkPassword(byte *input_flag)
{
byte bVar1;
int index;
int i;
index = 4;
do {
if (14 < index) {
return 0;
}
switch(index) {
case 4:
if ((*input_flag ^ input_flag[index]) != 0x1e) { 'n'
return 1;
}
break;
case 5: '0'
if (input_flag[index] != 0x30) { '0'
return 1;
}
break;
case 6:
if (((char)input_flag[(long)index + -2] * 3 ^ (int)(char)input_flag[index]) != 0x13e) { 't'
return 1;
}
break;
case 7:
if (input_flag[index] != 0x5f) { '_'
return 1;
}
break;
case 8:
if ((input_flag[(long)index + 4] ^ input_flag[index]) != 0x47) { 's'
return 1;
}
break;
case 9:
if ((int)(char)input_flag[index] * (int)(char)input_flag[index] * (int)(char)input_flag[index]
!= 0x1b000) { '0'
return 1;
}
break;
case 10:
if ((int)(char)input_flag[index] + (int)(char)input_flag[(long)index + 1] !=
(char)input_flag[4] + 100) {
return 1;
}
break;
case 11:
if (4 < (int)(char)input_flag[(long)index + -3] * (int)(char)input_flag[index] - 0x33a9U) {
return 1;
}
break;
case 12:
if (((int)(char)input_flag[index] & 0x3fffffffU) != 0x34) { '4'
return 1;
}
break;
case 13:
if (input_flag[index] != 0x66) { 'f'
return 1;
}
break;
case 14:
bVar1 = 0;
for (i = 0; i < 16; i = i + 1) {
bVar1 = bVar1 ^ input_flag[i];
}
if (bVar1 != 0x14) {
return 1;
}
}
index = index + 1;
} while( true );
}
一発で決まる文字もあれば、一意に決まらない文字もありました。
一発で決まったのはこのくらいでした。xが決まらない文字列です。
ptm{n0t_s0xx4fx}
上のコードの条件に合致する文字列を羅列するコードを書いてみます。
import string
for e in string.printable:
if 4 > ord('s') * ord(e) - 0x33a9:
flag = 'ptm{n0t_s0'+chr(ord('n') + 100 - ord(e))+e+'4f'
for e in string.printable:
flag_for = flag + e + '}'
v = 0
for ee in flag_for:
v = v ^ ord(ee)
if v == 20:
print(flag_for)
結果は以下のものが出力されます(重複は除く)
}tm{n0t_s0pb4f
}tm{n0t_s0`r4f
ptm{n0t_s0_s4f3}
ptm{n0t_s0S4f3}
ptm{n0t_s0~T4f5}
ptm{n0t_s0}U4f7}
ptm{n0t_s0|V4f5}
ptm{n0t_s0{W4f3}
ptm{n0t_s0zX4f=}
ptm{n0t_s0yY4f?}
ptm{n0t_s0xZ4f=}
ptm{n0t_s0w[4f3}
ptm{n0t_s0v\4f5}
ptm{n0t_s0u]4f7}
ptm{n0t_s0t^4f5}
ptm{n0t_s0s_4f3}
}tm{n0t_s0r`4f
この中で意味が通るものはptm{n0t_s0_s4f3}になります(not so safe)
f33linegCut3
miscカテゴリです。
Aperi'Solveというとんでもないサイトがあります。
ステガノグラフィ全般を解析できます。
画像をこのツールにかけると、zipファイルが見つかりました。

ちなみに、CyberChefでは見つからず…どうして

さて、message.zipを解凍しようとするとパスワードがかかっていました。

rockyouを試してみたのですが、合致するものはありませんでした。
Aperi'Solveをよく見ていると、CommonPasswordの欄が。

試しにTheMightyPawを入力してみると、なんと正解!
無事フラグを開くことができました

Aperi'Solveは便利ですが、特に理屈を知らずに解析できちゃうのがあまり良くないですね。
仕組みの理解も進めていきたいです。
File Recovery
miscカテゴリです。
pcapファイルなので、wiresharkを使います。

全体的にtcpですね。
一番上からtcp追跡しましょう。

チャットの内容が読めますね。
フィルタのtcp.stream eq 0の数字を増やしていくと、続きが見れます。

長い文字列が現れました。これはBase64でしょうか。解析してみましょう

ヘッダにJFIFの文字があるので、JPEGファイルみたいです。
CyberChefのRender Imageをつなげると、画像を表示できます。

釣り画像だった…
めげずにpcapの続きを解析しましょう。

フラグを見つけました!根気よく解析を続けるのが大事です。
WikiPTM
webカテゴリです。
残念ながらサーバーがダウンしていたので、解法だけ記します。
jsファイルを眺めると、cookieをデコードして管理者ログインしているかどうか判定している処理が書いてありました。
cookieを参照すると、
eyJpbmRleCI6ImluZGV4Lmh0bWwiLCJkYXRlIjoiMjAyMi0xMS0xMlQxMzoxMToxMi40NjZaIiwiaXNBZG1pbiI6ZmFsc2V9
だったはず。
js中でbase64デコードしていたので、やってみます。

isAdminのフラグがあります。
これをtrueにしたものをcookieにして書き換えてあげましょう。

cookieの書き換えは開発者ツールのアプリケーションタブから行えます。

書き換え後、ページを再読み込みすると、管理者ログイン状態になっていたはずです。
ページをたどっていくとフラグがありました。
所感
初めてのCTF大会参加でしたが、Beginner向けということもあり、思ったより解けたという印象でした。
あと、自分はCryptがまだまだ苦手だな、というのも再認識…
はやくBegginerから脱却したいですね。
暗号技術入門も買ったので、地道に勉強しようと思います。
Discussion