Stories

Detail Return Return

遊戲性能優化與逆向分析技術 - Stories Detail

【USparkle專欄】如果你深懷絕技,愛“搞點研究”,樂於分享也博採眾長,我們期待你的加入,讓智慧的火花碰撞交織,讓知識的傳遞生生不息!


一、前言

一直以來性能優化的工作,非常依賴於工具,從結果反推過程,採集產品運行時信息,反推生產環節中的問題,性能問題的定位其實就是在做各種逆向。

不同的工具有不同的檢測面,一般會按照由粗及細的順序使用,直到找到問題的答案。

  • 粗粒度的工具,可大致定位到問題是出在哪個硬件上,比如發熱問題,可能的負載點在於CPU、GPU、其它硬件(屏幕、傳感器、網絡),一般應該是系統級的工具,常用的有Perfetto、Xcode、GamePerf、PerfDog。
  • 細粒度的工具,檢測面較窄,但能提供更深入的信息,比如:定位到是CPU的問題時,可使用Unity Profiler、Simpleperf看問題堆棧;當定位到是GPU的問題時,則使用RenderDoc、SnapdragonProfiler、Arm Graphics Analyzer截幀。

打個比喻,粗粒度的工具好比地鐵,能帶你到大致的區域範圍,更細粒度的工具幫你解決最後一公里路,在實際情況中,“打通”一公里的問題往往是卡點,通用性質的工具可能滿足不了需求,常常做一些定製化的東西,通過一定積累,形成強大的工具鏈以應對各種突發問題,本文主要對於這些底層的技術棧做一些總結。

二、動態庫注入

Android系統的數據基本都能通過讀各種文件實現(統計線程,讀取CPU利用率/頻率),但有嚴格的權限限制,非root環境下,只能讀取自己進程相關的文件、內存信息。

我們注入到目標進程的動態庫,就好像我們派出的“間諜”一樣,利用目標進程的身份執行我們自己的代碼。

使用JDWP Shellifier是最常用的方式,我們用C++在NDK環境下編寫一個動態庫so文件,這個腳本利用Java調試服務加載我們自己的庫。這也是RenderDoc、‌LoliProfiler、Matrix用的方式,需要應用Debug權限,或者root開全局調試,或者使用APKTool,解包修改AndroidManifest文件的Debug權限。

https://github.com/IOActive/jdwp-shellifier

這個腳本用Python封裝了注入過程,在onCreate函數觸發時,加載我們的庫。

jdwp_start("127.0.0.1", 500, "android.app.Activity.onCreate", None, libname)

控制枱輸出顯示注入成功:

當動態庫注入成功時,C++側入口函數JNI_OnLoad會被執行,我們就可以幹自己想幹的事情了,這只是打開大門的第一步。

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved)
{
    (void)reserved;
    LOGI("JNI_OnLoad");

    JNIEnv *env;
    LOGI("------------------ 4000 : %d", (int)JNI_VERSION_1_6);
    if (vm->GetEnv((void **)&env, JNI_VERSION_1_6) != JNI_OK)
    {
        LOGI("JNI version not supported");
        return JNI_ERR; // JNI version not supported.
    }
    else
    {
        LOGI("JNI init complete");
    }
}

下一步介紹Hook技術,俗稱鈎子,能對特定函數劫持,兩種常見Hook手段為PLT Hook、Inline Hook。

三、PLT Hook

先大概講一下程序調用動態鏈接庫中函數的流程,以libunity.so中調用libc.so的Open函數為例子:會先訪問PLT(Procedure Linkage Table),第一次訪問它會使用動態連接器查找libc.so中Open函數的地址,然後地址保存到GOT(Global Offset Table)地址表,之後的調用就直接查GOT表了,如下:

所謂的PLT Hook就是在這個過程做文章、鑽空子,比如xHook就是修改GOT表的函數地址為我們的自定義函數實現攔截,xHook是一個常用的庫,較多運用於各種工具底層實現,我們可以直接使用它,同時它也是開源的,我們可以參考它裏面的很多代碼。

