はじめに
JNI利用時の難読化方法について調査したのでここに残します。
難読化は Java/Native の2視点があり、それぞれ異なる対応が必要です。
多くの場合、実装上の手軽さから静的JNIが適用されている場合がほとんどだと思われますが、リバースエンジニアリング耐性を高めるためには動的JNIを適用することが望ましいです。
本記事では動的JNIを用いてリバースエンジニアリング耐性を高める方法を記します。皆様の実装に役立つことを祈ります。
また、双方を同時に適用した方法までは書けなかったので今後の課題とします。
本記事のコードのすべてを動作確認したわけではありません。何か問題が発生し、それを解決された方はぜひナレッジを共有いただけると嬉しいです。
Native側の難読化(Java → Native)
方法
JNI_Onload関数上でRegisterNativesメソッドを利用し、動的に各メソッドに名前(Javaから利用)を割り当てる。
この場合、Javaから見えるNativeのメソッド名は動的に作成されているため、静的解析によるリバースエンジニアリング対策になる。
実装例
Java
public class NativeBridge {
public native void nativeDoSomething();
}
Native (C, C++)
// もともとJava_com_example_NativeBridge_nativeDoSomethingと定義されていた関数を改名
static void nativeDoSomething_impl(JNIEnv* env, jobject thiz) {
...
}
static JNINativeMethod sMethods[] = {
{ "nativeDoSomething", "()V", (void*)nativeDoSomething_impl },
};
jint JNI_OnLoad(JavaVM* vm, void*) {
JNIEnv* env;
vm->GetEnv((void**)&env, JNI_VERSION_1_6);
// ここが大事。Java側のClassが難読化されているとFindClassが失敗する
jclass cls = env->FindClass("com/example/NativeBridge");
env->RegisterNatives(cls, sMethods, 1);
return JNI_VERSION_1_6;
}
リスク分析
- 性能:
RegisterNativesは初期化時1回ならオーバーヘッドをほぼ無視できる - セキュリティ:静的解析耐性は限定的。動的名割り当ても、DEX側の
native宣言や.so側の実装で手がかりは残るし、動的解析(Frida/ptrace/JVMTI)には無力 - 運用/信頼性:名前生成の脆さ・同期ズレ・例外処理漏れ・参照管理ミスがクラッシュ要因。設計とガードが肝
参考文献
Java側の難読化(Native → Java)
方法
NativeからJavaを呼び出す(CallBack)際にはGetMethodIDを用いた方法が一般的に使われているが、静的にJava側のメソッドを決め打ちしているので、静的解析に弱い。
また、決め打ちされていることによりCallBackで利用されるClass・MethodはProGuardでの難読化ができない。
難読化をするには、次の2つの対策を同時に行う必要がある。
- ブリッジクラスの利用 → Classの難読化
- ブリッジクラスのみProGuardでkeepする
- ブリッジクラスは難読化されないが、ラップされているクラスを難読化することが可能
-
[classname].class.getDeclaredMethods()[0]でメソッド名を記述せずに指定 → メソッドの難読化- クラスが複数メソッドを持つ場合、うまく機能しない可能性
- メソッドの順番は定義した順番とは限らないため
- 将来的な拡張にリスクがある
- 単一メソッドを持つクラスが安全
- 単一メソッド内で引数によって複数の処理ができるようにする、などの工夫が必要
- アノテーションID方式での実装が可能。実装例は後述
- 単一メソッド内で引数によって複数の処理ができるようにする、などの工夫が必要
- クラスが複数メソッドを持つ場合、うまく機能しない可能性
実装例
Java
// コールバック契約(1メソッドだけのインターフェース)
public interface EventListener {
void onEvent(String msg);
}
// 実装クラス:ここをガッツリ難読化したい
public class MyListener implements EventListener {
@Override
public void onEvent(String msg) {
// 本処理(自由に難読化されてOK)
}
}
// ネイティブとのブリッジ
public class NativeBridge {
static {
System.loadLibrary("mynative");
}
public static void register(EventListener listener) {
// ★ メソッド名を文字列で使わない
Method m = EventListener.class.getDeclaredMethods()[0];
nativeSetListener(listener, m);
}
private static native void nativeSetListener(
EventListener listener,
Method method
);
}
Native (C, C++)
static jobject gListener = nullptr;
static jmethodID gOnEvent = nullptr;
extern "C"
JNIEXPORT void JNICALL
Java_com_example_NativeBridge_nativeSetListener(
JNIEnv* env, jclass,
jobject listener,
jobject methodObj) {
if (gListener) {
env->DeleteGlobalRef(gListener);
}
gListener = env->NewGlobalRef(listener);
// Method → jmethodID へ変換(名前不要)
gOnEvent = env->FromReflectedMethod(methodObj);
}
// ネイティブ側からイベントを通知するときに呼ぶ
void notify_from_native(JNIEnv* env, const char* msgCStr) {
if (!gListener || !gOnEvent) return;
jstring msg = env->NewStringUTF(msgCStr);
env->CallVoidMethod(gListener, gOnEvent, msg);
env->DeleteLocalRef(msg);
}
ProGuard
# ネイティブ入口だけ keep(最低限)
-keep class com.example.NativeBridge {
public static void register(...);
private static native void nativeSetListener(...);
}
# EventListener / MyListener / onEvent は keep 不要
# → クラス名・メソッド名ともに難読化OK
実装例(複数メソッド利用時)
Java
- アノテーション
// コールバック識別用(idだけ覚えておけばOK)
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface NativeCallback {
int id();
}
- コールバックインターフェース & 実装
public interface EventListener {
@NativeCallback(id = 1)
void onConnected(String info);
@NativeCallback(id = 2)
void onDisconnected(int reason);
}
// 実装クラス:ここをガッツリ難読化したい
public class MyListener implements EventListener {
@Override
public void onConnected(String info) {
// 好きにobfuscationされてOK
}
@Override
public void onDisconnected(int reason) {
// 同上
}
}
- ブリッジクラス
public class NativeBridge {
static {
System.loadLibrary("mynative");
}
public static void register(EventListener listener) {
List<Integer> ids = new ArrayList<>();
List<Method> methods = new ArrayList<>();
for (Method m : EventListener.class.getDeclaredMethods()) {
NativeCallback cb = m.getAnnotation(NativeCallback.class);
if (cb != null) {
ids.add(cb.id());
methods.add(m);
}
}
int[] idArray = ids.stream().mapToInt(i -> i).toArray();
Method[] methodArray = methods.toArray(new Method[0]);
nativeSetListener(listener, idArray, methodArray);
}
private static native void nativeSetListener(
EventListener listener,
int[] ids,
Method[] methods
);
Native (C, C++)
#include <jni.h>
#include <map>
static jobject gListener = nullptr;
static std::map<jint, jmethodID> gMethods;
extern "C" JNIEXPORT void JNICALL Java_com_example_NativeBridge_nativeSetListener(
JNIEnv* env, jclass,
jobject listener,
jintArray ids,
jobjectArray methods)
// listener を GlobalRef で保持
if (gListener) {
env->DeleteGlobalRef(gListener);
}
gListener = env->NewGlobalRef(listener);
gMethods.clear();
jsize len = env->GetArrayLength(ids);
jint* idBuf = env->GetIntArrayElements(ids, nullptr);
for (jsize i = 0; i < len; ++i) {
jobject mObj = env->GetObjectArrayElement(methods, i);
jmethodID mid = env->FromReflectedMethod(mObj); // ←名前不要
gMethods[idBuf[i]] = mid;
env->DeleteLocalRef(mObj);
}
env->ReleaseIntArrayElements(ids, idBuf, JNI_ABORT);
}
// ネイティブ側からID指定でコールバック
void notify_event_connected(JNIEnv* env, const char* info) {
auto it = gMethods.find(1); // id=1: onConnected
if (it == gMethods.end() || !gListener) return;
jstring jinfo = env->NewStringUTF(info);
env->CallVoidMethod(gListener, it->second, jinfo);
env->DeleteLocalRef(jinfo);
}
void notify_event_disconnected(JNIEnv* env, jint reason) {
auto it = gMethods.find(2); // id=2: onDisconnected
if (it == gMethods.end() || !gListener) return;
env->CallVoidMethod(gListener, it->second, reason);
}
リスク分析
-
性能:良い(実用上ほぼ無視)
- 初期化時の
Method走査+FromReflectedMethodが一度だけ→オーバーヘッド軽微 - 呼び出しは通常のJNIと同等(
Call*Method) - 注意点:大量メソッドの再登録ループはNG(初期化1回に集約)
- 初期化時の
-
セキュリティ:静的解析耐性を強化
- 効く:メソッド名/クラス名に非依存→静的解析の手がかり削減
- 弱い:Frida/JVMTI/ptrace等の動的解析でテーブル観測可能
- 推奨併用:R8難読化、文字列秘匿、
.soのvisibility hidden + version-script + strip、Integrity/改ざん検知、(必要に応じて)OLLVM
-
運用/信頼性:設計次第で安定
- リスク:
GlobalRefリーク、JNIEnv*のスレッド誤用、例外未処理、初期化順 - ガード:
- 再登録前に必ずDeleteGlobalRef、終了時解放
- 非JavaスレッドはAttach/Detach徹底、UI戻しは
Handler - すべての
Call*Method直後にExceptionCheck、失敗はフェイルファスト - 初期化完了フラグで未初期化呼び出しをブロック
- リスク:
-
拡張性:設計ルール前提で強い
- 〇:名前に依存しないため、リファクタや難読化変更に強い
- ×:
getDeclaredMethods()[index]依存は順序非保証で破綻の元 - 推奨:アノテーションID方式(
@NativeCallback(id=…))でid→Methodを作り、Native側はid→jmethodIDマップ管理。複数リスナーはMapで拡張
参考文献
まとめ
- Native/Java 双方の難読化の方法をそれぞれまとめた
- すでに静的JNIを利用している場合は大幅な設計変更になる
- 新規作成時点で要リバースエンジニアリング対策がおすすめ
- 設計変更は高コストであるため、重要度の高いクラス/メソッドに限定する、あるいは段階的に適用を進めることが望ましい