Reversing Native Libraries

Tip

Ucz się i ćwicz Hacking AWS:HackTricks Training AWS Red Team Expert (ARTE)
Ucz się i ćwicz Hacking GCP: HackTricks Training GCP Red Team Expert (GRTE) Ucz się i ćwicz Hacking Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Wsparcie dla HackTricks

Aby uzyskać więcej informacji sprawdź: https://maddiestone.github.io/AndroidAppRE/reversing_native_libs.html

Android apps can use native libraries, typically written in C or C++, for performance-critical tasks. Malware creators also abuse these libraries because ELF shared objects are still harder to decompile than DEX/OAT byte-code. Ta strona skupia się na praktycznych workflowach i najnowszych ulepszeniach narzędzi (2023–2025), które ułatwiają reversing plików .so na Androidzie.


Quick triage-workflow for a freshly pulled libfoo.so

  1. Extract the library
# From an installed application
adb shell "run-as <pkg> cat lib/arm64-v8a/libfoo.so" > libfoo.so
# Or from the APK (zip)
unzip -j target.apk "lib/*/libfoo.so" -d extracted_libs/
  1. Identify architecture & protections
file libfoo.so        # arm64 or arm32 / x86
readelf -h libfoo.so  # OS ABI, PIE, NX, RELRO, etc.
checksec --file libfoo.so  # (peda/pwntools)
  1. List exported symbols & JNI bindings
readelf -s libfoo.so | grep ' Java_'     # dynamic-linked JNI
strings libfoo.so   | grep -i "RegisterNatives" -n   # static-registered JNI
  1. Load in a decompiler (Ghidra ≥ 11.0, IDA Pro, Binary Ninja, Hopper or Cutter/Rizin) and run auto-analysis. Nowsze wersje Ghidry wprowadziły dekompilator AArch64 rozpoznający PAC/BTI stubs i tagi MTE, co znacznie poprawia analizę bibliotek zbudowanych za pomocą Android 14 NDK.
  2. Decide on static vs dynamic reversing: stripped, obfuscated code often needs instrumentation (Frida, ptrace/gdbserver, LLDB).

Dynamic Instrumentation (Frida ≥ 16)

Seria Frida 16 przyniosła kilka ulepszeń specyficznych dla Androida, które pomagają, gdy cel używa nowoczesnych optymalizacji Clang/LLD:

  • thumb-relocator can now hook tiny ARM/Thumb functions generated by LLD’s aggressive alignment (--icf=all).
  • Enumerating and rebinding ELF import slots works on Android, enabling per-module dlopen()/dlsym() patching when inline hooks are rejected.
  • Java hooking was fixed for the new ART quick-entrypoint used when apps are compiled with --enable-optimizations on Android 14.

Przykład: enumeracja wszystkich funkcji zarejestrowanych przez RegisterNatives i zrzucenie ich adresów w czasie wykonywania:

Java.perform(function () {
var Runtime = Java.use('java.lang.Runtime');
var register = Module.findExportByName(null, 'RegisterNatives');
Interceptor.attach(register, {
onEnter(args) {
var envPtr  = args[0];
var clazz   = Java.cast(args[1], Java.use('java.lang.Class'));
var methods = args[2];
var count   = args[3].toInt32();
console.log('[+] RegisterNatives on ' + clazz.getName() + ' -> ' + count + ' methods');
// iterate & dump (JNI nativeMethod struct: name, sig, fnPtr)
}
});
});

Frida will work out of the box on PAC/BTI-enabled devices (Pixel 8/Android 14+) as long as you use frida-server 16.2 or later – earlier versions failed to locate padding for inline hooks.

Zrzucanie natywnych bibliotek odszyfrowanych w czasie wykonywania z pamięci (Frida soSaver)

Gdy chroniony APK trzyma natywny kod zaszyfrowany lub mapuje go dopiero w czasie wykonania (packers, downloaded payloads, generated libs), podłącz Frida i zrzucaj zmapowany ELF bezpośrednio z pamięci procesu.

soSaver workflow (Python host + TS/JS Frida agent):

  • Hooks dlopen and android_dlopen_ext w celu wykrycia mapowania bibliotek w czasie ładowania i wykonuje wstępne skanowanie już załadowanych modułów.
  • Okresowo skanuje mapowania pamięci procesu w poszukiwaniu nagłówków ELF, aby wykryć moduły załadowane przez niestandardowe mapery, które nigdy nie trafiły do loader APIs.
  • Odczytuje każdy moduł blokami z pamięci i przesyła bajty przez Frida messages do hosta; jeśli region nie może zostać odczytany, wraca do odczytu z on-disk path jeśli jest dostępny.
  • Zapisuje zrekonstruowane pliki .so i drukuje statystyki ekstrakcji dla każdego modułu, dostarczając artefakty do static RE.