https://github.com/iqiyi/xHookgithub.com/iqiyi/xHook

PLT Hook比較適合去Hook一些公用庫的調用,不管上層怎麼變,IO的行為最終落地到對Open、Close、Read、Wirte的調用,實際項目中主要用於IO、內存分配、線程、網絡等行為的監控,但它的侷限性在於不能Hook內部函數,比如引擎內部的函數調用。

四、實戰:打印引擎啓動時的IO調用

隨便創建一個空的Demo,打包APK,將下面C++代碼通過NDK編譯成動態庫後,使用JDWP注入運行。

這裏在JNI_OnLoad函數創建一個新的線程,延遲3秒後再執行Hook的動作,是因為時機太早libunity.so未加載會導致失敗(據説xHook的作者後續開發了一個新的庫叫bHook,改進了這一點)。

#include <jni.h>
#include <dlfcn.h>
#include "xhook/xhook.h"
#include <thread>
#include <unistd.h>
#include <fcntl.h>
#include <android/log.h>

int MyOpen(const char *pathname, int flags, mode_t mode)
{
    int ret = open(pathname, flags, mode);
    __android_log_print(ANDROID_LOG_INFO, "TestHook", "unity open %s %d", pathname, ret);
    return ret;
}

void TestHook()
{
    // 延遲3秒,等待Unity加載完成
    std::this_thread::sleep_for(std::chrono::seconds(3));

    // 對Open函數Hook註冊
    xhook_register("libunity.so", "open", (void *)MyOpen, nullptr);

    // 執行Hook
    xhook_refresh(0);
}

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved)
{
    JNIEnv *env;
    if (vm->GetEnv((void **)&env, JNI_VERSION_1_6) != JNI_OK)
    {
        return JNI_ERR; // JNI version not supported.
    }

    std::thread(TestHook).detach();

    return JNI_VERSION_1_6;
}

這樣我們可以觀察到Unity啓動時加載的一些東西:

正在加載obb文件:

正在加載il2cpp.so:

五、Inline Hook

前面提到,PLT Hook不能Hook到庫內部的函數調用,這個時候就應該輪到Inline Hook出場,它是通過對目標函數地址插入跳轉指令實現,理論上可以Hook住任意內部函數,功能更為強大,由於涉及到在不同CPU架構上的運行狀態機器碼修改,看起來很複雜,其實一點也不簡單,雖兼容性不如PLT Hook,不推薦在生產環境使用,但作為測試環境中的性能工具還是很強的。

ShadowHook是我常用的庫,可以將它的C++源碼下載下來,和自己庫一起編譯。

https://github.com/bytedance/android-inline-hook

如果Hook的目標庫是帶符號表的,可以通過函數名hook,像這樣:

stub = shadowhook_hook_sym_name(
               "libart.so",
               "_ZN3art9ArtMethod6InvokeEPNS_6ThreadEPjjPNS_6JValueEPKc",
               (void *)proxy,
               (void **)&orig);

但是我們常見的libunity.so、libil2cpp.so的符號表是分離的,可以嘗試用llvm-objcopy合併回去,這裏更推薦另一種做法,ShadowHook也可以直接通過函數地址進行Hook

void *shadowhook_hook_func_addr(
    void *func_addr,
    void *new_addr,
    void **orig_addr);

這裏的func_addr函數地址是絕對地址,為動態庫基地址、函數偏移地址之和,找到這兩個地址加起來就行。

動態庫基地址每次進程啓動都不一樣,需要我們在程序中動態獲取,可以通過dl_iterate_phdr(Android 5.0以上)獲取,也可以讀/proc/self/maps實現(Android 4.0版本以上),之前介紹的xHook有源碼可以抄一下。

/proc/self/maps能查詢到動態庫基地址:

而函數的偏移地址可以使用NDK下llvm-readelf -s指令,讀取符號表獲取到:

readelf讀取出的引擎內部函數地址:

接下來,對函數Hook後,需要對參數進行內存分析提取裏面的有用信息,如果有源碼,就是開卷考試,按照其內存佈局定義出來;沒源碼,我們也可以通過一些技巧把信息提取出來,下面以實戰説明一下。

六、實戰:統計引擎內部調用

我曾經在《使用Simpleperf+Timeline診斷遊戲卡頓》[1]這一篇文章中提到過,一些常見的卡頓歸因,能通過Simpleperf識別,但我們只知道觸發堆棧,今天我們更進一步。

這裏以AddComponent函數為例,做一個Demo,然後嘗試使用Hook把觸發的GameObject、組件名字都打印出來,C# 測試代碼如下:

// New Game Object節點添加一些Unity內置組件
var go = newGameObject();
go.AddComponent<MeshFilter>();
go.AddComponent<MeshRenderer>();
go.AddComponent<MeshCollider>();
// 相機節點添加一個自定義腳本組件
gameObjet.AddComponent<TestCom>();

通過Simpleperf鎖定我們的目標函數為AddComponent(GameObject&, Unity::Type const*, ScriptingClassPtr, core::basic_string<char, core::StringStorageDefault<char> >*)

Simpleperf-Timeline查看命中的native函數:

接下來通過llvm-readelf -s指令,查詢函數在符號表中的位置,名字稍微和Simpleperf中的顯示形式有點區別,但是我們還是能認出它,它的地址就是0x5126a4。

搜索符號表內AddComponent函數地址:

接下來,我們需要在代理函數裏面,對函數參數做一些解析,從函數簽名可以看到,參數有4個:void *go、void *unitytype、void *scriptclassptr和void *error。

我們的目標是獲取節點名和組件名,解析前3個就行,主要有兩種方案:

  1. 在符號表裏多收集一些工具函數地址,比如獲取GameObject名字的方法0x435010,這個方法傳入GameObject對象指針作為參數,返回名字字符串,所以可以把這個函數地址存起來,直接調用,我管這叫“他山之石,可以攻玉”。

獲取GameObject名字的方法地址能輕易搜索到:

  1. 針對另外兩個參數,可以將結構直接定義出來使用,比如ScriptClass前兩個參數是指針,第三個就是C字符串。這些工作,在有相關源碼的情況下會容易很多,如果沒有的話,只能通過LLDB無源碼動態調試之類的手段來獲取其內存佈局,會涉及到一些二進制分析手段、工具。

有了這些準備工作,就可以開始編碼了:

#include <jni.h>
#include <dlfcn.h>
#include "shadowhook.h"
#include <thread>
#include <unistd.h>
#include <fcntl.h>
#include <android/log.h>
#include <link.h>

classScriptclass
{
    public:
        void *placeholder1;
        void *placeholder2;
        constchar *name;
};

classUnityType
{
    public:
        void *placeholder1;
        void *placeholder2;
        constchar *name;
};

uintptr_t baseaddr = 0;
int callback(struct dl_phdr_info *info, size_t size, void *data)
{
    constchar *target = (constchar *)data;

    // Check if the current shared library is the target library
    if (strstr(info->dlpi_name, target))
    {
        __android_log_print(ANDROID_LOG_INFO, "TestHook", "Base address of %s: 0x%lx\n", target, (unsigned long)info->dlpi_addr);
        baseaddr = info->dlpi_addr;
        return1; // Return 1 to stop further iteration
    }

    return0; // Continue iteration
}

void *old_AddComponent = nullptr;
typedef void *(*AddComponentFunc)(void *go, void *unitytype, void *scriptclassptr, void *error);
typedef constchar*(*GameObjectGetNameFunc)(void *ptr);

