Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions ddprof-lib/src/main/cpp/counters.h
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@
X(UNWINDING_TIME_JVMTI, "unwinding_ticks_jvmti") \
X(CALLTRACE_STORAGE_DROPPED, "calltrace_storage_dropped_traces") \
X(LINE_NUMBER_TABLES, "line_number_tables") \
X(LINE_NUMBER_TABLE_UNREADABLE, "line_number_table_unreadable") \
X(REMOTE_SYMBOLICATION_FRAMES, "remote_symbolication_frames") \
X(REMOTE_SYMBOLICATION_LIBS_WITH_BUILD_ID, "remote_symbolication_libs_with_build_id") \
X(REMOTE_SYMBOLICATION_BUILD_ID_CACHE_HITS, "remote_symbolication_build_id_cache_hits") \
Expand Down
22 changes: 18 additions & 4 deletions ddprof-lib/src/main/cpp/flightRecorder.cpp
Original file line number Diff line number Diff line change
Expand Up @@ -348,11 +348,25 @@ void Lookup::fillJavaMethodInfo(MethodInfo *mi, jmethodID method,
void *owned_table = nullptr;
if (line_number_table_size > 0) {
size_t bytes = (size_t)line_number_table_size * sizeof(jvmtiLineNumberEntry);
owned_table = malloc(bytes);
if (owned_table != nullptr) {
memcpy(owned_table, line_number_table, bytes);
// GetLineNumberTable() is called on the same possibly-stale jmethodID
// that GetMethodDeclaringClass/GetClassSignature/GetMethodName above
// were probed for -- the TOCTOU race documented above (class
// unloaded between sample capture and dump) applies here just as
// much as to those calls, and crash telemetry already showed those
// sibling calls returning JVMTI_ERROR_NONE with unmapped string
// pointers despite the spec saying the returned array should be a
// fresh, caller-owned allocation. Nothing about this call guarantees
// it is exempt from the same failure mode, so probe before copying
// rather than assume the pointer is safe to dereference.
if (SafeAccess::isReadableRange(line_number_table, bytes)) {
owned_table = malloc(bytes);
if (owned_table != nullptr) {
memcpy(owned_table, line_number_table, bytes);
} else {
TEST_LOG("Failed to allocate %zu bytes for line number table copy", bytes);
}
} else {
TEST_LOG("Failed to allocate %zu bytes for line number table copy", bytes);
Counters::increment(LINE_NUMBER_TABLE_UNREADABLE);

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Null unreadable line table before deallocation

When GetLineNumberTable returns a pointer that this new guard classifies as unreadable, this branch only increments the counter; line_number_table is still passed to jvmti->Deallocate(...) immediately afterward. This is the same stale/unmapped-pointer scenario the change is trying to survive, and the string path above explicitly nulls bad JVMTI pointers because deallocation can fault too, so the crash can simply move from memcpy to Deallocate. Skip deallocation or clear the pointer on this unreadable path, accepting the small leak as the existing metadata guard does.

Useful? React with 👍 / 👎.

}
}
jvmtiError dealloc_err = jvmti->Deallocate((unsigned char *)line_number_table);
Expand Down
189 changes: 189 additions & 0 deletions ddprof-lib/src/test/cpp/lineNumberTableCopy_ut.cpp
Original file line number Diff line number Diff line change
@@ -0,0 +1,189 @@
/*
* Copyright 2026, Datadog, Inc.
* SPDX-License-Identifier: Apache-2.0
*/

// Reproduces a crash reported in production:
//
// __memcpy_evex_unaligned_erms
// Lookup::resolveMethod(_asgct_callframe&)
// ...
// Recording::writeStackTraces(Buffer*, Lookup*)
// ...
// Profiler::dump / FlightRecorder::dump
//
// Lookup::fillJavaMethodInfo() (inlined into resolveMethod() under -O2/-O3)
// calls jvmtiEnv::GetLineNumberTable(method, ...) and then copied the
// result with an unguarded:
//
// memcpy(owned_table, line_number_table, bytes);
//
// (flightRecorder.cpp now guards this copy with SafeAccess::isReadableRange()
// -- see below -- so the line numbers of the unguarded version are no longer
// present in the file; this file's tests reproduce that removed pattern.)
//
// Unlike the class_name/method_name/method_sig strings returned by the
// preceding JVMTI calls (which ARE probed with SafeAccess::isReadableRange
// before use, see PR #537 / commit ef13aa29f), the line_number_table pointer
// is used directly.
//
// Per the JVMTI spec, GetLineNumberTable() hands back a freshly-allocated,
// caller-owned array (hence the Deallocate() call after the copy) that is
// decoupled from the Method's lifetime -- a spec-compliant implementation
// cannot invalidate it out from under the caller. The actual risk, per the
// comments already in fillJavaMethodInfo, is the TOCTOU race documented
// right there: "GetMethodDeclaringClass may return a jclass wrapping a
// stale/garbage oop when the class was unloaded between sample capture and
// dump" -- a race against class unloading, not tied to any one JVM vendor.
// That same comment block notes crash telemetry showed GetClassSignature/
// GetMethodName returning JVMTI_ERROR_NONE with unmapped string pointers
// despite the spec saying they should be valid (a separate OpenJ9-specific
// jmethodID-corruption mode is also handled a few lines above, but is not
// the only source of this). The production crash motivating this file was
// on a stock HotSpot JDK 11, confirming the race is not OpenJ9-specific.
// GetLineNumberTable() is called on the exact same jmethodID as
// GetMethodDeclaringClass/GetClassSignature/GetMethodName, with nothing
// about it that would exempt it from that same observed failure mode.
//
// These tests isolate the copy step from JVMTI/JNI (which fillJavaMethodInfo
// requires and which is impractical to fake in a plain gtest) by exercising
// the exact copy pattern against a pointer whose backing memory is gone --
// standing in for "GetLineNumberTable() returned a bad pointer" regardless
// of the precise reason. The result at the memcpy call site is identical
// either way, so this is sufficient to prove both the crash and the fix.

#include <gtest/gtest.h>
#include <cstring>
#include <signal.h>
#include <sys/mman.h>
#include <unistd.h>

#include "flightRecorder.h"
#include "os.h"
#include "safeAccess.h"

namespace {

// SafeAccess::isReadableRange()/isReadable() rely on a SIGSEGV handler
// registered via OS::replaceSigsegvHandler() to catch the fault at a known
// trampoline address and turn it into a safe return value (see
// safefetch_ut.cpp for the same pattern). Without it, a fault inside the
// safefetch trampoline is an ordinary unhandled SIGSEGV.
void (*orig_segvHandler)(int signo, siginfo_t *siginfo, void *ucontext);

void lineNumberTableSegvHandler(int signo, siginfo_t *siginfo, void *context) {
if (!SafeAccess::handle_safefetch(signo, context)) {
if (orig_segvHandler != nullptr) {
orig_segvHandler(signo, siginfo, context);
}
}
}

class LineNumberTableCopyTest : public ::testing::Test {
protected:
void SetUp() override {
orig_segvHandler = OS::replaceSigsegvHandler(lineNumberTableSegvHandler);
}

void TearDown() override { OS::replaceSigsegvHandler(orig_segvHandler); }
};

// Allocates a page-sized region seeded with `count` jvmtiLineNumberEntry
// records, standing in for a buffer jvmtiEnv::GetLineNumberTable() would
// have returned.
jvmtiLineNumberEntry *makeFakeLineNumberTable(void *page, int count) {
jvmtiLineNumberEntry *table = (jvmtiLineNumberEntry *)page;
for (int i = 0; i < count; i++) {
table[i].start_location = i * 4;
table[i].line_number = i + 1;
}
return table;
}

} // namespace

// Reproducer: the code shape that used to be in flightRecorder.cpp's
// fillJavaMethodInfo() before the fix, with no readability guard before the
// memcpy. Demonstrates that once the source page is gone, the copy step used
// by resolveMethod() crashes the process rather than failing gracefully.
TEST(LineNumberTableCopyRawTest, UnguardedCopyCrashesWhenSourceUnmapped) {
EXPECT_DEATH(
{
long page_size = sysconf(_SC_PAGESIZE);
void *page = mmap(NULL, page_size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
if (page == MAP_FAILED) {
_exit(1); // treat as death via non-zero exit if mmap itself fails
}
jint line_number_table_size = 4;
jvmtiLineNumberEntry *line_number_table =
makeFakeLineNumberTable(page, line_number_table_size);

// Stand-in for GetLineNumberTable() handing back a bad pointer for a
// corrupted/stale jmethodID: the backing memory is simply gone by
// the time the copy runs.
munmap(page, page_size);

// This mirrors the pre-fix fillJavaMethodInfo() shape verbatim: no
// readability check on line_number_table before the copy.
size_t bytes =
(size_t)line_number_table_size * sizeof(jvmtiLineNumberEntry);
void *owned_table = malloc(bytes);
memcpy(owned_table, line_number_table, bytes); // <-- crashes here
// Force the copied bytes to be observed before free(); otherwise an
// optimizing (Release) build can prove owned_table's contents are
// never read and eliminate the memcpy as dead code, silently
// skipping the very fault this test exists to demonstrate.
volatile unsigned char sink = *(volatile unsigned char *)owned_table;
(void)sink;
free(owned_table);
},
"");
}

// Documents the fix: guarding the same copy with
// SafeAccess::isReadableRange() (the same primitive already used to probe
// class_name/method_name/method_sig in fillJavaMethodInfo) turns the crash
// into a clean, detectable failure with no memory touched past the guard.
TEST_F(LineNumberTableCopyTest, GuardedCopySkipsSafelyWhenSourceUnmapped) {
long page_size = sysconf(_SC_PAGESIZE);
void *page = mmap(NULL, page_size, PROT_READ | PROT_WRITE,
MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
ASSERT_NE(page, MAP_FAILED);

jint line_number_table_size = 4;
jvmtiLineNumberEntry *line_number_table =
makeFakeLineNumberTable(page, line_number_table_size);

ASSERT_EQ(0, munmap(page, page_size));

size_t bytes = (size_t)line_number_table_size * sizeof(jvmtiLineNumberEntry);
void *owned_table = nullptr;
if (SafeAccess::isReadableRange(line_number_table, bytes)) {
owned_table = malloc(bytes);
memcpy(owned_table, line_number_table, bytes);
}

EXPECT_EQ(nullptr, owned_table);
free(owned_table);
}

// Sanity check: the guard must not reject a genuinely valid table, or every
// real dump would silently lose line-number info.
TEST_F(LineNumberTableCopyTest, GuardedCopyStillWorksForValidSource) {
jint line_number_table_size = 8;
jvmtiLineNumberEntry stack_table[8];
jvmtiLineNumberEntry *line_number_table =
makeFakeLineNumberTable(stack_table, line_number_table_size);

size_t bytes = (size_t)line_number_table_size * sizeof(jvmtiLineNumberEntry);
void *owned_table = nullptr;
if (SafeAccess::isReadableRange(line_number_table, bytes)) {
owned_table = malloc(bytes);
memcpy(owned_table, line_number_table, bytes);
}

ASSERT_NE(nullptr, owned_table);
EXPECT_EQ(0, memcmp(owned_table, line_number_table, bytes));
free(owned_table);
}
Loading
Loading