iOS 移動(dòng)端的 bug 的排查辦法披蕉,你確定不看嗎?

前言

不積跬步無以至千里乌奇,不積小流無以成江海没讲。學(xué)如逆水行舟,不進(jìn)則退礁苗。我是平平無奇游蕩于各平臺(tái)的搬運(yùn)工爬凑。優(yōu)秀的人已經(jīng)點(diǎn)贊了。本文主要記錄了 iOS 移動(dòng)端的一個(gè)疑難 bug 的排查過程试伙,以及介紹通過給 bitcode 打補(bǔ)丁重新生成機(jī)器碼嘁信,為有問題的第三方庫修復(fù) bug 的方法。廢話不多說疏叨,直接給大家上干貨潘靖,希望能對(duì)你有所幫助,

主要涉及到的知識(shí)點(diǎn)如下:

  • ARM 匯編
  • C++ 運(yùn)行時(shí)
  • 靜態(tài)庫文件的結(jié)構(gòu)
  • bitcode 及 LLVM IR

\

平臺(tái)監(jiān)控找崩潰

通過內(nèi)部的崩潰監(jiān)控發(fā)現(xiàn)蚤蔓,有一個(gè)內(nèi)部 App卦溢,近期出現(xiàn)了較多的崩潰現(xiàn)象。其中數(shù)量占比最多的崩潰,其崩潰線程捕獲到的調(diào)用棧如下:

libsystem_kernel.dylib   0x00000001cc78c414 ___pthread_kill + 7
libsystem_c.dylib        0x00000001a7db2b74 _abort + 103
App                      0x0000000103092868 ___48-[BLYLogicManager abortAfterSendingReportIfNeed]_block_invoke + 87
libdispatch.dylib        0x000000019e60824c __dispatch_call_block_and_release + 31
libdispatch.dylib        0x000000019e609db0 __dispatch_client_callout + 19
libdispatch.dylib        0x000000019e61aa68 __dispatch_root_queue_drain + 655
libdispatch.dylib        0x000000019e61b120 __dispatch_worker_thread2 + 115
libsystem_pthread.dylib  0x00000001ea1e77c8 __pthread_wqthread + 215

\

調(diào)用現(xiàn)場(chǎng)出端倪

這個(gè)調(diào)用棧并沒有提供什么有效的信息既绕,只能看出來是 bugly 框架已經(jīng)檢測(cè)到了崩潰創(chuàng)建了新的 dispatch queue 并終止進(jìn)程,也就是說涮坐,其實(shí)有效的崩潰信息被 bugly 給吃掉了凄贩。

