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.
//
·
A subtraction that wraps: turning a replicated row into a 4-gigabyte write in MySQL
Security advisory & technical analysis of CVE-2026-60585, an out-of-bounds write (CWE-787) in MySQL replication.
The short version
When a MySQL replica applies a row-based INSERT from its source, it copies each
column value out of the event and into the table’s in-memory row buffer. The
applier checks that a value’s bytes fit inside the event. It never checks that
they fit inside the destination column.
For CHAR/BINARY columns the copy is followed by a padding step, and that step is
where the interesting part lives:
memcpy(to, from, length); // length comes from the source
fill(to + length, field_length - length); // pad the rest of the column
If length is larger than field_length, the subtraction field_length - length
is unsigned, and it wraps. The “pad the rest” step becomes a memset of roughly 4.29 billion bytes starting past the end of a heap buffer. A single crafted row
from a malicious source turns a routine INSERT into a multi-gigabyte wild write on
the replica’s applier thread.
We found it by reading the row-unpack path, then proved it end to end against a
stock Oracle mysql:9.7.1: a benign row replicates cleanly, and the same code path
with an oversized value drops the server with a write fault whose backtrace lands
squarely in the padding routine.
In the real world, reaching this bug takes some standing: the attacker has to be
the source the replica applies from, or impersonate one, and the replica has to
carry an ordinary CHAR or BINARY column. Given that, a single crafted row
corrupts the applier’s memory with attacker-chosen bytes and reliably crashes the
database node. That crash is what we proved on a stock server: a dependable way to
take a replica down and keep it down, because the same row is re-applied from the
relay log on every restart.
Row-based replication is a deserializer
Under the default binlog_format=ROW, a source does not ship SQL text; it ships row images. Every change is a pair of events: a TABLE_MAP_EVENT that declares a
table’s columns and their metadata, then a WRITE_ROWS (or UPDATE/DELETE) event
carrying the raw column bytes. The replica’s SQL applier thread reads them,
rebuilds each row into table->record[0], and applies it.
Every byte of that row image is produced by the source, so the trust boundary is the
familiar one: the applier deserializes data it did not produce. Control the source,
or impersonate it, and you control the values and the length fields that
describe them.
Who is the high-privileged attacker in Oracle’s PR:H vector? Whoever decides which
source a replica trusts. CHANGE REPLICATION SOURCE TO … ; START REPLICA; requires REPLICATION_SLAVE_ADMIN. And because MySQL does not verify the source’s TLS
certificate by default (SOURCE_SSL_VERIFY_SERVER_CERT=0), an on-path attacker can
step into that role.
uint32 len = tabledef->calc_field_size(col_i, pack_ptr); // reads the length prefix
uint32 event_len = event_end - pack_ptr;
if (len > event_len) { // (*) does it fit the EVENT?
my_error(ER_REPLICA_CORRUPT_EVENT, MYF(0));
return true;
}
...
else if (unpack_field(&pack_ptr, f, metadata, row_image_type, is_partial_json))
And it is careful, about the wrong buffer. calc_field_size genuinely reads the
value’s length prefix, so check (*) proves the bytes we are about to read live
inside the event. What it never does is compare that length against the destination
column’s capacity. Nothing on this path bounds the write. That is the whole bug,
and it lands one call deeper.
The metadata chooses the parser’s code path
Before the sink, one detail decides how large the overflow can be. For a CHAR/BINARY value, the length prefix is one byte or two, and which one is decided
by the column’s declared byte-length, a value the source supplies in the TABLE_MAP metadata (sql/field.cc:6409):
from_length = (((param_data >> 4) & 0x300) ^ 0x300) + (param_data & 0x00ff);
if (from_length > 255) { length = uint2korr(from); from += 2; } // 2-byte prefix, up to 65535
else { length = (uint)*from++; } // 1-byte prefix, up to 255
calc_field_size decodes the metadata with the same formula, so the two agree and
check (*) is satisfied either way. We captured two real values from a live server’s
binlog:
Replica column
field_length
TABLE_MAP metadata
decoded from_length
prefix
BINARY(200)
200
0xfec8
200
1-byte (≤ 255)
CHAR(100) CHARACTER SET utf8mb4
400
0xee90
400
2-byte (≤ 65535)
The second row matters: an ordinary CHAR(100) in a four-byte charset is 400 bytes
wide, so the wire format legitimately switches to a 2-byte prefix. No forgery, the
attacker just needs the victim to have such a column, and can then declare a value up
to 65535 bytes long.
The sink, and the subtraction that wraps
Field_string::unpack (sql/field.cc:6400) performs two
writes into record[0]:
memcpy(to, from, length); // (1) no clamp vs field_length
// Pad the string with the pad character of the field's charset
field_charset->cset->fill(field_charset, pointer_cast<char *>(to) + length,
field_length - length, // (2) UNDERFLOWS
field_charset->pad_char);
Write (1) overflows the record[0] slot by length - field_length bytes of
attacker data, a controlled heap overwrite. But write (2) is the one that turns
a small overflow into an instant kill. field_length is uint32
(sql/field.h:739); fill() takes a size_t len
(m_ctype.h:399). When length >
field_length, field_length - length wraps to roughly 4,294,967,000 and is then
widened to size_t. The “padding” becomes a multi-gigabyte memset walking straight
off the end of the row buffer.
This is why the bug is fatal even when the memcpy overflow is tiny: overflowing a BINARY(200) by 55 bytes is enough, because the pad that follows tries to write four
gigabytes. It is CWE-787, an out-of-bounds write, amplified by CWE-191, an
integer underflow.
Proving it against a live replica
Reaching unpack_row means being the source, so we wrote a dependency-free Python
fake source (about 360 lines). It speaks the replication handshake and metadata
exchange, then streams a real, correctly-framed transaction, FORMAT_DESCRIPTION + TABLE_MAP + WRITE_ROWS + XID, captured from the target’s
own binlog, with exactly two changes.
The dead end
The first change we did not anticipate. Our first attempts streamed the captured
events verbatim, and the applier ran to the end of the transaction, position
advanced, SQL_Running stayed Yes, no error, yet zero rows appeared in the
table. The events were being read and discarded.
The cause is one of replication’s own defenses: a replica silently drops events that
carry its own server_id, the loop guard that stops a change from circling back to
its origin. Our captured events still bore the victim’s server_id. Rewriting it
(offset 5 of every common header) to a foreign value is what makes the applier
actually apply the row. The guard was not in our way by accident; understanding it
was part of reaching the sink.
The payload
The second change is the value: replace the CHAR value with an oversized one.
One crafted WRITE_ROWS row, replayed from the victim’s own binlog. The TABLE_MAP
declares meta = 0xee90 (from_length = 400), which selects the 2-byte length path;
the row image then carries a 0xFFFF length prefix and 65535 bytes of A. Field_string::unpack copies 65535 bytes into the 400-byte slot, then pads with fill(record[0] + 65535, 400 - 65535), a roughly 4 GB memset that walks off the
heap.
The three runs
We ran three cases against a stock mysql:9.7.1 (BuildID cf5f6416b45f14cf1cef8e6298ab2984ab52e68c), changing only the value:
Case
Column (field_length)
Value
Result
control
CHAR(100) utf8mb4 (400)
8 bytes
row replicates, SQL_Running: Yes, no crash
2-byte path
CHAR(100) utf8mb4 (400)
length = 65535
SIGSEGV (mysqld got signal 11)
1-byte path
BINARY(200) (200)
length = 255
SIGSEGV, write fault
The 1-byte case is the most telling, because there the memcpy overflow is only 255 - 200 = 55 bytes, far too little to leave the heap on its own. Its backtrace
names the real culprit:
mysqld got signal 11 ;
Signal SIGSEGV (Invalid permissions for mapped object) at address 0x7f1e389ab000
#2 <libc memset>
#3 0xe095ef charset fill()
#4 0x1553fd5 Field_string::unpack
#5 0x1553450 unpack_field
#6 0x155229e unpack_row
#7 0x154adee Rows_log_event::do_apply_event
Invalid permissions for mapped object is a write fault, the thread walked into
a page it was not allowed to write. The frame above Field_string::unpack is the
charset fill(), and above that is libc’s memset. The 55-byte memcpy did not
fault; the underflowed pad did, exactly as the source predicts. (The release binary
is stripped, so the crash prints <unknown> for every frame; we identify them from
the address stability across runs, the fact that this frame differs from the Field_varstring path at 0x1553c85, and the source above it.)
Only the value size changes between the passing and failing runs. Same table, same
code path, same event framing.
Pushing the crash toward code execution
A crash proves memory corruption; on its own it does not justify the
confidentiality and integrity halves of Oracle’s C:H/I:H/A:H. To see how far the
write actually reaches, we ran the clean overflow path (Field_varstring, which
writes attacker bytes with no trailing pad) under gdb on the stock mysql:9.7.1
and caught the fault in the applier thread:
RAX is not garbage, it is our payload. The instructions right before the fault
show where it came from:
mov -0xa8(%rbp),%r8 ; the applier's per-row field context
mov 0x8(%r8),%rax ; the table's field metadata
mov (%rax),%rax ; a Field* pulled from it (this is what we overwrote)
mov 0x268(%rax),%esi ; dereference the Field* -> SIGSEGV, rax = 0x4141...
So the overflow does not merely scribble on row bytes. It overwrites a Field
object pointer that unpack_row reads from the table’s field metadata and then
dereferences. Field is a polymorphic C++ class: unpacking a column calls virtual
methods on it (Field_string::unpack is one such override). A fully
attacker-controlled Field* therefore sits one dereference away from a virtual
call through an attacker-chosen vtable, which is control of the instruction pointer.
That is the line between “a crash” and “takeover”, and it is why the impact is
scored C:H/I:H/A:H rather than availability only: the corruption hands the program
a pointer the attacker owns and then dispatches through it.
Two properties of the shipped binary lower the bar rather than raise it. mysqld is
built non-PIE (its ELF type is EXEC, and the crash address 0x1553cd1 is
absolute, not randomized), so all of its code and its GOT sit at fixed, known
addresses, and it has only partial RELRO, which leaves the GOT writable. An
attacker does not need to leak the program base to know where its code lives.
We stopped there, and it is worth being precise about why. This bug is a write-only primitive: it never reads memory back to the attacker. To turn the
controlled Field* into execution, the attacker still has to aim it at a fake
object and fake vtable placed at a known address, and both record[0] and the field
array sit on the ASLR-randomized heap. Without a companion information leak, or a
heap-spray-and-partial-overwrite chain that we did not build, there is no reliable
way to learn a heap address to point at. So we confirmed the takeover-class
primitive, an attacker-controlled object pointer that the applier dereferences and
dispatches through, but we did not develop a working code-execution exploit. That
gap, a strong primitive behind a hard-to-satisfy addressing requirement, is exactly
what Oracle’s AC:H (difficult to exploit) and overall 6.6 score describe.
Impact and threat model
Oracle scores it 6.6, AV:N/AC:H/PR:H/UI:N/S:U/C:H/I:H/A:H:
PR:H, the attacker is the source the replica applies from: either they control
a source the victim already replicates from, or they impersonate one (no
certificate verification by default) after someone with REPLICATION_SLAVE_ADMIN
pointed the replica at it.
AC:H, the victim must hold a CHAR/BINARY column whose declared metadata the
source can match so no conversion field is interposed, and turning the heap
corruption into code execution requires grooming under ASLR.
C:H/I:H/A:H, write (1) is a controlled overwrite with attacker bytes, the
primitive Oracle rates as potential takeover; write (2) is the reliable denial
of service. Under a debugger we drove write (1) into a fully attacker-controlled Field object pointer that the applier dereferences and dispatches through (see
“Pushing the crash toward code execution”), one step short of a virtual-call
hijack. We did not build a working code-execution exploit; the reliably
reproducible outcome remained the crash.
In a replication topology or MySQL Cluster, one rogue or impersonated source can drop
every replica that applies its rows, and each restart re-applies the same
relay-logged transaction, so the crash repeats.
The fix Oracle shipped
Oracle fixed this in MySQL 9.7.2 and 8.4.11, and it landed exactly where the root
cause is: in unpack_row, not in any single field’s unpack. We pulled the fixed
source and diffed it against 9.7.0. The one bounds check that had only compared the
value length against the remaining event now also compares it against the
destination field’s capacity:
uint32 len = tabledef->calc_field_size(col_i, pack_ptr);
uint32 event_len = event_end - pack_ptr;
- if (len > event_len) {
+ // Reject fields whose reported packed length exceeds either the
+ // remaining event payload or the maximum packed destination size.
+ if (len > event_len || len > f->max_packed_col_length()) {
my_error(ER_REPLICA_CORRUPT_EVENT, MYF(0));
return true;
}
f->max_packed_col_length() is the largest packed size the destination field can
legitimately hold. Because the guard lives in unpack_row, one line protects
every field type at once, which is why Field_string::unpack and its Field_varstring sibling were left unchanged: neither can now be reached with a
length that exceeds the field.
We confirmed the fix end to end against the same PoC. A stock mysql:9.7.1 crashes
with signal 11 in the applier thread; a stock mysql:9.7.2 takes the identical WRITE_ROWS event and rejects it before any write, stopping the SQL thread with Corrupted replication event was detected (error 1610, ER_REPLICA_CORRUPT_EVENT).
No crash, no overflow.
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. The fix shipped in MySQL 9.7.2 and
8.4.11; we verified the finding against Oracle’s published fix by diffing 9.7.0
against 9.7.2 and reproducing the before/after behaviour on stock images.
Recommendation: upgrade MySQL Server to 9.7.2 or 8.4.11. Until you can patch, enable
source certificate verification with SOURCE_SSL_VERIFY_SERVER_CERT=1, and restrict REPLICATION_SLAVE_ADMIN so only trusted operators can point a replica at a source.
Disclosure timeline
Date
Event
2026-05-20
Reported to Oracle
2026-05-21
Oracle acknowledged the report
2026-05-28
Oracle assigned a tracking number for the report
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)