Welcome to our research page, where we provide you with the latest news, trends, and insights in the fast-paced world of tech. Join us on this exciting journey and discover the transformative power of technology today.
Security advisory & technical analysis of CVE-2026-60747, a free-of-memory-not-on-the-heap bug (CWE-590) in MySQL replication.
The short version
A MySQL replica reads a stream of events from the source it follows. One of those events, the heartbeat, is meant to be the most boring packet in the protocol: a keep-alive that carries a log file name and a position, nothing more. Its decoder, however, writes a pointer into the network receive buffer before it finishes validating the event, and only later replaces that pointer with a proper heap copy. When the event is malformed so that validation fails in the gap, the replacement never happens, and the object’s destructor calls free() on a pointer that points into the middle of a socket buffer.
We can send that malformed event with 19 bytes. One packet, and the replica’s mysqld aborts. Docker’s restart policy brings it back, the I/O thread reconnects, the next heartbeat arrives, and it aborts again, a denial of service that costs the attacker one short packet per crash.
In the real world this buys an attacker downtime, not data. The bug reads nothing and changes nothing on disk. What it gives is a way to knock a replica offline: anyone who can stand in as the replica’s source, or simply sit on the network between the replica and its source (MySQL does not verify the source’s TLS certificate by default), can drop that database node with one tiny packet and then hold it in a crash loop, because the replica keeps reconnecting and asking for the next heartbeat. The proven payoff is a persistent, one-packet-at-a-time outage of the replica. Oracle rates it 6.2 (Medium), availability only.
This post is the walk: why we were reading destructors in the first place, the four-part anatomy of how a keep-alive turns into an illegal free(), and the fake replication source we used to prove it against a stock Oracle build.
Why we were reading destructors
MySQL replication is a stream from a source to a replica. The source writes every change to its binary log as a sequence of events; the replica opens a client connection, authenticates, asks for the binlog stream, and its I/O thread reads those events off the socket one by one.
The property that makes this interesting is simple: the replica deserializes bytes it did not produce. Every binlog event is parsed by a C++ constructor that takes a raw const char *buf pointing straight at the network receive buffer, plus a Format_description_event that describes the layout. If any of those constructors mishandles a malformed event, whoever controls the event stream controls the bug.
Who controls the event stream? A source the replica has been pointed at, anyone with REPLICATION SLAVE credentials, or, because MySQL replication does not verify the source’s TLS certificate by default (SOURCE_SSL_VERIFY_SERVER_CERT=0), anyone who can sit on the path between a replica and its source.
So we read the event decoders with a single question: can a malformed event get a destructor to free() something it never allocated? We started by listing every binlog event class whose destructor releases memory with bapi_free, MySQL’s free() wrapper, and then asked, for each, whether the freed member could ever hold a pointer that no allocator returned. Heartbeat_event answered yes on all counts.
The anatomy of a keep-alive gone wrong
The alias
The constructor lives in libs/mysql/binlog/event/control_events.cpp. Its first meaningful act is to point log_ident at the reader’s current position:
// control_events.cpp:808
READER_TRY_SET(log_ident, ptr); // log_ident = cursor INTO the input buffer
if (log_ident == nullptr || header()->log_pos < BIN_LOG_HEADER_SIZE)
READER_THROW("Invalid Heartbeat information"); // (throw A)
ident_len = READER_CALL(available_to_read);
if (ident_len == 0) READER_THROW("Event is smaller than expected"); // (throw B)
if (ident_len > FN_REFLEN - 1) ident_len = FN_REFLEN - 1;
READER_TRY_SET(log_ident, strndup<const char *>, ident_len); // the only safe assignment
ptr is the cursor inside the network receive buffer, concretely buf + 19, just past the 19-byte common header. It is not heap memory; it is a location inside the bytes the socket handed us. The only line that ever replaces it with a real allocation is the strndup on the last line. Everything between the alias and the strndup is validation, and validation is allowed to bail out.
The throw that isn’t an exception
The word THROW sets an expectation the code does not meet. READER_THROW does not unwind the stack:
So on either throw path, log_pos < 4 (throw A; BIN_LOG_HEADER_SIZE is 4, per binlog_event.h:88) or a body-less event with available_to_read() == 0 (throw B), control jumps over the strndup. The constructor returns normally. The object is fully constructed, just flagged invalid. And log_ident still points at buf + 19.
The guard that guards nothing
When that object leaves scope, its destructor runs (control_events.h:1762):
~Heartbeat_event() {
if (log_ident) bapi_free(const_cast<char *>(log_ident));
}
The if (log_ident) reads like a safety check. It is not. The member is declared without an initializer:
// control_events.h:1767
const char *log_ident;
So on a normal new the guard tests uninitialized garbage. The only reason it does not free a random address on every construction is that our two throw paths faithfully set log_ident = buf + 19 first. The “guard” does not protect against the bug; it guarantees the bug fires with a predictable pointer.
What my_free does with a buffer pointer
bapi_free resolves to my_free when the library is built into the server (HAVE_MYSYS is defined in wrapper_functions.h). my_free does not simply call free(). It expects an allocation header sitting immediately before the pointer it is given, reads and updates that region, and only then hands off to the system allocator. Pointed at buf + 19, it treats the receive buffer’s own preceding bytes as allocator metadata, a small out-of-bounds write into the buffer, before glibc rejects the address outright with free(): invalid pointer and aborts.
That is the whole vulnerability, and it is CWE-590, free of memory not on the heap: a pointer aliased into an input buffer before validation, a goto masquerading as a throw, a destructor whose guard checks an uninitialized field, and an allocator wrapper that treats network bytes as heap bookkeeping.
Where it actually runs
The sink is not hypothetical. The replica constructs and destroys a heartbeat event inline, on the stack, in the I/O thread’s queue_event (sql/rpl_replica.cc:8035):
case mysql::binlog::event::HEARTBEAT_LOG_EVENT: {
Heartbeat_log_event hb(buf, mi->get_mi_description_event()); // ctor takes a throw path
...
} // hb leaves scope here -> ~Heartbeat_event() -> free(buf + 19)
Whatever the branch decides about the invalid event no longer matters. The moment hb leaves scope, the destructor frees the buffer pointer.
From a 19-byte packet to a bad free
Reaching that destructor requires being the source. So we wrote a dependency-free Python “fake source”, about 425 lines of standard library, that speaks just enough of the replication protocol to walk a real replica to the sink.
The conversation the replica expects is short: a HandshakeV10, a caching_sha2_password fast-auth, a handful of metadata SELECTs (@@version, @@server_id, @@server_uuid, @source_binlog_checksum), a COM_REGISTER_SLAVE, and finally a COM_BINLOG_DUMP. Our fake source answers each convincingly, then, on the dump request, streams one valid Format_description_event followed immediately by the trigger.
The fake source answers the handshake and every metadata query, acknowledges COM_REGISTER_SLAVE, and on COM_BINLOG_DUMP streams one valid FDE and then the 19-byte malformed heartbeat. The replica’s destructor frees buf + 19 and mysqld aborts with signal 6.
The trigger is a heartbeat whose declared total length equals the common header itself, 19 bytes, no body.
The wire bytes, little-endian, with the leading 0x00 replication-stream OK byte (the timestamp is arbitrary; in our run it held 0x085b686a). data_written = 0x13 = 19 claims the event is all header and no body, so available_to_read() returns 0, validation throws, and log_ident is freed while still pointing into the receive buffer.
data_written is 0x13 = 19. Inside the replica, available_to_read() therefore returns 0, throw B fires, log_ident keeps aliasing the buffer, and the destructor frees it.
We pointed a stock mysql:9.6.0 replica at the fake source and issued START REPLICA. The server log shows the corruption notice and the abort back to back:
Frame #9 (0x158d732) is queue_event, specifically the HEARTBEAT_LOG_EVENT branch at rpl_replica.cc:8035, called from handle_slave_io (#10), the I/O thread. The frames above it are glibc’s abort path from the rejected free(). The crash is deterministic: every heartbeat delivery reproduces it.
Because production mysqld is usually run under a restart supervisor, the process comes right back, the I/O thread reconnects, receives the next heartbeat, and crashes again. One 19-byte packet per crash is a self-sustaining denial of service.
Impact and threat model
Oracle scores this 6.2, AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H, a local attack vector, no privileges or interaction, availability-only impact. The “local” vector reflects the realistic precondition: the attacker must be positioned to act as, or sit on the path to, a replication source the victim already trusts.
On-path, no credentials. With the default SOURCE_SSL_VERIFY_SERVER_CERT=0, the replica does not authenticate the source’s certificate. An attacker who can intercept or redirect the replica-to-source connection can impersonate the source and deliver the 19-byte heartbeat.
Credentialed source. Anyone holding REPLICATION SLAVE on a source a replica connects to can trigger it directly.
The consequence is a complete DoS of the affected mysqld (C:N/I:N/A:H). The bad free does perform a small out-of-bounds write as my_free mishandles its metadata header, but the process aborts immediately; consistent with Oracle’s scoring, we treat this strictly as an availability issue. In a MySQL Cluster or any replicated topology, a single rogue or impersonated source can hold every downstream replica in a crash loop.
The fix Oracle shipped
Oracle fixed this in MySQL 9.7.2 and 8.4.11. The patch is in the Heartbeat_event constructor, and it is the obvious one: initialize the member, and never let log_ident point into the input buffer before it holds a real allocation. We pulled the fixed source and diffed it against 9.7.0:
Heartbeat_event::Heartbeat_event(const char *buf,
const Format_description_event *fde)
- : Binary_log_event(&buf, fde) {
+ : Binary_log_event(&buf, fde), log_ident(nullptr), ident_len(0) {
READER_TRY_INITIALIZATION;
READER_ASSERT_POSITION(fde->common_header_len);
- READER_TRY_SET(log_ident, ptr);
- if (log_ident == nullptr || header()->log_pos < BIN_LOG_HEADER_SIZE)
+ if (header()->log_pos < BIN_LOG_HEADER_SIZE)
READER_THROW("Invalid Heartbeat information");
ident_len = READER_CALL(available_to_read);
if (ident_len == 0) READER_THROW("Event is smaller than expected");
if (ident_len > FN_REFLEN - 1) ident_len = FN_REFLEN - 1;
READER_TRY_SET(log_ident, strndup<const char *>, ident_len);
+ if (log_ident == nullptr)
+ READER_THROW("Invalid binary log file name in Heartbeat event");
Three edits, one idea. The initializer list now sets log_ident to nullptr, so the destructor’s if (log_ident) guard finally means something. The early READER_TRY_SET(log_ident, ptr) is gone, so log_ident only ever holds nullptr or a real strndup allocation: every throw before the strndup now leaves it null and the destructor becomes a no-op. The destructor and the member declaration in control_events.h are untouched, because once the constructor stops aliasing there is nothing left to guard against.
We confirmed the fix end to end against the same PoC. A stock mysql:9.7.1 aborts with free(): invalid pointer and signal 6; a stock mysql:9.7.2 takes the identical 19-byte malformed heartbeat, logs the same [MY-013118] ... heartbeat event content seems corrupted, and keeps running. The corrupt event is now simply rejected.
Affected versions and recommendations
Per Oracle’s advisory: MySQL Server 8.4.0 to 8.4.10 and 9.7.0 to 9.7.1; MySQL Cluster 8.0.0 to 8.0.47, 8.4.0 to 8.4.10 and 9.7.0 to 9.7.1.
Recommendation: upgrade MySQL Server to 9.7.2 or 8.4.11 (fixed in the July 2026 Oracle Critical Patch Update). Until you can patch, enable source certificate verification with SOURCE_SSL_VERIFY_SERVER_CERT=1 and tighten which hosts are allowed to act as a replication source for your replica.
Disclosure timeline
Date
Event
2026-05-14
Reported to Oracle
2026-05-15
Oracle acknowledged the report and assigned a tracking number
2026-06-25
Oracle credited Pucagit of CyStack and set the report to be addressed in a future release
2026-07-18
CVE assigned, credited to Pucagit of CyStack
2026-07-21
Oracle disclosed this in the July 2026 Critical Patch Update
2026-07-28
Fix released in MySQL 9.7.2 and 8.4.11 (github.com/mysql/mysql-server)