All Articles

Iris CTF 2024 Writeup

新年初めての CTF は、昨年同様 IrisCTF でした。

簡単に Writeup を書いていきます。

もくじ

The Johnson’s(Rev)

Please socialize with the Johnson’s and get off your phone. You might be quizzed on it!

問題バイナリとして与えられた ELF ファイルを解析すると、 Color と Food に対応する文字列を順番に入力として受け取るプログラムでした。

check 関数を調査したところ、以下のように入力値として受け取った Color と Food の値と対応する ID がどの変数に格納されるかチェックを行っていることがわかります。

image-20240106113116528

このチェック部分を整理したとこと、以下のような条件式とイコールとなる比較を行っていることがわかりました。

food[0] == chicken
food[2] != pasta
food[3] != pasta
food[3] != steak

color[0] != green
color[1] != red
color[1] != green
color[2] != yellow
color[3] == blue

ここから、Flag を取得するために必要な文字列の入力順序は以下であることを特定できました。

color[0] == red
color[1] == yellow
color[2] == green
color[3] == blue

food[0] == chicken
food[1] == pasta
food[2] == steak
food[3] == pizza

以上の順に文字列を入力することで正しい Flag を取得できました。

image-20240106113008800

Rune? What’s that?(Rev)

Rune? Like the ancient alphabet?

問題バイナリとして与えられたものは以下の go スクリプトと、iÛÛÜÖ×ÚáäÈÑ¥gebªØšÔž’Íãâ£i¥§²ËÅÒÍÈä という謎の文字列でした。

package main

import (
	"fmt"
	"os"
	"strings"
)

var flag = "irisctf{this_is_not_the_real_flag}"

func init() {
	runed := []string{}
	z := rune(0)

	for _, v := range flag {
		runed = append(runed, string(v+z))
		z = v
	}

	flag = strings.Join(runed, "")
}

func main() {
	file, err := os.OpenFile("the", os.O_RDWR|os.O_CREATE, 0644)
	if err != nil {
		fmt.Println(err)
		return
	}

	defer file.Close()
	if _, err := file.Write([]byte(flag)); err != nil {
		fmt.Println(err)
		return
	}
}

スクリプトを読んでみると、flag 変数で定義した Flag 文字列を加工した結果が、問題バイナリとして与えられた意味不明な文字列として出力されることがわかります。

詳しい変換の実装を読んでみると、単純に前の文字に次の文字を足した値を Unicode 文字として表示しているだけであることがわかりました。

そこで、以下の Script を使用して、正しい Flag が irisctf{i_r3411y_1ik3_num63r5} であることを特定しました。

def decode_string(encoded_string):
    decoded_chars = []
    previous_char_unicode = 0

    for char in encoded_string:
        original_char_unicode = ord(char) - previous_char_unicode
        original_char = chr(original_char_unicode)
        decoded_chars.append(original_char)
        previous_char_unicode = ord(original_char)

    return ''.join(decoded_chars)

encoded_string = r'iÛÛÜÖ×ÚáäÈÑ¥gebªØšÔž’Íãâ£i¥§²ËÅÒÍÈä'
original_string = decode_string(encoded_string)
print(original_string)

Secure Computing(Rev)

Your own secure computer can check the flag! Might have forgotten to add the logic to the program, but I think if you guess enough, you can figure it out. Not sure

問題バイナリとして、chal という ELF バイナリと、以下の C コード、そして Dockerfile が与えられます。

// Here's a snippet of the source code for you
int main() {
    printf("Guess: ");
    char flag[49+8+1] = {0};
    if(scanf("%57s", flag) != 1 || strlen(flag) != 57 || strncmp(flag, "irisctf{", 8) != 0 || strncmp(flag + 56, "}", 1)) {
        printf("Guess harder\n");
        return 0;
    }
#define flg(n) *((__uint64_t*)((flag+8))+n)
    syscall(0x1337, flg(0), flg(1), flg(2), flg(3), flg(4), flg(5));
    printf("Maybe? idk bro\n");

    return 0;
}
FROM ubuntu:latest

RUN apt update && apt install -y gdbserver

COPY chal /
CMD /chal

