A replica set failover is supposed to be the boring part of running MongoDB. A primary disappears, the surviving members hold an election, one wins, and within seconds the cluster is taking writes again. Operators rehearse this during rolling upgrades and maintenance windows. They do not plan for the promotion itself being booby-trapped by a stranger who reached into the member’s memory days earlier.
That is the situation with CVE-2026-118383, a use-after-free MongoDB disclosed on November 10, 2026. Datadog Security Labs reproduced it and published the details on November 11. MongoDB rates it 9.5 on the CVSS 4.0 scale and classifies it as CWE-416. The company says it found the flaw internally and knew of no exploitation attempts when it announced the fix. Every replica set member is affected, including shard and config server members of sharded clusters. A standalone mongod outside a replica set is not.
The severity score is the least interesting part of this bug. The structure is what deserves study. The attacker’s whole active contribution is two ping commands carrying a readConcern.afterOpTime value, sent before any authentication handshake. Those commands crash nothing. They register pending notification objects, called waiters, that survive client timeouts and disconnects and sit dormant in the server’s replication bookkeeping. The crash needs a second event that the attacker can neither cause nor observe: the poisoned member must later win an election while lagging behind another member. At that point the catch-up cleanup runs twice, frees an object that is still executing one of its own methods, and the new primary dies before it accepts a single write.
Planting and detonation can sit hours or days apart. That gap is why this bug is hard to detect and easy to underestimate.
What CVE-2026-118383 affects at a glance
- Bug class: use-after-free (CWE-416) in the primary catch-up teardown path
- Attack precondition: network reach to a replica set member; no credentials needed
- Attacker input: two
pingcommands withreadConcern.afterOpTime - Trigger: the poisoned member wins an election while lagging on replication
- Demonstrated impact: denial of service (segfault on the new primary)
- Claimed but unproven impact: arbitrary code execution
- Not affected: standalone
mongodandmongosrouters
Why a newly elected primary has to catch up before it accepts writes
MongoDB’s replication protocol, the Raft-derived protocol version 1, does not require an election winner to hold the single most recent operation in the cluster. It requires only that the winner’s oplog be at least as current as a majority of the voting members. In a three-member set, a node that trails another survivor by a few milliseconds of writes still qualifies.
If that node started accepting writes the instant it collected its votes, the writes it had not yet applied would be stranded on the other member and eventually rolled back, which means lost. So the winner first enters a transitional phase called primary catch-up. It pulls the missing oplog entries from its peers, applies them, and only then becomes a fully writable primary.
Catch-up is the last safety mechanism between an election and silent data loss, and it is also a narrow window in the state machine. Datadog’s timeline of a three-member set puts the median detect-and-elect interval under twelve seconds. Catch-up lasts milliseconds after a planned step-down and seconds after a real failover. CVE-2026-118383 lives in the cleanup code that runs when catch-up ends. Normally that cleanup executes once and the node moves on. With the poisoned conditions described below, it executes twice in the same transition, and the second run operates on memory the first run already freed.
How MongoDB tracks replication time: OpTime, readConcern, and the waiter registry
To see how a connectivity probe reaches into that state machine, you need MongoDB’s notion of replication time. Every write a primary accepts is appended to the oplog, the capped collection local.oplog.rs, and stamped with an OpTime. An OpTime is not a wall-clock reading. It is a pair: a Timestamp (a 64-bit value that combines a 32-bit Unix epoch second with a 32-bit ordinal for operations within that second) and a 64-bit election term that increments with each new vote. Comparisons check the term first and fall back to the timestamp only when the terms match.
Secondaries apply batches of oplog entries and advance two watermarks, lastAppliedOpTime and lastDurableOpTime. They advertise both through heartbeats. Aggregating those reports produces the majority commit point, which underpins majority write concern and causal reads.
Clients interact with this frontier through readConcern. The afterOpTime option attaches a target {ts, t} to a command and asks the server to hold the command until local replication reaches that point. The server does not park a thread spinning on it. The read-concern wait runs before command dispatch, and the replication coordinator allocates a waiter and inserts it into a sorted registry, a std::multimap<OpTime, Waiter> keyed by the target OpTime. When an applier batch advances the node’s watermark, the coordinator sweeps the multimap from the lowest key upward through setValueIf_inlock(), satisfying and removing every waiter whose target has been reached.
📬 Stay Ahead of Cyber Threats
Get the latest cybersecurity news, critical vulnerabilities, threat intelligence, tutorials, and exclusive giveaways delivered straight to your inbox. No spam. Unsubscribe anytime.
Why afterOpTime on unauthenticated commands is an attack surface
Two properties of this machinery create the exposure.
First, before the November patches, the command pipeline honored readConcern.afterOpTime on the diagnostic commands that are deliberately allowed without authentication: ping, hello, and the legacy isMaster. Drivers and load balancers need those to probe health before a SASL handshake. The side effect was that an unauthenticated connection could register a replication waiter.
Second, the waiter outlives the request that created it. If a client attaches an unreachable afterOpTime and a short maxTimeMS, the command blocks, returns MaxTimeMSExpired, and closes. The multimap entry is not purged on timeout or on socket disconnect. It stays until the process restarts, the member rolls back, or the member steps down from primary. The trap does not expire.
How two waiters break the OpTime ordering
The public proof-of-concept on GitHub, published by googlesky, shows why two waiters are enough. The cause is a subtle defect in the comparator, not in the multimap. OpTime::operator< is not a strict weak ordering when either operand carries the legacy uninitialized term, -1. In that case it compares timestamps alone and ignores terms entirely. An attacker who omits the term on the wire gets exactly that legacy value.
The PoC plants two waiters in a deliberate order:
- W1 has a timestamp one second ahead of the node’s current applied position and an omitted term.
- W2 has a tiny timestamp,
Timestamp(1,1), and an enormous term, 2^40.
With W1 inserted first, the multimap settles into the order W2, W1, C, where C is the catch-up waiter the node will later register for itself. That ordering is cyclic. W2 < W1 by timestamp, and W1 < C by timestamp, but C < W2 by term.
The notification sweep in setValueIf_inlock() starts at W2. W2’s key compares greater than any real lastApplied value the node can reach, so the loop stops before it ever arrives at C. The internal catch-up waiter is never satisfied through the normal path, even after the node has applied everything it was waiting for.
From the outside, nothing looks wrong. CPU, memory, and query latency stay normal, and the two pings have already timed out and returned. The registry is poisoned and stays that way until the next state transition that touches it.
What the attacker controls, and what they cannot
The attacker’s control ends at planting. They cannot make the member lag, cannot force an election, and cannot query privileged replication status to learn when either happens. In the lab, Datadog and the PoC author produced the lagging-winner condition with administrative access or test-only server commands, pausing and stopping mongod processes. The only unauthenticated input was the ping traffic, and that client could not write to the database.
In production the same precondition arises by itself. Rolling restarts, cloud migrations, network partitions, and write bursts all leave a secondary briefly behind. Because the waiters never expire, planting can come days before the election that triggers them.
The double teardown: how CatchupState frees itself mid-method
When the poisoned member wins an election while lagging, it logs event 21359, “Entering primary catch-up mode,” and constructs a CatchupState object held on the replication coordinator as _repl->_catchupState. That object registers its own waiter C in the same multimap the pings polluted and attaches a completion callback, targetOpTimeCB.
In vulnerable versions the callback captures a raw this pointer and ignores the completion status it receives. The teardown method, CatchupState::abort_inlock(), removes and destroys the waiter whenever it is called with a conclusion reason other than success, and destroying the waiter can synchronously invoke that callback on the same thread. The PoC quotes the vulnerable logic in src/mongo/db/repl/replication_coordinator_impl.cpp roughly as follows:
cpp
void CatchupState::abort_inlock(PrimaryCatchUpConclusionReason reason) { ... if (reason != PrimaryCatchUpConclusionReason::kSucceeded && _waiter) { _repl->_lastAppliedOpTimeWaiterList.remove_inlock(_waiter); _waiter.reset(); // may synchronously run targetOpTimeCB } _repl->_enterDrainMode_inlock(); _repl->_catchupState.reset(); // destroys `this`}auto targetOpTimeCB = [this](const Status& /* ignored */) { const auto myLastApplied = _repl->_getMyLastAppliedOpTime_inlock(); if (_targetOpTime <= myLastApplied) { abort_inlock(PrimaryCatchUpConclusionReason::kSucceeded); // re-enters }};
Step by step: from heartbeat to freed memory
The reproduced trigger path is heartbeat-driven:
- A heartbeat response tells the coordinator that the node is already caught up, so it aborts the catch-up state with a non-success conclusion reason.
- That abort removes and destroys the still-unfulfilled waiter C, which synchronously fires
targetOpTimeCB. - The callback sees that the target is already applied, ignores the cancellation status, and calls
abort_inlock(kSucceeded)from inside the outerabort_inlock()frame. - The inner call skips the waiter branch, enters drain mode, and resets
_repl->_catchupState, which runs~CatchupState()and frees the object. - Control unwinds to the outer frame, which logs a second “Exited primary catch-up mode” (21363) in the same millisecond and keeps reading and writing through a
thispointer that no longer owns live memory.
The PoC lists three conditions that make the re-entry fatal. The node is in primary catch-up with a registered waiter. That waiter is still unfulfilled even though lastApplied has passed the target. And any abort with a reason other than success destroys the waiter, which lets the synchronous callback see the applied target and re-enter.
With the waiters planted and the lagging election underway, the double teardown is deterministic. The attacker does not have to win a multi-threaded race.
Whether the process dies at once depends on allocator reuse. Datadog’s run on 8.0.34 ended in Invalid access at address: 0 and signal 11, leaving the lab cluster without a primary. The PoC author notes that release builds can survive the use-after-free silently when the freed chunk is not reused right away, and ships a gdb oracle that forces the fault so it becomes visible. Either way, the corruption happens before the new primary accepts a single client write.
Is CVE-2026-118383 remote code execution? What the evidence shows
The demonstrated impact is denial of service. In Datadog’s lab the segfault left the set without a writable primary. In another deployment the remaining members might elect a replacement and keep running, so outage duration depends on topology and election timing as much as on the bug.
MongoDB’s advisory and the NVD record both say the flaw may, under certain conditions, execute arbitrary code in the server process. That wording is standard for a use-after-free, and nobody has demonstrated it here. Datadog says plainly that they did not achieve code execution, and the PoC lists its impact as crash or memory corruption with no code-execution claim.
The heap mechanics explain the gap. Controlled execution would require reclaiming the freed CatchupState allocation with attacker-shaped bytes between the inner free and the outer dereference. That interval is a few instructions on a single thread inside a locked section that holds the replication mutex. An unauthenticated client with no write privileges has almost no chance to place controlled data in the right allocator size class at the right instant.
The CVSS 4.0 vector reflects the same realism. Attack complexity is high (AC:H) even though privileges required are none, because the lagging-election precondition is outside the attacker’s control. The PoC’s reliable mode depends on enableTestCommands and the test-only failpoint pauseBatchApplicationAfterWritingOplogEntries, which holds the applier so the oplog stays current enough to win votes while lastApplied lags. A remote attacker cannot enable either. Its failpoint-free “natural” mode is explicitly racy and produced the double abort only under constrained CPU and a sustained write backlog.
The fair reading is a confirmed, reproducible crash primitive with a theoretical, unvalidated code-execution ceiling.
Why persistence makes the denial of service serious
An attacker can send the two pings to every reachable member once and wait for ordinary operations to supply the trigger. If more than one secondary is poisoned, successive failovers can keep killing whichever lagging node gets promoted, turning one maintenance window into a repeated outage. Nobody has measured that cascade as a separate result. It follows directly from waiters that never expire and a cleanup path that frees itself.
The two MongoDB fixes: SERVER-138119 and SERVER-138175
MongoDB shipped the remediation as two independent commits. Which one does what matters for deployments that cannot upgrade right away.
Fix 1: authorization on afterOpTime closes the injection vector
Commit 6111165c32bfc74f197e2dfeb9480d0b52154d68 (SERVER-138119) closes the injection path. checkAuthForAfterOpTime() now demands the ActionType::internal privilege on the cluster before a client-supplied readConcern.afterOpTime is honored. A regression test, jstests/auth/after_op_time_requires_internal.js, pins the unauthenticated case. On an auth-enabled 8.0.37, Datadog’s two pings are rejected immediately with Unauthorized (code 13), and no waiter is ever registered.
The PoC documents an important caveat. When security.authorization is disabled, the internal action is effectively granted to every connection, so even 8.0.37 accepts afterOpTime without a keyFile. The authorization gate only shows up on auth-enabled deployments.
Fix 2: shared ownership closes the lifetime bug
That caveat is why the second commit exists. Commit 23dfc542199ab3b88d6a9420b6cd3f125a897620 (SERVER-138175), titled in JIRA “Conclude primary catch-up only from a live, current catch-up state,” restructures ownership so the object cannot destroy itself mid-method:
CatchupStateinheritsstd::enable_shared_from_this.- The coordinator’s
_catchupStatebecomes ashared_ptr. - The completion callback captures a
weak_ptrinstead of a rawthis. - The callback returns immediately on a non-OK status, promotes the weak pointer with
.lock(), and concludes catch-up only if the locked object is still the coordinator’s current catch-up state.
A stale or canceled callback can no longer re-enter teardown, and a reset of _catchupState cannot free an object that an executing frame still references.
The PoC author tested this layer with authentication disabled, which lets the poisoned waiters past the authorization gate. 8.0.37 held the poison but produced no double abort, a single catch-up exit, and only the numCatchUpsAlreadyCaughtUp counter incrementing. Datadog verified the authorization gate directly and did not test the lifetime fix themselves. Two different parties therefore corroborated the two layers.
CVE-2026-118383 affected and fixed MongoDB versions
The fixed releases are:
Branch Fixed in 9.0 9.0.7 8.3 8.3.16 8.0 8.0.37 7.0 7.0.48 6.0 6.0.30 5.0 5.0.35 4.4 4.4.32
Every earlier release in each of those branches is affected, back to the branch’s first version. The catch-up state machine and the afterOpTime waiter registry have been part of the replication architecture since protocol version 1, which is why the range reaches back to 4.4.
Scope follows the replication coordinator. Shard replica set members and config server replica set members are affected. Standalone mongod instances and mongos routers hold no local oplog or catch-up state, so they are not. The fixes arrived as part of a coordinated set of maintenance releases, not a single hotfix.
The MongoDB 8.2 blind spot
The trap for fleet auditors is the 8.2 branch. It does not appear in the advisory’s affected-version list, and reading that omission as immunity is a mistake. MongoDB’s lifecycle schedule put 8.2 at end of life on July 31, 2026, with 8.2.12 as its final release, and end-of-life branches receive no security backports. The PoC tested 8.2.12 and reproduced the double abort, noting that the hotfix had not been backported to that tag.
Teams on 8.2.x cannot patch in place. They have to move to a supported line, 8.3.16 or later or 9.0.7 or later, to get either layer of the fix.
How to detect CVE-2026-118383 exploitation in MongoDB logs
Planting and detonation are separated in time, so incident response cannot stop at the last few lines before a segfault.
Detecting the planting stage
The planting stage is visible only if the command was logged. MongoDB records commands that exceed the slow-operation threshold, slowms (default 100 milliseconds), as a “Slow query” event with ID 51803. A planting ping that waits on an unreachable afterOpTime until its maxTimeMS expires produces exactly that entry: an unauthenticated ping (or hello/isMaster) carrying readConcern.afterOpTime, ending with errName: MaxTimeMSExpired and error code 50.
Three limits apply:
- An attacker who sets
maxTimeMSbelow the slow threshold still plants the waiter but emits no 51803 unless command logging verbosity has been raised. - Whether the request fields appear at all depends on logging configuration.
- The
attr.remotefield may show a loopback or proxy address instead of the true client IP, so source attribution from these entries alone is unreliable.
Detecting the trigger stage
The trigger leaves a much sharper fingerprint. A healthy lagging election logs 21359 once and 21363 once. The vulnerable path logs 21363 twice within the same transition, typically in the same millisecond, followed immediately by a fatal signal:
REPL 21359 Entering primary catch-up modeREPL 21363 Exited primary catch-up modeREPL 21363 Exited primary catch-up mode (same millisecond)CONTROL 31431 Invalid access at address: 0x0CONTROL 31427 Got signal: 11 (Segmentation fault)
The backtrace runs through CatchupState::abort_inlock() and _enterDrainMode_inlock. The PoC’s oracle shows signalHeartbeatUpdate_inlock beneath _handleHeartbeatResponse, which confirms the heartbeat-driven abort. Server-side counters back this up. The vulnerable double abort increments both numCatchUpsSucceeded and numCatchUpsAlreadyCaughtUp in one resume, while the fixed build increments only the latter.
Detection rules worth deploying
Datadog’s guidance turns this into two complementary rules:
- A high-confidence alert on two or more 21363 exits plus a fatal signal on one host within a five-minute window. It needs no command-level logging and no symbolized backtrace.
- A longer correlation, up to 24 hours, that links an unauthenticated
afterOpTimecommand to a later double exit or crash on the same host.
A single catch-up exit is normal and proves nothing. The lookback has to be long because the waiters are patient.
How to patch MongoDB for CVE-2026-118383 without triggering the crash
Atlas customers get fixed binaries automatically during scheduled maintenance. Teams that have deferred maintenance should trigger the pending upgrade now instead of waiting for the next window.
Self-managed fleets need the fixed release on every replica set member, and the order of operations carries a safety benefit. Waiters are cleared cleanly when a member restarts. A standard rolling upgrade does the following:
- Stop one secondary, swap the binary, and wait for it to rejoin as SECONDARY.
- Repeat for every secondary.
- Step down the primary last.
That sequence purges any dormant poison and installs both fixes before any failover can occur. Restarting a poisoned secondary before it ever wins an election defuses the trap without springing it.
Verify every member individually
Verification has to be per member, not per cluster. Connect to each node and check db.version(). That includes the config server replica set, hidden analytics members, delayed disaster-recovery members, containerized instances, and any node that was offline during the maintenance window. A replica set is only as safe as its least-patched failover candidate.
Interim mitigation
Until upgrades land, the only meaningful interim control is network reachability. Restrict the MongoDB port to the systems that need it, using security groups or host firewalls, and require mutual TLS with client-certificate validation. That shrinks the population of clients able to send the two pings. It does not fix the use-after-free, and it stops helping the moment an attacker gains reachability by another path, so it buys time and little else.
Lessons for database security: pre-auth commands are not stateless
Each component involved here was reasonable in isolation. The pre-auth command router treats ping as a harmless liveness probe that drivers need before they authenticate. The read-concern layer offers afterOpTime as a general synchronization hook and assumed the command layer would gate who could invoke it. The catch-up state machine assumed its waiter registry would only ever hold entries ordered by genuine cluster watermarks, and that a completion callback would respect its cancellation status.
Linked together across a network socket, those assumptions let a pre-authentication echo request reach into the consensus state machine, plant immortal state in a C++ multimap, and make a newly elected primary free its own memory at the moment the cluster needed it most. MongoDB’s two commits remove both the injection and the lifetime error. For operators, the takeaway is about attack surface: in a distributed database, the commands you allow before login are an interface to the internal state machines that keep the cluster alive, and they deserve the same scrutiny as the authenticated write path.









