Linker加載so失敗問題分析


WeTest 導讀

近期測試反饋一個問題,在舊版本微視基礎上覆蓋安裝新版本的微視APP,首次打開拍攝頁錄制視頻合成時高概率出現crash。

 


 

那么我們直奔主題,看看日志:

 

 

另外復現的日志中還出現如下信息:

'/data/data/com.tencent.weishi/appresArchiveExtra/res1bodydetect/bodydetect/libxnet.so: strtab out of bounds error

 

 

后經過測試,發現覆蓋安裝后首次使用美體功能也會出現crash,日志如下:

 

 

由於出現問題的場景都是覆蓋安裝首次使用,並且涉及到人體檢測相關的so,似乎存在某種共同的原因。

 

因此Abort異常比起fault addr類問題更容易分析,先從前面Linker出現Abort異常的位置開始着手。

 

Linker是so鏈接和加載的關鍵,屬於系統可執行文件,因此分析起來比較棘手。好在手上正好有一台剛刷完自己編譯的Android AOSP的Pixel,做一些實驗變得更輕松了。

出現異常的Linker代碼linker_soinfo.cpp如下:

 

const char* soinfo::get_string(ElfW(Word) index) const {
 if (has_min_version(1) && (index >= strtab_size_)) {
   async_safe_fatal("%s: strtab out of bounds error; STRSZ=%zd, name=%d",
       get_realpath(), strtab_size_, index);
 }

 return strtab_ + index;
}

bool soinfo::elf_lookup(SymbolName& symbol_name,
                       const version_info* vi,
                       uint32_t* symbol_index) const {
 uint32_t hash = symbol_name.elf_hash();

 TRACE_TYPE(LOOKUP, "SEARCH %s in %s@%p h=%x(elf) %zd",
            symbol_name.get_name(), get_realpath(),
            reinterpret_cast<void*>(base), hash, hash % nbucket_);

 ElfW(Versym) verneed = 0;
 if (!find_verdef_version_index(this, vi, &verneed)) {
   return false;
 }

 for (uint32_t n = bucket_[hash % nbucket_]; n != 0; n = chain_[n]) {
   ElfW(Sym)* s = symtab_ + n;
   const ElfW(Versym)* verdef = get_versym(n);

   // skip hidden versions when verneed == 0
   if (verneed == kVersymNotNeeded && is_versym_hidden(verdef)) {
       continue;
   }

   if (check_symbol_version(verneed, verdef) &&
       strcmp(get_string(s->st_name), symbol_name.get_name()) == 0 &&
       is_symbol_global_and_defined(this, s)) {
     TRACE_TYPE(LOOKUP, "FOUND %s in %s (%p) %zd",
                symbol_name.get_name(), get_realpath(),
                reinterpret_cast<void*>(s->st_value),
                static_cast<size_t>(s->st_size));
     *symbol_index = n;
     return true;
   }
 }

 TRACE_TYPE(LOOKUP, "NOT FOUND %s in %s@%p %x %zd",
            symbol_name.get_name(), get_realpath(),
            reinterpret_cast<void*>(base), hash, hash % nbucket_);

 *symbol_index = 0;
 return true;
}

 

從代碼上看,是在so的symtab中查找某個符號時ElfW(Sym)* s的地址出現異常,導致s->st_name獲取到錯誤的數據。

 

通過復現問題,可以抓到更完整的 /data/tombstone日志,得到如下完整的信息:

 

 

盡管從tombstone中我們可以看到一些寄存器數據及寄存處地址附近內存數據,同時也可以看到crash時的虛擬內存映射表,仍然無法獲取有價值的信息。另外通過幾次復現,發現並不是每次Crash都是SIGABRT,也出現不少SIGSEGV信號,而調用棧和之前都是一樣的,比如這個:

 

 

這基本上可以說明,並不是so本身的代碼存在異常,只可能是加載的so出現了文件異常。

 

另外通過在linker中增加日志,並重新編譯linker替換到/system/lib/linker中:

 

 

可以獲取到如下的地址信息:

 

 

通過根據tombstone中的/proc/<poc>/maps的虛擬內存地址與日志打印的地址進行對比,可以發現最為符號表地址的s並沒有指向so文件在虛擬內存中的地址段,因此可以懷疑,so加載確實出現了異常。

 

因為手機root,可以直接獲取到crash時的so文件(adb pull /data/data/com.tencent.weishi/appresArchiveExtra/res1bodydetect/bodydetect/libxnet.so),導出來對比md5,然而發現與正常情況下的so是一模一樣的:

 

 

既然前面的這些實驗都沒有得出什么有意義的結論,那么我回過頭來分析一下,與問題關聯的so加載到底有什么特殊性。

 

實際上,微視為了減包,將一部分so文件進行下發,由於so也處於不斷迭代的過程中,新版本的微視可能會在后台更新so文件,那么客戶端一旦發現新的版本有新的so,就會去下載so並進行本地替換。

 

那么這個過程有什么問題呢?唯一可能的問題,就是先加載了舊的so,之后下載新的so進行了熱更新。

 

