Post

How to protect my code from reversing - Research

.so 파일을 리버싱으로부터 보호하는 방법에 대한 연구 (Unreal/Unity)

How to protect my code from reversing - Research

악성코드가 앱에 코드를 숨기는 방법

Android 네이티브 라이브러리(.so) 리버싱은 오래전부터 꽤 높은 진입장벽을 자랑했다. ARM/ARM64 어셈블리, ELF 포맷 이해, Android linker 내부 구조, JNI 바인딩 역추적 등 알아야 할 게 한두 가지가 아니었기 때문이다.

하지만 AI 도구의 발달로 상황이 많이 달라졌다. Ghidra나 IDA Pro 의 디컴파일 결과물을 LLM 에 붙여넣으면 “이 함수가 뭘 하는 것 같아?” 한 줄이면 꽤 그럴듯한 분석이 나온다. 그렇다고 .so 리버싱이 쉬워졌다는 말은 아니다. 여전히 복잡하고, 특히 Unreal Engine 이나 Unity 같은 대형 엔진 .so 는 심볼도 거의 없고 구조도 독특해서 진입장벽이 높다.

그래서 이런 생각을 하게 됐다.

.so 을 숨길 수 있다면 어떨까?”

파일로 보이지 않으면 Ghidra에 드래그할 수도 없고, strings 로 긁을 수도 없다. 로드된 메모리를 덤프하더라도 재배치된 상태라 static 분석 도구에 바로 던져줄 수 없다.

근래 Malware 중에 Unity 기반의 IL2CPP 엔진을 사용하는 샘플을 습득하였는데 IL2CPP-Dumper 로 분석하려고 보니 .so 가 없어서 분석이 불가능했다. 정확히 말하면 libil2cpp.so 가 정상적이지 않았고 조금의 시간을 투자한 결과 거대한 쉘 안에 감싸져 있다는 걸 알았다. 그래서 “어떻게 하면 .so 를 숨길 수 있을까?” 하는 생각이 들었다.

여기서 가장 흥미로운 것은 원본 코드 플로우를 유지한 채 코드 인젝션과 은닉 기술을 이뤄낸 것이다. 다시말해 il2cpp 에 코드를 인젝션하되 정체불명의 쉘 같은 것이 il2cpp 를 감싸 어떤 것이 바뀌었는지 정적으로는 확인할 수 없게 한 것이다. 단, 라이브러리는 동일하게 로드되면서 엔진에 기생하고 있는 코드가 같이 로드된다.

해당 앱을 분석하며 어떻게 하면 .so 를 숨길 수 있을까 하는 아이디어가 떠올랐고, 이 앱을 분석하며 역으로 이를 구현하기 위한 도구를 만들어보았다. 이 글에서는 그 아이디어와 구현 과정을 공유하려고 한다.

Malware 에서 발견된 hyper-encrypt 덕분에 Android .so 파일에 대한 범용적인 보호 방법에 대한 고민을 담았다.

이 글은 Android ELF / JNI 기반 하에 진행합니다.

보호한다면 어떻게 가능할까

아이언맨 슈트를 입은 원본 .so 파일

일반적인 Android 앱에서 .so 는 APK 안의 lib/<abi>/ 경로에 존재한다. 앱이 실행되면 Android 링커가 이 파일을 mmap 으로 메모리에 올리고, dynamic section 을 파싱하고, 심볼을 resolve 하고, 생성자를 실행한다.

보호의 핵심 아이디어는 이렇다:

원본 .so 를 APK 에 직접 넣지 말고, 대신 starter(껍데기) .so 를 넣는다. starter 안에 원본 .so 의 바이트를 숨겨두고, starter 가 로드되는 시점에 직접 메모리에 재구성한다.

이렇게 되면:

  • 디스크에서 꺼낼 수 있는 파일은 starter 뿐이다.
  • 원본 라이브러리는 익명 메모리(anonymous mapping) 에만 존재한다.
  • Android 링커의 soinfo 테이블에 등록되지 않는다.
  • dlopen, dlsym 으로는 접근할 수 없다.
