Immutable Strings in Java – Are Your Secrets Still Safe?
How Java’s Immutability Exposes Sensitive Data in Android Apps and BeyondIntroductionAt 2025-11-11 19:2:38 Author: blog.includesecurity.com(查看原文) 阅读量:17 收藏

How Java’s Immutability Exposes Sensitive Data in Android Apps and Beyond

Introduction

At Include Security we often call out secrets that are hardcoded in application source code as a vulnerability. While most security professionals are aware of the risks posed by typical secret mismanagement, a lesser-known version of the problem exists in Java. The source of the problem is an immutable data type: the humble string.

Immutable data types represent a cornerstone of Java’s design philosophy, emphasizing stability, predictability, and efficiency in object-oriented programming. In Java, an immutable object is one whose internal state cannot be changed after instantiation. Core to this discussion is the String class, which exemplifies immutability and its far-reaching implications, particularly in the development of technologies such as Android where Java and Kotlin are dominant languages for application creation. 

In this post we’ll deep dive into why Strings behave in this manner, unpack the mechanics of memory on systems such as Android, and most importantly equip you with actionable solutions to lock down your applications. Whether you’re a developer, a security enthusiast, or just curious about the tech you use every day, stick around and keep reading as we’re about to bring attention to a misunderstood flaw that’s been hiding in plain sight.

Overview

The Culprit: Java String Immutability

At the heart of this issue is Java’s String immutability. In simple terms, once a variable of String data type is created in Java (think password, cryptographic key, user credentials) it cannot be altered or erased. This phenomenon is known as immutability, and Java Strings are immutable objects. Unlike mutable objects that you can overwrite or clear out, immutable objects such as Strings stick around in memory exactly as they were written. At first thought this might be considered “No big deal, it’ll get cleaned up by the garbage collector eventually, right?” Well, not quite. In Java, these Strings linger in the heap (or string pool) until the garbage collector decides to swoop in and pick up the trash. That is if it ever does. And there are instances where it will not!

A Real-World Wake-Up Call

This isn’t just an academic classroom hypothetical. There are a couple of high profile instances where I have encountered this development behavior for myself; most significantly was an Android focused experience while working on VR technology for a major fortune tech company spearheading the VR space. I was able to demonstrate the existence of this by examining heap memory contents using debugging tools and processes such as dumping the device’s memory, a technique attackers can replicate with tools like adb, sudo/root level access, and malicious applications. Through this process I was able to recover plaintext user credentials stored as Strings.  

These weren’t arbitrary nonsensical String data; they were secret treasures left exposed in shared memory, ripe for the picking. It’s a stark reminder that even top fortune companies, with all their resources, can overlook this fundamental construct of virtual machine based programming languages such as Java and Kotlin.

Since this initial discovery in late 2023/early 2024 I’ve encountered other, non-mobile production Java applications using Strings in the very same manner; again, user credentials being stored as Strings as well as database credentials. This time around the application was developed by a world renowned, industry leading news outlet. Based on empirical evidence from my experience performing application security assessments for Fortune companies, I would argue that this immutability issue is more prevalent than we realize and deeply highlights an industry wide misunderstanding of immutable object data types in virtual machine (VM) based programming languages. While the focus of this work was on Java and Kotlin, it is hypothesized that other VM-based languages (e.g., .NET, Python) would likely be susceptible in a similar manner to that which is described here.

Why it’s a Hacker’s Paradise

Here’s where the problem really comes into focus: because Java Strings are immutable, once a secret is materialized in a String object there are no APIs to overwrite those bytes that now reside in system memory (RAM). The only thing that ever removes them from RAM is the garbage collector, and its timing is dictated by the Java Virtual Machine (JVM) and the underlying operating system, not by your code. This means that any attacker who can obtain any form of read access into the target process can dump the heap region of system memory in seconds creating a condition wherein readable, plaintext values can be pulled right out of the contents of the heap memory segment. Read Access can be obtained through a debugger, malicious native library (dynamically linked library), OS APIs such as ptrace and ReadProcessMemory, or by simply having physical control of the computing device. The attack’s complexity is low; the cost is modest to high depending on the target system and configuration, and the payoff can be huge because the sensitive data remains in memory until the garbage collector decides to reclaim it. In short, immutability gives you no way to remove or “zero out” data proactively; you’re left hoping and waiting for the garbage collector to clean up before someone or something else gets to peak at system memory.

Finally, the prolonged residency of Java Strings in system memory dramatically lowers the complexity of an attack aimed at recovering sensitive data: a generic memory dump and pattern scan requires far less application‑specific knowledge than the context needed for performing application hooking techniques. While a secret stored within an application’s memory could be compromised by a memory dump lasting milliseconds, the secret’s window of exposure is increased substantially due to the limitations of Java’s garbage collection and immutable data types.

