Disclaimer: The views and opinions expressed in this post are solely those of the author and do not necessarily reflect the views or positions of their employer.
tl;dr It is possible to instrument DEX bytecode in non-debuggable apps on modern Android. In this post, I walk through the painful process of getting it working, and without loading an agent.
ART TI
Ever since Android 8.0, the Android Runtime (ART) has supported the loading of JVMTI
agents via the ART Tooling Interface (ART TI).
The JVMTI API powers basically all standard Java “debugging” capabilities, from
stepping through code to iterating loaded objects, most Java “debuggers” are
merely thin veneers on top of the JVMTI API that expose a socket and a
“protocol” for a debugger client. But the API is also the entire underpinning
of the java.lang.instrument
API. In fact, -javaagent:<path/to/jar>
is basically just an alias
for -agentlib:instrument=<path/to/jar>
. This [Java] API is what is
primarily used to perform Java bytecode instrumentation by “Java Agents”.
In the case of JVMTI on Android, bytecode instrumentation is done using DEX bytecode, not Java bytecode, since that is the lingua franca of ART. This can be easily verified by reviewing the CTS tests for ART TI:
curl -s 'https://android.googlesource.com/platform/cts/+/b2aeaea4018e701214d3ff7825c6096d6ca252b3/hostsidetests/jvmti/redefining/app/src/android/jvmti/cts/JvmtiRedefineClassesTest.java?format=TEXT' \
| base64 -d | grep -A13 'ONLOAD_FINAL_CLASS =' \
| grep -E '^\s{12}"' \
| sed -e 's/^\s\+"//g' -e 's/" +$//g' -e 's/");//g' \
| base64 -d | file -
/dev/stdin: Dalvik dex file version 035
Aside from some very foolish recent proposals to disable the Java runtime attach API by default in OpenJDK, Java agents have a long history of being used to observe and analyze critical problems live in production and facilitate hot patching of critical flaws, in addition to being heavily used to instrument observability into arbitrary application code.
WRT runtime attach, what they should do is mimic the sane yama ptrace_scope
setting allowing only the parent process or root to attach, but
shouganaiyo-loader
will work regardless.
Unfortunately, Android does not implement the java.lang.instrument
API for
Java (/Kotlin/etc.) application code, nor any equivalent, and it tries very hard
to only expose the JVMTI on apps with the android:debuggable
attribute set to "true"
:1
An agent may only be attached to a running app that’s marked as debuggable (part of the app’s manifest, with attribute android:debuggable set to true on the app node). Both the ActivityManager class and the ART perform checks before allowing an agent to be attached. The ActivityManager class checks the current app information (derived from the PackageManager class data) for the debuggable status, and the runtime checks its current status, which was set when the app was started.
To be fair, android:debuggable
is a fairly
risky attribute,
because it allows users to debug apps on their own devices and a lot of app
developers really don’t like that.
FWIW, I’m not too concerned about juice jacking dumping banking creds by debugging via adb access, since with adb they could just uninstall/hide the real apps and install a fake version in their place.
To be honest, I don’t see why the functionality has to be entirely gated on
being debuggable in the first place, since it would not be a security risk to
apps for them to load their own agents via
android.os.Debug.attachJvmtiAgent()
.
Now, the real question is whether or not the real reason why ART TI is
gated on being debuggable is due to performance or instability (and by
extension possible security risks). In desktop/server JVMs such as HotSpot and
J9, the JVMTI and java.lang.instrument
APIs are such fundamental parts of the
JVM that significant engineering has been done to ensure that it is entirely
stable, safe, and performant to arbitrarily update the bytecode. Android not
only does not have such a history, but its optimization model is entirely
different. ART uses AOT, interpretation, JIT, and profile-guided AOT based on
JIT profiling data.2
Due to the complexities of how an app may be loaded, it may not be stable for individual classes that are being executed from AOT compiled native shared objects linked against the ART support runtime to simply shift to interpretation or JITing. For example, if there aren’t sufficient sequence points in an optimized call from ClassA (AOT) to ClassB (AOT), how is ClassA (AOT) supposed to know to call into ClassB (redefined/retransformed, interpreted/JITed)? From my research, I suspect that this is one of the core problems in ART’s implementation limiting wider bytecode instrumentation support, but we’ll get to that later. For now, let’s start getting ourselves some forbidden JVMTI access.
Getting ART TI, The Hard Way
So we know that one can normally load a JVMTI agent on android through
android.os.Debug.attachJvmtiAgent()
or via adb
. But I’d rather not
deal with the complexity of that since, while calling the internals of
attachJvmtiAgent()
wouldn’t be particularly hard, I’d prefer to
take a simpler and less noisy approach to bypassing the hidden_api
34/dlopen(3)
56
checks than using sun.misc.Unsafe
for everything. We can save that for
another post. Instead, let’s take a different page out of
my previous post on Java 17
and reuse a JNI library as a JVMTI agent.
Now, as much as I personally am not a fan of the hidden_api
approach, I
get why it’s there. It’s there to prevent people from relying on APIs that
are unstable or a private implementation detail that may not be there in
the future and then crashing as a result. But the goal of this post is to
demonstrate the JVMTI on non-debuggable Android apps to argue for making it
a stable public API, so we’ll just have to bypass the hidden_api
checks.
However, I am extremely not a fan of the common approaches to bypassing the
hidden_api
. They tend to focus on getting a handle on the internal
dlopen
/dlsym
implementations, and from there disabling the hidden_api
checks by messing with its in-memory configuration. This is sort of fine,
but to get there, they all spend too much effort writing very brittle,
implementation specific bypasses, like relying on g_soinfo_handles_map
being
linked in on load to get dlopen
/ART symbols, or crawling around in Java
object memory via the JNI to get handles.
Instead, we’re going to do the most portable thing, and realize that we have
all the addresses we need from /proc/self/maps
.
Dumping /proc maps from Kotlin
// Copyright 2024 Google LLC.
// SPDX-License-Identifier: Apache-2.0
val lines = java.nio.file.Files.readAllLines(java.nio.file.Paths.get("/proc/self/maps"))
var lib = ""
for (line in lines) {
if (line.endsWith("/apex/com.android.art/lib64/libart.so")) {
lib = line
break
}
}
val base = lib.split("-")[0].toLong(16)
Dumping /proc maps from C++
// Copyright 2024 Google LLC.
// SPDX-License-Identifier: Apache-2.0
int dump_maps(size_t n, char const** paths, void** out) {
int fd = open("/proc/self/maps", O_RDONLY);
if (fd < 0) {
return -1;
}
char buf[1028];
char buf2[1028];
char* pos = buf;
char* nl = nullptr;
int r = read(fd, buf, sizeof(buf)-1);
if (r < 0) {
return -1;
}
buf[r] = '\0';
size_t len = r;
size_t rem = len;
nl = strchr(buf, '\n');
if (nl == nullptr) {
return -1;
}
int c = 0;
while (true) {
nl = strchr(pos, '\n');
if (nl == nullptr) {
rem = len - (pos-buf);
memmove(buf, pos, rem);
pos = buf;
r = read(fd, &pos[rem], sizeof(buf)-1-rem);
rem = sizeof(buf)-rem;
nl = strchr(pos, '\n');
if (nl == nullptr) {
return c;
}
}
*nl = '\0';
for (int i = 0; i < n; i++) {
char const* path = paths[i];
void* out_addr = out[i];
if (out_addr) continue;
char* p = strstr(pos, path);
if (p) {
void* start_addr;
sscanf(pos, "%p", &start_addr);
c += 1;
out[i] = start_addr;
break;
}
}
if (nl == &buf[sizeof(buf)-1]) {
continue;
}
pos = nl+1;
}
return c;
}
void foo() {
char const* libart_path = "/apex/com.android.art/lib64/libart.so";
char const* paths[1] = {libart_path};
void* addrs[1] = {nullptr};
dump_maps(1, paths, addrs);
}
With those base addresses in hand, we can now start parsing shared libraries ourselves, find the offsets for symbols we’re interested in, and apply them to the already loaded versions of the libraries in memory.
Simple ELF Symbol Walker Example
// Copyright 2024 Google LLC.
// SPDX-License-Identifier: Apache-2.0
typedef struct mapped_file {
char* mem_start;
char* start;
size_t size;
char const* path;
int fd;
ElfW(Sym) *symtab;
ElfW(Sym) *dynsym;
char *strtab;
size_t symtab_entries;
size_t dynsym_entries;
} mapped_file_t;
void unmap_file(mapped_file_t* m) {
munmap(m->start, m->size);
close(m->fd);
free(m);
}
mapped_file_t* map_file(char* mem_start, char const* path) {
int fd = open(path, O_RDONLY);
if (fd < 0) return nullptr;
struct stat sb = {0};
if (fstat(fd, &sb) == -1) {
close(fd);
return nullptr;
}
char *start = (char*)mmap(NULL, sb.st_size, PROT_READ, MAP_PRIVATE, fd, 0);
mapped_file_t* m = (mapped_file_t*)malloc(sizeof(mapped_file_t));
m->mem_start = mem_start;
m->start = start;
m->size = sb.st_size;
m->path = path;
m->fd = fd;
ElfW(Ehdr) *ehdr = (ElfW(Ehdr)*) start;
if (memcmp(ehdr->e_ident, ELFMAG, SELFMAG) != 0 ||
#if defined(__LP64__)
ehdr->e_ident[EI_CLASS] != ELFCLASS64) {
#else
ehdr->e_ident[EI_CLASS] != ELFCLASS32) {
#endif
return nullptr;
}
ElfW(Shdr) *shdr = (ElfW(Shdr)*) (start + ehdr->e_shoff);
ElfW(Shdr) *symtab_shdr = nullptr, *strtab_shdr = nullptr, *dynsym_shdr = nullptr;
for (int i = 0; i < ehdr->e_shnum; i++) {
if (shdr[i].sh_type == SHT_SYMTAB) {
symtab_shdr = &shdr[i];
} else if (shdr[i].sh_type == SHT_STRTAB && i != ehdr->e_shstrndx) {
strtab_shdr = &shdr[i];
} else if (shdr[i].sh_type == SHT_DYNSYM) {
dynsym_shdr = &shdr[i];
}
}
if (!strtab_shdr || (!symtab_shdr && !dynsym_shdr)) return nullptr;
ElfW(Sym) *symtab = nullptr;
if (symtab_shdr) {
symtab = (ElfW(Sym) *) (start + symtab_shdr->sh_offset);
size_t symtab_entries = symtab_shdr->sh_size / symtab_shdr->sh_entsize;
m->symtab_entries = symtab_entries;
} else {
m->symtab_entries = 0;
}
m->symtab = symtab;
ElfW(Sym) *dynsym = nullptr;
if (dynsym_shdr) {
dynsym = (ElfW(Sym) *) (start + dynsym_shdr->sh_offset);
size_t dynsym_entries = dynsym_shdr->sh_size / dynsym_shdr->sh_entsize;
m->dynsym_entries = dynsym_entries;
} else {
m->dynsym_entries = 0;
}
m->dynsym = dynsym;
m->strtab = (char *) (start + strtab_shdr->sh_offset);
return m;
}
void* map_dlsym(mapped_file_t* m, char const* symname) {
for (int i = 0; i < m->symtab_entries; i++) {
if (strcmp(m->strtab + m->symtab[i].st_name, symname) == 0) {
return m->mem_start + m->symtab[i].st_value;
}
}
for (int i = 0; i < m->dynsym_entries; i++) {
if (strcmp(m->strtab + m->dynsym[i].st_name, symname) == 0) {
return m->mem_start + m->dynsym[i].st_value;
}
}
return nullptr;
}
Since libart.so
is already loaded and we would rather have ART itself load
libopenjdkjvmti.so
, we don’t actually need to invoke dlopen(3)
/dlsym(3)
.
If you need to load shared libraries that are not already loaded you could do
something like the following to get the internal dlopen(3)
:
// Copyright 2024 Google LLC.
// SPDX-License-Identifier: Apache-2.0
char const* linker64 = "/apex/com.android.runtime/bin/linker64";
char const* paths[1] = {linker64};
void* addrs[1] = {nullptr};
if (dump_maps(1, paths, addrs) == 1) {
void*(*__loader_dlopen)(const char*, int, void*) = reinterpret_cast<void* (*)(const char*, int, void*)>(
map_dlsym(m, "__dl___loader_dlopen");
);
}
Now that we have our own dlsym(3)
for libart.so
, we’re going to use it a
bit. First, we’ll fetch the art::Runtime::instance_
variable that is
returned by art::Runtime::Current()
. Then we will forcibly enable debugging.
Lastly, we will invoke art::Runtime::EnsurePluginLoaded()
to load
libopenjdkjvmti.so
as zygote does not preload it, and mitotic Android processes
do not load it by default either.
// Copyright 2024 Google LLC.
// SPDX-License-Identifier: Apache-2.0
// art::Runtime::instance_
void* instance_ = *(void**) map_dlsym(m, "_ZN3art7Runtime9instance_E");
// art::Dbg::IsJdwpAllowed()
bool (*IsJdwpAllowed)() = reinterpret_cast<bool (*)()>(
map_dlsym(m, "_ZN3art3Dbg13IsJdwpAllowedEv")
);
// art::Dbg::SetJdwpAllowed(bool)
void (*SetJdwpAllowed)(bool) = reinterpret_cast<void (*)(bool)>(
map_dlsym(m, "_ZN3art3Dbg14SetJdwpAllowedEb")
);
// art::Runtime::SetRuntimeDebugState(art::Runtime::RuntimeDebugState)
void (*SetRuntimeDebugState)(void*,int) = reinterpret_cast<void (*)(void*,int)>(
map_dlsym(m, "_ZN3art7Runtime20SetRuntimeDebugStateENS0_17RuntimeDebugStateE")
);
// art::Runtime::EnsurePluginLoaded(char const*, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> >*)
bool (*EnsurePluginLoaded)(void*, const char*, std::string*) = reinterpret_cast<bool (*)(void*, const char*, std::string*)>(
map_dlsym(m, "_ZN3art7Runtime18EnsurePluginLoadedEPKcPNSt3__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEE")
);
if (!IsJdwpAllowed()) {
SetJdwpAllowed(true);
}
SetRuntimeDebugState(instance_, 2 /*kJavaDebuggableAtInit*/);
std::string err = std::string{};
EnsurePluginLoaded(instance_, "libopenjdkjvmti.so", &err);
Now that we’ve tricked ART into loading its JVMTI functionality, we can use
the JNI JavaVM->GetEnv()
API to get a handle on it:
// Copyright 2024 Google LLC.
// SPDX-License-Identifier: Apache-2.0
#include <jni.h>
#include "jvmti.h"
void do_all_the_stuff_from_earlier();
jint JNI_OnLoad(JavaVM *_vm, void *reserved) {
jvmtiEnv* jvmti = nullptr;
bool isArtTi = false;
do_all_the_stuff_from_earlier();
if (vm->GetEnv((void**)&jvmti, JVMTI_VERSION_1_2) != JNI_OK) {
if (vm->GetEnv((void**)&jvmti, 0x70010200 /*kArtTiVersion*/) == JNI_OK) {
isArtTi = true;
}
}
if (jvmti != nullptr) {
//...
}
return JNI_VERSION_1_6;
}
You may have noticed that fallback call of GetEnv
with 0x70010200
. That is
a special constant that the ART TI uses for a partial JVMTI implementation
handle with limited APIs when ART can’t provide its default JVMTI capabilities.
The key difference is that all bytecode modifying capabilities are disabled
(also can_pop_frame
and can_force_early_return
). But stepwise debugging and
breakpoints should work fine.
While one could try to manually probe for the list of supported capabilities,
you should just check out the list in art_jvmti.h
.
It is worth noting that because not all classes can be instrumented, such as
those that are part of the base platform and AOT compiled in the system image,
ART intrinsically does not support the can_redefine_any_class
or
can_retransform_any_class
. Lástima.
A Wild JVMTI Appeared!
Now that we have a JVMTI client handle, we can start doing all the things. But first, we have to follow some etiquette. First we’ll need to request the some capabilities. The following should be more than enough for now.
// Copyright 2024 Google LLC.
// SPDX-License-Identifier: Apache-2.0
jvmtiCapabilities caps;
caps = {
.can_get_bytecodes = 1,
.can_redefine_classes = 1,
.can_get_source_file_name = 1,
.can_get_line_numbers = 1,
.can_retransform_classes = 1
};
if (jvmti->AddCapabilities(&caps) == JVMTI_ERROR_NONE) {
//...
}
Next, we’ll want to register a ClassFileLoadHook
callback function.
This callback is basically the JVMTI equivalent of the
java.lang.instrument.ClassFileTransformer
interface.
// Copyright 2024 Google LLC.
// SPDX-License-Identifier: Apache-2.0
jvmtiEventCallbacks cb{
.ClassFileLoadHook = MyTransformerCallback,
};
jvmtiError e = jvmti->SetEventCallbacks(&cb, sizeof(cb));
Per the JVMTI API, the signature should match the following:7 (click to expand)
void JNICALL
ClassFileLoadHook(jvmtiEnv *jvmti_env,
JNIEnv* jni_env,
jclass class_being_redefined,
jobject loader,
const char* name,
jobject protection_domain,
jint class_data_len,
const unsigned char* class_data,
jint* new_class_data_len,
unsigned char** new_class_data)
Once we’ve registered our callback, we can now go about kicking off some
bytecode instrumentation. The first part of this is enabling class file load
hook events to be captured by our JVMTI client handle, which will cause our
ClassFileLoadHook
transformer callback to be invoked. The next step is to pick the classes
we want to run through our transformer. This is a bit trickier due to how
many non-transformable classes there are, so in general, you’ll probably
use one of two patterns; picking a few specific classes, or filtering through
all classes for just the modifiable ones.
// Copyright 2024 Google LLC.
// SPDX-License-Identifier: Apache-2.0
jvmtiError e = jvmti->SetEventNotificationMode(JVMTI_ENABLE, JVMTI_EVENT_CLASS_FILE_LOAD_HOOK, nullptr);
jclass res = env->FindClass("red/nso/unspo/artonist/MainActivity");
e = jvmti->RetransformClasses(1, &res);
// or
jint cnt;
jclass* klasses;
jvmti->GetLoadedClasses(&cnt, &klasses);
jclass* nklasses = (jclass*)calloc(cnt, sizeof(jclass));
int i = 0;
for (int j=0; j<cnt; j++) {
jboolean modifiable = false;
if (jvmti->IsModifiableClass(klasses[j], &modifiable) == 0) {
if (modifiable) {
nklasses[i] = klasses[j];
i++;
}
}
}
e = jvmti->RetransformClasses(i, nklasses);
e = jvmti->SetEventNotificationMode(JVMTI_DISABLE, JVMTI_EVENT_CLASS_FILE_LOAD_HOOK, nullptr);
jvmti->Deallocate(reinterpret_cast<unsigned char*>(klasses));
free(nklasses);
Acts of Dextrocity
So now we know how to kick off bytecode transformation, but what does the transformation itself look like? So that depends. Normally, one would use a bytecode instrumentation library like Byte Buddy, but all of the decades of Java bytecode instrumentation support were targeted at Java bytecode, not Dalvik (or DEX) bytecode. At the moment, there isn’t much organic support around DEX. Because Android’s bytecode instrumentation has been artificially restricted to debugging, it has primarily only been used for hot reloading of code by IDEs to save time compared to rebuild/reinstall cycles. Due to this, it is likely that most users of the JVMTI on Android don’t really do that much actual instrumentation and instead are simply rebuilding Java/Kotlin to Java bytecode, converting that to DEX, and then swapping the new code in-place with no regard to the DEX that was already loaded.
However, of the few projects out there that do manipulate DEX bytecode,
they pretty much all use AOSP’s own dexter/slicer
8 library.
However, for some reason, wherever I look, everyone has manually
vendored years old versions of the source code into their repos.
It’s a git repo people, just add a git submodule, it’s not that hard.
$ git submodule add https://android.googlesource.com/platform/tools/dexter <path>
Anyway, while the slicer
code is commented, it’s not really “documented.”
Overall, it does kind of remind me of the ASM
library, but for DEX, and in C++ instead of Java, though it does come with some
“outside-in” APIs
for placing entry hooks, exit hooks, and “detour” hooks (replace specific
opcodes with alternate bytecode) as opposed to ASM’s “inside-out” approach.
Anyway, I haven’t had a lot of time to play around with slicer
, so let’s
just do something simple for now to prove that our JVMTI-based instrumentation
works. In the below code implementing our “simple” ClassFileLoadHook
transformer callback, we filter for a specific class, do a string replace over
a copy the raw bytes of the class’ DEX bytes, and then tell the JVMTI to use
that modified copy as the new version of the class.
// Copyright 2024 Google LLC.
// SPDX-License-Identifier: Apache-2.0
...
// dex::Header / dex::ComputeChecksum
#include <slicer/dex_format.h>
...
static void MyTransformerCallback(jvmtiEnv* jvmti, JNIEnv* env,
jclass classBeingRedefined,
jobject loader, const char* name, jobject protectionDomain,
jint classDataLen, const unsigned char* classData,
jint* newClassDataLen, unsigned char** newClassData) {
if (strcmp(name, "red/nso/unspo/artonist/MainActivity") != 0) {
return;
}
std::string desc(classNameToDescriptor(name));
std::string fqcn(DescriptorToFQCN(desc));
char const* key = "Hello from java! I really hope no one messes with this string.";
char const* rep = "Hello from jni/jvmti/dex! It's mine now buddy. ";
char* pos = (char*)memmem(classData, classDataLen, key, strlen(key));
if (pos != nullptr) {
jint ncdl = classDataLen;
char* ncd = (char*)malloc(ncdl);
memcpy(ncd, classData, ncdl);
char* npos = (char*)memmem(ncd, classDataLen, key, strlen(key));
if (npos != nullptr) {
memcpy((void*)npos, (void*)rep, strlen(rep));
dex::Header* header = reinterpret_cast<dex::Header*>(ncd);
header->checksum = dex::ComputeChecksum(header);
}
*newClassData = (unsigned char*)ncd;
*newClassDataLen = ncdl;
}
}
But there are some caveats to be aware of. First, because DEX uses checksums, we need to recompute the checksum after modifying the class’ data. Second, is that this sort of in-place edit is extremely unstable in DEX because DEX for some reason requires that all strings are stored sorted in one block. If we were to change the first letter of the replacement string, we would have had to make a significant regeneration of the entire DEX to account for reordering the strings.
slicer
’s DEX verifier does not check for this and is significantly less
thorough than the DEX parser/validator used in ART itself.
At this point, our polyglot JNI-JVMTI “agent” has enough to get going and
we just need to load it via System.loadLibrary()
from Java/Kotlin. However,
I would recommend using Java for now as there is still one insidious gotcha
waiting for us.
Tying Up Loose Classes
If you were to run the above JNI/JVMTI code from a debuggable Android app,
it would work perfectly fine. However, we want to run it from a debuggable false
app. The problem one will find with such an app is that when the ART TI is
tricked into running with bytecode instrumentation enabled on debuggable false
apps, certain additional constraints are enforced more strictly. One of those
is that DEX transformations can only be applied to classes that are the sole
class within their respective classes.dex
file.9 While we could simply
dynamically load a target class via DexClassLoader
,10 the most real
test is to instrument the main Activity
class of one’s own app as it can
confirm to us that an already loaded (and partially initialized if loading the
JNI library from a static {}
type initializer block) class loaded from an APK
can have its modifications be applied.
However, we now have a slight chicken-and-egg problem. If we place our main
Activity class into a secondary multidex classesN.dex
file, our app will likely
fail to start as a core class is missing from the main classes.dex
file.
Also, the modern Android Gradle plugin has made manipulating DEX class lists a pain
and apparently no longer exposes the ability to set d8
CLI options.
So the first step is to eliminate all other dependencies in the app. This means
getting rid of AppCompat, Material(Design), and ConstraintLayout. The first is
the easiest, just make your main Activity a direct subclass of android.app.Activity
and replace all usages of generated AppCompat binding classes with the standard
resID
-based APIs like findViewById()
; then comment out viewBinding true
in app/build.gradle
. Next, we can drop ConstraintLayout by
swapping out its uses in our layout XML with LinearLayout
and updating it
accordingly to remove ConstraintLayout specific attributes. Dropping Material
was oddly tricky, but what worked for me was simply removing all <style>
s from
any themes.xml
files.
Doing all of this gets us down from four classes.dex
files down to one
classes.dex
file containing 9 classes.
The Android SDK’s dexdump
is great for determining how many classes
are in your .dex
files.
$ path/to/build-tools/35.0.0/dexdump -n classes.dex | grep 'Class desc' | wc -l
9
These classes are the main Activity class, the generated R
class, and its
nested classes.
$ path/to/build-tools/35.0.0/dexdump -n classes.dex | grep 'Class desc'
Class descriptor : 'Lred/nso/unspo/artonist/MainActivity;'
Class descriptor : 'Lred/nso/unspo/artonist/R$color;'
Class descriptor : 'Lred/nso/unspo/artonist/R$drawable;'
Class descriptor : 'Lred/nso/unspo/artonist/R$id;'
Class descriptor : 'Lred/nso/unspo/artonist/R$layout;'
Class descriptor : 'Lred/nso/unspo/artonist/R$mipmap;'
Class descriptor : 'Lred/nso/unspo/artonist/R$string;'
Class descriptor : 'Lred/nso/unspo/artonist/R$xml;'
Class descriptor : 'Lred/nso/unspo/artonist/R;'
As you may be aware, these classes are pretty fundamental to application
loading on Android if you have an activity at all. So our first step
is to remove all uses of the R
class from our code. Unfortunately, we
can’t simply replace those instances by hardcoding the integer constants
they refer to because those are not real values. However, we can use
dynamic resource lookups instead.
// Copyright 2024 Google LLC.
// SPDX-License-Identifier: Apache-2.0
Resources r = getResources();
setContentView(r.getIdentifier("activity_main", "layout", getPackageName()));
TextView tv = findViewById(r.getIdentifier("sample_text", "id", getPackageName()));
...
TextView tv = findViewById(r.getIdentifier("sample_text2", "id", getPackageName()));
tv.setText("Hello from java! I really hope no one messes with this string.");
Doing this will remove our dependency on the R
classes, but unfortunately
the Android Gradle plugin generates and builds them into our app regardless.
However, the Android Gradle plugin is not that smart about how it handles
that generated JAR, and while we can’t delete it outright, we can create a fake
one that is empty or only contains empty directories and swap it in place for
the real one during the build.
#!/bin/sh
# Copyright 2024 Google LLC.
# SPDX-License-Identifier: Apache-2.0
/bin/sh -c 'while true; do cp fakeR.jar ./app/build/intermediates/compile_and_runtime_not_namespaced_r_class_jar/debug/processDebugResources/R.jar ; done' &
clobber_pid="$!"
./gradlew app:installDebug
kill -9 "${clobber_pid}"
With the above, we reliably win the race condition and can successfully build an APK that contains only one class and we can now successfully run our bytecode instrumentation.
Android 15
All of the above was done on Android 14, as my test device (a Pixel 8) hadn’t received the update by the time I had most of this post written. So to tempt fate, I updated the device to see where things would break.
As it turns out, in Android 15, libart.so
no longer has symbols for any of
the Ensure*Plugin*
methods (e.g. EnsurePluginLoaded
, EnsureJvmtiPlugin
,
etc.). But it does have a symbol for the method that calls EnsureJvmtiPlugin
,
art::Runtime::AttachAgent
.
I previously had not wanted to use that as it
would try to load a JVMTI agent shared object by path, and I didn’t really
want to do that, and it would be noisy to give it a fake path and have it
error out after loading the plugin. But here we are.
An interesting thing about this method is that it ends up calling art::ti::AgentSpec::DoDlOpen
,
which calls OpenNativeLibrary
,
which is effectively a dlopen(3)
that also has soft detection for the “Native-bridge” (aka “libhoudini”).
Anyway, I guess it was a good thing I already got a handle on it that I simply wasn’t using:
// Copyright 2024 Google LLC.
// SPDX-License-Identifier: Apache-2.0
// EXPORT void AttachAgent(JNIEnv* env, const std::string& agent_arg, jobject class_loader);
// art::Runtime::AttachAgent(_JNIEnv*, std::__1::basic_string<char, std::__1::char_traits<char>, std::__1::allocator<char> > const&, _jobject*)
void (*AttachAgent)(void*, JNIEnv*, const std::string&, jobject) = reinterpret_cast<void (*)(void*, JNIEnv*, const std::string&, jobject)>(
map_dlsym(m, "_ZN3art7Runtime11AttachAgentEP7_JNIEnvRKNSt3__112basic_stringIcNS3_11char_traitsIcEENS3_9allocatorIcEEEEP8_jobject")
);
Our next step is simply to make sure we actually have an address for
EnsurePluginLoaded
and, if not, call AttachAgent
instead. The only tricky
part is that art::Runtime::AttachAgent
will throw an exception if we don’t
give it a real agent path to load, so we need to catch that, which requires
pulling a JNIEnv
out of our JavaVM
.
// Copyright 2024 Google LLC.
// SPDX-License-Identifier: Apache-2.0
if (EnsurePluginLoaded != nullptr) {
std::string err = std::string{};
EnsurePluginLoaded(instance_, "libopenjdkjvmti.so", &err);
} else {
JNIEnv * env;
int r = vm->GetEnv((void **)&env, JNI_VERSION_1_6);
if (r == JNI_OK) {
AttachAgent(instance_, env, "", nullptr);
if (env->ExceptionCheck()) {
env->ExceptionClear();
}
}
}
And now everything works again on the newly released Android 15.
And it only took me an extra hour. See you all next year or whenever for whatever Android 16 changes that breaks this (if I’m feeling up to it). Later.
-
https://source.android.com/docs/core/runtime/art-ti#agent-loading ↩
-
https://cs.android.com/android/platform/superproject/main/+/main:bionic/libdl/libdl.cpp;l=86;drc=a493fe415304efd19f089cbfc7d78c9db7d7263c ↩
-
https://cs.android.com/android/platform/superproject/+/android14-qpr3-release:bionic/linker/linker.cpp;l=2112;drc=7d15d61ae5f6382ce63f5d4e9d0668ebb1185962 ↩
-
https://docs.oracle.com/javase/8/docs/platform/jvmti/jvmti.html#ClassFileLoadHook ↩
-
https://cs.android.com/android/platform/superproject/main/+/main:tools/dexter/ ↩
-
https://cs.android.com/android/platform/superproject/+/android14-qpr3-release:art/openjdkjvmti/ti_redefine.cc;l=1074;drc=8925084706b544e93c817432d44b24b25a4bd1b7 ↩
-
https://developer.android.com/reference/dalvik/system/DexClassLoader ↩