From ade7027697e713d269f5dad85c0c1495cce026ba Mon Sep 17 00:00:00 2001 From: Jaroslav Bachorik Date: Wed, 10 Jun 2026 11:47:28 +0200 Subject: [PATCH 1/8] fix: validate temp dir ownership/perms in crashtracking (APMSP-3192) - TempLocationManager: POSIX ownership+0700 check on pre-existing dirs - Initializer: add isOwnedAndPrivate() helper (null-safe) - CrashUploaderScriptInitializer/OOMENotifierScriptInitializer: drop world-wide perms, validate pre-existing dirs/scripts, create new dirs with 0700 via Files.createDirectories+PosixFilePermissions - Add acceptance tests for both initializers and TempLocationManager Co-Authored-By: Claude Sonnet 4.6 --- .../CrashUploaderScriptInitializer.java | 58 ++++- .../datadog/crashtracking/Initializer.java | 46 ++++ .../OOMENotifierScriptInitializer.java | 52 ++++- .../ScriptInitializerSecurityTest.java | 217 ++++++++++++++++++ ...racking-temp-script-hijack-enables-loca.md | 173 ++++++++++++++ .../trace/util/TempLocationManager.java | 77 +++++++ .../util/TempLocationManagerSecurityTest.java | 171 ++++++++++++++ .../trace/util/TempLocationManagerTest.java | 4 +- 8 files changed, 774 insertions(+), 24 deletions(-) create mode 100644 dd-java-agent/agent-crashtracking/src/test/java/datadog/crashtracking/ScriptInitializerSecurityTest.java create mode 100644 docs/sphinx/specs/2026-06-10-crashtracking-temp-script-hijack-enables-loca.md create mode 100644 internal-api/src/test/java/datadog/trace/util/TempLocationManagerSecurityTest.java diff --git a/dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/CrashUploaderScriptInitializer.java b/dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/CrashUploaderScriptInitializer.java index 5677d10f220..a7fa0d473e5 100644 --- a/dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/CrashUploaderScriptInitializer.java +++ b/dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/CrashUploaderScriptInitializer.java @@ -4,6 +4,7 @@ import static datadog.crashtracking.Initializer.LOG; import static datadog.crashtracking.Initializer.findAgentJar; import static datadog.crashtracking.Initializer.getCrashUploaderTemplate; +import static datadog.crashtracking.Initializer.isOwnedAndPrivate; import static datadog.trace.api.telemetry.LogCollector.SEND_TELEMETRY; import static java.util.Locale.ROOT; @@ -20,6 +21,9 @@ import java.io.InputStreamReader; import java.io.OutputStreamWriter; import java.nio.charset.StandardCharsets; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.attribute.PosixFilePermissions; public final class CrashUploaderScriptInitializer { private static final String SETUP_FAILURE_MESSAGE = "Crash tracking will not work properly."; @@ -69,23 +73,36 @@ private static boolean copyCrashUploaderScript( File scriptFile, String onErrorFile, String agentJar) { File scriptDirectory = scriptFile.getParentFile(); if (!scriptDirectory.exists()) { - if (!scriptDirectory.mkdirs()) { + try { + if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) { + Files.createDirectories( + scriptDirectory.toPath(), + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + } else { + if (!scriptDirectory.mkdirs()) { + LOG.warn( + SEND_TELEMETRY, + "Failed to create writable crash tracking script folder {}. " + + SETUP_FAILURE_MESSAGE, + scriptDirectory); + return false; + } + } + } catch (IOException e) { LOG.warn( SEND_TELEMETRY, "Failed to create writable crash tracking script folder {}. " + SETUP_FAILURE_MESSAGE, scriptDirectory); return false; } - boolean permissionFailure = false; - permissionFailure |= !scriptDirectory.setReadable(true, false); - permissionFailure |= !scriptDirectory.setWritable(true, false); - permissionFailure |= !scriptDirectory.setExecutable(true, false); - if (permissionFailure) { + } else { + if (!isOwnedAndPrivate(scriptDirectory)) { LOG.warn( SEND_TELEMETRY, - "Failed to set permissions on crash tracking script folder {}. {}", - scriptDirectory, - SETUP_FAILURE_MESSAGE); + "Untrusted crash tracking script folder {} (wrong owner or group/world bits set). " + + SETUP_FAILURE_MESSAGE, + scriptDirectory); + return false; } } if (!scriptDirectory.canWrite()) { @@ -95,6 +112,9 @@ private static boolean copyCrashUploaderScript( try { LOG.debug("Writing crash uploader script: {}", scriptFile); writeCrashUploaderScript(getCrashUploaderTemplate(), scriptFile, agentJar, onErrorFile); + } catch (UntrustedScriptException e) { + LOG.warn(SEND_TELEMETRY, "{} {}", e.getMessage(), SETUP_FAILURE_MESSAGE); + return false; } catch (IOException e) { LOG.warn( SEND_TELEMETRY, @@ -105,6 +125,17 @@ private static boolean copyCrashUploaderScript( return true; } + /** + * Writes the crash uploader script if it does not already exist. When the script already exists + * it is validated for POSIX ownership and permissions before reuse; an untrusted script causes + * this method to throw {@link UntrustedScriptException} so the caller can return {@code false}. + */ + static class UntrustedScriptException extends IOException { + UntrustedScriptException(String msg) { + super(msg); + } + } + private static void writeCrashUploaderScript( InputStream template, File scriptFile, String execClass, String crashFile) throws IOException { @@ -120,9 +151,14 @@ private static void writeCrashUploaderScript( bw.newLine(); } } - scriptFile.setReadable(true, false); + scriptFile.setReadable(true, true); scriptFile.setWritable(false, false); - scriptFile.setExecutable(true, false); + scriptFile.setExecutable(true, true); + } else { + if (!isOwnedAndPrivate(scriptFile)) { + throw new UntrustedScriptException( + "Untrusted crash uploader script (wrong owner or group/world-writable): " + scriptFile); + } } } diff --git a/dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/Initializer.java b/dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/Initializer.java index 0aeca69ed00..6047608e75a 100644 --- a/dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/Initializer.java +++ b/dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/Initializer.java @@ -12,11 +12,18 @@ import datadog.trace.api.Platform; import datadog.trace.util.TempLocationManager; import java.io.File; +import java.io.IOException; import java.io.InputStream; import java.lang.management.ManagementFactory; import java.net.URL; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.UserPrincipal; import java.util.Arrays; +import java.util.EnumSet; import java.util.List; +import java.util.Set; import java.util.StringTokenizer; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -428,6 +435,45 @@ private static String getScriptFileName(String scriptName) { return scriptName + "." + (OperatingSystem.isWindows() ? "bat" : "sh"); } + private static final Set GROUP_WORLD_BITS = + EnumSet.of( + PosixFilePermission.GROUP_READ, + PosixFilePermission.GROUP_WRITE, + PosixFilePermission.GROUP_EXECUTE, + PosixFilePermission.OTHERS_READ, + PosixFilePermission.OTHERS_WRITE, + PosixFilePermission.OTHERS_EXECUTE); + + /** + * Returns {@code true} when {@code f} is safe to trust: on non-POSIX file systems always returns + * {@code true}; on POSIX returns {@code true} only when the path is owned by the current JVM user + * and has no group or world permission bits set (effective {@code 0700} for dirs, {@code 0600} or + * stricter for files). + */ + static boolean isOwnedAndPrivate(File f) { + if (!FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) { + return true; + } + try { + String userName = SystemProperties.get("user.name"); + if (userName == null) { + return false; + } + java.nio.file.Path path = f.toPath(); + UserPrincipal owner = Files.getOwner(path); + UserPrincipal jvmUser = + FileSystems.getDefault().getUserPrincipalLookupService().lookupPrincipalByName(userName); + if (!jvmUser.equals(owner)) { + return false; + } + Set perms = Files.getPosixFilePermissions(path); + return perms.stream().noneMatch(GROUP_WORLD_BITS::contains); + } catch (IOException e) { + LOG.debug("Unable to check ownership/permissions for {}: {}", f, e.getMessage()); + return false; + } + } + private static void logInitializationError(String msg, Throwable t) { if (LOG.isDebugEnabled()) { LOG.warn(SEND_TELEMETRY, msg, t); diff --git a/dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/OOMENotifierScriptInitializer.java b/dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/OOMENotifierScriptInitializer.java index 5d37b975b98..314d9cec97b 100644 --- a/dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/OOMENotifierScriptInitializer.java +++ b/dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/OOMENotifierScriptInitializer.java @@ -5,6 +5,7 @@ import static datadog.crashtracking.Initializer.findAgentJar; import static datadog.crashtracking.Initializer.getOomeNotifierTemplate; import static datadog.crashtracking.Initializer.getScriptPathFromArg; +import static datadog.crashtracking.Initializer.isOwnedAndPrivate; import static datadog.crashtracking.Initializer.pidFromSpecialFileName; import static datadog.trace.api.telemetry.LogCollector.SEND_TELEMETRY; @@ -14,6 +15,9 @@ import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.attribute.PosixFilePermissions; import java.util.Set; public final class OOMENotifierScriptInitializer { @@ -58,12 +62,17 @@ private static File getOOMEScriptFile(String onOutOfMemoryVal) { private static boolean copyOOMEscript(File scriptFile) { File scriptDirectory = scriptFile.getParentFile(); - // cleanup all stale process-specific generated files in the parent folder of the given OOME - // notifier script - runScriptCleanup(scriptDirectory); - if (scriptDirectory.exists()) { - // can be safely ignored; if the folder exists we will just reuse it + if (!isOwnedAndPrivate(scriptDirectory)) { + LOG.warn( + SEND_TELEMETRY, + "Untrusted OOME script folder {} (wrong owner or group/world bits set). OOME notification will not work properly.", + scriptDirectory); + return false; + } + // cleanup all stale process-specific generated files in the parent folder of the given OOME + // notifier script + runScriptCleanup(scriptDirectory); if (!scriptDirectory.canWrite()) { LOG.warn( SEND_TELEMETRY, @@ -72,26 +81,45 @@ private static boolean copyOOMEscript(File scriptFile) { return false; } } else { - if (!scriptDirectory.mkdirs()) { + try { + if (FileSystems.getDefault().supportedFileAttributeViews().contains("posix")) { + Files.createDirectories( + scriptDirectory.toPath(), + PosixFilePermissions.asFileAttribute(PosixFilePermissions.fromString("rwx------"))); + } else { + if (!scriptDirectory.mkdirs()) { + LOG.warn( + SEND_TELEMETRY, + "Failed to create writable OOME script folder {}. OOME notification will not work properly.", + scriptDirectory); + return false; + } + } + } catch (IOException e) { LOG.warn( SEND_TELEMETRY, "Failed to create writable OOME script folder {}. OOME notification will not work properly.", scriptDirectory); return false; } - scriptDirectory.setReadable(true, false); - scriptDirectory.setWritable(true, false); - scriptDirectory.setExecutable(true, false); } try { // do not overwrite existing if (!scriptFile.exists()) { copyStream(getOomeNotifierTemplate(), scriptFile); + scriptFile.setReadable(true, true); + scriptFile.setWritable(false, false); + scriptFile.setExecutable(true, true); + } else { + if (!isOwnedAndPrivate(scriptFile)) { + LOG.warn( + SEND_TELEMETRY, + "Untrusted OOME script {} (wrong owner or group/world-writable). OOME notification will not work properly.", + scriptFile); + return false; + } } - scriptFile.setReadable(true, false); - scriptFile.setWritable(false, false); - scriptFile.setExecutable(true, false); } catch (IOException e) { LOG.warn( SEND_TELEMETRY, diff --git a/dd-java-agent/agent-crashtracking/src/test/java/datadog/crashtracking/ScriptInitializerSecurityTest.java b/dd-java-agent/agent-crashtracking/src/test/java/datadog/crashtracking/ScriptInitializerSecurityTest.java new file mode 100644 index 00000000000..c9badb9dc77 --- /dev/null +++ b/dd-java-agent/agent-crashtracking/src/test/java/datadog/crashtracking/ScriptInitializerSecurityTest.java @@ -0,0 +1,217 @@ +package datadog.crashtracking; + +import static org.junit.jupiter.api.Assertions.assertDoesNotThrow; +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; +import static org.junit.jupiter.api.Assumptions.assumeTrue; + +import java.io.IOException; +import java.nio.file.FileSystems; +import java.nio.file.Files; +import java.nio.file.Path; +import java.nio.file.attribute.PosixFilePermission; +import java.nio.file.attribute.PosixFilePermissions; +import java.util.Comparator; +import java.util.Set; +import java.util.stream.Stream; +import org.junit.jupiter.api.AfterEach; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +/** + * Acceptance tests for the predictable-temp-path hardening spec: + * crashtracking-temp-script-hijack-enables-loca. + * + *

Tests 6-12 from the tester plan — POSIX-only (skipped automatically on non-POSIX). + */ +public class ScriptInitializerSecurityTest { + + private Path tempDir; + + @BeforeEach + void setup() throws IOException { + requirePosix(); + tempDir = Files.createTempDirectory("dd-security-test-"); + } + + private static void requirePosix() { + assumeTrue( + FileSystems.getDefault().supportedFileAttributeViews().contains("posix"), + "Skipping POSIX-only security tests on non-POSIX file system"); + } + + @AfterEach + void teardown() throws IOException { + if (tempDir == null || !Files.exists(tempDir)) { + return; + } + try (Stream stream = Files.walk(tempDir)) { + stream + .sorted(Comparator.reverseOrder()) + .map(Path::toFile) + .forEach( + f -> { + // Restore write permission before delete to handle read-only test artefacts + f.setWritable(true, false); + f.delete(); + }); + } + } + + // --------------------------------------------------------------------------- + // Test 6: Fresh dir — crash uploader script and directory are owner-restricted + // --------------------------------------------------------------------------- + @Test + void crashUploaderFreshDirIsOwnerRestricted() throws Exception { + Path scriptFile = tempDir.resolve("dd_crash_uploader.sh"); + CrashUploaderScriptInitializer.initialize(scriptFile.toString(), "/tmp/hs_err.log"); + + assertTrue(Files.exists(scriptFile), "Script should have been created"); + assertNoGroupWorldWriteBit(scriptFile); + assertNoGroupWorldWriteBit(tempDir); + } + + // --------------------------------------------------------------------------- + // Test 7: Hijacked crash uploader script (group/world-writable) is not trusted + // --------------------------------------------------------------------------- + @Test + void crashUploaderHijackedScriptIsRefused() throws Exception { + Path scriptFile = tempDir.resolve("dd_crash_uploader.sh"); + // Plant an attacker-writable script + Files.createFile(scriptFile); + Files.setPosixFilePermissions(scriptFile, PosixFilePermissions.fromString("rwxrwxrwx")); + + long sizeBefore = Files.size(scriptFile); + + // Initializer must not proceed to write config (it returns false internally) + assertDoesNotThrow( + () -> CrashUploaderScriptInitializer.initialize(scriptFile.toString(), "/tmp/hs_err.log")); + + // The config file must NOT have been written (init refused) + String cfgName = scriptFile.getFileName().toString().replace(".sh", "") + "_pid*.cfg"; + boolean configWritten = + Files.list(tempDir).anyMatch(p -> p.getFileName().toString().endsWith(".cfg")); + assertFalse(configWritten, "Config must not be written when the script is hijacked"); + + // The script content must remain unchanged (empty file planted by attacker) + long sizeAfter = Files.size(scriptFile); + assertTrue( + sizeAfter == sizeBefore, + "Hijacked script must not be overwritten by the initializer (size changed)"); + } + + // --------------------------------------------------------------------------- + // Test 8: Hijacked crash uploader directory (group/world-writable) causes refusal + // --------------------------------------------------------------------------- + @Test + void crashUploaderHijackedDirectoryIsRefused() throws Exception { + // Create the script dir with world-writable perms to simulate hijack + Path scriptDir = tempDir.resolve("hijacked_crash_dir"); + Files.createDirectories(scriptDir); + Files.setPosixFilePermissions(scriptDir, PosixFilePermissions.fromString("rwxrwxrwx")); + + Path scriptFile = scriptDir.resolve("dd_crash_uploader.sh"); + assertDoesNotThrow( + () -> CrashUploaderScriptInitializer.initialize(scriptFile.toString(), "/tmp/hs_err.log")); + + // Script must not have been written into the untrusted dir + assertFalse(Files.exists(scriptFile), "Script must not be written into a hijacked directory"); + } + + // --------------------------------------------------------------------------- + // Test 9: Fresh OOME dir — generated script and directory are owner-restricted + // --------------------------------------------------------------------------- + @Test + void oomeNotifierFreshDirIsOwnerRestricted() throws Exception { + Path scriptFile = tempDir.resolve("dd_oome_notifier.sh"); + OOMENotifierScriptInitializer.initialize(scriptFile + " %p"); + + assertTrue(Files.exists(scriptFile), "OOME notifier script should have been created"); + assertNoGroupWorldWriteBit(scriptFile); + assertNoGroupWorldWriteBit(tempDir); + } + + // --------------------------------------------------------------------------- + // Test 10: Hijacked OOME script (group/world-writable) is not reused + // --------------------------------------------------------------------------- + @Test + void oomeNotifierHijackedScriptIsRefused() throws Exception { + Path scriptFile = tempDir.resolve("dd_oome_notifier.sh"); + // Plant an attacker-writable script + Files.createFile(scriptFile); + Files.setPosixFilePermissions(scriptFile, PosixFilePermissions.fromString("rwxrwxrwx")); + + long sizeBefore = Files.size(scriptFile); + + assertDoesNotThrow(() -> OOMENotifierScriptInitializer.initialize(scriptFile + " %p")); + + // Config must not be written when initializer refuses + boolean configWritten = + Files.list(tempDir).anyMatch(p -> p.getFileName().toString().endsWith(".cfg")); + assertFalse(configWritten, "Config must not be written when the OOME script is hijacked"); + + // Script content must be unchanged + long sizeAfter = Files.size(scriptFile); + assertTrue( + sizeAfter == sizeBefore, "Hijacked OOME script must not be overwritten by the initializer"); + } + + // --------------------------------------------------------------------------- + // Test 11: Hijacked OOME directory (group/world-writable) causes refusal + // --------------------------------------------------------------------------- + @Test + void oomeNotifierHijackedDirectoryIsRefused() throws Exception { + Path scriptDir = tempDir.resolve("hijacked_oome_dir"); + Files.createDirectories(scriptDir); + Files.setPosixFilePermissions(scriptDir, PosixFilePermissions.fromString("rwxrwxrwx")); + + Path scriptFile = scriptDir.resolve("dd_oome_notifier.sh"); + assertDoesNotThrow(() -> OOMENotifierScriptInitializer.initialize(scriptFile + " %p")); + + assertFalse(Files.exists(scriptFile), "Script must not be written into a hijacked directory"); + } + + // --------------------------------------------------------------------------- + // Test 12: Regression — end-to-end init on a clean POSIX tree produces + // working uploader and notifier scripts plus their .cfg files + // --------------------------------------------------------------------------- + @Test + void cleanPosixTreeEndToEndInitProducesScriptsAndConfigs() throws Exception { + Path crashScript = tempDir.resolve("dd_crash_uploader.sh"); + Path oomeScript = tempDir.resolve("dd_oome_notifier.sh"); + + // Initialise crash uploader + assertDoesNotThrow( + () -> CrashUploaderScriptInitializer.initialize(crashScript.toString(), "/tmp/hs_err.log")); + + // Initialise OOME notifier + assertDoesNotThrow(() -> OOMENotifierScriptInitializer.initialize(oomeScript + " %p")); + + // Both scripts must exist and be non-empty + assertTrue(Files.exists(crashScript), "dd_crash_uploader.sh must exist"); + assertTrue(Files.size(crashScript) > 0, "dd_crash_uploader.sh must be non-empty"); + assertTrue(Files.exists(oomeScript), "dd_oome_notifier.sh must exist"); + assertTrue(Files.size(oomeScript) > 0, "dd_oome_notifier.sh must be non-empty"); + + // At least one .cfg file must have been written (crash uploader config) + boolean crashCfgWritten = + Files.list(tempDir).anyMatch(p -> p.getFileName().toString().endsWith(".cfg")); + assertTrue(crashCfgWritten, "Crash uploader .cfg file must be written in the clean flow"); + } + + // --------------------------------------------------------------------------- + // helpers + // --------------------------------------------------------------------------- + + private static void assertNoGroupWorldWriteBit(Path path) throws IOException { + Set perms = Files.getPosixFilePermissions(path); + for (PosixFilePermission bit : + new PosixFilePermission[] { + PosixFilePermission.GROUP_WRITE, PosixFilePermission.OTHERS_WRITE + }) { + assertFalse( + perms.contains(bit), + "Expected no group/world write bit but found " + bit + " on " + path); + } + } +} diff --git a/docs/sphinx/specs/2026-06-10-crashtracking-temp-script-hijack-enables-loca.md b/docs/sphinx/specs/2026-06-10-crashtracking-temp-script-hijack-enables-loca.md new file mode 100644 index 00000000000..8fd734b4b83 --- /dev/null +++ b/docs/sphinx/specs/2026-06-10-crashtracking-temp-script-hijack-enables-loca.md @@ -0,0 +1,173 @@ +# Spec: crashtracking-temp-script-hijack-enables-loca + +## Problem + +Crashtracking is enabled by default. When autoconfig is active the agent sets +HotSpot `OnError` / `OnOutOfMemoryError` to execute `dd_crash_uploader` / +`dd_oome_notifier` scripts located under a **predictable** temp path derived by +`TempLocationManager`: `/ddprof_/pid_/`. + +The path is predictable and the agent does not establish a trust boundary on +directories or scripts it reuses: + +- `TempLocationManager.createTempDir(Path)` calls `Files.createDirectories(...)` + which is a no-op when the directory already exists. On the existing-directory + path it never validates that the directory is owned by the current user or + that its permissions are `0700`. POSIX permissions are only inspected lazily + inside a `catch (IOException)` block, i.e. only when creation *fails* — never + when an attacker-owned directory already satisfies traversal so creation + silently succeeds. +- `CrashUploaderScriptInitializer.copyCrashUploaderScript` and + `OOMENotifierScriptInitializer.copyOOMEscript` create the script directory + with world-readable/world-writable/world-executable bits + (`setWritable(true, false)` / `setReadable(true, false)` / + `setExecutable(true, false)`; the `false` second arg means "all users") and + do **not** validate an already-existing directory's ownership. +- Both initializers deliberately skip writing the script when it already exists + (`if (!scriptFile.exists())`). A pre-planted attacker-owned script is reused + verbatim. + +A local attacker who can pre-create or race an attacker-owned +`/ddprof_/pid_/` directory and plant +`dd_crash_uploader.sh` or `dd_oome_notifier.sh` causes the JVM crash/OOME +handler to execute attacker-controlled code as the instrumented service user +when the process crashes or hits OOME. This is local code execution / privilege +pivot. + +## Correct behaviour + +The agent must establish a trust boundary on every directory and script it +reuses under the predictable temp tree on POSIX file systems: + +1. When `TempLocationManager` resolves/creates a temp directory and the + directory (or any ancestor it creates under `baseTempDir`, plus + `baseTempDir` itself) already exists, it must verify the directory is: + - owned by the current user (`java.nio.file.Files.getOwner` equals the + owner of a freshly created reference path, or equals the JVM user + principal), and + - not group- or world-accessible (effective permissions `0700`; reject if + any of `GROUP_*` / `OTHERS_*` bits are set). + If validation fails the manager must refuse to use the directory: throw + `IllegalStateException` (consistent with the existing failure mode in + `createTempDir`) and emit a `ProfilerFlareLogger` message naming the + offending path and its permissions/owner. It must not silently proceed. + +2. Newly created directories must continue to be created with `0700` + (`rwx------`) — already the case via the `PosixFilePermissions` attribute — + and must not be widened afterwards. + +3. `CrashUploaderScriptInitializer` and `OOMENotifierScriptInitializer` must, + on POSIX file systems: + - not create or reuse script directories with group/world bits — drop the + world-wide `setReadable/​setWritable/​setExecutable(.., false)` widening; + restrict to owner-only; + - before reusing a pre-existing script directory, verify it is owned by the + current user with `0700` perms, and refuse (return `false`, log telemetry) + otherwise; + - before reusing a pre-existing script file, verify it is owned by the + current user and not group/world-writable; if it fails validation, refuse + to use it (do not execute an untrusted script) rather than silently + trusting it. + +4. Non-POSIX file systems (e.g. Windows) retain current behaviour; ownership / + permission checks are POSIX-gated, matching the existing `isPosixFs` + branching. + +## Constraints + +- `internal-api` `TempLocationManager` must stay free of new external + dependencies; use only `java.nio.file` (`Files.getOwner`, + `Files.getPosixFilePermissions`, `PosixFilePermission`, + `UserPrincipal`) already imported/available. +- Preserve existing public API: `getTempDir(...)`, `getInstance(...)`, + package-visible test constructors and `@VisibleForTesting` hooks must keep + their signatures so existing tests compile. +- Failure mode for an untrusted temp dir is `IllegalStateException` with a + `ProfilerFlareLogger` message, matching the current `createTempDir` contract. +- Script initializers must continue to degrade gracefully (log via + `SEND_TELEMETRY`, `return`/`return false`) rather than throw — matching their + current contract; the new behaviour is "refuse to use untrusted path", not + "crash the agent". +- google-java-format / Spotless enforced. No `java.util.logging`, `javax.management` + additions in bootstrap-reachable code. + +## Scope + +### Primary fixes + +- `internal-api/src/main/java/datadog/trace/util/TempLocationManager.java` + (`createTempDir`, ~L425-501; also `createDirStructure` L420-423 and the + `getTempDir(Path, boolean)` reuse path L350-357) — defect class: + *missing ownership/permission validation on pre-existing directory + (predictable-path TOCTOU / hijack)* — add POSIX ownership+`0700` validation + for any reused/pre-existing directory under `baseTempDir` (including + `baseTempDir`), rejecting non-conforming dirs with `IllegalStateException` + + flare log. + +### Auto-expanded sibling fixes + +- `dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/CrashUploaderScriptInitializer.java` + (`copyCrashUploaderScript` L68-106, `writeCrashUploaderScript` L108-127) — + defect class: *script/dir hijack via world-wide perms + reuse of pre-existing + unvalidated file*. + - evidence: L80-82 set dir world-readable/writable/executable; L91 only + checks `canWrite`; L111 `if (!scriptFile.exists())` reuses any pre-existing + script without ownership check. + - reasoning: + FLOW: agent autoconfig → `Initializer.initialize` → `initializeCrashUploader` + → `CrashUploaderScriptInitializer.initialize` → `copyCrashUploaderScript` + on the predictable `tempDir`. + PRECONDITION: attacker can create `/ddprof_/pid_/` or the + script before the agent and is a local user on the host. + REACHABLE: yes — enabled by default; OnError points at the predictable path. + CONCLUSION: critical — pre-planted `dd_crash_uploader.sh` executes as the + service user on crash; world-writable dir creation also lets others tamper. + +- `dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/OOMENotifierScriptInitializer.java` + (`copyOOMEscript` L58-103) — + defect class: *script/dir hijack via world-wide perms + reuse of pre-existing + unvalidated file*. + - evidence: L82-84 set dir world-readable/writable/executable; L65-73 reuse + existing dir checking only `canWrite`; L88-90 `if (!scriptFile.exists())` + reuses any pre-existing script without ownership check. + - reasoning: + FLOW: agent autoconfig → `Initializer.initialize` → `initializeOOMENotifier` + → `OOMENotifierScriptInitializer.initialize` → `copyOOMEscript` on the + predictable `tempDir`. + PRECONDITION: attacker pre-plants `/ddprof_/pid_/dd_oome_notifier.sh`. + REACHABLE: yes — OnOutOfMemoryError points at the predictable path; default-on. + CONCLUSION: critical — pre-planted notifier script executes as the service + user on OOME. + +- `dd-java-agent/agent-crashtracking/src/main/java/datadog/crashtracking/ConfigManager.java` + (`writeConfigToPath` L193-197, `writeConfigToFile` L199-235) — + defect class: *config file written into predictable dir without trust check*. + - evidence: writes `