~/posts/mobile/android-runtime-restrictions-bypass.md

Android runtime restrictions: namespaces and Hidden API

Trace native-library and Hidden API restrictions through soinfo and Runtime in Android 7–9, distinguish inline field access from symbol imports, and define a controlled validation procedure.

date[31:24]
read[23:16]
8 min
cat[15:8]
Mobile
Contents
  1. 0x00Identify the layer that rejected access
  2. 0x01Follow soinfo to its namespace
  3. 0x02Namespace state affects subsequent loads
  4. 0x03Hidden API reads policy from Runtime
  5. 0x04Why two inline accessors create different dependencies
  6. 0x05Close the loop between policy changes and observations
  7. 0x06What the two paths establish
  8. 0x07References

An Android app can fail to load a system library that exists on disk, or fail to resolve a Java method that exists in its class. These failures occur at different layers: the dynamic linker checks namespaces, while Android Runtime (ART) applies its non-SDK interface policy.

The relevant Android 7–9 implementations can be traced through soinfo and art::Runtime. Romain Thomas' analysis of these paths exposes the relationship between in-process policy state and interface dependencies. Private object layouts still need to match the target build; they are not a cross-version ABI.

Identify the layer that rejected access

Android 7 introduced restrictions on app dependencies on private native libraries. The following linker error is from that version's environment; ellipses mark omitted context:

linker · historical error excerptlog
library "/system/lib64/libart.so" ... is not accessible for the namespace:
[name="classloader-namespace", ... permitted_paths="/data:/mnt/expand:..."]

An existing file, a correct path, and a matching process architecture do not guarantee a successful load. The useful clue is classloader-namespace: which namespace contains the requesting module, and which libraries does that namespace allow it to access?

Native-library access divides into four cases by library category and target API level. “Later platforms” describes the compatibility direction documented for Android 7, not a test of every current release. See the Android 7.0 behavior changes for context.

Native-library access in Android 7: historical documentation4 rows
Library category Target API level Dynamic-linker access Android 7.0 behavior Documented behavior on later platforms
Public NDK library Any Allowed Works Works
Temporarily accessible private library ≤ 23 Temporarily allowed Works with a logcat warning Runtime error
Temporarily accessible private library ≥ 24 Restricted Runtime error Runtime error
Other private library Any Restricted Runtime error Runtime error

A different failure occurs during JNI method lookup:

JNI method lookupcpp
jclass cls = env->FindClass("android/os/Debug");
jmethodID method = env->GetStaticMethodID(
    cls, "getVmFeatureList", "()[Ljava/lang/String;");

This excerpt assumes FindClass succeeded and no exception is pending. In the historical case, the second lookup returns null and raises NoSuchMethodError because of the Hidden API policy. Check the pending JNI exception as well as the return value: a null result alone does not distinguish a missing member from policy rejection.

Request Decision point Main object to inspect
Load a native library linker The requesting module's soinfo and namespace
Resolve a Java member ART Member classification, caller context, and Runtime policy

Follow soinfo to its namespace

The linker maintains a soinfo for each loaded ELF module. It records the module's name, path, load address, and namespace associations. The two relevant members follow; this excerpt does not specify their complete surrounding layout or offsets:

linker_soinfo.h · field excerptcpp
android_namespace_t* primary_namespace_;
android_namespace_list_t secondary_namespaces_;

The primary and secondary namespaces form part of the module's loading context. Looking only at the requested file path misses the caller's context.

The historical implementation keeps a handle-to-soinfo* map in g_soinfo_handles_map. Locating the object requires symbol information from that linker together with runtime image information. Keep three quantities separate: the handle, the soinfo*, and the library's load address.

The following sketch illustrates how to locate the app's JNI module. It requires version-matched definitions for handles, get_soname, and the private types; it is not a standalone implementation:

find_soinfo.cpp · illustrativecpp
for (const auto& entry : handles) {
    soinfo* info = entry.second;
    const char* name = get_soname(info);
    if (name != nullptr && wanted_name == std::string(name)) {
        return info;
    }
}
return nullptr;
  1. The map value points to soinfo. Its key is not the module's load address.
  2. Match SONAME contents. wanted_name should be a string object. The overloaded comparison between std::string and a C string already compares contents; the pitfall is comparing two raw pointer addresses.

Namespace state affects subsequent loads

The following interfaces act on the namespace associated with the requesting module:

namespace policy · historical interfacecpp
ns->set_ld_library_paths({"/system/lib64", "/system/lib"});
ns->set_isolated(false);

