Advisories

Twice-Burned Hash: shallow listpack validation leaks heap memory in DragonflyDB

By Will·
CVSS
Affected
DragonflyDB ≤ 2.0.0 (RDB loader + replication)
Disclosed
September 17, 2026
Summary. DragonflyDB loads hash and sorted-set listpacks from RDB snapshots and from its replication stream using only shallow (header-level) validation - the same malformed data its RESTORE command rejects. A crafted listpack is accepted at load time and then crashes the server on the next pairwise read (HGETALL, ZRANK), or - with a forged entry length - makes the loader copy out-of-bounds heap memory into a key the client can read back. No authentication is required; Dragonfly ships without auth by default.

Dragonfly is a high-throughput, Redis/Valkey-compatible in-memory datastore written from scratch in C++. "Compatible" is the operative word here: to be a drop-in replacement it reimplements Redis’s on-wire encodings - listpacks, quicklists, intsets, streams - and the RDB opcode stream, in its own hand-written parsers. Those parsers trust length and count fields that an attacker controls, and they were never hardened to the same depth as the code path that RESTORE uses.

The incomplete fix

Earlier in 2026, Dragonfly patched a chain of RESTORE crash bugs (CVE-2026-54341 / GHSA-cwjr-j869-h8q9). The fix threaded a deep_integrity flag through the shared RDB-object deserializer - but enabled it at exactly one call site in the entire tree:

src/server/generic_family.cc:225   config.deep_integrity = true;   // RESTORE only
src/server/rdb_load.h:191          bool deep_integrity = false;    // the default

Every other consumer of the same deserializer kept shallow validation - including the three that matter most, because they ingest bytes from a source the client never had to authenticate to:

src/server/replica.cc:504          RdbLoader loader(NULL, &load_context);          // PSYNC
src/server/replica.cc:1232         rdb_loader_ = make_unique<RdbLoader>(...);      // DFLY full sync
src/server/server_family.cc:1613   RdbLoader loader{&service_, load_context, ...}; // RDB file load

The guard the CVE fix added, in hset_family.cc’s LoadListpackBlob, is literally gated on that flag:

// Reject an unpaired tail; gated on deep since counting may scan
// not-yet-validated entries.
if (deep && lpLength((uint8_t*)blob.data()) % 2 != 0) {
    LOG(ERROR) << "Hash listpack has an odd number of entries.";
    return LoadBlobResult::kCorrupted;
}

Replication passes deep = false, so the check is skipped. A hash with an odd number of entries - which a valid writer can never emit - sails straight into the keyspace.

Why it crashes in shipping builds

The listpack walker’s only remaining bounds check is a bare C assert. In src/redis/listpack.c:

static inline void lpAssertValidEntry(unsigned char* lp, size_t lpbytes,
                                      unsigned char *p) {
    assert(lpValidateNext(lp, &p, lpbytes));
}

unsigned char *lpNext(unsigned char *lp, unsigned char *p) {
    assert(p);
    p = lpSkip(p);                      // advance by attacker-controlled length
    if (p[0] == LP_EOF) return NULL;    // OOB read happens HERE, pre-validation
    lpAssertValidEntry(lp, lpBytes(lp), p);  // compiled out under -DNDEBUG
    return p;
}

Dragonfly ships release builds with -DNDEBUG. Under that flag the C standard elides the entire argument expression of assert - so lpValidateNext(), the actual bounds check, is never called at all in production. The protection exists only in debug builds. lpSkip advances the pointer by an encoded length the attacker chose, and the p[0] read dereferences it before anything validates it.

Two outcomes from one malformed blob

1. Denial of service

An odd-entry hash listpack, or a zset/list with a mismatched length, loads cleanly and then SIGSEGVs inside lpGet / lpNext on the next pairwise read. For a single-shard datastore that is loss of all in-memory data. A second, independent availability path also exists: some payloads pin a shard’s fiber in a multi-million-iteration walk - one measured run emitted 7,176,512 log lines and 569 MB of output in 25 seconds before resolving - a sustained CPU and log-volume exhaustion vector, not just an instant crash.

2. Information disclosure

