third_party/jni_zero/docs/safe_jni_pointers.md
Bug: Mitigate security risks due to C++ pointers in Java represented by a `long`.
Building upon the exploration and discussions in Protecting JNI C++ Pointers, this document distills the proposed ideas into a concrete solution. The design presented here was selected for offering the most robust safety guarantees with the lightest possible performance footprint. We also value ergonomics for fail-safe and developer productivity.
Currently, C++ pointers are passed across the JNI boundary as raw long values. While efficient, this practice bypasses the security protections offered by smart pointers like raw_ptr. If a Java object holds onto a long representing a native pointer, and that native pointer is later freed, the Java object now holds a dangling pointer. If this long is passed back to C++ and reinterpret_casted, it can be used to exploit a Use-after-Free (UaF) vulnerability.
This document explores solutions to ensure that C++ pointers exposed to or stored in Java are properly managed.
raw_ptr (MiraclePtr)
raw_ptr (often referred to as MiraclePtr in Chromium context) is a UaF mitigation technology. It replaces raw C++ pointers with a raw_ptr template class. When the underlying object T is freed, its memory is not immediately returned to the allocator (quarantine). This is managed by a reference count.
There are low-level APIs to manage reference counts directly.
The JNI Problem
The core problem is that a long in Java is not a smart pointer. When a native pointer is converted to a long, nothing tracks this reference.
Goals
Non-Goals
We have three categories of ownership patterns regarding C++ pointers passed to Java.
We observe all of these categories in the wild.
We propose a robust pointer management system where all pointer exchanges happen as primitive jlong values, preventing overhead from JNI object construction on the native side. The safety and ownership semantics are enforced by distinct wrapper types and automated code generation logic.
To ensure safety without sacrificing performance, we explicitly categorize JNI pointer usage into three Ownership Models and assign a specific Java wrapper class to each.
From C++ to Java
| Ownership Model | Java Class | C++ Type | Description | Responsibility | Count |
|---|---|---|---|---|---|
| 1. Owned Pointer | JniUniquePtr | JniUniquePtr | Java takes full ownership of the native object T. | Java must explicitly destroy the object. | 135 |
| 2. Long-borrowed | JniRawPtr | JniRawPtr | Java holds a reference to a native object owned by C++ (or elsewhere). The reference persists across JNI calls. | Java manages the reference lifecycle, C++ manages T's lifecycle. | 278 |
| 3. Short-borrowed | JniPtr | T* | Java borrows a pointer only for the duration of a single JNI call. Cannot be used as a return type from Native. | Automatically invalidated after the function returns. | 18 |
From Java to C++
| Ownership Model | Java Class | C++ Type | Description | Responsibility |
|---|---|---|---|---|
| Short-borrow | JniPtr | T* | C++ borrows a pointer from Java. | N/A |
This model applies when Java is responsible for the lifecycle of the native object. This is analogous to std::unique_ptr in C++.
C++ Implementation:
MakeUnique (helper defined in Detailed Implementation) creates T, and holds its pointer. (It doesn't create a std::unique_ptr, because it's meaningless now that the ownership is transferred to Java.)
jni_zero::JniUniquePtr<Foo> Foo_create(JNIEnv* env) {
// Constructs Foo and transfers the ownership to Java.
return jni_zero::MakeUnique<Foo>();
}
// No explicit Foo_destroy needed in C++ API; handled by UniquePtr logic.
Java Implementation:
The UniquePtr class ensures that when destroy() is called, the underlying native object is deleted.
As a measure to prevent memory leaks, on debug builds, we use LifetimeAssert to cause a crash when it's GCed without being destroyed. See implementation details. Same for RawPtr.
// Java
class Foo {
// Strongly typed ownership
private JniUniquePtr<NativeFoo> mHandle;
public Foo() {
// Receives ownership
mHandle = FooJni.get().create();
}
public void destroy() {
// Destroys the native object Foo
// If we forget to call it, chrome crashes when mHandle is GCed on debug builds.
mHandle.destroy();
}
}
This model applies when Java needs to hold a reference to a native object for an extended period, but does not own the object. The native object's lifecycle is managed by C++.
long.Example: Native object owns the Java object
Native owns the Java object, but passes a pointer to itself so Java can call back.
class Foo {
public:
Foo() {
// Create RawPtr and pass to Java.
// C++ does NOT keep a ref to the Foo*, only the Java object itself.
j_foo_ = Java_Foo_Constructor(jni_zero::JniRawPtr<Foo>::Create(this));
}
// ...
};
Java obtains a handle to an existing C++ object.
// Java
class Foo {
private JniRawPtr<NativeFoo> mFooHandle;
void onFinished() {
// We are done with the reference.
// destroy() decrements the refcount of Foo*, but Foo remains alive.
// If we forget to call it, chrome crashes when mFooHandle is GCed on debug builds.
if (mFooHandle != null) {
mFooHandle.destroy();
mFooHandle = null;
}
}
}
This model is for pointers that are valid only for the duration of a single JNI method call. This is the most common "pass-through" scenario.
C++ Source:
void CallsJava(Foo* foo) {
Java_MyClass_onEvent(foo);
}
Java Source:
@CalledByNative
void onEvent(JniPtr<NativeFoo> ref) {
// Safe to use within this method.
// If we capture 'ref' in a closure/field, it will throw an error when accessed later.
doSomething(ref);
}
Generated Glue Code (The Magic):
Because the parameter type is Ptr, JNI Zero generates a cleanup block automatically.
public static void onEvent(Object obj, long nativePtr) {
// 1. Wrap (Lightweight, stack-like allocation)
// JniPtrImpl is the internal implementation of Ptr (details below)
JniPtrImpl ref = new JniPtrImpl(nativePtr);
try {
// 2. Invoke
((MyClass)obj).onEvent(ref);
} finally {
// 3. Auto-Destroy (Enforced by Ptr contract)
// This invalidates the Java reference, preventing future access.
ref.release();
}
}
To maintain zero-overhead on the native side, C++ always passes pointers as primitive jlong values. However, Java methods expect typed wrapper objects (JniPtr<T>, JniRawPtr<T>, JniUniquePtr<T>).
To bridge this gap, JNI Zero generates an intermediate static Java wrapper
(glue code) for all @CalledByNative methods that use Safe JNI Pointers.
We do this in phase 2 of the implementation. In phase 1, we don't generate the
glue code. See [roll out strategy]#implementation--rollout-plan for details.
The Flow:
Comparison:
| Feature | Traditional JNI Zero | Safe JNI Pointers |
|---|---|---|
| C++ Calls | Direct JNI Reflection (e.g., CallVoidMethod) | Static Stub (e.g., CallStaticVoidMethod) |
| Target | The user's @CalledByNative method | Generated GEN_JNI or inner class wrapper method |
| Arguments | jlong pointer | Also jlong (converted to Objects in Java glue) |
| Cleanup | N/A | Automated via generated glue (try-finally destroy()) for short-borrow.Manual destroy() for long-borrow and owned pointers. |
A consequence of the generated glue code architecture is that
@CalledByNative methods using Safe Pointers can no longer be private.
private methods to be called directly.FooJni) residing in the same package. Standard Java visibility rules apply.Required Change: Methods accepting
Ptr,RawPtr, orUniquePtrmust be at least package-private. Usingprivatewill result in a Java compile-time error.
// ❌ BAD: Wrapper cannot access this
@CalledByNative
private void onEvent(JniPtr<NativeFoo> ref) { ... }
// ✅ GOOD: Package-private allows access from generated glue
@CalledByNative
void onEvent(JniPtr<NativeFoo> ref) { ... }
To ensure type safety and seamless integration across build targets, we need a
robust mechanism to map Java types (e.g., JniPtr<NativeFoo>) to their
corresponding C++ types (e.g., ::foo::Foo).
Currently, jni_zero processes files in isolation. With a straightforward
implementation, it cannot resolve NativeFoo to ::foo::Foo if NativeFoo is
defined in a different file, creating an artificial limitation where users must
colocate type definitions or use tedious manual annotations.
We will use GN Metadata to propagate type information up the dependency graph. This treats JNI types similarly to C++ headers: if you depend on the target, you can use the type definitions.
When using JniPtr<NativeFoo>, the generate_jni target aggregates metadata
from its dependencies. The code generator uses this "catalog" to resolve
NativeFoo to ::foo::Foo and generates the correct C++ glue code.
User Experience: Users simply import the generated marker class.
// Bar.java
import org.chromium.foo.Foo.NativeFoo;
void action(JniPtr<NativeFoo> handle); // Automatically maps to ::foo::Foo*
Benefits:
(See [Type parameter design discussion]#type-safety--code-generation for detailed GN implementation, architecture, and alternatives considered.)
Because the C++ entry point shifts from the user's method to the generated glue code, ProGuard rules must be updated to reflect this indirection.
-keep rule, ensuring
they are kept.void onEvent(JniPtr ref)) are no longer called directly by native code.
Foo> to long.Since RawPtr uses BRP, which depends on //base, this abstraction would need to
be guarded with "build_with_chromium", and would not be usable by other
projects.
The Java classes enforce safety through LifetimeAssert.
// Best practice:
// - Use JniPtr<T> for function parameters.
// - Use JniRawPtr<T> or JniUniquePtr<T> for return values or stored fields.
// Interface for native pointers.
public interface JniPtr<T extends JniTypeToken> {
}
// We put it as package-private within jni zero, so that devs
// can't cast Ptr to JniPtrInner.
interface JniPtrInner<T extends JniTypeToken> extends JniPtr<T> {
// Accessed by JNI generated code.
long getNativePtr();
}
// Model 1: Owns T
public interface JniUniquePtr<T extends JniTypeToken> extends JniPtr<T> {
void destroy();
static <T extends JniTypeToken> JniUniquePtr<T> createForTesting(long fakePtr) {
return new JniUniquePtrImpl<>(fakePtr, 0);
}
}
public class JniUniquePtrImpl<T extends JniTypeToken> implements JniUniquePtr<T>, JniPtrInner<T> {
// Guards against memory leaks (destroy() not called) in debug builds.
private final LifetimeAssert mLifetimeAssert = LifetimeAssert.create(this);
private long mPtr;
private long mDeleter;
// Instantiated by generated code
public JniUniquePtr(long ptr, long deleter) {
mPtr = ptr;
mDeleter = deleter; // This can be deleted in phase 2.
}
// Public API to release memory
public void destroy() {
LifetimeAssert.destroy(mLifetimeAssert);
if (mPtr == 0) throw new RuntimeException("Pointer already destroyed (Possible double free attempt)!");
long p = mPtr;
mPtr = 0;
// Calls C++ to delete the T.
UniquePtrJni.get().delete(p, mDeleter);
}
@Override
public long getNativePtr() {
if (mPtr == 0) throw new RuntimeException("Use after free!");
return mPtr;
}
}
// Model 2: Owns T* (not T).
//
// Note that `RawPtr` itself is not thread-safe. If multiple
// threads access/destroy the same RawPtr object without synchronization, a data race
// will occur.
public interface JniRawPtr<T extends JniTypeToken> extends JniPtr<T> {
void release();
static <T extends JniTypeToken> JniRawPtr<T> createForTesting(long fakePtr) {
return new JniRawPtrImpl<>(fakePtr);
}
}
public class JniRawPtrImpl<T extends JniTypeToken> implements JniRawPtr<T>, JniPtrInner<T> {
// Same lifetime assert constructs as UniquePtr are used (omitted here).
// Points to T*
private long mPtr;
// Instantiated by generated code
public JniRawPtr(long ptr) { mPtr = ptr; }
// Public API to release memory
public void release() {
if (mPtr == 0) throw new RuntimeException("Use after free!");
long p = mPtr;
mPtr = 0;
// Calls C++ to decrement T* refcount.
RawPtrJni.get().release(p);
}
@Override
public long getNativePtr() {
if (mPtr == 0) throw new RuntimeException("Use after free!");
return mPtr;
}
}
// Model 3: Short-lived wrapper.
public class JniPtrImpl<T extends JniTypeToken> implements JniPtrInner<T> {
private long mPtr;
public JniPtrImpl(long ptr) { mPtr = ptr; }
// Invalidates the reference (does NOT delete the underlying object)
// Called automatically by generated glue code.
public void release() {
mPtr = 0;
}
@Override
public long getNativePtr() {
if (mPtr == 0) throw new RuntimeException("Use after free!");
return mPtr;
}
}
namespace jni_zero {
// Corresponds to java RawPtr.
template <typename T>
class JniRawPtr {
public:
// Increment the refcount and store the ptr as is.
static JniRawPtr Create(T* ptr) {
#if PA_BUILDFLAG(USE_RAW_PTR_BACKUP_REF_IMPL)
ptr = base::internal::RawPtrBackupRefImpl<>::WrapRawPtr(ptr);
#endif
return JniRawPtr(ptr);
}
void Destroy() {
#if PA_BUILDFLAG(USE_RAW_PTR_BACKUP_REF_IMPL)
base::internal::RawPtrBackupRefImpl<>::ReleaseWrappedPtr(ptr_);
#endif
}
private:
JniRawPtr(T* ptr) : ptr_(ptr) {}
T* ptr_;
};
// Deleter implementation for JniUniquePtr<T>
// We pass a pointer to a specialized DeleterBase so that CFI works.
struct DeleterBase {
virtual void Destroy(void* ptr) = 0;
};
template <typename T>
struct TemplatedDeleter : public DeleterBase {
void Destroy(void* ptr) override {
delete static_cast<T*>(ptr);
}
};
template <typename T>
jlong GetDeleterAddress() {
static TemplatedDeleter<T> instance;
return reinterpret_cast<jlong>(&instance);
}
// Corresponds to java UniquePtr
template <typename T>
class JniUniquePtr {
private:
JniUniquePtr(T* ptr) : ptr_(ptr), deleter_(GetDeleterAddress<T>()) {}
// We pass these two addresses to Java as long.
T* ptr_;
jlong deleter_;
};
// Helper to create a JniUniquePtr.
template <typename T, typename... Args>
JniUniquePtr<T> MakeUnique(Args&&... args) {
T* t = new T(std::forward<Args>(args)...);
return JniUniquePtr(t);
}
} // namespace jni_zero
As real world examples, we have the following common cases.
Java instantiates the native object and is responsible for destroying it.
C++ Implementation:
Use jni_zero::MakeUnique<T>() to create the object and transfer ownership.
jni_zero::JniUniquePtr<Foo> Foo_create(JNIEnv* env) {
// Transfers ownership to Java.
return jni_zero::MakeUnique<Foo>();
}
// No Foo_destroy needed in C++ API; handled by UniquePtr logic.
Java Implementation:
Java receives a JniUniquePtr<NativeFoo>.
class MyClass {
private JniUniquePtr<NativeFoo> mHandle;
public void init() {
// Receives ownership
mHandle = FooJni.get().create();
}
public void close() {
// Destroys both the wrapper AND the native object Foo
mHandle.destroy();
}
}
Native owns the Java object, but passes a pointer to itself so Java can call back.
C++ Implementation:
C++ creates a JniRawPtr (which wraps T*) and passes it to Java.
class Foo {
public:
Foo() {
// Create RawPtr and pass to Java.
// C++ does NOT keep a ref to the RawPtr, only the Java object itself.
j_foo_ = Java_Foo_Constructor(jni_zero::JniRawPtr<Foo>::Create(this));
}
~Foo() {
// Tell Java to clean up its handle.
Java_Foo_destroy(j_foo_);
}
private:
ScopedJavaGlobalRef<jobject> j_foo_;
};
Java Implementation:
Java holds a JniRawPtr. It does not destroy the native object, but it must
destroy the JniRawPtr wrapper when told to do so.
class Foo {
private JniRawPtr<NativeFoo> mRawPtr;
@CalledByNative
Foo(JniRawPtr<NativeFoo> handle) {
mRawPtr = handle;
}
@CalledByNative
void destroy() {
// We own the handle (wrapper), so must destroy it.
// This decrements the Foo* refcount, but NOT Foo itself.
if (mRawPtr != null) {
mRawPtr.release();
mRawPtr = null;
}
}
}
Java obtains a handle to an existing C++ object but doesn't own it.
Java Implementation:
class Bar {
private JniRawPtr<NativeFoo> mFooHandle;
void onFinished() {
// We are done with the reference.
// destroy() decrements the Foo* refcount on C++ heap,
// but Foo remains alive.
if (mFooHandle != null) {
mFooHandle.release();
mFooHandle = null;
}
}
}
Native calls Java with a pointer, which is used only for the duration of the call.
C++ Implementation:
void CallsJava(Foo* foo) {
Java_Helper_onEvent(foo);
}
Java Implementation:
Use JniPtr<T> in the argument. The generated code handles cleanup.
@CalledByNative
void onEvent(JniPtr<NativeFoo> ref) {
// Safe to use within this method.
// If we capture 'ref' in a closure/field, it will throw UaF when accessed later.
doSomething(ref);
}
JniRawPtr and JniUniquePtr are not thread-safe and must be thread-confined (similar to base::WeakPtr) or externally synchronized (similar to raw_ptr).
We considered introducing ThreadChecker to enforce thread confinement, but it's not adopted because raw_ptr also doesn't enforce it.
Pros
Cons
Strict Annotation-driven Safety
This idea attempted to enforce safety purely through annotations (e.g., @JniOwned, @JniBorrowed) and generated code, using raw longs everywhere to completely avoid Java object allocation.
Others
See Protecting JNI C++ Pointers (internal doc) for other alternatives considered.
We will adopt a phased approach to balance implementation complexity with performance requirements.
Phase 1: Foundation & Safety (No Java Glue)
JniUniquePtr,
JniRawPtr, JniPtr) and enforce UaF protection.Phase 2: Java-side Codegen
jlong to the static glue methods.NewObject).Phase 3: Implement R8 pass to convert Ptr types to longs
JniPtr fields to long= null to = 0destroy(), which does this.mPtr = null, to be a static
invoke + sibling = 0.We will roll out directory by directory. Once a directory is migrated, enable a linter to prevent new usages of raw long pointers.
(from dirty POC)
Benchmark Results (Brya)
Amortized Performance after phase 1 (500,000 iterations)
A. Java Creation from Native Handle (Java Instantiation) Java calls Native, retrieves an object handle directly in Java.
long (Baseline): 42.8 nsPtr: 90.3 nsRawPtr: 128.4 nsUniquePtr: 132.4 nsB. Java -> Native Passing Java passes an existing wrapper object to a Native method.
long (Baseline): 23.1 nsPtr: 23.5 nsAmortized Performance after phase 2 (500,000 iterations)
A. Java Creation from Native Handle (Java Instantiation) Java calls
Native, retrieves a jlong handle, and instantiates the wrapper object in
Java.
long (Baseline): 42.8 nsPtr: 50.2nsRawPtr: 63.7 nsUniquePtr: 63.3nsB. Java -> Native Passing Java passes an existing wrapper object to a Native method.
Latency Distribution & GC Analysis (Batch size: 100)
To ensure type safety and seamless integration across build targets, we need a
robust mechanism to map Java marker types (e.g., JniPtr<NativeFoo>) to their
corresponding C++ types (e.g., ::foo::Foo*).
We propose defining the mapping natively in Java using a public nested
interface, and propagating this mapping via GN metadata. This requires zero
new GN templates and keeps the developer UX entirely within standard Java.
1. Definition (Single Source of Truth): Instead of introducing new GN
templates (like java_cpp_type) or scanning C++ headers, the developer
explicitly defines the marker inside the Java class that logically owns it.
// Foo.java
package org.chromium.foo;
import org.jni_zero.JniTypeToken;
import org.jni_zero.JniType;
public class Foo {
// Explicitly maps the Java token to the C++ type.
// This flawlessly handles generic types like "std::vector<char>"!
@JniType("::foo::Foo")
public interface NativeFoo extends JniTypeToken {}
@CalledByNative
void onEvent(JniPtr<NativeFoo> ptr) { ... }
}
2. Usage across files: Because it is a standard public interface, other
files can safely import and pass it around.
// Bar.java
import org.chromium.foo.Foo.NativeFoo;
class Bar {
@CalledByNative
void process(JniPtr<NativeFoo> ptr) { ... }
}
To allow jni_zero to resolve NativeFoo to ::foo::Foo during the isolated
parsing of downstream files (like Bar.java), we piggyback on the existing
generate_jni GN template:
generate_jni processes Foo.java, JNI Zero parses the
@JniType annotation and outputs a temporary JSON catalog (e.g.,
{"org.chromium.foo.Foo$NativeFoo": "::foo::Foo"}).generate_jni template emits this JSON file
path via GN metadata (jni_type_files = [...]).Bar.java's target adds a regular GN
deps on Foo.java's target, GN automatically aggregates the metadata
catalog. jni_zero uses this catalog to correctly resolve the C++ type
during isolated parsing.This approach requires zero new GN boilerplate, keeps IDEs happy (since the file physically exists), and crucially, establishes a strict Single Owner for Phase 2 optimizations.