OpenSource is an ‘easy’ recent box that I started just as it was coming to the end of its time in the Release Arena. It took me a while longer than I would have liked. So long in fact, it had dropped into the regular area with a different IP well before I solved it. It might well be that there are easier ways to pwn this box, but my route was anything but direct.
Kicking us off with some surface area enumeration I ran an nmap -sC -sV
and 3 ports lit up – although one of those was not accessible from my current context.
Lets take a look at what’s on the site being hosted.
I downloaded the ZIP package which was code for a Docker container to host files. It would also appear that this same container was being hosted on the target machine too – the link “Take me there!” does exactly what it says on the tin.
I have a quick play with the target machine’s upcloud instance through BurpSuite and I very quickly manage to break the web application. Breaking stuff = good, Error messages are normally helpful.
Tampering with the POST request caused the error above. As we scroll through that message, something interesting shows up…
A console you say? I want to know more about this. A bit of GoogleFu turns up this https://book.hacktricks.xyz/network-services-pentesting/pentesting-web/werkzeug from the ever handy HackTricks. Honestly, where would we be without it?
Poking at /console
does indeed show there is a Python debugging console running, but it’s PIN protected. We already knew it would be. The question is can we break in? HackTricks seems to suggest it is possible.
When I ran the Docker container from the download, I couldn’t find any debugging console which means something is different in the configuration between the hosted version. Having noted there was a .git
directory I wanted to explore if any previous edits gave a clue of how to enable this. After all, I want to crack the PIN my side first. It’s just easier.TM
Using git log --raw
we can see that Dockerfile
was modified in the previous commit. We can see what changes were made to that file with a git diff
.
Aha! Uncommenting that debug line looks like it’ll enable that console, and running it up quickly confirms it absolutely does. The console shows a 9 digit PIN shown as nnn-nnn-nnn. Consulting the links that HackTricks provides, you can see it’s possible to derive the PIN code, as long as you are able to leak some information from the target. You need 6 (well 6 and a half) pieces of information. 3 of them are fairly public, 1 leaks by virtue of being in debug mode, the other 2 are pretty private and would require some sort of LFI. Checking the code this is the breakdown of what we need:
username
is the user who started this Flask instancemodname
is always flask.appgetattr(app, '__name__', getattr (app .__ class__, '__name__'))
is always Flaskgetattr(mod, '__file__', None)
is the absolute path of app.py in the flask directory – this leaks when we cause an error due to the app being in debug modeuuid.getnode()
is the MAC address of the server which it asks for in decimal formget_machine_id()
is the value in/etc/machine-id
or/proc/sys/kernel/random/boot_id
(and here’s where the .5 extra bit is, and it had me stumped for a bit) also some material from/proc/self/cgroup
. The below part of the function was not included in the write-ups for cracking the PIN.
Containers share the same machine id, add some cgroup
information. This is used outside containers too but should be
relatively stable across boots.
try:
with open("/proc/self/cgroup", "rb") as f:
linux += f.readline().strip().rpartition(b"/")[2]
except OSError:
pass
Looking at the source we can see there’s a pretty futile attempt to prevent LFI. If you submit ../
for path traversal the app will stop you in your tracks…
However, the app is doing nothing to prevent you submitting a double-slash after the dots (..//<path>
). I encode it for good measure (it’s actually not required). The below shows me grabbing the hardware address of the NIC.
Using the same technique I was able to gather the machine ID and then put together all of the pieces of the puzzle. One thing I will call out: The PIN generation code used to use md5 for the hashing algorithm and the version I’m working on uses sha1. The code you’ll get off of HackTricks relates to an older version and so you’ll never get the right PIN. Trivial to fix, a nightmare to spot – especially when you’re tired! The code I used in the end is on my GitHub here.
On entering the code – we have access to the Python console…
I wanted a reverse shell – even though that only really gets my inside the container. At this point I was thinking maybe we’re up for a Docker container escape. I use a Python reverse shell back to my Kali box. Nothing complex – you can find this particular reverse shell on most cheat sheets, including mine on this site.
For almost a day I went looking for a way to escape the container. It seems reasonable to assume thats the way to go – but this ‘easy’ box is currently beating me. I can’t find a way out of this container.
I turn back to information I already have. That port that was filtered – port 3000 – could that be the next step? Also something I’d been seeing in my shell was messages about a GET request.
I put 2 and 2 together and try to connect to that port…
Okay, we are onto something. But I’m not going to be able to do very much from inside this container. I’m going to need to proxy through the container to reach this port. There’s a fantastic tool for doing the kind of proxying we’re interested in. It’s called Chisel and I learned all about it from this page: https://0xdf.gitlab.io/2020/08/10/tunneling-with-chisel-and-ssf-update.html
Using the upcloud application I uploaded the chisel
binary to the container. There’s a huge problem though.
‘Not found’! The file I just set the execute bit on? That’s annoying. It’s also not a very helpful error message. For a start it doesn’t mean it can’t find the file to execute it. At least not in this case. It means I cannot make use of libraries on which this executable depends. You see executables, usually, are dynamically linked. There is an expectation that the system on which the file is being executed will have certain dependancies available. In this Docker container, those important dependancies are not present. This is going to be a challenge.
In the above example, I’ve compiled a ‘Hello Word’ C program with gcc
– twice. In the first run, I’ve asked gcc
to statically link the executable. In the second run I’ve not provided any options which will cause it to create a dynamically linked binary by default. Using ldd
you can see this first hand. Why choose one over the other then? Take a look at the image below and you’ll see why.
16KB versus 800KB. I think dynamic executables win this race by quite a margin. That doesn’t help us with our chisel
problem though. For that we’re going to need to create a static binary from the dynamic one.
The tool I use for this conversion is called statifier
which converts chisel
into something which I named, for no reason at all, xray
. You’ll find that on my GitHub too. It might be useful somewhere else too.
Let’s get going with our proxy! I run the chisel
(ahem xray
) server on my Kali box:
./xray server -p 8000 --reverse
On the container I ran the client:
./xray client 10.10.14.4:8000 R:3000:172.17.0.1:3000
The simple explanation for what is happening here is that the client is talking back to my Kali box on port 8000, and then presenting port 3000 locally to me. To connect to that port, all I need to do is point my browser at http://127.0.0.1:3000.
What can of worms have we opened here? Surely I haven’t got to break into another application. There’s a hint here to where I should look. Something we’ve already looked at once. Git. That code that we downloaded earlier… We looked at other commits earlier but we didn’t look at other branches.
git branch
shows there are indeed two branches: dev
and public
. We’ve been looking at the public
branch, what might we find on the dev
branch.
The current commit of the dev branch will get us nowhere. The previous commit deleted a file settings.json
. The one before was the one that had added it. Perhaps our developer ‘accidentally’ added a file with sensitive content. I had a look at the file and it contained the credentials for a user called dev01
. I felt good about these creds. I tried them in the Gitea app we’d found on port 3000. We were in.
Exploring the home-backup repo we hit the jackpot. Finally. There was the .ssh
directory complete with the id_rsa
private key. All that needed to happen now was a download of that key and and ssh
toward the server.
That felt like a lot of work. It did seem like I went down a few rabbit holes, but I feel a bit better now I’m in as the user. Time to go for the privilege escalation.
I tried sudo -l
and that turned up nothing. I moved on to Linpeas and the things it ‘uncovered’ were all dead ends. I may have said “oh come on” or something else less repeatable to your local vicar. It was time to break out pspy64
. Something interesting did catch my eye. It took a while to work out what to do with it…
This machine has been able Git all the way through, so it makes sense the entries here for git
are something to do with the PrivEsc. It’s made all the more likely as the UID of the calling process is root.
Something from my current role was lodged in my head. There’s a bunch of scripts which git
can use to help automate various processes. The git commit
is happening once a minute automatically – perhaps we can automate something more interesting than managing the repo!
Automation scripts are present under the .git/hooks
directory. By default they’re all named with a .sample
suffix, which means they do nothing. Remove the suffix and you activate them. How about we get the pre-commit
to fire a reverse shell for us?
We edit the file to insert this reverse shell line, renaming the file to drop the .sample
suffix. On setting up a listener on my Kali box, all I need to do is wait. 30 seconds later, I have a shell.
Success – 30 points please Mr. Hackthebox.
There may have been an easier path to solve this. I’m sure there is, but it was very much my journey. I feel like it was a battle well won, even if it took some time to achieve the victory. Did you find an easier way?
hi
I don’t understand why the code can filter out ‘../’ but do nothing about ‘..//’ ?
Appreciate for your reply
The filter is looking for a string that matches the literal “../” exactly and then replaces it with NULL. As “..//” does not exactly match it is returned as it was before. UNIX ignores additional slashes in the path, so the system treats it no differently to what we intended.
oh yes, acctually python’s os.join() will treat “app/uploads///////app/views.py” as “/app/views.py”
os.path.join(“/asfasas”,”public”, “uploads”, ‘///////app/views.py’)