Uruchom (root + frida-server, Python ≥3.8, uv):

git clone https://github.com/TheQmaks/sosaver.git
cd sosaver && uv sync
source .venv/bin/activate    # .venv\Scripts\activate on Windows

# target by package or PID; choose output/verbosity
sosaver com.example.app
sosaver 1234 -o /tmp/so-dumps --debug

To podejście omija zabezpieczenia „only decrypted in RAM” poprzez odzyskanie żywego zmapowanego obrazu, pozwalając na analizę offline w IDA/Ghidra nawet jeśli kopia w systemie plików jest obfuskowana lub nieobecna.

Process-local JNI telemetry via preloaded .so (SoTap)

When full-featured instrumentation is overkill or blocked, you can still gain native-level visibility by preloading a small logger inside the target process. SoTap is a lightweight Android native (.so) library that logs the runtime behavior of other JNI (.so) libraries within the same app process (no root required).

Key properties:

  • Initializes early and observes JNI/native interactions inside the process that loads it.
  • Persists logs using multiple writable paths with graceful fallback to Logcat when storage is restricted.
  • Source-customizable: edit sotap.c to extend/adjust what gets logged and rebuild per ABI.

Setup (repack the APK):

  1. Włóż odpowiednią kompilację dla ABI do APK, aby loader mógł rozwiązać libsotap.so:
  • lib/arm64-v8a/libsotap.so (for arm64)
  • lib/armeabi-v7a/libsotap.so (for arm32)
  1. Upewnij się, że SoTap ładuje się przed innymi bibliotekami JNI. Wstrzyknij wywołanie wcześnie (np. Application subclass static initializer lub onCreate), aby logger został zainicjalizowany pierwszy. Przykład fragmentu Smali:
const-string v0, "sotap"
invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
  1. Rebuild/sign/install, run the app, then collect logs.

Log paths (checked in order):

/data/user/0/%s/files/sotap.log
/data/data/%s/files/sotap.log
/sdcard/Android/data/%s/files/sotap.log
/sdcard/Download/sotap-%s.log
# If all fail: fallback to Logcat only

Notes and troubleshooting:

  • Wyrównanie ABI jest obowiązkowe. Niezgodność spowoduje wyrzucenie UnsatisfiedLinkError i logger nie zostanie załadowany.
  • Ograniczenia przestrzeni dyskowej są powszechne w nowoczesnym Androidzie; jeśli zapisy do plików się nie powiodą, SoTap nadal będzie wypisywać do Logcat.
  • Zachowanie/poziom szczegółowości ma być dostosowywany; po edycji sotap.c przebuduj z źródeł.

To podejście jest przydatne przy triage złośliwego oprogramowania i debugowaniu JNI, gdy obserwacja przepływów wywołań natywnych od startu procesu jest krytyczna, a brak jest uprawnień root albo system-wide hooków.


See also: in‑memory native code execution via JNI

Powszechnym schematem ataku jest pobranie surowego blobu shellcode w czasie wykonywania i wykonanie go bezpośrednio z pamięci przez mostek JNI (bez ELF na dysku). Szczegóły i gotowy snippet JNI tutaj:

In Memory Jni Shellcode Execution


Recent vulnerabilities worth hunting for in APKs

RokCVEBiblioteka objętaUwagi
2023CVE-2023-4863libwebp ≤ 1.3.1Przepełnienie bufora na stercie dostępne z kodu natywnego, który dekoduje obrazy WebP. Kilka aplikacji Android zawiera podatne wersje. Gdy zobaczysz libwebp.so wewnątrz APK, sprawdź jego wersję i spróbuj przeprowadzić exploit lub załatać ją.
2024MultipleOpenSSL 3.x seriesKilka problemów z bezpieczeństwem pamięci i padding-oracle. Wiele bundli Flutter & ReactNative dołącza własne libcrypto.so.