我們先看下微視中是否有這種現象。要觀察這種現象,我們可以打開linker自身的調試開關,開啟so加載的日志。通過設置系統屬性,我們可以很容易地進行開啟LD_LOG日志:

adb shell setprop debug.ld.all dlerror,dlopen

 

 

當然我們也可以只針對某個應用開啟這個日志(設置系統屬性debug.ld.app.)。另外,為了開啟linker中更多的日志,比如DEBUG打印的信息等,我們只需要在adb shell中設置環境變量:

export LD_DEBUG=10

 

 

 

那么,我們重新復現問題,可以看到如下so加載過程:

 

 

這個過程表明:舊的so先被加載了,然后下載了新版本的so,並進行了替換。

 

這個過程有什么問題呢?根據《理解inode》一文我們可以得知,linux的文件系統使用的inode機制支持了so文件的熱更新(動態更新),即每個文件都有一個唯一的inode號,打開文件后使用inode號區分文件而不是文件名:

 

八、inode的特殊作用

由於inode號碼與文件名分離,這種機制導致了一些Unix/Linux系統特有的現象。

 

1. 有時,文件名包含特殊字符,無法正常刪除。這時,直接刪除inode節點,就能起到刪除文件的作用。

2. 移動文件或重命名文件,只是改變文件名,不影響inode號碼。

3. 打開一個文件以后,系統就以inode號碼來識別這個文件,不再考慮文件名。因此,通常來說,系統無法從inode號碼得知文件名。

 

第3點使得軟件更新變得簡單,可以在不關閉軟件的情況下進行更新,不需要重啟。因為系統通過inode號碼,識別運行中的文件,不通過文件名。更新的時候,新版文件以同樣的文件名,生成一個新的inode,不會影響到運行中的文件。等到下一次運行這個軟件的時候,文件名就自動指向新版文件,舊版文件的inode則被回收。

 

但是問題就出在這里,如果替換文件使用的是cp這樣的操作,會導致原來的so文件截斷,然后重新寫入數據,但是inode並沒有更新號,磁盤與內存中的信息出現不一致,這種情況在linux中很常見,比如這篇文章就進行了分析:

 

1. cp new.so old.so,文件的inode號沒有改變,dentry找到是新的so,但是cp過程中會把老的so截斷為0,這時程序再次進行加載的時候,如果需要的文件偏移大於新的so的地址范圍會生成buserror導致程序core掉,或者由於全局符號表沒有更新,動態庫依賴的外部函數無法解析,會產生sigsegv從而導致程序core掉,當然也有一定的可能性程序繼續執行,但是十分危險。

 

2. mv new.so old.so,文件的inode號會發生改變,但老的so的inode號依舊存在,這時程序必須停止重啟服務才能繼續使用新的so,否則程序繼續執行,使用的還是老的so,所以程序不會core掉,就像我們在第二部分刪除掉log文件,而依然能用lsof命令看到一樣。

 

還有更深入的解釋:

 

Linux由於Demand Paging機制的關系,必須確保正在運行中的程序鏡像(注意,並非文件本身)不被意外修改,因此內核在啟動程序后會綁定 內存頁 到這個so的inode,而一旦此inode文件被open函數O_TRUNC掉,則kernel會把so文件對應在虛存的頁清空,這樣當運行到so里面的代碼時,因為物理內存中不再有實際的數據(僅存在於虛存空間內),會產生一次缺頁中斷。Kernel從so文件中copy一份到內存中去,a)但是這時的全局符號表並沒有經過解析,當調用到時就產生segment fault , b)如果需要的文件偏移大於新的so的地址范圍,就會產生bus error。

 

那么問題基本清晰了。我們在回去看看微視的代碼,這里下載了so之后直接unzip到原來的路徑,並沒有先進行rm操作。

 

更近一步,我們自己寫個demo測試下剛才的問題(2個按鈕,一個加載指定so,一個調用so中的native方法):

 

 

代碼不能再簡單了:

 

 

正常加載so然后執行native方法都是ok的,使用rm+mv替換或者adb push替換也都是ok的,最后再按照錯誤的方法操作,步驟為:

 

1. 啟動app,點擊加載so;

2. 通過cp命令替換so;

3. 點擊執行native方法;

 

 

結果確實是crash了:

 

 

日志如下,是不是很最開始的日志信息一樣呢:

 

 

到此,我們有兩種解決辦法:

1. 如果so有升級,先不加載舊的so,等新的so下載完成之后再加載;

2. 可以先加載舊的so,但是下載了新的so之后,要刪除舊的so,再進行替換。

 


 

 

目前,“自動化兼容測試” 提供雲端自動化兼容服務,提交雲端百台真機,並行測試。快速發現游戲/應用兼容性和性能問題,覆蓋安卓主流機型。

 

點擊:https://wetest.qq.com/product/auto-compatibility-testing 即可體驗。

 

如果使用當中有任何疑問,歡迎聯系騰訊WeTest企業QQ:2852350015


免責聲明!

本站轉載的文章為個人學習借鑒使用,本站對版權不負任何法律責任。如果侵犯了您的隱私權益,請聯系本站郵箱yoyou2525@163.com刪除。



 
粵ICP備18138465號   © 2018-2025 CODEPRJ.COM