This blogpost is about a simple arbitrary file upload vulnerability that I discovered by accident in a file sharing python script.
Finding a script
After an awesome conference and RuCTF 2017 finals in Jekaterinburg (Russia), I wanted to quickly share some pictures with my colleagues from the ENOFLAG team, while still allowing them to upload their own sets of media files to the same server. I didn't want to setup a full-fledged webserver like Apache for the short timeframe, so I typed the following query into google:
The first result looked promising and from quickly reading through the sourcecode it seemed to do what I wanted. The script also didn't need any special dependencies, so I uploaded it to my server and started it:
$> useradd -m -s /bin/false untrusted $> su -s /bin/bash untrusted $> mkdir upload $> cd upload $> wget https://gist.githubusercontent.com/UniIsland/3346170/raw/059aca1d510c615df3d9fedafabac4d538ebe352/SimpleHTTPServerWithUpload.py $> python2 SimpleHTTPServerWithUpload.py Serving HTTP on 0.0.0.0 port 8000 ...
While skimming through the code I took a more closer look on the download part, because I didn't want my colleagues to be able to download arbitrary files. Luckily, I was not able to spot any major mistakes, so I continued to upload my fotos.
At our next weekly meetup I handed the URL to the others and someone jokingly said "Hopefully you checked the script for vulnerabilities". I answered that I indeed checked the download functionality, but the person replied "So I'll just upload something malicious ;)". That was when I realised, that I didn't pay much attention to the file uploading part.
Finding a vulnerability
The upload function
deal_post_data uses the following code pieces. Security oriented readers should spot the issue quickly.
fn = re.findall(r'Content-Disposition.*name="file"; filename="(.*)"', line) if not fn: return (False, "Can't find out file name...") path = self.translate_path(self.path) fn = os.path.join(path, fn) # [...] try: out = open(fn, 'wb') except IOError: return (False, "Can't create file to write, do you have permission to write?") # [...] out.write(preline) out.close() return (True, "File '%s' upload success!" % fn)
The problem lies within the regular expression
filename="(.*)", which matches any characters and is then used as the filename for the target file in the upload directory.
Let's analyze what possibly could go wrong. A normal file upload request looks like this:
POST / HTTP/1.1 Host: localhost:8000 Content-Type: multipart/form-data; boundary=---------------------------24551002913809647471483345518 Content-Length: 239 Connection: close -----------------------------24551002913809647471483345518 Content-Disposition: form-data; name="file"; filename="testfile" Content-Type: application/octet-stream foobar -----------------------------24551002913809647471483345518--
This is an usual
multipart/form-data POST request used to upload files. The script extracts the filename from the request, stores the contents in it and returns success:
True File '/home/untrusted/upload/testfile' upload success! by: ('127.0.0.1', 33950)
But what happens when the filename is changed to
../hackedfile? An intercepting proxy like BurpSuite makes such manipulations easy.
On the right side we can see the success message telling us that the file has been uploaded to
/home/untrusted/upload/../hackedfile. That is, however, the same as
/home/untrusted/hackedfile meaning that we just wrote a file in the parent directory. A quick look in the
untrusted user's home directory confirms this:
[untrusted@LagTop ~]$ ls -R ./ ./: hackedfile upload ./upload: SimpleHTTPServerWithUpload.py testfile
Files should only be uploaded into the
upload/ directory, but we managed to circumvent this restriction.
Finding a fix
I thought about a possible fix for a while, but in the end decided that the quickest and easiest fix would be to adjust the regular expression. So instead of matching all characters, we want to match everything except
\, because those characters allow to manipulate the path:
fn = re.findall(r'Content-Disposition.*name="file"; filename="([^\/]*)"', line)
I've sent the developer of that script an email with my finding and the proposed fix, but got an answer that the script is not supposed to be run 24/7 on a server, but rather a tiny ad-hoc tool. I see his point, but given the popularity of the script (high search enigne ranking + a lot of comments/forks) I think that this issue should be communicated. At least he promised to fix the issue eventually we he gets the time for that.
Finding an exploit
Okay, so the vulnerability allows us to write arbitrary content to arbitrary locations as long as the user has write permissions.
Let us assume that the user does not run as
root, because otherwise it is game over. How can an attacker exploit this to get remote code execution on the system? The only drawback is that we cannot create new directories. Only files can be (over)written.
The only way of direct exploitation that comes into my mind is writing your own SSH publickey into the
~/.ssh/authorized_keys file. It will overwrite the actual contents of it, so it might not be the stealthiest attack option, but that should lead to direct remote access to the server. Further exploitation using priveledge escalation bugs might be imaginable.
If the first method does not work, an indirect exploitation could be used. With indirect I mean that it requires user interaction. There are several files that are executed when the user logs into his account or an administrator uses
sudo to access it. Those files are:
Various files related to the interactive shells could be overwritten.
This file will get executed when
su is used to switch to the user.
When a custom
~/bin/ folder exists and it is configured to be in the
PATH variable, then a malicious binary or script could be uploaded with a common command's name.
cgi.pyor another import or the
Creating a python file with the name of an import or actually overwriting the sourcecode. When the script is restarted, the file will be executed. I didn't include those options, because that requires the script to be restarted.
Do you know any other methods or clever ways to transform an arbitrary file upload into remote code execution? If so, I'd be happy to hear about it.
Update 14th of May, 2017:
I've added some more exploitation possibilities from the hackernews discussion. Thanks to all commenters!