Skip to content

Repository files navigation

Emulite

PyPI Python Tests

Emulite is a Python framework for emulating native Android and iOS binaries. Strong emphasis is being put towards logging, configurable profiles, strong typing, and loud explicit errors. This project is currently in alpha, breaking API changes will occur while we aim for a stable v1.0.0 release

This project was inspired by Unidbg and Chomper.

Requirements

  • Python 3.10+
  • Unicorn 2.1.4+
  • Capstone 5.0.2+
  • LIEF 0.17+
  • Cryptography 42+

Installation

From PyPI:

pip install emulite

From source:

git clone --recurse-submodules https://github.com/retrowave3/emulite.git
cd emulite
pip install -e .

Examples

Examples has a different repository, click here to be redirected

Android Usage

Use AndroidEmulator32 or AndroidEmulator64 based on the target architecture. A default Android rootfs is included. Pass a path as the first argument to use a custom rootfs.

Every hook returns a HookHandle; call unhook() when the hook is no longer needed. Replacement, trace, and memory callbacks use explicit action enums, so callback behavior is visible in both code and type checkers. Syscall hooks receive (emu, original_function): call original_function() to continue through the hook chain, or return an integer directly to handle the syscall.

import logging
from collections.abc import Callable

from emulite import AndroidEmulator64, AndroidEmulatorBase, AndroidProfile, JniHandler, JniValue
from emulite import MemoryAccess, MemoryHookAction, ReplacementAction, TraceAction, TraceInfo
from emulite.abi.enums.native_type import NativeType
from emulite.abi.native_signature import NativeSignature
from emulite.android.java.lang.reflect.java_method import JavaMethod


class CustomJniHandler(JniHandler):
    @staticmethod
    def get_timeout() -> int:
        return 15_000

    def call_static_method(self, method: JavaMethod, args: list[object]) -> JniValue:
        if method.java_class.name == "com/example/Native" and method.name == "getValue" and method.signature == "(I)I":
            return 42
        return super().call_static_method(method, args)


def before_memcpy(emu: AndroidEmulatorBase) -> ReplacementAction:
    destination = emu.get_argument_pointer(0)
    print("memcpy destination:", destination, "size:", emu.get_argument(2))
    return ReplacementAction.CALL_ORIGINAL


def after_memcpy(emu: AndroidEmulatorBase) -> None:
    print("memcpy returned:", hex(emu.get_return_value()))


def replace_time(_emu: AndroidEmulatorBase) -> int:
    return 150000


def on_openat(emu: AndroidEmulatorBase, original_function: Callable[[], int]) -> int:
    print("openat syscall from", hex(emu.pc))
    return original_function()


def on_instruction(_emu: AndroidEmulatorBase, info: TraceInfo) -> TraceAction:
    print(info.format())
    return TraceAction.CONTINUE


def on_memory(_emu: AndroidEmulatorBase, access: MemoryAccess, address: int, size: int, value: int) -> MemoryHookAction:
    print(access.name.lower(), hex(address), size, hex(value))
    return MemoryHookAction.CONTINUE


logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(name)s: %(message)s")
profile = AndroidProfile(package_name="com.example.research", seed=1)
emu = AndroidEmulator64(profile=profile, jni_handler=CustomJniHandler())
module = emu.load_library("path/to/libnative.so")
module.call_jni_onload()

native = emu.java_class("com.example.Native")
memcpy_hook = module.hook_import("memcpy", before_memcpy, after_memcpy)
time_hook = emu.intercept("time", replace_time)
syscall_hook = emu.hook_syscall(56, on_openat)
trace = module.trace(on_instruction)

# Override a Java callback made by native code.
java_hook = emu.jni_handler.override_method("com/example/Config", "getTimeout", "()I", CustomJniHandler.get_timeout, static=True)
jni_result = native.call_static("getValue", "(I)I", 123)
java_hook.unhook()

buffer = emu.allocate_string("testing")
print(buffer.read_cstr())

watchpoint = emu.watchpoint(buffer, on_memory, length=8)
emu.map_file(guest_path="/data/local/tmp/config.json", host_path="config.json")
native_result = module.call_symbol("exported_symbol", buffer)

# For an export declared as uint64_t add(uint64_t, uint64_t):
signature = NativeSignature(NativeType.UINT64, (NativeType.UINT64, NativeType.UINT64))
typed_result = module.call_typed("add", signature, 20, 22, instruction_limit=10_000)
runtime_image = module.dump()

watchpoint.unhook()
trace.unhook()
syscall_hook.unhook()
time_hook.unhook()
memcpy_hook.unhook()
emu.free(buffer)
emu.close()

Guest Android threads run cooperatively, with their own TLS (thread-local storage) and JNI state.

iOS Usage

IOSEmulator64.load_app() accepts an unpacked .app or an .ipa, returns its native module, and keeps the emulator's configured profile. The bundled rootfs is used by default; system_runtime=True enables native Objective-C and system frameworks. Mach-O symbols retain their leading underscore. Darwin syscall hooks use non-negative BSD syscall numbers and negative Mach trap numbers, such as -int(MachTrap.HOST_SELF).

import logging
from collections.abc import Callable

from emulite import IOSEmulator64, IOSEmulatorBase, IOSProfile, MemoryAccess, MemoryHookAction
from emulite import ObjCObject, TraceAction, TraceInfo
from emulite.ios.enums.mach_trap import MachTrap


def replace_time(_emu: IOSEmulatorBase) -> int:
    return 1_700_000_000


def replace_shared_instance(emu: IOSEmulatorBase) -> ObjCObject:
    return emu.objc.require_class("Signer").call_method("alloc")