Technical Overview and Demonstration

In this section of the white paper we will explore why Strings linger in memory longer than you would anticipate and quantify the risk this poses to applications and user data with substantive results from a custom test application. By the end of this section you will see and understand why this is a big deal for application security.

Why Strings Linger: Java’s Memory Model

Strings in Java (and Kotlin, by extension) have a unique trait: they’re immutable. Once you create a String like “MySecretPassword”, you can’t modify it or wipe it clean. This is great for performance and thread safety, but it’s a nightmare for security. String data sits in memory as defined until something removes it. And that something is Java’s garbage collector (GC). However, therein lies the problem; the problem is that the GC does not work on the programmers schedule. Instead it works based on the execution environment and as a result the behavior is not clearly defined. Here is what we do know:

Heap and String Pool: Defined Strings live in the heap memory segment, and some Strings such as string literals get stored in the String pool for optimization. This behavior means that Strings can stick around longer than other objects, even after the program is done using them.

Garbage Collection (GC): GC cleans up unreferenced objects, but as mentioned its behavior is non-deterministic. For example, you cannot force the garbage collector to run precisely when you want, even when explicitly calling the garbage collector (i.e., System.gc() ). On Android with its multi-process architecture, GC can be especially unpredictable and sometimes delayed by minutes, and sometimes skipped entirely if memory pressure is low.

Strong Reference: In Java, a Strong Reference is the default and most common type of object reference. Conceptually you can think of a strong reference as a pointer to an object. In the case of Java, objects remain ineligible for garbage collection when one or more strong references to said object exist. The referenced object will remain in memory until the reference is cleared or goes out of execution scope. This contrasts with soft references, which have a weaker preservation within Java memory management, which allow the garbage collector to reclaim the object under certain conditions of execution even if references exist.

Android Memory Management and Access

Android’s default execution model assigns one process per executing application, but developers can use the android:process manifest attribute to create additional processes for isolation or performance. Regardless of whether an app runs in a single sandbox or across multiple processes, every Java/Kotlin String resides on the Dalvik/ART heap as an immutable object that cannot be overwritten once created. This immutability means that when a String is passed through Android’s interprocess communication (IPC) mechanisms such as Intent.putExtra(), AIDL calls, Binder parcels, or ContentProvider queries, the runtime serializes it into an anonymous shared memory (ashmem) buffer to cross the process boundary. Since the original String cannot be cleared, each copy becomes a new immutable object in its own heap segment, persisting until garbage collection occurs. For example, a single password could exist simultaneously in the originating app’s heap, the ashmem buffer during a Binder transaction, as well as the receiving app’s heap after deserialization, with all copies being immutable and retaining the “cannot be zeroed out” property.

Android throttles garbage collection to prioritize UI responsiveness and battery life, potentially delaying reclamation for minutes under low memory pressure. Our Java garbage collector measurements indicate that a String can remain in system memory for many minutes after its last strong reference is cleared, providing attackers a significant window to extract sensitive data from any of these copies.

The Android sandbox is enforced by per-UID Linux permissions and SELinux policies and prevents normal mobile apps from accessing another app’s memory (e.g., /proc/<pid>/mem or ashmem regions). However, an attacker with elevated privileges can bypass these protections. On a rooted device, SELinux restrictions are nullified, allowing commands like cat /proc/<process>/mem to dump heap memory and extract lingering Strings in seconds. Even without root, a compromised privileged service could use ptrace to inspect a victim process’s memory, creating a memory accessing backdoor. Misconfigured exported components such as ContentProviders, Services, or BroadcastReceivers that accept untrusted input may inadvertently serialize sensitive Strings to world readable files (e.g., using the deprecated and insecure MODE_WORLD_READABLE) or expose ashmem buffers via leaked file descriptors, leaking data to apps with sufficient permissions.

Quantifying the Risk: New Data

Moving beyond theory and into practical application, I experimented with Java garbage collection by building a multi-threaded Java program called MultiStringLifespanTracker.java. The program’s intent was to measure exactly how long Strings linger in system memory after their strong references were removed. Here is what I found.

Experiment Setup

