本文會詳細敘述客戶端風控對抗的“邊界值”在哪里,如果你是在做風控對抗 ,不管你是這場游戲中在演“貓”的角色還是“老鼠”的角色 。

本文將站在上帝視角去講解對應的“規則” 和“玩法”,以及如何實現角色轉換。

通過之前的系列文章,配合這篇文章希望每個小白玩家都能知道大廠是怎么玩的,如何設置游戲規則,我們應該如何進行解謎。

一
前奏知識

IPC代理&IPC協議是什么?

在第一篇文章中介紹了一個細節點,就是IPC代理 ,但是現在在講這篇文章的時候,需要更詳細的敘述一下。

第一篇文章里面介紹了Android是基礎的CS架構,客戶端和服務端架構 。安卓為什么要這么設計呢?當時問了GTP,他給出的回答是穩定性。如果服務端和客戶端在一個進程內,客戶端崩潰了,服務端也會一起崩潰,導致整個系統不穩定。 

安卓和Java相比多個一個Context,這個Context是調用安卓本身提供api的橋梁 ,里面有各種安卓系統提供的各種基礎API。

這些API可以直接操作Android系統 ,安卓本身通過各種各樣的Manager去提供對應的Api去獲取和修改 。比如PackageManager,ActivityManager等,這些Manager里面都會持有一個代理人 。當我們去調用這個Manager里面的一些Api的時候,一些簡單的Api他會嘗試去自己在本進程Native或者Java去實現,如果一些復雜的字段,比如查詢系統的一些信息,或者調用一些系統關鍵函數,這種時候他會去調用“IPC代理人 ”,這個IPC代理人就是像服務端通訊的關鍵 。他相當于是向服務端的傳話得人 ,代理設計模式 。對不同的Manager提供不一樣的功能 ,而他傳的話就是對應的IPC協議 。這個協議如何傳遞的,就是通過底層的共享內存Binder去實現的 。

而這個協議里面具體發送的內容,就是IPC協議裝的“包裹”就是用的Parcel 。

這塊舉個栗子,當用戶調用一個未初始化的API時候,需要跨進程通訊,到底發生了哪些動作。

用戶調用系統API->Manager收到調用消息->判斷是否需要調用服務端->調用IPC代理里面的方法->

IPC代理構建發送的數據包調用Binder進行通訊數據寫入以后返回。

這個IPC代理實現了Binder的接口,當前進程調用的最后一個API就是:

"android.os.BinderProxy"->transact

也就是說這個方法底層調用的是Binder的驅動,最終會去native層寫入,剩下的就是開始運行服務端的邏輯了。把數據寫入到transact方法的參數3里面。然后程序返回,下面是這個方法的原型 。

/**
     * Perform a binder transaction on a proxy.
     *
     * @param code The action to perform.  This should
     * be a number between {@link #FIRST_CALL_TRANSACTION} and
     * {@link #LAST_CALL_TRANSACTION}.
     * @param data Marshalled data to send to the target.  Must not be null.
     * If you are not sending any data, you must create an empty Parcel
     * that is given here.
     * @param reply Marshalled data to be received from the target.  May be
     * null if you are not interested in the return value.
     * @param flags Additional operation flags.  Either 0 for a normal
     * RPC, or {@link #FLAG_ONEWAY} for a one-way RPC.
     *
     * @return
     * @throws RemoteException
     */
    public boolean transact(int code, Parcel data, Parcel reply, int flags) throws RemoteException {
            ...
    }

第一個參數就是code,這個code指的是具體的事件類型,不同的事件傳入的數字也不一樣 。

第二參數是發送的數據包,這時候IPC已經往里面進行了寫入對應的數據包。

第三個參數是reply,也就是服務端返回的保存內容。

注意:

這塊可能存在一個問題,有的數據,這個時候你在after去復寫這個參數3已結晚了,因為數據在別的進程已結寫入了 。
如果你想對這個參數進行修改,最好的辦法是直接模擬服務端手動往reply進行寫入

第四個參數是flags,舉個例子,比如你想獲取正常的PackageInfo,不需要獲取簽名之類的信息,你傳入0即可。

如果想獲取簽名就需要傳入PackageManager.GET_SIGNATURES,這個flag相當于告訴服務端,都需要哪些功能。

getPackageManager().getPackageInfo("aaa",0);
getPackageManager().getPackageInfo("aaa",PackageManager.GET_SIGNATURES);

比如第一個Api獲取到的PackageInfo里面是不包含簽名信息的,第二個則包含。當你需要什么功能的時候使用 “或” 連接即可。