Gdy zauważysz zewnętrzne pliki .so w APK, zawsze porównaj ich hash z biuletynami upstream. SCA (Software Composition Analysis) jest rzadko stosowane na urządzeniach mobilnych, więc przestarzałe podatne buildy są powszechne.


  • Pointer Authentication (PAC) & Branch Target Identification (BTI): Android 14 włącza PAC/BTI w bibliotekach systemowych na wspieranym sprzęcie ARMv8.3+. Decompilery teraz pokazują pseudo-instrukcje związane z PAC; do analizy dynamicznej Frida wstrzykuje trampoliny po usunięciu PAC, ale twoje własne trampoliny powinny wywoływać pacda/autibsp tam, gdzie to konieczne.
  • MTE & Scudo hardened allocator: memory-tagging jest opcjonalne, ale wiele aplikacji świadomych Play-Integrity buduje się z -fsanitize=memtag; użyj setprop arm64.memtag.dump 1 plus adb shell am start ... aby przechwycić błędy tagów.
  • LLVM Obfuscator (opaque predicates, control-flow flattening): komercyjne packery (np. Bangcle, SecNeo) coraz częściej chronią kod native, nie tylko Java; spodziewaj się fałszywych przepływów sterowania i zaszyfrowanych blobów łańcuchów w .rodata.

Neutralizing early native initializers (.init_array) and JNI_OnLoad for early instrumentation (ARM64 ELF)

Silnie chronione aplikacje często umieszczają sprawdzenia root/emulator/debug w konstruktorach natywnych, które uruchamiają się bardzo wcześnie przez .init_array, przed JNI_OnLoad i na długo przed wykonaniem jakiegokolwiek kodu Java. Możesz uczynić te niejawne inicjalizatory jawne i odzyskać kontrolę poprzez:

  • Usunięcie INIT_ARRAY/INIT_ARRAYSZ z tabeli DYNAMIC, aby loader nie wykonywał automatycznie wpisów z .init_array.
  • Wyznaczenie adresu konstruktora z relocacji RELATIVE i wyeksportowanie go jako zwykły symbol funkcji (np. INIT0).
  • Zmiana nazwy JNI_OnLoad na JNI_OnLoad0, aby zapobiec domyślnemu wywoływaniu przez ART.

Dlaczego to działa na Android/arm64

  • Na AArch64, wpisy .init_array często są wypełniane w czasie ładowania przez relocacje R_AARCH64_RELATIVE, których addend jest adresem docelowej funkcji wewnątrz .text.
  • Bajty .init_array mogą wyglądać statycznie na puste; dynamiczny linker zapisuje rozwiązywany adres podczas przetwarzania relocacji.

Zidentyfikuj cel konstruktora

  • Użyj toolchaina Android NDK do dokładnego parsowania ELF na AArch64:
# Adjust paths to your NDK; use the aarch64-linux-android-* variants
readelf -W -a ./libnativestaticinit.so | grep -n "INIT_ARRAY" -C 4
readelf -W --relocs ./libnativestaticinit.so
  • Znajdź relocację, która trafia wewnątrz zakresu adresów wirtualnych .init_array; addend tej R_AARCH64_RELATIVE jest konstruktorem (np. 0xA34, 0x954).
  • Zdisasembluj wokół tego adresu w celu weryfikacji:
objdump -D ./libnativestaticinit.so --start-address=0xA34 | head -n 40

Patch plan

  1. Usuń tagi DYNAMIC INIT_ARRAY i INIT_ARRAYSZ. Nie usuwaj sekcji.
  2. Dodaj symbol INIT0 z atrybutami GLOBAL DEFAULT FUNC pod adresem konstruktora, aby można go było wywołać ręcznie.
  3. Zmień nazwę JNI_OnLoadJNI_OnLoad0, aby ART nie wywoływał jej domyślnie.

Validation after patch

readelf -W -d libnativestaticinit.so.patched | egrep -i 'init_array|fini_array|flags'
readelf -W -s libnativestaticinit.so.patched | egrep 'INIT0|JNI_OnLoad0'

Łatanie z LIEF (Python)

