It has been a long while since my last post and a long while since I last played in a CTF. Here are my writeups for a few of the Web Challenges in CSAW CTF 2018.

Thanks to @itszn @_ghost_ and the other web challenge writers for such awesome challenges. Also props to @_ghost_ for being super responsive and superhuman efficient in patching challenges during the CTF. These notes are a little bit unstructured, since they’re my direct thoughs while solving the challenge.

web 100 - sso

Don't you love undocumented APIs
Be the _admin_ you were always meant to be
Update chal description at: 4:38 to include solve details

Challenge info

home page:

  <a href="/protected">.</a>
  Wish we had an automatic GET route for /authorize... well they'll just have to POST from their own clients I guess
  POST /oauth2/token 
  POST /oauth2/authorize


(Things I ended up googling fortrying to find hints). I forgot how to OAuth so there were a lot more links on how oauth worked…

https://medium.com/bugbountywriteup/real-world-ctf-2018-writeup-4c48359330c2 -> nothing https://github.com/david942j/ctf-writeups/tree/master/hitcon-quals-2017/footbook -> mentioned auth but doesn’t have any details https://www.arneswinnen.net/2017/06/authentication-bypass-on-airbnb-via-oauth-tokens-theft/ -> seems to be client side oauth attack https://qiita.com/kusano_k/items/e264227b4de9298cfa40 -> Hijacking auth tokens in CTF


  • just going to use my auth client app I’ve already made, and then use that t proxy the requests to the remote host and see what happens.


At first I was trying to simply make the oauth requests using Postman, since that gave me the arguments. But it only accepted POST requests. I intercepted Postman, then pulled the arguments and used it myself. Turns out the challenge was a lot easier than I thought…

Main note: Auth requires content-type application/x-www-form-urlencoded

HTTP/1.1 302 Found
Location: http://v.mewy.pw:9447/redirurl?code=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRpZGZpZWxkIiwicmVkaXJlY3RfdXJpIjoiaHR0cDovL3YubWV3eS5wdzo5NDQ3L3JlZGlydXJsIiwiaWF0IjoxNTM2OTkyNjM4LCJleHAiOjE1MzY5OTMyMzh9.ZM0CxbqRMWdH6gy6oD0mdRWdrzU7aeLTIiE3ooAetYg&state=statefield
Content-Type: text/html; charset=utf-8
Content-Length: 603
Date: Sat, 15 Sep 2018 06:23:58 GMT
Connection: close

Redirecting to <a href="http://v.mewy.pw:9447/redirurl?code=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRpZGZpZWxkIiwicmVkaXJlY3RfdXJpIjoiaHR0cDovL3YubWV3eS5wdzo5NDQ3L3JlZGlydXJsIiwiaWF0IjoxNTM2OTkyNjM4LCJleHAiOjE1MzY5OTMyMzh9.ZM0CxbqRMWdH6gy6oD0mdRWdrzU7aeLTIiE3ooAetYg&amp;state=statefield">http://v.mewy.pw:9447/redirurl?code=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJjbGllbnRfaWQiOiJjbGllbnRpZGZpZWxkIiwicmVkaXJlY3RfdXJpIjoiaHR0cDovL3YubWV3eS5wdzo5NDQ3L3JlZGlydXJsIiwiaWF0IjoxNTM2OTkyNjM4LCJleHAiOjE1MzY5OTMyMzh9.ZM0CxbqRMWdH6gy6oD0mdRWdrzU7aeLTIiE3ooAetYg&amp;state=statefield</a>.

Middle chunk


HTTP/1.1 200 OK
Content-Type: application/json; charset=utf-8
Content-Length: 209
Date: Sat, 15 Sep 2018 06:31:25 GMT
Connection: close



In [7]: b64d(pad('eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ0eXBlIjoidXNlciIsInNlY3JldCI6InVmb3VuZG1lISIsImlhdCI6MTUzNjk5Njc5NiwiZXhwIjoxNTM2OTk3Mzk2fQ.HaZrmri4ztoaMuE6S1m-d2RPdzxxHCIVRQu3UT6sVvM'))
Out[7]: '{"alg":"HS256","typ":"JWT"}{"type":"user","secret":"ufoundme!","iat":1536996796,"exp":1536997396}\x01\xdaf\xb9\xab\x8b\x8c\xed\xa1\xa3.\x13\xa4\xb5\x99\xdd\x91=\xdc\xf1\xc4p\x88U\x14.\xddD\xfa\xb1[\xcc'

