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はオンサイトならではといった感じがして良かったです。運営の皆様、本当にありがとうございました。来年も決勝進出できるように精進していきたいです。