1. 程式人生 > 其它 >如何定位Obj-C野指標隨機Crash(三):如何讓Crash自報家門

如何定位Obj-C野指標隨機Crash(三):如何讓Crash自報家門

轉自:https://cloud.tencent.com/developer/article/1070528

本文主要介紹如何利用OC Runtime的特性,讓OC野指標物件主動丟擲自己的資訊,秒殺某些全系統棧Crash。

陳其鋒,騰訊SNG即通產品部音視訊技術中心軟體工程師,主要負責iOS平臺音視訊功能開發,熱衷於移動開發,以及各類APP體驗。

(注:本文由於涉及一些技術比較猥瑣,可能會引起處女座同學的不適,如果有任何疑問歡迎一起討論。另外,本文只討論Arm 32位情況)

為什麼錯誤地址是0x55555561?

我們在前文(第一部分第二部分裡曾經介紹過在記憶體釋放後填充0x55使野指標出現後資料不能訪問,從而使野指標變成了必現的方法,那這裡會有一個比較奇怪的問題:我們在釋放的記憶體上填上了0x55,但為什麼大部分時候野指標Crash了,出錯的地址卻是0x55555561?

為了解答這個問題,我們可以先看看Crash棧,就會發現這些Crash都是在objc_msgSend上。我們知道Obj-C的物件方法呼叫是通過objc_msgSend進行的,我們通過野指標訪問一個物件的方法也一樣,其實是通過objc_msgSend給已經釋放的物件發了一條訊息。

而objc_msgSend的函式簽名是這樣:

id objc_msgSend(id self, SEL op, ...)

我們再來看看objc_msgSend的程式碼:

libobjc.A.dylib`objc_msgSend:
0x2f879f40 <+0>:  cbz    r0, 0x2f879f7e            ; <+62>
0x2f879f42 <+2>:  ldr.w  r9, [r0]
0x2f879f46 <+6>:  ldrh.w r12, [r9, #0xc]
0x2f879f4a <+10>: ldr.w  r9, [r9, #0x8]
0x2f879f4e <+14>: and.w  r12, r12, r1
0x2f879f52 <+18>: add.w  r9, r9, r12, lsl #3
0x2f879f56 <+22>: ldr.w  r12, [r9]
0x2f879f5a <+26>: teq.w  r12, r1
0x2f879f5e <+30>: bne    0x2f879f66                ; <+38>
0x2f879f60 <+32>: ldr.w  r12, [r9, #0x4]  
0x2f879f64 <+36>: bx     r12
0x2f879f66 <+38>: cmp.w  r12, #0x1  
0x2f879f6a <+42>: blo    0x2f879f78                ; <+56>
0x2f879f6c <+44>: it     eq
0x2f879f6e <+46>: ldreq.w r9, [r9, #0x4]
0x2f879f72 <+50>: ldr    r12, [r9, #8]!
0x2f879f76 <+54>: b      0x2f879f5a                ; <+26>
0x2f879f78 <+56>: ldr.w  r9, [r0]
0x2f879f7c <+60>: b      0x2f87a1c0                ; _objc_msgSend_uncached
0x2f879f7e <+62>: mov.w  r1, #0x0
0x2f879f82 <+66>: bx     lr

我們可以結合Obj-C類的記憶體佈局再來解讀一下上面的彙編程式碼(節選於Obj-C類的原始碼):

struct objc_class : objc_object {
// Class ISA;
Class superclass;
cache_t cache;
uintptr_t data_NEVER_USE;  // class_rw_t * plus custom rr/alloc flags
class_rw_t *data() {
return (class_rw_t *)(data_NEVER_USE & ~CLASS_FAST_FLAG_MASK);
}
void setData(class_rw_t *newData) {
uintptr_t flags = (uintptr_t)data_NEVER_USE & CLASS_FAST_FLAG_MASK;
data_NEVER_USE = (uintptr_t)newData | flags;
}
……..
struct cache_t {
struct bucket_t *buckets;
mask_t shiftmask;
mask_t occupied;
……..
struct bucket_t {
cache_key_t key;
IMP imp;
…...
typedef uintptr_t cache_key_t;    

根據蘋果的函式呼叫約定,objc_msgSend被呼叫的時候,暫存器對應關係:r0是物件本身self,r1是sel,r2和r3是引數。根據objc_class的宣告,我們可以知道:

0x2f879f40 <+0>:  cbz    r0, 0x2f879f7e          //如果self為0就跳轉到0x2f879f7e。給nil發訊息的話就什麼都不做
0x2f879f42 <+2>:  ldr.w  r9, [r0]    //取物件的類到r9
0x2f879f46 <+6>:  ldrh.w r12, [r9, #0xc]  //取類的偏移#0xc的資料到r12,也就是shiftmask的值
0x2f879f4a <+10>: ldr.w  r9, [r9, #0x8] //取類的偏移#0x8的成員到r9,也即是cache
0x2f879f4e <+14>: and.w  r12, r12, r1     //r1和shiftmask與,放到r12,r1是引數一,也就是sel,用來計算sel的index
0x2f879f52 <+18>: add.w  r9, r9, r12, lsl #3    //左移3位就是乘8,8是索引項 bucket_t的寬度,r12是cache索引,r9就cache的位置,r9+r12*8,就是當前sel對應的bucket_t快取
0x2f879f56 <+22>: ldr.w  r12, [r9]   //取快取bucket_t
0x2f879f5a <+26>: teq.w  r12, r1     //判斷快取項是不是要找的sel    key==sel?
0x2f879f5e <+30>: bne    0x2f879f66              //不是的話就要查詢sel
0x2f879f60 <+32>: ldr.w  r12, [r9, #0x4]   //是的話就取出imp
0x2f879f64 <+36>: bx     r12    //調sel的實現,跳到imp裡面去執行

其實上面的程式碼就是從快取中找sel的實現的過程,而錯誤地址之所以是0x55555561是因為ldrh.w r12, [r9, #0xc]這行指令。我們用0x55555555覆蓋了物件的isa指標,當發生OC呼叫查詢快取0x55555555+0xc取shiftmask的時候,發現這個地址不可讀,於是CPU丟擲了異常。

怎麼獲取野指標的更多異常資料?

弄清楚上述問題後,又有一個問題:既然0x55555555是被當成了類的指標使用,那假如我們用指定的類覆蓋這個指標,是不是就可以執行我們指定類的方法呢?

進一步說就是在發生野指標呼叫的時候,我們是不是可以控制CPU的行為?說起來有點像溢位攻擊,利用shellcode覆蓋函式返回值,一旦我們在出錯的時候控制了CPU就可以獲取更多異常資訊,比如是哪個類,調了什麼方法,物件的地址之類。

先解決幾個關鍵問題:

1.覆蓋成什麼?

我們需要自己寫一個類,用它的isa來替換已經釋放的物件的isa。如果不出我們所料,我們用自己的類覆蓋之後,之前呼叫的sel就換成了呼叫我們自己的類的某個sel。這樣,只要我們指定的類也實現這個方法,就可以執行我們需要執行的程式碼,然後在裡面獲取我們需要的資訊。當然,我們無法預料野指標物件會在呼叫哪個函式時發生Crash,好在我們可以利用runtime的重定向特性了轉到我們自己的程式碼裡面去。

2.怎麼覆蓋isa?

object_setClass可以替換一個類的isa,但是試了一下,發生死鎖!根據Obj-C物件的記憶體佈局,物件的第一個資料就是isa,這裡我們可以直接用自己的類指標替換它,反正是已經釋放的記憶體,隨便我們怎麼玩。

總之,還是很簡單,這個類就是下面這樣:

@interface DPCatcher : NSObject
@property (readwrite,assign,nonatomic) Class origClass;
@end
@implementation DPCatcher
- (id)forwardingTargetForSelector:(SEL)aSelector{
NSLog(@"發現objc野指標:%s::%p=>%@",class_getName(self.origClass),self,NSStringFromSelector(aSelector));
abort();
return nil;
}
-(void)dealloc{
NSLog(@"發現objc野指標:%s::%p=>%@",class_getName(self.origClass),self,@"dealloc");
abort();
}
-(oneway void)release{
NSLog(@"發現objc野指標:%s::%p=>%@",class_getName(self.origClass),self,@"release");
abort();
}
- (instancetype)autorelease{
NSLog(@"發現objc野指標:%s::%p=>%@",class_getName(self.origClass),self,@"autorelease");
abort();
}
@end

注意:物件的release、dealloc等函式要特殊處理一下,因為任何物件都有這些方法,不會執行重定向。

然後,我們的free函式改成下面這樣(去掉了一些多餘程式碼):

static void DPFree(void* p){
size_t memSiziee=malloc_size(p);
if (memSiziee>sDPCatchSize) {//有足夠的空間才覆蓋
id obj=(id)p;
Class  origClass=object_getClass(obj);//判斷是不是objc物件 ,registeredClasses裡面有所有的類,如果可以查到,說明是objc類
if (origClass && CFSetContainsValue(registeredClasses, origClass)) {
memset(obj, 0x55, memSiziee);
memcpy(obj, &sDPCatchIsa, sizeof(void*));//把我們自己的類的isa複製過去
DPCatcher* bug=(DPCatcher*)p;
bug.origClass=origClass;
}else{
memset(p, 0x55, memSiziee);
}
}else{
memset(p, 0x55, memSiziee);
}
return;
}

初始化的時候獲取所有類資訊,獲取填充類的的大小:

registeredClasses = CFSetCreateMutable(NULL, 0, NULL);
unsigned int count = 0;
Class *classes = objc_copyClassList(&count);
for (unsigned int i = 0; i < count; i++) {
CFSetAddValue(registeredClasses, (__bridge const void *)(classes[i]));
}
free(classes);
classes=NULL;
sDPCatchIsa=objc_getClass("DPCatcher");
sDPCatchSize=class_getInstanceSize(sDPCatchIsa);

用下面簡單的程式碼試一下:

UIView* testObj=[[UIView alloc] init];
[testObj release];
[testObj setNeedsLayout];

發生野指標的類、物件地址和訪問的方法就這樣可以被打印出來!

再看看下面這幾個讓人頭疼的傳說中的全系統棧Crash,你是否熟悉?

棧1:

棧2:

上面這兩個Crash如果不能重現幾乎是無解!但是,加上我們的野指標定位神器之後再看看,類名和地址都可以打出來了,解決起來就不是什麼問題了。

棧1被捕獲後的資訊:

棧2被捕獲後的資訊:

說明:

  1. 我們打印出了野指標物件的名字和地址,當這個類的物件比較少時,對查詢問題有很大的用處(如果是自定義的類出現野指標,一般還是比較容易找到問題),但是如果是一些經常出現的類,比如nsarray,定位起來還是比較麻煩。這個時候建議試一下xcode的malloc history工具,或者可以自己實現一個類似記錄記憶體使用記錄的工具,因為有記憶體申請和釋放的記錄,只要重現一次就可以精確定位野指標。
  2. 如果出現dealloc的使用錯誤,例如先[super dealloc],然後release成員變數,那麼就會出現崩潰的現象,且此時物件的地址為0x55555555。這是因為[super dealloc]只會釋放對應的記憶體,但其成員的記憶體不會被release而變成了0x555555。 這種問題場景比較簡單,一旦發生絕對是必現的,修復也比較容易。

後記

寫到這裡,關於iOS野指標隨機問題定位的三篇文章就寫完了,特別說一下,文中提到的方法雖然可以提高野指標的曝光率和定位精度,但並不是萬能,比如下面這幾種情況,可能並不一定適用:

  1. 未觸發出現野指標的邏輯:比如說一個有問題的程式碼,只有在特殊的邏輯下才會有野指標問題,如果我們沒有觸發這個邏輯,肯定也是無法暴露出這個問題的。這種情況建議還是提高測試的場景覆蓋。
  2. 產生野指標和使用野指標的時間間隔太長:時間太長的話,很可能我們保留的指標已經被釋放了。
  3. APP記憶體消耗大,會降低曝光率。因為記憶體消耗大的時候,我們保留的指標數量必然減少,而且保留的時間也會更短。

之前也收到了很多同學的反饋,感謝大家對這個系列文的關注!在這裡先回答一下大家提出的一些疑問。

  1. free之前先填上 0x55 ,這個0x55有什麼具體含義嗎? 答:實際上填寫資料的關鍵在於填寫資料後其地址指向不可讀的記憶體。而填寫0x55,和前面提到的出現異常情況的物件地址0x555555連線起來被當成指標使用的話,就會被識別為0x55555555,而CPU訪問這個地址就會丟擲異常。 另外一點,就是方便區分野指標,例如在Xcode啟用Enable Scribble時,指定alloc之後填寫的地址為0xaa,防止記憶體初始化就使用,也是為了方便和free之後的記憶體做區分。
  2. 這個方法對於arc和非arc是否都可以用? 答:都可以,不過都是arc的話應該比較少出現野指標吧。

本文系騰訊Bugly特邀文章,轉載請註明作者和出處“騰訊Bugly(http://bugly.qq.com)”

騰訊Bugly,最專業的質量跟蹤平臺