Skrypt: usuń INIT_ARRAY/INIT_ARRAYSZ, wyeksportuj INIT0, zmień nazwę JNI_OnLoad→JNI_OnLoad0 ```python import lief

b = lief.parse(“libnativestaticinit.so”)

Locate .init_array VA range

init = b.get_section(‘.init_array’) va, sz = init.virtual_address, init.size

Compute constructor address from RELATIVE relocation landing in .init_array

ctor = None for r in b.dynamic_relocations: if va <= r.address < va + sz: ctor = r.addend break if ctor is None: raise RuntimeError(“No R_*_RELATIVE relocation found inside .init_array”)

Remove auto-run tags so loader skips .init_array

for tag in (lief.ELF.DYNAMIC_TAGS.INIT_ARRAYSZ, lief.ELF.DYNAMIC_TAGS.INIT_ARRAY): try: b.remove(b[tag]) except Exception: pass

Add exported FUNC symbol INIT0 at constructor address

sym = lief.ELF.Symbol() sym.name = ‘INIT0’ sym.value = ctor sym.size = 0 sym.binding = lief.ELF.SYMBOL_BINDINGS.GLOBAL sym.type = lief.ELF.SYMBOL_TYPES.FUNC sym.visibility = lief.ELF.SYMBOL_VISIBILITY.DEFAULT

Place symbol in .text index

text = b.get_section(‘.text’) for idx, sec in enumerate(b.sections): if sec == text: sym.shndx = idx break b.add_dynamic_symbol(sym)

Rename JNI_OnLoad -> JNI_OnLoad0 to block implicit ART init

j = b.get_symbol(‘JNI_OnLoad’) if j: j.name = ‘JNI_OnLoad0’

b.write(‘libnativestaticinit.so.patched’)

</details>

Uwagi i nieudane podejścia (dla przenośności)
- Wyzerowanie bajtów `.init_array` lub ustawienie długości sekcji na 0 nie pomaga: dynamiczny linker wypełnia ją ponownie za pomocą relokacji.
- Ustawienie `INIT_ARRAY`/`INIT_ARRAYSZ` na 0 może zepsuć loader z powodu niespójnych tagów. Czyste usunięcie tych wpisów DYNAMIC jest niezawodnym dźwignią.
- Usunięcie sekcji `.init_array` w całości zwykle powoduje awarię loadera.
- Po załataniu adresy funkcji/układu mogą się przesunąć; zawsze przelicz konstruktor z addendów `.rela.dyn` w załatanym pliku, jeśli musisz ponownie zastosować patch.

Bootstrapowanie minimalnego ART/JNI, aby wywołać INIT0 i JNI_OnLoad0
- Użyj JNIInvocation, aby uruchomić niewielki kontekst ART VM w samodzielnym pliku binarnym. Następnie ręcznie wywołaj `INIT0()` i `JNI_OnLoad0(vm)` przed jakimkolwiek kodem Java.
- Dołącz docelowy APK/classes do classpath, aby każde `RegisterNatives` znalazło swoje klasy Java.

<details>
<summary>Minimalny harness (CMake i C) do wywołania INIT0 → JNI_OnLoad0 → metody Java</summary>
```cmake
# CMakeLists.txt
project(caller)
cmake_minimum_required(VERSION 3.8)
include_directories(AFTER ${CMAKE_SOURCE_DIR}/include)
link_directories(${CMAKE_SOURCE_DIR}/lib)
find_library(log-lib log REQUIRED)
add_executable(caller "caller.c")
add_library(jenv SHARED "jnihelper.c")
target_link_libraries(caller jenv nativestaticinit)
// caller.c
#include <jni.h>
#include "jenv.h"
JavaCTX ctx;
void INIT0();
void JNI_OnLoad0(JavaVM* vm);
int main(){
char *jvmopt = "-Djava.class.path=/data/local/tmp/base.apk"; // include app classes
if (initialize_java_environment(&ctx,&jvmopt,1)!=0) return -1;
INIT0();                   // manual constructor
JNI_OnLoad0(ctx.vm);       // manual JNI init
jclass c = (*ctx.env)->FindClass(ctx.env, "eu/nviso/nativestaticinit/MainActivity");
jmethodID m = (*ctx.env)->GetStaticMethodID(ctx.env,c,"stringFromJNI","()Ljava/lang/String;");
jstring s = (jstring)(*ctx.env)->CallStaticObjectMethod(ctx.env,c,m);
const char* p = (*ctx.env)->GetStringUTFChars(ctx.env,s,NULL);
printf("Native string: %s\n", p);
cleanup_java_env(&ctx);
}
# Build (adjust NDK/ABI)
cmake -DANDROID_PLATFORM=31 \
-DCMAKE_TOOLCHAIN_FILE=$HOME/Android/Sdk/ndk/26.1.10909125/build/cmake/android.toolchain.cmake \
-DANDROID_ABI=arm64-v8a ..
make

Częste pułapki:

  • Adresy konstruktorów zmieniają się po patchowaniu z powodu ponownego rozmieszczenia; zawsze przeliczaj je z .rela.dyn dla ostatecznego binarium.
  • Upewnij się, że -Djava.class.path obejmuje każdą klasę używaną przez wywołania RegisterNatives.
  • Zachowanie może się różnić w zależności od wersji NDK/loadera; krok, który był konsekwentnie niezawodny, polegał na usunięciu tagów DYNAMIC INIT_ARRAY/INIT_ARRAYSZ.

Referencje

Tip

Ucz się i ćwicz Hacking AWS:HackTricks Training AWS Red Team Expert (ARTE)
Ucz się i ćwicz Hacking GCP: HackTricks Training GCP Red Team Expert (GRTE) Ucz się i ćwicz Hacking Azure: HackTricks Training Azure Red Team Expert (AzRTE)

Wsparcie dla HackTricks