Analiza bibliotek natywnych

Reading time: 12 minutes

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

Więcej informacji: https://maddiestone.github.io/AndroidAppRE/reversing_native_libs.html

Aplikacje Android mogą wykorzystywać biblioteki natywne, zwykle napisane w C lub C++, do zadań krytycznych pod względem wydajności. Twórcy malware również nadużywają tych bibliotek, ponieważ ELF shared objects są nadal trudniejsze do zdekompilowania niż kod bajtowy DEX/OAT. Ta strona koncentruje się na praktycznych procedurach i najnowszych ulepszeniach narzędziowych (2023-2025), które upraszczają analizę plików .so na Androidzie.


Szybka procedura triage dla świeżo wyciągniętego libfoo.so

  1. Wyodrębnij bibliotekę
bash
# 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. Zidentyfikuj architekturę i zabezpieczenia
bash
file libfoo.so        # arm64 or arm32 / x86
readelf -h libfoo.so  # OS ABI, PIE, NX, RELRO, etc.
checksec --file libfoo.so  # (peda/pwntools)
  1. Wypisz eksportowane symbole i powiązania JNI
bash
readelf -s libfoo.so | grep ' Java_'     # dynamic-linked JNI
strings libfoo.so   | grep -i "RegisterNatives" -n   # static-registered JNI
  1. Załaduj do dekompilera (Ghidra ≥ 11.0, IDA Pro, Binary Ninja, Hopper or Cutter/Rizin) i uruchom auto-analizę. Nowsze wersje Ghidra wprowadziły dekompiler AArch64, który rozpoznaje PAC/BTI stubs i tagi MTE, co znacząco poprawia analizę bibliotek zbudowanych z użyciem Android 14 NDK.
  2. Zdecyduj o analizie statycznej vs dynamicznej: kod pozbawiony symboli (stripped) lub obfuskowany często wymaga instrumentacji (Frida, ptrace/gdbserver, LLDB).

Dynamiczna instrumentacja (Frida ≥ 16)

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

  • thumb-relocator może teraz hookować małe funkcje ARM/Thumb wygenerowane przez agresywne wyrównanie LLD (--icf=all).
  • Enumeracja i ponowne powiązanie ELF import slots działa na Androidzie, umożliwiając patchowanie dla każdego modułu przez dlopen()/dlsym(), gdy inline hooks są odrzucane.
  • Java hooking został naprawiony dla nowego ART quick-entrypoint używanego, gdy aplikacje są kompilowane z --enable-optimizations na Androidzie 14.

Example: enumerating all functions registered through RegisterNatives and dumping their addresses at runtime:

javascript
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.

Lokalne telemetry JNI za pomocą preładowanej biblioteki .so (SoTap)

Gdy pełna instrumentacja jest przesadą lub zablokowana, nadal możesz uzyskać widoczność na poziomie natywnym przez preładowanie małego loggera wewnątrz docelowego procesu. SoTap to lekka natywna biblioteka Android (.so), która loguje zachowanie w czasie działania innych bibliotek JNI (.so) w tym samym procesie aplikacji (nie wymaga roota).

Kluczowe cechy:

  • Inicjalizuje się wcześnie i obserwuje interakcje JNI/native wewnątrz procesu, który ją ładuje.
  • Zapisuje logi używając wielu zapisywalnych ścieżek z płynnym fallbackem do Logcat, gdy zapis jest ograniczony.
  • Możliwość dostosowania źródła: edytuj sotap.c, aby rozszerzyć/dostosować, co jest logowane i przebuduj dla każdego ABI.

Ustawienie (przepakuj APK):

  1. Upuść właściwy build 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 jako pierwszy. Przykład fragmentu Smali:
smali
const-string v0, "sotap"
invoke-static {v0}, Ljava/lang/System;->loadLibrary(Ljava/lang/String;)V
  1. Przebuduj/podpisz/zainstaluj, uruchom aplikację, a następnie zbierz logi.

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

Notatki i rozwiązywanie problemów:

  • ABI alignment jest obowiązkowe. Niezgodność spowoduje UnsatisfiedLinkError i logger nie załaduje się.
  • Ograniczenia pamięci masowej są powszechne na nowoczesnym Androidzie; jeśli zapisy plików zawiodą, SoTap i tak wyemituje poprzez Logcat.
  • Zachowanie/poziom szczegółowości jest przeznaczony do dostosowania; przebuduj z źródeł po edycji sotap.c.

