HTB: Nexus Writeup

Nexus - HackTheBox Writeup

Machine Information

AttributeDetails
NameNexus
OSLinux
DifficultyEasy
PointsN/A
Release DateN/A
IP Address10.129.234.54
Authord3vn0mi

Machine Rating

⭐⭐☆☆☆ (2/5)

Difficulty Assessment:

  • Enumeration: ⭐⭐☆☆☆
  • Real-world: ⭐⭐⭐⭐☆
  • CVE: ⭐⭐⭐☆☆
  • CTF-like: ⭐⭐☆☆☆

Summary

Nexus is an easy Linux box built around a leaky Gitea instance sitting in front of a Krayin CRM deployment. A password committed to git history in a Docker setup repo gives CRM login access, and the CRM itself is vulnerable to an authenticated file-upload RCE (CVE-2026-38526) once the CSRF protection is bypassed. From there, credential reuse inside the CRM’s own .env file hands over SSH access as a low-privileged user, and a root-owned systemd timer that syncs Gitea “template” repositories with an unsanitized os.path.join() becomes a directory-traversal write primitive straight into /root/.ssh/authorized_keys.

TL;DR: Gitea commit history leak → Krayin CRM login → CVE-2026-38526 authenticated upload RCE (www-data) → .env password reuse over SSH (jones) → Gitea template-sync path traversal → root.


Reconnaissance

Port Scanning

Initial scan against 10.129.234.54 turned up two open ports:

  • 22/tcp — OpenSSH
  • 80/tcp — nginx, redirecting to nexus.htb

nexus.htb was added to /etc/hosts to follow the redirect and reach the site.

Service Enumeration

Virtual-host enumeration off the base domain surfaced two additional hosts:

  • git.nexus.htb — a Gitea instance
  • billing.nexus.htb — a Krayin CRM instance

Both were added to /etc/hosts alongside nexus.htb.

Vulnerability Assessment

Poking at the Gitea instance found a public repository, admin/krayin-docker-setup, used to stand up the CRM’s Docker environment. Its commit history was the weak point:

Terminal window
# reviewed commit history on the krayin-docker-setup repo via the Gitea web UI / API
# commit 1615c465 predates a later cleanup and still carries a real secret in its diff

Commit 1615c465 leaked:

DB_PASSWORD=N27xh!!2ucY04

This is the classic Gitea/Git anti-pattern: rotating a secret in a later commit doesn’t erase it — the old blob is still reachable through history unless the repo is rewritten or the object is purged. With database credentials in hand and billing.nexus.htb already identified as the CRM, the next step was authentication.


Initial Foothold

Krayin CRM login

The leaked DB_PASSWORD was tried against the CRM login at billing.nexus.htb using the hiring-manager address discovered on the site, j.matthew@nexus.htb, and it authenticated successfully.

CVE-2026-38526 — Authenticated File Upload RCE

Krayin CRM (this instance) is vulnerable to CVE-2026-38526, an authenticated arbitrary file upload via the TinyMCE upload endpoint at /admin/tinymce/upload. The endpoint accepts a file upload but doesn’t properly validate the extension server-side, so a .php file disguised as an image is stored under the web root and becomes directly executable.

The catch is CSRF protection on the endpoint — normally this would block a scripted/CSRF-style upload. The bypass: Laravel-based apps (Krayin is Laravel-based) validate the CSRF token against the XSRF-TOKEN cookie value, so replaying that cookie’s value as the X-XSRF-TOKEN request header satisfies the check without needing a page-rendered token.

Terminal window
# Upload request (conceptually):
# POST /admin/tinymce/upload
# Cookie: XSRF-TOKEN=<value>; laravel_session=...
# X-XSRF-TOKEN: <same value as XSRF-TOKEN cookie>
# multipart body: PHP webshell renamed with an image-passing extension

Once uploaded, the shell was reached directly and returned execution as www-data.

uid=33(www-data) gid=33(www-data) groups=33(www-data)

Privilege Escalation

www-data → jones

Inside the Krayin application directory, the app’s own .env file was read and disclosed a second, distinct credential:

DB_PASSWORD=y27xb3ha!!74GbR

Password reuse between the application-layer database credential and the local Unix account jones allowed direct SSH login:

Terminal window
ssh jones@10.129.234.54
# authenticated with the .env-leaked password: y27xb3ha!!74GbR

user.txt was retrieved from jones’s home directory.

jones → root