看一下其他線程,是否有可用的信息袱讹,一般可以在其他線程的調(diào)用棧上搜索以下內(nèi)容:

  1. _ZSt9terminateEv: C++ 的終端異常處理( std::terminate(void)
  2. __sigtramp: 信號(hào)中斷處理例程入口

終于搜索到了以下內(nèi)容:

Thread #52: id=1a6c6, name=
libsystem_kernel.dylib   0x00000001cc78cf5c ___ulock_wait + 7
libdispatch.dylib        0x000000019e60a528 __dispatch_thread_event_wait_slow + 55
libdispatch.dylib        0x000000019e618708 ___DISPATCH_WAIT_FOR_QUEUE__ + 351
libdispatch.dylib        0x000000019e6182b0 __dispatch_sync_f_slow + 147
App                      0x00000001030925f0 -[BLYLogicManager executeEmergencyLogic:] + 695
App                      0x000000010308b6a8 -[BLYCrashManager sendLiveCrashReport] + 203
App                      0x000000010305f478 _BLYCrashHandlerCallback + 5555
App                      0x000000010305bc2c _BLYBSDSignalHandlerCallback + 95
libsystem_platform.dylib 0x00000001ea1e1290 __sigtramp + 55
App                      0x00000001029543dc *redacted*
App                      0x00000001029543dc *redacted*
App                      0x00000001028a1918 *redacted*
App                      0x00000001027ea9c4 *redacted*
App                      0x00000001027ea794 *redacted*
App                      0x00000001027ead60 *redacted*
libsystem_pthread.dylib  0x00000001ea1e5b40 __pthread_start + 319

內(nèi)部應(yīng)用同時(shí)集成了 Bugly 和自有的崩潰捕獲疲扎,通常情況下 Bugly 會(huì)在自己捕獲完成后,將崩潰現(xiàn)場(chǎng)轉(zhuǎn)交給其他框架捷雕,使兩次捕獲的崩潰現(xiàn)場(chǎng)相同椒丧。而這個(gè)崩潰則不然生均, Bugly 捕獲了崩潰后肥荔,直接調(diào)用 abort 結(jié)束了應(yīng)用肆资,導(dǎo)致自有崩潰只捕獲到了 SIGABRT 凌简。

通過檢查主線程調(diào)用棧出牧,發(fā)現(xiàn)了一些不同:

Thread #0: id=1a0d3, name=
libsystem_kernel.dylib   0x00000001cc78c1ac ___psynch_cvwait + 7
libc++.1.dylib           0x00000001b3a25328 __ZNSt3__118condition_variable4waitERNS_11unique_lockINS_5mutexEEE + 27
App                      0x000000010280e5c8 *redacted*
App                      0x00000001027e9414 *redacted*
App                      0x00000001027e9380 *redacted*
libsystem_c.dylib        0x00000001a7d930b8 ___cxa_finalize_ranges + 423
libsystem_c.dylib        0x00000001a7d93400 _exit + 27
UIKitCore                0x00000001a13d4bdc -[UIApplication _terminateWithStatus:] + 503
UIKitCore                0x00000001a0a23648 -[_UISceneLifecycleMultiplexer _evalTransitionToSettings:fromSettings:forceExit:withTransitionStore:] + 127
UIKitCore                0x00000001a0a23278 -[_UISceneLifecycleMultiplexer forceExitWithTransitionContext:scene:] + 219
UIKitCore                0x00000001a13ca644 -[UIApplication workspaceShouldExit:withTransitionContext:] + 211
FrontBoardServices       0x00000001ae6d2780 -[FBSUIApplicationWorkspaceShim workspaceShouldExit:withTransitionContext:] + 87
FrontBoardServices       0x00000001ae701390 ___63-[FBSWorkspaceScenesClient willTerminateWithTransitionContext:]_block_invoke_2 + 79
FrontBoardServices       0x00000001ae6e54a0 -[FBSWorkspace _calloutQueue_executeCalloutFromSource:withBlock:] + 239
FrontBoardServices       0x00000001ae701328 ___63-[FBSWorkspaceScenesClient willTerminateWithTransitionContext:]_block_invoke + 131
libdispatch.dylib        0x000000019e609db0 __dispatch_client_callout + 19
libdispatch.dylib        0x000000019e60d738 __dispatch_block_invoke_direct + 267
FrontBoardServices       0x00000001ae72a250 ___FBSSERIALQUEUE_IS_CALLING_OUT_TO_A_BLOCK__ + 47
FrontBoardServices       0x00000001ae729ee0 -[FBSSerialQueue _targetQueue_performNextIfPossible] + 447
FrontBoardServices       0x00000001ae72a434 -[FBSSerialQueue _performNextFromRunLoopSource] + 31
CoreFoundation           0x000000019e99176c ___CFRUNLOOP_IS_CALLING_OUT_TO_A_SOURCE0_PERFORM_FUNCTION__ + 27
CoreFoundation           0x000000019e991668 ___CFRunLoopDoSource0 + 207
CoreFoundation           0x000000019e9909cc ___CFRunLoopDoSources0 + 375
CoreFoundation           0x000000019e98aa8c ___CFRunLoopRun + 823
CoreFoundation           0x000000019e98a21c _CFRunLoopRunSpecific + 599
GraphicsServices         0x00000001b648e784 _GSEventRunModal + 163
UIKitCore                0x00000001a13c8fe0 -[UIApplication _run] + 1071
UIKitCore                0x00000001a13ce854 _UIApplicationMain + 167
App                      0x00000001037dc93c *redacted*
App                      0x00000001032625c4 *redacted*
App                      0x00000001027bc618 main (main.swift:9:13)
libdyld.dylib            0x000000019e64a6b0 _start + 3

可以看到調(diào)用棧中有 exit 约郁,這表明應(yīng)用正在正常退出蜡饵。

調(diào)用棧中烂瘫,在 exit 這一項(xiàng)的上面精盅,可以看到 __cxa_finalize_ranges 帽哑,這是由 C++ 代碼產(chǎn)生的調(diào)用,通過 __cxa_atexit 注冊(cè)回調(diào)叹俏,在應(yīng)用退出時(shí)調(diào)用妻枕,用來正在進(jìn)行全局變量的銷毀。

由此可以看出粘驰,這是一個(gè)由于 C++ 全局變量在應(yīng)用退出時(shí)銷毀屡谐,導(dǎo)致其他線程引用到了銷毀的資源產(chǎn)生的崩潰。這也可以解釋為什么 Bugly 沒有把崩潰現(xiàn)場(chǎng)交給其他框架進(jìn)行處理了: Bugly 檢測(cè)到了應(yīng)用正在退出蝌数,直接調(diào)用了 executeEmergencyLogic: 方法康嘉,優(yōu)先保證自己的處理。

\

全局變量會(huì)析構(gòu)

__cxa_atexit 是 Itanium C++ ABI 運(yùn)行時(shí)規(guī)范的一部分籽前,它用來支持 C++ 語法中的全局變量亭珍。我們知道,C++ 對(duì)象分為 POD 和非 POD 兩類枝哄,其中 POD 以及 constexpr 構(gòu)造器可以在編譯期初始化肄梨,而非 constexpr 構(gòu)造的類型只能通過構(gòu)造器在運(yùn)行期間來構(gòu)造。C++ 對(duì)于這兩類對(duì)象挠锥,都支持它們作為全局變量并提供初始值众羡,那么這些全局變量就要在 dso 加載期間調(diào)用構(gòu)造器來初始化。

同樣地蓖租,為了防止內(nèi)存/資源泄漏粱侣,C++ 規(guī)定這樣初始化的全局變量要在 dso 卸載時(shí)析構(gòu)羊壹。

我們可以通過查看反匯編,對(duì)它的實(shí)現(xiàn)機(jī)制一探究竟齐婴。

舉個(gè)例子油猫,有如下 C++ 代碼:

#include <iostream>

class Test {
public:
    virtual ~Test();
    Test();
};

Test::Test() {}

Test::~Test() {
    std::cout << "Test: dtor" << std::endl;
}

static Test t = Test();

使用 clang++ 對(duì)以上文件進(jìn)行編譯,查看生成的匯編代碼:

xcrun clang++ -sdk iphoneos -arch arm64 1.cc -s -o 1.s

.section        __TEXT,__StaticInit,regular,pure_instructions
        .p2align        2                               ; -- Begin function __cxx_global_var_init
___cxx_global_var_init:                 ; @__cxx_global_var_init
        .cfi_startproc
; %bb.0:
        sub     sp, sp, #32                     ; =32
        stp     x29, x30, [sp, #16]             ; 16-byte Folded Spill
        add     x29, sp, #16                    ; =16
        .cfi_def_cfa w29, 16
        .cfi_offset w30, -8
        .cfi_offset w29, -16
        adrp    x0, __ZL1t@PAGE
        add     x0, x0, __ZL1t@PAGEOFF
        str     x0, [sp, #8]                    ; 8-byte Folded Spill
        bl      __ZN4TestC1Ev
        ldr     x1, [sp, #8]                    ; 8-byte Folded Reload
        adrp    x0, __ZN4TestD1Ev@PAGE
        add     x0, x0, __ZN4TestD1Ev@PAGEOFF
        adrp    x2, ___dso_handle@PAGE
        add     x2, x2, ___dso_handle@PAGEOFF
        bl      ___cxa_atexit                   ; ②
        ldp     x29, x30, [sp, #16]             ; 16-byte Folded Reload
        add     sp, sp, #32                     ; =32
        ret
        .cfi_endproc
                                        ; -- End function

        .section        __TEXT,__StaticInit,regular,pure_instructions
        .p2align        2                               ; -- Begin function _GLOBAL__sub_I_1.cc
__GLOBAL__sub_I_1.cc:                   ; @_GLOBAL__sub_I_1.cc
        .cfi_startproc
; %bb.0:
        stp     x29, x30, [sp, #-16]!           ; 16-byte Folded Spill
        mov     x29, sp
        .cfi_def_cfa w29, 16
        .cfi_offset w30, -8
        .cfi_offset w29, -16
        bl      ___cxx_global_var_init
        ldp     x29, x30, [sp], #16             ; 16-byte Folded Reload
        ret
        .cfi_endproc
                                        ; -- End function
                                        
        .section        __DATA,__mod_init_func,mod_init_funcs
        .p2align        3
        .quad   __GLOBAL__sub_I_1.cc ; ①

從反匯編代碼中柠偶,可以看出實(shí)際的方案是:

  • 生成一個(gè)函數(shù)用來構(gòu)造當(dāng)前編譯單元內(nèi)的所有全局(以及靜態(tài))變量情妖,將該函數(shù)寫到 __mod_init_funcs 中去,這樣 dso 加載時(shí)诱担,動(dòng)態(tài)鏈接器會(huì)主動(dòng)執(zhí)行它們(位于匯編代碼①處)毡证;
  • 在這個(gè)生成的函數(shù)中,調(diào)用 __cxa_atexit 蔫仙,傳入已經(jīng)構(gòu)造的對(duì)象的指針和 deleting destructor料睛,這樣 dso 卸載時(shí),這些構(gòu)造的對(duì)象會(huì)被銷毀(位于匯編代碼②處)摇邦。

很多使用 C++ 的庫都會(huì)分配線程資源進(jìn)行并發(fā)執(zhí)行秦效。如果這些正在執(zhí)行的線程需要引用全局變量,同時(shí)觸發(fā)了 dso 卸載涎嚼,那么就會(huì)發(fā)生線程跑著跑著阱州,全局變量析構(gòu)了,于是進(jìn)程就崩潰了法梯。

理論上講苔货,dso 的卸載是可控的,因?yàn)槲覀兛偪梢钥刂七壿嬃⒀疲寗?dòng)態(tài)庫的資源都釋放掉以后夜惭,再去卸載動(dòng)態(tài)庫/退出進(jìn)程。

但是在 iOS 移動(dòng)應(yīng)用上铛绰,有一個(gè)例外——

用戶可以通過多任務(wù)手勢(shì)诈茧,殺死應(yīng)用。如果被殺的應(yīng)用恰巧在前臺(tái)運(yùn)行捂掰,那么 iOS 會(huì)給這個(gè)應(yīng)用發(fā)送 SIGTERM 信號(hào)敢会。UIKit 收到信號(hào)后會(huì)調(diào)用應(yīng)用代理的 applicationWillTerminate(_:) 方法,使得應(yīng)用有機(jī)會(huì)保存一些狀態(tài)數(shù)據(jù)这嚣,然后正常退出應(yīng)用鸥昏。

這個(gè)時(shí)候我們是沒有機(jī)會(huì)釋放線程資源的,因?yàn)?terminate 的生命周期很短姐帚,沒有時(shí)間給我們等待異步線程結(jié)束吏垮,所以這個(gè)崩潰就無法避免了。

所幸的是,這種崩潰并不會(huì)被用戶感知到:即使應(yīng)用不崩潰膳汪,也會(huì)立即正常退出唯蝶,對(duì)于用戶來說表現(xiàn)是一樣的。

\

解決需要重編譯遗嗽?

其實(shí)同類的問題以前在該 App 中也是發(fā)生過的——我們有一個(gè)內(nèi)部 SDK 同樣也是 C++ 寫成粘我,擁有全局狀態(tài)變量,開啟異步線程池訪問這些變量媳谁,用戶在前臺(tái)殺死應(yīng)用時(shí)觸發(fā)崩潰涂滴。

當(dāng)時(shí)的解決方案是:升級(jí)工具鏈友酱。根據(jù) Apple 發(fā)布的 Xcode 11 更新日志晴音,apple clang++ 編譯器增加了禁用全局變量析構(gòu)的編譯參數(shù) -fno-c++-static-destructors 。使用該標(biāo)記編譯的 C++ 源文件缔杉,不會(huì)生成對(duì)全局變量進(jìn)行析構(gòu)的代碼锤躁。

這對(duì) iOS 應(yīng)用來說是安全的——因?yàn)?iOS 應(yīng)用幾乎不會(huì)在運(yùn)行時(shí)卸載動(dòng)態(tài)庫,無需考慮動(dòng)態(tài)庫卸載的資源泄漏問題或详。

然而這次的問題又有所不同——出現(xiàn)問題的是一個(gè)由第三方提供的二進(jìn)制庫系羞,我們手里是沒有它的源代碼的,也就無法通過修改編譯參數(shù)的方式來重新編譯生成機(jī)器碼霸琴。

但是我們能否再深入一下椒振,幫助三方庫來修復(fù)這個(gè) bug 呢?

修復(fù)該問題的直接方案梧乘,就是修改機(jī)器碼澎迎,消除對(duì) __cxa_atexit 的調(diào)用。

\

靜態(tài)庫里有什么

一個(gè)三方靜態(tài)庫 SDK选调,一般由以下文件組成:

  • 一組頭文件夹供,提供了公開的函數(shù)/OC 類及方法聲明;
  • 一個(gè) .a 靜態(tài)庫仁堪,包含了這個(gè)庫的代碼實(shí)現(xiàn)哮洽,由多個(gè)編譯單元生成的 .o 目標(biāo)文件打包而成;
  • 一組資源文件弦聂,提供代碼運(yùn)行時(shí)的外部數(shù)據(jù)(圖片鸟辅、以及其他資源)。

無論是采用零散的文件莺葫,還是采用 .framework 封裝剔桨,它們的組成基本上是一致的。

我們要修改的是它的部分機(jī)器碼徙融,所以要將其中的 .a 靜態(tài)庫解開洒缀,再進(jìn)行編輯。

首先來查看一下 .a 文件的內(nèi)容:

? lipo -info libsample.a
Architectures in the fat file: libsample.a are: armv7 arm64

這是一個(gè) Universal binary,包含了兩種 iOS 真機(jī)的 CPU 架構(gòu)的代碼树绩。我們先針對(duì)主流機(jī)型使用的 arm64 架構(gòu)嘗試調(diào)整萨脑。

使用 lipo 命令將 arm64 架構(gòu)單獨(dú)抽取出來:

? lipo -thin arm64 libsample.a -o libsample_arm64.a

只有把 Universal binary 中特定的架構(gòu)抽取出來,才能使用 ar(1) 操作:

? mkdir objects
? cd objects
# 打印 .a 中包含的文件列表
? ar t ../libsample_arm64.a
__.SYMDEF
sample.o
sample.o
# 解包 .a 文件
? ar -x ../libsample_arm64.a

使用上述命令進(jìn)行 .a 文件的展開后饺饭,出現(xiàn)了一個(gè)問題: ar t 命令中渤早,列出了兩個(gè) sample.o 文件,但是 ar x 命令只解出來了一個(gè)瘫俊。這是因?yàn)?ar 歸檔中鹊杖,沒有目錄的概念,不同目錄下的同名目標(biāo)文件扛芽,在 ar 歸檔的過程中骂蓖,會(huì)被打平,導(dǎo)致 ar 歸檔中包含多個(gè)同名文件川尖。

這會(huì)導(dǎo)致我們使用 ar x 解包的時(shí)候登下,相同的文件會(huì)被覆蓋成一個(gè),也沒法把它們單獨(dú)解壓出來叮喳。

那么如何才能把 ar 歸檔中的同名文件分別解包出來呢……那么就得提到「不務(wù)正業(yè)」的 7-zip 了……

\

壓縮軟件有妙用

7-zip 作為一個(gè)壓縮軟件被芳,除了支持常規(guī)的壓縮文件格式之外,還支持了很多歸檔文件以及 PE 可執(zhí)行文件(特別地馍悟,支持了部分安裝器的 SFX 模塊)畔濒。我們來嘗試一下它是否支持 .a 歸檔:

? 7z l ./libsample_arm64.a

7-Zip [64] 17.04 : Copyright (c) 1999-2021 Igor Pavlov : 2017-08-28
p7zip Version 17.04 (locale=utf8,Utf16=on,HugeFiles=on,64 bits,16 CPUs x64)

Scanning the drive for archives:
1 file, 78960 bytes (78 KiB)

Listing archive: ./libsample_arm64.a

--
Path = ./libsample_arm64.a
Type = Ar
Physical Size = 78960
SubType = a:BSD

   Date      Time    Attr         Size   Compressed  Name
------------------- ----- ------------ ------------  ------------------------
2021-06-23 15:05:09 .....         1710         1710  1.txt
2021-06-23 15:03:54 .....        38616        38616  1.sample.o
2021-06-23 15:04:02 .....        38616        38616  2.sample.o
------------------- ----- ------------ ------------  ------------------------
2021-06-23 15:05:09              78942        78942  3 files

可以看到,7-zip 自動(dòng)為 .a 中的文件名進(jìn)行了修正锣咒。同時(shí)侵状,7-zip 在解壓的時(shí)候遇到同名文件,會(huì)提供是否覆蓋及自動(dòng)重命名文件的選項(xiàng):

? 7z x ./libsample_arm64.a

7-Zip [64] 17.04 : Copyright (c) 1999-2021 Igor Pavlov : 2017-08-28
p7zip Version 17.04 (locale=utf8,Utf16=on,HugeFiles=on,64 bits,16 CPUs x64)

Scanning the drive for archives:
1 file, 78960 bytes (78 KiB)

Extracting archive: ./libsample_arm64.a
--
Path = ./libsample_arm64.a
Type = Ar
Physical Size = 78960
SubType = a:BSD
Would you like to replace the existing file:
  Path:     ./sample.o
  Size:     2736 bytes (3 KiB)
  Modified: 2017-05-15 11:59:49
with the file from archive:
  Path:     sample.o
  Size:     76088 bytes (75 KiB)
  Modified: 2017-05-15 11:58:47
? (Y)es / (N)o / (A)lways / (S)kip all / A(u)to rename all / (Q)uit? u

Everything is Ok

Files: 3
Size:       78942
Compressed: 78960

只要我們選擇 Auto rename all宠哄,7-zip 就會(huì)自動(dòng)幫我們處理文件重名的問題了壹将。而我們重新打包 .a 文件時(shí),.o 文件的名稱并不重要毛嫉,可以隨便取诽俯,所以這里改成其他名字也沒有關(guān)系。

\

人肉寫出機(jī)器碼

我們?cè)賮砘仡櫼幌碌湫偷娜肿兞课鰳?gòu)調(diào)用的注冊(cè):