void *MyAddComponent(void *go, void *unitytype, void *scriptclassptr, void *error)
{
    constchar *goName = nullptr;
    constchar *typeName = nullptr;

    if(go != nullptr)
    {
        // 計算GameObjectGetName的地址
        uintptr_t addr = baseaddr + 0x435010; 
        // 調用GameObjectGetName獲取名稱
        GameObjectGetNameFunc func = (GameObjectGetNameFunc)(addr);
        goName = func(go);
    }

    if (scriptclassptr != nullptr)
    {
        Scriptclass *t = (Scriptclass *)scriptclassptr;
        typeName = t->name;
    }
    elseif (unitytype != nullptr)
    {
        UnityType *t = (UnityType *)unitytype;
        typeName = t->name;
    }

    if(goName == nullptr)
        goName = "null";

    if(typeName == nullptr)
        typeName = "null";

    __android_log_print(ANDROID_LOG_INFO, "TestHook", "UnityAddComponent: %s %s\n", goName, typeName);

    return ((AddComponentFunc)old_AddComponent)(go, unitytype, scriptclassptr, error);
}

void TestHook()
{
    // 延遲3秒,等待Unity加載完成
    std::this_thread::sleep_for(std::chrono::seconds(3));

    // 查詢libunity的基地址
    constchar *library_name = "libunity.so";
    dl_iterate_phdr(callback, (void *)library_name);

    // 計算AddComponent的函數地址
    uintptr_t addr = baseaddr + 0x5126a4;

    // 執行Hook並保存原函數地址到old_AddComponent
    void *stub = shadowhook_hook_func_addr((void *)addr, (void *)MyAddComponent, (void **)&old_AddComponent);
    if (stub == nullptr)
    {
        int err_num = shadowhook_get_errno();
        constchar *err_msg = shadowhook_to_errmsg(err_num);
        __android_log_print(ANDROID_LOG_INFO, "TestHook", "hook error %d - %s\n", err_num, err_msg);
    }
    else
    {
        __android_log_print(ANDROID_LOG_INFO, "TestHook", "hook success\n");
    }
}

JNIEXPORT jint JNICALL JNI_OnLoad(JavaVM *vm, void *reserved)
{
    JNIEnv *env;
    if (vm->GetEnv((void **)&env, JNI_VERSION_1_6) != JNI_OK)
    {
        return JNI_ERR; // JNI version not supported.
    }

    // 初始化Shadowhook
    int ret = shadowhook_init(SHADOWHOOK_MODE_UNIQUE, true);
    if (ret != 0)
    {
        constchar *err_msg = shadowhook_to_errmsg(shadowhook_get_init_errno());
        __android_log_print(ANDROID_LOG_INFO, "TestHook", "init error %d - %s\n", shadowhook_get_init_errno(), err_msg);
    }
    else
    {
        __android_log_print(ANDROID_LOG_INFO, "TestHook", "init success\n");
    }

    std::thread(TestHook).detach();

    return JNI_VERSION_1_6;
}

和前面PLT Hook的例子一樣,使用JDWP注入執行,最終可以輸出Demo中調用AddComponet的參數詳情,利用這些信息,接下來就可以做很多事情了,我們現在可以幾乎Hook任意函數!

控制枱最終能正常輸出節點、組件名:

七、棧回溯

在棧上每個函數都有自己的儲存空間,被稱之為棧幀(Frame),上面保存了部分參數、局部變量。當調用其它函數時,會將這個函數返回後的下一行指令地址也保存在棧幀,棧回溯就是分析這些棧上面函數地址,還原函數運行軌跡的過程。

函數A調用函數B,0x40056a是函數B結束後返回的地址:

棧回溯經常和Hook一起配合,當Hook住某個函數後,輸出它的調用棧,能更進一步分析問題歸因,如果對性能要求不高,可以直接使用libunwind庫,它在不需要開-fno-omit-frame-pointer編譯選項、dwarf調試信息的情況下,也能輸出函數地址,然後我們通過符號表將函數名解析出來。

#include <unwind.h>
#include <android/log.h>

// 棧回溯上下文結構
struct BacktraceState
{
    void **current;
    void **end;
};