This application was developed with the intent of simulating real world conditions of an executing Java program with ongoing memory pressure through continued application use. Several strings are defined within the application and their lifespans are tracked.

  • String Creation: Our experimental application spawns multiple threads, each creating a unique String (e.g., secretStr = SuperDuperSecret_1) to mimic sensitive data.
  • Weak References: Each String is wrapped in a WeakReference and tied to a ReferenceQueue. The strong reference is then nulled out (e.g., secretStr = null) to make it eligible for garbage collection.
  • Memory Pressure: To encourage the Java virtual machine to run the garbage collector, the application simulates computational load by allocating byte arrays (62.5KB chunks) in a loop. There is also a brief thread delay to vary timing and to prevent a potential overload of the CPU.
  • Lifespan Tracking: The program logs when each String is finally collected by the garbage collector, calculating the time from nulling the strong reference to cleanup via garbage collection.
MultiStringLifespanTracker.java
import java.lang.ref.ReferenceQueue;
import java.lang.ref.WeakReference;
import java.time.Instant;
import java.time.LocalDateTime;
import java.time.ZoneId;
import java.util.ArrayList;
import java.util.List;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
import java.util.concurrent.atomic.AtomicInteger;
import java.time;


public class MultiStringLifespanTracker {
   // Number of threads/Strings to track
   private static final int NUM_THREADS = 4;
   private static final int NUM_HOGS = 1;
   private static final int NUM_COUNT = 1;
   private static final int NUM_BYTES = 62500;//125000; // .125 of 1mb
   // Counter for unique String IDs
   private static final AtomicInteger stringIdCounter = new AtomicInteger(1);


   public static void main(String[] args) {
       System.out.println("\n[*] Starting multi-threaded String Object Strong reference tracker...\n\n");
       ExecutorService executor = Executors.newFixedThreadPool(NUM_THREADS);


       // Spawn threads to track Strings
       for (int i = 0; i < NUM_THREADS; i++) {
           final int stringId = stringIdCounter.getAndIncrement();
           executor.submit(() -> trackStringLifespan(stringId));
       }


       // Shutdown executor after tasks are submitted
       executor.shutdown();
   }


   private static void trackStringLifespan(int stringId) {
       try {
           // Create a unique String for this thread
           String secretStr = new String("SuperDuperSecret_" + stringId);
           ReferenceQueue<String> queue = new ReferenceQueue<>();
           WeakReference<String> weakRef = new WeakReference<>(secretStr, queue);
           long startTime = System.currentTimeMillis();
           LocalDateTime currentTime = LocalDateTime.now();


           System.out.printf("[!!!] Thread %d with String-%d was created at %s with value: %s\n",
                           Thread.currentThread().getId(), stringId, currentTime, secretStr);


           System.out.printf("[!] Thread %d removed Strong reference to String Object String-%d with value %s\n",
                           Thread.currentThread().getId(), stringId, secretStr);


           // Remove strong reference to make String eligible for GC
           secretStr = null;               
           System.out.printf("[-] Inducing memory pressure in Thread %d\n\n", Thread.currentThread().getId());


           while (true) {
               // Check if String has been garbage collected
               if (queue.poll() != null) {
                   LocalDateTime currentTimeEnd = LocalDateTime.now();
                   long endTime = System.currentTimeMillis();
                   long durationMs = endTime - startTime;
                   double durationSec = durationMs / 1000.0;
                   System.out.printf("[EOF] Thread %d with String-%d was garbage collected at %s after %.3f seconds\n",
                                   Thread.currentThread().getId(), stringId, currentTimeEnd, durationSec);
                   break;
               }
               // Brief sleep to avoid overwhelming CPU
               Thread.sleep(1000);
               List<byte[]> memoryHog = new ArrayList<>();
               for (int i = 0; i < NUM_COUNT; i++){
                   //Simple count to keep programing executing/in use
                   //System.out.printf("Counting: %d in Thread %d\n", i, Thread.currentThread().getId());
                   // Induce memory pressure to encourage GC
                   try {
                       for (int j = 0; j < NUM_HOGS; j++) {
                           memoryHog.add(new byte[NUM_BYTES]);
                       }
                   } catch (OutOfMemoryError e) {
                       System.out.printf("[OOM] Thread %d with String-%d hit OOM, clearing thread's memory hog\n",
                                       Thread.currentThread().getId(), stringId);
                       memoryHog.clear();
                   }
               }


               // Suggest garbage collection
               //System.gc();
           }
       } catch (InterruptedException e) {
           System.err.printf("[!!!] Thread %d was Interrupted for String-%d\n",
                           Thread.currentThread().getId(), stringId);
       }
   }
}
Code Overview

Imports and Dependencies: 

The program relies on several Java packages to enable its functionality:

  • Packages for weak references and reference queues allow tracking of objects without preventing their collection by the garbage collector.
  • Time-related packages provide precise timestamping for logging when String objects are created and collected.
  • Collection classes support memory pressure simulation by creating lists of byte arrays.
  • Concurrency utilities manage a thread pool for running tasks simultaneously.
  • A thread-safe counter ensures unique identifiers for each String across threads.

