# SECCON CTF 2022 Finals writeup

I wrote all the web challenges in SECCON CTF 2022 Finals, following the Quals round. Thank you for participating in the CTF and I was glad to receive positive feedback at the after-party and on Twitter/Discord.

In this post, I describe my solution for the following challenges:

Challenge Category Intended
Difficulty
Score
(static)
Solved / 10
(Internatinal)
Solved / 12
(Domestic)
babybox web warmup 100 6 4
easylfi2 web easy 200 10 8
MaaS web medium 300 3 1
light-note web medium 300 0 0
dark-note web hard 500 0 0

These seemed hard compared to the other categories and there were two 0-solved challenges. So I'm sorry for the inappropriate scores bacause they are important values, especially in static scoring. I think it's very difficult to estimate the difficulty of tasks.

## [web 100] babybox

Description:

Can you hack this sandbox?

• http://babybox.{int,dom}.seccon.games:3000

### Overview

The server-side source code is very simple:

In POST /calc, the server executes calc.js as a subprocess with a parameter expr and returns the result. The implementation of calc.js is as follows:

This is also simple.

As you can see from Dockerfile, the file name of a flag is unknown:

Thus, this is a JavaScript sandbox challenge and the goal is RCE.

### Solution

The server uses the latest of expr-eval, so is this challenge a 0-day RCE?

No.

You can find this open issue from the repository in GitHub. According to this, the latest version (published to npm) has a vulnerability although it was already patched on the latest commit. The vulnerability is Prototype Pollution:

So, what you should do is "Prototype Pollution to RCE"[1].

For this type of JavaScript sandbox challenges, it's often important to somehow obtain eval or Function.prototype.constructor to RCE.

In REPL of Node.js, I tried many things and found the following useful behavior:

The code is polluting value to Function.prototype.constructor. Finally, my expr is:

Got a RCE!

## [web 200] easylfi2

Description:

easylfi again! I know you fully understand everything about curl.

• http://easylfi2.{int,dom}.seccon.games:3000

### Overview

The server-side code is as follows:

It is obviously vulnerable for path traversal.

However, the WAF disallows responses including a flag.

The goal in this challenge is bypassing the WAF.

### Solution

In this part, why is JSON.stringify used?
Are there cases that ctx.body is not string?

Yes.

If a subprocess causes an error, ctx.body becames the error object:

E.g.:

If you can cause an error including a substring of a flag so that it don't match with /SECCON{\w+}/, you can avoid the WAF and get the substring in the response body.

Here, you need one idea: what would happen if the stdout of a subprocess is very very very large?

It causes an error and the stdout of the error object is a prefix of the file content.

Noje.js docs says the following for maxBuffer option of execFile:

maxBuffer <number>: Largest amount of data in bytes allowed on stdout or stderr. If exceeded, the child process is terminated and any output is truncated. See caveat at maxBuffer and Unicode. Default: 1024 * 1024.

So, with this specification, making "SECCON{...}".slice(0, -1) as a result of curl execution is to steal a flag. In fact, it is possible using URL globbing of curl. See the following Solver section.

## [web 300] MaaS

Description:

Minifier as a Service

• http://maas.{int,dom}.seccon.games:3000

### Overview

If you post a JavaScript code, you will get a minified code and the compression rate:

Also, you can report a JavaScript code to a bot, then the bot submits the given code on the web service.

The bot program is as follows:

The goal is to get the flag cookie of the bot by XSS.

### Solution

#### Step 1: Newline normalizations in form submissions

The implementation of the form submission is as follows:

Your code is minified with terser and the following values are sent to POST /post:

• minifiedCode: the string of the minified code
• originalLength: the length of the original code
• minifiedLength: the length of the minified code

Then, the server processes them as follows:

views/result.html:

The function escapeHtml escapes the minified code to avoid XSS. The program assumes that minifiedLength is the length of the code.

Why it uses minifiedLength rather than minifiedCode.length as the length value? If the value of minifiedLength is controllable and is not equal to minifiedCode.length, you might be able to break the sanitization.

Here, you need to know an interesting behavior for form submissions. It is "newline normalization".

I prepared a playground to try the behavior:

When you input a string including \n and submit it, the \n is converted to \r\n:

I found a detailed post on newline normalizations. See it if you are interested:

Anyway, using \n seems to make sense. However, the \n characters will be erased by the minifier unfortunately.

Is there a way to maintain \n characters? See the documentation of terser:

comments (default "some") -- by default it keeps JSDoc-style comments that contain "@license", "@copyright", "@preserve" or start with !, pass true or "all" to preserve all comments, false to omit comments in the output, a regular expression string (e.g. /^!/) or a function.

The service uses default options for terser, so you can maintain \n characters using copyright comments like /*! foo\nbar */.

#### Step 2: CSP bypass

The service uses the following CSP:

You should bypass it to XSS.

The first step of CSP bypass is using <meta> to redirect to a web site that you prepared.

For example, the following code causes a redirection:

The many \n characters will be not erased by the minifier and will be converted to \r\n characters in a form submission. So, the value of minifiedLength will be minifiedCode.length - (meta.length + 3). Then, it will bypass escapeHtml and will redirect to ATTACK_BASE_URL.

In your redirected web site, you can controll submit values freely by CSRF. Now all you have to do is gain XSS using appropriate values of minifiedCode, originalLength, and minifiedLength.

