SECCON CTF 2023 Quals Writeup

2023/9/16(土) 14:00 ~ 2023/9/17(日) 14:00に開催されたSECCON CTF 2023 QualsのWriteupです。

チームDouble Lariatで参加し、全体で41位、国内で8位という結果になりました。今年も国内決勝に行けるようで非常に嬉しいです。

私はrev問を担当し、xuyao以外の4問を解きました。

[rev] jumpout

ELFバイナリのjumpoutが与えられます。中身はシンプルなフラグチェッカーで、フラグを入力するとCorrect!Wrong...を表示してくれます。

$ ./jumpout
FLAG: hogehoge
Wrong...

Ghidraでmain関数の処理を見ると、puts("Wrong...")等の処理が見当たらず、中途半端なデコンパイル結果になっていることが分かりました。

undefined8 main(void)

{
  int iVar1;
  long i;
  undefined8 *ptr;
  long in_FS_OFFSET;
  undefined8 buf [13];
  long local_40;
  
  local_40 = *(long *)(in_FS_OFFSET + 0x28);
  ptr = buf;
  for (i = 0xc; i != 0; i = i + -1) {
    *ptr = 0;
    ptr = ptr + 1;
  }
  *(undefined4 *)ptr = 0;
  do {
    FUN_001010c0(1,"FLAG: ");
    iVar1 = __isoc99_scanf("%99s",buf);
  } while (iVar1 == 1);
  if (local_40 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return 1;
}

アセンブリを詳しく見てみると、複数の場所で以下のアセンブリコードが見つかります。

mov rax, QWORD ptr [rsp + rbx*8]
lea ebx, [r14 - 1]
jmp rax

このコードによって処理の順番がばらばらになり、デコンパイル結果が中途半端になっていたと考えられます。ジャンプ先のアドレスをたどり、デコンパイル結果を組み合わせると以下のソースコードが得られました。

#include <stdio.h>

unsigned char xor[] = {0xf6, 0xf5, ...};
unsigned char enc_flag[] = {0xf0, 0xe4, ...};

unsigned char FUN_00101360(unsigned char a, int b) {
    return a ^ (unsigned char)b ^ 0x55 ^ xor[b];
}

int FUN_00101480(char *str) {
    size_t len = strlen(str);
    int unaff_EBP = 1;

    if (len != 0x1d) {
        return 1;
    }

    for (int i = 0; i < 0x1d; i++) {
        unaff_EBP &= (FUN_00101360(str[i], i) == enc_flag[i]);
    }
    return unaff_EBP == 0;
}

int main() {
    char buf[100];
    long *ptr;
    int ret;

    // memset(buf, 0, 100)?
    ptr = (long*)buf;
    for (int i = 0xc; i != 0; i--) {
        *ptr = 0;
        ptr++;
    }
    *ptr = 0;

    
    do {
        ret = scanf("%99s", buf);
    } while (ret != 1);

    if (FUN_00101480(buf) == 0) {
        puts("Correct!");
    } else {
        puts("Wrong...");
    }
}

FUN_0010360の出力結果がenc_flagと一致するような値を以下のソースコードで求めることでフラグを得られました。

enc = [246, 245, 49, 200, 129, 21, 20, 104, 246, 53, 229, 62, 130, 9, 202, 241, 138, 169, 223, 223, 51, 42, 109, 129, 245, 166, 133, 223, 23]
target = [240, 228, 37, 221, 159, 11, 60, 80, 222, 4, 202, 63, 175, 48, 243, 199, 170, 178, 253, 239, 23, 24, 87, 180, 208, 143, 184, 244, 35]

for i, e in enumerate(enc):
    for x in range(256):
        if 0x55 ^ i ^ e ^ x == target[i]:
            print(chr(x), end="")

print()
$ python3 solve.py
SECCON{jump_table_everywhere}

[rev] Sickle

pickle化されたフラグチェッカーのコードを実行するPythonスクリプトproblem.pyが与えられます。

flickingを用いてpickle化されたコード(problem.py中のpayload)をデコンパイルすると、以下のコードが得られました。

import ast
import pickle
from fickling.pickle import Pickled

payload = b'\x8c\x08builtins\x8c\x07getattr...\x85R.'

print(ast.dump(Pickled.load(payload).ast, indent=4))
_var0 = input('FLAG> ')
_var1 = getattr(_var0, 'encode')
_var2 = _var1()
_var3 = getattr(dict, 'get')
_var4 = globals()
_var5 = _var3(_var4, 'f')
_var6 = getattr(_var5, 'seek')
_var7 = getattr(int, '__add__')
_var8 = getattr(int, '__mul__')
_var9 = getattr(int, '__eq__')
_var10 = len(_var2)
_var11 = _var9(_var10, 64)
_var12 = _var7(_var11, 261)
result = _var6(_var12)

payload中にみられる__xor__appendなどが結果に表れておらず、不自然な結果となっていることが分かります。調べてみると、payload中にSTOP命令を表す「.」が複数存在することが原因であることが確認できました。

pickle化されたコードの中から.を削除し、pickletools.optimizeで最適化したものをflickingでデコンパイルすると以下のコードが得られました。

_var0 = input('FLAG> ')
_var1 = getattr(_var0, 'encode')
_var2 = _var1()
_var3 = getattr(dict, 'get')
_var4 = globals()
_var5 = _var3(_var4, 'f')
_var6 = getattr(_var5, 'seek')
_var7 = getattr(int, '__add__')
_var8 = getattr(int, '__mul__')
_var9 = getattr(int, '__eq__')
_var10 = len(_var2)
_var11 = _var9(_var10, 64)
_var12 = _var7(_var11, 261)
_var13 = _var6(_var12)
_var14 = getattr(_var2, '__getitem__')
_var15 = _var14(0)
_var16 = getattr(_var15, '__le__')
_var17 = _var16(127)
_var18 = _var7(_var17, 330)
_var19 = _var6(_var18)
_var20 = _var7(0, 1)
_var21 = _var9(_var20, 64)
_var22 = _var8(_var21, 85)
_var23 - _var7(_var22, 290)
_var24 = _var6(_var23)
_var25 = getattr([], 'append')
_var26 = getattr([], "__getitem__")
_var27 = getattr(int, 'from_bytes')
_var28 = _var8(0, 8)
_var29 = _var7(0, 1)
_var30 = _var8(_var29, 8)
_var31 = slice(_var28, _var30)
_var32 = _var14(_var31)
_var33 = _var27(_var32, 'little')
_var34 = _var25(_var33)
_var35 = _var7(0, 1)
_var36 = _var9(_var35, 8)
_var37 = _var8(_var36, 119)
_var38 = _var7(_var37, 457)
_var39 = _var6(_var38)
_var40 = getattr([], 'append')
_var41 = getattr([], '__getitem__')
_var42 = getattr(int, '__xor__')
_var43 = _var26(0)
_var44 = _var42(_var43, 1244422970072434993)
_var45 = pow(_var44, 65537, 18446744073709551557)
_var46 = _var40(_var45)
_var47 = _var41(0)
_var48 = _var7(0, 1)
_var49 = _var9(_var48, 8)
_var50 = _var8(_var49, 131)
_var51 = _var7(_var50, 679)
_var52 = _var6(_var51)
_var53 = getattr([], '__eq__')
_var54 = _var53([8215359690687096682,1862662588367509514,8350772864914849965,11616510986494699232,3711648467207374797,9722127090168848805,16780197523811627561,18138828537077112905])

処理をまとめると、以下の6つのステップに分けられます。

  1. 標準入力からフラグを入力させる(以下flagと表記)
  2. flagの長さが64文字か確認する
  3. flagの各byteが0x7fより小さいことを確認する
  4. flagを8文字ごとに区切り、int.from_bytesで整数型に変換する(区切られたflagをブロックと表記する)
  5. 定数とxorをとって鍵(n, e) = (18446744073709551557, 65537)RSAで暗号化する
  6. 正解のフラグを暗号化したものと5.で暗号化したものを比較する

公開鍵に用いられているn素数であるため秘密鍵d = pow(e, -1, n-1)で求められ、暗号文はpow(enc, d, n)で復号できます。最後に、復号して得られた平文と定数でxorをとることでフラグを求められます。

以下のコードで復号を試みましたが、最初のブロックのフラグは得られたものの2ブロック目以降はフラグとは関係のないバイト列が出力されました。

from Crypto.Util.number import *

enc = [8215359690687096682,1862662588367509514,8350772864914849965,11616510986494699232,3711648467207374797,9722127090168848805,16780197523811627561,18138828537077112905]

p = 18446744073709551557
phi = p - 1
d = pow(65537, -1, phi)
key = 1244422970072434993

for e in enc:
    print(long_to_bytes(pow(e, d, p) ^ key)[::-1].decode(), end="")
$ python solve.py
b'SECCON{C'
b':\xc0\xa9\xcbj\xab"\x0c'
b'Ut\xd05\xb4\xf0\xfd{'
b'\xf9\xd6\xd4]\xdf\x96\xf9\x03'
b'\x8e\x0b\x94\x84]P\x14\xd5'
b"\x8e\x91\xe3\xd5\xf6'\x97K"
b'\xf7\x03ZjR\x99\xd7\xe3'
b'}\x18@f\xc2\x17\xa5\x84'

試行錯誤して2ブロック目以降でxorをとる値を直前のブロックの暗号文に変更したところ、フラグが出力されました。

from Crypto.Util.number import *

enc = [8215359690687096682,1862662588367509514,8350772864914849965,11616510986494699232,3711648467207374797,9722127090168848805,16780197523811627561,18138828537077112905]
keys = [1244422970072434993, *enc[:-1]]

p = 18446744073709551557
phi = p - 1
d = pow(65537, -1, phi)

for e, k in zip(enc, keys):
    print(long_to_bytes(pow(e, d, p) ^ k)[::-1].decode(), end="")
$ python solve.py
SECCON{Can_someone_please_make_a_debugger_for_Pickle_bytecode??}

[rev] optinimize

nim言語で作成されたバイナリmainが与えられます。

実行するとフラグが一文字ずつ出力されていきますが、フラグの後半になるにつれて出力にかかる時間が増大していき、10文字目あたりから全然出力されなくなります。

$ ./main
SECCON{3b4

気合で解析してmainが行う処理をPythonで書き直すと以下のソースコードになります。エントリポイントはNimMainModuleです。

qmain_vals = [0x4a,0x55,0x6f,0x79,0x80,0x95,0xae,0xbf,0xc7,0xd5,0x306,0x1ac8,0x24ba,0x3d00,0x4301,0x5626,0x6ad9,0x7103,0x901b,0x9e03,0x1e5fb6,0x26f764,0x30bd9e,0x407678,0x5b173b,0x6fe3b1,0x78ef25,0x858e5f,0x98c639,0xad6af6,0x1080096,0x18e08cd,0x1bb6107,0x1f50ff1,0x25c6327,0x2a971b6,0x2d68493,0x362f0c0,0x3788ead,0x3caa8ed]
xor_vals = [0x3c,0xf4,0x1a,0xd0,0x8a,0x17,0x7c,0x4c,0xdf,0x21,0xdf,0xb0,0x12,0xb8,0x4e,0xfa,0xd9,0x2d,0x66,0xfa,0xd4,0x95,0xf0,0x66,0x6d,0xce,0x69,0x0,0x7d,0x95,0xea,0xd9,0xa,0xeb,0x27,0x63,0x75,0x11,0x37,0xd4]

def P_main(x):
    if x == 0:
        return 3
    elif x == 1:
        return 0
    elif x == 2:
        return 2
    
    i, j, k = 3, 0, 2
    while (x > 2):
        tmp = i + j
        i, j, k = j, k, tmp
        x -= 1
    return k
    
def Q_main(x):
    i, j = 0, 0
    while i < x:
        j += 1

        if P_main(j) % j == 0:
            i += 1
    return j

def NimMainModule():
    for i in range(40):
        x = qmain_vals[i]
        y = Q_main(x) % 256
        z = y ^ xor_vals[i]
        if (z < 0x100) {
            print(chr(z), end="")

ループの中でP_mainが何度も呼ばれ、P_main自体が非常に処理の重い関数であるため高速化する必要があるのですが、いい感じに高速化する方法が思いつきませんでした。rev部分だけ終わらせて他の問題に手を付けたり、高速化手法を考えていたところチームメンバーの方がP_main(j) % j == 0となるj素数の数列であることに気づき、総当たりで解いてくれました。

SECCON{3b4297373223a58ccf3dc06a6102846f}

チームメンバーに感謝🙏

[rev] Perfect Blu

ブルーレイディスクのisoファイルperfect-blu.isoが与えられます。VLCなどで再生するとフラグ入力画面が表示され、画面中央下のCHECKボタンを押すことで入力したフラグの正誤を判定してくれます。

perfect-blu.isoをVLCで再生した様子

BDeditでisoファイルを開き、CLIPINFタブでcpiファイルを選択、Program Infoパネルのstream typeがIGとなっている要素をダブルクリックすると以下の画面が表示されます。

BDeditのMenuタブ

複数あるボタンの中で一つだけCallObjectの引数が異なっているボタンが存在します。0000.clpi~0047.clpiに対して、「CallObjectの引数が異なっているボタンを見つけ、その座標を記録する」という作業を地道に行い、最後にボタンの座標から文字列に変換することでフラグが得られました。

xs = [315, 453, 590, 727, 865, 1002, 1139, 1277, 1414, 1551]
ys = [413, 543, 672, 802]

chars = "1234567890QWERTYUIOPASDFGHJKL{ZXCVBNM_-}"

button_coords = [
    (453, 672), (590, 543), (590, 802), (590, 802), (1414, 543),
    (1002, 802), (1551, 672), (1139, 672), (453, 543), (865, 802),
    (1002, 672), (1414, 802), (865, 413), (1277, 413), (590, 543),
    (1414, 672), (1414, 802), (315, 543), (453, 543), (727, 543),
    (1414, 672), (1414, 802), (590, 802), (1414, 672), (453, 672),
    (453, 543), (1414, 802), (1139, 543), (727, 672), (727, 543),
    (1277, 543), (1414, 802), (453, 802), (1139, 543), (1002, 543),
    (590, 413), (1414, 802), (1002, 543), (1002, 672), (1277, 672),
    (1277, 672), (1414, 802), (1277, 672), (727, 672), (865, 802),
    (727, 802), (1551, 802)
]

def coord_to_key(coord):
    for i, x in enumerate(xs):
        for j, y in enumerate(ys):
            if x == coord[0] and y == coord[1]:
                return chars[i + j*10]
    return ""

flag = ""
for coord in button_coords:
    flag += coord_to_key(coord)
print(flag)
$ python solve.py
SECCON{JWBH-58EL-QWRL-CLSW-UFRI-XUY3-YHKK-KFBV}

感想

rev力が足りずxuyaoが解けなかったのは少し悔しいですが、5問あるrev問の中で4問も解けたのは良かったです。

今回もrev以外の問題に手を付けられなかったので少しずつ手を出せるジャンルを増やしていきたいですね。