Search paths and the isolation flag serve different purposes. One influences file lookup; the other affects namespace access checks. Even with an absolute dlopen path, distinguish finding a file from permitting access to it.

Changing the object affects later requests that use that namespace. If several modules share it, they may all be affected. This does not change another process's linker configuration or grant access to another app's data.

Keep the library path, requesting module, and loading flags fixed during validation. Compare the dlopen result, dlerror(), and the loaded-module list, and first rule out interference from a library already in memory. Changing both the search paths and the isolation flag proves only that the combination works; testing them separately helps identify the decisive change.

Hidden API reads policy from Runtime

The relevant Android 9 call path follows. This is a logical call-path diagram, not a debugger snapshot with registers and runtime addresses. GetActionFromAccessFlags is one of the policy-decision stages:

flowchart TD
  accTitle: Android 9 Hidden API policy lookup
  accDescr: JNI method lookup passes through member-access checks and reads the Hidden API enforcement policy from the current Runtime.
  A["GetStaticMethodID"] --> B["FindMethodID"]
  B --> C["ShouldBlockAccessToMember"]
  C --> D["hiddenapi::GetMemberAction"]
  D --> E["GetActionFromAccessFlags"]
  E --> F["Runtime::Current()"]
  F --> G["GetHiddenApiEnforcementPolicy()"]

The historical policy options include no checks, warnings only, blocking dark-grey and black-list members, and blocking only black-list members. The diagram summarizes the relevant path; it does not claim that every query unconditionally executes every stage. Member flags and caller context still contribute to the decision.

Dark-grey and black-list are historical terms. Android changed its classifications and rules in later releases. For a newer system, consult the corresponding non-SDK interface restrictions.

Why two inline accessors create different dependencies

One way to obtain the instance is art::Runtime::Current(). In this implementation, it reads the static variable art::Runtime::instance_, whose mangled symbol is _ZN3art7Runtime9instance_E. Inlining the function can still leave an external-variable import and therefore a dependency on libart.so.

Another route starts with the JavaVM* passed to JNI_OnLoad. In that ART implementation it refers to an internal JavaVMExt object, which stores a Runtime* and exposes GetRuntime(). Compare the two paths:

Read a static instance
Runtime::Currentcpp
art::Runtime* runtime = art::Runtime::Current();
Read an existing object's field
JavaVMExt::GetRuntimecpp
art::Runtime* runtime =
    reinterpret_cast<art::JavaVMExt*>(vm)->GetRuntime();

The left-hand accessor inlines a static-variable access. With the correct layout, the right-hand accessor can become a field load from an existing object. Inlining removes a function call, not necessarily every symbol dependency. Searching only for exported getters or setters can therefore miss the actual field access.

Close the loop between policy changes and observations

The historical setter changes the current Runtime's member-access policy:

Hidden API policy · historical interfacecpp
runtime->SetHiddenApiEnforcementPolicy(
    hiddenapi::EnforcementPolicy::kNoChecks);

This interface sketch assumes a valid object with a matching layout. The statement alone is not evidence of a successful test. Query the same member before and after the change.

Minimal validation record4 steps
  1. 1

    Record the baseline

    Fix the OS, ART build, architecture, target API level, and member being queried. Record return values and pending JNI exceptions, and handle exceptions in the isolated test flow before continuing.

  2. 2

    Verify the state location

    Record the actual object, field, and policy. Confirm that the read and write refer to the same process and build. A missing symbol does not establish that the logic is absent.

  3. 3

    Change only the relevant policy

    Repeat the same query and compare its return value and exception. For the native-library path, also compare the load result, error text, and loaded-module list.

  4. 4

    Restore the baseline and check again

    Restore the policy or restart the test process and confirm that the original behavior returns. Store the procedure separately from the observations.

These steps define a validation procedure, not a claim that every Android 7–9 build has been tested. Establish the result through before-and-after observations with identical input on the target device.

What the two paths establish

Both paths lead to state inside the app process. Native-library loading follows soinfo to a namespace; Java member lookup follows ART to Runtime. These policies primarily manage dependencies on internal implementation details. System-service permissions, SELinux, and kernel checks operate at other layers.

Locate the rejection first, identify the object read by the decision, and then test state changes with fixed inputs. Production apps should move to public NDK and SDK interfaces rather than rely on private fields remaining stable.

References

NORMAL~/posts/mobile/android-runtime-restrictions-bypass.md§--
0%en