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と記述しています。

  1. コマンドライン引数が与えられているか判定する
  2. フラグが32文字か判定する
  3. inputの先頭がSECCON{か判定する
  4. inputの末尾が}か判定する
  5. ptraceでデバッガの検知(検知してもプログラムが終了するわけではない)
  6. inputを8byteずつencryptする
  7. 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?}'

first blood, うれしい

[rev] efsbk

実行ファイルefsbk.exeflag.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つの処理を実行していることが分かりました。

  1. ReadEncryptedFileRawflag.txtのバックアップflag.binを作成
  2. PFXExportCertStoreExflag.binの末尾に証明書を書き込む

ReadEncryptedFileRawAのドキュメントを読むと、Windows EFS(Encrypted File System)に関連するAPIであることが書かれています。EFSで暗号化されたファイルの復号方法が分かれば何とかなりそうです。調べてみたら以下のページが見つかりました。

jpwinsup.github.io

このページから暗号化されたファイルの復号には以下の2つの要素が必要であることが分かります。

  1. 暗号化されたファイルの本体
  2. 証明書

flag.txtの復元

flag.binReadEncryptedFileRawによってバックアップされたファイルであるため、元の暗号化されたファイルに復元する必要があります。復元方法について手がかりを得るために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_00cb1030xor eax, eaxを実行するだけの関数で、同様の関数が0x10でalignされて並んでいます。

問題文の「間違ったフラグを入力するとabortする」という記述から__guard_check_icall関数が気になります。Googleで調べてみるとControl Flow Guardというセキュリティ機構が関係していることが分かりました。これは、GuardCFFunctionTable内に存在しない関数が呼び出されたときに強制終了させるという機構らしいです*1*2

GhidraでFUN_00cb1030のXREFを見ると実際のGuardCFFunctionTableが見つかり、その中身はFUN_00cb1030, FUN_00cb1040, ...と関数のアドレスが並んでいます。

GuardCFFunctionTable

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