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