SECCON 2020 OnlineCTF writeup -- SCSBX:Reversing

概要

f:id:hikalium:20201011204310p:plain

今年も例年のように、チームHarekazeの一員として出ました。チームの結果は2123点で11位/579チーム(何らかのFLAGを提出したチーム数)でした。 そのうち、私はSCSBX:Reversingを解いて129ポイントを稼ぎました。(st98さんが一人で1552ポイントも稼いでおり目を回しています。)

あと、同じSCSBXをテーマにしたSCSBX:Escapeや、脆弱なkernel moduleの秘孔を突くkstackという問題にも挑戦しましたが、時間内に解くまでにはいたりませんでした。

ということで、挑戦した各問題の解法 or 挑戦のようすを書いておきます。

SCSBX:Reversing

f:id:hikalium:20201011194731p:plain

ソースコードの公開された独自のバイトコード処理系と、それ向けに書かれたアプリケーションのバイナリが与えられています。 そのアプリケーションは、入力がフラグであるか否かを教えてくれるものでした。

$ ./scsbx seccon.bin 
FLAG: hogehoge
Wrong!!

ということで、その処理をみてみれば、どのような入力が期待されているのか、つまりフラグがわかりそうです。ということで、読んでいきましょう。

VMソースコードを読んでみる

ファイルとしてはこんな感じ。

$ ls
Makefile          instr_logic.cpp  main.cpp
instr_arith.cpp   instr_mem.cpp    scsbx.cpp
instr_branch.cpp  instr_sys.cpp    scsbx.hpp

C++で書かれているんですねー。たとえば、scsbx.cppの中をみてみると、