They give you the secret to sign with so just sign your own

Challenge 2 - web200 Hacker Movie Club

When we report to the admin

So obviously an xss, but whats the vector?

Page is pretty sparse

<script data-src="mustache.min.js" data-cdn="74246e08bf2ec3239870c76048788ea22fd7d8e7.hm.vulnerable.services"></script>
<script data-src="app.js" data-cdn="74246e08bf2ec3239870c76048788ea22fd7d8e7.hm.vulnerable.services"></script>
<div id="content">Loading..</div>
window.loaded_recapcha = () => {
    window.loaded_recapcha = true;
window.loaded_mustache = () => {
    window.loaded_mustache = true;
<script src="/cdn.js"></script>
<script src='https://www.google.com/recaptcha/api.js?onload=loaded_recapcha&render=explicit'></script>

cdn.js simply loads the javascript in the script tags from the data-src with a header.

for (let t of document.head.children) {
    if (t.tagName !== 'SCRIPT')
    let { cdn, src } = t.dataset;
    if (cdn === undefined || src === undefined)
        headers: {
    ).then(r=>r.blob()).then(b=> {
        let u = URL.createObjectURL(b);
        let s = document.createElement('script');
        s.src = u;

When we load app.js

It fetches the template


But if we mess with X-Forwarded-Host, we have an injection

Notably, its necessary to load the page with a ?1 so the caching gives a fresh page. If we request the page again, without the header we’ll still have the cached response.

So that means we’re trying to cache the base page. And route the admin to our main.mst.

The numbers before the .hm.vulnerable.services is just a sha1 of your IP address. Pretty clever, but its a trick @itszn has used before in his web challenges (see hackthevote :>).

So we need to somehow cache the /cdn/app.js for our domain and ip. My teammate and I tried a whole bunch of things until we noticed that the Age header in the response reset to 0 after ~170-180. Meaning the cache was being refreshed once every 3 minutes.

We simply had to race setting the cache on our IP address each 3 minutes.

Thanks burp pro:

After a coffee…

So now if we just submit our payload to the admin.

base64 decode it.

Challenge 3 - web400

http://web.chal.csaw.io:3306/ This app didn’t give us much to work with. Simply a submission box.

But the headers indicated something extra.

After some more coffees at like 11pm, we realise that it might be hex encoded. And after hex decoding, we thought it might be a decimal IP address, like the URL indicated, its an ‘ip’.

So we have a dns resolver and we can generate a record that points to our own domain. Just generate a domain for ourselves. And try out an XSS payload…

Standard JS cookie stealing. So I’ll omit the script. Tap our foot for a few minutes, and voila we have ourselves a cookie and a domain (admin.no.vulnerable.services) from the referrer header.

Navigating to that site with our cookie we’re met with.

The HTML looks as the following:

If we look up support.no.vulnerable.services, its an internal IP address, so probably we’re looking at an SSRF of sorts.

They also provide a list of load balancers. Notably the online IP is different to the one we’ve been using so far. So maybe we can abuse it.

Hitting up that IP we don’t get much. Its a 404.

But most load balancers and reverse proxies use host headers to decide where you go.

Unfortunately it seems to complain at these specific hostnames. But if we instead point it to the IP address itself (encoded with that magic dns proxy). We have ourselves an ssrf!, and a shitty ping script.

So with a trivial shell escape after. :)


We’re presented with an app that lets us blog…


Things are encoded so :( not much is happening here.

Taking a look at the robots.txt

And looking at the verify function…

It seems to give us the source code for what looks like a SQL procedure.

I set up a quick and dirty loop to pull all the data we care about.

Looking at the source. We look at post-login functionality

Looking at template we see it calls template_string on our data

So we look at template_string

Variables like ${something} will be replaced by the template engine. But this selects only from template_vars.

So we can get anything like config_blah or cookie_blah So lets try

Unfortunately, as the source below indicates, we can’t make posts with bad words

So we can see that banned words won’t even get posted, nor can we find out what banned words are. At best we can guess /config_.*/ is banned

Looking the request_ option, we try and pull some of our query params. So we can pull some parameters, but not all. What if we really want config_.*

Lets go back and look at the parser for the template engine.

Notice that formatted is replaced iteratively. So that means we can iteratively hit ${} to replace things. Since we can control request_ with url query parameters, we can do something like ${${request_p}} . The first iteration of the loop would replace it like ${<$GET['p']>} so if we had url.com?p=config_admin it would become ${config_admin} which then loops and evaluates and tries to get config_admin from the template_vars table.

We thought it might be possible to get straight injection here, but forgot that the regex for the template is checked every time. So no bueno there. We didn’t think there were any config_ mentions in the source code, so we tried brute forcing names in the config_ field. But then we realised we missed the sign_cookie function, which had So ?p=config_signing_key returned

This means, we can now sign our own cookies. :) Looking at sign_cookie, this is trivially

import hashlib
import binascii
def gen_cookie(data):
    return hashlib.sha256(data+SECRET).hexdigest() + binascii.hexlify(data)

Going back to our login, our cookies that were set as

In order of admin, email, privs.

So we can now look at where admin cookie is used

We’re looking at is_admin

Which gets the admin cookie.

This is quite trivially generated with our function defined previously.

If we visit /admin with this admin cookie

But the page is pretty blank…

If we look at the SQL, the functions for rendering SQL tables are only generated if we have privileges to do so

So we need to look at has_priv

To explain whats happening, we have to again sign our data. So it looks something like

data = priv1;priv2
payload = md5(priv_config_signing_key + data) + data
privs_cookie = sha256(payload + config_signing_key)+hex_encode(payload)

The data would be a string containing our permissions, which is the argument to this function,

So it would look like panel_view;panel_create;

The weird thing to notice, is that the order of the secret is flipped here. And we have no method of acquiring signing_key from priv_config.

Also of note, is that currently, privs is empty. The cookie provided

Only has an md5 hash, with no extra data. So

hash = "60f0cc64f5b633cf502d25ea561a98bf"
cur_privs = ""

And md5(signing_key) == "60f0cc64f5b633cf502d25ea561a98bf". We tried finding the hash, but no luck.

But since we didn’t have the signing_key, what I effectively wanted to do was given md5(secret) acquire something like md5(secret + "panel_view;panel_create;"). For those who are shit at crypto like me, my teammate explained that this was a length extension attack, and we could achieve this.

Given only md5(secret), we can use hashpump to generate md5(secret + garbage +"panel_view;panel_create;").

But we need to change it a little bit, since it’ll truncate up at each ;, so instead its md5(secret + garbage +";panel_view;panel_create;") with an extra ; in the middle.

So, we stick it into hash pump, and spit out a bunch of data

`for i in `seq 0 50`; do hashpump -s '60f0cc64f5b633cf502d25ea561a98bf' -d "" -a ';panel_view;panel_create;' -k $i; done > output1.txt

Manipulate the output a bit to look like


And we try the payloads

We get :( faces.

After some testing, we realise that the SQL backend only allows 0x01 to 0x79 as valid characters in TEXT fields. After a frustrating 1 hour of testing and finding online SQL sandboxes to test our theory in… I decide to ask our glorious challenge writers.

Thankfully, our hypothesis was correct, its just a small implementation error on their end :>. (We feel your pain here, writing CTFs is incredibly hard). After a superhuman effort on _ghost_’s part, they fixed the challenge within an hour.

Believe me, we did. We generated payloads for key length 0-50 for the length extension attack with hash pump, and loaded it into burp intruder

And we really wanted to be prepared to get first solve

And when we finally ran it…

And with that we see the sunshine over the horizon with first blood on wtf.sql. :) Thanks ghost for an amazing web challenge, and CSAW2018 for putting up an amazing set of web challenges.