Constants and Fields: 

The program defines several constants to control its behavior:

  • It uses four threads, meaning it tracks four String objects concurrently.
  • Memory pressure is applied by allocating one byte array per loop iteration.
  • An outer loop counter is set to numerical 1, making it effectively a single pass for memory allocation.
  • Each byte array is sized at 62,500 bytes (approximately 62.5KB), reduced from a previous version to apply lighter memory pressure.
  • A thread-safe counter starts at one and generates unique IDs for each String (e.g., SuperDuperSecret_1, SuperDuperSecret_2).

These constants create a controlled environment, balancing concurrency and memory usage to simulate realistic conditions of a lightly to moderately used application without overwhelming the JVM.

main() Method: 

The main method is the program entry point and initiates the experiment by starting and coordinating the multi-threaded execution:

  • It begins by printing a startup message to the console, indicating the program is running.
  • A fixed thread pool is created with four threads, allowing concurrent execution of tasks without excessive resource use.
  • The program loops four times, assigning a unique ID to each task using the thread-safe counter. Each task is submitted to the thread pool to run a method that tracks a single String’s lifespan.
  • After submitting all tasks, the thread pool is shut down, preventing new tasks but allowing the four tasks to complete.

This sets up a concurrent environment where each thread independently monitors a String’s lifecycle, mimicking scenarios where multiple components in an application handle sensitive data.

trackStringLifespan() Method: 

The core method, executed by each thread, manages the lifecycle of a single String object. It takes a unique ID as input to differentiate each String. Here’s what it does:

  • String Creation and Weak Reference Setup:
    • A unique String is created with a value like SuperDuperSecret_1, using the provided ID. The String is explicitly created as a new object to ensure it resides in the heap, not the String pool, making it eligible for garbage collection when dereferenced.
    • A reference queue is created to detect when the String is collected, and the String is wrapped in a weak reference tied to this queue. A weak reference allows the String to be garbage collected if no strong references remain, and the queue signals when collection occurs.
    • The creation time is recorded in two formats: milliseconds since epoch for duration calculations, and a human-readable timestamp for logging.
    • A log message is printed with a [!!!] prefix, showing the thread ID, String ID, creation time, and String value.
  • Removing the Strong Reference:
    • The program logs the removal of the strong reference with a [!] prefix, including the thread ID, String ID, and String value.
    • The String variable is set to null, removing its strong reference. This makes the String eligible for garbage collection, as it’s now only referenced by the weak reference.
    • A log message with a [-] prefix indicates that the thread is starting to induce memory pressure.
  • Monitoring Loop for Garbage Collection:
    • The method enters an infinite loop to check if the String has been garbage collected.
    • It polls the reference queue to see if the weak reference has been enqueued, indicating the String was collected. If so:
      • It captures the end time in both milliseconds and human-readable format.
      • It calculates the duration from creation to collection in seconds, with three decimal places for precision.
      • It logs a message with an [EOF] prefix, including the thread ID, String ID, end timestamp, and duration (e.g., Thread 35 with String-2 was garbage collected at 2025-04-17T11:09:31.961383 after 242.933 seconds).
      • It breaks the loop, ending the thread’s task.
    • If the String hasn’t been collected:
      • The thread sleeps for 1 second to avoid excessive CPU usage, simulating a realistic application with intermittent activity.
      • A new list is created to hold byte arrays for memory pressure.
      • An outer loop (set to 1 iteration) contains an inner loop that allocates one 62.5KB byte array per iteration. This adds modest memory pressure to encourage garbage collection.
      • If an OutOfMemoryError occurs during allocation, the program logs an [OOM] message, clears the list to recover memory, and continues the loop.
    • The code includes a commented out call to suggest garbage collection, but it’s disabled, relying on natural JVM behavior or memory pressure to trigger GC.
  • Exception Handling:
    • If the thread is interrupted, it catches the interruption and logs an error with a [!!!] prefix, including the thread ID and String ID.
Test Hardware Description

The experimental application code MultiStringLifespanTracker.java was developed and executed on a MacbookPro M4 Max CPU and 128GB of ram

Results

