Skip to content

Commit 08500b0

Browse files
gh-154318: Guard C recursion when hashing nested tuples and frozendicts (GH-154362)
* gh-154318: Guard C recursion when hashing nested tuples and frozendicts tuple_hash() and frozendict_hash() fold each contained object's hash into an accumulator with PyObject_Hash() inside an unguarded loop. For a deeply nested tuple or frozendict this recurses back into the same function once per level and overflows the C stack (SIGSEGV / STATUS_STACK_OVERFLOW) instead of raising RecursionError. Wrap both loops in _Py_EnterRecursiveCall() / _Py_LeaveRecursiveCall(), as the sibling comparison and repr paths already do. The guard is placed after the cached-hash early return, so repeated hashing of an already-hashed tuple is unaffected. This also covers types.GenericAlias, whose ga_hash() hashes its __args__ tuple and so crashed through tuple_hash().
1 parent 5a17b2e commit 08500b0

5 files changed

Lines changed: 49 additions & 0 deletions

File tree

‎Lib/test/test_dict.py‎

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1990,6 +1990,21 @@ def test_hash(self):
19901990
with self.assertRaisesRegex(TypeError, "unhashable type: 'list'"):
19911991
hash(fd)
19921992

1993+
@support.skip_if_huge_c_stack(500_000)
1994+
@support.skip_wasi_stack_overflow()
1995+
@support.skip_emscripten_stack_overflow()
1996+
def test_hash_deeply_nested(self):
1997+
# This should raise a RecursionError and not crash.
1998+
# Nesting through *values*: unlike keys, values are not hashed at
1999+
# construction time, so hashing recurses the full depth.
2000+
# See https://github.com/python/cpython/issues/154318.
2001+
fd = frozendict({0: 0})
2002+
for _ in range(500_000):
2003+
fd = frozendict({0: fd})
2004+
with self.assertRaisesRegex(RecursionError,
2005+
"while hashing a frozendict"):
2006+
hash(fd)
2007+
19932008
@support.cpython_only
19942009
def test_hash_cpython(self):
19952010
# Check that hash(frozendict) implementation is:

‎Lib/test/test_tuple.py‎

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,6 +123,18 @@ def check_one_exact(t, e32, e64):
123123
check_one_exact((0.5, (), (-2, 3, (4, 6))), 714642271,
124124
-1845940830829704396)
125125

126+
@support.skip_if_huge_c_stack(500_000)
127+
@support.skip_wasi_stack_overflow()
128+
@support.skip_emscripten_stack_overflow()
129+
def test_hash_deeply_nested(self):
130+
# This should raise a RecursionError and not crash.
131+
# See https://github.com/python/cpython/issues/154318.
132+
t = ()
133+
for _ in range(500_000):
134+
t = (t,)
135+
with self.assertRaisesRegex(RecursionError, "while hashing a tuple"):
136+
hash(t)
137+
126138
# Various tests for hashing of tuples to check that we get few collisions.
127139
# Does something only if RUN_ALL_HASH_TESTS is true.
128140
#
Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
Fix a crash when hashing a deeply nested :class:`tuple` or :class:`frozendict`.
2+
A :exc:`RecursionError` is now raised instead of overflowing the C stack.

‎Objects/dictobject.c‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8474,16 +8474,26 @@ frozendict_hash(PyObject *op)
84748474
PyDictObject *mp = _PyAnyDict_CAST(op);
84758475
Py_uhash_t hash = 0;
84768476

8477+
/* Hashing a value recurses back into frozendict_hash() when that value
8478+
is itself a frozendict, so guard the descent to raise RecursionError
8479+
rather than overflow the C stack on a deeply nested frozendict. Keys
8480+
are safe: their hashes are already computed at construction time. */
8481+
if (_Py_EnterRecursiveCall(" while hashing a frozendict")) {
8482+
return -1;
8483+
}
8484+
84778485
PyObject *value; // borrowed ref
84788486
Py_ssize_t pos = 0;
84798487
Py_hash_t key_hash;
84808488
while (_PyDict_Next(op, &pos, NULL, &value, &key_hash)) {
84818489
Py_hash_t pair_hash = frozendict_pair_hash(key_hash, value);
84828490
if (pair_hash == -1) {
8491+
_Py_LeaveRecursiveCall();
84838492
return -1;
84848493
}
84858494
hash ^= _shuffle_bits(pair_hash);
84868495
}
8496+
_Py_LeaveRecursiveCall();
84878497

84888498
/* Factor in the number of active entries */
84898499
hash ^= ((Py_uhash_t)mp->ma_used + 1) * 1927868237UL;

‎Objects/tupleobject.c‎

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -380,16 +380,26 @@ tuple_hash(PyObject *op)
380380

381381
Py_ssize_t len = Py_SIZE(v);
382382
PyObject **item = v->ob_item;
383+
384+
/* Hashing an item recurses back into tuple_hash() when that item is
385+
itself a tuple, so guard the descent to raise RecursionError rather
386+
than overflow the C stack on a deeply nested tuple. */
387+
if (_Py_EnterRecursiveCall(" while hashing a tuple")) {
388+
return -1;
389+
}
390+
383391
acc = _PyTuple_HASH_XXPRIME_5;
384392
for (Py_ssize_t i = 0; i < len; i++) {
385393
Py_uhash_t lane = PyObject_Hash(item[i]);
386394
if (lane == (Py_uhash_t)-1) {
395+
_Py_LeaveRecursiveCall();
387396
return -1;
388397
}
389398
acc += lane * _PyTuple_HASH_XXPRIME_2;
390399
acc = _PyTuple_HASH_XXROTATE(acc);
391400
acc *= _PyTuple_HASH_XXPRIME_1;
392401
}
402+
_Py_LeaveRecursiveCall();
393403

394404
/* Add input length, mangled to keep the historical value of hash(()). */
395405
acc += len ^ (_PyTuple_HASH_XXPRIME_5 ^ 3527539UL);

0 commit comments

Comments
 (0)