Skip to content

Commit 17a0855

Browse files
authored
fix(live-debugger): fix use-after-free, tags leak, and Windows 7.x crash on probe removal (#4036)
* fix(live-debugger): key spans_map by config_id to prevent probe hook use-after-free The live debugger tracks installed probe hooks in `spans_map`, but keyed it inconsistently: apply_config and remove_config used the probe's `id` field, while the DI-enable reinstall path used the `active` map key (the config_id). `active` is keyed by config_id, which is unique per remote-config path, whereas probe.id is not: two distinct configs can carry the same probe id. When they do (or when a probe is installed via different paths), the second install overwrites the probe.id-keyed spans_map entry, orphaning the first hook. Removing a config then tears down the wrong hook (or none) and frees the parsed-config box while an orphaned hook still borrows its strings. The orphan's first fire afterwards reads the freed probe id in ddog_debugger_diagnostics_create_unboxed -> use-after-free. Only valgrind observes it (plain runs read freed-but-intact bytes), which is why it surfaced intermittently as a LEAKED test in test_extension_ci. Key spans_map by config_id everywhere (apply_config, remove_config, and the already-correct reinstall path) so every config's hook is tracked and removed independently. Adds tests/ext/live-debugger/debugger_remove_shared_probe_id.phpt, which reproduces the UAF (two configs sharing a probe id) and is clean with the fix. Verified under valgrind on PHP 7.1 and 8.3: the reproducer leaks before the fix and passes after; the whole live-debugger suite is clean. * test(live-debugger): distinguish configs by an ignored field, not tags The regression test used a distinct `tags` entry per config only to give the two configs different remote-config paths. But a non-empty `tags` makes probe.into() allocate an FFI CharSliceVec that the C side never frees (a separate latent leak for tagged probes), which LeakSanitizer flags in the ASAN 'multiple observers' job (32 bytes / 2 objects) and breaks the test's expected output. Use an ignored unknown field instead: same two distinct config paths, same probe id, no per-config allocation -> no unrelated LSan noise. Verified under valgrind (clean) and the two configs still install at distinct paths with the shared id. * fix(live-debugger): free the probe tags CharSliceVec on uninstall probe.into() heap-allocates a CharSliceVec for the probe `tags`, but the C side (def->probe) only freed the nested span-decoration / log allocations in dd_probe_dtor -- never the tags vec -- so every probe carrying tags leaked it (LeakSanitizer: 32 bytes / 2 objects in the ASAN 'multiple observers' job). Add ddog_drop_probe, which consumes the FFI probe by value so its drop glue frees the tags CharSliceVec together with the nested span-decoration / log allocations, and call it from dd_probe_dtor in place of the piecemeal drops. Cleanup is now a single, consistent operation covering every FFI-owned field. Restore the shared-probe-id regression test to distinguish its two configs by `tags`, so it also exercises this path (a tags leak would resurface under LSan). Verified on PHP 8.3: the live-debugger suite is clean under valgrind (no UAF, no double-free), and valgrind --leak-check=full on a tagged probe shows 0 bytes definitely lost with no CharSliceVec leak record. * fix(live-debugger): remove stale hook on re-add; wait for both probes; trim comments Address Codex review: - apply_config: remove any hook already installed for a config id before installing the replacement, so an in-place Add (Occupied entry) can't orphan the previous hook into the dropped parsed config. - regression test: await both colliding probes (2) before removing, so it actually exercises the shared-probe-id collision. Also condense the comments added in this PR. * fix(live-debugger): fix PHP 7.x Windows crash and drop stale removal map entry The regression test exposed a PHP 7.x Windows-only crash (0xC0000005) in dd_remove_live_debugger_probe: MSVC 2017 if-converts the inline ternary that builds zai_hook_remove's scope/function args into an unconditional ZSTR_LEN load, a NULL deref for global functions (scope == NULL). Build the zai_str views with explicit if-guards, as the install path already does. PHP 8.x (MSVC 2019) and Linux were unaffected. Also drop the stale active_live_debugger_hooks entry on removal so a later lookup of the same id can't use-after-free the already-freed def.
1 parent a70313e commit 17a0855

4 files changed

Lines changed: 107 additions & 18 deletions

File tree

components-rs/datadog.h

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -189,6 +189,8 @@ void ddog_rshutdown_remote_config(struct ddog_RemoteConfigState *remote_config);
189189

190190
void ddog_shutdown_remote_config(struct ddog_RemoteConfigState*);
191191

192+
void ddog_drop_probe(struct ddog_Probe probe);
193+
192194
void ddog_log_debugger_data(const struct ddog_Vec_DebuggerPayload *payloads);
193195

194196
void ddog_log_debugger_datum(const struct ddog_DebuggerPayload *payload);

components-rs/remote_config.rs

Lines changed: 26 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,7 @@ pub extern "C" fn ddog_process_remote_configs(remote_config: &mut RemoteConfigSt
392392
RemoteConfigProduct::LiveDebugger => {
393393
let val = Box::new((data, MaybeShmLimiter::open(limiter_index)));
394394
let rc_ref: &mut RemoteConfigState = unsafe { mem::transmute(remote_config as *mut _) }; // sigh, borrow checker
395+
let config_id = value.config_id.clone();
395396
let entry = remote_config.live_debugger.active.entry(value.config_id);
396397
let (parsed, limiter) = match entry {
397398
Entry::Occupied(mut e) => {
@@ -405,7 +406,7 @@ pub extern "C" fn ddog_process_remote_configs(remote_config: &mut RemoteConfigSt
405406
}
406407
};
407408
if let Some(debugger) = parsed.downcast::<LiveDebuggingData>() {
408-
apply_config(rc_ref, debugger, limiter);
409+
apply_config(rc_ref, &config_id, debugger, limiter);
409410
}
410411
}
411412
RemoteConfigProduct::ApmTracing => {
@@ -440,7 +441,7 @@ pub extern "C" fn ddog_process_remote_configs(remote_config: &mut RemoteConfigSt
440441
RemoteConfigProduct::LiveDebugger => {
441442
if let Some(boxed) = remote_config.live_debugger.active.remove(&path.config_id) {
442443
if let Some(debugger) = boxed.0.downcast::<LiveDebuggingData>() {
443-
remove_config(remote_config, debugger);
444+
remove_config(remote_config, &path.config_id, debugger);
444445
}
445446
}
446447
}
@@ -472,6 +473,7 @@ pub extern "C" fn ddog_process_remote_configs(remote_config: &mut RemoteConfigSt
472473

473474
fn apply_config(
474475
remote_config: &mut RemoteConfigState,
476+
config_id: &str,
475477
debugger: &LiveDebuggingData,
476478
limiter: &MaybeShmLimiter,
477479
) {
@@ -480,12 +482,21 @@ fn apply_config(
480482
LiveDebuggingData::Probe(probe) => {
481483
debug!("Applying live debugger probe {probe:?}");
482484
if remote_config.live_debugger.di_enabled {
485+
// Tear down any hook already installed for this config before
486+
// replacing it, so it isn't left dangling into the dropped config.
487+
if let Some(old_hook_id) =
488+
remote_config.live_debugger.spans_map.remove(config_id)
489+
{
490+
(callbacks.remove_probe)(old_hook_id);
491+
}
483492
let hook_id = (callbacks.set_probe)(probe.into(), limiter);
484493
if hook_id >= 0 {
494+
// Key by config_id, not probe.id: distinct configs can share a
495+
// probe id, so a probe.id key would orphan a hook on removal (UAF).
485496
remote_config
486497
.live_debugger
487498
.spans_map
488-
.insert(probe.id.clone(), hook_id);
499+
.insert(config_id.to_string(), hook_id);
489500
}
490501
}
491502
// If di_enabled is false, probe is stored in `active` but hook is not installed.
@@ -522,12 +533,12 @@ fn apply_config(
522533
}
523534
}
524535

525-
fn remove_config(remote_config: &mut RemoteConfigState, debugger: &LiveDebuggingData) {
536+
fn remove_config(remote_config: &mut RemoteConfigState, config_id: &str, debugger: &LiveDebuggingData) {
526537
if let Some(callbacks) = unsafe { &LIVE_DEBUGGER_CALLBACKS } {
527538
match debugger {
528539
LiveDebuggingData::Probe(probe) => {
529-
if let Some(id) = remote_config.live_debugger.spans_map.remove(&probe.id) {
530-
debug!("Removing live debugger probe {}", probe.id);
540+
if let Some(id) = remote_config.live_debugger.spans_map.remove(config_id) {
541+
debug!("Removing live debugger probe {} (config {})", probe.id, config_id);
531542
(callbacks.remove_probe)(id);
532543
}
533544
}
@@ -719,15 +730,15 @@ pub extern "C" fn ddog_set_dynamic_instrumentation_enabled(
719730
(callbacks.remove_probe)(hook_id);
720731
}
721732
} else {
722-
// Reinstall all probes currently stored in `active`.
723-
for (probe_id, boxed) in remote_config.live_debugger.active.iter() {
733+
// Reinstall all probes in `active`, keyed by config_id (like apply/remove).
734+
for (config_id, boxed) in remote_config.live_debugger.active.iter() {
724735
if let Some(LiveDebuggingData::Probe(probe)) = boxed.0.downcast::<LiveDebuggingData>() {
725736
let hook_id = (callbacks.set_probe)(probe.into(), &boxed.1);
726737
if hook_id >= 0 {
727738
remote_config
728739
.live_debugger
729740
.spans_map
730-
.insert(probe_id.clone(), hook_id);
741+
.insert(config_id.clone(), hook_id);
731742
}
732743
}
733744
}
@@ -750,6 +761,12 @@ pub extern "C" fn ddog_rshutdown_remote_config(remote_config: &mut RemoteConfigS
750761
#[no_mangle]
751762
pub extern "C" fn ddog_shutdown_remote_config(_: Box<RemoteConfigState>) {}
752763

764+
/// Free the FFI-owned allocations in a `Probe` (the `tags` vec and the nested
765+
/// span-decoration / log allocations) by consuming it; borrowed `CharSlice`s are
766+
/// left untouched. Called from `dd_probe_dtor` when a probe is uninstalled.
767+
#[no_mangle]
768+
pub extern "C" fn ddog_drop_probe(_: Probe) {}
769+
753770
#[no_mangle]
754771
pub extern "C" fn ddog_log_debugger_data(payloads: &Vec<DebuggerPayload>) {
755772
if !payloads.is_empty() {
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
--TEST--
2+
Removing live debugger configs that share a probe id must not leave a dangling hook
3+
--SKIPIF--
4+
<?php include __DIR__ . '/../includes/skipif_no_dev_env.inc'; ?>
5+
--ENV--
6+
DD_AGENT_HOST=request-replayer
7+
DD_TRACE_AGENT_PORT=80
8+
DD_TRACE_GENERATE_ROOT_SPAN=0
9+
DD_REMOTE_CONFIG_POLL_INTERVAL_SECONDS=0.1
10+
--INI--
11+
datadog.trace.agent_test_session_token=live-debugger/remove_shared_probe_id
12+
--FILE--
13+
<?php
14+
15+
require __DIR__ . "/live_debugger.inc";
16+
17+
reset_request_replayer();
18+
19+
function foo() {
20+
$span = \DDTrace\active_span();
21+
return $span ? $span->name : "removed";
22+
}
23+
24+
put_dynamic_config_file([
25+
"dynamic_instrumentation_enabled" => true,
26+
]);
27+
28+
// Two configs at different paths sharing the SAME probe id. Keying the hook map
29+
// by probe id would orphan a hook on removal (use-after-free); distinct non-empty
30+
// tags also make the paths differ and exercise the tags allocation freed on drop.
31+
function put_span_probe_id1($tag) {
32+
return put_live_debugger_file([
33+
"id" => "1",
34+
"language" => "php",
35+
"evaluateAt" => "EXIT",
36+
"type" => "SPAN_PROBE",
37+
"where" => ["methodName" => "foo"],
38+
"tags" => [$tag],
39+
]);
40+
}
41+
42+
$p1 = put_span_probe_id1("a");
43+
$p2 = put_span_probe_id1("b");
44+
45+
// Wait for BOTH probes to install, without calling foo() (keeps them pre-EMITTING
46+
// so the diagnostics read of the probe id happens on the first call, after removal).
47+
await_probe_installation(function () {}, 2);
48+
49+
// Remove both configs and let the remote-config poll process the removals.
50+
del_rc_file($p1);
51+
del_rc_file($p2);
52+
for ($i = 0; $i < 300; $i++) {
53+
usleep(10000);
54+
}
55+
56+
// No hook must remain: an orphaned one would read freed memory here.
57+
var_dump(foo());
58+
59+
?>
60+
--EXPECT--
61+
string(7) "removed"

tracer/live_debugger.c

Lines changed: 18 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -128,11 +128,8 @@ static bool dd_probe_file_mismatch(dd_probe_def *def, zend_execute_data *execute
128128

129129
static void dd_probe_dtor(void *data) {
130130
dd_probe_def *def = data;
131-
if (def->probe.probe.tag == DDOG_PROBE_TYPE_SPAN_DECORATION) {
132-
ddog_drop_span_decoration_probe(def->probe.probe.span_decoration);
133-
} else if (def->probe.probe.tag == DDOG_PROBE_TYPE_LOG) {
134-
ddog_drop_log_probe_capture_expressions(def->probe.probe.log);
135-
}
131+
// Frees the probe's FFI-owned allocations (tags + span-decoration/log).
132+
ddog_drop_probe(def->probe);
136133
if (def->file) {
137134
zend_string_release(def->file);
138135
}
@@ -854,16 +851,28 @@ static void dd_remove_live_debugger_probe(int64_t id) {
854851
zend_string *scope = def->scope ? zend_string_copy(def->scope) : NULL;
855852
zend_string *func = def->function ? zend_string_copy(def->function) : NULL;
856853
def->removed = true;
857-
zai_hook_remove(
858-
def->scope ? (zai_str)ZAI_STR_FROM_ZSTR(def->scope) : (zai_str)ZAI_STR_EMPTY,
859-
def->function ? (zai_str)ZAI_STR_FROM_ZSTR(def->function) : (zai_str)ZAI_STR_EMPTY,
860-
id);
854+
// Use explicit if-guards, not an inline ternary in the call args:
855+
// MSVC 2017 (PHP 7.x Windows) if-converts
856+
// `def->scope ? ZAI_STR_FROM_ZSTR(def->scope) : ZAI_STR_EMPTY` into an
857+
// unconditional ZSTR_LEN(def->scope) — a NULL deref for global functions
858+
// (scope == NULL). See PR #4036.
859+
zai_str scope_str = ZAI_STR_EMPTY, func_str = ZAI_STR_EMPTY;
860+
if (def->scope) {
861+
scope_str = (zai_str) ZAI_STR_FROM_ZSTR(def->scope);
862+
}
863+
if (def->function) {
864+
func_str = (zai_str) ZAI_STR_FROM_ZSTR(def->function);
865+
}
866+
zai_hook_remove(scope_str, func_str, id);
861867
if (scope) {
862868
zend_string_release(scope);
863869
}
864870
if (func) {
865871
zend_string_release(func);
866872
}
873+
// zai_hook_remove freed `def` via the hook's aux dtor; drop the now-stale
874+
// map entry so a later lookup of the same id can't use-after-free it.
875+
zend_hash_index_del(&DDTRACE_G(active_live_debugger_hooks), (zend_ulong)id);
867876
}
868877
}
869878

0 commit comments

Comments
 (0)