与えられた問題バイナリを Ghidra で解析してみると、問題バイナリとして与えられた C のコードと一致していそうなことがわかります。

ここでは、irisctf{ から始まり } で終わる 57 文字を入力として受け取ります。

続けて、#define flg(n) *((__uint64_t*)((flag+8))+n) で定義された flg(n) によって、flag の 8 文字目から 56 文字目までが 8 文字ずつ syscall 関数の引数として与えられます。

image-20240110120644240

実際に Flag 文字列に対して何らかの処理を行うのはこの 0x1337 で呼び出されている syscall 関数のようですが、実際にこの syscall が何を行うのかこのままではわかりません。

0x1337 は一般的な Linux の syscall ではなさそうなので、どこかで独自に定義されている可能性が高そうです。

しかし、問題バイナリの Dockerfile を見る限り、実行に使用しているイメージは ubuntu 公式のものであり、特にカーネルモジュールなどを追加している様子はありません。

そこで少々悩みましたが、どうやら実際に syscall 0x1337 で呼び出される処理を実行しているわけではなさそうなことがわかりました。

システムコール呼び出し時の処理をプログラム側から操作するアプローチとしては seccomp があるようです。

Linux カーネルには、バージョン 2.6.12 から seccomp という仕組みが導入されています。

seccomp はアプリケーションが実行できるシステムコールを制限することによりセキュリティを高める機能です。

seccomp では、プロセスが実行可能なシステムコールをホワイトリスト形式で制御します。

この仕組みにより、プロセスでシステムコールが発行されると、seccomp のフィルタを通して実行可否の決定などのアクションが実行されるようです。

詳しくは以下の記事が参考になりました。

参考:seccompを利用し、自プロセスから発行されるシステムコールを制限する

上記の記事の通り、seccomp フィルタは prctl で追加できます。

この記事で使用しているプログラムでは、seccomp-tools dump ツールを使うことで seccomp フィルタを一覧できたようですが、今回の問題バイナリではこの方法は使用できませんでした。

sudo apt install gcc ruby-dev -y
sudo gem install seccomp-tool
seccomp-tools dump ./a.out

参考:david942j/seccomp-tools: Provide powerful tools for seccomp analysis

どのような seccomp フィルタが登録されているかを調べるため、バイナリを解析していきます。

Ghidra でデコンパイルした entry 関数のコードから _libcstart_main の呼び出しを見てみると、init と fini に当たる第 4 引数と第 5 引数に何かが定義されていることがわかります。

void FUN_555555400a20(undefined8 param_1,undefined8 param_2,undefined8 param_3)
{
  undefined8 unaff_retaddr;
  undefined auStack_8 [8];
  __libc_start_main(main,unaff_retaddr,&stack0x00000008,FUN_555555400b30,FUN_555555400ba0,param_3,
                    auStack_8);
  do {
                    /* WARNING: Do nothing block with infinite loop */
  } while( true );
}

_libcstart_main における init は初期化関数を指し、init が定義されている場合には main 関数の実行前に何らかの処理が呼び出されます。一般には、グローバル変数の初期化などがここで実行されるようです。

参考:_libcstart_main

Ghidra 上では、init に指定されている関数は _DTINIT_ARRAY として参照できました。

image-20240113221338303

ここから、以下の prctl を使用するコードがプログラムの起動時に呼び出されることがわかります。

void init_unkown(void)
{
  long lVar1;
  long in_FS_OFFSET;
  undefined2 local_48 [4];
  undefined8 local_40;
  long local_30;
  
  local_30 = *(long *)(in_FS_OFFSET + 0x28);
  lVar1 = ptrace(PTRACE_TRACEME,0);
  if (-1 < lVar1) {
    lVar1 = 0;
    prctl(0x26,1,0,0,0);
    do {
      local_48[0] = (undefined2)*(undefined8 *)((long)&DAT_555555602020 + lVar1);
      local_40 = *(undefined8 *)((long)&PTR_DAT_55555563d560 + lVar1);
      lVar1 = lVar1 + 8;
      syscall(0x13d,1,0,local_48);
    } while (lVar1 != 0x40);
  }
  if (local_30 == *(long *)(in_FS_OFFSET + 0x28)) {
    return;
  }
                    /* WARNING: Subroutine does not return */
  __stack_chk_fail();
}

