SECCON Beginners CTF 2021(#ctf4b) writeup
結果はこんな感じでした
チーム名: SatoHack365
順位: 144
チームで解けた問題数: 13
チームで解けたスコア: 1110
自分が解けた問題数(メンバーが先に解いたものを含む): 8
自分が解けた問題のスコア: 575
去年は出てませんが,一昨年は 0 完だったので結構成長していました.
crypto
simple_RSA (75 pt)
受け取ったファイルを見ると公開鍵e
が 3 と,ものすごく小さいです(一般的には 65537 など).Low Public Exponent Attack ができます.
import sys
import gmpy2
def long_to_bytes(x):
return x.to_bytes((x.bit_length() + 7) // 8, 'big')
def low_public_exponent_attack(c, e, n=0):
while True:
m, exact_root = gmpy2.iroot(c, e)
if exact_root == True:
break
c += n
return int(m)
if __name__ == '__main__':
n = ファイルに書かれた n
e = 3
c = ファイルに書かれた c
m = low_public_exponent_attack(c, e, n)
flag = long_to_bytes(m).decode().strip()
print(flag)
ctf4b{0,1,10,11...It's_so_annoying.___I'm_done}
web
osoba (51 pt)
app.py のコードをみます.
page = request.args.get('page', 'public/index.html')
response = make_response(send_file(page))
ここでは URL クエリパラメータpage
に与えられた値のパスにあるファイルを,そのまま出力しているみたいです.デフォルトではpublic/index.html
つまり index.html をそのまま出力します.よってディレクトリトラバーサル攻撃ができます.
現在のルートディレクトリはsrc
のようなので,https://osoba.quals.beginners.seccon.jp/?page=../flag
にアクセスするとフラグが入手できます.
ctf4b{omisoshiru_oishi_keredomo_tsukuruno_taihen}
Werewolf (70 pt)
player.role
を WEREWOLF
に変えることができればフラグが取れるらしいです.ここで,二つ確認すべき点があります.
1) Python には完全なプライベート変数は存在しません.
以下のコードを見てみます.
class Player:
def __init__(self):
self.__role = "VILLAGER"
@property
def role(self):
return self.__role
このコードでplayer = Player()
とした後にplayer.role
の値は_Player__role
とするとプライベート変数なのに取得することができます.カスだね,Python ちゃん.
2) player.__dict__[k] = v
でキーk
値v
の変数を作成する.もし存在していれば上書き
よって以下のように POST を遅れば flag が取れます.
curl -w '\n' 'https://werewolf.quals.beginners.seccon.jp/' --data '_Player__role=WEREWOLF' -XPOST
ctf4b{there_are_so_many_hackers_among_us}
cant_use_db (108 pt)
品物を買った後に走るスクリプトに
time.sleep(random.uniform(-0.2, 0.2) + 1.0)
という箇所があります.データベースを使用していないため,排他制御を気にしているのか 1 秒待っているみたいです.逆に言うと,お金があるかちゃんと確認してから 1 秒後にお金が取られます.そこで,このスリープ時間に急いでクリックして,もう一個リクエスト送ったら balance の値は不定になりそうですが,soup と noodles はちゃんと増えます.
コツは Soup → Noodles の順で早く押すとだいたい Balance が $10000 になるので,あとはゆっくり Noodles を買って Flag をもらいに行きます.
ctf4b{r4m3n_15_4n_3553n714l_d15h_f0r_h4ck1n6}
misc
git-leak (58 pt)
与えられたファイルに書いてあるreflog
コマンドを使ってみます.
e0b545f (HEAD -> master, origin/master) HEAD@{0}: commit (amend): feat: めもを追
80f3044 HEAD@{1}: commit (amend): feat: めもを追加
b3bfb5c HEAD@{2}: rebase -i (finish): returning to refs/heads/master
b3bfb5c HEAD@{3}: commit (amend): feat: めもを追加
7387982 HEAD@{4}: rebase -i: fast-forward
36a4809 HEAD@{5}: rebase -i (start): checkout HEAD~2
7387982 HEAD@{6}: reset: moving to HEAD
7387982 HEAD@{7}: commit: feat: めもを追加
36a4809 HEAD@{8}: commit: feat: commit-treeの説明を追加
9ac9b0c HEAD@{9}: commit: change: 順番を変更
8fc078d HEAD@{10}: commit: feat: git cat-fileの説明を追加
d3b47fe HEAD@{11}: commit: feat: fsckを追記する
f66de64 HEAD@{12}: commit: feat: reflogの説明追加
d5aeffe HEAD@{13}: commit: feat: resetの説明を追加
a4f7fe9 HEAD@{14}: commit: feat: git logの説明を追加
9fcb006 HEAD@{15}: commit: feat: git commitの説明追加
6d21e22 HEAD@{16}: commit: feat: git addの説明を追加
656db59 HEAD@{17}: commit: feat: add README.md
c27f346 HEAD@{18}: commit (initial): initial commit
僕もよくわかってませんが,rebase
した後でも元に戻せるようです.rebase
する前のHEAD@{7}
に戻りましょう.
git reset --hard HEAD@{7}
すると flag.txt というファイルが出現(復元)されます.
ctf4b{0verwr1te_1s_n0t_c0mplete_1n_G1t}
Mail_Address_Validator (76 pt)
ReDoS 攻撃という攻撃が成立します.運よく数日前に僕はスライドにまとめていました.
ちなみにこの問題の簡易版の問題も作り,公開しています(おそらく記事公開後,数日で消えます).
https://redos-server.herokuapp.com/
詳しくはスライド参照していただきたいのですが,正規表現は下手に書くと match に時間がかかる場合があります.今回 main.rb には
pattern = /\A([\w+\-].?)+@[a-z\d\-]+(\.[a-z]+)*\.[a-z]+\z/i
と書かれていますが,後半の+)*
が ReDoS できそうです.なので適当に.
を入れて最後にb
を入れてこんな感じにすればだいたい処理時間が爆発します.
aaa@a0a.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa.aaaaab.aaa
ctf4b{1t_15_n0t_0nly_th3_W3b_th4t_15_4ff3ct3d_by_ReDoS}
depixelization (136 pt)
フラグの文字列を画像に変換して,各文字の上にP
,I
,X
の三文字を重ねて出力しているみたいです.そこで画像をいくつかに分割し,また ASCII 全ての文字を画像に変換して一致を比較とします.
「画像をいくつかに分割」
ソースコードより,画像は横 85px らしいです.
import cv2
# 画像読み込み
img = cv2.imread("output.png")
height = img.shape[0]
width = img.shape[1]
for i in range(31):
img1 = img[0:, i*85: i*85+85]
cv2.imwrite("out/" + str(i) + ".png", img1)
「ASCII 全ての文字を画像に変換」
import cv2
import numpy as np
flag = "@!#$%&'()*+,-./0123456789:;<=>?ABCDEFGHIJKLMNOPQRSTUVWXYZ[\]^_abcdefghijklmnopqrstuvwxyz{|}~"
flag = " "
print("FLAG: " + flag)
cnt= 0
for i in flag:
images = np.full((100, 85, 3), (255,255,255), dtype=np.uint8)
# char2img
img = np.full((100, 85, 3), (255,255,255), dtype=np.uint8)
cv2.putText(img, 'd', (0, 80), cv2.FONT_HERSHEY_PLAIN, 8, (0, 0, 0), 5, cv2.LINE_AA)
# pixelization
cv2.putText(img, "P", (0, 90), cv2.FONT_HERSHEY_PLAIN, 7, (0, 0, 0), 5, cv2.LINE_AA)
cv2.putText(img, "I", (0, 90), cv2.FONT_HERSHEY_PLAIN, 8, (0, 0, 0), 5, cv2.LINE_AA)
cv2.putText(img, "X", (0, 90), cv2.FONT_HERSHEY_PLAIN, 9, (0, 0, 0), 5, cv2.LINE_AA)
simg = cv2.resize(img, None, fx=0.1, fy=0.1, interpolation=cv2.INTER_NEAREST) # WTF :-o
img = cv2.resize(simg, img.shape[:2][::-1], interpolation=cv2.INTER_NEAREST)
if images.all():
images = img
else:
images = cv2.hconcat([images, img])
cv2.imwrite("char/" + i + "_char.png", images)
これを実行した後,md5sum
などで各画像をハッシュ化し,一致している画像ペアを探します.すると,以下のようにフラグが得られそうです.
ctf4b{1f_y0u_p1x_y0u_c4n_[ここ一文字謎]3p1x}
一文字だけ復元できませんでした.そこでその文字と PIX だけの文字を比較します.
上の画像が例の文字ですが,o
のように見えます.ですがo
と比較すると右上に少し何かがあったようです.そこでd
と比較すると見た目は一致します.よって
ctf4b{1f_y0u_p1x_y0u_c4n_d3p1x}
です.後に知った情報ですが,OpenCV はバージョンによって結果が少し変わるそうです.なのでどうにか類似度を測ってやるのが最適そうです.