DiceCTF 2023 writeups
I wrote 3 challenges for DiceCTF 2023, here are writeups going over their intended solutions
geminiblog
misc, 3 solves
Overview
geminiblog consists a client and server for the Gemini protocol, written entirely in bash.
When we connect to the challenge, we are connected to the client and are able to issue requests to arbitrary gemini://
URLs.
The server runs on the same host as the client, listening on 127.0.0.1:1965. It is a small application to write and read blog posts, which are stored in a memcached instance on the same host (:11211). The 3 endpoints are:
/
- a hello world message/post
- create a blog post, returns post ID (inserts random post ID into memcached)/post?$id
- view a blog post with given ID (fetch from memcached)
The flag for the challenge is stored in the memcached instance, as the key flag
bug 1: bash argument injection
in client.sh:8-10
, there's a bash argument injection bug due to $host:$port
not being quoted
This allows us to inject arbitrary arguments to the openssl s_client
command
bug 2: IFS not restored
in lib.sh:28-35
, the IFS variable is changed to parse the URL querystring, but is never restored to its original value
Because it's never restored, when doing argument expansion in bug 1, =&
will be used to split arguments in $host
rather than the default IFS.
memcached ssrf & openssl s_client arguments
Now, it should be relatively clear that the goal is to find arguments to openssl s_client
that allow us to send commands to memcached and exfiltrate the flag.
We have 2 problems:
1. exfiltration
we need to be able to read the memcached output somehow. stderr is discarded and anything that isn't a valid TLS response is suppressed by the -quiet flag
solution: -debug
flag outputs a full dump of raw bytes sent and received to stdout!
2. sending plaintext data
s_client establishes a TLS connection, but we need to send plaintext data and linefeeds to issue memcached commands... so how can we?
solution: SNI injection via -servername
argument! the SNI field of a TLS Client Hello message contains the requested host in plaintext, and we can change this via the -servername
argument
But... we have another problem, although we can set -servername
to a string we control, we don't have a way to inject linefeeds into it. So, how do we send valid memcached commands? The memcached protocol dictates that each command should be separated by a CRLF (\r\n
).
- Turns out, the bytes immediately following the SNI value are null bytes, and when memcached sees a null byte, it will stop reading the command😅 so we luck out there
- To get the command to start on a newline, we simply make it 10 length, because the bytes immediately preceding it is the length field of the servername. We can do this by padding it with spaces (memcached will strip them)
- Our servername simply becomes:
"get flag "
Putting it all together, our final payload is
jnotes
web, 6 solves
Overview
jnotes is a very simple webapp, it's written in Java using the Javalin web framework. There are endpoints to render the note (/
) and edit the note (/create
), and the note is stored in a note
cookie on the browser.
There's also an adminbot, which has the flag set in an HttpOnlyFLAG
cookie for the challenge domain, and will visit any URL you send it.
The relevant source code is below:
XSS is straightforward, as note HTML isn't escaped. CSRF is straightforward as well, and we can POST to the /create endpoint from a crossorigin site easily.
However, in a standard setting, this challenge is still impossible, because the FLAG
cookie is HttpOnly and should be inaccessible by JavaScript in the browser, we have no way to read it!
Solution
The idea behind the challenge is to find and utilize a parsing bug in the Jetty webserver, which is what Javalin uses by default. Looking at Jetty's cookie parsing code, it's clear that it's more convoluted than it needs to be, using a weird state machine to parse cookies.
It turns out, that if Jetty reads a cookie value starting with an open quote, it will continue reading the cookie string until it reaches an end quote – ignoring semicolons which should separate cookies!
e.g. if we have 3 cookies:
- note=
"a
- FLAG=
dice{flag}
- end=
b"
the browser sends the cookie header: note="a; FLAG=dice{flag}; end=b";
but Jetty will parse it as a single cookie: note= a; FLAG=dice{flag}; end=b;
So now – the basic idea to solve: smuggle the FLAG
cookie into notes
cookie, use XSS to read the rendered contents on the page.
We can use our XSS to tamper with the cookies (setting document.cookie
), but the problem is – how do we get the cookies to be in that order?
We need to abuse chrome's cookie ordering behavior to smuggle it correctly
- chrome orders cookies by longest path length first, then least recently updated first. We can figure this out by reading some chromium source code
So, to get our custom note
cookie to appear first, we simply create a new note cookie with the path //
Our javascript payload becomes something like:
this results in the sent cookiestr being something like:
and from there, we can read the opened window HTML to get the flag.
Putting it all together, here's the HTML of the page we make the adminbot visit.
impossible-xss
web, 0 solves
impossible-xss is another challenge with very brief source code. This challenge actually got 0 solves during the CTF - which, is unsurprising given how esoteric the solution is.
The challenge consists of a simple express.js webserver with two routes.
/
- gives us XSS via the?xss
query param/flag
- returns theFLAG
cookie value
The flag is stored in the admin bot's FLAG
cookie, which is rendered by the /flag
endpoint. So, the goal appears to be to exfiltrate the contents of /flag
from the adminbot, presumably using the xss on /
. Up until this point, the challenge seems pretty straightforward.
However, moving to the code of the adminbot, we see that it uses puppeteer (chrome headless browser) to view the URL you send it, but it calls .setJavascriptEnabled(false)
on the page.
Now, the challenge suddenly becomes a lot harder - how can we exfiltrate the contents of /flag
(or otherwise read the FLAG
cookie value) without JavaScript?
Solution
As far as I'm aware, .setJavaScriptEnabled
is not bypassable, it propagates into subframes, and I don't think there's any way to open new pages without JS execution. So, we need to find another way to read /flag
without JavaScript.
One thing to realize is that the express.js webserver sends our input back with res.end
instead of res.send
– meaning there's no Content-Type: text/html
header on the response. This means chrome will perform content sniffing on the response. Since we can't use JavaScript, what other powerful forms of execution might browsers have?
It turns out XML documents in web browsers support a relatively little known feature known as XSLT – Extensible Stylesheet Language Transformations. XSLT is essentially an XML specification for transforming XML documents into other XML documents or HTML.
Although XSLT is blocked by client-side security features such as CSP script-src and sandboxing, it is not blocked by Puppeteer's .setJavaScriptEnabled
.
If you decided to investigate why the challenge doesn't just use a script-src
, you can find a reference to XSLT in MDN's script-src page!
The HTTPContent-Security-Policy
(CSP)script-src
directive specifies valid sources for JavaScript. This includes not only URLs loaded directly into<script>
elements, but also things like inline script event handlers (onclick
) and XSLT stylesheets which can trigger script execution.
XSLT allows for some powerful functionality, such as loading other XML documents and other functions to process XML. Because XSLT uses XML, it also allows for XML External Entities (XXE)!
If you have an External Entity referring to an HTTP resource, Chrome will gladly request it and put the contents into the XML entity:
So, we construct an XML document that uses XSLT. Our XSLT stylesheet contains an XXE that loads the flag, and sends the flag to our server via an HTML element, such as an img src attribute.
Our final payload becomes:
and upon submitting it, the flag is sent to our server: dice{XXE_in_th3_br0ws3r_WtF}