基礎知識介紹完畢:

這塊我們得到一個結論,這個方法是Java層通訊最后一個方法,也就是當前進程能操作的最后一個方法,剩下的就是服務端進程的事情了。這個方法是Java層的 “邊界值 ”,這個邊界值記住后面在總結里面會介紹到不同的邊界值和風控的關系。

題外話:

這塊還有的大廠更惡心,他不走transact方法,因為transact方法底層走的就是Binder,可以直接在Native層調用的Binder 驅動,實現了transact 這個方法。然后進行IPC通訊,直接不走Java層。

當然他這種方法也是很不穩定,需要對每個android 版本都進行兼容,屬于傷敵1000自損800類型,適配難度也很大。

隨著安卓不斷增強安全性,后面這種方式肯定會慢慢被PASS掉,現在利用跨進程在低版本越權App的太多了。

動態代理IPC:

這塊還有一個知識點:

就是我不用hook可以實現IPC的代理人替換么?

這塊有一個動態代理的知識點,就是他代理人本身是實現了一個接口,我們可以直接反射把他這個代理人給替換成我們的,然后我們使用Proxy.newProxyInstance動態代理這個接口類,也可以實現不需要Hook框架的情況下實現動態代理 。比如一些VA之類的用的就是這種,因為Hook其實穩定性啥的沒有動態代理的穩定性好,Hook的話需要對不同版本兼容,一旦版本發生變化需要適配很多東西,而動態代理則不需要。
Hook的話痕跡可能更少一點,動態代理檢測的話只需要反射這個IPC代理人,然后getClass().getName() 里面直接就有proxy之類的關鍵字 ,各有各的好處 。自己決定使用哪種方式。
二
設備指紋

IPCAndroid_Id

在之前第二篇設備指紋里面介紹了獲取Android Id的五種方式,第五種方式因為當時沒時間也沒對高版本兼容,所以一直沒發,這塊抽空對照不同Android完善一下。

直接構建IPC協議和服務端進行通訊,這塊targetSdkVersion 必須升級到32以上,因為getAttributionSource這個玩意32版本以上好像才有。

為了兼容Android高版本需要升級。不過還好是Java方法,就算出異常也可以try住。

不過這種方式在高版本不一定能用了,低版本可以,因為我看API提示,

Reflective access to CALL_TRANSACTION will throw an exception when targeting API 33 and above

當針對API 33及以上目標時,對CALL_TRANSACTION的反射性訪問將拋出異常
public String getAndroidId5(Context context) {
        try {
            // Acquire the ContentProvider
            Class activityThreadClass = Class.forName("android.app.ActivityThread");
            Method currentActivityThreadMethod = activityThreadClass.getMethod("currentActivityThread");
            Object currentActivityThread = currentActivityThreadMethod.invoke(null);
            Method acquireProviderMethod = activityThreadClass.getMethod("acquireProvider", Context.class, String.class, int.class, boolean.class);
            Object provider = acquireProviderMethod.invoke(currentActivityThread, context, "settings", 0, true);
            // Get the Binder
            Class iContentProviderClass = Class.forName("android.content.IContentProvider");
            Field mRemoteField = provider.getClass().getDeclaredField("mRemote");
            mRemoteField.setAccessible(true);
            IBinder binder = (IBinder) mRemoteField.get(provider);
            // Create the Parcel for the arguments
            Parcel data = Parcel.obtain();
            data.writeInterfaceToken("android.content.IContentProvider");
            if (android.os.Build.VERSION.SDK_INT
                    >= android.os.Build.VERSION_CODES.S) {
                context.getAttributionSource().writeToParcel(data, 0);
                data.writeString("settings"); //authority
                data.writeString("GET_secure"); //method
                data.writeString("android_id"); //stringArg
                data.writeBundle(Bundle.EMPTY);
            } else if (android.os.Build.VERSION.SDK_INT
                    == android.os.Build.VERSION_CODES.R) {
                //android 11
                data.writeString(context.getPackageName());
                data.writeString(null); //featureId
                data.writeString("settings"); //authority
                data.writeString("GET_secure"); //method
                data.writeString("android_id"); //stringArg
                data.writeBundle(Bundle.EMPTY);
            } else if (android.os.Build.VERSION.SDK_INT
                    == android.os.Build.VERSION_CODES.Q) {
                //android 10
                data.writeString(context.getPackageName());
                data.writeString("settings"); //authority
                data.writeString("GET_secure"); //method
                data.writeString("android_id"); //stringArg
                data.writeBundle(Bundle.EMPTY);
            } else {
                data.writeString(context.getPackageName());
                data.writeString("GET_secure"); //method
                data.writeString("android_id"); //stringArg
                data.writeBundle(Bundle.EMPTY);
            }
            Parcel reply = Parcel.obtain();
            binder.transact((int) iContentProviderClass.getDeclaredField("CALL_TRANSACTION").get(null), data, reply, 0);
            reply.readException();
            Bundle bundle = reply.readBundle();
            reply.recycle();
            data.recycle();
            return bundle.getString("value");
        } catch (Exception e) {
            e.printStackTrace();
            return null;
        }
    }