graph LR
    A[APK\nlib/arm64-v8a/\nlibgame.so] --> B[starter .so\n=libstarter.so]
    B --> C{Application Runtime}
    C --> D[익명 mmap\n원본 original]
    C -.->|"존재하지 않음"| E[원본 .so]

2. 설계 원칙: 깔끔한 체계를 위한 고민

본격적으로 PoC 를 위한 도구를 만들어볼까 한다. 먼저 뭔가를 만들기 전에 설계부터 깔끔하게 잡는 게 중요하다. 특히 보호 도구 같은 솔루션은 “내가 만든 NDK 프로젝트” 뿐만 아니라 다른 케이스도 생각해야 한다. 따라서 플랫폼들이 빌드한 .so (Unreal, Unity, Flutter 등)도 처리할 수 있어야 했다. 그래서 아래 원칙을 세웠다:

원칙 1 — starter 와 original 의 완전한 분리

starter(로더)는 어떤 original 라도 로드할 수 있어야 한다. original 가 NDK 빌드인지, Unreal 빌드인지, Unity 빌드인지 알 필요가 없다. 오직 ELF 스펙에만 의존한다.

원칙 2 — 공유 인터페이스로서의 Footer 구조체

starter 와 Packer(패키징 도구) 가 공유하는 정보는 딱 하나의 헤더 파일(_import_original.h)로 정의한다. 이 구조체만 알면 누구든 포맷을 이해하고 구현할 수 있다.

1
2
3
4
5
6
footer layout (48 bytes, little-endian):

┌──────────────┬──────────┬─────────────┬───────────────────────────┬────────────────┬──────────┐
│ magic (8B)   │ ver (4B) │ size (4B)   │ original_runtime_offset   │ original_size  │ reserved │
│ "SIGMAGIC"   │    1     │     48      │     (VA Offset)           │  (Packed)      │          │
└──────────────┴──────────┴─────────────┴───────────────────────────┴────────────────┴──────────┘

원칙 3 — 아키텍처 범용 처리

32비트/64비트, ARM/x86 모두 지원해야 한다. ELF 헤더의 EI_CLASS 필드로 32/64를 분기하고, 재배치 타입은 e_machine 으로 분기한다. 지원 ABI: armeabi-v7a, arm64-v8a, x86, x86_64.

원칙 4 — Packer 는 Python, Loader 는 C

패키징 자동화는 Python 으로 작성해 APK/AAB 모두 처리한다. 런타임 로더는 NDK C 로 작성한다. 두 컴포넌트가 _import_original.h 의 상수와 구조체 정의를 공유한다.

3. 전체 시스템 구조

컴포넌트는 크게 셋으로 나뉜다.

graph TB
    subgraph Build Time
        A[원본 .so\noriginal] 
        B[libstarter.so\nstarter]
        C[SoGuard.py\nPacker]
        A --> C
        B --> C
        C --> D[패치된 APK/AAB]
    end

    subgraph Runtime
        D --> E[Android Linker\nstarter 로드]
        E --> F[ensure_library_loaded\n생성자 호출]
        F --> G[Footer 탐색\nmagic = SIGMAGIC]
        G --> H[byte-array 복호화]
        H --> I[익명 mmap 할당\nPT_LOAD 복사]
        I --> J[동적 링킹\nDT_NEEDED 처리]
        J --> K[재배치 적용\nREL/RELA/APS2/RELR]
        K --> L[생성자 실행\nDT_INIT_ARRAY]
        L --> M[JNI_OnLoad 전달\nauto_register_jni_symbols]
    end

4. Packer: APK/AAB 안의 모든 .so 를 일괄 패치

SoGuard.py 는 Python zipfile 모듈로 APK/AAB 를 직접 다시 쓴다. 파일시스템에 압축 풀기 없이 ZIP 스트림을 직접 조작하는 이유는 두 가지다:

  1. 속도 — 대형 APK 에서 불필요한 I/O 없음.
  2. Windows/macOS 안전성 — 아카이브 내 경로 대소문자 충돌 문제 회피.

처리 흐름:

sequenceDiagram
    participant P as SoGuard.py
    participant Z as ZipFile
    participant M as merge_with_loader()

    P->>Z: APK/AAB 열기
    loop lib/<abi>/*.so 각각에 대해
        P->>M: (starter_so, original_so) 전달
        M->>M: 1. starter ELF 유효성 검사
        M->>M: 2. original byte-array 암호화
        M->>M: 3. starter 뒤에 original bytes append
        M->>M: 4. footer 구조체 append
        M->>M: 5. 마지막 PT_LOAD p_filesz/p_memsz 확장
        M->>M: 6. 섹션 헤더 메타데이터 재계산 (e_shoff)
        M-->>P: 병합된 bytes 반환
        P->>Z: 원본 항목 대신 병합된 bytes 로 재작성
    end
    P->>Z: APK 재서명 (apksigner)

5. Runtime Loader: 익명 메모리에서 ELF 재구성

starter 가 로드되면 생성자에서 ensure_library_loaded() 가 호출된다. 이 함수가 핵심이다.

Step 1 — Footer 탐색

starter 는 자신의 load base 주소를 알고 있다. 마지막 PT_LOAD 세그먼트 끝부터 역방향으로 "SIGMAGIC" 매직을 찾는다.

Step 2 — byte-array 복호화

암호화된 original bytes 를 복호화한다. 복호화는 세 단계로 나뉜다:

  1. ELF 헤더 복호화 (절대 오프셋 0 기준)
  2. phdr 테이블 복호화 (절대 오프셋 e_phoff 기준)
  3. PT_LOAD 세그먼트 복사 시 복호화 (절대 오프셋 p_offset 기준)

Step 3 — 익명 메모리에 세그먼트 재구성

mmap(NULL, total_size, PROT_READ|PROT_WRITE, MAP_PRIVATE|MAP_ANONYMOUS, -1, 0) 로 메모리를 할당하고, 각 PT_LOAD 세그먼트를 올바른 상대 위치에 복사한다. Android 링커가 개입하지 않는다.

Step 4 — 동적 링킹

PT_DYNAMIC 을 파싱하고 다음을 처리한다:

항목설명
DT_NEEDED의존 라이브러리 로드 (보호된 starter 도 처리)
DT_SYMTAB / DT_STRTAB심볼 테이블
DT_HASH / DT_GNU_HASH심볼 해시
DT_REL / DT_RELA일반 재배치
DT_ANDROID_REL / DT_ANDROID_RELAAndroid packed 재배치 (APS2)
DT_RELRRELR compact relative 재배치
DT_INIT_ARRAY / DT_FINI_ARRAY생성자/소멸자 배열

Step 5 — 심볼 Resolution 순서

1
2
3
4
1. 로컬 original 심볼
2. 로더 override 테이블 (dlopen, dlsym, dlclose, dladdr, dl_iterate_phdr, signal/sigaction, AAudio 콜백들, __cxa_thread_atexit_impl)
3. DT_NEEDED 핸들 목록
4. 프로세스 전역 탐색 (dlsym(RTLD_DEFAULT, ...))

Step 6 — 페이지 보호 적용

PT_GNU_RELRO 가 있으면 재배치 완료 후 해당 영역을 PROT_READ 로 변경한다.

Step 7 — JNI 등록

익명 메모리의 original 는 JVM 의 dlsym 기반 심볼 조회로 찾을 수 없다. 이를 해결하기 위해 auto_register_jni_symbols() 를 구현했다:

sequenceDiagram
    participant C as starter JNI_OnLoad
    participant P as original JNI_OnLoad
    participant J as JVM

    C->>P: JNI_OnLoad 호출 및 전달
    P-->>C: JNI_VERSION_1_6 반환
    C->>C: auto_register_jni_symbols() 시작
    loop original symtab 의 Java_* STT_FUNC 심볼 각각
        C->>C: JNI mangled 이름 파싱-(com/foo/Bar, methodName)
        C->>J: FindClass("com/foo/Bar")
        C->>J: getDeclaredMethods()
        C->>J: getParameterTypes() + getReturnType()
        C->>C: JNI 시그니처 문자열 조립
        C->>J: RegisterNatives(cls, {name, sig, fn_ptr}, 1)
    end

6. 호환성을 위한 다양한 .so 연구 — NDK 가 아닌 .so 들

처음엔 “내가 NDK 로 빌드한 단순한 JNI 라이브러리”만 테스트했다. 그 단계에서는 비교적 쉽게 동작했다. 문제는 그 다음이었다.

Unreal Engine .so 의 특수성

Unreal Engine Android 빌드는 여러 특수한 점이 있다:

  1. Android Packed Relocation (APS2) 사용

    일반 DT_REL/DT_RELA 가 아닌 DT_ANDROID_REL/DT_ANDROID_RELA packed 형식을 사용한다. ULEB128/SLEB128 delta 인코딩된 오프셋과 addend 스트림으로 재배치를 압축 표현한다. 처음에 이 태그를 지원하지 않아 JNI_ERR returned from JNI_OnLoad 가 발생했다.

  2. NativeActivity 진입점

    Unreal 은 ANativeActivity_onCreate 를 진입점으로 사용한다. 플랫폼이 dlopen 후 이름으로 심볼을 찾는데, original 가 익명 메모리에 있으므로 starter 가 래퍼 함수를 public 으로 export 해야 했다.

  3. Java_* 심볼 수동 등록

    Unreal original 는 44개의 Java_* 심볼을 export 한다. 이것들이 익명 메모리에 있어 JVM 이 찾지 못했고, UnsatisfiedLinkError 가 발생했다. auto_register_jni_symbols() 구현으로 해결했다.

  4. C++ thread_local__dso_handle

    Unreal GameThread 가 thread_local 변수를 사용한다. C++ 런타임이 소멸자를 등록할 때 __dso_handle 을 전달하는데, 익명 메모리의 original __dso_handle 은 Android 링커의 soinfo 테이블에 없어서 increment_dso_handle_reference_counter 가 abort 했다.

  5. Android CFI (Control Flow Integrity)

    AAudio 콜백 함수 포인터가 익명 메모리를 가리키면 시스템 AAudio 코드가 CFI 검사에서 __loader_cfi_fail 을 발생시킨다. starter 가 링커에 등록된 CFI-safe 트램폴린을 제공하는 방식으로 해결했다.

Unity / Flutter .so

Unity 와 Flutter 는 Unreal 만큼 극단적이지 않지만, 각각 특유의 구조가 있다. Unity 는 libunity.so + libil2cpp.so 의 이중 구조, Flutter 는 libflutter.so + 앱별 libapp.so 구조다. 둘 다 ELF 표준을 따르므로 로더의 core path 는 동일하게 통과한다. JNI_OnLoad 없는 지원 라이브러리 처리(JNI_OnLoad 선택사항화)도 이 과정에서 필요했다.

Troubleshooting

어셈블리를 벗겨내고 소스코드화 헀을 떄 당연히 크래쉬를 마주쳤다. 이를 아래와 같이 하나씩 해결했다.

ELF 섹션 헤더 메타데이터 손상

증상: Android 링커가 패치된 .soe_shstrndx invalid 오류로 거부.

원인: 병합 도구가 런타임 로드 커버 영역만 보존하고 섹션 헤더 테이블 오프셋(e_shoff)을 재계산하지 않았다.

해결: original + footer append 후 e_shoff 재계산, 섹션 헤더 트레일러 올바른 위치로 이동, 정렬 보장.

graph LR
    A["loader ELF\n(원본)"] --> B["loader ELF\n+ 확장된 PT_LOAD"]
    B --> C["original bytes"]
    C --> D["footer (SIGMAGIC)"]
    D --> E["section header table\n(재계산된 e_shoff 반영)"]

Starter 전역 메모리 손상

증상: original 로드 성공, 그러나 JNI_OnLoad 전달 중 전역 변수 관련 크래시.

원인: 병합 시 original 가 들어가야 할 영역에 starter 의 .bss 가 제로 초기화되어야 하는데, 트레일러 데이터가 그 영역을 오염시켰다.

해결: 파일 백업 original 영역과 제로 채움 .bss 를 명확히 분리. 트레일러를 잘못된 로드 커버 영역 밖으로 이동.

dl* 래퍼 불완전

증상: 주요 original 정상 실행, 그러나 백그라운드 스레드에서 Firebase 관련 반복 크래시.

원인: 익명 메모리의 original 에 대해 dlopen/dlsym/dladdr/dl_iterate_phdr 이 올바른 응답을 반환하지 않음. 잘못된 하위 비트 pseudo-handle 추측 로직이 임의의 핸들을 임베디드 라이브러리로 오인.

해결: 임베디드 핸들 등록 테이블 도입. dl* 함수들을 로더 override 로 intercept 하고 등록 테이블 기반으로 응답.

APS2 디코더 버그 (Unreal)

증상: Unreal original 의 생성자 실행 직후 DT_INIT_ARRAY[0] 이 잘못된 주소를 가리켜 SIGSEGV.

원인: APS2 packed relocation 디코더의 두 가지 버그:

  1. delta addend 를 누적하지 않고 SET 으로 처리 (reloc_addend = delta 가 아닌 reloc_addend += delta 이어야 함)
  2. RELA 포맷인데 HAS_ADDEND 플래그가 없는 그룹에서 메모리에서 addend 를 읽는 REL 방식 적용

진단: apply_all_relocations()INIT_ARRAY[0] 값을 각 재배치 단계 전후로 로그 출력하는 [diag] 스냅샷을 추가해 어느 단계에서 값이 손상되는지 즉시 확인.

해결:

  • reloc_addend += addend_delta (누적)
  • has_addend = is_rela (HAS_ADDEND 플래그 독립, RELA 면 항상 addend 존재, 없으면 0)

__cxa_thread_atexit_impl abort (Unreal GameThread)

증상:

1
2
Abort message: 'increment_dso_handle_reference_counter:
  Couldn't find soinfo by dso_handle=0x6e933d2a60'

원인: C++ thread_local 변수 소멸자 등록 시 전달되는 __dso_handle 이 익명 메모리 주소라 Android 링커가 soinfo 를 찾지 못해 abort.

해결: original 의 GOT/PLT 에서 __cxa_thread_atexit_impl 을 후킹. 후크 함수가 original 의 __dso_handle 을 starter 의 __dso_handle (링커에 정상 등록된 것)로 교체하고, dtorobj 는 그대로 통과.

sequenceDiagram
    participant T as GameThread (original 코드)
    participant H as Hook (starter_cxa_thread_atexit_impl)
    participant L as libc __cxa_thread_atexit_impl
    participant LK as Android Linker

    T->>H: __cxa_thread_atexit_impl(dtor, obj, original_dso_handle)
    H->>H: original_dso_handle → starter_dso_handle 교체
    H->>L: __cxa_thread_atexit_impl(dtor, obj, starter_dso_handle)
    L->>LK: increment_dso_handle_reference_counter(starter_dso_handle)
    LK-->>L: 성공 (starter soinfo 존재)

이 방식이 제공하는 것

  • 원본 라이브러리가 독립 파일로 디스크에 존재하지 않음
  • proc/maps 에서 익명 매핑으로만 보임 (파일 경로 노출 없음)
  • dlopen/dlsym 으로 직접 접근 불가
  • NDK 빌드 뿐만 아니라 Unreal, Unity, Flutter 등 외부 빌드 .so 에도 적용 가능
  • APK/AAB 모두 자동화된 Python 도구로 일괄 처리

이 프로젝트는 현재 Unreal Engine 과 Unity 어플리케이션 타겟에서 fatal crash 없이 완전한 실행을 달성한 상태를 기준선으로 한다.

Conclusion

그러나 여전히 동적 분석 툴에 의하여 메모리에서 모든 필드와 메소드가 노출될 수 있다는 점이 남아있다. 이는 추후 RASP(Runtime Application Self-Protection) 에 맡겨야 하는 부분일 수 있지만 이러한 부분도 .so 를 보호할 때 커버 가능하도록 개선할 수 있을 것 같다. 하지만 결국 방어하는 방법이 있으면 공격하는 방법도 있기 마련이므로.. 좀 더 깔끔하고 세련된 방법을 고민해야 한다.

보호하려다 보면 공격자 입장에서 어떻게 분석할지 자연스럽게 생각하게 되고, 그 과정에서 플랫폼 내부가 더 잘 보인다.

References

This post is licensed under CC BY 4.0 by the author.
If you find any errors, please let me know by comment or email. Thank you.

© Ruffalo. Some rights reserved.

I'm

Using the Chirpy theme for Jekyll.