LoginSignup
0
1

[Android] 非RootかつJavaのみでJavaの関数をフックする

Posted at

はじめに

Javaでも関数フックを出来たら面白いんじゃないかと思ったのでやってみました。

環境

・Android 11(実機)
・非Root

フック前の下準備

まず、簡単なライブラリを作っていきます。
フォイル/フォルダ構成はこんな感じ

title
...
jinterceptor
   EvacuatedMethodStorage.java
   Interceptor.java
   Memory.java
   ArtMethod.java
   Reflection.java
   Unsafe.java
...
MainActivity.java
...

以下説明とソース

EvacuatedMethodStorage.java
public class EvacuatedMethodStorage {

    private static int mReferencedCount = 0;

    public static int referencedCount(){
        return mReferencedCount++;
    }
    public static Object method0(Object receiver, Object...params){
        return null;
    }

    public static Object method1(Object receiver, Object...params){
        return null;
    }

    public static Object method2(Object receiver, Object...params){
        return null;
    }

    public static Object method3(Object receiver, Object...params){
        return null;
    }

    public static Object method4(Object receiver, Object...params){
        return null;
    }

    ...

    public static Object method255(Object receiver, Object...params){
        return null;
    }
}

このクラスにはフック先のオリジナル関数を退避させるたのメゾット群が実装されています。

Interceptor.java
public class Interceptor {

    private static final Class<?> class_EvacuatedMethodStorage = EvacuatedMethodStorage.class;

    private static Map<Pair<String, String>, Integer> mBackupMap = new ConcurrentHashMap<>();

    public static void override(Method original, Method replace) throws NoSuchMethodException {
        long originalAddress = Unsafe.getMethodAddress(original), replaceAddress = Unsafe.getMethodAddress(replace);
        int c = backupMethod(originalAddress); //cは保存先の関数の番号
        Memory.copy(originalAddress, replaceAddress, ArtMethod.getSize());
        mBackupMap.put(new Pair<>(replace.getDeclaringClass().getName(), replace.getName()), c);
        //...<>(original.getDeclaringClass().getName(), original.getName())... ではないのは、元の関数が上書きされるため
    }

    public static Object callOriginal(Object receiver, Object...params){
        StackTraceElement current = Thread.currentThread().getStackTrace()[3]; //どのフックされた関数を通過したのかのトレースを取得
        int originalNum = mBackupMap.get(Pair.create(current.getClassName(), current.getMethodName())); //保存先の関数の番号を取得
        switch (originalNum){
            case 0:
                return EvacuatedMethodStorage.method0(receiver, params);
            case 1:
                return EvacuatedMethodStorage.method1(receiver, params);
            case 2:
                return EvacuatedMethodStorage.method2(receiver, params);

            ...

            case 255:
                return EvacuatedMethodStorage.method255(receiver, params); 
        }
        return null;
    }

    private static int backupMethod(long original) throws NoSuchMethodException {
        int count = EvacuatedMethodStorage.referencedCount();
        Method evacuationDst = class_EvacuatedMethodStorage.getDeclaredMethod("method" + count, Object.class, Object[].class);
        Memory.copy(Unsafe.getMethodAddress(evacuationDst), original, ArtMethod.getSize()); //退避先の関数をoriginaに上書き
        return count; //保存先の番号
    }
}

このクラスはフック関数が実装されています。
ちなみに、callOriginal関数内で退避させた元の関数を、リフレクションを使わずわざわざswitch文で判定して呼び出しているのには理由があります。
理由は後述します。

Memory.java
public class Memory {

    private static byte peekByte(long address){
        return (Byte) Reflection.call(null, "libcore.io.Memory", "peekByte", null, new Class[]{long.class}, new Object[]{address});
    }

    private static void pokeByte(long address, byte value) {
        Reflection.call(null, "libcore.io.Memory", "pokeByte", null, new Class[]{long.class, byte.class}, new Object[]{address, value});
    }

    public static void copy(long dst, long src, long length) {
        for (long i = 0; i < length; i++) {
            pokeByte(dst, peekByte(src));
            dst++;
            src++;
        }
    }
}

メモリ操作をするためのクラスです

ArtMethod.java
public class ArtMethod {

    private static long mSize;

    static {
        try {
            Method r1 = ArtMethod.class.getDeclaredMethod("r1"),
                    r2 = ArtMethod.class.getDeclaredMethod("r2");
            mSize = Unsafe.getMethodAddress(r2) - Unsafe.getMethodAddress(r1);
        } catch (Throwable throwable){
                throwable.printStackTrace();
        }
    }

    public static long getSize(){
        return mSize;
    }

    private static void r1(){}

    private static void r2(){}

}

このクラスはArtMethodのサイズを提供します

そもそもArtMethodとは

ArtMethod は、Androidの実行時システム(ART:Android Runtime)内部で使用されるクラスの一部です。これはJavaのメソッドを表すクラスで、Androidアプリケーションの実行中にメソッドの情報を格納し、操作するために使用されます。