For this experiment I chose to run this application with four threads, and here are the results the data revealed during my experimentation:

  • It is important to note that in cases where a strong reference to an immutable object such as a Java String is never explicitly removed, the data that the strong reference points to (or references) will remain in system memory indefinitely, never marked eligible for garbage collection and subsequent removal until cessation of application execution. This behavior underscores a critical risk that without proactively clearing these references, immutable object data persists in system memory consuming resources and increasing the likelihood of inadvertent exposure
  • Depending on the size of allocated byte arrays, Strings persisted in memory for approximately 12-358 seconds after their strong references were programmatically removed.
  • Especially in low-memory scenarios, such as on Android mobile devices, the time in which string data lingers in memory before garbage collection could extend beyond the worst case measurement during this experimentation. Furthermore, a point for consideration is that this experimental reference tracking program is simply a garbage collection benchmarking tool to demonstrate that immutable data is not immediately removed when the reference to said data is removed.

For example, consider the following terminal output demonstrating execution of the experimental program:

gimppy@HackBookProM4 ~ %  /usr/bin/env /Library/Java/JavaVirtualMachines/temurin-24.jdk/Contents/Home/bin/java --enable-preview -XX:+ShowCodeDetailsInExceptionMessages -cp /private/var/folde
rs/f3/9vcpw8bn5qd_2yy9_dzzdxy80000gn/T/vscodesws_69d50/jdt_ws/jdt.ls-java-project/bin MultiStringLifespanTracker 

[*] Starting multi-threaded String Object Strong reference tracker...


[!!!] Thread 37 with String-4 was created at 2025-08-08T23:08:37.955206 with value: SuperDuperSecret_4
[!] Thread 37 removed Strong reference to String Object String-4 with value SuperDuperSecret_4
[-] Inducing memory pressure in Thread 37

[!!!] Thread 35 with String-2 was created at 2025-08-08T23:08:37.955151 with value: SuperDuperSecret_2
[!] Thread 35 removed Strong reference to String Object String-2 with value SuperDuperSecret_2
[-] Inducing memory pressure in Thread 35

[!!!] Thread 36 with String-3 was created at 2025-08-08T23:08:37.955220 with value: SuperDuperSecret_3
[!] Thread 36 removed Strong reference to String Object String-3 with value SuperDuperSecret_3
[-] Inducing memory pressure in Thread 36

[!!!] Thread 34 with String-1 was created at 2025-08-08T23:08:37.955232 with value: SuperDuperSecret_1
[!] Thread 34 removed Strong reference to String Object String-1 with value SuperDuperSecret_1
[-] Inducing memory pressure in Thread 34

[EOF] Thread 34 with String-1 was garbage collected at 2025-08-08T23:14:35.138565 after 357.193 seconds
[EOF] Thread 35 with String-2 was garbage collected at 2025-08-08T23:14:35.166267 after 357.221 seconds
[EOF] Thread 36 with String-3 was garbage collected at 2025-08-08T23:14:35.174058 after 357.229 seconds
[EOF] Thread 37 with String-4 was garbage collected at 2025-08-08T23:14:36.110963 after 358.165 seconds

Since Java code can be interpreted by a myriad of virtual machines rather than a single, uniform runtime, each implementation can differ in heap layout, garbage collection, and string handling. As a result, the timing or memory usage benchmarks we produced and observed are for illustrative purposes only as running the same code on another JVM (or with different GC settings) could inevitably produce slightly different results even though the overarching behavior is the same. While there is nuance across virtual machines, we can confidently state that immutability leaves defined data in system memory for undeterminable amounts of time.

Implications

Let us take a second and put 358 seconds into perspective: that is almost a full six minutes. When it comes to a window for an attack, that is arguably more than enough time for an attacker to perform attacks such as the following:

Memory Dump: Memory dumping, also known as creating a core dump or system memory (random access memory/RAM) snapshot, is a fundamental technique in computer science and information security for capturing the contents of a targeted system’s volatile memory (RAM) at a given time. This process allows for inspecting runtime states, diagnosis of errors, or even recovering data from crashes across diverse platforms such as desktop and mobile operating systems. For example, if you ever held an Android device in your hand with adb access, you may already know how easy it is to dump a process’s memory once you have root permissions or a debuggable application build. A simple one line command such as the following will dump the full contents of the targeted applications heap memory segment to a file you can subsequently grep for high entropy strings. Reason being, high entropy strings often indicate cryptographic keys, passwords, or other sensitive secrets (like cryptographic material or compressed/encoded data).

adb shell cat /proc/$(ps -A | grep <APP_NAME>)/mem > /sdcard/memdump.bin

The aforementioned command requires root permissions, but this attack can still occur without root permissions if USB debugging is enabled on the device as an attacker could attach a Java debugger via the Java Debug Wire Protocol (i.e., JDWP) and read over objects stored within the heap memory section. In practice the garbage collector we measured in our test application never reclaimed the secret string for 12 – 358 seconds after the last strong reference was cleared. That means a single dump taken any time during that interval will contain the sensitive (secret) string data in plaintext.

