../

Running Android Code Without Android

Introduction

An Android app depends on more than its own code. It expects a working file system, system libraries, and an operating system that remembers what the app has done.

But sometimes I only want to study one native function. Starting the whole app makes that harder. I have to navigate to the right screen and recreate the same state each time.

Rehosting gives me another option. I can run a piece of native code inside a smaller environment on my computer. The interesting part is finding out how much of Android that code actually needs.

In my research, a small event-handling test exposed bugs in both my test program and a rehosting framework. Fixing them taught me more than the initial failures did.

What is rehosting?

Android apps can include native libraries, often written in C or C++. These are compiled files with a .so extension. A library built for ARM64 expects ARM64 instructions and the software interfaces around them.

A rehosting framework loads that code and supplies enough of its expected environment to run it. unidbg, for example, supports Android native libraries, ARM emulation, and parts of JNI. JNI is the interface that connects Java code to native code.

The program being run is called the guest. The computer running the framework is the host. The host still has an operating system. The framework does not need to boot a complete Android system for the guest.

This is useful for calling the same function with different inputs. I can inspect memory and follow execution without repeating the whole app flow. How well that works depends on the features the function uses.

Android native code calls into a rehosting framework, which runs on the host computer's own operating system and hardware.
The framework supplies the interfaces the guest code expects. The host still runs its own operating system.

The environment is part of the program

A function that only transforms an input buffer has a fairly small set of needs. Another function might read a file, ask Java for an object, or wait for data on a socket.

What the code needsWhat the framework must provide
Machine instructionsA way to execute the target architecture
A Java method or objectThe expected JNI behavior
A file or system callA file mapping or an implementation of that operation
A later read of earlier stateA result consistent with what already happened

That last row became the focus of my tests. Returning a plausible value once is fairly simple. Keeping several operations consistent takes more work.

Qiling provides a framework for this kind of binary emulation. It includes support for executable loading, system calls, and file systems. I used it to study what happened when a small program crossed several of those boundaries.

A small event test

I wrote a small native test program so I could check a specific behavior. It used epoll, a Linux interface for waiting on events such as a socket becoming ready to read.

When a program registers an event, it can attach a value to it. That value acts like a label. The epoll documentation explains that the value is returned with the event.

My test followed that label through registration, the information reported under /proc, and event handling. I wanted those views to agree about the same operation.

A successful registration was only the first check. The later checks asked whether the expected state had actually been created and could be used.

The first bug was mine

An early result looked like a difference between a physical Android device and an emulator. The test failed on the phone. It was tempting to treat that as a useful signal.

The problem was in my program's memory layout.

I had used a packed declaration for struct epoll_event. Packing removes padding between fields. The right layout depends on the target architecture. These rules are part of its application binary interface, usually shortened to ABI.

On ARM64, the kernel expected the event's 64-bit data field to start at byte offset 8. My packed structure put it at offset 4.

The packed structure puts the eight-byte event label at offset 4. ARM64 expects four bytes of padding and reads the label at offset 8.
The label started four bytes too early. Correcting the ARM64 layout put it where the kernel expected to find it.

The registration call could succeed while the kernel read the wrong label. My test then stopped before running the checks that depended on it.

I corrected the ARM64 layout and ran the original and corrected versions on the same phone. The sequence was original, corrected, corrected, original. The corrected version passed both times. The original failed both times.

That removed the basis for my earlier interpretation. I had found a bug in the test, and I needed to fix it before comparing environments.

Fixing the rehost

I then ran the corrected ARM64 binary on the phone and in Qiling. I kept the test binary unchanged across those runs.

The phone passed. The Qiling build I tested still failed at the label check. Its event structure handling made the same packed-layout assumption on ARM64.

Fixing that layout moved the failure to a later step. The label was now correct, but the event-readiness checks still failed. A combined local patch also corrected descriptor mapping and wait handling. That made the complete test pass twice.

EnvironmentResult with the corrected test
Physical Android devicePassed
Tested Qiling buildFailed at the label check
Qiling with the layout fixReached a later readiness failure
Qiling with the combined fixPassed

I also ran the same corrected binary in a pinned unidbg setup. It failed for a different reason. Source review showed that the required epoll handlers were missing in that build. A missing operation and an incorrectly implemented operation needed different explanations.

What I learned

The passing patch mattered as much as the failure. It showed that the difference could be repaired. I could not treat it as a permanent way to distinguish a phone from a rehost.

These results apply to the builds and test program I used. They do not show that every Android library will work after the patch. The fixes were local research changes, with no upstream acceptance claimed.

For future tests, I want to keep three habits:

  • Check the test program against a real device before trusting a difference.
  • Find the first failing operation before counting later failures.
  • Test a proposed fix with the same binary and the same expected behavior.

Rehosting lets me study native code in a smaller, more controlled setting. This experiment showed me where that control can become misleading. I now pay closer attention to the state behind a return value, and to whether my own test is asking the operating system the right question.