Khuyến cáo bảo mật & phân tích kỹ thuật CVE-2026-60747, lỗi free of memory not on the heap (CWE-590) trong MySQL replication.
Tóm tắt nhanh
CVE-2026-60747 là lỗi CWE-590, free of memory not on the heap, nằm ngay trong đoạn decode sự kiện heartbeat của MySQL replication. Một source giả mạo chỉ cần gửi đúng một gói tin 19 byte là đủ khiến tiến trình mysqld trên replica abort tức thì, tạo thành một kiểu tấn công từ chối dịch vụ (DoS) mà mỗi lần hạ gục chỉ tốn một gói tin.
Trong replication, replica đọc một luồng sự kiện từ source mà nó đang bám theo, và heartbeat đáng ra là loại sự kiện tẻ nhạt nhất trong cả giao thức, một tín hiệu keep-alive chỉ mang tên file log kèm một vị trí. Thế nhưng decoder của sự kiện này lại trỏ một pointer thẳng vào receive buffer trước khi validation chạy xong, mãi sau mới thay bằng một bản sao trên heap, nên chỉ cần dựng sự kiện méo mó sao cho validation gãy đúng ở quãng giữa thì bước thay thế kia không bao giờ xảy ra, và destructor của đối tượng đem free() một pointer vẫn đang chỉ vào giữa một socket buffer.
Cả trigger gói gọn trong 19 byte, và chỉ một gói tin là đủ để mysqld trên replica lăn ra abort. Ngay sau đó, restart policy của Docker dựng tiến trình dậy, I/O thread nối lại, heartbeat kế tiếp bay tới rồi lại abort, cứ thế thành một vòng lặp mà kẻ tấn công tốn đúng một gói tin ngắn cho mỗi lần đánh sập.
Trên thực tế, lỗi này chỉ mang lại downtime cho kẻ tấn công chứ không phải dữ liệu, bởi lỗi này không đọc cũng không ghi gì lên đĩa. Thứ lỗi này tạo ra là một cách đánh một replica ra khỏi hoạt động: bất kỳ ai đóng được vai source của replica, hoặc chỉ cần nằm trên đường mạng giữa replica và source tương ứng (MySQL mặc định không xác thực TLS certificate của source), đều có thể hạ node database đó bằng một gói tin tí hon rồi giam node trong một vòng lặp sập, vì replica cứ kết nối lại và xin heartbeat kế tiếp. Hệ quả chúng tôi chứng minh được là một cuộc gián đoạn dai dẳng, mỗi lần chỉ một gói tin. Oracle chấm 6.2 (Medium), chỉ ảnh hưởng availability.
Dưới đây, chúng tôi trình bày lại toàn bộ quá trình, gồm vì sao chúng tôi lại ngồi đọc destructor ngay từ đầu, bốn mắt xích biến một tín hiệu keep-alive thành lệnh free() bất hợp lệ, và source giả mà chúng tôi tự dựng để chứng minh lỗi ngay trên một bản build Oracle nguyên gốc.
Vì sao chúng tôi nghiên cứu về destructor
MySQL replication là một luồng dữ liệu chảy từ source tới replica, trong đó source ghi mọi thay đổi vào binary log dưới dạng một chuỗi event, còn replica thì mở kết nối client, xác thực, xin luồng binlog rồi để I/O thread đọc từng event một khỏi socket.
Điểm khiến chuyện này đáng chú ý rất đơn giản, đó là replica deserialize những byte mà chính nó không sinh ra. Mỗi binlog event đi qua một constructor C++ nhận vào pointer thô const char *buf trỏ thẳng vào receive buffer, kèm một Format_description_event mô tả bố cục, nên chỉ cần một constructor xử lý hụt một event méo mó thì ai nắm luồng event sẽ nắm luôn con lỗi.
Vậy ai nắm luồng event? Đó có thể là một source mà replica bị trỏ tới, bất kỳ ai có quyền REPLICATION SLAVE, hoặc bất kỳ ai chen được vào giữa đường truyền từ replica tới source, bởi MySQL replication mặc định bỏ qua bước kiểm tra TLS certificate của source (SOURCE_SSL_VERIFY_SERVER_CERT=0).
Thế nên chúng tôi soi các event decoder với đúng một câu hỏi, rằng liệu một event méo mó có dụ được destructor free() thứ mà nó chưa bao giờ cấp phát hay không. Chúng tôi khởi đầu bằng cách liệt kê mọi class binlog event có destructor giải phóng bộ nhớ qua bapi_free, tức wrapper free() của MySQL, rồi xét từng cái xem member bị free ấy có khi nào giữ một pointer mà không allocator nào trả về, và Heartbeat_event đáp “có” ở mọi khía cạnh.
Mổ xẻ một keep-alive hỏng
Bước đặt bí danh
Constructor nằm trong libs/mysql/binlog/event/control_events.cpp, và việc đầu tiên có ý nghĩa của nó là trỏ log_ident vào ngay vị trí hiện tại của reader:
// control_events.cpp:808
READER_TRY_SET(log_ident, ptr); // log_ident = pointer trỏ VÀO buffer đầu vào
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); // dòng gán an toàn duy nhất
ptr là pointer nằm bên trong receive buffer, cụ thể là buf + 19, ngay sau common header 19 byte. Đây không phải bộ nhớ heap mà chỉ là một vị trí nằm giữa đống byte socket vừa trao, và dòng duy nhất thay pointer này bằng một allocation thật là lệnh strndup ở cuối. Tất cả những gì nằm giữa bước đặt bí danh và strndup đều là validation, mà validation thì được phép bỏ ngang bất cứ lúc nào.
Cú throw không phải exception
Chữ THROW gợi một kỳ vọng mà đoạn code không đáp ứng, bởi READER_THROW không hề unwind stack:
Vậy nên ở cả hai đường throw, dù là log_pos < 4 (throw A, với BIN_LOG_HEADER_SIZE bằng 4 theo binlog_event.h:88) hay một event không có body khiến available_to_read() == 0 (throw B), luồng điều khiển đều nhảy vượt qua strndup. Constructor trả về bình thường, đối tượng dựng xong hoàn chỉnh nhưng chỉ bị đánh dấu không hợp lệ, còn log_ident thì vẫn trỏ vào buf + 19.
Guard chẳng bảo vệ được gì
Khi đối tượng rời scope, destructor của nó chạy (control_events.h:1762):
~Heartbeat_event() {
if (log_ident) bapi_free(const_cast<char *>(log_ident));
}
Dòng if (log_ident) trông như một lớp bảo vệ nhưng thực ra không phải, vì member này khai báo trần, chẳng khởi tạo gì:
// control_events.h:1767
const char *log_ident;
Vậy nên ở một lệnh new bình thường, guard đó đi kiểm tra đúng một giá trị rác. Lý do duy nhất khiến nó không free một địa chỉ ngẫu nhiên ở mọi lần khởi tạo là vì hai đường throw đã sốt sắng đặt log_ident = buf + 19 từ trước, cho nên guard này không hề che chắn ta khỏi lỗi mà ngược lại còn bảo đảm lỗi kích hoạt với một pointer đoán trước được.
my_free làm gì với một pointer trỏ vào buffer
Khi thư viện build thẳng vào server (HAVE_MYSYS bật trong wrapper_functions.h), bapi_free rẽ về my_free, và my_free không chỉ gọi free() cho xong. Ngay trước pointer nhận vào, nó chờ sẵn một allocation header để đọc rồi cập nhật vùng đó, sau cùng mới bàn giao cho system allocator. Bị trỏ vào buf + 19, my_free coi mấy byte đứng liền trước trong receive buffer là metadata allocator và thực hiện một cú ghi out-of-bounds cỡ nhỏ vào buffer, trước khi glibc thẳng tay bác địa chỉ này với free(): invalid pointer rồi abort.
Đó là toàn bộ lỗ hổng, và đúng là CWE-590, free of memory not on the heap: một pointer bị đặt bí danh vào buffer đầu vào trước khi validation, một goto đội lốt throw, một destructor có guard đi soi một trường chưa khởi tạo, và một allocator wrapper coi byte mạng là dữ liệu bookkeeping của heap.
Nơi lỗi thật sự kích hoạt
Sink này không hề giả định, bởi replica dựng rồi hủy một heartbeat event ngay tại chỗ, trên stack, bên trong queue_event của I/O thread (sql/rpl_replica.cc:8035):
case mysql::binlog::event::HEARTBEAT_LOG_EVENT: {
Heartbeat_log_event hb(buf, mi->get_mi_description_event()); // constructor đi theo đường throw
...
} // hb rời khỏi scope tại đây -> ~Heartbeat_event() -> free(buf + 19)
Nhánh code phán xử ra sao về event không hợp lệ cũng chẳng còn nghĩa lý gì, vì hb vừa rời scope thì destructor lập tức free pointer trỏ vào buffer.
Từ một gói tin 19 byte tới một lệnh free sai
Muốn chạm tới destructor đó thì phải đóng vai source, nên chúng tôi viết một source giả bằng Python thuần thư viện chuẩn, dài chừng 425 dòng, nói vừa đủ giao thức replication để dắt một replica thật đi tới sink.
Đoạn hội thoại replica chờ đợi khá ngắn, gồm một HandshakeV10, một lượt fast-auth caching_sha2_password, dăm truy vấn metadata SELECT (@@version, @@server_id, @@server_uuid, @source_binlog_checksum), một COM_REGISTER_SLAVE và chốt lại bằng COM_BINLOG_DUMP. source giả đáp trơn tru từng bước, rồi ngay ở lệnh dump thì phát một Format_description_event hợp lệ, liền sau là trigger.
source giả đáp handshake lẫn mọi truy vấn metadata, xác nhận COM_REGISTER_SLAVE, và ở COM_BINLOG_DUMP thì phát một FDE hợp lệ rồi tới heartbeat méo mó 19 byte, khiến destructor của replica free buf + 19 và mysqld abort với signal 6.
Trigger là một heartbeat khai độ dài tổng đúng bằng chính common header, tức 19 byte và không có body.
Các byte trên đường truyền, little-endian, kèm byte 0x00 mở đầu báo OK cho luồng replication (timestamp tùy ý, lần chạy của chúng tôi nó mang 0x085b686a). data_written = 0x13 = 19 khai rằng event toàn header và không có body, nên available_to_read() trả về 0, validation throw, và log_ident bị free trong khi vẫn đang trỏ vào receive buffer.
data_written bằng 0x13 = 19, nên trong replica available_to_read() trả về 0, throw B nổ, log_ident giữ nguyên bí danh trỏ vào buffer và destructor đem nó đi free.
Chúng tôi trỏ một replica mysql:9.6.0 nguyên gốc vào source giả rồi gõ START REPLICA, và log server phơi ra thông báo hỏng dữ liệu cùng lệnh abort sát nhau:
Frame #9 (0x158d732) là queue_event, cụ thể là nhánh HEARTBEAT_LOG_EVENT tại rpl_replica.cc:8035, gọi lên từ handle_slave_io (#10), tức I/O thread, còn mấy frame phía trên là đường abort của glibc sau lệnh free() bị bác. Lỗi mang tính tất định, bởi hễ một heartbeat cập bến là một lần tái hiện.
mysqld chạy production thường nằm dưới một restart supervisor, nên tiến trình bật dậy tức thì, I/O thread nối lại, nhận heartbeat kế tiếp rồi sập tiếp, và một gói tin 19 byte đổi lấy một lần sập chính là một cuộc từ chối dịch vụ tự nuôi chính nó.
Tác động và mô hình mối đe dọa
Oracle chấm 6.2 với vector AV:L/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H, tức attack vector local, không cần đặc quyền hay tương tác và chỉ đụng tới Availability. Chữ “local” phản ánh đúng điều kiện thực tế, bởi kẻ tấn công phải ở thế đóng vai hoặc chen vào đường tới một source replication mà nạn nhân đã đặt niềm tin.
On-path, tay không thông tin đăng nhập. Với mặc định SOURCE_SSL_VERIFY_SERVER_CERT=0, replica không xác thực certificate của source, nên ai chặn hoặc bẻ hướng được kết nối từ replica tới source đều có thể giả dạng source và tuồn gói heartbeat 19 byte.
source có thông tin đăng nhập. Ai nắm REPLICATION SLAVE trên một source mà replica kết nối tới đều kích hoạt lỗi được ngay.
Hệ quả là một cú DoS trọn vẹn với mysqld dính lỗi (C:N/I:N/A:H). Lệnh free sai có kèm một cú ghi out-of-bounds cỡ nhỏ khi my_free xử lý hụt metadata header, nhưng tiến trình abort ngay lập tức, nên nhất quán với thang điểm của Oracle, chúng tôi xếp đây thuần túy là vấn đề Availability. Trong một MySQL Cluster hay bất kỳ topo replication nào, một source bất hảo hoặc bị giả dạng đủ sức giam mọi replica phía dưới trong một vòng lặp sập.
Bản vá Oracle phát hành
Oracle vá lỗi này trong MySQL 9.7.2 và 8.4.11. Bản vá nằm ở constructor của Heartbeat_event, và đúng như dự đoán: khởi tạo member, đồng thời không bao giờ để log_ident trỏ vào buffer đầu vào khi chưa nắm một allocation thật. Chúng tôi kéo source đã vá về rồi diff với 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");
Bản vá gồm ba chỗ sửa nhỏ, tất cả cùng phục vụ một ý tưởng. Initializer list giờ đặt log_ident thành nullptr, nhờ vậy guard if (log_ident) trong destructor cuối cùng cũng có ý nghĩa. Lệnh READER_TRY_SET(log_ident, ptr) ở đầu đã bị bỏ, nên log_ident chỉ còn hoặc giữ nullptr hoặc một allocation strndup thật, và mọi đường throw trước strndup giờ để lại giá trị null khiến destructor trở thành no-op. Destructor cùng phần khai báo member trong control_events.h vẫn giữ nguyên, bởi một khi constructor thôi đặt bí danh thì chẳng còn gì để phải canh chừ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 abort với free(): invalid pointer và signal 6, còn một bản mysql:9.7.2 nguyên gốc nhận đúng heartbeat méo mó 19 byte đó, ghi cùng dòng log [MY-013118] ... heartbeat event content seems corrupted, rồi vẫn chạy tiếp. Giờ đây MySQL chỉ đơn giản từ chối event hỏng đó.
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, cùng MySQL Cluster 8.0.0 đến 8.0.47, 8.4.0 đến 8.4.10 và 9.7.0 đến 9.7.1, đều nằm trong diện bị ảnh hưởng.
Khuyến nghị: nâng cấp MySQL Server lên 9.7.2 hoặc 8.4.11 (bản vá trong Oracle Critical Patch Update tháng 7/2026). 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 lại danh sách ai được phép đóng vai source cho replica của bạn.
Các mốc thời gian công bố lỗ hổng
Ngày
Sự kiện
14/05/2026
Báo cáo cho Oracle
15/05/2026
Oracle tiếp nhận báo cáo và cấp mã theo dõi
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)