static _Unwind_Reason_Code UnwindCallback(struct _Unwind_Context *context, void *arg)
{
    BacktraceState *state = static_cast<BacktraceState *>(arg);
    uintptr_t pc = _Unwind_GetIP(context);
    if (pc)
    {
        if (state->current == state->end)
        {
            return _URC_END_OF_STACK;
        }
        else
        {
            *state->current++ = reinterpret_cast<void *>(pc);
        }
    }
    return _URC_NO_REASON;
}

size_t CaptureBacktrace(void **buffer, size_t max)
{
    BacktraceState state = {buffer, buffer + max};
    _Unwind_Backtrace(UnwindCallback, &state);
    return state.current - buffer;
}

void DumpBacktrace(std::ostream &os, void **buffer, size_t count)
{
    for (size_t idx = 0; idx < count; ++idx)
    {
        constvoid *addr = buffer[idx];
        constchar *symbol = "";

        Dl_info info;
        if (dladdr(addr, &info) && info.dli_sname)
        {
            symbol = info.dli_sname;
        }

        // 這裏將函數的絕對地址轉換為相對地址
        uintptr_t relative = (uintptr_t)addr - (uintptr_t)info.dli_fbase;
        os << "  #" << std::setw(2) << idx << ": " << info.dli_fname << " " << (void *)relative << "\n";
    }
}

// 經封裝後的打印函數
void PrintStacktrace(const size_t count)
{
    void* buffer[count];
    std::ostringstream oss;
    DumpBacktrace(oss, buffer, CaptureBacktrace(buffer, count));
    __android_log_print(ANDROID_LOG_INFO, "TestHook", oss.str().c_str());
}

棧回溯的步驟雖然看起來繁瑣,但只要經過封裝後,使用起來其實和在C# 裏面一樣方便,下一步我們來試一下。

八、實戰:為IO調用加入棧統計

沿用之前的PLT Hook的例子,這次我們將調用堆棧打印出來:

調用封裝好的PrintStacktrace:

現在打印日誌裏多了調用棧函數地址:

使用NDK目錄下的addr2line.exe對這些地址進行解析,最終得到我們想要的結果。

LocalFileSystemPosix::Open(FileEntryData&, FilePermission, FileAutoBehavior)
zip::CentralDirectory::Enumerate(bool (*)(FileSystemEntry const&, FileAccessor&, char const*, zip::CDFD const&, void*), void*)
VerifyAndMountObb(char const*)
MountObbs()
UnityPause(int)
UnityPlayerLoop()
nativeRender(_JNIEnv*, _jobject*)

九、結語

本文從以性能優化分析目的入手,介紹了常用的逆向分析手段 —— 注入、Hook、堆棧回溯,這裏只是淺顯地聊了一下運用場景,事實上每一個坑都能挖到很深,比如注入與反注入,如何對競品進行注入,Hook的相關調試方法、內存分析、更高性能的棧回溯、聚合顯示(火焰圖)等等。

之所以總結此文,是因為我在近期的工作中感覺到,瞭解一點逆向分析的知識,對性能優化、程序調試方面很有好處,也不侷限於遊戲開發領域,技多不壓身。

參考
[1] 使用Simpleperf+Timeline診斷遊戲卡頓
https://zhuanlan.zhihu.com/p/666443120


這是侑虎科技第1878篇文章,感謝作者其樂陶陶供稿。歡迎轉發分享,未經作者授權請勿轉載。如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ羣:793972859)

作者主頁:https://www.zhihu.com/people/jun-yan-76-80

再次感謝其樂陶陶的分享,如果您有任何獨到的見解或者發現也歡迎聯繫我們,一起探討。(QQ羣:793972859)

user avatar hankin_liu Avatar sihaihande Avatar hightopo Avatar beckyyyy Avatar xiangyuecn Avatar htdaydayup_5da2d7a6d4888 Avatar
Favorites 6 users favorite the story!
Favorites

Add a new Comments

Some HTML is okay.