def replace_signer_value(_emu: IOSEmulatorBase) -> int:
    return 42


def on_host_self(emu: IOSEmulatorBase, original_function: Callable[[], int]) -> int:
    print("host_self Mach trap from", hex(emu.pc))
    return original_function()


def on_instruction(_emu: IOSEmulatorBase, info: TraceInfo) -> TraceAction:
    print(info.format())
    return TraceAction.CONTINUE


def on_memory(_emu: IOSEmulatorBase, access: MemoryAccess, address: int, size: int, value: int) -> MemoryHookAction:
    print(access.name.lower(), hex(address), size, hex(value))
    return MemoryHookAction.CONTINUE


logging.basicConfig(level=logging.DEBUG, format="%(levelname)s %(name)s: %(message)s")
profile = IOSProfile(bundle_id="com.example.research", executable_name="Example", seed=1)
emu = IOSEmulator64(profile=profile, system_runtime=True)
module = emu.load_app("path/to/Example.ipa")  # An unpacked .app directory also works.
objc = emu.objc

time_hook = emu.intercept("_time", replace_time)
mach_hook = emu.hook_syscall(-int(MachTrap.HOST_SELF), on_host_self)
trace = module.trace(on_instruction)
payload = emu.allocate_string("payload")
watchpoint = emu.watchpoint(payload, on_memory, length=8)
emu.map_file(guest_path="/tmp/config.json", host_path="config.json")
native_result = module.call_symbol("_sign", payload)

signer = objc.require_class("Signer")
instance_hook = objc.add_interceptor("+[Signer sharedInstance]", replace_shared_instance)
value_hook = objc.add_interceptor("-[Signer value]", replace_signer_value)
instance = signer.call_method("sharedInstance")
objc_result = instance.call_method("value")
instance_class_name = instance.class_name
state = instance.get_variable("_state")
class_header = signer.dump()
runtime_image = module.dump()

watchpoint.unhook()
trace.unhook()
value_hook.unhook()
instance_hook.unhook()
mach_hook.unhook()
time_hook.unhook()
emu.free(payload)
emu.close()

For standalone targets, use IOSEmulator64.load_library() for a dylib or load_framework() for a framework. Loading an app does not start its event loop.

Android and iOS share the same module, pointer, allocation, file, interception, tracing, watchpoint, and lifecycle primitives. String and byte arguments are borrowed for one call; use allocate_string() or allocate_bytes() when native code retains the pointer. module.call_address(offset, ...) uses a module-relative offset; emulator.call(address, ...) uses an absolute guest address.

Debugging

Debugging is disabled by default. Before a native call, use emu.attach_debugger() for the terminal console, or attach a GDB-compatible client:

from emulite import DebuggerType

debugger = emu.attach_debugger(DebuggerType.GDB, host="127.0.0.1", port=12345)
debugger.add_breakpoint(module.base + 0x1234)
result = module.call_symbol("exported_symbol")

Set host and port to your preferred listening address. The call pauses at entry. Connect GDB with target remote 127.0.0.1:12345 for the example above, or select Remote GDB in IDA, use the same host/port, and Start (F9). No extra IDA scripts are needed. Breakpoints take integer guest addresses; use runtime addresses in IDA. Call debugger.close() when finished debugging.

Crash Details

EmulatorCrashed retains a readable message and exposes pc, location, and an immutable frames tuple when that information can be collected:

from emulite import EmulatorCrashed

try:
    module.call_symbol("run")
except EmulatorCrashed as crash:
    print(crash.location)
    for frame in crash.frames:
        print(frame.format())

Virtual Filesystem Providers

Providers receive mount-relative POSIX paths:

import errno

from emulite import AndroidEmulator64
from emulite.filesystem import FileIO, FileStat, OpenFlag, RegularFileIO


class MemoryProvider:
    def __init__(self, data: bytes):
        self.data = bytearray(data)

    def open(self, path: str, flags: OpenFlag, *, mode: int = 0o666) -> FileIO | int:
        if path != "/message.txt":
            return -errno.ENOENT
        return RegularFileIO(path, self.data, writable=False, oflags=flags)

    def stat(self, path: str) -> FileStat | None:
        if path == "/":
            return FileStat.for_directory()
        if path == "/message.txt":
            return FileStat.for_file(len(self.data))
        return None

    def listdir(self, path: str) -> list[str] | None:
        return ["message.txt"] if path == "/" else None


emu = AndroidEmulator64()
provider = MemoryProvider(b"hello from Python")
emu.mount("/virtual", provider)
contents = emu.read_file("/virtual/message.txt")
emu.unmount("/virtual", provider)
emu.close()

Guest Snapshots

Android supports restoring guest checkpoints:

checkpoint = emu.snapshot()
first = module.call_symbol("exported_symbol")
emu.restore(checkpoint)
second = module.call_symbol("exported_symbol")

Snapshots restore CPU and guest memory on the emulator that created them. They do not restore Python objects, hooks, or open files. iOS snapshots support inspection only; restoring them is unsupported.

Project Status

Supported Platforms

Platform ARM32 ARM64
Android ✅ AndroidEmulator32 ✅ AndroidEmulator64
iOS ✅ IOSEmulator64

Supported Engines

Backend Engine Status
Unicorn
Dynarmic
Apple Silicon Hypervisor
Linux KVM Hypervisor

✅ Supported · ⏳ Planned · — Not planned

Resources

About

Emulation framework for Android & iOS native libraries

Topics

Resources

Stars

23 stars

Watchers

0 watching

Forks

Releases

Packages

Used by

Contributors

Languages