Never patch the profiler's own import table - #721
Conversation
e29143e to
5c7c2d5
Compare
This comment has been minimized.
This comment has been minimized.
5c7c2d5 to
64845c6
Compare
zhengyu123
left a comment
There was a problem hiding this comment.
_profiler_name is no longer used, so please remove it. You can also remove LibraryPatcher::initialize(), as its sole purpose was to initialize _profiler_name.
64845c6 to
19572a3
Compare
zhengyu123
left a comment
There was a problem hiding this comment.
Actually, there is a similar problem in LibraryPatcher::patch_socket_functions()
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated 2 comments.
Suppressed comments (2)
ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp:433
- This
_initializedcheck needs an atomic load once_initializedis changed tostd::atomic<bool>; otherwise it won’t compile and the intended cross-thread gate won’t be correctly synchronized.
void LibraryPatcher::patch_libraries() {
// Profiler::start() has not run yet, so the hook would have nowhere to
// register new threads. Also the case in Gtest, which never initializes.
if (!_initialized) {
return;
}
ddprof-lib/src/main/cpp/libraryPatcher_linux.cpp:511
- This
_initializedcheck needs an atomic load once_initializedis changed tostd::atomic<bool>; otherwise it won’t compile and the intended cross-thread gate won’t be correctly synchronized.
void LibraryPatcher::patch_sigaction_in_library(CodeCache* lib) {
if (lib->name() == nullptr) return;
if (!_initialized) return; // Not initialized yet
| static SpinLock _lock; | ||
| static const char* _profiler_name; | ||
| // Set by initialize(), which Profiler::start() calls just before the first | ||
| // library scan. Patching must not begin any earlier: pthread_create_hook() | ||
| // routes newly created threads through Profiler::registerThread(), which | ||
| // dereferences state that only exists once the profiler is running. | ||
| static bool _initialized; | ||
| static PatchEntry _patched_entries[MAX_NATIVE_LIBS]; | ||
| static int _size; |
| SpinLock LibraryPatcher::_lock; | ||
| const char* LibraryPatcher::_profiler_name = nullptr; | ||
| bool LibraryPatcher::_initialized = false; | ||
| PatchEntry LibraryPatcher::_patched_entries[MAX_NATIVE_LIBS]; |
LibraryPatcher recognised its own library by comparing realpath(lib) with the profiler's path, and skipped that comparison entirely when realpath() returned nullptr. dd-trace-java extracts libjavaProfiler.so to a temporary file and unlinks it once loaded, so realpath() on the still-mapped path fails and the self-check reported "not self" - letting a library re-scan patch our own GOT entry for pthread_create. pthread_create_hook() reaches the real pthread_create() through that same entry, so the hook then called itself until the thread stack was exhausted: SIGSEGV with no hs_err file, since crash reporting needs stack of its own. Whether it happened depended on a re-scan landing after the unlink, which made it look random. Recognise our own library by mapped address range instead, which cannot fail. Every native library cache carries its mapping bounds, so no name comparison is kept as a fallback. Apply it at all three patch sites: pthread_create, sigaction and the socket functions. patch_socket_functions() had the same hazard by another route. It computed its is-self flags in a pre-pass keyed by library index and applied them in a second, locked pass; the array can grow in between, so a flag could be applied to the wrong entry and let us patch ourselves. The pre-pass only existed because the old check called realpath() and could not run under the lock, which no longer applies - the check now runs on the library actually being patched. _profiler_name is no longer used for identification, so drop it. The "initialized yet?" guards it doubled as are still needed and now read an explicit flag: patching must not start before Profiler::start(), because pthread_create_hook() routes new threads through Profiler::registerThread(), which crashes on a profiler that is not running. The flag is atomic with release/acquire, being written from Profiler::start() and read from the Libraries refresher thread. Environment: Datadog workspace Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
19572a3 to
e4225cf
Compare
@zhengyu123 Claude made more changes to apply your comment and Codex comments: https://github.com/DataDog/java-profiler/compare/19572a3ef622ab14cd6715f616a91355ba2bc791..e4225cf51eb5f61ec3911f5cd6c5e0d544950ff7 |
rkennke
left a comment
There was a problem hiding this comment.
Looks good to me, thank you!
There was a problem hiding this comment.
Pull request overview
Copilot reviewed 4 out of 4 changed files in this pull request and generated no new comments.
Suppressed comments (1)
ddprof-lib/src/test/cpp/libraryPatcher_ut.cpp:60
- anchorMin()/anchorMax() do pointer arithmetic on a
const char*derived from a function address. In C++ this is undefined behavior (pointer arithmetic is only defined within the same object/array), and can trip UBSan or stricter toolchains. Prefer doing the offset math viauintptr_tand cast back toconst void*.
const void* anchorMin() {
return (const char*)LibraryPatcherTestAccessor::selfAnchor() - 0x1000;
}
LibraryPatcher recognised its own library by comparing realpath(lib) with the profiler's path, and skipped that comparison entirely when realpath() returned nullptr. dd-trace-java extracts libjavaProfiler.so to a temporary file and unlinks it once loaded, so realpath() on the still-mapped path fails and the self-check reported "not self" - letting a library re-scan patch our own GOT entry for pthread_create. pthread_create_hook() reaches the real pthread_create() through that same entry, so the hook then called itself until the thread stack was exhausted: SIGSEGV with no hs_err file, since crash reporting needs stack of its own. Whether it happened depended on a re-scan landing after the unlink, which made it look random. Recognise our own library by mapped address range instead, which cannot fail. Every native library cache carries its mapping bounds, so no name comparison is kept as a fallback. Apply it at all three patch sites: pthread_create, sigaction and the socket functions. patch_socket_functions() had the same hazard by another route. It computed its is-self flags in a pre-pass keyed by library index and applied them in a second, locked pass; the array can grow in between, so a flag could be applied to the wrong entry and let us patch ourselves. The pre-pass only existed because the old check called realpath() and could not run under the lock, which no longer applies - the check now runs on the library actually being patched. _profiler_name is no longer used for identification, so drop it. The "initialized yet?" guards it doubled as are still needed and now read an explicit flag: patching must not start before Profiler::start(), because pthread_create_hook() routes new threads through Profiler::registerThread(), which crashes on a profiler that is not running. The flag is atomic with release/acquire, being written from Profiler::start() and read from the Libraries refresher thread. Environment: Datadog workspace Co-authored-by: Claude Opus 5 (1M context) <noreply@anthropic.com> (cherry picked from commit f55119f)
This PR stops
LibraryPatcherfrom patching the profiler's own import table, which could turnpthread_create_hook()into unbounded recursion and kill the JVM.patch_library_unlocked()recognised its own library by comparingrealpath(lib)with the profiler's path, and skipped that comparison entirely whenrealpath()returnednullptr(libraryPatcher_linux.cpp#L419-L426). dd-trace-java extractslibjavaProfiler.soto a temporary file and unlinks it once loaded, sorealpath()on the still-mapped path fails, the self-check reports "not self", and a library re-scan patches our own GOT entry forpthread_create.pthread_create_hook()reaches the realpthread_create()through that same entry, so the hook then calls itself until the thread stack is exhausted:SIGSEGV, and nohs_errfile, because crash reporting needs stack of its own. Whether it happens depends on a re-scan landing after the unlink, which is what made these crashes look random.Diagnosed from core dumps of JVM test crashes:
pthread_create_hook+0xa8frames (the return address of its ownbl pthread_create@plt)si_signo=11 si_code=128 (SI_KERNEL) si_addr=0x0,pcinmalloc, andsp~2MB below the thread's stack basepthread_createJUMP_SLOTholdsbase+<pthread_create_hook>in every core inspectedReproduced at ~8-10% per run on a JVM test target with the profiler active. A standalone reproducer (unlink the extracted
.so,dlopento force a re-scan, then create threads) crashes dd-java-agent 1.65.0 downloaded straight from Maven Central, so this is not fixed in the latest release.The fix
self_anchor()returns the address of a function in this translation unit — a function rather than a static variable, because aCodeCachespans a library's executable segments (Symbols::parseLibrariesbuilds the bounds from/proc/self/maps), which do not cover.data/.bss. Since every native library cache carries those bounds, there is no name comparison and no fallback: per review, falling back to something known to be faulty is not worth keeping, and the library deletion that triggers it is going away regardless.Applied at all three patch sites:
pthread_create,sigaction, and the socket functions.patch_socket_functions()had the same hazard by another route. It computed its is-self flags in a pre-pass keyed by library index, then applied them in a second, locked pass that re-readnative_libs.at(index). The array can grow between the two passes, so a flag could be applied to a differentCodeCachethan the one it was computed for — and a stalefalseon our own library would let us patch ourselves. That pre-pass only existed because the old check calledrealpath(), which must not run while holding_lock; the range check has no such constraint, so it now runs on the library actually being patched and thebool is_self[MAX_NATIVE_LIBS]array is gone._profiler_nameandinitialize()_profiler_nameis no longer used for identification and is removed.initialize()stays, now setting an explicit flag rather than that string. Those_profiler_name == nullptrchecks were doing double duty: besides the path comparison they gated patching on the profiler being up.Profiler::start()callsinitialize()immediately before the firstupdateSymbols(), so in production the guard is always satisfied by then — but remove it and any earlierLibrariesrefresh installspthread_create_hookbefore there is a profiler to register threads with. The reproducer then crashes deterministically:(The full gtest suite passes with the guard removed, so tests alone do not catch this. The
// only happens in Gtestcomment on that guard is misleading.)The flag is
std::atomic<bool>with release/acquire, matching_socket_active: it is written fromProfiler::start()and read from the Libraries refresher thread viapatch_libraries().initialize()resets_sizebefore the release store, so a thread that observes the flag also observes the reset.Note the gate is deliberately not added to
patch_socket_functions(): that is only reachable fromNativeSocketSampler::start()and frominstall_socket_hooks(), which early-returns unless_socket_active(false until the first batch), so it cannot run before start — and gating it would risk silently disabling socket patching ifNativeSocketSampler::start()were ever ordered beforeLibraryPatcher::initialize().Tests
New
libraryPatcher_ut.cpp(7 tests). They are verified to fail without the fix: swapping the originalrealpath/strcmplogic back in makes exactlyRecognisesSelfWhenItsLibraryFileWasUnlinkedandLeavesItsOwnPthreadCreateSlotUntouchedfail.StillPatchesForeignPthreadCreateSlotpasses in both states as a control, so the guard is shown to discriminate rather than to have quietly disabled patching. The full:ddprof-lib:gtestDebugsuite passes (54 test binaries, no failures).Possible follow-up
Having
pthread_create_hook()call the real function through a cacheddlsym(RTLD_NEXT, ...)pointer instead of its own PLT — aspatch_socket_functions()already does forsend/recv/write/read— would make a self-patch harmless rather than merely prevented. Not included here to keep this change off the thread-creation hot path.🤖 Generated with Claude Code