From e553c8a1c17509fa56c9cfdb2bdfb068ac8ae98e Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Thu, 15 Oct 2026 10:59:37 +0800 Subject: [PATCH 1/3] gh-142782: Fix use-after-free in the zoneinfo strong cache Comparing a lookup key against a cached key runs the key's __eq__, which can re-enter and call ZoneInfo.clear_cache(), freeing the node being compared; find_in_strong_cache() then dereferenced the freed node. Keep a reference to the node key during the comparison and bump a cache version counter on every mutation, so a lookup that observes a mutation during a comparison returns a miss instead of using a freed node. --- Lib/test/test_zoneinfo/test_zoneinfo.py | 20 +++++++++++ ...-10-15-09-30-00.gh-issue-142782.z1Uaf7.rst | 2 ++ Modules/_zoneinfo.c | 34 ++++++++++++++----- 3 files changed, 47 insertions(+), 9 deletions(-) create mode 100644 Misc/NEWS.d/next/Library/2026-10-15-09-30-00.gh-issue-142782.z1Uaf7.rst diff --git a/Lib/test/test_zoneinfo/test_zoneinfo.py b/Lib/test/test_zoneinfo/test_zoneinfo.py index 41ba92342ff2cbd..49c35c005db0e71 100644 --- a/Lib/test/test_zoneinfo/test_zoneinfo.py +++ b/Lib/test/test_zoneinfo/test_zoneinfo.py @@ -1664,6 +1664,26 @@ class EvilZoneInfo(self.klass): self.assertEqual(str(zone2), key) self.assertIsNot(zone2, zone1) + def test_strong_cache_reentrant_clear(self): + # gh-142782: a key whose __eq__ clears the cache during a strong-cache + # lookup must not cause a use-after-free. + klass = self.klass + + class Stringy(str): + def __new__(cls, value): + self = super().__new__(cls, value) + self.cleared = False + return self + def __eq__(self, other): + if not self.cleared: + self.cleared = True + klass.clear_cache(only_keys=(self,)) + return super().__eq__(other) + __hash__ = str.__hash__ + + klass(Stringy("America/Los_Angeles")) + klass(Stringy("America/Los_Angeles")) + class CZoneInfoCacheTest(ZoneInfoCacheTest): module = c_zoneinfo diff --git a/Misc/NEWS.d/next/Library/2026-10-15-09-30-00.gh-issue-142782.z1Uaf7.rst b/Misc/NEWS.d/next/Library/2026-10-15-09-30-00.gh-issue-142782.z1Uaf7.rst new file mode 100644 index 000000000000000..d68f9f5a5b8903b --- /dev/null +++ b/Misc/NEWS.d/next/Library/2026-10-15-09-30-00.gh-issue-142782.z1Uaf7.rst @@ -0,0 +1,2 @@ +Fix a use-after-free in :class:`zoneinfo.ZoneInfo` when a key's ``__eq__`` +clears the cache during a strong-cache lookup. diff --git a/Modules/_zoneinfo.c b/Modules/_zoneinfo.c index 46ab3499e003542..c25a9ed5ba9962b 100644 --- a/Modules/_zoneinfo.c +++ b/Modules/_zoneinfo.c @@ -103,6 +103,9 @@ typedef struct { PyObject *TIMEDELTA_CACHE; PyObject *ZONEINFO_WEAK_CACHE; StrongCacheNode *ZONEINFO_STRONG_CACHE; + // Bumped whenever the strong cache is mutated, so a lookup can detect + // re-entrant mutation during a key comparison (gh-142782). + size_t strong_cache_version; _ttinfo NO_TTINFO; } zoneinfo_state; @@ -2434,19 +2437,31 @@ remove_from_strong_cache(zoneinfo_state *state, StrongCacheNode *node) * root may be NULL, indicating an empty cache. */ static StrongCacheNode * -find_in_strong_cache(const StrongCacheNode *const root, PyObject *const key) -{ - const StrongCacheNode *node = root; +find_in_strong_cache(zoneinfo_state *state, PyObject *const key) +{ + // Comparing keys may run arbitrary Python code (a str subclass __eq__), + // which can re-enter and mutate the cache (e.g. ZoneInfo.clear_cache()), + // freeing nodes. Keep a reference to the key for the comparison, and if + // the cache was mutated during it, treat the lookup as a miss rather than + // dereference a possibly freed node (gh-142782). + size_t version = state->strong_cache_version; + const StrongCacheNode *node = state->ZONEINFO_STRONG_CACHE; while (node != NULL) { - int rv = PyObject_RichCompareBool(key, node->key, Py_EQ); + PyObject *node_key = Py_NewRef(node->key); + const StrongCacheNode *next = node->next; + int rv = PyObject_RichCompareBool(key, node_key, Py_EQ); + Py_DECREF(node_key); if (rv < 0) { return NULL; } + if (state->strong_cache_version != version) { + return NULL; + } if (rv) { return (StrongCacheNode *)node; } - node = node->next; + node = next; } return NULL; @@ -2465,12 +2480,12 @@ eject_from_strong_cache(zoneinfo_state *state, const PyTypeObject *const type, } _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(state->ZoneInfoType); - StrongCacheNode *cache = state->ZONEINFO_STRONG_CACHE; - StrongCacheNode *node = find_in_strong_cache(cache, key); + StrongCacheNode *node = find_in_strong_cache(state, key); if (node != NULL) { remove_from_strong_cache(state, node); strong_cache_node_free(node); + state->strong_cache_version++; } else if (PyErr_Occurred()) { return -1; @@ -2522,8 +2537,7 @@ zone_from_strong_cache(zoneinfo_state *state, const PyTypeObject *const type, } _Py_CRITICAL_SECTION_ASSERT_OBJECT_LOCKED(state->ZoneInfoType); - StrongCacheNode *cache = state->ZONEINFO_STRONG_CACHE; - StrongCacheNode *node = find_in_strong_cache(cache, key); + StrongCacheNode *node = find_in_strong_cache(state, key); if (node != NULL) { StrongCacheNode **root = &(state->ZONEINFO_STRONG_CACHE); @@ -2555,6 +2569,7 @@ update_strong_cache(zoneinfo_state *state, const PyTypeObject *const type, } StrongCacheNode **root = &(state->ZONEINFO_STRONG_CACHE); move_strong_cache_node_to_front(state, root, new_node); + state->strong_cache_version++; StrongCacheNode *node = new_node->next; for (size_t i = 1; i < ZONEINFO_STRONG_CACHE_MAX_SIZE; ++i) { @@ -2587,6 +2602,7 @@ clear_strong_cache(zoneinfo_state *state, const PyTypeObject *const type) strong_cache_free(state->ZONEINFO_STRONG_CACHE); state->ZONEINFO_STRONG_CACHE = NULL; + state->strong_cache_version++; } static PyObject * From b7c45b64fdf2fa0155360f0d3e9ce251371e9852 Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Thu, 15 Oct 2026 11:04:32 +0800 Subject: [PATCH 2/3] Trim comments --- Modules/_zoneinfo.c | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/Modules/_zoneinfo.c b/Modules/_zoneinfo.c index c25a9ed5ba9962b..eb59090e584258e 100644 --- a/Modules/_zoneinfo.c +++ b/Modules/_zoneinfo.c @@ -103,8 +103,7 @@ typedef struct { PyObject *TIMEDELTA_CACHE; PyObject *ZONEINFO_WEAK_CACHE; StrongCacheNode *ZONEINFO_STRONG_CACHE; - // Bumped whenever the strong cache is mutated, so a lookup can detect - // re-entrant mutation during a key comparison (gh-142782). + // Bumped on every strong-cache mutation (gh-142782). size_t strong_cache_version; _ttinfo NO_TTINFO; @@ -2439,11 +2438,8 @@ remove_from_strong_cache(zoneinfo_state *state, StrongCacheNode *node) static StrongCacheNode * find_in_strong_cache(zoneinfo_state *state, PyObject *const key) { - // Comparing keys may run arbitrary Python code (a str subclass __eq__), - // which can re-enter and mutate the cache (e.g. ZoneInfo.clear_cache()), - // freeing nodes. Keep a reference to the key for the comparison, and if - // the cache was mutated during it, treat the lookup as a miss rather than - // dereference a possibly freed node (gh-142782). + // A key's __eq__ can re-enter and clear the cache during the comparison, + // freeing nodes; treat such a mutation as a miss (gh-142782). size_t version = state->strong_cache_version; const StrongCacheNode *node = state->ZONEINFO_STRONG_CACHE; while (node != NULL) { From 8b10008babf517ec0b9ec7726116b7f9a328c66c Mon Sep 17 00:00:00 2001 From: tonghuaroot Date: Thu, 15 Oct 2026 20:08:32 +0800 Subject: [PATCH 3/3] Assert the returned zone in the regression test --- Lib/test/test_zoneinfo/test_zoneinfo.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/Lib/test/test_zoneinfo/test_zoneinfo.py b/Lib/test/test_zoneinfo/test_zoneinfo.py index 49c35c005db0e71..659af400e71994e 100644 --- a/Lib/test/test_zoneinfo/test_zoneinfo.py +++ b/Lib/test/test_zoneinfo/test_zoneinfo.py @@ -1681,8 +1681,10 @@ def __eq__(self, other): return super().__eq__(other) __hash__ = str.__hash__ - klass(Stringy("America/Los_Angeles")) - klass(Stringy("America/Los_Angeles")) + zone1 = klass(Stringy("America/Los_Angeles")) + zone2 = klass(Stringy("America/Los_Angeles")) + self.assertEqual(str(zone1), "America/Los_Angeles") + self.assertEqual(str(zone2), "America/Los_Angeles") class CZoneInfoCacheTest(ZoneInfoCacheTest):