Khuyến cáo bảo mật & phân tích kỹ thuật CVE-2026-60585, lỗi out-of-bounds write (CWE-787) trong MySQL replication.
Tóm tắt nhanh
Khi một MySQL replica áp dụng một INSERT dạng row-based từ source của nó, replica chép
từng giá trị cột ra khỏi event rồi đưa vào row buffer trong bộ nhớ của bảng. Bộ applier có
kiểm tra rằng các byte của một giá trị nằm gọn bên trong event, nhưng không bao giờ kiểm
tra chúng có nằm gọn bên trong cột đích hay không.
Với các cột CHAR/BINARY, sau bước chép là một bước pad, và chỗ thú vị nằm chính ở bước
pad đó:
memcpy(to, from, length); // length comes from the source
fill(to + length, field_length - length); // pad the rest of the column
Nếu length lớn hơn field_length, phép trừ field_length - length là unsigned nên bị
wrap. Bước “pad phần còn lại” biến thành một lệnh memset cỡ khoảng 4,29 tỷ byte, bắt
đầu từ ngay sau đuôi một heap buffer. Chỉ một dòng được dựng có chủ đích từ một source độc
hại là đủ biến một lệnh INSERT bình thường thành một cú ghi hoang cỡ nhiều gigabyte trên
applier thread của replica.
Chúng tôi tìm ra lỗi khi đọc đường row-unpack, rồi chứng minh đầu cuối trên một bản Oracle mysql:9.7.1 nguyên gốc: một dòng lành tính replicate trơn tru, còn cùng đường code đó với
một giá trị quá khổ thì đánh sập server bằng một write fault mà backtrace rơi đúng vào
routine pad.
Trên thực tế, để chạm tới lỗi này cần một chút vị thế, đó là kẻ tấn công phải là source mà
replica áp dụng, hoặc giả dạng source đó, và replica phải có một cột CHAR hoặc BINARY
thông thường. Với điều kiện ấy, chỉ một dòng được dựng có chủ đích là đủ làm hỏng bộ nhớ của
applier bằng các byte do kẻ tấn công chọn, rồi đánh sập node database một cách ổn định. Cú
sập đó là thứ chúng tôi đã chứng minh trên server nguyên gốc, một cách đáng tin cậy để hạ một
replica và giữ nó nằm luôn, bởi cùng dòng đó được áp dụng lại từ relay log ở mỗi lần khởi
động.
Replication dạng row-based là một bộ deserialize
Với mặc định binlog_format=ROW, source không gửi văn bản SQL mà gửi row image. Mỗi thay
đổi là một cặp event: một TABLE_MAP_EVENT khai báo các cột của bảng cùng metadata của
chúng, rồi một event WRITE_ROWS (hoặc UPDATE/DELETE) mang theo các byte cột thô. SQL applier thread của replica đọc chúng, dựng lại từng dòng vào table->record[0] rồi
áp dụng.
Mọi byte của row image đó đều do source sinh ra, nên ranh giới tin cậy vẫn là ranh giới quen
thuộc: applier deserialize dữ liệu mà chính nó không tạo ra. Kiểm soát source, hoặc giả dạng
source, là bạn kiểm soát cả các giá trị lẫn các trường độ dài mô tả chúng.
Ai là kẻ tấn công đặc quyền cao trong vector PR:H của Oracle? Chính là người quyết định
replica tin source nào. Lệnh CHANGE REPLICATION SOURCE TO … ; START REPLICA; đòi quyền REPLICATION_SLAVE_ADMIN. Và vì MySQL mặc định không xác thực TLS certificate của source
(SOURCE_SSL_VERIFY_SERVER_CERT=0), một kẻ tấn công nằm trên đường truyền (on-path) có thể
bước vào vai đó.
Một lần kiểm tra biên, nhắm nhầm buffer
Vòng lặp theo từng cột trong unpack_row()
(sql/rpl_record.cc:654) trông có vẻ cẩn thận:
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))
Và nó đúng là cẩn thận, nhưng với nhầm buffer. calc_field_size thật sự đọc length prefix
của giá trị, nên lần kiểm tra (*) chứng minh các byte mà ta sắp đọc nằm bên trong event.
Điều nó không bao giờ làm là so độ dài đó với sức chứa của cột đích. Không có gì trên đường
này giới hạn cú ghi. Đó là toàn bộ con lỗi, và nó nằm sâu hơn một lời gọi.
Metadata quyết định đường code của bộ parse
Trước khi tới sink, một chi tiết quyết định overflow có thể lớn tới đâu. Với một giá trị CHAR/BINARY, length prefix dài một byte hoặc hai byte, và chọn cái nào là do byte-length
khai báo của cột, một giá trị mà source cung cấp trong metadata của TABLE_MAP
(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 giải mã metadata bằng cùng công thức, nên cả hai khớp nhau và kiểm tra (*) thỏa mãn theo cả hai đường. Chúng tôi bắt được hai giá trị thật từ binlog của một
server đang chạy:
Cột trên replica
field_length
Metadata TABLE_MAP
from_length giải mã
prefix
BINARY(200)
200
0xfec8
200
1 byte (≤ 255)
CHAR(100) CHARACTER SET utf8mb4
400
0xee90
400
2 byte (≤ 65535)
Dòng thứ hai mới đáng chú ý: một cột CHAR(100) bình thường trong charset bốn byte rộng tới
400 byte, nên định dạng trên đường truyền chuyển sang prefix 2 byte một cách hợp lệ. Không
cần giả mạo gì, kẻ tấn công chỉ cần nạn nhân có một cột như vậy, rồi có thể khai một giá trị
dài tới 65535 byte.
Sink, và phép trừ bị tràn ngược
Field_string::unpack (sql/field.cc:6400) thực hiện hai cú ghi vào 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);
Cú ghi (1) làm tràn slot record[0] thêm length - field_length byte dữ liệu của kẻ
tấn công, một cú ghi đè heap có kiểm soát. Nhưng cú ghi (2) mới là cú biến một overflow
nhỏ thành một phát chết ngay. field_length là uint32
(sql/field.h:739); còn fill() nhận một size_t len
(m_ctype.h:399). Khi length > field_length, field_length - length wrap về khoảng 4.294.967.000 rồi được nới rộng thành size_t.
“Phần pad” biến thành một memset cỡ nhiều gigabyte chạy thẳng ra ngoài đuôi row buffer.
Đây là lý do lỗi vẫn chí mạng ngay cả khi overflow của memcpy rất nhỏ: làm tràn một BINARY(200) thêm 55 byte là đủ, vì cú pad theo sau cố ghi bốn gigabyte. Đây là CWE-787
(một out-of-bounds write), và CWE-191 (một integer underflow) chính là thứ khuếch đại nó.
Chứng minh trên một replica thật
Chạm tới unpack_row nghĩa là phải chính là source, nên chúng tôi viết một source giả bằng
Python thuần thư viện chuẩn (khoảng 360 dòng). Source giả này nói được phần handshake và trao
đổi metadata của replication, rồi phát một transaction dựng đúng khung, FORMAT_DESCRIPTION + TABLE_MAP + WRITE_ROWS + XID, lấy từ chính binlog của mục tiêu,
với đúng hai thay đổi.
Ngõ cụt
Thay đổi thứ nhất nằm ngoài dự liệu của chúng tôi. Những lần thử đầu, chúng tôi phát các
event bắt được y nguyên, và applier chạy tới hết transaction, position tiến lên, SQL_Running
vẫn Yes, không lỗi, nhưng không có dòng nào xuất hiện trong bảng. Các event bị đọc rồi
vứt đi.
Nguyên nhân là một trong những cơ chế phòng vệ của chính replication: một replica lặng lẽ bỏ
các event mang server_id của chính nó, cơ chế loop guard chặn một thay đổi khỏi vòng ngược
về nơi phát sinh. Các event chúng tôi bắt được vẫn mang server_id của nạn nhân, nên việc
ghi đè nó (offset 5 của mọi common header) sang một giá trị ngoại lai mới khiến applier thật
sự áp dụng dòng. Cơ chế guard cản đường chúng tôi không phải do tình cờ, và hiểu được nó là
một phần của việc chạm tới sink.
Payload
Thay đổi thứ hai là giá trị: thay giá trị CHAR bằng một giá trị quá khổ.
Một dòng WRITE_ROWS được dựng có chủ đích, phát lại từ chính binlog của nạn nhân. TABLE_MAP khai meta = 0xee90 (from_length = 400), chọn đường length 2 byte; row image
sau đó mang một length prefix 0xFFFF và 65535 byte A. Field_string::unpack chép 65535
byte vào slot 400 byte, rồi pad bằng fill(record[0] + 65535, 400 - 65535), một memset cỡ
khoảng 4 GB chạy ra ngoài heap.
Ba lần chạy
Chúng tôi chạy ba trường hợp trên một bản mysql:9.7.1 nguyên gốc (BuildID cf5f6416b45f14cf1cef8e6298ab2984ab52e68c), chỉ đổi giá trị:
Trường hợp
Cột (field_length)
Giá trị
Kết quả
control
CHAR(100) utf8mb4 (400)
8 byte
dòng replicate, SQL_Running: Yes, không sập
đường 2 byte
CHAR(100) utf8mb4 (400)
length = 65535
SIGSEGV (mysqld got signal 11)
đường 1 byte
BINARY(200) (200)
length = 255
SIGSEGV, write fault
Trường hợp 1 byte mới nói lên nhiều nhất, vì ở đó overflow của memcpy chỉ 255 - 200 = 55 byte, quá ít để tự nó ra khỏi heap. Backtrace của nó gọi tên thủ phạm thật:
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 là một lỗi ghi, thread bước vào một trang mà nó
không được phép ghi. Frame ngay trên Field_string::unpack là fill() của charset, và trên
đó nữa là memset của libc. Cú memcpy 55 byte không gây fault; cú pad bị tràn ngược mới
gây, đúng như source dự đoán. (Bản release binary bị strip nên crash in ra <unknown> cho
mọi frame; chúng tôi nhận diện chúng nhờ tính ổn định của địa chỉ qua các lần chạy, nhờ việc
frame này khác với đường Field_varstring ở 0x1553c85, và nhờ source phía trên nó.)
Chỉ kích thước giá trị thay đổi giữa lần chạy đạt và lần chạy lỗi. Cùng bảng, cùng đường
code, cùng khung event.
Đẩy cú sập tiến tới thực thi mã
Một cú sập chứng minh có memory corruption, nhưng tự thân nó chưa biện minh được cho nửa
confidentiality và integrity trong C:H/I:H/A:H của Oracle. Để xem cú ghi thực sự vươn xa
tới đâu, chúng tôi chạy đường overflow sạch (Field_varstring, vốn ghi byte của kẻ tấn công
mà không có phần pad đuôi) dưới gdb trên bản mysql:9.7.1 nguyên gốc, và bắt được fault
trong applier thread:
RAX không phải rác, đó là payload của chúng tôi. Các lệnh ngay trước fault cho thấy nó tới
từ đâu:
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...
Vậy overflow không chỉ bôi bẩn lên các byte của dòng, mà còn ghi đè một pointer tới object Field mà unpack_row đọc từ metadata trường của bảng rồi dereference. Field là một
class C++ đa hình (polymorphic): unpack một cột sẽ gọi các phương thức virtual trên nó
(Field_string::unpack là một override như vậy). Một Field* bị kẻ tấn công kiểm soát hoàn
toàn do đó chỉ cách một lần dereference so với một virtual call qua một vtable do kẻ tấn công
chọn, tức là kiểm soát instruction pointer. Đó là ranh giới giữa “một cú sập” và “chiếm quyền
(takeover)”, và là lý do tác động được chấm C:H/I:H/A:H thay vì chỉ availability: sự hỏng
hóc trao cho chương trình một pointer mà kẻ tấn công sở hữu rồi điều phối qua nó.
Hai đặc tính của binary được phát hành lại hạ thấp rào cản chứ không nâng lên. mysqld được
build non-PIE (kiểu ELF là EXEC, và địa chỉ crash 0x1553cd1 là tuyệt đối, không bị
ngẫu nhiên hóa), nên toàn bộ code cùng GOT của nó nằm ở địa chỉ cố định, đã biết, và nó chỉ
có partial RELRO, để GOT vẫn ghi được. Kẻ tấn công không cần rò rỉ base của chương trình
để biết code của nó nằm đâu.
Chúng tôi dừng ở đó, và cần nói rõ vì sao. Lỗi này là một primitive chỉ ghi, nó không bao
giờ đọc bộ nhớ trả lại cho kẻ tấn công. Để biến Field* bị kiểm soát thành thực thi, kẻ tấn
công vẫn phải nhắm nó vào một object giả và vtable giả đặt ở một địa chỉ đã biết, mà cả record[0] lẫn mảng field đều nằm trên heap bị ASLR ngẫu nhiên hóa. Không có một info leak đi
kèm, hoặc một chuỗi heap-spray kèm partial-overwrite mà chúng tôi chưa dựng, thì không có cách
đáng tin cậy nào để biết một địa chỉ heap để trỏ tới. Vậy nên chúng tôi xác nhận được
primitive cỡ takeover, một pointer object do kẻ tấn công kiểm soát mà applier dereference rồi
điều phối qua đó, nhưng chưa phát triển một exploit thực thi mã hoạt động. Khoảng cách đó, một
primitive mạnh nằm sau một yêu cầu về địa chỉ khó thỏa mãn, chính là điều mà AC:H (khó khai
thác) và điểm 6.6 tổng thể của Oracle mô tả.
PR:H, kẻ tấn công là source mà replica áp dụng, tức hoặc họ kiểm soát một source mà nạn
nhân đang replicate, hoặc họ giả dạng một source (mặc định không xác thực certificate) sau
khi ai đó có REPLICATION_SLAVE_ADMIN đã trỏ replica tới đó.
AC:H, nạn nhân phải có một cột CHAR/BINARY với metadata khai báo mà source khớp
được để không có conversion field chen vào, và biến hỏng hóc heap thành thực thi mã đòi hỏi
phải groom dưới ASLR.
C:H/I:H/A:H, cú ghi (1) là một cú ghi đè có kiểm soát bằng byte của kẻ tấn công,
chính là primitive mà Oracle xếp là có khả năng takeover; còn cú ghi (2) là cú từ chối
dịch vụ đáng tin cậy. Dưới debugger, chúng tôi đẩy cú ghi (1) thành một pointer object Field bị kẻ tấn công kiểm soát hoàn toàn mà applier dereference rồi điều phối qua đó (xem
“Đẩy cú sập tiến tới thực thi mã”), chỉ còn cách một bước so với một cú cướp virtual-call.
Chúng tôi chưa dựng một exploit thực thi mã hoạt động, và kết quả tái hiện ổn định vẫn là cú
sập.
Trong một topo replication hay một MySQL Cluster, một source bất hảo hoặc bị giả dạng có thể
hạ mọi replica áp dụng dòng của nó, và mỗi lần khởi động lại lại áp dụng cùng transaction đã
ghi trong relay log, nên cú sập lặp lại.
Bản vá Oracle phát hành
Oracle vá lỗi này trong MySQL 9.7.2 và 8.4.11, và bản vá đáp đúng vào chỗ nguyên nhân gốc:
trong unpack_row, chứ không phải trong unpack của một trường đơn lẻ nào. Chúng tôi kéo
source đã vá về rồi diff với 9.7.0. Lần kiểm tra biên duy nhất, vốn chỉ so độ dài giá trị với
phần event còn lại, giờ so thêm với sức chứa của trường đích:
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() là kích thước packed lớn nhất mà trường đích có thể chứa hợp lệ.
Vì guard nằm trong unpack_row, một dòng bảo vệ mọi loại trường cùng lúc, và đó là lý do Field_string::unpack cùng người anh em Field_varstring của nó được để nguyên: giờ không
cái nào còn có thể bị chạm tới với một độ dài vượt quá trường.
Chúng tôi kiểm chứng bản vá đầu cuối bằng chính PoC cũ. Một bản mysql:9.7.1 nguyên gốc sập
với signal 11 trong applier thread; còn một bản mysql:9.7.2 nguyên gốc nhận đúng event WRITE_ROWS đó rồi từ chối trước khi có bất kỳ cú ghi nào, dừng SQL thread với Corrupted replication event was detected (lỗi 1610, ER_REPLICA_CORRUPT_EVENT). Không sập,
không overflow.
Phiên bản bị ảnh hưởng và khuyến nghị
Theo advisory của Oracle: MySQL Server 8.4.0 đến 8.4.10 và 9.7.0 đến 9.7.1; MySQL Cluster
8.0.0 đến 8.0.47, 8.4.0 đến 8.4.10 và 9.7.0 đến 9.7.1. Bản vá phát hành trong MySQL 9.7.2 và
8.4.11; chúng tôi đã kiểm chứng phát hiện này với bản vá công bố của Oracle bằng cách diff
9.7.0 với 9.7.2 và tái hiện hành vi trước và sau trên các image nguyên gốc.
Khuyến nghị: nâng cấp MySQL Server lên 9.7.2 hoặc 8.4.11. Trong lúc chưa vá được, hãy bật xác
thực certificate của source bằng SOURCE_SSL_VERIFY_SERVER_CERT=1, và siết quyền REPLICATION_SLAVE_ADMIN để chỉ những người vận hành đáng tin mới có thể trỏ một replica tới
một source.
Các mốc thời gian công bố lỗ hổng
Ngày
Sự kiện
20/05/2026
Báo cáo cho Oracle
21/05/2026
Oracle tiếp nhận báo cáo
28/05/2026
Oracle cấp mã theo dõi cho báo cáo
25/06/2026
Oracle ghi nhận công lao của Pucagit (CyStack) và xếp lỗi vào diện sẽ vá ở bản phát hành sau
18/07/2026
Gán CVE, ghi nhận cho Pucagit (CyStack)
21/07/2026
Oracle công bố trong Oracle Critical Patch Update tháng 7/2026
28/07/2026
Bản vá phát hành trong MySQL 9.7.2 và 8.4.11 (github.com/mysql/mysql-server)