From c2da5c8b7324f894f89df79775ea9d409b95f477 Mon Sep 17 00:00:00 2001 From: Levi Morrison Date: Wed, 4 Feb 2026 14:29:10 -0700 Subject: [PATCH 01/12] tests: add ASAN repro for eval parse error in internal fake closure hook Adds a PHPT that forces an eval() parse error inside an end-hook on an internal fake closure. Under ASAN this triggers a crash in ddtrace backtrace collection. --- ..._fake_closure_forced_parse_error_asan.phpt | 35 +++++++++++++++++++ 1 file changed, 35 insertions(+) create mode 100644 tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt diff --git a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt new file mode 100644 index 00000000000..44b4a4b0c9e --- /dev/null +++ b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt @@ -0,0 +1,35 @@ +--TEST-- +ASAN repro: internal fake closure + forced eval parse error +--SKIPIF-- + +--INI-- +datadog.trace.generate_root_span=0 +datadog.trace.auto_flush_enabled=0 +--ENV-- +DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 +--FILE-- +getClosure(); + +\DDTrace\install_hook( + $closure, + function () {}, + function () { + // Intentionally invalid PHP code to force a ParseError from eval(). + eval('class Broken {'); + }, + \DDTrace\HOOK_INSTANCE +); + +$callable = $closure; +$callable(1); + +echo "ok\n"; +?> +--EXPECT-- +ok + From 1c9ed56781cf1cb2ab8fd7acd8e82820b1d13ee0 Mon Sep 17 00:00:00 2001 From: Levi Morrison Date: Wed, 4 Feb 2026 14:39:36 -0700 Subject: [PATCH 02/12] tests: extend ASAN repro with hook removal + re-entry Updates the internal fake closure ASAN reproducer to use two hooks and re-enter after removing one hook, while forcing eval() into the error path. --- ..._fake_closure_forced_parse_error_asan.phpt | 60 ++++++++++++++----- 1 file changed, 46 insertions(+), 14 deletions(-) diff --git a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt index 44b4a4b0c9e..bb94b7c9139 100644 --- a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt +++ b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt @@ -13,20 +13,52 @@ datadog.trace.auto_flush_enabled=0 DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 --FILE-- getClosure(); - -\DDTrace\install_hook( - $closure, - function () {}, - function () { - // Intentionally invalid PHP code to force a ParseError from eval(). - eval('class Broken {'); - }, - \DDTrace\HOOK_INSTANCE -); - -$callable = $closure; -$callable(1); +$iterations = 2000; +$callsPerIter = 5; + +for ($i = 0; $i < $iterations; $i++) { + $closureA = (new ReflectionFunction("intval"))->getClosure(); + $closureB = (new ReflectionFunction("intval"))->getClosure(); + + $hookIdA = null; + $hookIdB = null; + + $hookIdB = \DDTrace\install_hook( + $closureB, + function () {}, + function () {}, + \DDTrace\HOOK_INSTANCE + ); + + $hookIdA = \DDTrace\install_hook( + $closureA, + function () {}, + function () use ($i, $callsPerIter, &$hookIdA, &$hookIdB, $closureB) { + if ($hookIdB !== null) { + \DDTrace\remove_hook($hookIdB); + $hookIdB = null; + } + + // Force eval() error path (deterministic ASAN crash site). + eval('class Broken {'); + + // Re-enter via internal fake closure after removal. + $callB = $closureB; + for ($j = 0; $j < $callsPerIter; $j++) { + $callB($i + $j); + } + + if ($hookIdA !== null) { + \DDTrace\remove_hook($hookIdA); + $hookIdA = null; + } + }, + \DDTrace\HOOK_INSTANCE + ); + + $callA = $closureA; + $callA($i); +} echo "ok\n"; ?> From 2de9237ed3019ff9e366e355985710938156b347 Mon Sep 17 00:00:00 2001 From: Levi Morrison Date: Wed, 4 Feb 2026 14:41:17 -0700 Subject: [PATCH 03/12] tests: reduce ASAN repro loop counts Reduces iterations and re-entry calls to the smallest values while preserving the ASAN crash. --- .../internal_fake_closure_forced_parse_error_asan.phpt | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt index bb94b7c9139..62064fbb55e 100644 --- a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt +++ b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt @@ -13,8 +13,8 @@ datadog.trace.auto_flush_enabled=0 DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 --FILE-- getClosure(); From 76ed5a7994519da46711c2ab6ce83dbbb9f63ab3 Mon Sep 17 00:00:00 2001 From: Levi Morrison Date: Wed, 4 Feb 2026 14:42:54 -0700 Subject: [PATCH 04/12] tests: drop secondary hook from ASAN repro Removes the extra hook installation/removal while preserving the ASAN crash. --- ...rnal_fake_closure_forced_parse_error_asan.phpt | 15 +-------------- 1 file changed, 1 insertion(+), 14 deletions(-) diff --git a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt index 62064fbb55e..cb382b089f1 100644 --- a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt +++ b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt @@ -21,24 +21,11 @@ for ($i = 0; $i < $iterations; $i++) { $closureB = (new ReflectionFunction("intval"))->getClosure(); $hookIdA = null; - $hookIdB = null; - - $hookIdB = \DDTrace\install_hook( - $closureB, - function () {}, - function () {}, - \DDTrace\HOOK_INSTANCE - ); $hookIdA = \DDTrace\install_hook( $closureA, function () {}, - function () use ($i, $callsPerIter, &$hookIdA, &$hookIdB, $closureB) { - if ($hookIdB !== null) { - \DDTrace\remove_hook($hookIdB); - $hookIdB = null; - } - + function () use ($i, $callsPerIter, &$hookIdA, $closureB) { // Force eval() error path (deterministic ASAN crash site). eval('class Broken {'); From 90d3afa4a1f1be75fc9fe967e0955a3db6de0825 Mon Sep 17 00:00:00 2001 From: Levi Morrison Date: Wed, 4 Feb 2026 14:45:03 -0700 Subject: [PATCH 05/12] tests: reduce ASAN repro to single hook Drops the secondary closure and re-entry call while preserving the ASAN crash in ddtrace backtrace collection. --- .../internal_fake_closure_forced_parse_error_asan.phpt | 10 +--------- 1 file changed, 1 insertion(+), 9 deletions(-) diff --git a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt index cb382b089f1..e5f9d917a87 100644 --- a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt +++ b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt @@ -18,23 +18,15 @@ $callsPerIter = 1; for ($i = 0; $i < $iterations; $i++) { $closureA = (new ReflectionFunction("intval"))->getClosure(); - $closureB = (new ReflectionFunction("intval"))->getClosure(); - $hookIdA = null; $hookIdA = \DDTrace\install_hook( $closureA, function () {}, - function () use ($i, $callsPerIter, &$hookIdA, $closureB) { + function () use (&$hookIdA) { // Force eval() error path (deterministic ASAN crash site). eval('class Broken {'); - // Re-enter via internal fake closure after removal. - $callB = $closureB; - for ($j = 0; $j < $callsPerIter; $j++) { - $callB($i + $j); - } - if ($hookIdA !== null) { \DDTrace\remove_hook($hookIdA); $hookIdA = null; From 1bee40189b946ebcb29608d30224f80f28f86c9d Mon Sep 17 00:00:00 2001 From: Levi Morrison Date: Wed, 4 Feb 2026 14:45:38 -0700 Subject: [PATCH 06/12] tests: reduce ASAN repro to single internal fake closure Simplifies the reproducer to a single internal fake closure with one hook, while forcing eval() into the error path and still crashing under ASAN. --- ..._fake_closure_forced_parse_error_asan.phpt | 39 ++++++++----------- 1 file changed, 17 insertions(+), 22 deletions(-) diff --git a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt index e5f9d917a87..22fa83036d6 100644 --- a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt +++ b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt @@ -13,31 +13,26 @@ datadog.trace.auto_flush_enabled=0 DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 --FILE-- getClosure(); +$hookId = null; -for ($i = 0; $i < $iterations; $i++) { - $closureA = (new ReflectionFunction("intval"))->getClosure(); - $hookIdA = null; +$hookId = \DDTrace\install_hook( + $closure, + function () {}, + function () use (&$hookId) { + // Force eval() error path (deterministic ASAN crash site). + eval('class Broken {'); - $hookIdA = \DDTrace\install_hook( - $closureA, - function () {}, - function () use (&$hookIdA) { - // Force eval() error path (deterministic ASAN crash site). - eval('class Broken {'); + if ($hookId !== null) { + \DDTrace\remove_hook($hookId); + $hookId = null; + } + }, + \DDTrace\HOOK_INSTANCE +); - if ($hookIdA !== null) { - \DDTrace\remove_hook($hookIdA); - $hookIdA = null; - } - }, - \DDTrace\HOOK_INSTANCE - ); - - $callA = $closureA; - $callA($i); -} +$callable = $closure; +$callable(1); echo "ok\n"; ?> From 645614118e904684c61126ed262f4c51574d3e4c Mon Sep 17 00:00:00 2001 From: Levi Morrison Date: Wed, 4 Feb 2026 14:46:44 -0700 Subject: [PATCH 07/12] tests: stop removing hook during ASAN repro The crash occurs during eval() error handling, so hook removal is unnecessary; keep the hook installed. --- .../internal_fake_closure_forced_parse_error_asan.phpt | 5 +---- 1 file changed, 1 insertion(+), 4 deletions(-) diff --git a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt index 22fa83036d6..1e4375c3205 100644 --- a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt +++ b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt @@ -23,10 +23,7 @@ $hookId = \DDTrace\install_hook( // Force eval() error path (deterministic ASAN crash site). eval('class Broken {'); - if ($hookId !== null) { - \DDTrace\remove_hook($hookId); - $hookId = null; - } + // Keep the hook installed; crash occurs during backtrace collection. }, \DDTrace\HOOK_INSTANCE ); From 0d8d8887dd68ba9030dd6e93dc070cf45e0d033f Mon Sep 17 00:00:00 2001 From: Levi Morrison Date: Wed, 4 Feb 2026 14:47:26 -0700 Subject: [PATCH 08/12] tests: drop begin hook and hook id from ASAN repro Passes no begin hook and removes unused hook-id plumbing, while still reproducing the ASAN SEGV in ddtrace backtrace collection. --- .../internal_fake_closure_forced_parse_error_asan.phpt | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt index 1e4375c3205..b50501dd6c0 100644 --- a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt +++ b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt @@ -14,12 +14,11 @@ DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 --FILE-- getClosure(); -$hookId = null; -$hookId = \DDTrace\install_hook( +\DDTrace\install_hook( $closure, - function () {}, - function () use (&$hookId) { + null, + function () { // Force eval() error path (deterministic ASAN crash site). eval('class Broken {'); From a775586ccb136e0c6fe5004139edce3c998b0776 Mon Sep 17 00:00:00 2001 From: Levi Morrison Date: Wed, 4 Feb 2026 14:48:47 -0700 Subject: [PATCH 09/12] tests: call internal fake closure directly in ASAN repro Removes the unnecessary callable indirection; the reduced test still deterministically triggers the ASAN SEGV in ddtrace backtrace collection. --- .../internal_fake_closure_forced_parse_error_asan.phpt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt index b50501dd6c0..3b649ee8019 100644 --- a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt +++ b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt @@ -27,8 +27,7 @@ $closure = (new ReflectionFunction("intval"))->getClosure(); \DDTrace\HOOK_INSTANCE ); -$callable = $closure; -$callable(1); +$closure(1); echo "ok\n"; ?> From d91929edccbd359ec150c05aaa47e3aebf9c3fb5 Mon Sep 17 00:00:00 2001 From: Levi Morrison Date: Wed, 4 Feb 2026 14:49:35 -0700 Subject: [PATCH 10/12] tests: drop telemetry env from ASAN repro Removes the non-essential DD_INSTRUMENTATION_TELEMETRY_ENABLED environment section; the test still deterministically crashes under ASAN. --- .../internal_fake_closure_forced_parse_error_asan.phpt | 2 -- 1 file changed, 2 deletions(-) diff --git a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt index 3b649ee8019..a60823f4f33 100644 --- a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt +++ b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt @@ -9,8 +9,6 @@ if (PHP_VERSION_ID < 80000) { --INI-- datadog.trace.generate_root_span=0 datadog.trace.auto_flush_enabled=0 ---ENV-- -DD_INSTRUMENTATION_TELEMETRY_ENABLED=0 --FILE-- getClosure(); From 7c51afbe4ca57cbfb7ff3958df9ccdd4e514bf5f Mon Sep 17 00:00:00 2001 From: Levi Morrison Date: Wed, 4 Feb 2026 14:55:59 -0700 Subject: [PATCH 11/12] tests: make ASAN repro use valid eval that throws Replace the eval parse-error payload with valid PHP that throws an exception, and catch the exception at the call site. The ASAN SEGV in ddtrace backtrace collection still reproduces deterministically. --- ...internal_fake_closure_forced_parse_error_asan.phpt | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt index a60823f4f33..120d3ba83d1 100644 --- a/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt +++ b/tests/ext/sandbox/install_hook/internal_fake_closure_forced_parse_error_asan.phpt @@ -17,15 +17,16 @@ $closure = (new ReflectionFunction("intval"))->getClosure(); $closure, null, function () { - // Force eval() error path (deterministic ASAN crash site). - eval('class Broken {'); - - // Keep the hook installed; crash occurs during backtrace collection. + eval('throw new \\Exception("boom");'); }, \DDTrace\HOOK_INSTANCE ); -$closure(1); +try { + $closure(1); +} catch (Throwable $e) { + // ignore +} echo "ok\n"; ?> From ee9e13526b54a0008c85ce1ae6acfc18f5e94b14 Mon Sep 17 00:00:00 2001 From: Bob Weinand Date: Tue, 3 Mar 2026 19:46:15 +0100 Subject: [PATCH 12/12] Cache scope for end hook as it may be invalidated before Signed-off-by: Bob Weinand --- ext/hook/uhook.c | 10 ++++++---- ext/hook/uhook.h | 6 ++---- ext/hook/uhook_legacy.c | 9 +++++---- 3 files changed, 13 insertions(+), 12 deletions(-) diff --git a/ext/hook/uhook.c b/ext/hook/uhook.c index a9e6151aeae..e4db2721f7f 100644 --- a/ext/hook/uhook.c +++ b/ext/hook/uhook.c @@ -74,6 +74,7 @@ typedef struct { typedef struct { dd_hook_data *hook_data; + zend_class_entry *called_scope; } dd_uhook_dynamic; #if PHP_VERSION_ID < 70400 @@ -234,14 +235,14 @@ void dd_uhook_report_sandbox_error(zend_execute_data *execute_data, zend_object }) } -static bool dd_uhook_call_hook(zend_execute_data *execute_data, dd_uhook_callback *callback, dd_hook_data *hook_data) { +static bool dd_uhook_call_hook(zend_execute_data *execute_data, dd_uhook_callback *callback, dd_hook_data *hook_data, zend_class_entry *scope) { zval hook_data_zv; ZVAL_OBJ(&hook_data_zv, &hook_data->std); zval rv; zai_sandbox sandbox; zai_sandbox_open(&sandbox); - dd_uhook_callback_ensure_scope(callback, execute_data); + dd_uhook_callback_ensure_scope(callback, execute_data, scope); zend_fcall_info fci = dd_fcall_info(1, &hook_data_zv, &rv); bool success = zai_sandbox_call(&sandbox, &fci, &callback->fcc); if (!success || PG(last_error_message)) { @@ -321,6 +322,7 @@ static bool dd_uhook_begin(zend_ulong invocation, zend_execute_data *execute_dat return true; } + dyn->called_scope = zend_get_called_scope(execute_data); dyn->hook_data = (dd_hook_data *)dd_hook_data_create(ddtrace_hook_data_ce); dyn->hook_data->returns_reference = execute_data->func->common.fn_flags & ZEND_ACC_RETURN_REFERENCE; dyn->hook_data->vm_stack_top = EG(vm_stack_top); @@ -356,7 +358,7 @@ static bool dd_uhook_begin(zend_ulong invocation, zend_execute_data *execute_dat LOGEV(HOOK_TRACE, dd_uhook_log_invocation(log, execute_data, "begin", def->begin.closure);); def->running = true; - dd_uhook_call_hook(execute_data, &def->begin, dyn->hook_data); + dd_uhook_call_hook(execute_data, &def->begin, dyn->hook_data, dyn->called_scope); def->running = false; dyn->hook_data->retval_ptr = NULL; } @@ -480,7 +482,7 @@ static void dd_uhook_end(zend_ulong invocation, zend_execute_data *execute_data, def->running = true; dyn->hook_data->retval_ptr = retval; dyn->hook_data->execute_data = execute_data; - keep_span = dd_uhook_call_hook(execute_data, &def->end, dyn->hook_data); + keep_span = dd_uhook_call_hook(execute_data, &def->end, dyn->hook_data, dyn->called_scope); dyn->hook_data->execute_data = NULL; dyn->hook_data->retval_ptr = NULL; def->running = false; diff --git a/ext/hook/uhook.h b/ext/hook/uhook.h index f459a8df87e..662d7816be5 100644 --- a/ext/hook/uhook.h +++ b/ext/hook/uhook.h @@ -22,14 +22,12 @@ void zai_uhook_minit(int module_number); void zai_uhook_mshutdown(); void dd_uhook_callback_apply_scope(dd_uhook_callback *cb, zend_class_entry *scope); -static inline void dd_uhook_callback_ensure_scope(dd_uhook_callback *cb, zend_execute_data *execute_data) { - zend_class_entry *scope; +// Note that we cannot access zend_get_called_scope(execute_data) here - we need to have it provided from earlier, it might have been invalidated by now, e.g. in ZEND_NAMED_FUNCTION(zend_closure_internal_handler). +static inline void dd_uhook_callback_ensure_scope(dd_uhook_callback *cb, zend_execute_data *execute_data, zend_class_entry *scope) { if (!cb->fcc.function_handler) { - scope = zend_get_called_scope(execute_data); goto apply_scope; } else if (!cb->is_static) { bool has_this; - scope = zend_get_called_scope(execute_data); if (scope != cb->fcc.called_scope) { apply_scope: dd_uhook_callback_apply_scope(cb, scope); diff --git a/ext/hook/uhook_legacy.c b/ext/hook/uhook_legacy.c index a59b3246cf0..074794ee9c4 100644 --- a/ext/hook/uhook_legacy.c +++ b/ext/hook/uhook_legacy.c @@ -24,6 +24,7 @@ typedef struct { typedef struct { zend_array *args; ddtrace_span_data *span; + zend_class_entry *called_scope; bool skipped; bool dropped_span; bool was_primed; @@ -35,7 +36,7 @@ static bool dd_uhook_call(dd_uhook_callback *callback, bool tracing, dd_uhook_dy #define ZVAL_EXCEPTION(zv) do { if (EG(exception)) ZVAL_OBJ(zv, EG(exception)); else ZVAL_NULL(zv); } while (0) if (tracing) { - dd_uhook_callback_ensure_scope(callback, execute_data); + dd_uhook_callback_ensure_scope(callback, execute_data, dyn->called_scope); ZVAL_OBJ(¶ms[0], &dyn->span->std); ZVAL_ARR(¶ms[1], dyn->args); @@ -62,9 +63,8 @@ static bool dd_uhook_call(dd_uhook_callback *callback, bool tracing, dd_uhook_dy ZVAL_COPY_VALUE(¶ms[0], This); callback->fcc.object = Z_OBJ_P(This); } - zend_class_entry *scope_ce = zend_get_called_scope(execute_data); - if (scope_ce) { - ZVAL_STR(¶ms[1], scope_ce->name); + if (dyn->called_scope) { + ZVAL_STR(¶ms[1], dyn->called_scope->name); } else { ZVAL_NULL(¶ms[1]); } @@ -108,6 +108,7 @@ static bool dd_uhook_begin(zend_ulong invocation, zend_execute_data *execute_dat dyn->skipped = false; dyn->was_primed = false; dyn->dropped_span = false; + dyn->called_scope = zend_get_called_scope(execute_data); dyn->args = dd_uhook_collect_args(execute_data); if (def->tracing) {