Skip to content

Commit e304f82

Browse files
committed
fix(iast): wrong memory address in subprocess in mcp servers (#15514)
IAST-enabled applications using Gunicorn/Uvicorn workers were experiencing segmentation faults (~33% crash rate on MCP streaming requests) due to memory corruption when processes fork. - C++ global singletons (`taint_engine_context`, `initializer`) initialized at module load - Taint maps storing PyObject pointers by memory address - Child processes after fork inherited stale pointers from parent process memory - Accessing these stale pointers → use-after-free → SIGSEGV crash - Implemented `pthread_atfork` handler that automatically resets C++ global state in child processes after every fork: - Added comprehensive null-check wrappers around all native functions to prevent crashes when native state is - Fixed test regression issues where context slots weren't being freed: **[AddressSanitizer (ASAN)](https://github.com/google/sanitizers/wiki/AddressSanitizer)** is a fast memory error detector that catches use-after-free, buffer overflows, and other memory corruption bugs at runtime. **1. Runtime Environment (No Recompilation Required)** The simplest way to test is using LD_PRELOAD with the system's libasan: ```bash ASAN_LIB=$(gcc -print-file-name=libasan.so) LD_PRELOAD=$ASAN_LIB \ ASAN_OPTIONS="detect_leaks=0:symbolize=1:abort_on_error=0" \ python3 -m pytest tests/appsec/iast/test_fork_handler_regression.py -v ``` **ASAN_OPTIONS explained:** - `detect_leaks=0` - Disable leak detection (Python has many false positives) - `symbolize=1` - Show human-readable stack traces - `abort_on_error=0` - Continue after first error (collect all errors) **2. Build with ASAN (Optional, for deeper analysis)** For more thorough testing, compile the native extension with ASAN: ```bash export CFLAGS="-fsanitize=address -fno-omit-frame-pointer -g" export CXXFLAGS="-fsanitize=address -fno-omit-frame-pointer -g" export LDFLAGS="-fsanitize=address" pip install --no-build-isolation --force-reinstall -e . ASAN_OPTIONS="detect_leaks=0:symbolize=1:abort_on_error=0" \ python3 -m pytest tests/appsec/iast/test_fork_handler_regression.py -v ``` This script demonstrates the fork safety fix and can be used to verify ASAN finds no errors: ```python """Minimal fork safety reproduction test for ASAN verification.""" import os from ddtrace.appsec._iast._taint_tracking import OriginType from ddtrace.appsec._iast._taint_tracking._native import initialize_native_state from ddtrace.appsec._iast._taint_tracking._taint_objects import taint_pyobject from ddtrace.appsec._iast._taint_tracking._context import ( start_request_context, debug_context_array_free_slots_number, debug_num_tainted_objects ) def main(): print(f"[Parent PID {os.getpid()}] Initializing IAST...") # Initialize native state initialize_native_state() # Create context and tainted objects in parent ctx_id = start_request_context() print(f"[Parent] Context created: {ctx_id}") # Create some tainted objects (populates native maps) for i in range(10): taint_pyobject(f"data_{i}", "source", f"value_{i}", OriginType.PARAMETER) tainted_count = debug_num_tainted_objects(ctx_id) print(f"[Parent] Tainted objects: {tainted_count}") # Fork pid = os.fork() if pid == 0: # Child process print(f"[Child PID {os.getpid()}] Started after fork") # Verify pthread_atfork reset worked free_slots = debug_context_array_free_slots_number() print(f"[Child] Free slots: {free_slots}") if free_slots > 0: print("[Child] ✅ Context slots were reset (pthread_atfork worked!)") os._exit(0) # Success else: print("[Child] ❌ Context slots NOT reset") os._exit(1) # Failure else: # Parent waits for child _, status = os.waitpid(pid, 0) exit_code = os.WEXITSTATUS(status) if exit_code == 0: print(f"[Parent] ✅ Child exited cleanly - Fork safety verified!") return 0 else: print(f"[Parent] ❌ Child failed with exit code {exit_code}") return 1 if __name__ == "__main__": exit(main()) ``` **Run with ASAN:** ```bash LD_PRELOAD=$(gcc -print-file-name=libasan.so) \ ASAN_OPTIONS="detect_leaks=0:symbolize=1:abort_on_error=0" \ python3 test_fork_asan.py ``` **Expected output (success):** ``` [Parent PID 12345] Initializing IAST... [Parent] Context created: 0 [Parent] Tainted objects: 10 [Child PID 12346] Started after fork [Child] Free slots: 2 [Child] ✅ Context slots were reset (pthread_atfork worked!) [Parent] ✅ Child exited cleanly - Fork safety verified! ``` **What ASAN would report WITHOUT this fix:** ``` ==12346==ERROR: AddressSanitizer: SEGV on unknown address 0x000000000040 ==12346==The signal is caused by a READ memory access. #0 0x7f... in get_tainted_object_map_by_ctx_id #1 0x7f... in debug_context_array_free_slots_number ``` (cherry picked from commit d1b4fd8)
1 parent c9959f6 commit e304f82

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

58 files changed

+1478
-245
lines changed

ddtrace/appsec/_iast/__init__.py

Lines changed: 47 additions & 29 deletions
Original file line numberDiff line numberDiff line change
@@ -51,33 +51,37 @@ def wrapped_function(wrapped, instance, args, kwargs):
5151

5252
def _disable_iast_after_fork():
5353
"""
54-
Conditionally disable IAST in forked child processes to prevent segmentation faults.
54+
Handle IAST state after fork to prevent segmentation faults.
5555
56-
This fork handler differentiates between two types of forks:
56+
This fork handler works in conjunction with the C++ pthread_atfork handler
57+
(registered in native.cpp) to provide complete fork-safety:
5758
58-
1. **Early forks (web framework workers)**: Gunicorn, uvicorn, Django, Flask workers
59-
fork BEFORE IAST initializes any state. These are safe - IAST remains enabled.
59+
**C++ pthread_atfork handler (automatic, runs first):**
60+
- Clears all taint maps (removes stale PyObject pointers from parent)
61+
- Resets taint_engine_context (recreates context array with fresh state)
62+
- Resets initializer (recreates memory pools)
63+
- Happens automatically for ALL forks before this Python handler runs
6064
61-
2. **Late forks (multiprocessing)**: multiprocessing.Process forks AFTER IAST has
62-
initialized state. These inherit corrupted native extension state and must have
63-
IAST disabled to prevent segmentation faults.
65+
**Python fork handler (this function, runs second):**
66+
- Detects fork type (early vs late)
67+
- Manages Python-level IAST_CONTEXT
68+
- Conditionally disables IAST for late forks (multiprocessing)
6469
65-
Detection logic:
66-
- If IAST has active request contexts when fork occurs → Late fork → Disable IAST
67-
- If IAST has no active state → Early fork (worker) → Keep IAST enabled
70+
Fork types:
71+
1. **Early forks (web framework workers)**: Gunicorn, uvicorn, etc. fork BEFORE
72+
IAST has active request contexts. Native state was reset by pthread_atfork.
73+
IAST remains enabled and works correctly in workers.
6874
69-
This is critical for multiprocessing compatibility while maintaining IAST coverage
70-
in web framework workers. The native extension state (taint maps, context slots,
71-
object pools, shared_ptr references) cannot be safely used across fork boundaries
72-
when it exists, but is safe to initialize fresh in clean workers.
75+
2. **Late forks (multiprocessing)**: fork AFTER IAST has active contexts.
76+
Native state was reset by pthread_atfork, but we disable IAST in these
77+
processes for multiprocessing compatibility and to avoid confusion.
7378
74-
For late forks, the child process:
75-
- Clears all C++ taint maps and context slots
76-
- Resets the Python-level IAST_CONTEXT
77-
- Disables IAST by setting asm_config._iast_enabled = False
79+
Detection logic:
80+
- If is_iast_request_enabled() → Late fork → Disable IAST
81+
- If not is_iast_request_enabled() → Early fork → Keep IAST enabled
7882
79-
This prevents segmentation faults in multiprocessing while allowing IAST to work
80-
in web framework workers.
83+
This prevents segmentation faults in ALL fork scenarios while maintaining
84+
IAST coverage in web framework workers.
8185
"""
8286
if not asm_config._iast_enabled:
8387
return
@@ -86,33 +90,45 @@ def _disable_iast_after_fork():
8690
# Import locally to avoid issues if the module hasn't been loaded yet
8791
from ddtrace.appsec._iast._iast_request_context_base import IAST_CONTEXT
8892
from ddtrace.appsec._iast._iast_request_context_base import is_iast_request_enabled
89-
from ddtrace.appsec._iast._taint_tracking._context import clear_all_request_context_slots
93+
94+
# Note: The C++ pthread_atfork handler (in native.cpp) automatically resets
95+
# native state in actual fork scenarios. It clears the inherited state and
96+
# creates fresh instances without touching the invalid PyObject pointers.
97+
# We don't need to (and shouldn't) call clear_all_request_context_slots()
98+
# here because the C++ handler has already done the necessary cleanup.
9099

91100
# In pytest mode, always disable IAST in child processes to avoid segfaults
92101
# when tests create multiprocesses (e.g., for testing fork behavior)
93102
if _iast_in_pytest_mode:
94103
log.debug("IAST fork handler: Pytest mode detected, disabling IAST in child process")
95-
clear_all_request_context_slots()
96104
IAST_CONTEXT.set(None)
97105
asm_config._iast_enabled = False
98106
return
99107

100108
if not is_iast_request_enabled():
101109
# No active context - this is an early fork (web framework worker)
102-
# IAST can be safely initialized fresh in this child process
103-
log.debug("IAST fork handler: No active context, keeping IAST enabled (web worker fork)")
110+
# The C++ pthread_atfork handler has already reset native state with fresh instances.
111+
# IAST can continue working correctly in this child process.
112+
log.debug(
113+
"IAST fork handler: No active context (early fork/web worker). "
114+
"Native state auto-reset by pthread_atfork. IAST remains enabled."
115+
)
116+
# Clear Python-side context just in case
117+
IAST_CONTEXT.set(None)
104118
return
105119

106120
# Active context exists - this is a late fork (multiprocessing)
107-
# Native state is corrupted, must disable IAST
108-
log.debug("IAST fork handler: Active context detected, disabling IAST (multiprocessing fork)")
121+
# The C++ pthread_atfork handler has already reset native state.
122+
# We disable IAST in these processes for consistency.
123+
log.debug(
124+
"IAST fork handler: Active context (late fork/multiprocessing). "
125+
"Native state auto-reset by pthread_atfork. Disabling IAST in child."
126+
)
109127

110-
# Clear C++ side: all taint maps and context slots
111-
clear_all_request_context_slots()
112128
# Clear Python side: reset the context ID
113129
IAST_CONTEXT.set(None)
114130

115-
# Disable IAST to prevent segmentation faults
131+
# Disable IAST for multiprocessing compatibility
116132
asm_config._iast_enabled = False
117133

118134
except Exception as e:
@@ -182,6 +198,7 @@ def enable_iast_propagation():
182198
if asm_config._iast_propagation_enabled:
183199
from ddtrace.appsec._iast._ast.ast_patching import _should_iast_patch
184200
from ddtrace.appsec._iast._loader import _exec_iast_patched_module
201+
from ddtrace.appsec._iast._taint_tracking import initialize_native_state
185202

186203
global _iast_propagation_enabled
187204
if _iast_propagation_enabled:
@@ -190,6 +207,7 @@ def enable_iast_propagation():
190207
log.debug("iast::instrumentation::starting IAST")
191208
ModuleWatchdog.register_pre_exec_module_hook(_should_iast_patch, _exec_iast_patched_module)
192209
_iast_propagation_enabled = True
210+
initialize_native_state()
193211
_register_fork_handler()
194212

195213

ddtrace/appsec/_iast/_taint_tracking/CMakeLists.txt

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -59,6 +59,7 @@ file(
5959
GLOB
6060
SOURCE_FILES
6161
"*.cpp"
62+
"api/*.cpp"
6263
"context/*.cpp"
6364
"aspects/*.cpp"
6465
"initializer/*.cpp"
@@ -69,6 +70,7 @@ file(
6970
GLOB
7071
HEADER_FILES
7172
"*.h"
73+
"api/*.h"
7274
"context/*.h"
7375
"aspects/*.h"
7476
"initializer/*.h"

ddtrace/appsec/_iast/_taint_tracking/__init__.py

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
1+
from ddtrace.appsec._iast._taint_tracking._native import initialize_native_state # noqa: F401
12
from ddtrace.appsec._iast._taint_tracking._native import ops # noqa: F401
3+
from ddtrace.appsec._iast._taint_tracking._native import reset_native_state # noqa: F401
24
from ddtrace.appsec._iast._taint_tracking._native.aspect_format import _format_aspect # noqa: F401
35
from ddtrace.appsec._iast._taint_tracking._native.aspect_helpers import _convert_escaped_text_to_tainted_text
46

@@ -65,7 +67,8 @@
6567
"copy_ranges_from_strings",
6668
"get_range_by_hash",
6769
"get_ranges",
68-
"is_in_taint_map",
70+
"reset_native_state",
71+
"initialize_native_state",
6972
"is_tainted",
7073
"new_pyobject_id",
7174
"origin_to_str",
Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,35 @@
1+
#include "api/safe_context.h"
2+
3+
// ============================================================================
4+
// Safe wrapper functions for global pointers
5+
// ============================================================================
6+
// These functions centralize null checks for taint_engine_context and
7+
// initializer to prevent segmentation faults when called before
8+
// initialize_native_state(). All aspect functions should use these wrappers
9+
// instead of accessing the globals directly.
10+
TaintedObjectMapTypePtr
11+
safe_get_tainted_object_map(PyObject* tainted_object)
12+
{
13+
if (!taint_engine_context) {
14+
return nullptr;
15+
}
16+
return taint_engine_context->get_tainted_object_map(tainted_object);
17+
}
18+
19+
TaintedObjectMapTypePtr
20+
safe_get_tainted_object_map_from_list_of_pyobjects(const std::vector<PyObject*>& objects)
21+
{
22+
if (!taint_engine_context) {
23+
return nullptr;
24+
}
25+
return taint_engine_context->get_tainted_object_map_from_list_of_pyobjects(objects);
26+
}
27+
28+
TaintedObjectMapTypePtr
29+
safe_get_tainted_object_map_by_ctx_id(size_t ctx_id)
30+
{
31+
if (!taint_engine_context) {
32+
return nullptr;
33+
}
34+
return taint_engine_context->get_tainted_object_map_by_ctx_id(ctx_id);
35+
}
Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
#pragma once
2+
#include "context/taint_engine_context.h"
3+
4+
// Safe wrapper functions that check for null before accessing global pointers
5+
// These functions centralize null checks to prevent segmentation faults when
6+
// called before initialize_native_state()
7+
8+
/**
9+
* @brief Safely get tainted object map for a PyObject.
10+
* @param tainted_object The Python object to look up
11+
* @return TaintedObjectMapTypePtr or nullptr if not initialized or not found
12+
*/
13+
TaintedObjectMapTypePtr
14+
safe_get_tainted_object_map(PyObject* tainted_object);
15+
16+
/**
17+
* @brief Safely get tainted object map by context ID.
18+
* @param ctx_id The context ID to look up
19+
* @return TaintedObjectMapTypePtr or nullptr if not initialized or not found
20+
*/
21+
TaintedObjectMapTypePtr
22+
safe_get_tainted_object_map_by_ctx_id(size_t ctx_id);
23+
24+
/**
25+
* @brief Safely get tainted object map from a list of PyObjects.
26+
* @param objects Vector of PyObject pointers to search
27+
* @return TaintedObjectMapTypePtr or nullptr if not initialized or not found
28+
*/
29+
TaintedObjectMapTypePtr
30+
safe_get_tainted_object_map_from_list_of_pyobjects(const std::vector<PyObject*>& objects);
Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
#include "api/safe_initializer.h"
2+
3+
TaintRangePtr
4+
safe_allocate_taint_range(RANGE_START start, RANGE_LENGTH length, const Source& source, const SecureMarks secure_marks)
5+
{
6+
if (!initializer) {
7+
return nullptr;
8+
}
9+
return initializer->allocate_taint_range(start, length, source, secure_marks);
10+
}
11+
12+
TaintedObjectPtr
13+
safe_allocate_tainted_object_copy(const TaintedObjectPtr& from)
14+
{
15+
if (!initializer) {
16+
return nullptr;
17+
}
18+
return initializer->allocate_tainted_object_copy(from);
19+
}
20+
21+
TaintedObjectPtr
22+
safe_allocate_tainted_object()
23+
{
24+
if (!initializer) {
25+
return nullptr;
26+
}
27+
return initializer->allocate_tainted_object();
28+
}
29+
30+
TaintedObjectPtr
31+
safe_allocate_ranges_into_taint_object(TaintRangeRefs ranges)
32+
{
33+
if (!initializer) {
34+
return nullptr;
35+
}
36+
return initializer->allocate_ranges_into_taint_object(ranges);
37+
}
38+
39+
TaintedObjectPtr
40+
safe_allocate_ranges_into_taint_object_copy(const TaintRangeRefs& ranges)
41+
{
42+
if (!initializer) {
43+
return nullptr;
44+
}
45+
return initializer->allocate_ranges_into_taint_object_copy(ranges);
46+
}
Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,49 @@
1+
#pragma once
2+
3+
#include "initializer/initializer.h"
4+
#include <Python.h>
5+
6+
/**
7+
* @brief Safely allocate a taint range.
8+
* @param start The start position of the taint range
9+
* @param length The length of the taint range
10+
* @param source The source of the taint
11+
* @param secure_marks Optional secure marks
12+
* @return TaintRangePtr or nullptr if not initialized
13+
*/
14+
TaintRangePtr
15+
safe_allocate_taint_range(RANGE_START start,
16+
RANGE_LENGTH length,
17+
const Source& source,
18+
const SecureMarks secure_marks = 0);
19+
20+
/**
21+
* @brief Safely allocate a copy of a tainted object.
22+
* @param from The tainted object to copy
23+
* @return TaintedObjectPtr or nullptr if not initialized
24+
*/
25+
TaintedObjectPtr
26+
safe_allocate_tainted_object_copy(const TaintedObjectPtr& from);
27+
28+
/**
29+
* @brief Safely allocate a tainted object.
30+
* @return TaintedObjectPtr or nullptr if not initialized
31+
*/
32+
TaintedObjectPtr
33+
safe_allocate_tainted_object();
34+
35+
/**
36+
* @brief Safely allocate ranges into a tainted object.
37+
* @param ranges The ranges to allocate
38+
* @return TaintedObjectPtr or nullptr if not initialized
39+
*/
40+
TaintedObjectPtr
41+
safe_allocate_ranges_into_taint_object(TaintRangeRefs ranges);
42+
43+
/**
44+
* @brief Safely allocate a copy of ranges into a tainted object.
45+
* @param ranges The ranges to copy and allocate
46+
* @return TaintedObjectPtr or nullptr if not initialized
47+
*/
48+
TaintedObjectPtr
49+
safe_allocate_ranges_into_taint_object_copy(const TaintRangeRefs& ranges);
Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
#define CHECK_IAST_INITIALIZED_OR_RETURN(fallback_result) \
2+
if (!taint_engine_context || !initializer) { \
3+
return fallback_result; \
4+
}

ddtrace/appsec/_iast/_taint_tracking/aspects/aspect_extend.cpp

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,7 +29,7 @@ api_extend_aspect(PyObject* self, PyObject* const* args, const Py_ssize_t nargs)
2929
return nullptr;
3030
}
3131

32-
auto ctx_map = taint_engine_context->get_tainted_object_map_from_list_of_pyobjects({ candidate_text, to_add });
32+
auto ctx_map = safe_get_tainted_object_map_from_list_of_pyobjects({ candidate_text, to_add });
3333
if (not ctx_map or ctx_map->empty()) {
3434
auto method_name = PyUnicode_FromString("extend");
3535
PyObject_CallMethodObjArgs(candidate_text, method_name, to_add, nullptr);
@@ -40,7 +40,7 @@ api_extend_aspect(PyObject* self, PyObject* const* args, const Py_ssize_t nargs)
4040
Py_DecRef(method_name);
4141
} else {
4242
const auto& to_candidate = get_tainted_object(candidate_text, ctx_map);
43-
auto to_result = initializer->allocate_tainted_object_copy(to_candidate);
43+
auto to_result = safe_allocate_tainted_object_copy(to_candidate);
4444
const auto& to_toadd = get_tainted_object(to_add, ctx_map);
4545

4646
// Ensure no returns are done before this method call
Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,7 @@
11
#pragma once
22

3-
#include "initializer/initializer.h"
4-
#include <Python.h>
3+
#include "api/safe_context.h"
4+
#include "api/safe_initializer.h"
55

66
PyObject*
77
api_extend_aspect(PyObject* self, PyObject* const* args, Py_ssize_t nargs);

0 commit comments

Comments
 (0)