How to protect my code from reversing - Rust Binary
.so 파일을 리버싱으로부터 보호하는 방법에 대한 연구 (Rust)
Rust 기반 .so 적용 시 UnsatisfiedLinkError 크래시
이 글은 .so 를 보호하는 방법 에서 다룬 ELF 보호 도구를 실제로 운영하면서 마주친 버그의 원인 분석과 해결 과정을 기록한다.
현상
NDK C/C++ 로 작성된 라이브러리는 정상 동작하는 보호 도구를 Rust 기반 Android 라이브러리(libnative.so)에 적용했더니 앱이 즉시 크래시됐다.
1
2
3
4
5
6
7
E REDACTED.Sample: No implementation found for void com.REDACTED.REDACTED.init()
(tried Java_com_REDACTED_REDACTED_init
and Java_com_REDACTED_REDACTED_init__)
- is the library loaded, e.g. System.loadLibrary?
E AndroidRuntime: FATAL EXCEPTION: main
E AndroidRuntime: java.lang.UnsatisfiedLinkError: ...
라이브러리 로드 자체는 성공했고, 재배치도 문제없었다. 그러나 REDACTED.init() 를 호출하는 순간 찾을 수 없다는 것이다.
원인 분석
logcat 에서 핵심 라인을 뽑아보면:
1
2
3
4
5
I P4CKER: [diag] reloc tables: relr_count=0 android_rel_size=0 android_rela_size=0 rel_count=0 rela_count=6471
I P4CKER: embedded library embedded-payload does not export JNI_OnLoad; constructors only
I P4CKER: loaded embedded library embedded-payload (..., size=3756032)
I P4CKER: calling constructors for embedded-payload (..., init_array_count=0)
→ (crash)
두 가지 관찰이 눈에 띈다:
JNI_OnLoad없음init_array_count=0으로 생성자도 없음
Rust 로 Android JNI 바인딩을 작성하는 방식은 C/C++ 과 다르다.
C/C++ 일반적인 패턴:
1
2
3
4
5
6
7
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
// RegisterNatives 또는 그냥 버전 반환
return JNI_VERSION_1_6;
}
// JVM이 JNI_OnLoad 이후 dlsym으로 이 함수를 직접 찾음
JNIEXPORT void JNICALL Java_com_example_Foo_bar(JNIEnv *env, jobject thiz) { ... }
Rust 일반적인 패턴:
1
2
3
4
5
6
7
8
// JNI_OnLoad 없음
// #[no_mangle]로 C ABI 명명 규칙에 따라 직접 export
#[no_mangle]
pub extern "system" fn Java_com_REDACTED_REDACTED_init(
env: JNIEnv, _class: JClass
) {
// ...
}
Rust 는 JNI_OnLoad 없이 Java_* 심볼을 직접 export 하는 것이 관용적인 패턴이다. JNI_OnLoad 가 있더라도 내부에서 별도의 RegisterNatives 를 호출하지 않고 명명 규칙에만 의존하는 경우가 많다.
문제는 기존 로더 코드의 분기 구조에 있었다:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved) {
if (ensure_library_loaded() != 0) return JNI_ERR;
if (g_payload_jni_loaded) return g_cached_jni_version;
// ↓ JNI_OnLoad 가 없으면 여기서 바로 return → auto_register 호출 안 됨!
if (g_library.jni_onload == NULL) {
g_cached_jni_version = JNI_VERSION_1_6;
g_payload_jni_loaded = 1;
return g_cached_jni_version; // ← 문제의 조기 반환
}
// JNI_OnLoad 있을 때만 여기까지 도달
g_cached_jni_version = g_library.jni_onload(vm, reserved);
...
// Java_* 심볼 등록 → JNI_OnLoad 있는 경우에만 실행됨
auto_register_jni_symbols(env, &g_library);
...
}
auto_register_jni_symbols() 는 payload 심볼 테이블에서 Java_* 형식의 STT_FUNC 심볼을 스캔하여 JNI Reflection 으로 RegisterNatives 를 호출하는 함수다. 익명 메모리에 로드된 payload 는 JVM 의 dlsym 기반 탐색에서 보이지 않으므로, 이 함수로 강제 등록하지 않으면 JVM 이 절대 찾을 수 없다.
sequenceDiagram
participant JVM
participant Carrier as Carrier SO (링커에 등록됨)
participant Anon as Payload (익명 메모리)
JVM->>Carrier: System.loadLibrary("libnative")
JVM->>Carrier: JNI_OnLoad 호출
Carrier->>Carrier: ensure_library_loaded() → payload를 익명 메모리에 로드
Note over Carrier: jni_onload == NULL 확인
Carrier-->>JVM: JNI_VERSION_1_6 반환 (바로 return)
Note over Anon: Java_*_initJni 심볼 존재하지만 미등록 상태
JVM->>Carrier: dlsym(handle, "Java_*_initJni")
Note over Carrier: 캐리어 심볼 테이블에 없음
Carrier-->>JVM: NULL
JVM->>JVM: UnsatisfiedLinkError 발생!
해결
auto_register_jni_symbols() 호출을 JNI_OnLoad 존재 여부와 무관하게 항상 실행하도록 분기 구조를 변경했다.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
// 변경 전: JNI_OnLoad 없으면 조기 return
if (g_library.jni_onload == NULL) {
g_cached_jni_version = JNI_VERSION_1_6;
g_payload_jni_loaded = 1;
return g_cached_jni_version; // ← 여기서 끝
}
// ... JNI_OnLoad 호출 ...
auto_register_jni_symbols(env, &g_library); // JNI_OnLoad 있을 때만 실행
// 변경 후: if-else 로 구조 변경, auto_register는 항상 실행
if (g_library.jni_onload == NULL) {
// JNI_OnLoad 없는 경우 (Rust 등)
g_cached_jni_version = JNI_VERSION_1_6;
} else {
// JNI_OnLoad 있는 경우 (C/C++, Unreal 등)
g_cached_jni_version = g_library.jni_onload(vm, reserved);
if (g_cached_jni_version == JNI_ERR) return JNI_ERR;
}
// JNI_OnLoad 유무 관계없이 항상 Java_* 심볼 등록
auto_register_jni_symbols(env, &g_library);
수정 후 흐름:
sequenceDiagram
participant JVM
participant Carrier as Carrier SO
participant Anon as Payload (익명 메모리)
JVM->>Carrier: JNI_OnLoad 호출
Carrier->>Carrier: ensure_library_loaded()
Note over Carrier: jni_onload == NULL 확인
Carrier->>Carrier: g_cached_jni_version = JNI_VERSION_1_6
Carrier->>Carrier: auto_register_jni_symbols() 실행
loop payload symtab Java_* STT_FUNC 심볼 각각
Carrier->>JVM: FindClass("com/...REDACTED/REDACTED") → cls
Carrier->>JVM: getDeclaredMethods() → initJni 찾기
Carrier->>JVM: RegisterNatives(cls, {name, sig, fn_ptr})
end
Carrier-->>JVM: JNI_VERSION_1_6 반환
JVM->>Carrier: REDACTED.init() 호출
Carrier->>Anon: 등록된 fn_ptr 로 직접 dispatch
Note over Anon: 정상 실행!
수정 후 예상 로그
1
2
3
4
5
6
7
8
9
10
# 수정 전
I P4CKER: embedded library embedded-payload does not export JNI_OnLoad; constructors only
→ (auto_register_jni_symbols 호출 없이 반환)
E REDACTED.Sample: No implementation found for void ...REDACTED.init()
E AndroidRuntime: FATAL EXCEPTION
# 수정 후
I P4CKER: carrier JNI_OnLoad: payload has no JNI_OnLoad, scanning for Java_* symbols
I P4CKER: auto_register_jni_symbols: registered=N skipped=M
→ REDACTED.init() 정상 호출
영향 범위
이 수정은 Rust 에만 국한되지 않는다. JNI_OnLoad 없이 Java_* 명명 규칙만으로 JNI 함수를 export 하는 모든 언어/빌드시스템에 동일하게 적용된다.
| 케이스 | 수정 전 | 수정 후 |
|---|---|---|
C/C++ with JNI_OnLoad | ✅ 정상 | ✅ 정상 |
C/C++ without JNI_OnLoad + Java_* | ❌ 크래시 | ✅ 정상 |
Rust #[no_mangle] Java_* | ❌ 크래시 | ✅ 정상 |
| Kotlin/Native | ❌ 크래시 | ✅ 정상 |
| Unreal Engine (JNI_OnLoad 있음) | ✅ 정상 | ✅ 정상 |
| Go (gomobile) | ❌ 크래시 | ..✅ 아마도..정상 - Next(?) |
Conclusion
보호 도구의 목표는 모든 .so 에 대한 호환성이다. 내가 빌드하지 않은 것들 — Unreal, Unity, Flutter, 그리고 이번처럼 Rust — 이 오히려 더 다양한 엣지케이스를 가져온다. 각 언어/엔진의 ABI 관용패턴을 이해하는 것이 결국 호환성 있는 보호 도구를 만드는 핵심이다.
If you find any errors, please let me know by comment or email. Thank you.
