本文作者:燒麥
目前,國內對應用程式安全隱私問題監管變的越來越嚴格。各個應用市場對APP上架也有比較嚴格的檢查。雲音樂今年也在Google Play上上架了一些海外的社交類業務。Google Play在稽核應用的時候,也有相應的政策。當我們每次遇到問題的時候,需要根據檢查方的資訊對一些程式碼邏輯進行排查。
這是一個相對來說非常低效的過程。開發在平時寫程式碼的時候一般不會使用敏感的API。大部分的敏感 API 呼叫都在一些三方 SDK 裡面,或者一些敏感級別不是很高的 API,會存在多次呼叫的情況,例如:
- 某應用在Google Play上架,雲音樂內部的基礎 SDK 裡包括了一些國內的三方 SDK,這些SDK 使用了熱修復或者動態下發 so 的功能。被 Google Play 發現拒審。
- 某應用在三方的檢查中發現對地理位置的獲取存在每 30 秒一次的頻繁呼叫。
為了避免這類問題拖累 APP 上架,也為了提升檢查的準確性和效率。筆者開發了一個針對 Android APK 的敏感方法呼叫的靜態檢查工具。
檢查關鍵字,對於一些敏感 API 呼叫,例如 oaid、androidId 相關的呼叫。我們其實只要能檢測到這些相關 API 裡的一些關鍵字,找出整個 APP 裡面有哪些地方直接呼叫了這些方法就可以了。
針對的上述的一些場景,這個工具具有兩個方向的工作:
- APK 包的掃描,檢查出整個APK中,哪些地方有對包含上面這些 API 關鍵字的直接呼叫。
- 執行時檢查。針對執行時頻繁呼叫這個場景,還是需要在執行時輔助檢查特定API的呼叫情況。
工具方案
執行時
執行時的檢測需要知道我們的方法在什麼時候被呼叫了。那麼被檢測方法如果能有呼叫棧,那麼我們在整改執行時的一些場景就會比較容易。這裡我們用一個 Gradle 外掛在 transfrom 裡給我們需要檢測的方法插入一行列印呼叫棧的程式碼即可。
這裡利用 Javassist
給找到的方法插入一行呼叫棧列印就可以。
method.insertBefore(
"android.util.Log.e(\"隱私方法呼叫棧\", android.util.Log.getStackTraceString(new Throwable()));"
)
產物掃描
對APK的掃描思路其實也很簡單,我們的訴求就是檢查所有的程式碼。但是我們這時候只有一個 APK 檔案。那最直接並且掃描簡單的的就是想辦法把我們的包轉成Java程式碼,逐行掃描我們的 Java 程式碼,檢查是否有敏感的 API 呼叫。
如果我們平時想看一個APK包裡面的程式碼我們會怎麼做呢,最簡單的就是反編譯這個APK,然後把裡面的 dex 檔案轉成 Java 去檢視。我們可以用指令碼把這個流程再實現一遍。
- 第一步先解壓APK檔案,把裡面的dex檔案單獨拿出來。
- 使用
dex2jar
把 dex 檔案轉成 jar 檔案。 - jar檔案轉成java檔案
- 逐行掃描java檔案
那麼如何把jar檔案轉成java檔案呢?我們平時點開Android Studio裡面的jar或者aar就可以看到Java檔案。我們也可以參考Android Studio的做法。
在 IDEA
的目錄裡面,我們其實可以找到相關功能依賴的 jar 包,也可以clone IDEA原始碼裡面的相關模組自己打一個jar。
掃描工具的工作流程如圖:
多APP配置
雲音樂目前旗下APP比較多,不同的APP也有可能會有不同的掃描型別和不同的關鍵字規則。
配置如下:
每個 APP 目前最多會有兩份配置:
gp.json 和 privacy.json 分別對應 google play掃描和隱私合規掃描。
裡面的配置包括
- keys 掃描的關鍵字。
- filterPackages 過濾掉的包名。如果我們關注是不是某些三方 SDK 寫了一些不合規的程式碼,那麼我們可以把自己的包名給過濾。避免輸出結果太多。
掃描結果會輸出一個 json 檔案和 html 檔案。json 檔案可以對比上次的掃描結果,增量的輸出新增的掃描結果。html 檔案則用來展示掃描結果,輔助對應的排查人排查相關的問題。
例如,熱修復等動態下發的相關技術,都會有關於 getField
、ClassLoader
之類的關鍵字存在。
我們可以找到直接呼叫這些 API 的地方,從下圖我們可以看到,很多呼叫都是在三方 SDK 裡面找到的。
優化
第一版的合規掃描開發完後,在使用上還是有一些問題:
- 執行時:檢查我們自己程式碼內的方法很容易,但是如果想要檢測系統 API 的時候就無效了。因為Android Framework 的 API 不會參與打包。自然也不可能插入位元組碼。
- 產物掃描:jar 轉 java 的過程非常的耗時。整體掃描時間會被拉到3-5分鐘。
- jar 轉 java 的過程實際上也是一種反編譯過程。因為 java 和 kotlin 語法的問題,某些會decompile 失敗。這種情況多了的話,其實掃描是會有遺漏的。
- 掃描 java 檔案是逐行遍歷,把其他地方的關鍵字也掃描進來了,比如 field、import。這些掃描結果實際上是多餘的。
針對上面這些問題,進行了針對性的優化。
執行時如果要檢測系統 API 的呼叫,想到兩種方案:
- transform 處理每個 class 和 jar 檔案的時候,都去看下 class 內部的 method 有沒有去呼叫這個系統 API。但是這個依賴位元組碼操作庫的支援。
- 用一個專門的手機,用 xposed 之類的外掛去 hook 系統 API。
第二種實現成本會比較高,不適合。但是運氣比較好的是使用的 javassist 是支援第一種思路的操作的。
javassist 的 CtMethod
繼承自 CtBehavior
物件。包括一個 instrument
方法。這個方法會找到方法內的表示式並允許替換。這裡的表示式就包括 MethodCall
。
這樣我們通過這個功能找到所有的呼叫就可以給直接呼叫了系統 API 的這個方法插入呼叫棧的列印。
執行期的檢查就變成了:
完成這個優化之後,我們可以發現實際上在編譯期的方法掃描,我們是通過直接讀取 class 檔案去做的。那麼對於 APK 包,我們也可以採取類似的思路。用相同方法去讀取 dex2jar 之後解壓出來的 jar包裡的 class 檔案。
但是再仔細想想,Android 在 class 檔案之後會有 dex 檔案。Android 虛擬機器直接執行的應該是 dex檔案。而 dex 檔案本質上只是一種二進位制格式,最終會根據這個檔案格式裡的內容,按照彙編去執行。
思路到這裡就清晰了,如果我們試著把 dex 檔案直接反彙編成 smali 檔案,去遍歷 smali 檔案可能效果會更好。
smail 語法介紹
一個 smail 檔案對應一個 Java 的類,準確來說,是對應一個 .class 或者 .dex 檔案。
內部類則會按照 ClassName$InnerClassA
、ClassName$InnerClassB
的格式來命名。
smail 裡面存在的基本型別,分別對應 Java 的基本型別,如下表所示:
型別關鍵字 | Java 基本型別 |
---|---|
V | void |
Z | boolean |
B | byte |
S | short |
C | char |
I | int |
J | long |
F | float |
D | double |
smail一些常見的基本指令如下表:
指令 | 含義 |
---|---|
.class | 包名和類名 |
.super | 父類 |
.source | 原始檔名稱 |
.implements | 介面實現 |
.field | 變數 |
.method | 方法 |
.end method | 方法結束 |
.line | 行數 |
.param | 函式引數 |
.annotation | 註解 |
.end annotation | 註解結束 |
方法的呼叫也分為以下幾種指令:
指令 | 含義 |
---|---|
invoke-virtua | 呼叫虛方法 |
invoke-static | 呼叫靜態方法 |
invoke-direct | 呼叫沒有被override的方法,例如private和構造方法 |
invoke-super | 呼叫父類的方法 |
invoke-interface | 呼叫介面方法 |
我們看一個示例 smali
檔案的格式:
.class public abstract Lcom/horcrux/svg/RenderableView;
.super Lcom/horcrux/svg/VirtualView;
.source "RenderableView.java"
# static fields
.field private static final CAP_BUTT:I = 0x0
.field static final CAP_ROUND:I = 0x1
# instance fields
.field public fillOpacity:F
.field public fillRule:Landroid/graphics/Path$FillType;
.method static constructor <clinit>()V
.registers 1
.line 97
invoke-static {v0}, Ljava/util/regex/Pattern;->compile(Ljava/lang/String;)Ljava/util/regex/Pattern;
return-void
.end method
.method resetProperties()V
.registers 4
.line 635
invoke-virtual {p0}, Ljava/lang/Object;->getClass()Ljava/lang/Class;
invoke-virtual {v1, v2}, Ljava/lang/Class;->getField(Ljava/lang/String;)Ljava/lang/reflect/Field;
return-void
.end method
smali
檔案的開頭會告訴類名、父類、原始檔名。
這個檔案的類名就是 com.horcrux.svg.RenderableView
。父類是 com.horcrux.svg.VirtualView
,原始檔名為 RenderableView.java
。
裡面的變數和方法都有開頭和結束的標記。
在 .method
裡,我們可以看到
line
開頭會標記行號invoke-
開頭會標記方法的呼叫
上面例子裡包括兩個方法:
- 構造方法。97行調了一個靜態方法。
resetProperties
方法。在 635 行,呼叫了 getClass() 和 getField() 這兩個虛擬函式。
對應的java程式碼則是:
// line 635
Field field = this.getClass().getField((String)this.mLastMergedList.get(i));
這裡我們基本能定義出 smali 檔案的掃描方式:
- 逐行讀取一個smali檔案,讀到前面三行的時候,讀取類的基本資訊。
- 讀取到
.method
和.end method
的時候標記為讀取到自己的方法。 - 讀取到
.line
和下一個.line
的時候,標記為讀取到方法內的具體行號。 - 讀取到
invoke-
開頭的行,標記為讀取到方法呼叫。如果此行末尾的方法簽名滿足我們的關鍵字匹配,就記錄為掃描結果之一。
在實踐中,我們可以使用開源的 baksmali.jar
進行dex轉 smali的操作。使用上述規則直接掃描 smali 檔案。避免了上面提到的缺陷。掃描時間也有很大的提升。基本上在半分鐘左右都可以完成整個全量的掃描。省略了反編譯jar包的巨長時間。
這個工具最終呈現為一個 jar 檔案,通過命令列執行。在排查隱私合規可疑的 API 呼叫的時候,非常適用。
總結
通過這個工具, 在 APK 的隱私合規問題檢查的時候,我們可以獲取比較完整的可疑呼叫來輔助我們進行合規方面工作的處理。
這個工具的優勢在於:
- APK 包是最終產物,掃描內容比較完整。
- 不在編譯期進行掃描,不會降低開發效率。
但是這個工具還有一些不足之處,例如
- 不能精確定位到隱私函式呼叫具體歸因在哪個模組或者 aar,難以整合在 CI/CD 進行歸因處理。
- 比較難以獲取完整的函式呼叫鏈。
所以我們還會繼續進行編譯期的合規檢查工作。兩者結合來完善相關的工作。
本文釋出自 網易雲音樂大前端團隊,文章未經授權禁止任何形式的轉載。我們常年招收前端、iOS、Android,如果你準備換工作,又恰好喜歡雲音樂,那就加入我們 grp.music-fe(at)corp.netease.com!