LDR             X1, [SP,#0x10+var_8]
    ADRP            X0, #__ZN4TestD1Ev@PAGE ; Test::~Test()
    ADD             X0, X0, #__ZN4TestD1Ev@PAGEOFF ; Test::~Test()
    ADRP            X2, #___dso_handle@PAGE
    ADD             X2, X2, #___dso_handle@PAGEOFF
    BL              ___cxa_atexit
    LDP             X29, X30, [SP,#0x10+var_s0]
    ADD             SP, SP, #0x20
    RET

通過閱讀 Itanium C++ ABI承粤,可以看到 __cxa_atexit 的函數(shù)簽名如下:

// 3.3.6.3 Runtime API
extern _LIBCXXABI_FUNC_VIS int __cxa_atexit(void (*f)(void *), void *p, void *d);

對(duì)比反匯編代碼暴区,可以看到 X0 傳入了對(duì)象類型的刪除析構(gòu)( ...D1Ev )函數(shù)的指針,X1 傳入了對(duì)象地址辛臊,X2 傳入了 dso 句柄仙粱,與函數(shù)簽名相符。

要消除對(duì) __cxa_atexit 的調(diào)用彻舰,只需要把其中的 bl 指令改成 nop 即可伐割。

反匯編軟件 IDA 提供了即時(shí)匯編的功能候味,可以通過手寫匯編指令,由 IDA 生成機(jī)器碼直接寫入文件中隔心“兹海可惜這個(gè)功能對(duì)于 arm64 架構(gòu)沒有支持,我們需要找另外的方法硬霍。

好在我們可以查閱 AArch64 指令集架構(gòu)文檔帜慢,其中提到:

image

通過文檔,我們看到了在 AArch64 架構(gòu)下唯卖, NOP 指令的具體編碼粱玲。

由于 Apple arm64 CPU 是小端序,那么我們應(yīng)該把 bl 指令對(duì)應(yīng)的四個(gè)字節(jié)替換為:

1F 20 03 D5 ; NOP

除此之外拜轨,還有數(shù)種不同的情況抽减,需要針對(duì)性地做不同的修改。以下列出了兩種不同情況撩轰。

尾調(diào)用:

; 各種填寫參數(shù)...
    B               ___cxa_atexit
    ; end of function

此時(shí)要把 B 指令改為 RET lr 胯甩。

返回值校驗(yàn):

; 各種填寫參數(shù)...
    BL              ___cxa_atexit
    CBZ             W0, check_pass
    BL              assert_fail
check_pass:
    ; ... 正常邏輯

此時(shí)要把 B 指令改為 MOV w0, wzr 昧廷,才能通過校驗(yàn)堪嫂。

至此我們可以看出,通過這種方式修改機(jī)器碼木柬,存在很大的局限性:

  • 做人肉匯編器真的很難皆串;
  • 對(duì)象文件中的跳轉(zhuǎn)記錄在 GOT 表中,直接刪除它們的引用會(huì)導(dǎo)致鏈接失斆颊怼恶复;
  • 不是所有 CPU 架構(gòu)上都存在可以等長替換的指令,因此對(duì)于部分 CISC 指令集架構(gòu)無能為力速挑。

那么是否存在更好的解決方案呢谤牡?

\

蘋果又有新科技

在使用 otool 檢查解包出來的 .o 文件時(shí),發(fā)現(xiàn)了如下區(qū)段:

Section
  sectname __bitcode
   segname __LLVM
      addr 0x0000000000000ee8
      size 0x0000000000005f70
    offset 4928
     align 2fn:0 (1)
    reloff 0
    nreloc 0
     flags 0x00000000
 reserved1 0
 reserved2 0

這意味著姥宝,這個(gè)目標(biāo)文件內(nèi)嵌了 bitcode翅萤。眾所周知,Clang/LLVM 是蘋果親兒子腊满,蘋果基于這一套體系搞出了許多新鮮玩意兒套么,bitcode 就是其中之一。

clang 編譯器會(huì)先將源文件編譯為 LLVM IR碳蛋,再把 IR 編譯到機(jī)器碼胚泌。IR 的大部分設(shè)計(jì)都是平臺(tái)中立的,少部分平臺(tái)相關(guān)的代碼在 CPU 架構(gòu)不發(fā)生大變化時(shí)基本兼容肃弟,而且從 IR 生成機(jī)器碼的過程可以單獨(dú)優(yōu)化玷室。

LLVM IR 有不同的表示方案零蓉,有文本形式的 IR 匯編、二進(jìn)制編碼的 bitcode穷缤。

Apple 允許應(yīng)用在編譯時(shí)將 bitcode 內(nèi)嵌在二進(jìn)制文件內(nèi)壁公,隨應(yīng)用一起提交給 Apple。一旦 Apple 推出了效率更高的機(jī)器碼生成方案绅项,或者是推出了新款 CPU紊册,Apple 可以根據(jù)你提交的 bitcode 重新生成更高效的機(jī)器指令,開發(fā)者無需做任何事即可享受到這個(gè)優(yōu)化快耿。

比如 iPhone X 的 CPU 架構(gòu)有小升級(jí)囊陡,內(nèi)嵌了 bitcode 的應(yīng)用就可以免費(fèi)獲得 arm64e CPU 架構(gòu)的支持。

使用開源項(xiàng)目 LibEBC 可以提取 .o 文件中的 bitcode :

? /path/to/ebcutil -e ./1.sample.o
Mach-O arm64
  File name: 1.sample.o
       Arch: arm64
       UUID: 00000000-0000-0000-0000-000000000000
    Wrapper: D809E5ED-7D43-4E42-B829-7EFF246EE28C
         IR: 250BD0A9-67D6-499B-9E63-9D628FB0D7C7
? mv ./250BD0A9-67D6-499B-9E63-9D628FB0D7C7 ./1.sample.bc

使用 LLVM 項(xiàng)目(需要通過 Homebrew 安裝 llvm)提供的 llvm-dis 工具可以將 bc 文件轉(zhuǎn)換為可讀的 IR 匯編格式:

? llvm-dis ./1.sample.bc

這會(huì)生成一個(gè)同名的 .ll 文件掀亥,可以用文本編輯器打開撞反。其中關(guān)于全局變量初始化的部分如下:

; 省略無關(guān)代碼
; Function Attrs: noinline ssp uwtable
define internal void @__cxx_global_var_init() #3 section "__TEXT,__StaticInit,regular,pure_instructions" {
  %1 = call %class.Sample1* @_ZN7Sample1C1Ev(%class.Sample1* @_ZL2s1)
  %2 = call i32 @__cxa_atexit(void (i8*)* bitcast (%class.Sample1* (%class.Sample1*)* @_ZN7Sample1D1Ev to void (i8*)*), i8* bitcast (%class.Sample1* @_ZL2s1 to
 i8*), i8* @__dso_handle) #4
  ret void
}

; Function Attrs: nounwind
declare i32 @__cxa_atexit(void (i8*)*, i8*, i8*) #4

IR 的詳細(xì)語法在此就不展開介紹了,有興趣的同學(xué)可以查看LLVM 官方文檔搪花。其中比較重要的有:

  • declare 用來聲明對(duì)外部符號(hào)的引用遏片,例如此處引用了外部函數(shù) __cxa_atexit
  • call 用來做函數(shù)調(diào)用

需要注意的是撮竿,在 IR 中吮便,所有 % 加數(shù)字組成的標(biāo)號(hào)必須連續(xù)。例如如果我注釋了上述代碼中的 %1 所在的一行幢踏,就會(huì)產(chǎn)生 IR 匯編錯(cuò)誤髓需,此時(shí)就必須把下一行的 %2 改成 %1 ,才能符合規(guī)則匯編通過房蝉。

在上述代碼中僚匆,我們只需要把 %2 所在的一行給注釋掉,即可完成修復(fù)搭幻。如果一個(gè) IR 函數(shù)內(nèi)有多個(gè)調(diào)用咧擂,就需要按照標(biāo)號(hào)連續(xù)的規(guī)則,將注釋掉的代碼后面的所有標(biāo)號(hào)依次提前了檀蹋。

正確的 IR 操作姿勢(shì)是寫一個(gè) IR pass松申,然后通過 llvm-opt 去加載這個(gè) pass,讀取 .bc 文件而不是人類可讀的 .ll 文件续扔,來對(duì)原有的 bitcode 做變換攻臀。但是寫一個(gè) pass 需要的成本比臨時(shí)修復(fù)問題要高得多,對(duì)于少數(shù)幾個(gè)目標(biāo)文件的修復(fù)纱昧,可以通過文本替換工具或腳本語言來替換標(biāo)號(hào)刨啸。例如使用 node.js:

function replaceLabels(from, to, diff) {
  let source = fs.readFileSync('tmp.ll', 'utf8');
  for (let i = from; i <= to; ++i) {
    // 修改 % 變量標(biāo)號(hào)
    let re = new RegExp('%'+i+'\b', 'g');
    source = source.replace(re, '%'+(i - diff));
    // 修改 jump label 標(biāo)號(hào)
    let re2 = new RegExp('\b'+i+':', 'g');
    source = source.replace(re2, ''+(i - diff)+':');
  }
  fs.writeFileSync('tmp.ll', source)
}

\

重新組裝靜態(tài)庫

修改過后的 .ll 文件,可以通過以下方式重新生成機(jī)器碼:

# 生成 arm64 匯編文件
? llc ./1.sample.ll
# 調(diào)用匯編器重新生成目標(biāo)文件
? xcrun -sdk iphoneos as -arch arm64 ./1.sample.s -o ./1.sample.o

這樣做有一個(gè)缺點(diǎn)识脆,就是生成的目標(biāo)文件沒有內(nèi)嵌 bitcode设联,以后再想改就不好改了善已。

好在 clang driver 功能齊全,可以直接接受 bitcode 以及 IR 匯編文件:

? xcrun -sdk iphoneos clang -arch arm64 -target arm64-apple-ios6.0.0 -fembed-bitcode -c ./1.sample.ll -o ./1.sample.o

對(duì)存在問題的 .o 文件打補(bǔ)丁后离例,即可將所有的 .o 文件重新合成靜態(tài)庫:

? xcrun libtool -static -o ../libsample_arm64_patched.a *.o

\

實(shí)機(jī)驗(yàn)證大成功

通過調(diào)用堆棧换团,我們已經(jīng)可以知道這個(gè)問題的復(fù)現(xiàn)方式:

  • 在應(yīng)用中進(jìn)入使用該三方庫內(nèi)部觸發(fā)多線程工作的場(chǎng)景
  • 直接開啟多任務(wù)手勢(shì),殺死應(yīng)用

但是在連接調(diào)試器的情況下宫蛆,通過多任務(wù)手勢(shì)殺應(yīng)用會(huì)導(dǎo)致調(diào)試器斷開艘包,不容易觀察是否有崩潰的現(xiàn)象。

所以耀盗,需要找到一個(gè)讓應(yīng)用正常退出想虎,而又不影響調(diào)試器的方法。

通過查詢 iOS system framework class dump叛拷,可以知道 UIApplication 有一個(gè)未公開的方法: UIApplication.terminateWithSuccess() 舌厨。

經(jīng)過實(shí)際試驗(yàn),這個(gè)方法確實(shí)可以使應(yīng)用直接退出忿薇。

因此裙椭,我們可以修改應(yīng)用代碼,在進(jìn)入能夠觸發(fā)問題的場(chǎng)景下署浩,通過代碼來讓應(yīng)用退出揉燃,就可以通過調(diào)試器來觀察應(yīng)用是否觸發(fā)崩潰了。分別使用修復(fù)前瑰抵、修復(fù)后的庫進(jìn)行實(shí)機(jī)驗(yàn)證你雌,結(jié)果為:

  • 使用舊版庫時(shí)器联,有概率引發(fā)調(diào)試器由于崩潰觸發(fā)斷點(diǎn)二汛;
  • 使用修改機(jī)器碼的庫后,不會(huì)觸發(fā)崩潰拨拓;
  • 不影響正常的業(yè)務(wù)功能肴颊。

這表明我們的修復(fù)是成功的。

總結(jié)

本文通過修改 bitcode渣磷,成功地在沒有源碼的情況下婿着,修復(fù)了一個(gè)三方庫的 bug。其中用到的知識(shí)點(diǎn)總結(jié)如下:

  1. 崩潰現(xiàn)場(chǎng)中醋界,在主線程發(fā)現(xiàn) exit 竟宋,多半是由于 C++ 全局變量析構(gòu) + 多線程導(dǎo)致的;
  2. 在有源碼的情況下形纺,可以通過調(diào)整編譯參數(shù)消除全局變量析構(gòu)丘侠;
  3. 使用 7-zip 可以無損解包靜態(tài)庫文件;
  4. 使用 otool 可以看到目標(biāo)文件是否嵌入了 bitcode逐样;
  5. 使用 llvm 提供的工具蜗字,可以對(duì) bitcode 進(jìn)行修改打肝、重新生成機(jī)器碼;
  6. 可以通過私有 API 來模擬應(yīng)用退出挪捕,制造復(fù)現(xiàn)場(chǎng)景粗梭。

作者

郭同學(xué),便利蜂客戶端基礎(chǔ)框架團(tuán)隊(duì)的一名 iOS 工程師级零,負(fù)責(zé)移動(dòng)客戶端的基礎(chǔ)建設(shè)断医。對(duì)跨端技術(shù)、App 框架及系統(tǒng)有所研究奏纪,專治各種客戶端疑難雜癥孩锡。

推薦觀看Fluuter手把手教你從入門到精通

參考資料

[1]Xcode 11 更新日志: https://developer.apple.com/documentation/xcode-release-notes/xcode-11-release-notes

[2]Itanium C++ ABI: https://itanium-cxx-abi.github.io/cxx-abi/abi.html#dso-dtor-runtime-api

[3]AArch64 指令集架構(gòu)文檔: https://developer.arm.com/architectures/cpu-architecture/a-profile/exploration-tools

[4]LibEBC: https://github.com/Guardsquare/LibEBC

[5]LLVM 官方文檔: https://llvm.org/docs/LangRef.html

[6]iOS system framework class dump: https://developer.limneos.net/?ios=14.4&framework=UIKitCore.framework&header=UIApplication.h

搬運(yùn)自知乎,如有侵犯亥贸,請(qǐng)聯(lián)系小編刪除哦躬窜。

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
  • 序言:七十年代末,一起剝皮案震驚了整個(gè)濱河市炕置,隨后出現(xiàn)的幾起案子荣挨,更是在濱河造成了極大的恐慌,老刑警劉巖朴摊,帶你破解...
    沈念sama閱讀 211,042評(píng)論 6 490
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件默垄,死亡現(xiàn)場(chǎng)離奇詭異,居然都是意外死亡甚纲,警方通過查閱死者的電腦和手機(jī)口锭,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 89,996評(píng)論 2 384
  • 文/潘曉璐 我一進(jìn)店門,熙熙樓的掌柜王于貴愁眉苦臉地迎上來介杆,“玉大人鹃操,你說我怎么就攤上這事〈荷冢” “怎么了荆隘?”我有些...
    開封第一講書人閱讀 156,674評(píng)論 0 345
  • 文/不壞的土叔 我叫張陵,是天一觀的道長赴背。 經(jīng)常有香客問我椰拒,道長,這世上最難降的妖魔是什么凰荚? 我笑而不...
    開封第一講書人閱讀 56,340評(píng)論 1 283
  • 正文 為了忘掉前任燃观,我火速辦了婚禮,結(jié)果婚禮上便瑟,老公的妹妹穿的比我還像新娘缆毁。我一直安慰自己,他們只是感情好胳徽,可當(dāng)我...
    茶點(diǎn)故事閱讀 65,404評(píng)論 5 384
  • 文/花漫 我一把揭開白布积锅。 她就那樣靜靜地躺著爽彤,像睡著了一般。 火紅的嫁衣襯著肌膚如雪缚陷。 梳的紋絲不亂的頭發(fā)上适篙,一...
    開封第一講書人閱讀 49,749評(píng)論 1 289
  • 那天,我揣著相機(jī)與錄音箫爷,去河邊找鬼嚷节。 笑死,一個(gè)胖子當(dāng)著我的面吹牛虎锚,可吹牛的內(nèi)容都是我干的硫痰。 我是一名探鬼主播,決...
    沈念sama閱讀 38,902評(píng)論 3 405
  • 文/蒼蘭香墨 我猛地睜開眼窜护,長吁一口氣:“原來是場(chǎng)噩夢(mèng)啊……” “哼效斑!你這毒婦竟也來了?” 一聲冷哼從身側(cè)響起柱徙,我...
    開封第一講書人閱讀 37,662評(píng)論 0 266
  • 序言:老撾萬榮一對(duì)情侶失蹤缓屠,失蹤者是張志新(化名)和其女友劉穎,沒想到半個(gè)月后护侮,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體敌完,經(jīng)...
    沈念sama閱讀 44,110評(píng)論 1 303
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 36,451評(píng)論 2 325
  • 正文 我和宋清朗相戀三年羊初,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了滨溉。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片。...
    茶點(diǎn)故事閱讀 38,577評(píng)論 1 340
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡长赞,死狀恐怖晦攒,靈堂內(nèi)的尸體忽然破棺而出,到底是詐尸還是另有隱情涧卵,我是刑警寧澤勤家,帶...
    沈念sama閱讀 34,258評(píng)論 4 328
  • 正文 年R本政府宣布,位于F島的核電站柳恐,受9級(jí)特大地震影響,放射性物質(zhì)發(fā)生泄漏热幔。R本人自食惡果不足惜乐设,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 39,848評(píng)論 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一處隱蔽的房頂上張望绎巨。 院中可真熱鬧近尚,春花似錦、人聲如沸场勤。這莊子的主人今日做“春日...
    開封第一講書人閱讀 30,726評(píng)論 0 21
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至格遭,卻和暖如春哈街,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背拒迅。 一陣腳步聲響...
    開封第一講書人閱讀 31,952評(píng)論 1 264
  • 我被黑心中介騙來泰國打工骚秦, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人璧微。 一個(gè)月前我還...
    沈念sama閱讀 46,271評(píng)論 2 360
  • 正文 我出身青樓作箍,卻偏偏與公主長得像,于是被迫代替她去往敵國和親前硫。 傳聞我的和親對(duì)象是個(gè)殘疾皇子胞得,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 43,452評(píng)論 2 348

推薦閱讀更多精彩內(nèi)容

  • 前言 編譯的主要任務(wù)是將源代碼文件作為輸入,最終輸出目標(biāo)文件屹电,這期間發(fā)生了什么?便是我們本篇文章要介紹的嗤详。在開始之...
    QiShare閱讀 4,358評(píng)論 0 7
  • 引言 維基百科: 編譯語言(英語:Compiled language)是一種以編譯器來實(shí)現(xiàn)的編程語言递宅。它不像解釋型...
    素還真人閱讀 2,250評(píng)論 0 7
  • 前言 2000年,伊利諾伊大學(xué)厄巴納-香檳分校(University of Illinois at Urbana-...
    星光社的戴銘閱讀 15,873評(píng)論 8 180
  • 引言 維基百科:編譯語言(英語:Compiled language)是一種以編譯器來實(shí)現(xiàn)的編程語言。它不像解釋型語...
    Flame_Dream閱讀 8,546評(píng)論 5 52
  • 前言 編譯的主要任務(wù)是將源代碼文件作為輸入,最終輸出目標(biāo)文件驶悟,這期間發(fā)生了什么材失?便是我們本篇文章要介紹的。在開始之...
    沐靈洛閱讀 3,222評(píng)論 0 2