概要

今年も例年のように、チームHarekazeの一員として出ました。チームの結果は2123点で11位/579チーム(何らかのFLAGを提出したチーム数)でした。 そのうち、私はSCSBX:Reversingを解いて129ポイントを稼ぎました。(st98さんが一人で1552ポイントも稼いでおり目を回しています。)
あと、同じSCSBXをテーマにしたSCSBX:Escapeや、脆弱なkernel moduleの秘孔を突くkstackという問題にも挑戦しましたが、時間内に解くまでにはいたりませんでした。
ということで、挑戦した各問題の解法 or 挑戦のようすを書いておきます。
SCSBX:Reversing

ソースコードの公開された独自のバイトコード処理系と、それ向けに書かれたアプリケーションのバイナリが与えられています。 そのアプリケーションは、入力がフラグであるか否かを教えてくれるものでした。
$ ./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 ...
やっぱりスタックマシンだし長いですね。というわけで、じっとこれを見つめてデコンパイラごっこをします。
私なりのやり方としては、大体以下のような手順を踏みました。
- 分岐命令を境に、命令列をブロックに分ける。
- 上から辿っていって何をやっているかお気持ちを理解する
- 複雑な部分では、スタックを書いて追いながらロジックを推定する
たとえばこんな感じに、スタックの使われ方を書いていったり、
<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
...
}
さて、デコンパイラの気持ちを理解したところで、このプログラムは大体以下のような処理を行っていることがわかりました。
- 作業用のメモリ領域[0xDEAD0000 - 0xDEAD1000)をmapする
- そのメモリ領域に符号化されたフラグを展開する(0xDEAD004A から0x40バイト)
- 0xDEAD0000番地に値0x06D35BCDを格納する(おそらく何らかのシード値、後のロジックで使われる)
- ユーザーからの入力を受け取る(0x40バイト、これは0xDEAD000A番地から0x40バイトの空間に格納される)
- 上の擬似コードに書いたような処理を行って、ユーザーからの入力を符号化する
- 符号化した入力(0xDEAD000Aからの0x40バイト)と、展開しておいた符号化済みのフラグ値(0xDEAD004Aからの0x40バイト)を比較する
- それらが一致したら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構造と呼ばれるものらしい。
SCSBX:Escape
ところでこのSCSBX(SecCon SandBoX), reversingだけでなくsandboxの問題としても登場していました。

こちらは、リモートで動いている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

なんか/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がぐるっと一周したらどうなる?とか色々あったのですが、結局時間も割けなくてとけませんでした。もっとカーネルに詳しくなりたいですね。
作問者による簡易解説ツイート
SCSBX:Reversing: Feistel構造
— pwnyaa (@pwnyaa) 2020年10月11日
SCSBX:Escape: Guard Pageがunmapできる。pushに範囲チェックが無いのでVM instanceを破壊できる。偽のstd::vector<std::pair>に向けて範囲外read/writeが可能になる。libc baseはstack base,top上書き後にGOTの値をdupして取得。あとはvtable書き換え。
pwarmup: stdoutが閉じてるのでシェルコード実行。stdinをdupするなりリバースシェル作るなりご自由に。
— pwnyaa (@pwnyaa) 2020年10月11日
kstack: userfaultfdでrace。seq_operationsなどでRIPを取る。SMAP無効なのでuser-landでROP。
encryptor: libc-2.31でargv[0] leak。同じオプションを何度も指定できるのでstrcpyでNULLを量産。
感想
バイナリおいしい!あとカーネルむずかしい…。
全体的に、問題のクオリティがかなり上がっているという印象を受けました。とてもよいことだと思います。それに、何より問題を解くのが楽しかったです。(可視化とかも面白かった。)
運営のみなさん本当にお疲れ様でした!ありがとうございましたー!