ってChatGPTさんが教えてくれました。
つまり、Javaのメゾットの様々な情報を格納するためのラッパーのようなものってことですね
ArtMethodのヘッダーへのリンク
ArtMethodのソースコードへのリンク

さて、肝心のサイズを取得するコードについて触れていきたいと思います
サイズを取得しているのはここです

mSize = Unsafe.getMethodAddress(r2) - Unsafe.getMethodAddress(r1);

Unsafe.getMethodAddressの実装は下の通りです

Unsafe.java
public class Unsafe {

    public static long getMethodAddress(Method method){
        return (Long) Reflection.get(method.getClass().getSuperclass(), null, "artMethod", method);
    }

}

Methodのアドレスは、MethodクラスのスーパークラスであるExecutableにあるlong型のartMethodという変数をリフレクションを介して取得することで得られます。
正確には、変数artMethodにはそのメゾットをラップしているArtMethodへのアドレス、ないしはポインタが格納されているわけですね

そして、リフレクションを介して取得したr1, r2メゾットをUnsafe.getMethodAddressメゾットを介してそれぞれのArtMethodのアドレスを取得し、それらを引いて差分を求めています。
これがArtMethodのサイズとなるわけですが、下手な説明だけでは分かりにくいので、図を用意しました
スクリーンショット 2023-09-10 190148.png

こんな感じです。図も分かりにくかったら申し訳ないです

Reflection.java
public class Reflection {

    public static Object call(Class<?> clazz, String className, String methodName, Object receiver,
                              Class[] types, Object[] params) {
        try {
            if (clazz == null) clazz = Class.forName(className);
            Method method = clazz.getDeclaredMethod(methodName, types);
            method.setAccessible(true);
            return method.invoke(receiver, params);
        } catch (Throwable throwable) {
            throw new RuntimeException("reflection error:", throwable);
        }
    }

    public static Object get(Class<?> clazz, String className, String fieldName, Object receiver) {
        try {
            if (clazz == null) clazz = Class.forName(className);
            Field field = clazz.getDeclaredField(fieldName);
            field.setAccessible(true);
            return field.get(receiver);
        } catch (Throwable e) {
            throw new RuntimeException("reflection error:", e);
        }
    }
}

このクラスはリフレクション機能の使用を容易にするために作りました

動作確認

MainActivityはこんな感じに書きました

MainActivity.java
public class MainActivity extends AppCompatActivity {

    private ActivityMainBinding binding;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        try {

            //original関数をreplace関数に上書き
            Interceptor.override(MainActivity.class.getDeclaredMethod("original"), MainActivity.class.getDeclaredMethod("replace"));

        } catch (Throwable e) {
            throw new RuntimeException(e);
        }

        original(); //呼び出し

        TextView tv = binding.sampleText;
        tv.setText("あいうえお");
    }

    void original(){
        System.out.println("original");
    }

    void replace() {
        System.out.println("replace");
    }
}

実行結果

replace

original関数を呼んだはずがreplace関数が呼ばれています。フック成功です
もちろん、元の関数を呼び出すことが出来ます。

MainActivity::replace
    void replace() {
        System.out.println("replace");
        Interceptor.callOriginal(this, new Object[]{}); //元の関数呼び出し
    }

実行結果

replace
original

ちなみに、フックした後にMainActivityクラスに宣言されたメゾットを確認すると面白いことが起きます

MainActivity::onCreate
    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        try {

            Interceptor.override(MainActivity.class.getDeclaredMethod("original"), MainActivity.class.getDeclaredMethod("replace"));

        } catch (Throwable e) {
            throw new RuntimeException(e);
        }

        System.out.println(" ==== Declared Methods ==== ");
        for (Method method : MainActivity.class.getDeclaredMethods()){
            System.out.println(method.getName());
        }
        System.out.println(" ========================== ");

        TextView tv = binding.sampleText;
        tv.setText("あいうえお");
    }

実行結果

==== Declared Methods ====
onCreate
replace
replace
=====================

なんと同じMethodが二つもあります。引数のシグネチャも同じです。
スクリーンショット 2023-09-10 232833.png
つまりはこういうことですね
なぜこういうことが起きるのかというと、original関数をラップしているArtMethodがある場所にreplace関数をラップしているArtMethodをコピーしたからです
ここの部分です

Interceptor::override line-3
Memory.copy(originalAddress, replaceAddress, ArtMethod.getSize());

まだこの方法を試して時間がたっていないので、どんな副作用が起こるか要調査ですね

注意点

今のところ分かっている注意点/副作用をご紹介します

・フックされた関数はJNIから呼び出せない

C++などのオリジナル関数の先端数バイトをjmpに書き換えてtrampolineうんちゃらとは違い、関数をまるまる上書きしているので仕方ないですね。
Nativeから呼び出される関数はフック出来ないので気を付けましょう

・originalメゾットを保存した関数はリフレクション経由で呼び出すと無限ループになる