Finally, you need to break the rendering of escapeHtml so that the CSP nonce is applied to an injected script. See index.html of my solver below[2].

My solution abuses the behavior of substring used in escapeHtml:

If indexStart is greater than indexEnd, then the effect of substring() is as if the two arguments were swapped; see example below.

### Solver

index.js:

index.html:

## [web 300] light-note

Description:

I created a blazing fast note application!

• https://light-note.{int,dom}.seccon.games

### Overview

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

Also, you can report a URL to a bot, then the bot executes the following program:

The goal is to steal the first note of the bot.

### Solution

The HTML file is as follows:

When each note is rendered, the write function is used:

The function uses Sanitizer API as a sanitizer. If an error occurs in the sanitizer, DOMPurify will be used as a fallback. Also, if an error occurs in import maps[3] or DOMPurify, input.replace(/[<>'"&]/, "") will be used as a fallback. Obliviously, the replace is vulnerable for XSS because the regex uses no flags such as /g.

Thus, what you should do is to make errors so that the second fallback is used, and then you gain XSS.

Here, you need to know security considerations for Sanitizer API:

4.2. DOM clobbering

This section is not normative.

DOM clobbering describes an attack in which malicious HTML confuses an application by naming elements through id or name attributes such that properties like children of an HTML element in the DOM are overshadowed by the malicious content.

The Sanitizer API does not protect DOM clobbering attacks in its default state, but can be configured to remove id and name attributes.

The sanitizer in write is not configured to remove id and name attributes. So, it does not protect DOM Clobbering attacks.

Firstly, let's break element.setHTML(...) by DOM Clobbering.

If the following value is not a function, an error will occur:

Also, the following value must be an Element object so that the assignment to innerHTML in the second fallback is valid:

These are completed by the following DOM..., really?

Try to create the note:

Then, the following value is null:

Why? See the DOM:

The inner <form> element was removed

See HTML Living Standard:

4.10.3 The form element
...
Content model:
Flow content, but with no form element descendants.

Nested form elements violate the content model of <form>. So, the browser removes the inner <form> when constructing a DOM tree for the input[4].

Hmm..., is there anything that could replace nested forms?

My solution uses form atttibute:

The following is valid as a DOM tree:

Then, you can get expected results if you create the note:

You could break Sanitizer API by DOM Clobbering!

Next, you need to break import maps or DOMPurify, but this part is easier than the above.

Read the source code of DOMPurify:

If window.document.nodeType is clobbered, DOMPurify will stop defining DOMPurify.sanitize. Then, an error will occur in DOMPurify.sanitize(input) and the second fallback will be used.

The DOM Clobbering to break DOMPurify is:

In summary, you can gain XSS by creating the following notes with CSRF.

First note:

Second note:

### Solver

index.js:

index.html:

## [web 500] dark-note

Description:

I created an incredibly blazing-fast note application!

Instancer:

Note: The instancer has no bugs or vulnerabilities (at least in my intended solution).

### Overview

This is also a note application. You can create and delete notes:

The server uses a template engine Hogan.js when rendering your notes:

Unlike light-note, the service has a login/signup system and you can change your emoji:

Also, you can report a URL to a bot, then the bot executes the following program:

The bot creates a note for each character of a flag string:

• S, E, C, C, O, N, {, ..., }

### Solution

The HTML file is as follows:

The assignment of notes uses textContent. So, XSS seems impossible.

The server-side code for rendering notes is as follows:

There is a Proxy using a get handler in an array notes. If you access notes[i], then you will get an rendered note with render. Obviously, there is SSTI for Hogan.js, but text.replace(/[#\^<\\$\/!>=&]/g, "") limits various features of Hogan.js. "SSTI to RCE" also seems impossible.

Here, read the source code of Hogan.js:

The template engine uses cache mechanism, and the cache key is:

If the text was already evaluated, the engine skips the compile process for text and uses the cached value as a compiled result.

In REPL of Node.js, let's confirm the cache effect:

The rendering time depends largely on whether cache is used or not. Is it possible to use the difference as an oracle to leak notes[i], which is the i-th character in the flag string?

To construct the oracle, you need to let the bot render the following text as a note:

Btw, the deleteNote function uses Array.prototype.splice to delete a note:

There is an interesting behavior between splice and Proxy:

When notes.splice(1, 1) was executed, the get handler of Proxy was implicitly called and "3" changed to "3x". So, the final result of notes[1] was "3xx" because the get handler was called again.

My solution abuses the above behavior to construct a time-based oracle.

Firstly, my solver lets the bot pollute cache in the template engine as follows (in index.html):

Next, my solver leaks the flag characters using the time-based oracle as follows (in index.js):

See my solver below for details.

### Solver

index.js:

index.html:

### Flag[6]

1. Actually, I discovered the Prototype Pollution before I found this report. Although I don't like 0-day challenges in CTF, it's not 0-day in this case. Also, the part of "Prototype Pollution to RCE" is interesting for me. So I decided to create this challenge. ↩︎

2. The CSP bypass is too complicated to explain. So, please try it with your hands ↩︎

3. Firefox has recently added support for import maps at version 108
ref. https://www.mozilla.org/en-US/firefox/108.0/releasenotes/ ↩︎

4. However, we can construct nested forms by DOM manipulation in JavaScript.
E.g. document.body.appendChild(document.createElement("form")).appendChild(document.createElement("form"))
ref. https://html.spec.whatwg.org/#association-of-controls-and-forms ↩︎