ここで seccomp のフィルタを登録しているようです。

まず、このプログラムの seccomp フィルタについては実際のコードから判断する限り、while ループ内の syscall(0x13d,1,0,local_48); の行で seccomp フィルタを登録していそうなことがわかります。

この 0x13d という ID は調べても何も情報が見つかりませんでしたが、第 4 引数に seccomp フィルタを与えていることから、syscall(SYS_seccomp, SECCOMP_SET_MODE_FILTER, 0, &prog) の呼び出しに該当していると推測できます。

参考:seccomp(2) - Linux manual page

このコードは、while ループの中で 0x40 / 8 回、つまり 8 回呼び出されます。

実際に &PTR_DAT_55555563d560 のセクションを調べてみると、20 00 00 00 ... つまり A = sys_number から始まる 8 つのメモリ領域のアドレスがリストされていることを確認できます。

image-20240114174453793

つまり、このプログラムでは 8 つの seccomp フィルタを起動時に登録していると考えられます。

8 つのフィルタをそれぞれ抜き出して seccomp-tools disasm --no-bpf を使用すれば各フィルタを抽出できそうでしたが、終端を特定できず断念しました。

そこで、再度 seccomp-tools dump による出力を得るため、このコードを解析していきます。

seccomp-tools dump では、プログラムを実行し ptrace でアタッチすることで seccomp フィルタを抽出するようです。

前述のコードを見ると、ptrace(PTRACE_TRACEME,0); の行でアンチデバッグの機能が追加されています。

つまり、seccomp-tools が ptrace を使用する場合にはこのフィルタ登録の処理がスキップされるために、先ほどの seccomp-tools dump によるフィルタの出力が失敗していたと考えられます。

そこで、Ghidra でパッチを当ててこのアンチデバッグ処理部分をすべて無効化しました。

image-20240113222332408

これで、seccomp-tools dump コマンドでフィルタをダンプできるようになりました。

フィルタの出力は膨大な量になりましたので、一部のみ記載しています。

概ね以下のような形で、まずシステムコールの ID が 0x1337 であるかをチェックし、その後、特定の値に対して演算を繰り返し、最後に特定の値との比較が行われます。

$ seccomp-tools dump ./chal_patched

line  CODE  JT   JF      K
=================================
 0000: 0x20 0x00 0x00 0x00000000  A = sys_number
 0001: 0x15 0x01 0x00 0x00001337  if (A == 0x1337) goto 0003
 0002: 0x06 0x00 0x00 0x7fff0000  return ALLOW
 0003: 0x03 0x00 0x00 0x0000000b  mem[11] = X
 0004: 0x04 0x00 0x00 0x9a0b31d4  A += 0x9a0b31d4
 0005: 0x04 0x00 0x00 0x5245d02a  A += 0x5245d02a
 0006: 0x1c 0x00 0x00 0x00000000  A -= X
 0007: 0x04 0x00 0x00 0x7d5a280a  A += 0x7d5a280a
 0008: 0x1c 0x00 0x00 0x00000000  A -= X
 0009: 0x24 0x00 0x00 0x000081af  A *= 0x81af
 0010: 0x03 0x00 0x00 0x00000003  mem[3] = X
**
 2692: 0x04 0x00 0x00 0xb06bedbc  A += 0xb06bedbc
 2693: 0x20 0x00 0x00 0x0000001c  A = args[1] >> 32
 2694: 0x44 0x00 0x00 0xc7fdf7c2  A |= 0xc7fdf7c2
 2695: 0x04 0x00 0x00 0x88410078  A += 0x88410078
**
 3791: 0x60 0x00 0x00 0x0000000f  A = mem[15]
 3792: 0x15 0x00 0x01 0xd101957e  if (A != 3506541950) goto 3794
 3793: 0x06 0x00 0x00 0x00050000  return ERRNO(0)
 3794: 0x06 0x00 0x00 0x00000000  return KILL

このチェックに失敗した場合には seccomp にてスレッドが KILL され、成功した場合には True を返しているようです。

