Skip to content

Commit 87b751c

Browse files
fix(iast): avoid native taint lookup without an active request context
is_pyobject_tainted and the raw get_ranges wrapper forwarded to the native no-context multi-slot resolver, whose first step is is_text(obj). Invoked from a GC/finalizer with no active request, that dereferences a type already freed by the cyclic GC and crashes (SIGSEGV). Both wrappers now short-circuit in Python when no IAST request context is active, mirroring get_tainted_ranges.
1 parent 8e44831 commit 87b751c

4 files changed

Lines changed: 139 additions & 9 deletions

File tree

ddtrace/appsec/_iast/_taint_tracking/__init__.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,16 @@ def get_ranges(string_input: Any, context_id: Optional[int] = None) -> Any:
5959

6060
_CACHE_GET_IAST_CONTEXT_ID = _get_iast_context_id
6161
context_id = _CACHE_GET_IAST_CONTEXT_ID()
62+
if context_id is None:
63+
# No active request context. Taint is request-scoped, so there is nothing
64+
# to resolve. Short-circuit here instead of falling through to the native
65+
# multi-slot resolver: with a null context id ``api_get_ranges`` scans via
66+
# ``get_tainted_object_map(obj)`` whose first step, ``is_text(obj)``,
67+
# dereferences the object's type. Reached from a finalizer/GC callback on
68+
# an object whose type has already been freed, that faults with SIGSEGV.
69+
# The sibling wrappers ``is_pyobject_tainted`` and ``get_tainted_ranges``
70+
# apply the same request-scoped guard.
71+
return []
6272
return _native_get_ranges(string_input, context_id)
6373

6474

ddtrace/appsec/_iast/_taint_tracking/_taint_objects_base.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,8 @@ def get_tainted_ranges(pyobject: Any) -> tuple:
7777

7878

7979
def is_pyobject_tainted(pyobject: Any) -> bool:
80+
if not is_iast_request_enabled():
81+
return False
8082
if not isinstance(pyobject, IAST.TAINTEABLE_TYPES):
8183
return False
8284

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
---
2+
fixes:
3+
- |
4+
Code Security (IAST): This fix resolves a crash (``SIGSEGV``) that could
5+
occur when a taint-tracking query ran while no IAST request context was
6+
active, for example from an object finalizer executed by the garbage
7+
collector during interpreter teardown. Such queries now short-circuit in
8+
Python instead of scanning the native taint maps, which could dereference
9+
the type of an object already freed by the cyclic garbage collector.

tests/appsec/iast/taint_tracking/test_context.py

Lines changed: 118 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -108,23 +108,43 @@ def test_get_tainted_ranges_returns_empty_without_context():
108108
assert get_tainted_ranges("xyz") == tuple()
109109

110110

111-
def test_in_taint_map_scans_container_across_active_maps():
111+
def test_is_pyobject_tainted_is_scoped_to_active_slot():
112+
"""Taint queries resolve only within the active request slot.
113+
114+
Two independent slots each hold their own tainted object. Each object is
115+
reported tainted only while its owning slot is the active ``IAST_CONTEXT``.
116+
With no active request the query short-circuits to ``False`` instead of
117+
scanning every slot in native code -- that no-context scan runs
118+
``is_text(obj)`` on the argument and can fault on a type freed during
119+
GC/finalization (see the regression tests below).
120+
"""
112121
clear_all_request_context_slots()
113122
_end_iast_context_and_oce()
114123

115-
# Start two independent maps
116124
ctx1 = start_request_context()
117125
ctx2 = start_request_context()
118126
assert ctx1 is not None and ctx2 is not None and ctx1 != ctx2
119127

120-
target = "TAINT_ME"
121-
# Taint the string into ctx1 explicitly without setting ContextVar
122-
target_tainted1 = _taint_pyobject_base(target, "p", target, OriginType.PARAMETER, contextid=ctx1)
123-
target_tainted2 = _taint_pyobject_base(target, "p", target, OriginType.PARAMETER, contextid=ctx2)
128+
# Distinct values -> distinct objects, so each lives in exactly one slot.
129+
tainted_in_1 = _taint_pyobject_base("TAINT_ONE", "p", "TAINT_ONE", OriginType.PARAMETER, contextid=ctx1)
130+
tainted_in_2 = _taint_pyobject_base("TAINT_TWO", "p", "TAINT_TWO", OriginType.PARAMETER, contextid=ctx2)
124131