IPCAppSign

在第二篇帖子里面,還有一種檢測簽名方法遺漏,IPC獲取簽名也是一些大廠經常用檢測簽名的辦法,修改的話也很簡單,直接替換掉數據包即可。

具體獲取方法如下:

public static int TRANSACTION_getPackageInfo() {
    if(TRANSACTION_getPackageInfo == -1) {
         try {
                Field field = null;
                try {
                    Class pkmIPCClazz = Class.forName("android.content.pm.IPackageManager$Stub");
                    field = pkmIPCClazz.getDeclaredField("TRANSACTION_getPackageInfo");
                } catch (Throwable e) {
                    CLog.e(">>>>>>>>>> getTranscationId forName error " + e.getMessage());
                }
                assert field != null;
                field.setAccessible(true);
                TRANSACTION_getPackageInfo = field.getInt(null);
            } catch (Throwable e) {
                e.printStackTrace();
                CLog.e(">>>>>>>>>> getTranscationId error " + e.getMessage());
            }
        }
        return TRANSACTION_getPackageInfo;
}
    
try {
    PackageManager packageManager = getBaseContext().getPackageManager();
    Object IPC_PM_Obj = RposedHelpers.getObjectField(packageManager, "mPM");
    //取binder
    IBinder mRemote = (IBinder) RposedHelpers.getObjectField(IPC_PM_Obj, "mRemote");
    Parcel _data = Parcel.obtain();
    Parcel _reply = Parcel.obtain();
    _data.writeInterfaceToken("android.content.pm.IPackageManager");
    _data.writeString(getPackageName());
    _data.writeLong(PackageManager.GET_SIGNATURES);
    _data.writeInt(android.os.Process.myUid());
    boolean _status = mRemote.transact(TransactCase.TRANSACTION_getPackageInfo(), _data, _reply, 0);
    _reply.readException();
    PackageInfo packageInfo = _reply.readTypedObject(PackageInfo.CREATOR);
    _data.recycle();
    _reply.recycle();
    CLog.e("簽名信息: "+packageInfo.signatures[0].toCharsString());
} catch (Throwable e) {
    CLog.i("IPC_TEST_getPackageInfo error "+e);
}

Maps解析Apk簽名:

這塊還有一個方案,主要實現思路就是因為我們當前進程去打開apk是存在風險的。

很有可能被IO重定向,導致得到的簽名是錯誤的,所以我們可以讓三方進程去加載當前apk文件,通過共享內存的方式,然后當前進程對apk文件maps里面的內存簽名進行解析即可。這塊需要雙進程通訊。

其他字段IPC

根據上面的兩個經典IPC例子,可以發現只要是服務端獲取的都可以使用IPC協議的方式去獲取。

其他字段其實一樣也可以這么玩,如果需要什么字段,就對照安卓源碼客戶端往里面寫入對應的數據,直接IPC即可。

