三天前,群裡看到Penguin大佬寫的一篇文章:你的App是否有出現過幽靈調用。看完後不禁感慨:分析之深入、工具之強大,令人嘆服。這篇文章寫得很好,但想要看懂需要很多前置知識,另外結尾處戛然而止,讓人意猶未盡。因此本文狗尾續貂,一來想多介紹些背景知識,降低原文的理解難度;二來想深入探究下問題的根因,如果是通用機制出了問題,那麼可以聯繫谷歌從根上解決,造福其他App開發者。對調試部分不感興趣、只對根因和解決方案感興趣的,可以直接跳到「階段五」。
起因是他們的進程發生了SIGSEGV的內存錯誤。
signal 11 (SIGSEGV), code 1 (SEGV_MAPERR), fault addr 0x0000000000000018
Cause: null pointer dereference
x0 0000000000000000 x1 0000000002e62ec8 x2 0000000000000000 x3 0000000072223650
x4 0000007c3ec13000 x5 3b7463656a624f2f x6 3b7463656a624f2f x7 0000007bbdababac
x8 0000000000000002 x9 2542ebd30d0dfceb x10 0000000000000000 x11 0000000000000002
x12 00000000af950a08 x13 b400007d15e5fa50 x14 0000007f1598f880 x15 0000007bbd9446e8
x16 0000007fea726e40 x17 0000000000000020 x18 0000007f15ca0000 x19 b400007d55e10be0
x20 0000000000000000 x21 b400007d55e10ca0 x22 0000000002d51610 x23 0000000002e61b08
x24 0000000000000005 x25 0000000000000002 x26 0000000002e62ec8 x27 0000000000000002
x28 00000000031b1f38 x29 00000000ffffffff
lr 00000000721b63c8 sp 0000007fea728e90 pc 00000000721b63d4 pst 0000000080001000101 total frames
backtrace:
#00 pc 00000000008123d4 /system/framework/arm64/boot-framework.oat (android.view.ViewGroup.jumpDrawablesToCurrentState+132)
#01 pc 00000000007ca6c8 /system/framework/arm64/boot-framework.oat (android.view.View.onDetachedFromWindowInternal+472)
#02 pc 00000000007bb8d0 /system/framework/arm64/boot-framework.oat (android.view.View.dispatchDetachedFromWindow+288)
#03 pc 000000000080c444 /system/framework/arm64/boot-framework.oat (android.view.ViewGroup.dispatchDetachedFromWindow+484)
#04 pc 000000000080c34c /system/framework/arm64/boot-framework.oat (android.view.ViewGroup.dispatchDetachedFromWindow+236)
#05 pc 000000000080c34c /system/framework/arm64/boot-framework.oat (android.view.ViewGroup.dispatchDetachedFromWindow+236)
...
針對內存錯誤,一般的分析思路就是查看錯誤處的匯編代碼,以及相關的內存/寄存器值。oat文件雖然屬於ELF文件,但一般我們通過oatdump來恢復出它的DEX CODE(字節碼)和OAT CODE(匯編代碼),原文使用了作者自研的core-parser工具,使用起來更加方便,但得到的信息是相同的。
DEX CODE:
0x795fe114a2: 0212 | const/4 v2, #+0
0x795fe114a4: 1235 000a | if-ge v2, v1, 0x795fe114b8 //+10
0x795fe114a8: 0346 0200 | aget-object v3, v0, v2
0x795fe114ac: 106e 80d3 0003 | invoke-virtual {v3}, void android.view.View.jumpDrawablesToCurrentState() // method@32979
OAT CODE:
0x72f563b0: 6b18033f | cmp w25, w24
0x72f563b4: 540001aa | b.ge 0x72f563e8
0x72f563b8: 110032e0 | add w0, w23, #0xc
0x72f563bc: 1000007e | adr x30, 0x72f563c8
0x72f563c0: b59da314 | cbnz x20, 0x72e91820
0x72f563c4: b8797801 | ldr w1, [x0, x25, lsl #2]
0x72f563c8: aa0103fa | mov x26, x1
0x72f563cc: b9400020 | ldr w0, [x1]
0x72f563d0: f949c000 | ldr x0, [x0, #0x1380]
0x72f563d4: f9400c1e | ldr x30, [x0, #0x18]
0x72f563d8: d63f03c0 | blr x30
0x72f563dc: 11000739 | add w25, w25, #1
原始tombstone第0幀的pc值為0x721b63d4,而coredump裡第0幀的pc值為0x72f563d4,二者不一樣,主要是它們不是在同一次異常中產生的。但二者地址的低位都是0x3d4,則表明它們反映的調用棧應該是一致的。原因是文件加載到內存時需要按頁對齊(新版本需要按16K的大頁對齊),因此即便每一次加載時起始地址不同,但同一處代碼的頁內偏移是固定的,也就是這裡的0x3d4。
具體的錯誤指令是ldr x30, [x0, 0x18]
,如果看OAT有經驗的話,基本一眼就明白這是一個拿著ArtMethod*找entry_point_from_quick_compiled_code_
的過程,也就是尋找接下來調用的Java方法的目標地址的過程。當然,對OAT沒那麼熟悉的朋友可以借助oatdump輸出的dex_pc和pc的映射關係,找到這段匯編對應的DEX字節碼,也即invoke-virtual {v3}, void android.view.View.jumpDrawablesToCurrentState() // method@32979
,通過字節碼,我們也能夠知道這是一個Java調用。
Fault address = 0x18,它是x0+0x18得到的,因此可以知道x0為0。它作為一個ArtMethod的指針值,顯然是有問題的。因此接下來就要去排查,為何我們會得到一個值為0的ArtMethod*。
再看錯誤發生前的兩行匯編:
0x72f563cc: b9400020 | ldr w0, [x1]
0x72f563d0: f949c000 | ldr x0, [x0, #0x1380]
0x72f563d4: f9400c1e | ldr x30, [x0, #0x18]
這裡需要一些對ART虛擬機運作的了解,也即我們是如何找到ArtMethod的。虛方法的ArtMethod一般存在類中,具體是存在Class的Embedded VTable中,這個我在以前寫類加載時畫過一張圖,可以看到虛方法在類中具體的位置。
因此,ldr w0, [x1]
就是從實例對象(x1)中取出Class,而ldr x0, [x0, #0x1380]
就是從類中取出ArtMethod的過程。至於0x1380哪來的,這個是dex2oat過程中優化得到的值,dex2oat幫我們找到了具體的虛方法在類中的偏移,省了運行時再去做一遍查找。
既然得到的ArtMethod有問題,那我們進一步就要確認:Class是不是一個有問題的值?
Class*來自於x1,因此我們需要檢查x1的內存情況。
通過coredump裡的寄存器情況,我們可以知道x1=0x27e6d40。在64位的Java進程裡,小於4G(地址有效位數≤8位)的地址都被用Java使用著,例如Java堆,或者boot image等。
x0 0x0000000000000000 x1 0x00000000027e6d40 x2 0x0000000000000000 x3 0x0000000072fc3650
接下來就是查看這個x1實例對象內部的內存值。
core-parser> rd 0x00000000027e6d40 -e 0x00000000027e6e40
27e6d40: 8adbc7e1b0000888 0000000000000000 ................
27e6d50: 0000000000000000 0000000000000000 ................
27e6d60: 0000000000000000 0000000000000000 ................
27e6d70: 0000000000000000 0000000000000000 ................
27e6d80: 0000000000000000 0000000000000000 ................
對於一個Java對象而言,它的頭部8個字節一定存的是這兩個字段:
// The Class representing the type of the object.
HeapReference<Class> klass_;
// Monitor and hash code information.
uint32_t monitor_;
第一個是klass,第二個是monitor。由於小端機製的作用,8adbc7e1b0000888
實際上是後半部分屬於第一個字段:klass_,因此klass_的值為0xb0000888。可是查看klass_的內存,發現它裡面的數據竟然都是0,這顯然是異常的,因此我們有理由懷疑,這裡得到的0xb0000888是有問題的。
core-parser> rd 0xb0001c08 (這裡原文看的是0xb0000888+0x1380的內存,但我相信0xb0000888起始位置的內存值就能看出它不是一個正常的Class對象)
b0001c08: 0000000000000000 .......
分析到這裡,我可能就會懵逼一會兒,然後對著這個異常的0xb0000888犯迷糊。按照我的經驗,接下來會分析這個異常值周邊的內存,尋找一些規律確定這個對象是誰,但其實這個工作耗時,而且需要一些運氣。原文利用了它們自研工具的壞根檢測機制,直接檢測出這塊內存異常的對象大小,這能給確定對象的類型帶來極大的幫助。
最終,作者通過內存的數據規律,判斷出該對象屬於A類。
core-parser> class A -f
[0xb01f0888]
public final class A extends androidx.appcompat.widget.AppCompatImageView {
// Object instance fields:
[0x03b8] private boolean n
[0x03b4] private a.b.c.d.u.o q
[0x03b0] private volatile a.b.c.d.u.H k
// extends androidx.appcompat.widget.AppCompatImageView
[0x03ac] private final androidx.appcompat.widget.ld6 mImageHelper
[0x03a8] private final androidx.appcompat.widget.q mBackgroundTintHelper
[0x03a6] private boolean mHasLevel
...
}
那麼異常值為什麼異常也就確定了,類指針原本的值是0xb01f0888,而實際寫的值是0xb0000888,二者的差異是0x1f,這不像是硬體的bitflip,而且多次錯誤都集中在同一調用棧,更加否定了bitflip的可能。因此唯一可能只能是內存踩踏,這個類指針值被其他人給踩了。
正如原文裡作者說的,Java堆上的內存踩踏是缺乏調試工具的,因為不論是HWASan還是MTE,它們其實都是針對malloc的hook,而Java的堆是虛擬機自行管理的,它不走malloc,因此也就無法追蹤。那有人會問,為什麼ART虛擬機不針對Java堆也搞個調試機制呢?一是沒必要,能夠用上的使用場景實在太少;二是很麻煩,因為GC會頻繁地對存活對象進行搬移,搬移之後如何保持檢測,處理起來比較複雜;三是性能犧牲太大,不論是HWASan還是MTE,都依賴64位地址的高位不用來尋址、可以另作他用的這個feature。而Java對象由於都放在低4G內存中,因此它們的地址只有32bit,沒有多餘的空間用來保存tag。如果要支持調試,只能走ASan那種風格,會對堆內存產生嚴重的浪費。
但恰好,這個問題就需要去尋找Java堆內存踩踏的元兇。原文作者先用BPF採集的方式將範圍縮小,接著採用掛起→mprotect保護→恢復運行的方式,讓踩踏變成SIGSEGV的錯誤,從而恢復出踩踏瞬間的調用棧,知道它在幹嘛。
Native: #0 0000000072ef6cf4 /system/framework/arm64/boot-framework.oat+0x8facf4
Native: #1 0000000072ef6cf0 /system/framework/arm64/boot-framework.oat+0x8facf0
JavaKt: #00 0000006dfec2ae3e android.widget.ImageView.onDetachedFromWindow
JavaKt: #01 0000006d7d08ec04 B.onDetachedFromWindow
JavaKt: #02 0000006dfeb491e4 android.view.View.dispatchDetachedFromWindow
JavaKt: #03 0000006dfeb128e2 android.view.ViewGroup.dispatchDetachedFromWindow
JavaKt: #04 0000006dfeb128e2 android.view.ViewGroup.dispatchDetachedFromWindow
core-parser> class B -f
[0xb00f0dd8]
public class B extends android.view.TextureView {
// Object instance fields:
[0x0374] private java.lang.Boolean y
[0x0370] java.util.HashSet s
}
從上述調用棧,可以看到B.onDetachedFromWindow
調用了android.widget.ImageView.onDetachedFromWindow
,可是B繼承的壓根就不是ImageView,怎麼會跑到它的方法裡呢?
結合內存中的數據,發現正是因為android.widget.ImageView.onDetachedFromWindow
方法將this對象當成了ImageView(而它實際上是B),而B的大小比ImageView小,所以ImageView裡有些字段的偏移超出了。往這些字段裡寫數據,就會導致越界寫,從而造成內存踩踏。
那好端端的B.onDetachedFromWindow
為什麼會跳到android.widget.ImageView.onDetachedFromWindow
裡去呢?這還得回到B.onDetachedFromWindow
的字節碼來找原因。
core-parser> method 0xb0476378 --dex --oat
protected void B.onDetachedFromWindow() [dex_method_idx=15974]
DEX CODE:
0xb6d7d08ec04: 106f 0525 0001 | invoke-super {v1}, void android.view.View.onDetachedFromWindow() // method@1317
...
0xb6d7d08ec18: 000e | return-void
可以看到,它是通過invoke-super進入到的android.widget.ImageView.onDetachedFromWindow
。上述調用棧是原文作者自研工具的產物,但我相信,如果是原始調用棧(tombstone或gdb的原始輸出),我們應該可以從調用棧中看出B.onDetachedFromWindow
是解釋執行的。因此,需要從nterp的invoke-super源碼中去了解目標方法的來源,也即這裡錯誤的android.widget.ImageView.onDetachedFromWindow
來自何處。
%def invoke_direct_or_super(helper="", range="", is_super=""):
EXPORT_PC
// Fast-path which gets the method from thread-local cache.
% fetch_from_thread_cache("x0", miss_label="2f")
1:
// Load the first argument (the 'this' pointer).
FETCH w1, 2
.if !$range
and w1, w1, #0xf
.endif
GET_VREG w1, w1
cbz w1, common_errNullObject // bail if null
b $helper
2:
mov x0, xSELF
ldr x1, [sp]
mov x2, xPC
bl nterp_get_method
.if $is_super
b 1b
.else
tbz x0, #0, 1b
and x0, x0, #-2 // Remove the extra bit that marks it's a String.<init> method.
.if $range
b NterpHandleStringInitRange
.else
b NterpHandleStringInit
.endif
.endif
這是它的源碼,匯編格式。可以看到方法解析其實有兩條路徑,一條是fast path,一條是slow path。
Fast path就是fetch_from_thread_cache
,所以我們有必要了解下thread cache在這裡的含義。
%def fetch_from_thread_cache(dest_reg, miss_label):
// Fetch some information from the thread cache.
// Uses ip and ip2 as temporaries.
add ip, xSELF, #THREAD_INTERPRETER_CACHE_OFFSET // cache address
ubfx ip2, xPC, #2, #THREAD_INTERPRETER_CACHE_SIZE_LOG2 // entry index
add ip, ip, ip2, lsl #4 // entry address within the cache
ldp ip, ${dest_reg}, [ip] // entry key (pc) and value
cmp ip, xPC
b.ne ${miss_label}
這是Android為解釋執行引入的加速機制,因為每條invoke-指令基本都會觸發一次方法解析,為了減少這種解析開銷,ART引入了per-thread的cache。Cache裡可以存256個條目,每個條目是個鍵值對,key是dex_pc,也即字節碼的地址;value則根據invoke類型的不同而不同,對於invoke-super而言,它存的就是最終的目標方法指針(ArtMethod)。
fetch_from_thread_cache
是取,那麼什麼地方會往cache裡存呢?答案就是剩下的那條slow path。
Slow path會調用nterp_get_method
,它會先FindSuperMethodToCall,然後把解析出來的方法存入cache。
NTERP_TRAMPOLINE nterp_get_method, NterpGetMethod
LIBART_PROTECTED FLATTEN
extern "C" size_t NterpGetMethod(Thread* self, ArtMethod* caller, const uint16_t* dex_pc_ptr)
REQUIRES_SHARED(Locks::mutator_lock_) {
...
if (invoke_type == kSuper) {
resolved_method = caller->SkipAccessChecks()
? FindSuperMethodToCall(/*access_check=*/false)(method_index, resolved_method, caller, self)
: FindSuperMethodToCall(/*access_check=*/true)(method_index, resolved_method, caller, self);
if (resolved_method == nullptr) {
DCHECK(self->IsExceptionPending());
return 0;
}
}
...
if (invoke_type == kInterface) {
...
} else {
UpdateCache(self, dex_pc_ptr, resolved_method);
return reinterpret_cast<size_t>(resolved_method);
}
}
我們可以從當前線程的interpreter cache裡根據invoke-super的dex_pc值取出存在ArtMethod*,不出意外,確實是android.widget.ImageView.onDetachedFromWindow
。可是B壓根沒有繼承ImageView,怎麼會這樣呢?
讓我們重新思考cache的邏輯,key是dex_pc,意味著我們只要拿相同的dex_pc,那麼就會得到相同的方法。所以可不可以是別人拿著相同的dex_pc往cache裡面存了這個方法(android.widget.ImageView.onDetachedFromWindow)呢?
查找其他方法,發現有一個C.onDetachedFromWindow居然和B.onDetachedFromWindow共用同一段字節碼(字節碼的地址完全一樣)。
core-parser> method 0xb0404368 --dex --oat
protected void C.onDetachedFromWindow() [dex_method_idx=16079]
DEX CODE:
0x6d7d08ec04: 106f 0525 0001 | invoke-super {v1}, void android.view.View.onDetachedFromWindow() // method@1317
...
0x6d7d08ec18: 000e | return-void
再進一步查看C的類型,繼承自AppCompatImageView(未實現onDetachedFromWindow),而AppCompatImageView又繼承自ImageView(實現了onDetachedFromWindow)。
core-parser> class C -i -f
[0xb0017e60]
public final class C extends androidx.appcompat.widget.AppCompatImageView {
...
}
好了,這下邏輯鏈條清晰了,一定是C先解析,將ImageView的onDetachedFromWindow方法存入cache。而後B再運行,拿著相同的dex_pc取出了C先前存入的ArtMethod*。原文分析到這裡基本也就結束了,但為何二者會共用同一段字節碼,以及最終該如何修復並沒有展開。下面我們繼續。
要知道DEX字節碼的共用來自於何處,我們需要對App的編譯安裝有些了解。
程式員編寫的一般是kotlin或java,它們經過各自語言的編譯器,出來的結果就是class文件,其中包含的是JVM字節碼。
當App的minifyEnabled選項打開以後,class文件由R8進一步處理(未打開由D8處理,因此也不會有問題),它在JVM字節碼到DEX字節碼的轉換過程中會做shrink(縮減)、obfuscate(混淆)和optimize(優化)的動作。
待到App安裝到手機上時,dex2oat又會介入,它裡面有個環節,是將原始的DEX文件轉換成VDEX文件,其中關鍵有兩步:verification和quickening,也就是驗證和加速。加速主要是對一些指令進行改寫,從而減少運行時的解析成本。
了解了以上三個環節,我們不禁要問:字節碼復用到底發生在哪個環節?
我在本地做了些測試,最終發現復用發生在R8環節。因此,問題的矛盾點抓住了:R8的code deduplication機制在碰到invoke-super時,和ART內部的interpreter thread cache機制衝突了。R8認為字節碼相同便可以復用,但ART認為同一段字節碼,每次進來的invoke-super不可能變換目標。
其實這裡還隱藏著一個問題:為什麼兩個類的繼承關係明明不同,可是生成的invoke-super字節碼卻是相同的呢?
它們兩個生成的字節碼都是:
invoke-super {v1}, void android.view.View.onDetachedFromWindow() // method@1317
操作數裡指向的類竟然都是android.view.View
,也就是它們最頂端的父類。按照Java的語義,invoke-super指向的類應該是往上找到的第一个聲明該方法的類,對C而言,應該是ImageView才是。
帶著上述的發現和疑惑,我把問題反饋給了谷歌,拉了ART和R8相關的工程師。因為這個問題影響的範圍其實挺大的,而且時間應該不短。
谷歌的反饋很快,在看完分析後,他們承認這是一個原生bug。接著我又和他們溝通了一些技術細節,就修復方案給了一些建議。下面把完整的前因後果按照時間線給大家梳理出來:
至此以後,這個問題就一直存在著。
了解清楚了前因後果,那麼接下來就是修復方案。既然是多種機制合在一塊造成的問題,那麼修復放在哪邊就很有講究了。
改ART側?不能夠。因為按照Java原始語義,包括javac生成的結果,都表明同一段invoke-super字節碼,不應該存在多種理解。
改R8側的rebind機制?有點用,但阻止不了問題的發生。這裡涉及compile sdk和實際運行設備之間的區別,二者對於同一個類的方法定義可能是不同的。如果compile sdk裡這個類沒有實現這個方法,而運行版本實現了這個方法,問題依然會發生。
因此最好的修復方法就是改R8側的code deduplication機制:如果字節碼中有invoke-super,那麼就不能夠進行代碼去重。具體修復在這裡。今天剛剛提交。
再次感謝Penguin大佬,這個修復的絕大數功勞應該歸於他,因為這種複雜問題一般人真搞不定。
對眾多App開發者而言,我很好奇你們的App有沒有受到過這個問題的影響?以及編譯時用的R8版本是多少?谷歌也需要這個版本信息來決定修復需要backport到哪些歷史版本中。所以大家有類似經歷的話,歡迎留言!