これは原因が分かりませんでした。
例えば、先ほどの例だとoriginal関数の退避先はEvacuatedMethodStorage::method0となりますが、リフレクション経由でmethod0関数を呼び出すと何故かreplaceメゾットが呼び出され、無限ループが発生してしまいます。
フックをする前にコピーしているので、影響は受けないと思ったのですが....
フック後のmethod0のアドレスを取ってみてもoriginalとreplaceのものとは一致しなかったので、尚更分かりませんでした。
前の方でも言及した、callOriginal関数内で退避させた元の関数をリフレクションを使わずわざわざswitch文で判定して呼び出しているのはこれがあるからです。

・フックする関数が多い場合はEvacuatedMethodStorageにメゾットをいちいち追加しなければならない

ひたすらに面倒くさいですね。Interceptor::callOriginal関数のswitchも項目を書き足さなきゃダメですし....

終わり

まさかJavaのみで関数フックを実現できるとは思いませんでした。こういうのはだいたいJNIが絡んでくるイメージがあります。
こういった実験は大好物なので、また機会があれば今回のような内容のものを書きたいと思います
また、何か間違いがあれば遠慮なくお申し付けください。

おまけ

TextView::setTextのフック
public class MainActivity extends AppCompatActivity {

    private ActivityMainBinding binding;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        try {

            Interceptor.override(TextView.class.getDeclaredMethod("setText", CharSequence.class), MainActivity.class.getDeclaredMethod("setText_hook", CharSequence.class));

        } catch (Throwable throwable){
            throwable.printStackTrace();
        }

        TextView tv = binding.sampleText;
        tv.setText("あいうえお");
    }

    void setText_hook(CharSequence text){
        System.out.println("setText_hook -> text : " + text);
        Interceptor.callOriginal(this, text);
    }
}

実行結果

Fatal signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x83efd50fdc in tid 13503 (ec.javahooktest), pid 13503 (ec.javahooktest)

何だかView系の関数をフックしたらだいたいクラッシュするような気がします

System::loadLibraryのフック
public class MainActivity extends AppCompatActivity {

    private ActivityMainBinding binding;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        try {

            Interceptor.override(System.class.getDeclaredMethod("loadLibrary", String.class), MainActivity.class.getDeclaredMethod("loadLibrary_hook", String.class));

        } catch (Throwable throwable){
            throwable.printStackTrace();
        }

        TextView tv = binding.sampleText;
        tv.setText("あいうえお");

        System.loadLibrary("javahooktest");
    }

    static void loadLibrary_hook(String libname){
        System.out.println("System::loadLibrary -> libname : " + libname);
        //Interceptor.callOriginal(null, libname);
    }

}

実行結果を載せるほどではないのですが、何故かcallOriginalを呼ぶとクラッシュしました。
しかしこれは実用的だと思います、ゲームなどのアプリなどで必要なライブラリをロードした後にloadLibrary関数をフックすることで、外部ライブラリのインジェクションなどを防ぐことが出来るのでチート対策に一役買ってくれるのではないでしょうか(メモリから読み込まれなければ)

Log::dのフック
public class MainActivity extends AppCompatActivity {

    private ActivityMainBinding binding;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        try {

            Interceptor.override(TextView.class.getDeclaredMethod("setText", CharSequence.class), MainActivity.class.getDeclaredMethod("setText_hook", CharSequence.class));

        } catch (Throwable throwable){
            throwable.printStackTrace();
        }

        Log.d("ABC", "YO");

        TextView tv = binding.sampleText;
        tv.setText("あいうえお");
    }

    void d_hook(String tag, String msg){
        System.out.println("Log.d -> hook : tag | " + tag + ", msg | " + msg);
        Interceptor.callOriginal(null, tag, msg);
    }
}

実行結果

runtime.cc:663] JNI DETECTED ERROR IN APPLICATION: jstring has wrong type: java.lang.Object[]

ダメでした

ちなみにオリジナルを呼び出さなければ

    void d_hook(String tag, String msg){
        System.out.println("Log.d -> hook | tag : " + tag + ", msg : " + msg);
        //Interceptor.callOriginal(null, tag, msg);
    }

実行結果

Log.d -> hook | tag : ABC, msg : YO

ContextWrapper::getPackageNameのフック
public class MainActivity extends AppCompatActivity {

    private ActivityMainBinding binding;

    @Override
    protected void onCreate(Bundle savedInstanceState) {
        super.onCreate(savedInstanceState);

        binding = ActivityMainBinding.inflate(getLayoutInflater());
        setContentView(binding.getRoot());

        try {

            Interceptor.override(ContextWrapper.class.getDeclaredMethod("getPackageName"), MainActivity.class.getDeclaredMethod("getPackageName_hook"));

        } catch (Throwable throwable){
            throwable.printStackTrace();
        }

        System.out.println("Package : " + getPackageName());

        TextView tv = binding.sampleText;
        tv.setText("あいうえお");
    }

    String getPackageName_hook(){
        return "ai.ue.o";
    }

実行結果

Package : ai.ue.o

何かと使えるかも....?

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1