Stack traces, crash dumps, and associated reports pose subtle but significant risks for leaking plaintext data in applications. When an exception is thrown or a crash occurs, diagnostic outputs often capture runtime state, including strings that developers assume are cleared but persist in system memory. This is particularly relevant in Java environments like Android, where immutable strings linger until reclaimed.

Malicious App (Malware): Now picture an attacker who never needs to plug a cable into the target or convince you to run a debugger. A malicious program can be dropped on any platform ranging from desktop operating systems such as Windows, macOS, and Linux to mobile operating systems such as Android, and with elevated privileges become capable of reading whatever other process’s memory it can access.

On Windows, a typical info stealing malware opens a file handle with OpenProcess(PROCESS_VM_READ) and then calls ReadProcessMemory() to pull raw bytes out of the address space of any Java application that happens to be running. On Linux and Android, the same trick is performed by opening /proc/<pid>/mem (or by attaching with ptrace(PTRACE_ATTACH) ) and streaming the heap straight to disk, typically requiring root access and potentially pausing the target process. macOS provides the analogous pair of APIs, task_for_pid plus() and vm_read_overwrite(), that let a rogue binary copy another task’s memory once it has the necessary entitlement or root privilege. Many modern malware families employ similar techniques for memory dumping attacks: they may spin up a background thread or process that wakes every few seconds, scans targeted processes for potentially sensitive data, and ships any findings to a command and control server. 

Since immutable Java Strings can linger for variable periods after the application believes it’s gone, a malicious scanner may have chances to catch the sensitive string data before the garbage collector finally discards it. In practice this means malware running on a user’s laptop can harvest sensitive data such as a saved password, an OAuth token or even a one-time code that is still cached in memory.

Operating System/Environment Exploit: Arguably the most frightening attack vector does not even need a rogue application to be executing on the affected computing system, all that is needed is a vulnerability in the operating system itself. While usually complex and uncommon, flaws in the operating system create a scenario wherein an attacker could elevate privileges. Privilege escalation vulnerabilities usually result in an attacker gaining at least kernel‑level read access to any process’s memory, turning the affected computing system into a searchable memory dumping ground. Using our experimental application as an upper bounds of time (although in production applications the timeframe could be far greater than the results of our experiment), the garbage collector kept defined sensitive strings alive for up to 358 seconds, meaning that any sensitive string data used within the last few minutes should still be present in system memory, which would be exposed in the case of a memory dump (i.e., intentional and unintentional such as stack trace). The result of such is a device wide harvest of every sensitive string (e.g., password, API key, session token, credit card number) still lingering in system memory.

Side-Channel Attacks: Not every attack needs to read memory directly. Modern CPUs leak information through their caches, speculative execution pipelines and even power consumption. An attacker who can run native code on the same device can launch a Flush+Reload or Prime+Probe timing attack against the heap region that holds the sensitive immutable String data. This is particularly relevant for cloud-based systems, where multi-tenant setups often have code from different customers running on the same hardware. By repeatedly invoking innocuous class methods as String.equals() and measuring nanosecond‑level latency differences, an attacker can infer the value of each character. A six‑minute exposure translates to hundreds of thousands of measurement cycles, more than enough to recover a typical password or other sensitive string under realistic noise conditions. The same principle applies to Spectre/Meltdown‑style transient execution attacks on ARM cores: as long as the sensitive string data remains in physical memory, speculative loads can leak it into a covert channel. Even low‑end virtual reality (VR) headsets are vulnerable to power or electromagnetic analysis; a six‑minute window gives an attacker thousands of UI cycles to capture clean traces and extract the sensitive string data.

This is not a hypothetical risk, in fact it’s arguably an overlooked attack surface that is egregiously misunderstood by application developers and security professionals alike. Experimentation demonstrated here proves that Strings do not just vanish from system memory when the application is done with them; they indeed hang around for undetermined periods of time, just waiting to be harvested.

Heap Memory Segment Analysis (Memory Dump)

Once the target Java application is running we can analyze the heap contents by getting a heap segment memory dump from the target process. This can be done through a combination of methods and tools but for this example we will use Java Development Kit tooling and Eclipse Memory Analyzer Tool installed on our test hardware system. Consider the following where in a matter of seconds an application’s heap can be dumped to file and its contents analyzed for sensitive data. This is a rudimentary example of how this looks in practice:

gimppy@HackBookProM4 ~ % jcmd -l
36448 jdk.jcmd/sun.tools.jcmd.JCmd -l
79637 com.install4j.runtime.launcher.MacLauncher
36436 MultiStringLifespanTracker
…