我一般分析的SO文件的時候直接對jni交互進行監聽,配合以前自己寫的一套jnitrace(https://github.com/w296488320/JnitraceForCpp),在保存的調用棧里面,看他如果調用了Parcel.obtain() 初始化或者 這種writeLong ()寫入數據的方法,基本就可以確認他是IPC獲取的一些字段,具體看他寫入的內容是什么,或者看他寫入的token是什么,比如上面的獲取簽名的token就是"android.content.pm.IPackageManager" ,即可知道他想做什么字段的獲取。

IPC總結&反思:

后來我又思考了一下,IPC服務端這些設備指紋,或者說這些配置到底哪里來的,一直在源碼里面跟。

發現就拿android id來說,他最終讀取的文件路徑是/data/system/users/0/settings_ssaid.xml ,這個目錄下,/data/system/users/0/我發現這里面全是各種注冊表和各種配置信息。我這邊嘗試改了一下里面的android id。然后直接手機重啟,我發現我之前自己寫的Hunter獲取的設備指紋android id竟然變了。

后來我把這些文件都拷貝出來,把里面熟悉的值都隨機了一份,通過magisk 插件系統文件替換的方式,對文件/data/system/users/0/進行替換,真沒想到以前被封的設備解封了。而且不需要回復出廠設置,只需要軟重啟一下就行。

而且基本可以做到無痕,因為沒有對apk任何修改,改的全是系統級別的變量,而我只需要Root,替換系統文件,相當于每一次都是恢復出廠設置。

現在基本大廠想要在回復出廠設置保持設備指紋不變基本不可能 。這套方案我測試過一段時間,現階段基本大廠從客戶端角度基本沒辦法對抗 。只能靠一些服務端指紋去做檢測 。(我課程里面會更詳細的去講這套方案的落地實現 ,包括途中踩得一些坑。)

這套方案也是我2年前對抗風控用的主要方案,現在分享一下。我給攻擊方添把柴。加速行業內卷我輩刻不容緩!

我猜測這種方案會成為未來的主流風控對抗方案。

這塊還有個細節,如果我實現了系統文件替換,應該如何多開呢?其實非常簡單,我直接利用系統級別的多開 ,這時候肯定會有人過來問 。
有的Apk系統不讓多開怎么辦呀 ?
你忘記你會Hook了么,他判斷條件本身就是在系統桌面和setting里面 ,直接Hook,讓每個Apk都可以實現多開不就完事了 。
就比如核心破解啥的,可以Hook系統的簽名解析進行anti,實現不簽名直接安裝,他Apk層拿什么去檢測 簽名被修改了?這種方案anti掉各種企業殼,親測 。

當然文章繼續往下看,會給在沒有Root的情況下如何進行對抗呢?

服務端級別設備指紋:

還是針對上面的場景,如果我是防御方應該怎么對抗呢?我之前一直思考這個問題,如果客戶端拿到的數據都是不安全的,那么有沒有一種方案可以服務端獲取設備指紋呢?

其實也是有的,其實我之前的文章講過一點。但是沒細化,這塊我會詳細介紹一下架構和設計模式 。

就是服務端去獲取客戶端的IPV6信息,配合客戶端上報。IPV6號稱能給世界上每粒沙子分配一個ip, 2的128次方。

可以在設備指紋初始化的時候調用一下接口,服務端網關層去獲取IpV6信息。將信息保存作為客戶端設備指紋。

tls最新版+socket進行通訊,用這個ipv6作為設備指紋。當然這塊也需要防代理,具體的方案也很多,比如一些大廠會去購買一些代理IP,試用的時候去請求自己的網關,然后把這些IP都拉黑。當然,對抗方式也有很多,這里不一一敘述了。下面我會更詳細的敘述一下對抗場景。

三
Hunter檢測&反制

這塊主要是介紹一些比較新奇的對抗和檢測,也是我之前在做黑產對抗的時候發現的一些辦法。

MapIo重定向Anti:

一般在實現一機多號的時候,因為需要對不同的賬號進行IO重定向,把不同的賬號,保存到自己的虛擬分身里面。

這時候如果你要讀取Maps去遍歷Item的時候就會發現這個Item異常,一般沙箱開發者會將MapsIo重定向。當發現讀取Maps的時候把指向自己的文件,因為這個Maps是不斷變化的,所以需要在svc openat這塊進行攔截生成 一份新的。然后指向到這份新的文件,在新的maps里面他會對里面的item路徑進行反轉,轉換成正常的目錄,而不是包含沙箱的目錄。導致獲取的數據被欺騙。

這塊讀文件偏移完全可以不讀取Maps,而是讀取proc/self/maps_files 對這個文件進行opendir,對每個文件進行遍歷,然后再路徑拼接,通過readlinkat去反查路徑 即可。

“反調試”進程檢測實現細節:

一般Apk都會開啟一條線程作為檢測反調試線程,這條線每隔幾秒對線程進行一些特征進行檢查當前進程是否被調試。

有很多攻擊者會Hook線程創建的辦法,然后在線程啟動的時候進行pass,不讓其啟動,以實現逃過檢測的辦法。

這種情況其實對抗也很簡單,可以在主線程搞個flag,只有在調試線程開啟的時候,并且檢測執行成功的時候使用process_vm_writev對flag進行寫入。

因為是異步,所以主線程可以延遲2秒鐘對這個flag進行檢測,判斷調試線程是否開啟,如果沒開啟上報埋點即可。

自實現RegisterNativeMethod:

我們正常注冊一個native方法是調用的env->RegisterNatives,但是這種直接api調用很有可能被Hook。

所以我們可以自己實現一份,因為native注冊底層本質上是給artmethod里面的fnptr進行賦值,最終調用artmethod里面的RegisterNative方法,所以我們可以不直接調用Jni直接走 artmethod里面的注冊方法。具體實現如下,因為artmethod里面的注冊方法每個版本的實現都不一樣,所以這塊需要根據不同版本進行case分發。

    
//call art method register
    if (!RegisterNativeMethod(env, NativeEngine,
                              SignatureFixMethods,
                              sizeof(SignatureFixMethods) / sizeof(SignatureFixMethods[0]))) {
        LOG(ERROR) << "JNI_OnLoad call art method register fail ,start env register natives! ";
        env->RegisterNatives(NativeEngine,
                             SignatureFixMethods,
                             sizeof(SignatureFixMethods) / sizeof(SignatureFixMethods[0]));
    }

調用的話很簡單直接嘗試調用我們自己實現的方法,如果失敗了則調用系統的api ,這樣可以有效防止jni被hook實現,jni RegisterNative 函數被監聽。

//
// Created by Zhenxi on 2022/8/22.
//
#include 
#include "../include/logging.h"
#include "../include/libpath.h"
#include "../include/dlfcn_compat.h"
#include "../include/version.h"
#include "../include/main.h"
static void *art_method_register = nullptr;
static void *class_linker_ = nullptr;
size_t OffsetOfJavaVm(bool has_small_irt, int SDK_INT) {
    if (has_small_irt) {
        switch (SDK_INT) {
            case ANDROID_T:
            case ANDROID_SL:
            case ANDROID_S:
                return sizeof(void *) == 8 ? 624 : 300;
            case ANDROID_R:
            case ANDROID_Q:
                return sizeof(void *) == 8 ? 528 : 304;
            default:
                LOGE("OffsetOfJavaVM Unexpected android version %d", SDK_INT);
                abort();
        }
    } else {
        switch (SDK_INT) {
            case ANDROID_T:
            case ANDROID_SL:
            case ANDROID_S:
                return sizeof(void *) == 8 ? 520 : 300;
            case ANDROID_R:
            case ANDROID_Q:
                return sizeof(void *) == 8 ? 496 : 288;
            default:
                LOGE("OffsetOfJavaVM Unexpected android version %d", SDK_INT);
                abort();
        }
    }
}
template
int findOffset(void *start, size_t len, size_t step, T value) {
    if (nullptr == start) {
        return -1;
    }
    for (int i = 0; i <= len; i += step) {
        T current_value = *reinterpret_cast((size_t) start + i);
        if (value == current_value) {
            return i;
        }
    }
    return -1;
}
/**
* 根據runtime獲取class_linker
* https://github.com/magician8520/BlackBox/blob/99f26925aa303fd0a71543e3713ef3fc57a08e81/Bcore/pine-core/src/main/cpp/android.h#L36
*/
void *getClassLinker() {
    if (class_linker_ != nullptr) {
        return class_linker_;
    }
    int SDK_INT = get_sdk_level();
    // If SmallIrtAllocator symbols can be found, then the ROM has merged commit "Initially allocate smaller local IRT"
    // This commit added a pointer member between `class_linker_` and `java_vm_`. Need to calibrate offset here.
    // https://android.googlesource.com/platform/art/+/4dcac3629ea5925e47b522073f3c49420e998911
    // https://github.com/crdroidandroid/android_art/commit/aa7999027fa830d0419c9518ab56ceb7fcf6f7f1
    bool has_smaller_irt = getSymCompat(getlibArtPath(),
                                        "_ZN3art17SmallIrtAllocator10DeallocateEPNS_8IrtEntryE") !=
                           nullptr;
    size_t jvm_offset = OffsetOfJavaVm(has_smaller_irt, SDK_INT);
    auto runtime_instance_ = *reinterpret_cast
    (getSymCompat(getlibArtPath(), "_ZN3art7Runtime9instance_E"));
    auto val = jvm_offset
               ? reinterpret_cast *>(
                       reinterpret_cast(runtime_instance_) + jvm_offset)->get()
               : nullptr;
    if (val == getVm()) {
        LOGD("JavaVM offset matches the default offset");
    } else {
        LOGW("JavaVM offset mismatches the default offset, try search the memory of Runtime");
        int offset = findOffset(runtime_instance_, 1024, 4, getVm());
        if (offset == -1) {
            LOGE("Failed to find java vm from Runtime");
            return nullptr;
        }
        jvm_offset = offset;
        LOGW("Found JavaVM in Runtime at %zu", jvm_offset);
    }
    const size_t kDifference = has_smaller_irt
                               ? sizeof(std::unique_ptr) + sizeof(void *) * 3
                               : SDK_INT == ANDROID_Q
                                 ? sizeof(void *) * 2
                                 : sizeof(std::unique_ptr) + sizeof(void *) * 2;
    class_linker_ = *reinterpret_cast(reinterpret_cast(runtime_instance_) +
                                               jvm_offset - kDifference);
    return class_linker_;
}
bool call_MethodRegister(JNIEnv *env, void *art_method, void *native_method) {
    if (art_method_register == nullptr) {
        if (get_sdk_level() < ANDROID_S) {
            //android 11
            art_method_register = getSymCompat(getlibArtPath(),
                                               "_ZN3art9ArtMethod14RegisterNativeEPKv");
            if (art_method_register == nullptr) {
                art_method_register = getSymCompat(getlibArtPath(),
                                                   "_ZN3art9ArtMethod14RegisterNativeEPKvb");
            }
        } else {
            //12以上還是在libart里面,但是在linker里面實現,符號名稱存在變化
            art_method_register = getSymCompat(getlibArtPath(),
                                               "_ZN3art11ClassLinker14RegisterNativeEPNS_6ThreadEPNS_9ArtMethodEPKv");
        }
        if (art_method_register == nullptr) {
            LOG(ERROR) << "register native method  get art_method_register = null  ";
            return false;
        }
    }
    if (get_sdk_level() >= ANDROID_S) {
        //12以上
        //const void* RegisterNative(Thread* self, ArtMethod* method, const void* native_method)
        auto call = reinterpret_cast                                               void *)>(art_method_register);
        //get self thread
        void *self = getSymCompat(getlibArtPath(), "_ZN3art6Thread14CurrentFromGdbEv");
        if (self == nullptr) {
            LOG(ERROR) << "register native method  get CurrentFromGdb = null  ";
            return false;
        }
        //手動計算一下linker實例地址
        void *classLinker = getClassLinker();
        if (classLinker == nullptr) {
            LOG(ERROR) << "register native method  get getClassLinker = null  ";
            return false;
        }
        call(classLinker, self, art_method, native_method);
        //LOG(ERROR) << "register native method  get getClassLinker success!  ";
    } else if (get_sdk_level() >= ANDROID_R) {
        auto call = reinterpret_cast(art_method_register);
        call(art_method, native_method);
    } else {
        auto call = reinterpret_cast(art_method_register);
        call(art_method, native_method, true);
    }
    return true;
}
inline static bool IsIndexId(jmethodID mid) {
    return ((reinterpret_cast(mid) % 2) != 0);
}
static jfieldID field_art_method = nullptr;
bool RegisterNativeMethod(JNIEnv *env,
                          jclass clazz,
                          const JNINativeMethod *methods,
                          size_t nMethods) {
    if (env == nullptr) {
        LOG(ERROR) << "register native method  JNIEnv = null  ";
        return false;
    }
    void *arm_method = nullptr;
    for (int i = 0; i < nMethods; i++) {
        jmethodID methodId = env->GetMethodID(clazz, methods[i].name, methods[i].signature);
        if (methodId == nullptr) {
            //maybe static
            env->ExceptionClear();
            methodId = env->GetStaticMethodID(clazz, methods[i].name, methods[i].signature);
            if (methodId == nullptr) {
                LOG(ERROR) << "register native method  get orig method  == null  "
                           << methods[i].signature;
                env->ExceptionClear();
                return false;
            }
        }
        if (get_sdk_level() >= ANDROID_R) {
            if (field_art_method == nullptr) {
                jclass pClazz = env->FindClass("java/lang/reflect/Executable");
                field_art_method = env->GetFieldID(pClazz, "artMethod", "J");
            }
            if (field_art_method == nullptr) {
                LOG(ERROR) << "register native method  get artMethod  == null  ";
                return false;
            }
            if (IsIndexId(methodId)) {
                jobject method = env->ToReflectedMethod(clazz, methodId, true);
                arm_method = reinterpret_cast(env->GetLongField(method, field_art_method));
                //LOG(ERROR) << "arm_method   "<            }
        } else {
            arm_method = methodId;
        }
        if (arm_method == nullptr) {
            LOG(ERROR) << "register native method art method  == null  ";
            return false;
        }
        if (!call_MethodRegister(env, arm_method, methods[i].fnPtr)) {
            LOG(ERROR) << "register native method fail  " <<
                       methods[i].name << "  " << methods[i].signature;
            return false;
        }
//        LOG(INFO) << "register native method success  " << methods[i].name << "  "
//                  << methods[i].signature;
    }
    return true;
}


四
風控“貓鼠游戲”規則解密

我們假設一個場景,在一個農場里面有一些糧倉。

這些糧倉有一些貓在守護,貓的角色任務是守護好糧倉里面的糧食,而老鼠的任務是為了填飽肚子偷糧(“抓數據”)。

當然,農場很大,有很多糧倉,很多只貓,有的保護的糧倉有大有小,保存的糧食也不一樣(“不同數據”)。

當然也會有很多很多的老鼠,老鼠的數量肯定是比貓多的。貓通過眼睛和耳朵(“情報”)去探查消息 ,不同老鼠的氣味(“設備風險標簽”)的方式去抓不同的老鼠。

防止老鼠去通過一些特殊的手段去偷糧 ,規則是每個老鼠每天只能拿一粒米 。

當然有的老鼠很笨,他每一次只拿1粒米,貓也不會去管 。但是有的老鼠很聰明,可以通過逃逸“氣味”的方式,每次用不同身份去偷米,偷得次數多了,貓發現米變少了,貓就需要去找到這個老鼠的行動方式,或者他身上的“氣味”,去查看他是否存在作弊的情況。

可以給老鼠一些假大米(“臟數據”)去定位,有很多老鼠不知道自己的大米有問題,正在吃的時候就被貓抓到了,或者對老鼠的搬運速度進行限制(“請求速度”),或者當發現某個老鼠帶著包裹進來糧倉的時候都進行限制(“策略”)。

當然老鼠也會有更多辦法,每次可以不帶包裹進入,換一些別的可以攜帶的大量米粒的方式進入。

這時候貓就需要去檢查都有哪些可以裝數據的辦法(”定制策略“)去分析老鼠的行為,看看不同的老鼠都在都在做什么。當然每次定制的策略都不一樣,老鼠也不知道,只有貓知道,所以老鼠一直處在明,而貓在暗。

時間久了,貓很聰明,需要根據不同“氣味”去定位不同的老鼠,如果發現同一個氣味的老鼠進入多次,拿到的糧食過多,的時候,就下令將這只老鼠永久不得入內。

那么你現在演的是老鼠?你應該如何偷到更多的大米呢?

你如果演的是貓,你應該如何保護自己的糧食不被偷呢?

首先我們先說老鼠角色如何勝利:

這場戰斗中其實有一點很明顯,就是貓是通過不同的“氣味”去定位這個老鼠是否存在風險 ,如果可以去掉本身的氣味,讓“設備風險標簽”失效老鼠就贏了 ,這場游戲局勢便從老鼠在明,貓在暗。
轉換立場,變成貓在明,老鼠在暗。在配合多只老鼠即可實現大量糧食的盜取。 

貓的勝利規則:

不斷找出變化“氣味”的老鼠,通過不同老鼠的氣味,實現最準確的判斷,影響老鼠的數據搬運,分子和分母越大,找出作弊老輸越準確,覆蓋率越高,貓得到的獎勵越多。
五
無Root情況客戶端對抗的邊界值在哪里?

ok 經過上面的例子總結和反思,我們發現一個問題,如果在不Root的情況下,注入方法主要兩種,重打包或者把Apk放到沙箱里面。并且在不修改系統文件,那么我應該如何修改“氣味”呢?

決定貓和老鼠明暗關系位置的關系本質上是 “氣味”主導因素。這個“設備風險標簽”是這場游戲中決定勝敗的主要因素,如果“設備風險標簽” 是沒問題,也配合一些多開軟件,云手機等控制多只老鼠即可。

這里面的標簽分為很多種。每個子項又分為很多小項,不同的標簽顏色不同或者說不同價值的標簽對不同貓咪的反應程度也不一樣。比如重打包這種標簽,在一些高度敏感的場景,會直接被貓進行封號。

主要的三個核心功能組成水桶木板分別如下。

◆設備指紋

◆環境&風險檢測能力

◆代碼防護

第一項每個大廠都不一樣,根據不同的策略每個字段的比重占比也都不一樣。

把一些常見的或者第一篇和第二篇提到的對著改一下即可。

第三項現在So層基本大廠都差不多,都是各種混淆配合控制流,但是Java層防護做的不夠,java層其實可以參考我之前19年搞的Java控制流混淆。https://bbs.kanxue.com/thread-255514.htm,可以直接廢掉Jadx反編譯軟件。

這塊重點介紹一下第二項,包括一些常見的子項,每個子項還可以繼續劃分各種檢測方式。

◆環境&風險檢測能力

重打包檢測能力

Hook檢測能力 

模擬器&云手機&自定義ROM檢測能力

多開&沙箱檢測能力

風險Apk檢測能力

上面說的這幾項便是不同”氣味“的組成部分,而這個”氣味”采集方式的邊界值又分為三部分。

◆Java層就是IPC協議 ,因為IPC協議是當前進程可以操作的最后一個方法 。剩下的就是服務端給喂數據了。

◆Native層就是SVC攔截,因為SVC是Linux進入內核的最后一條指令。

◆還有一種是讀文件 ,這塊區分成兩部分

  • 進程文件,也就是/proc/下面的
  • 系統文件,系統提供的一些文件可供讀取

好 ,根據上面的總結,只要我們在上面的三個邊界值進行攔截理論上就是最完美的方案。

很多小白基本都是遇到一個指紋,咦,發現自己沒有修改。趕緊去Hook修改一下。去打個補丁,在我看來這是一種很Low的辦法。繞來繞去人家采集一個字段有N種手段,很容易導致遺漏,特別是一些大廠基本采集一個字段都是N種獲取方式,就比如第二篇文章里面的磁盤大小,或者Android id的獲取五種方式。

你打了一個補丁補上去,在其他地方設備指紋或者環境風險又泄漏了,最后代碼寫的破破爛爛。所以在邊界值修改對應的值是最完美的方案。先把架子搭好了,后面發現什么直接在邊界值處的callback進行修改即可。

六
“邊界值”攔截技術實現

下面的架子是我自己的沙箱的設計模式,這塊也是分享一下對應的“架構” 。

如果說你想實現上面的功能按照我的架子去搭建應該是比較完美的方案 ,畢竟我已經把坑踩得差不多了。

IPC協議攔截:

先說IPC,IPC的話很簡單,我在上面也說了可以動態代理,也可以直接去用Hook框架Hook binder里面的交互方法。當發現觸發指定的IPC協議的時候,直接模擬服務端往里面寫入即可。

這塊還有個細節點,為了防止程序直接通過cache獲取,因為有的字段初始化以后可能被保存到cache里面,如果不存在的話再通過ipc去獲取。Apk在啟動一瞬間就進行了初始化,cache會被保存。很多IPC代理人會這么設計,所以需要清理掉cache,這個cache可以是Parcel的cache也可以是IPC代理人里面的cache。比如Parcel里面的mCreators 或者sPairedCreators 都需要清空。如果是IPC代理人的話也可以看代碼看具體實現,看看是否包含cache,有的話清掉即可。

SVC攔截:

這塊不多說了,我之前的帖子里面介紹過,主要用的是ptrace+seccomp做的架子。

https://bbs.kanxue.com/thread-273160.htm詳細文章可以看這個。

文件讀取:

這塊分為兩部分,proc下的文件我會使用fuse 對整個proc進行模擬,這是完美方案。proot代碼寫好現成的,遷移到android 上直接用就好了。

如果是讀取系統文件的話,可以直接使用 IO重定向配合SVC攔截即可,SVC都可以攔截了,任何文件讀取你都可以隨便修改。

因為不管如何,最終都會調用到系統內核去讀取文件,都會被轉換成SVC指令。

七
總結

這三篇文章也是我自己對風控對抗的一點點見解。如果你三篇文章都讀完,應該是有那么億點收獲的。

直接通過協議的話肯定是白費,很多心跳和埋點沒辦法完全模擬。

如果想做自動化的方式,怎么把自己模擬的更像一個真實的“老鼠” ,比如可以在自動化點擊的記錄一些人手操作的路徑,而不是單純地去點擊。在點擊過程中添加一些隨機路徑,這些都是很不錯的對抗手段。

IP啥的能用真實的電話卡肯定是最好的,還有賬號權重問題。這些也都需要去解決。

不過還好,只要風險標簽可以做到逃逸,其他的都是小問題,只要花點時間都可以解決。

因為在你做到標簽逃逸以后,頭疼的就不是老鼠,而是貓了。

這時候有人可能會問,我應該如何知道我自己的設備是否存在問題呢?

你可以用我的Hunter去檢測自己的設備是否存在問題。Hunter會把每一個可能存在的風險項都展示出來,對著改就可以了。