unspo.nso.red

ART Adventures: Instrumenting Non-Debuggable Android Apps

03 Nov 2024 - Jeff Dileo // #Android #Java #ART #DEX #JVMTI #Instrumentation

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_api34/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/slicer8 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.

Instrumented App Screenshot

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.