Using the jcmd (Java Command) tool that comes bundled with the Java JDK we list the process identifier for every Java process running on our system. As you can see in the terminal output our experimental program MultiStringLifespanTracker has a process identifier of 36436.

gimppy@HackBookProM4 ~ % jcmd 36436 GC.heap_dump ~/Desktop/MultiStringLifespanTracker_dump.hprof
36436:
Dumping heap to /Users/gimppy/Desktop/MultiStringLifespanTracker_dump.hprof ...
Heap dump file created [4928491 bytes in 0.009 secs]

With the process identifier in hand we can use the jcmd once again to dump heap contents of the target process. Our experimental program had its heap contents dumped nine thousandths of a second. This file can then be opened in the Eclipse Memory Analyzer Tool.

Once the dump file is opened we can use Object Query Language (OQL), which is an SQL-like query language to query the dump for String data. In this example we will run a query to locate strings containing the word Super that existed in the heap memory segment at the time the dump file was created.

SELECT s, s.toString() FROM java.lang.String s WHERE (s.toString().indexOf("Super") >= 0)

This OQL query searches all java.lang.String instances in the heap dump and returns each matching String object plus its text content; it filters results to only those Strings whose text contains the substring “Super” (using indexOf, which returns -1 when the substring is not present), so the query effectively lists every String object whose value includes “Super”.

Based on the rudimentary method for memory dumping you can see that the sensitive data defined in our experimental program (SuperDuperSecret_X Strings) are recoverable from the contents of the memory dump.

Risk Quantified

There you have it: a clear, evidence based breakdown of why Java Strings can linger in system memory and why it is a security headache for Java applications that can quickly turn into a security nightmare. This experimental application hammers home the urgency of understanding data type mutability and lifecycle.

Regulatory and Privacy Considerations

When sensitive data hangs around in memory longer than it should, it’s not just a security flaw, it is a regulatory and privacy ticking time bomb. Regulations like GDPR and PCI-DSS set the bar high for protecting user information, and slipping up can mean massive fines, lawsuits, or worse.

GDPR: A Compliance Nightmare

GDPR’s “data protection by design” rule (Article 25) demands that you keep data exposure to an absolute minimum. If sensitive information lingers in memory for 358 seconds as our tests showed (or longer), you’re asking for trouble. That’s more than enough time for an attacker to swoop in and grab it, turning a technical oversight into a full-blown data breach. In GDPR’s eyes, that may be considered non-compliant and the penalty could be brutal: with penalties up to 20 million Euros or 4% of your company’s global revenue, whichever is higher. Ignoring this isn’t an option.

PCI-DSS: Cardholder Data at Stake

For apps handling payment info, PCI-DSS is non-negotiable. It’s all about keeping cardholder data locked down, and while it zeroes in on storage, unprotected data in memory can still sink you. If card details are left exposed because they’re stuck in immutable String objects, an attacker could perform a memory dump and compromise everything. Fail an audit over this and you’re not just looking at penalties, you could lose the ability to process payments entirely.

Remediation

As we have discussed and demonstrated, when sensitive data like passwords and encryption keys linger in memory, it’s a security risk waiting to happen. Fortunately, there are straightforward methods to remediate this in your Java and Kotlin applications. Let’s dive into how to handle sensitive data the right way.

The Fix: Use It and Wipe It to Secure It

Use Data Fast: Declare and define your sensitive data quickly and securely. Use data types like char[ ] as it provides mutability, which means you as developers have more granular control over the life cycle of defined data within system memory. Another point to note is that any copies of the character array that are passed to another API that internally uses String data types and subsequently creates a Java string from the character array could leave sensitive data exposed, even when good immutable data hygiene is practiced.

Wipe Data Fast: Because mutable data types like char[ ] were used, the defined data can be modified, which means Java applications can be developed in such a way to overwrite sensitive data the second it’s done being used. Always explicitly invoke garbage collection by calling the System.gc() method; calling the aforementioned method tells the Java Virtual Machine (JVM) to prioritize recycling unused objects in order to make the memory they currently occupy available for quick reuse.

Secure Data Fast: The following examples are trivial illustrative code examples to demonstrate the simplicity of using mutable data types for sensitive data. As a reminder, this is an illustrative example and real world applications should never obtain sensitive data from a hardcoded string literal. Instead, the character array should be populated directly from user input (with proper validation and sanitization).

// The insecure immutable data type: Using Java String
String password = "secret";
// Setting it to null doesn’t erase it from memory immediately, it just removes the strong reference (pointer)
password = null;

