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:
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.
| 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:
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:
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:
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;- The map value points to
soinfo. Its key is not the module's load address. - Match SONAME contents.
wanted_nameshould be a string object. The overloaded comparison betweenstd::stringand 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:
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:
art::Runtime* runtime = art::Runtime::Current();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:
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.
- 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
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
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
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.