125-
assert is_pyobject_tainted(target) is False
126-
assert is_pyobject_tainted(target_tainted1) is True
127-
assert is_pyobject_tainted(target_tainted2) is True
132+
IAST_CONTEXT.set(ctx1)
133+
assert is_pyobject_tainted(tainted_in_1) is True
134+
assert is_pyobject_tainted(tainted_in_2) is False
135+
136+
IAST_CONTEXT.set(ctx2)
137+
assert is_pyobject_tainted(tainted_in_2) is True
138+
assert is_pyobject_tainted(tainted_in_1) is False
139+
140+
# No active request => queries short-circuit to False (native is not consulted).
141+
IAST_CONTEXT.set(None)
142+
assert is_pyobject_tainted(tainted_in_1) is False
143+
assert is_pyobject_tainted(tainted_in_2) is False
144+
145+
finish_request_context(ctx1)
146+
finish_request_context(ctx2)
147+
IAST_CONTEXT.set(None)
128148

129149

130150
def test_num_objects_tainted_is_per_current_context():
@@ -229,3 +249,92 @@ def test_get_ranges_public_api_does_not_leak_across_request_slots():
229249
finish_request_context(ctx_a)
230250
finish_request_context(ctx_b)
231251
IAST_CONTEXT.set(None)
252+
253+
254+
def test_is_pyobject_tainted_without_context_does_not_consult_native():
255+
"""Regression for a teardown/GC crash (SIGSEGV in ``is_text``).
256+
257+
With no active request context the query must return ``False`` *without*
258+
entering native code. Previously ``is_pyobject_tainted`` forwarded to the
259+
native ``is_in_taint_map`` which, with a ``None`` context id, ran the
260+
multi-slot resolver whose first step is ``is_text(obj)``. Invoked from a
261+
uvloop async-generator/task finalizer during shutdown, that dereferenced the
262+
type of an object already freed by the cyclic GC and crashed the process.
263+
"""
264+
clear_all_request_context_slots()
265+
_end_iast_context_and_oce()
266+
267+
ctx = start_request_context()
268+
assert ctx is not None
269+
tainted = _taint_pyobject_base(
270+
"http://dummy.location.com",
271+
"location",
272+
"http://dummy.location.com",
273+
OriginType.PARAMETER,
274+
contextid=ctx,
275+
)
276+
IAST_CONTEXT.set(ctx)
277+
assert is_pyobject_tainted(tainted) is True # sanity: visible within its own slot
278+
279+
# Detach the context, as happens once the request finishes but the tainted
280+
# object is still referenced by a soon-to-be-finalized structure.
281+
IAST_CONTEXT.set(None)
282+
assert is_pyobject_tainted(tainted) is False
283+
assert get_tainted_ranges(tainted) == tuple()
284+
285+
finish_request_context(ctx)
286+
IAST_CONTEXT.set(None)
287+
288+
289+
def test_taint_query_from_finalizer_without_context_is_safe():
290+
"""A ``__del__`` that queries taint outside any request must not crash.
291+
292+
This mirrors the production stack: a finalizer runs under the GC while no
293+
IAST request context is active. The query must resolve to ``False`` in
294+
Python without touching the native taint map.
295+
"""
296+
import gc
297+
298+
clear_all_request_context_slots()
299+
_end_iast_context_and_oce()
300+
IAST_CONTEXT.set(None)
301+
302+
results = []
303+
304+
class _QueriesTaintOnDel:
305+
def __init__(self, value):
306+
self.value = value
307+
308+
def __del__(self):
309+
results.append(is_pyobject_tainted(self.value))
310+
311+
obj = _QueriesTaintOnDel("some-tainted-looking-string")
312+
del obj
313+
gc.collect()
314+
315+
assert results == [False]
316+
317+
318+
def test_get_ranges_public_api_returns_empty_without_context():
319+
"""The raw public ``get_ranges`` (used by aspects/sinks) also short-circuits.
320+
321+
With no active request the wrapper must return empty rather than running the
322+
native no-context resolver, which is the same ``is_text``-on-a-freed-type
323+
crash path guarded above.
324+
"""
325+
from ddtrace.appsec._iast._taint_tracking import get_ranges
326+
327+
clear_all_request_context_slots()
328+
_end_iast_context_and_oce()
329+
330+
ctx = start_request_context()
331+
assert ctx is not None
332+
tainted = _taint_pyobject_base("secret", "p", "secret", OriginType.PARAMETER, contextid=ctx)
333+
334+
# The tainted object lives in a real slot, but with no active context the
335+
# public wrapper must not consult the native multi-slot resolver.
336+
IAST_CONTEXT.set(None)
337+
assert list(get_ranges(tainted)) == []
338+
339+
finish_request_context(ctx)
340+
IAST_CONTEXT.set(None)

0 commit comments

Comments
 (0)