The more serious outcome. A hash whose listpack total_bytes header exceeds max_listpack_map_bytes (default 1024) is converted to a StringMap at load time by ConvertToStrMap:

StringMap* HSetFamily::ConvertToStrMap(uint8_t* lp) {
  StringMap* sm = CompactObj::AllocateMR<StringMap>();
  detail::ListpackWrap lw{lp};
  sm->Reserve(lw.size());
  for (const auto [key, value] : lw)        // walks via the unchecked Read()
    LOG_IF(ERROR, !sm->AddOrUpdate(key, value)) << "...";
  return sm;
}

The shallow check only verifies that the listpack’s outer total_bytes matches the buffer size and that the last byte is LP_EOF. It never validates individual entries - so a single entry can forge its own declared string length (LP_ENCODING_32BIT_STR) to claim far more data than was supplied. GetView then builds a string_view of that forged length starting past the end of the allocation, and AddOrUpdate copies those out-of-bounds bytes into the StringMap as a hash value. A plain HGETALL hands them to the client. Unlike the pure-read crash paths, this persists what it read into a structure built for client retrieval.

Measured, not hypothesized

Both outcomes were reproduced on a release build (pinned commit 2f0a7ff, -release -DNDEBUG, mimalloc, ASLR full) on a real x86_64 Linux host - the architecture production Dragonfly actually runs on. The headline is that at the stock default configuration (no server misconfig), information disclosure is the modal outcome of a single attempt, not a long shot:

30 single-attempt runs, stock default --listpack_max_bytes (1024):
  crash_before_sync:       8/30 (26.7%)
  timed_out_still_alive:   3/30 (10.0%)
  leaked_no_crash:        19/30 (63.3%)   <- majority: HGETALL returns heap bytes, replica stays up
  leaked_then_crash:       0/30

Leaked values were not just zeroed padding: individual runs returned legible bytes such as -848288572636135421 and repeated small-integer counters - consistent with reading live adjacent process state, not scrubbed memory. One 240-element HGETALL reply contained 120 field/value pairs where only a single field was ever part of the payload.

Severity

GitHub’s catalog labels this Moderate. That label is a qualitative tag chosen by the publisher; GitHub does not require it to match the CVSS vector, and here it does not. The CVSS 3.1 vector attached to the same advisory computes 9.1 (Critical) - network-reachable, unauthenticated, no user interaction, with two High impacts (confidentiality from the heap leak, availability from the crash). We assess it as:

  • 9.1 Critical (primary) - CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:N/A:H
  • 7.4 High (conservative alternative, if ASLR-dependent heap layout is held to be an inherent complexity barrier) - CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:N/A:H

The AC judgement is the only open call. We argue AC:L: the leak-versus-crash split is outcome variance inherent to the bug, not a precondition the attacker must separately arrange - no heap grooming, no race to win, no extra requests - and at the realistic default config the worst case (C:H) is the 63% majority result of a single attempt. C:H is demonstrated rather than argued; A:H is reliable across batches and has a second independent path via the hang variant.

Delivery and preconditions

The network path requires the attacker to be - or be on-path to - a replication master for a victim replica. The RDB-file path requires the ability to plant a snapshot the server loads at startup, via DFLY LOAD, or via DEBUG RELOAD. Neither requires authentication in the default configuration, because Dragonfly does not require authentication by default.

Workarounds (from the vendor advisory): avoid loading RDB files from untrusted sources; restrict write access to snapshot directories; protect replication links from interception; and require authentication to prevent unauthorized replication.

Fix

Dragonfly extended deep listpack validation to the RDB-load and replication paths. The fix landed on main in commit 47768113 (PR #8328, merged 2026-09-17), with a regression test RestoreRejectsOddHashListpack in src/server/generic_family_test.cc. At time of writing the fix is on main only - no tagged release has shipped it, so deployments tracking releases remain exposed until one does.

Timeline

  • 2026-08-31 - crash primitive confirmed against pinned commit; reported to Dragonfly.
  • 2026-09-01 - escalation to demonstrated heap disclosure.
  • 2026-09-17 - fix merged to main (commit 47768113, PR #8328).
  • 2026-09-17 - advisory published as GHSA-pmgx-g74v-v37g.

Read the GitHub advisory · announcement on X