いくつかの変数を見ていきます。

まず arg[] については、0 から 5 までの添え字を持つ配列変数として扱われていそうです。

これは、syscall(0x1337, flg(0), flg(1), flg(2), flg(3), flg(4), flg(5)); にて呼び出される Flag 文字を 8 文字ずつ分割した値に該当していると考えられます。

次に A と X ですが、こちらは恐らくレジスタ的に使用される一時変数の可能性が高そうです。

最後に mem[] ですが、これは 0 から 15 までの添え字を持つ配列変数です。

最終的にハードコードされた値との比較は、この mem の各要素に対して実行されます。

そのため、何らかの形で入力として受け取った Flag 文字を変換して mem に格納していると予想されます。

実際に行われている処理から判断する限り、この演算自体は Z3 で解読できそうです。

ただし、4000 行近い処理をすべて手動で Z3 に落とし込むのは断念しました。

そこで、Discord で共有されていた Solver を参考にし、この出力から Z3 の制約を自動作成して Flag を取得するプログラムを作成していきます。

まずは以下のコマンドで seccomp-tools dump の出力結果から不要な部分を削除します。

seccomp-tools dump ./chal_patched -l 8 | grep -Pv "=======|CODE" > seccomp_filter.txt

次に、各種初期値を与えていきます。

変数 arch については、AUDITARCHX86_64 の値を設定します。

from z3 import *

s = Solver()
def add_cons(v):
    s.add(And(v > ord(' '), v <= ord('~')))

mem = [0, ]*16
X = 0
A = 0
sys_number = 0x1337
arch = 0xc000003e # AUDIT_ARCH_X86_64

# bpf is 32 bits
args = []       # low DWORD
args2 = []      # hight DWORDs

次の箇所では、arg と arg_2 を 0 から 5 までの 6 つ定義します。

さらに、それぞれを 4 バイトに分割し、1 バイトごとに Printable ASCII の制約を付与します。

# args is lower DWORD
for x in range(6):
    v = BitVec("arg%d"%x, 32)
    args.append(v)
    add_cons(Extract(7, 0, v))
    add_cons(Extract(15, 8, v))
    add_cons(Extract(23, 16, v))
    add_cons(Extract(31, 24, v))

# args2 is upper DWORD
for x in range(6):
    v = BitVec("arg_2%d"%x, 32)
    args2.append(v)
    add_cons(Extract(7, 0, v))
    add_cons(Extract(15, 8, v))
    add_cons(Extract(23, 16, v))
    add_cons(Extract(31, 24, v))

これによって、各引数(= Flag が 8 文字ごとに格納されている)の変数を定義することができます。

ここから、取得した seccomp_filter.txt の処理を一行ずつ追っていきます。

i = -1
with open("seccomp_filter.txt") as fp:
    for line in fp:
        i += 1

        # 命令の抽出
        ins = line.split("  ")[1].strip()
        # print(line)
        # print(ins)

        # Return 文を無視する
        if ins.startswith("return "):
            continue

        # args[0] >> 32 のような演算を特定する
        # これによって、上位 32 bit 文の文字を取得し、変数名をベクタ名に合わせる
        elif " >> " in ins:
            ins = ins[:11] # A = args[x]
            ins = ins.replace("args", "args2")

        # if (A == 0x1337) goto 0003 のような if 文の処理を制約に追加する
        if ins.startswith("if ("):
            val = ins.split()[3][:-1] # if (A == val)
            if val.startswith("0x"):
                val = int(val[2:], 16)
            else:
                val = int(val)

            s.add(A == val)
            continue

        # A ^= X など、そのまま Python コードとして実行可能な行を実行する
        exec(ins)

        # 32 bit int を維持
        A &= 0xffffffff

最後に、Z3 で制約を解くことで、正しい Flag 文字が 1f_0nly_s3cc0mp_c0ulD_us3_4ll_eBPF_1nstruct10ns! であることを特定できます。

assert s.check() == sat
model = s.model()
out = b''

# 各 arg と atg_2 の文字を連結して出力
for x in range(len(args)):
    v = model[args[x]].as_long()
    out += bytes.fromhex(hex(v)[2:])[::-1]
    v = model[args2[x]].as_long()
    out += bytes.fromhex(hex(v)[2:])[::-1]