Enumeration on the box turned up a root-owned systemd timer, gitea-template-sync.timer, firing a sync job against Gitea “template” repositories. The backing script processes file paths returned by git ls-tree and joins them onto a staging directory using Python’s os.path.join():

target = os.path.join(stage_path, filepath)
os.makedirs(os.path.dirname(target), exist_ok=True)

The bug: os.path.join() happily resolves .. segments, and nothing in the sync script validates filepath before that join. If a Gitea repository marked as a template contains tree entries with ../../../.. components in their names, the root-run sync job will write files outside its intended staging directory — anywhere the git/root context has permission to write, including /root/.ssh/.

Git’s own working-tree checkout (verify_path()) normally refuses to create paths containing .., which is why this can’t just be done with a normal git add/commit. The way around that check is to construct the malicious tree and blob objects directly as raw git objects (writing compressed objects straight into .git/objects/) rather than going through git’s path-validated commit machinery — the sync script’s own git ls-tree walk doesn’t apply the same guard when it later reads those objects back out.

Using this primitive:

  1. A Gitea repository (jones/rce) was created and marked as a template, so it gets picked up by gitea-template-sync.timer.
  2. A malicious tree was constructed containing a traversal path resolving to ../../../../../root/.ssh/authorized_keys, with an attacker-controlled SSH public key as the blob content.
  3. Once the timer fired, the sync script’s unsanitized os.path.join() wrote that key straight into /root/.ssh/authorized_keys.
  4. SSH with the corresponding private key granted a root shell, and root.txt was retrieved.
uid=0(root) gid=0(root) groups=0(root)

Attack Chain Summary

Gitea repo (admin/krayin-docker-setup) commit history leak (DB_PASSWORD)
→ Krayin CRM login (j.matthew@nexus.htb)
→ CVE-2026-38526 authenticated upload RCE (CSRF bypass via XSRF-TOKEN → X-XSRF-TOKEN)
→ shell as www-data
→ Krayin .env password reuse
→ SSH as jones (user.txt)
→ gitea-template-sync.timer os.path.join() traversal (raw git object bypass of verify_path())
→ SSH key planted in /root/.ssh/authorized_keys
→ root (root.txt)

Tools Used

ToolPurpose
nmapPort scanning / service discovery
Gitea web UIRepository and commit-history review, template repo creation
Krayin CRM (web)Authenticated login, TinyMCE upload RCE
PHP webshellInitial code execution as www-data
sshCredential-reuse foothold as jones, key-based root access
git / raw git object craftingDirectory-traversal write primitive against the template-sync timer
PythonUnderstanding/exploiting the os.path.join() flaw in template-sync.py

Key Learnings

Techniques Practiced

  • Virtual-host discovery and enumeration of Gitea/CRM subdomains
  • Mining git commit history for secrets that survived a later “rotation”
  • Exploiting a Laravel CSRF check by replaying the XSRF-TOKEN cookie as the X-XSRF-TOKEN header
  • CVE-2026-38526 authenticated file-upload RCE in Krayin CRM
  • Credential reuse across an application’s .env and OS-level accounts
  • Abusing os.path.join()’s traversal-resolving behavior in a root-run automation job
  • Bypassing git’s verify_path() protections by writing raw objects directly into .git/objects/

Lessons Learned

  1. Committing a secret and later “removing” it in a subsequent commit does not delete it from history — Gitea/GitHub repos remain exploitable via old commits unless the object is actually purged.
  2. CSRF double-submit-cookie schemes (token in cookie, mirrored in a header) are only as strong as the assumption that an attacker can’t read the cookie — once any other bug (or, here, direct cookie access) is available, the “protection” adds nothing.
  3. Reusing the same password across an application’s database config and a real OS account turns a web-app leak into a full SSH foothold.
  4. os.path.join() in Python is not a sanitizer — it will honor .. and absolute-path segments from its second argument, so any code trusting external input (like paths from git ls-tree in an automated sync) needs explicit path-containment checks before writing to disk.
  5. Automation that runs as root against attacker-influenceable input (like a Gitea template repository a normal user can create) needs the same trust boundary scrutiny as any other privileged service.

Proof of Ownership

User Flag: <redacted>
Root Flag: <redacted>

References

  • TheCyberGeek & k1ph4ru, Nexus — official HackTheBox writeup (Machine Authors: TheCyberGeek & k1ph4ru), used here for the CVE-2026-38526 identification and the conceptual explanation of the os.path.join() / git raw-object traversal technique.