// The secure mutable data type: Using char[]
char[] password = "secret".toCharArray();
// Use the password variable, then zero it out/wipe it
Arrays.fill(password, '\0'); // Data is overwritten immediately

With Strings, the defined data hangs around until the Java garbage collector decides to clean up, which could be seconds, minutes, or longer. With char[ ], you zero the data out instantly with Arrays.fill(), leaving nothing for an attacker to harvest from system memory. It’s a small change with a big security payoff.

Additional Considerations for Sensitive Data

Beyond handling data types in code, below are general recommendations for sensitive data management:

Use Secure Storage: Not all sensitive data can be wiped right away. For anything that needs to stick around, leverage the operating system’s built-in secure storage mechanisms. These are encrypted containers designed to safeguard sensitive information like cryptographic keys and passwords, often utilizing hardware-backed security on modern devices for enhanced protection against unauthorized access.

For example, on mobile operating systems:

  • Android’s KeyStore provides a secure container for cryptographic keys with hardware support via Trusted Execution Environments (TEE).
  • Apple iOS (and macOS) use Keychain Services to store keys, passwords, and certificates, integrating with the Secure Enclave Processor for hardware isolation.

And on desktop operating systems:

  • Windows offers the Data Protection API (DPAPI) and Credential Manager for encrypting and storing user credentials securely.
  • Linux distributions could use GNOME Keyring or KDE Wallet to manage encrypted secrets like SSH keys and passphrases.

When applying these concepts in code, initialization varies by platform. These available technologies not only store the data but enforce robust data security through encryption, authentication bindings/factors (e.g., biometric authentication, hardware device), and isolation which all contribute to a significantly reduced risk of exposure. If your application deals with long living sensitive data, integrating these secure storage technologies is essential, regardless of the platform. It’s just another application of defense in depth.

Do Not Write Sensitive Data to Disk: Persisting sensitive data such as passwords, API keys/token, cryptographic keys, or any other secret on permanent storage is far riskier than it may appear at surface level, even with the existence of modern day protections on operating systems such as Android. In the particular case of Android, even though the operating system isolates each application via process isolation sandboxing technology, the underlying disk (nowadays flash memory) is subject to wear‑leveling and can retain old blocks long after you think data has been overwritten; wear-leveling is a technique employed by flash memory to evenly distribute input and output (read and write) operations across memory cells of the flash memory device. 

Wear-leveling is done to increase the reliability and robustness of the flash memory disk by ensuring that no specific cells are excessively used and worn out prematurely. As such, forensic tools are able to recover those remnants. In addition, Android based devices exacerbate this risk by allowing automatic backup of application data directories; Android backup service will copy everything that lives in an application’s data directory, including SharedPreferences, stored files, and SQLite databases to the user’s Google Drive account unless the developer explicitly opts out of this feature by defining android:allowBackup=”false” in the application’s manifest XML file. This creates an additional Android specific scenario wherein sensitive data written to disk could be exposed to an attacker. 

Minimize Exposure: Declare sensitive variables as late as possible and wipe them as soon as they’re no longer needed. Less time in memory means less risk.

Keep Application Quiet: In other words limit verbosity. Never log or print sensitive data, not even in debug mode. This is an easy way to accidentally leak sensitive data resulting in security and privacy concerns. This is a common vulnerability and potential privacy violation that I regularly encounter working with Android applications.

Watch Your Libraries: Third-party libraries can be a blind spot. Verify they handle sensitive data responsibly before integrating them. Third-party code should be reviewed and security tested in the same capacity as first-party developed code.

Audit Regularly: Scan your codebase for String misuse and other vulnerabilities. Tools like Semgrep can catch issues early in your CI/CD pipeline.

What’s Next

Now that we’ve explained and demonstrated how immutable data types in Java can expose sensitive data and incorporated a deeper understanding of the garbage collector’s role in retaining heap objects, we turn to what comes next. The following list outlines our upcoming work ideologically aligned with this document:

  • Development and release of CI/CD tooling to facilitate identification of potentially sensitive data being inadvertently exposed as Java’s immutable data type String.
  • A blog post further exploring the dangers of writing sensitive data to disk, and discussion with counterarguments against guidance offered by industry tech giant, Google.

Reference

OWASP Mobile Application Security Testing Guide – Testing Memory for Sensitive Data: https://mas.owasp.org/MASTG/tests/android/MASVS-STORAGE/MASTG-TEST-0011/


文章来源: https://blog.includesecurity.com/2025/11/immutable-strings-in-java-are-your-secrets-still-safe/
如有侵权请联系:admin#unsafe.sh