From 3aeaff502ac4efcaefe49336a72ca9ecb2c630a1 Mon Sep 17 00:00:00 2001 From: Nicolas Brousse Date: Sat, 22 Aug 2026 21:48:03 -0700 Subject: [PATCH 1/3] fix(security): guard against destructor reentrancy and UAF on STRING_TO_ENTRY - Copy old zval descriptor into temporary stack variable and commit new entry state before calling zval_ptr_dtor() in judy_object_write_dimension_helper() and Judy::set(). - Delete entry key from JudySL prior to calling zval_ptr_dtor() in Judy::pruneExpired() to prevent double-free or UAF if a destructor re-enters. - Add regression test regression_string_to_entry_reentrancy_001.phpt. --- php_judy.c | 18 ++++--- ...ession_string_to_entry_reentrancy_001.phpt | 50 +++++++++++++++++++ 2 files changed, 61 insertions(+), 7 deletions(-) create mode 100644 tests/regression_string_to_entry_reentrancy_001.phpt diff --git a/php_judy.c b/php_judy.c index d33e45d..d5714ff 100644 --- a/php_judy.c +++ b/php_judy.c @@ -1153,10 +1153,12 @@ int judy_object_write_dimension_helper(zval *object, zval *offset, zval *value) } judy_cache_entry_t *entry = (judy_cache_entry_t *)(uintptr_t)(*slot); if (entry != NULL) { - zval_ptr_dtor(&entry->value); + zval old_val; + ZVAL_COPY_VALUE(&old_val, &entry->value); entry->expires_at = 0; entry->flags = 0; ZVAL_COPY(&entry->value, value); + zval_ptr_dtor(&old_val); } else { entry = (judy_cache_entry_t *)emalloc(sizeof(judy_cache_entry_t)); entry->expires_at = 0; @@ -5984,11 +5986,13 @@ PHP_METHOD(Judy, set) entry = (judy_cache_entry_t *)(uintptr_t)(*slot); if (entry != NULL) { - /* Overwrite existing entry */ - zval_ptr_dtor(&entry->value); + /* Overwrite existing entry: update new state before destroying old value */ + zval old_val; + ZVAL_COPY_VALUE(&old_val, &entry->value); entry->expires_at = expires_at; entry->flags = (uint16_t)flags; ZVAL_COPY(&entry->value, value); + zval_ptr_dtor(&old_val); } else { /* Allocate new entry */ entry = (judy_cache_entry_t *)emalloc(sizeof(judy_cache_entry_t)); @@ -6107,15 +6111,15 @@ PHP_METHOD(Judy, pruneExpired) memcpy(key_to_del, kindex, klen); key_to_del[klen] = '\0'; - zval_ptr_dtor(&entry->value); - efree(entry); - - /* Delete the key from JudySL */ + /* Delete the key from JudySL before running destructor */ JSLD(Rc_int, intern->array, key_to_del); intern->counter--; judy_string_bytes_sub(intern, (Word_t)klen); pruned_count++; + zval_ptr_dtor(&entry->value); + efree(entry); + /* Find the next key strictly greater than key_to_del */ JSLN(PValue, intern->array, key_to_del); if (PValue != NULL && PValue != PJERR) { diff --git a/tests/regression_string_to_entry_reentrancy_001.phpt b/tests/regression_string_to_entry_reentrancy_001.phpt new file mode 100644 index 0000000..f1a5886 --- /dev/null +++ b/tests/regression_string_to_entry_reentrancy_001.phpt @@ -0,0 +1,50 @@ +--TEST-- +Regression: Judy STRING_TO_ENTRY destructor reentrancy and UAF safety +--SKIPIF-- + +--FILE-- +judy[$this->key]); + } +} + +class PruneEvilObject { + public function __construct(public Judy $judy) {} + public function __destruct() { + // Reentrantly access/prune the cache + $this->judy->pruneExpired(); + } +} + +// 1. ArrayAccess overwrite reentrancy +$j = new Judy(Judy::STRING_TO_ENTRY); +$j["k"] = new EvilObject($j, "k"); +$j["k"] = 42; +var_dump(isset($j["k"])); + +// 2. Judy::set() overwrite reentrancy +$j2 = new Judy(Judy::STRING_TO_ENTRY); +$j2->set("token", new EvilObject($j2, "token"), 300); +$j2->set("token", "new_token_payload", 300); +var_dump(isset($j2["token"])); + +// 3. Judy::pruneExpired() reentrancy +$j3 = new Judy(Judy::STRING_TO_ENTRY); +$j3->set("expired_item", new PruneEvilObject($j3), 1); +sleep(2); +$evicted = $j3->pruneExpired(); +var_dump($evicted >= 1); +var_dump(count($j3)); + +echo "OK\n"; +?> +--EXPECT-- +bool(false) +bool(false) +bool(true) +int(0) +OK From 3ead40b1eba8d52b951b5fdf3924f56af892dc5a Mon Sep 17 00:00:00 2001 From: Nicolas Brousse Date: Sat, 22 Aug 2026 21:51:37 -0700 Subject: [PATCH 2/3] chore: declare regression_string_to_entry_reentrancy_001 in package.xml --- package.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/package.xml b/package.xml index dc6498e..ee2da4e 100644 --- a/package.xml +++ b/package.xml @@ -319,6 +319,7 @@ + From 3b263db241536e71c9d82d405d17b6a12c1ce32d Mon Sep 17 00:00:00 2001 From: Nicolas Brousse Date: Sat, 22 Aug 2026 22:55:40 -0700 Subject: [PATCH 3/3] release: 2.7.1 --- package.xml | 47 ++++++++++++++++++++++++++++++++--------------- php_judy.h | 2 +- 2 files changed, 33 insertions(+), 16 deletions(-) diff --git a/package.xml b/package.xml index ee2da4e..9555682 100644 --- a/package.xml +++ b/package.xml @@ -16,11 +16,11 @@ nicolas@brousse.info yes - 2026-08-22 + 2026-08-23 - 2.7.0 - 2.7.0 + 2.7.1 + 2.7.1 stable @@ -28,18 +28,10 @@ PHP -- FEATURE: Native cache and TTL storage engine -- Judy::STRING_TO_ENTRY (type 11). - Stores values with TTL timestamps (uint32) and 16-bit metadata flags (uint16_t) - directly packed into native C struct entries (judy_cache_entry_t) in the - JudySL radix trie without PHP array wrappers or secondary lookup indexes (#188). -- FEATURE: Native cache entry methods -- set(), get(), pruneExpired(), getEntry(), - getExpiry(), and getFlags(). pruneExpired(?int $now = null) performs a single-pass - trie sweep directly in C with 0 PHP heap allocations (12.9 ms per 100k items). -- FEATURE: Full bulk and iteration support on STRING_TO_ENTRY -- ArrayAccess, - Iterator, keys(), values(), toArray(), slice(), getAll(), and serialization. -- BUILD: Pure-Rust Expanse backend integration support (--with-expanse) for - modern 64-bit microarchitectures, zero-leak memory management, and cross-platform - MSVC Windows support without source patching. +- SECURITY FIX: Guard against destructor re-entrancy and heap Use-After-Free (UAF) + in Judy::STRING_TO_ENTRY during ArrayAccess overwrites, Judy::set(), and + Judy::pruneExpired() sweeps (#198). Commits new slot payload prior to invoking + zval_ptr_dtor(), and unlinks keys from the JudySL trie before freeing entry structs. @@ -446,6 +438,31 @@ + + 2026-08-22 + + 2.7.0 + 2.7.0 + + + stable + stable + + +- FEATURE: Native cache and TTL storage engine -- Judy::STRING_TO_ENTRY (type 11). + Stores values with TTL timestamps (uint32) and 16-bit metadata flags (uint16_t) + directly packed into native C struct entries (judy_cache_entry_t) in the + JudySL radix trie without PHP array wrappers or secondary lookup indexes (#188). +- FEATURE: Native cache entry methods -- set(), get(), pruneExpired(), getEntry(), + getExpiry(), and getFlags(). pruneExpired(?int $now = null) performs a single-pass + trie sweep directly in C with 0 PHP heap allocations (12.9 ms per 100k items). +- FEATURE: Full bulk and iteration support on STRING_TO_ENTRY -- ArrayAccess, + Iterator, keys(), values(), toArray(), slice(), getAll(), and serialization. +- BUILD: Pure-Rust Expanse backend integration support (--with-expanse) for + modern 64-bit microarchitectures, zero-leak memory management, and cross-platform + MSVC Windows support without source patching. + + 2026-08-19 diff --git a/php_judy.h b/php_judy.h index e6f731b..412fb6f 100644 --- a/php_judy.h +++ b/php_judy.h @@ -19,7 +19,7 @@ #ifndef PHP_JUDY_H #define PHP_JUDY_H -#define PHP_JUDY_VERSION "2.7.0" +#define PHP_JUDY_VERSION "2.7.1" #define PHP_JUDY_EXTNAME "judy" /* Windows x64 (LLP64): Force 64-bit Word_t to match libjudy ABI.