void SCSBX::__cpu_exec(u8 instr)
{
  u32 val;

  switch(instr) {
    /* Memory Operation */
  case INS_PUSH:
    val = *(u32*)(&code[pc+1]);
    __stack_push(val);
    pc += 4;
    break;
  case INS_POP:
    __stack_pop();
    break;
  case INS_DUP:
    __stack_dup();
    break;
  case INS_XCHG:
    __stack_xchg();
    break;
  case INS_LOAD32:
    __mem_load32();
    break;
  case INS_LOAD64:
    __mem_load64();
    break;
  case INS_STORE8:
    __mem_store8();

という感じで、非常に素直に書かれています。

また、DOCS.md というファイルも含まれていたのでみてみると、命令形式についての説明やメモリレイアウトについても書かれていました。めっちゃ親切。

### Instruction
Every instruction except for `push` is 1-byte long.
`push` accepts a 4-byte value to push into the stack.
**SCSBX is proudly open-sourced!**
Check the source code for more details.
0x00000000 +-------------------+
           |                   |
           |     Free Space    |
0x55540000 +-------------------+
           | Machine Code (R-) |
           +-------------------+
           |                   |
           |     Free Space    |
           |                   |
0xfffe0000 +-------------------+
           |     Stack (RW)    |
0xffff0000 +-------------------+  ^^^ user-space ^^^
           |  Guard Page (--)  |
0xffffffff +-------------------+  vvv  VM-space  vvv
           |    VM instance    |
           |        ...        |

とりあえず、PUSH命令以外はすべて1バイトのスタックマシンということがわかったので、さくっとディスアセンブラを書きます。というか、VMソースコードを改造して、オプションを渡したらdisasしてくれるようにしました。その出力がこちら。

$ ./scsbx ../seccon.bin -d
00000000: 20 00001000: push
00000005: 20 DEAD0000: push
0000000A: 62: map
0000000B: 20 00000256: push
00000010: 34: call
00000011: 20 0000020A: push
00000016: 34: call
00000017: 20 47414C46: push
0000001C: 20 DEAD0004: push
00000021: 28: store32
00000022: 20 0000203A: push
00000027: 20 DEAD0008: push
0000002C: 27: store16
0000002D: 20 00000006: push
00000032: 20 DEAD0004: push
00000037: 61: write
00000038: 20 00000040: push
0000003D: 20 DEAD000A: push
00000042: 60: read
00000043: 20 00000008: push
00000048: 20 0000010E: push
...

やっぱりスタックマシンだし長いですね。というわけで、じっとこれを見つめてデコンパイラごっこをします。

私なりのやり方としては、大体以下のような手順を踏みました。

  1. 分岐命令を境に、命令列をブロックに分ける。
  2. 上から辿っていって何をやっているかお気持ちを理解する
  3. 複雑な部分では、スタックを書いて追いながらロジックを推定する

たとえばこんな感じに、スタックの使われ方を書いていったり、

<check_loop()>
/*
[0] k
[1] v
*/
00000135: 20 00000000: push
0000013A: 22: dup
0000013B: 20 00000001: push
00000140: 20 00000001: push
00000145: 23: xchg
00000146: 41: sub
/*
[0] k - 1
[0] k
[1] v
*/
00000147: 20 00000008: push
0000014C: 42: mul
/*
[0] (k - 1) * 8
[0] k
[1] v
*/
0000014D: 20 DEAD000A: push
00000152: 40: add
/*
[0] (k - 1) * 8 + 0xDEAD000A
[0] k
[1] v
*/

...(省略)

00000183: 20 00000001: push
00000188: 23: xchg
/*
[2] k
[0] v | (*p - *q) | (*(p + 4) - *(q + 4))
*/
00000189: 20 00000001: push
0000018E: 20 00000001: push
00000193: 23: xchg
00000194: 41: sub
/*
[2] k - 1
[0] v | (*p - *q) | (*(p + 4) - *(q + 4))
*/

これを元にC言語ベースの擬似コードに落とし込んだりしてみました。

int main(int argc, char *argv) {
  // 00000000
  // map [DEAD0000 - DEAD1000)
  InitEncFlag();
  // 0000020A
  *(uint32_t *)&mem[0] = 0x06D35BCD;
  // 00000017
  // read 0x40 bytes into *0xDEAD000A
  strncpy(&mem[0xA], "SECCON{[\x20-\x7e]+}", 0x40);
  uint32_t i = 8; // pushed at +00000043
  // 0000010E
  // [] i
  while(i) {
    // 0000004E
    // 000000DD
    // Stack after 00000097
    // []: *((8-i) * 8 + 0xDEAD000E) = v1
    // []: *(8-i) * 8 + 0xDEAD000A) = v2
    // []: (8-i) * 8 + 0xDEAD000A
    // []: (8-i) * 8 + 0xDEAD000E
    // []: i
    uint32_t v1 = *((8-i) * 8 + 0xE + mem);
    uint32_t v2 = *((8-i) * 8 + 0xA + mem);
    uint32_t p = 3; // pushed at +00000098
    // 000000DD
    while(p) {
      // 000000A3
      // []: p
      // []: v1
      // []: v2
      p--;
      uint32_t c = v2;
      v2 = v1;
      v1 = random_num(v1) ^ c;
    }
    // 000000F3
    // []: p
    // []: v1
    // []: v2
    // []: (8-i) * 8 + 0xDEAD000A
    // []: (8-i) * 8 + 0xDEAD000E
    // []: i
    *((8-i) * 8 + 0xE + mem) = v1;
    *((8-i) * 8 + 0xA + mem) = v2;
    i--;  // 00000102 - 0000010D
    // 0000010E
  };
  // check if flag_buf is same as flag_encrypted
  ...
}

さて、デコンパイラの気持ちを理解したところで、このプログラムは大体以下のような処理を行っていることがわかりました。

  1. 作業用のメモリ領域[0xDEAD0000 - 0xDEAD1000)をmapする
  2. そのメモリ領域に符号化されたフラグを展開する(0xDEAD004A から0x40バイト)
  3. 0xDEAD0000番地に値0x06D35BCDを格納する(おそらく何らかのシード値、後のロジックで使われる)
  4. ユーザーからの入力を受け取る(0x40バイト、これは0xDEAD000A番地から0x40バイトの空間に格納される)
  5. 上の擬似コードに書いたような処理を行って、ユーザーからの入力を符号化する
  6. 符号化した入力(0xDEAD000Aからの0x40バイト)と、展開しておいた符号化済みのフラグ値(0xDEAD004Aからの0x40バイト)を比較する
  7. それらが一致したらCorrect, 一致しなければWrong!!と出力して終了する

さて、肝心のstep5の処理ですが、擬似コードとしてはこんな感じです。(enc()がその処理)

uint32_t random_num(uint32_t v) {
  // 00000216
  uint32_t *dead0000 = (uint32_t *)&mem[0];
  *dead0000 = (*dead0000 * 0x77F - 0x32A) % 0x0x305EB3EA;
  return ~(v ^ *dead0000);
}
void enc() {
  uint32_t i = 8; // pushed at +00000043
  // 0000010E
  while(i) {
    // 0000004E
    // 000000DD
    uint32_t v1 = *((8-i) * 8 + 0xE + mem);
    uint32_t v2 = *((8-i) * 8 + 0xA + mem);
    uint32_t p = 3; // pushed at +00000098
    // 000000DD
    while(p) {
      // 000000A3
      p--;
      uint32_t c = v2;
      v2 = v1;
      v1 = random_num(v1) ^ c;
    }
    // 000000F3
    *((8-i) * 8 + 0xE + mem) = v1;
    *((8-i) * 8 + 0xA + mem) = v2;
    i--;  // 00000102 - 0000010D
    // 0000010E
  }
}

要するに、8バイトを1セットとして符号化するのを8回くりかえすことで、8*8 = 64 = 0x40バイトの領域を符号化しているわけです。 また、random_num()と仮においた処理は、0xDEAD0000番地のメモリ内容という状態を持っているため、呼び出しには副作用があります。 とはいえ、基本的にやっていることは、その8バイトの中で前後4バイトずつの部分をxorしているだけで、入力と出力は各桁ごとに独立に変化します。 結局、random_num()の状態に注意しつつ、8バイトずつのセットの各文字を順に確定させてゆけば、フラグがわかりそうです。(これなら最大でも256 * 8 * 8通り調べればいけますね。)

というわけで、このアイディアをかんたんなC言語のプログラムに落とし込みました。

#include <stdint.h>
#include <stdio.h>

uint32_t dead0000 = 0x06D35BCD;

uint32_t random_num(uint32_t v) {
  // 00000216
  dead0000 = (dead0000 * 0x77F - 0x32A) % 0x305EB3EA;
  return ~(v ^ dead0000);
}

uint64_t enc(uint64_t v) {
  uint32_t v1 = v >> 32;
  uint32_t v2 = v;
  uint32_t p = 3;  // pushed at +00000098
  while (p) {
    p--;
    uint32_t c = v2;
    v2 = v1;
    v1 = random_num(v1) ^ c;
  }
  return ((uint64_t)v1 << 32) | v2;
}

uint8_t mem[64];

void InitEncFlag() {
  // 00000256
  *(uint32_t *)&mem[4 * 0x0] = 0x46761223;
  *(uint32_t *)&mem[4 * 0x1] = 0x54BEA5C5;
  *(uint32_t *)&mem[4 * 0x2] = 0x7A22E8F6;
  *(uint32_t *)&mem[4 * 0x3] = 0x5DB493C9;
  *(uint32_t *)&mem[4 * 0x4] = 0x055D175E;
  *(uint32_t *)&mem[4 * 0x5] = 0x022FCD33;
  *(uint32_t *)&mem[4 * 0x6] = 0x42C46BE6;
  *(uint32_t *)&mem[4 * 0x7] = 0x6D10A0E8;
  *(uint32_t *)&mem[4 * 0x8] = 0x53F4C278;
  *(uint32_t *)&mem[4 * 0x9] = 0x7279EC2A;
  *(uint32_t *)&mem[4 * 0xA] = 0x5491FB39;
  *(uint32_t *)&mem[4 * 0xB] = 0x49AC421F;
  *(uint32_t *)&mem[4 * 0xC] = 0x49AB3A37;
  *(uint32_t *)&mem[4 * 0xD] = 0x47855812;
  *(uint32_t *)&mem[4 * 0xE] = 0x5718BB05;
  *(uint32_t *)&mem[4 * 0xF] = 0x0540FB5B;
}

int main(int argc, char *argv[]) {
  InitEncFlag();
  char buf[64 + 1] = "SECCON{........";

  for (int i = 0; i < 8; i++) {
    for (int k = 0; k < 8; k++) {
      for (char c = 0x20; c <= 0x7E; c++) {
        buf[k] = c;
        uint32_t prev_dead0000 = dead0000;
        uint64_t e = enc(*(uint64_t *)buf);
        uint8_t *p = (uint8_t *)&e;
        dead0000 = prev_dead0000;
        if (p[k] == mem[k + i * 8]) {
          break;
        }
      }
    }
    uint64_t e = enc(*(uint64_t *)buf);
    printf("%.8s", buf);
  }

  return 0;
}

気をつけるべき点は、forの中でencを実行する際に、毎回dead0000の内容を戻しているところですね。(少し冗長ですが色々ためしていたせいである。)

というわけで、以下のような出力を得ました。

$ ./solve2 
SECCON{TfuRYYVaz8Us696t3JWNxZZPsXEmdL7cCmgzpgxXKarUOnIwhSj9tQ}~~

SECCON{の後にはTがきそう(きっと英語のメッセージ的にTheとかがきそうだから)と思っていて、最初の8桁だけ試してTがでてきてわーいと思っていたのですが、全部出してみたら乱数フラグでちょっと意外でした。(ちゃんと通ってほっとした。)

追記

このアルゴリズムはFeistel構造と呼ばれるものらしい。

ja.wikipedia.org

SCSBX:Escape

ところでこのSCSBX(SecCon SandBoX), reversingだけでなくsandboxの問題としても登場していました。

f:id:hikalium:20201011202618p:plain

こちらは、リモートで動いているSCSBXにいい感じのバイナリを送りつけて、思い通りの動きをさせてshellをゲットしような!という問題だったのだと思います。

SCSBXの説明文にこんな一文がありますが、

### Direct Memory Access
SCSBX allows the program to directly access to the host memory.
This may sound dangerous but the program can only use the 32-bit
address space. Since SCSBX runs on a 64-bit machine with PIE enabled,
there's no way the user can read/write the VM memory directly.

明らかにこれはアヤシイ!と思いますよね。というわけで、じっとソースコードを見つめると、

void SCSBX::__sys_unmap()
{
  u64 address = ((u64)__stack_pop()) & ~0xfff;
  __assert_address_valid(address);

  for(auto itr = memmap.begin(); itr != memmap.end(); ++itr) {
    if (address == itr->first) {
      memmap.erase(itr);
      munmap((void*)address, itr->second);
      return;
    }
  }

  throw MEMORY_ERROR;
}

ほう、munmapの戻り値を確認しないとは勇気がありますね?というところを見つけられます。これは何に役立つかと言うと、メモリマップで言う

0x00000000 +-------------------+
           |                   |
           |     Free Space    |
0x55540000 +-------------------+
           | Machine Code (R-) |
           +-------------------+
           |                   |
           |     Free Space    |
           |                   |
0xfffe0000 +-------------------+
           |     Stack (RW)    |
0xffff0000 +-------------------+  ^^^ user-space ^^^
           |  Guard Page (--)  |
0xffffffff +-------------------+  vvv  VM-space  vvv
           |    VM instance    |
           |        ...        |

Guard Pageを除去するのに使えます。というのも、このGuard Pageは、実際にmapされているわけではなく、

SCSBX::SCSBX(u8 *_code, u32 _size)
{
  /* Guard page */
  memmap.push_back(std::make_pair((u64)STACK_BASE + (u64)STACK_SIZE_INIT,
                                  0x100000000 - (u64)STACK_BASE - (u64)STACK_SIZE_INIT));
}

という感じでVMの初期化時に「確保済みのメモリ領域」として予約されているだけなのです。この結果として、VMはこの領域にアクセスするとマップされていないのでSEGVがおきるし、マップしようにもマップ済みなのでmapを実行できない…はずなのですが、先ほどのようにmunmap命令が実行されると、munmapの結果にかかわらずこのmemmapというリストから除去が行われるため、なんとこの領域をmunmapしてから再度mmapすれば、普通にアクセスできるようになってしまうというバグがあったわけです。

で、ここにアクセスできると何がやばいかというと、Stackが伸びていくとやがてこのGuardPageにあたり、さらにそこを超えるとVM instance、つまりクラスSCSBXのインスタンスが確保されている領域を上書きできてしまうのです。

というわけで、たとえばこうすれば

  // unmap guard page
  instr_push(0xffff0000);
  instr_unmap();
  // map again
  instr_push(0x00010000);
  instr_push(0xffff0000);
  instr_map();

あとはstackをばんばん伸ばすだけでやがてこの構造体を上書きできるようになります。

class SCSBX {
private:
  /* fields */
  std::vector<std::pair<u64, u32>> memmap;

  u32 pc;
  int status;

  u8* code;
  u32* stack;
  u32 code_size;
  u32 capacity;
  u32 top;
...
  virtual void __assert_address_valid(u64 address);
  virtual void __assert_range_valid(u64 address, u64 size);
  virtual void __assert_resource_available(u64 address, u32 size);
...
};

しかもこの構造体、virtual関数としてアドレス範囲チェック系の重要な関数のポインタが記録されている場所でもあるので、ここをいい感じにRETを指すポインタように上書きすれば、チェックを全部回避することも、さらに任意のアドレスに実行を飛ばすこともできます。

…というところまでわかって、時間切れでした。ざんねん。

pythonが得意ではないので、hashのチャレンジレスポンスを解きつつ、ペイロードを突っ込むスクリプトを将来また書かなくてもいいようにここに貼っておきます。

from pwn import *
import itertools
import hashlib
import string
import sys

print(sys.argv[1])
f = open(sys.argv[1], "rb")
attack_bytes = f.read()

table = string.ascii_letters + string.digits + "._"

p = remote('pwn-neko.chal.seccon.jp', 19001)
ret = p.recvline().decode("utf-8") 
# ret = 'sha256("????LFx0jSq8mYA4oZC_.Kuw") = ca67aa308662f39b8e294a99120588102a94

hashval = ret.split("=")[-1].strip()
print(hashval)
suffix = ret.split("\"")[1].replace("?", "")
print(suffix)

for v in itertools.product(table, repeat=4):
    if hashlib.sha256((''.join(v) + suffix).encode()).hexdigest() == hashval:
        prefix = ''.join(v)
        break
else:
    print("[-] Solution not found :thinking_face:")

print("[+] Prefix = " + prefix)
p.sendline(prefix)
p.recvuntil('size')
p.sendline(str(len(attack_bytes)))
p.recvuntil('code')
p.sendline(attack_bytes)
p.interactive()

kstack

f:id:hikalium:20201011204833p:plain

なんか/proc/stackというファイルに対するioctlを生やすkernel moduleが入ったマシンにつながるので、そいつのバグを突いて権限昇格しようね、という問題だったようです。 QEMUで実行できるようイメージと引数の書かれたシェルスクリプトが一緒に配られていて、めちゃくちゃ親切でうれしかったです。

ioctlの仕様としては、ioctl(fd, CMD_PUSH, &value)を発行すると、その値がユーザーランドからカーネル空間のスタック(実体は各要素がkmalloc(GFP_KERNEL)で確保されたLinked List)に格納され、ioctl(fd, CMD_POP, &value)を発行すると、そのカーネル空間のスタックからユーザーランドに値がコピーされていく、というものでした。 ちなみに、そのLinked Listの各要素には、格納したタスクのtgidが一緒に記録されていて、あるtgidが記録したデータに対する操作はそのtgidからしかできないようになっていました。

これについては全然進捗がなくて、複数タスクから同時にPOP処理をかけたら、解放された領域を指し続けるLinked Listが構成できそうとか、pidがぐるっと一周したらどうなる?とか色々あったのですが、結局時間も割けなくてとけませんでした。もっとカーネルに詳しくなりたいですね。

作問者による簡易解説ツイート

感想

バイナリおいしい!あとカーネルむずかしい…。

全体的に、問題のクオリティがかなり上がっているという印象を受けました。とてもよいことだと思います。それに、何より問題を解くのが楽しかったです。(可視化とかも面白かった。)

運営のみなさん本当にお疲れ様でした!ありがとうございましたー!