print(out)

Solver 全文は以下。

# seccomp-tools dump ./chal_patched -l 8 > seccomp_filter.txt && sed -i '1,2d' seccomp_filter.txt
from z3 import *

s = Solver()
def add_cons(v):
    s.add(And(v > ord(' '), v <= ord('~')))

mem = [0, ]*16
X = 0
A = 0
sys_number = 0x1337
arch = 0xc000003e # AUDIT_ARCH_X86_64

# bpf is 32 bits
args = []       # low DWORD
args2 = []      # hight DWORDs


# args is lower DWORD
for x in range(6):
    v = BitVec("arg%d"%x, 32)
    args.append(v)
    add_cons(Extract(7, 0, v))
    add_cons(Extract(15, 8, v))
    add_cons(Extract(23, 16, v))
    add_cons(Extract(31, 24, v))

# args2 is upper DWORD
for x in range(6):
    v = BitVec("arg_2%d"%x, 32)
    args2.append(v)
    add_cons(Extract(7, 0, v))
    add_cons(Extract(15, 8, v))
    add_cons(Extract(23, 16, v))
    add_cons(Extract(31, 24, v))


i = -1
with open("seccomp_filter.txt") as fp:
    for line in fp:
        i += 1

        # 命令の抽出
        ins = line.split("  ")[1].strip()
        # print(line)
        # print(ins)

        # Return 文を無視する
        if ins.startswith("return "):
            continue

        # args[0] >> 32 のような演算を特定する
        # これによって、上位 32 bit 文の文字を取得し、変数名をベクタ名に合わせる
        elif " >> " in ins:
            ins = ins[:11] # A = args[x]
            ins = ins.replace("args", "args2")

        # if (A == 0x1337) goto 0003 のような if 文の処理を制約に追加する
        if ins.startswith("if ("):
            val = ins.split()[3][:-1] # if (A == val)
            if val.startswith("0x"):
                val = int(val[2:], 16)
            else:
                val = int(val)

            s.add(A == val)
            continue

        # A ^= X など、そのまま Python コードとして実行可能な行を実行する
        exec(ins)

        # 32 bit int を維持
        A &= 0xffffffff       

assert s.check() == sat
model = s.model()
out = b''

# 各 arg と atg_2 の文字を連結して出力
for x in range(len(args)):
    v = model[args[x]].as_long()
    out += bytes.fromhex(hex(v)[2:])[::-1]
    v = model[args2[x]].as_long()
    out += bytes.fromhex(hex(v)[2:])[::-1]
print(out)

Not Just Media(Forensic)

I downloaded a video from the internet, but I think I got the wrong subtitles.

Note: The flag is all lowercase.

問題バイナリとして受け取った mkv を mkvinfo で解析します。