To podejście jest przydatne do triage malware i debugowania JNI, gdy obserwacja przepływów wywołań natywnych od startu procesu jest krytyczna, ale root/system-wide hooks nie są dostępne.


See also: in‑memory native code execution via JNI

Powszechny wzorzec ataku polega na pobraniu surowego bloba shellcode w czasie działania i wykonaniu go bezpośrednio z pamięci przez most JNI (bez ELF na dysku). Szczegóły i gotowy fragment JNI tutaj:

In Memory Jni Shellcode Execution


Recent vulnerabilities worth hunting for in APKs

RokCVEDotknięta bibliotekaUwagi
2023CVE-2023-4863libwebp ≤ 1.3.1Przepełnienie bufora na heapie osiągalne z kodu natywnego, który dekoduje obrazy WebP. Wiele aplikacji Android dołącza podatne wersje. Gdy zobaczysz libwebp.so w APK, sprawdź jego wersję i spróbuj exploitować lub załatać.
2024MultipleOpenSSL 3.x seriesKilka problemów z bezpieczeństwem pamięci i padding-oracle. Wiele bundli Flutter & ReactNative dostarcza własne libcrypto.so.

Gdy natrafisz na third-party .so w APK, zawsze porównaj ich hash z upstream advisories. SCA (Software Composition Analysis) jest rzadkie na mobile, 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 obsługiwanym krzemie ARMv8.3+. Decompilery teraz wyświetlają 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 jest kompilowanych z -fsanitize=memtag; użyj setprop arm64.memtag.dump 1 plus adb shell am start ... aby złapać błędy tagów.
  • LLVM Obfuscator (opaque predicates, control-flow flattening): commercial packers (e.g., Bangcle, SecNeo) coraz częściej chronią native code, nie tylko Java; spodziewaj się bogus control-flow i zaszyfrowanych string blobs w .rodata.

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

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

  • Usunięcie INIT_ARRAY/INIT_ARRAYSZ z tabeli DYNAMIC, tak aby loader nie wykonywał automatycznie wpisów z .init_array.
  • Rozwiązanie adresu konstruktora z RELATIVE relocations i wyeksportowanie go jako zwykły symbol funkcji (np. INIT0).
  • Zmianę nazwy JNI_OnLoad na JNI_OnLoad0, aby ART nie wywoływał jej implicitnie.

Dlaczego to działa na Android/arm64

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

Identyfikacja celu konstruktora

  • Użyj toolchainu Android NDK do dokładnego parsowania ELF na AArch64:
bash
# 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ź relokację, która trafia wewnątrz zakresu adresów wirtualnych .init_array; addend tego R_AARCH64_RELATIVE jest konstruktorem (np. 0xA34, 0x954).
  • Disasembluj wokół tego adresu, żeby to sanity check:
bash
objdump -D ./libnativestaticinit.so --start-address=0xA34 | head -n 40

Plan patchowania

  1. Usuń znaczniki DYNAMIC INIT_ARRAY i INIT_ARRAYSZ. Nie usuwaj sekcji.
  2. Dodaj a GLOBAL DEFAULT FUNC symbol INIT0 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 implicitnie.

Weryfikacja po patchu

bash
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, eksportuj 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')

Notes and failed approaches (for portability)

  • Wyzerowanie bajtów .init_array lub ustawienie długości sekcji na 0 nie pomaga: dynamiczny linker odtwarza ją 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 to niezawodny sposób.
  • Całkowite usunięcie sekcji .init_array zwykle powoduje awarię loadera.
  • Po patchowaniu adresy funkcji/układu mogą się przesunąć; zawsze przelicz konstruktor z addendów .rela.dyn w zmodyfikowanym pliku, jeśli musisz ponownie zastosować patch.

Bootstrapping a minimal ART/JNI to invoke INIT0 and JNI_OnLoad0

  • Użyj JNIInvocation, aby uruchomić niewielki kontekst ART VM w samodzielnym pliku binarnym. Następnie wywołaj ręcznie INIT0() i JNI_OnLoad0(vm) przed jakimkolwiek kodem Java.
  • Dołącz docelowe APK/classes do classpath, aby RegisterNatives znalazł odpowiednie klasy Java.
Minimal harness (CMake and C) to call INIT0 → JNI_OnLoad0 → Java method
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)
c
// 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);
}
bash
# 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 re-layout; zawsze przeliczaj je ponownie z .rela.dyn na finalnym pliku binarnym.
  • 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, to usunięcie tagów DYNAMIC INIT_ARRAY/INIT_ARRAYSZ.

Źródła

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