$ mkvinfo chal.mkv
+ EBML head
|+ EBML version: 1
|+ EBML read version: 1
|+ Maximum EBML ID length: 4
|+ Maximum EBML size length: 8
|+ Document type: matroska
|+ Document type version: 4
|+ Document type read version: 2
+ Segment: size 25689323
|+ Seek head (subentries will be skipped)
|+ EBML void: size 4012
|+ Segment information
| + Timestamp scale: 1000000
| + Multiplexing application: libebml v1.4.4 + libmatroska v1.7.1
| + Writing application: mkvmerge v80.0 ('Roundabout') 64-bit
| + Duration: 00:02:11.674000000
| + Date: 2024-01-05 00:28:38 UTC
| + Segment UID: 0x0b 0xea 0x43 0x59 0xc7 0xd0 0x77 0xd9 0x8a 0xaf 0x19 0x68 0x93 0x40 0xd7 0xe4
|+ Tracks
| + Track
|  + Track number: 1 (track ID for mkvmerge & mkvextract: 0)
|  + Track UID: 15645917742896964978
|  + Track type: video
|  + "Lacing" flag: 0
|  + Language: und
|  + Codec ID: V_MPEG4/ISO/AVC
|  + Codec's private data: size 51 (H.264 profile: High @L3.2)
|  + Default duration: 00:00:00.016666666 (60.000 frames/fields per second for a video track)
|  + Language (IETF BCP 47): und
|  + Video track
|   + Pixel width: 1280
|   + Pixel height: 720
|   + Display width: 1280
|   + Display height: 720
| + Track
|  + Track number: 2 (track ID for mkvmerge & mkvextract: 1)
|  + Track UID: 516687677308344442
|  + Track type: audio
|  + Language: und
|  + Codec ID: A_AAC
|  + Codec's private data: size 5
|  + Default duration: 00:00:00.023219954 (43.066 frames/fields per second for a video track)
|  + Language (IETF BCP 47): und
|  + Audio track
|   + Sampling frequency: 44100
|   + Channels: 2
| + Track
|  + Track number: 3 (track ID for mkvmerge & mkvextract: 2)
|  + Track UID: 4321065271376252327
|  + Track type: subtitles
|  + "Forced display" flag: 1
|  + "Lacing" flag: 0
|  + Language: und
|  + Codec ID: S_TEXT/ASS
|  + Codec's private data: size 965
|  + Language (IETF BCP 47): und
|+ EBML void: size 1172
|+ Attachments
| + Attached
|  + File name: NotoSansTC-Regular_0.ttf
|  + MIME type: font/ttf
|  + File data: size 7110560
|  + File UID: 13897746459734659379
|  + File description: Imported font from Untitled.ass
| + Attached
|  + File name: FakeFont_0.ttf
|  + MIME type: font/ttf
|  + File data: size 64304
|  + File UID: 13557627962983747543
|  + File description: Imported font from Untitled.ass
| + Attached
|  + File name: NotoSans-Regular_0.ttf
|  + MIME type: font/ttf
|  + File data: size 582748
|  + File UID: 7918181187782517176
|  + File description: Imported font from Untitled.ass
|+ Cluster

すると、このファイルには動画と音楽の他に、我們歡迎您接受一生中最大的挑戰,即嘗試理解這段文字的含義 という文字を表示する字幕設定が埋め込まれていることがわかりました。

image-20240106154348057

しかし、この動画を再生しても字幕は正しく表示されません。

さらに mkvinfo の結果を解析すると、Attachments に FakeFont_0.ttf という怪しげなフォントデータが埋め込まれていることがわかります。

以下のコマンドでフォントデータのみを抜き出しました。

mkvextract attachments chal.mkv 2:FakeFont_0.ttf

試しに、我們歡迎您接受一生中最大的挑戰,即嘗試理解這段文字的含義 という文字列を抽出したフォントデータでレンダリングしてみたところ、正しい Flag が表示されました。

image-20240106161119964

フォントのレンダリングには以下のスクリプトを使用しました。

from PIL import Image, ImageDraw, ImageFont

font_file = './FakeFont_0.ttf'
font = ImageFont.truetype(font_file, 40)

image = Image.new('RGB', (1024, 300), color=(255, 255, 255))
draw = ImageDraw.Draw(image)

text = "我們歡迎您接受一生中最大的挑戰,即嘗試理解這段文字的含義"
draw.text((10, 10), text, fill=(0, 0, 0), font=font)

image_path = './flag.png'
image.save(image_path)

Where’s skat?(Network)

While traveling over the holidays, I was doing some casual wardriving (as I often do). Can you use my capture to find where I went?

Note: the flag is irisctf{thelocation}, where thelocation is the full name of my destination location, not the street address. For example, irisctf{Washington_Monument}. Note that the flag is not case sensitive.

問題バイナリとして与えられた pcap ファイルを解析すると、以下のような SSID を持つアクセスポイントと通信していることがわかります。

image-20240106202322056

これらのアクセスポイントを Wigle で調べてみたところ、そのアクセスポイントが存在する座標を特定できました。

image-20240106202338335

この座標を Google Map で調べた結果、irisctf{Los_Angeles_Union_Station} が正解の Flag になることがわかりました。

image-20240106202348174

まとめ

新年初の CTF でしたが、新しい学びがありとても楽しかったです。

最近は何かと eBPF に触れる機会が多い気がするので、Linux の低レイヤ周りももっと勉強していかないとなという感じです。