知乎 Android 客戶端三方庫敏感程式碼掃描機制 - FindDanger
更多移動技術文章請關注本文集:知乎移動平臺專欄
背景
知乎非常重視使用者隱私資料的保護,安全團隊一直在為此提供各種保護機制;另外國內外一些知名的 Android 商店,如 Google+Play/">Google Play 等也會針對使用者隱私資料進行一系列的上架稽核,一旦出問題會被嚴重警告甚至下架。為全面保護使用者的隱私資料,同時避免被商店拒審或下架的風險,Android 移動平臺團隊建立了一套敏感程式碼掃描機制,取名為 FindDanger。
FindDanger 簡介
在 App 開發過程中,對於自己的程式碼中涉及使用者隱私的部分很容易約束,但對於 App 中引用的第三方庫是否會有此類行為就無法知曉了。對於這種情況,FindDanger 機制應運而生,FindDanger 是一套敏感程式碼掃描機制,主要用於掃描第三方庫是否存在獲取使用者隱私之類的高風險行為。
機制介紹
整個機制的執行過程如下圖所示:

image.png
由於我們使用 GitLab + Jenkins 進行程式碼管理和持續整合,所以普通的工程師也可以很快上手,具體的流程如下:
首先,工程師提交檢測 jar 包的 merge request 後,會觸發 Jenkins 上的掃描任務,掃描結束將報告連結傳送給 GitLab 的 merge request 頁面,如下圖所示:

image.png
然後,點選頁面中的連結開啟報告,如下圖所示:

image.png
最後,移動平臺的工程師對報告進行分析,並給出使用意見。
規則支援
目前 FindDanger 支援下列檢測規則,其中危險級別的數值越大越危險:
- StealAPPListClazz 讀取裝置已安裝的 App 列表 10
- StealRunningList 讀取裝置正在執行的 App 列表 10
- RuntimeCommand 通過 Runtime 介面執行終端命令 10
- StealAddressList 讀取通訊錄資訊 10
- StealAccount 讀取裝置賬號資訊 9
- StealBluetoothInfo 讀取裝置的藍芽資訊 5
- StealNFCInfo 讀取裝置的 NFC 資訊 5
- StealSensorInfo 讀取裝置的感測器資訊 5
- StealTelephonyInfo 讀取裝置的電話資訊 5
- StealWifiInfo 讀取裝置的 WIFI 資訊 5
FindDanger 原理之自定義 Lint 規則
從生成的報告可以看出,FindDanger 是利用 Android Lint 進行程式碼掃描的,而這之中最關鍵的就是如何自定義 Lint 規則以滿足我們的需求。
Android Lint 規則會打包到一個 Jar 包中,所以自定義規則主要有五步:
- 建立 Java Library 工程
- 實現自己的 Detector
- 建立對應的 Issue
- 實現自己的 Registry 以便將規則註冊到 Lint
- 在 App 中使用
第一步,建立 Java Library 工程
為了方便開發和除錯,建議建立一個空的 Android App 工程,然後新增 Java Module 用於編寫規則程式碼。
之後在 Java Module 中新增 lint-api 依賴:
dependencies { compileOnly "com.android.tools.lint:lint-api:26.1.4" }
第二步,實現自己的 Detector
建立 Java 工程後,就要開始寫規則程式碼了,每個規則都要實現一個 Detector,首先繼承抽象類 Detector,然後根據需求實現一個或多個 Scanner 介面。
Scanner 類有如下幾種:
- SourceCodeScanner 掃描 Java 或符合JVM規範的原始碼檔案(如 kotlin)
- ClassScanner 掃描編譯後的 class 檔案
- BinaryResourceScanner 掃描二進位制資原始檔(如.png)
- ResourceFloderScanner 掃描資源目錄
- XmlScanner 掃描 xml 檔案
- GradleScanner 掃描 Gradle 檔案
-
OtherFileScanner 掃描其他檔案
最常用的就是 SourceCodeScanner 和 ClassScanner,由於目標檔案的差異(原始碼檔案和 class 檔案),這兩個 Scanner 的實現方式完全不同,下面分別介紹。(當前 lint 最新版本是26.1.4。從原始碼看到 Detector 類被標記為 Beta,裡面還聲明瞭所有 Scanner 的方法,其實這些方法我們無法使用,個人猜測 Google 未來可能會用 Detector 封裝所有 Scanner 以達到簡化介面的目的)
先看 SourceCodeScanner,從原始碼看到這個介面聲明瞭很多 getApplicableXXX 和 visitXXX 方法,這些方法是成對使用的,比如我想掃描方法呼叫就實現 getApplicableMethodNames() 和 visitMethod() 方法,getApplicableMethodNames() 返回一個列表,包含了所有關心的方法名字,當掃描到關心的方法呼叫時 visitMethod() 會被回撥,在 visitMethod() 裡實現具體檢測邏輯,下面看個 Android 的例子。
如果我們在 App 中不正確的使用 AlarmManager.setRepeating 方法,會有 lint 提示:
image.png
我們看下 Android 是如何檢測的:
class AlarmDetector : Detector(), SourceCodeScanner { ... // AlarmDetector 只關心 setRepeating 方法呼叫 override fun getApplicableMethodNames(): List<String>? = listOf("setRepeating") // 掃描到方法名字為 setRepeating 的方法呼叫 override fun visitMethod(context: JavaContext, node: UCallExpression, method: PsiMethod) { val evaluator = context.evaluator // 判斷此方法是否是 android.app.AlarmManager 類中的,並且有 4 個引數 if (evaluator.isMemberInClass(method, "android.app.AlarmManager") && evaluator.getParameterCount(method) == 4) { // 判斷索引為1的引數是否小於5000 ensureAtLeast(context, node, 1, 5000L) // 判斷索引為2的引數是否小於60000 ensureAtLeast(context, node, 2, 60000L) } } // 如果引數小於最小值,上報錯誤 private fun ensureAtLeast(context: JavaContext, node: UCallExpression, parameter: Int, min: Long) { val argument = node.valueArguments[parameter] val value = getLongValue(context, argument) if (value < min) { val message = "Value will be forced up to $min as of Android 5.1; " + "don't rely on this to be exact" context.report(ISSUE, argument, context.getLocation(argument), message) } } }
可以看到在 visitMethod 裡,根據三個引數 JavaContext、UCallExpression、PsiMethod 可以很方便的獲取方法的引數列表、所在類等資訊。
(這裡的 UCallExpression 和 PsiMethod 其實是原始碼抽象語法樹 AST 的兩種實現,最早 Android Lint 用 Lombok 解析 AST,由於 Lombok 只能支援到 Java6 並且功能有限,在 AndroidStudio 2.2 之後 Lint 改用 PSI 解析語法樹,但 PSI 也只是過渡方案,因為他不支援 kotlin,後面 Lint 會使用 UAST 作為抽象語法樹解析庫,PSI 和 UAST 都是 JetBrains 為 IDEA 開發的,UAST 是基於 PSI 擴充套件而來所以很容易移植,UAST 的優勢是不止支援 Java 原始碼,理論上能夠支援任何 JVM 型別的語言)
同樣的,ClassScanner 也聲明瞭一些 getApplicableXXX 和 checkXXX 方法,在 FindDanger 中用的最多的就是 getApplicableAsmNodeTypes() 和 checkInstruction() 方法,getApplicableAsmNodeTypes() 方法返回我們關心的指令列表,當掃描到關心的指令時會呼叫 checkInstruction() 方法,下面看個 FindDanger 中的例子:
/** * @author zhoukewen * @since 2018/8/20 */ class StealNFCInfoDetector : Detector(), ClassScanner { /** * 返回這個 Detector 適用的 ASM 指令 */ override fun getApplicableAsmNodeTypes(): IntArray? { //這裡關心的是與方法呼叫相關的指令,其實就是以 INVOKE 開頭的指令集 return intArrayOf(AbstractInsnNode.METHOD_INSN) } /** * 掃描到 Detector 適用的指令時,回撥此介面 */ override fun checkInstruction(context: ClassContext, classNode: ClassNode, method: MethodNode, instruction: AbstractInsnNode) { if (instruction.opcode != Opcodes.INVOKEVIRTUAL) { return } val callerMethodSig = classNode.name + "." + method.name + method.desc val methodInsn = instruction as MethodInsnNode // 這裡邏輯是:呼叫 NfcAdapter 中的任何方法都會報告異常 if (methodInsn.owner == "android/nfc/NfcAdapter") { val message = "SDK 中 $callerMethodSig 呼叫了 " + "${methodInsn.owner.substringAfterLast('/')}.${methodInsn.name} 的方法來獲取 NFC 資訊,需要注意!" context.report(ISSUE, method, methodInsn, context.getLocation(methodInsn), message) } } }
通過 checkInstruction() 方法的四個引數可以方便的獲取當前指令的上下文環境。
有了這些資訊便可知道原始碼或者位元組碼是否存在問題程式碼,當發現問題時可以用ClassContext、JavaContext 的 report 介面上報。不同介面的 report 引數不同,我們只要自定義好 message 即可。report 還有個可選的 LintFix 引數,這個引數作用是提供一個快速修復的功能,這裡不做介紹,感興趣的同學可以自行檢視原始碼。
第三步,建立對應的 Issue
Issue 代表具體的問題物件,物件包含問題的型別、描述、級別,還有上報問題的 Detector 和對應的 Scope。
下面看個 Issue 的例子:
val ISSUE = Issue.create( "StealNFCInfo",//問題 Id "",//問題的簡單描述,會被 report 介面傳入的描述覆蓋 "",//問題的詳細描述 Category.CORRECTNESS,//問題型別 6,//問題嚴重程度,0~10,越大嚴重 Severity.ERROR,//問題嚴重程度 //Detector 和 Scope 的對應關係 Implementation(StealNFCInfoDetector::class.java, EnumSet.of(Scope.CLASS_FILE, Scope.JAVA_LIBRARIES)))
通常情況下 Issue 和 Detector 是一一對應的,所以將 Issue 宣告為 Detector 的靜態屬性會比較直觀。
第四步,建立 Registry
有了 Issue 之後還要手動註冊,首先實現一個 IssueRegistry,然後複寫 getIssues 返回我們的 Issue 列表:
class DangerIssueRegistry : IssueRegistry() { override val issues: List<Issue> get() { return Arrays.asList( StealAPPListClazzDetector.ISSUE, StealRunningListDetector.ISSUE, ... StealWifiInfoDetector.ISSUE) } override val api: Int get() = CURRENT_API }
接下來需要在 manifest 中宣告 Registry,在 build.gradle 裡新增:
jar { manifest { attributes("Lint-Registry-v2": "com.zhihu.android.findDanger.DangerIssueRegistry") } }
大功告成,編譯打包後就會生成包含自定義規則的 Jar 包。
第五步,在 App 中應用
最後一步就是將自定義規則應用到 App 中了,最新版的 Android Gradle 外掛提供了簡便的方式(不用再自己包裝 AAR 了),在 App 的 dependencies 中新增 lintChecks,和使用 implementation 沒有任何區別:
dependencies { implementation fileTree(include: ['*.jar'], dir: 'libs') lintChecks 'zhihu.find.danger:FindDanger:local' //或者 lintChecks project(':findDangerRules') }
然後執行:
./gradlew app:lintDebug
即可看到輸出的報告檔案。
Android Lint 執行原理
為了便於大家理解,我們可以看看 Android Lint 是如何使用我們定義的規則的,下圖是簡單的 Android Lint 處理流程:

image.png
簡單介紹一下上圖中的幾個流程:
- 啟動 Lint 後會先解析引數並做一些準備工作
- 之後我們自定義的規則和系統自己的規則會統一放到了一個 Map 裡
- 然後根據專案的檔案型別按順序掃描檔案,掃描過程中報告的問題物件都存在記憶體中,
- 全部掃描結束會使用具體 Reporter(如 HtmlReporter、XMLReporter 等)建立報告檔案。
成果展示
FindDanger 上線後,我們對正在評審中的一個第三方庫進行掃描,發現其有兩處上報使用者資料的行為:
- 上報使用者已安裝的應用列表
- 上報使用者正在執行的應用列表
以上兩個問題的風險較大,不但有可能被 Google Play 商店拒審,更重要的是可能造成我們的使用者資料洩露,這個是我們的底線絕對不能被觸碰。
另外還有若干個地方也存在一定的風險,不過由於影響不大,所以就不在這裡一一列舉了。
最終我們的處理結果是:直接移除了此庫,改用別的方案。
FindDanger 後續計劃
目前最新的 Android Lint 版本是 26.1.4,從註釋看還處於 Beta 版,介面可能會發生變化,並且第一版 FindDanger 支援的功能有限,後續還會有一些改進計劃,如:
- 新增對 aar 包的支援(官方暫不支援,但需求較大)
- 豐富檢測規則
- 增加更復雜的邏輯檢測,比如發現存在讀取隱私的程式碼後還能進一步檢測到將隱私洩露的程式碼
- 隨著 Android Lint 版本變化,持續維護相關 Api
最後
由於本人的水平有限,如有錯誤和疏漏,歡迎各位同學指正。
另外,知乎移動平臺團隊也在招人中,歡迎各位小夥伴的加入,和我們一起做一些酷事情!具體招聘資訊在這裡 ofollow,noindex">https://app.mokahr.com/apply/zhihu#/job/7b1b32c2-f30c-4638-93ce-09c2ac9a52d8
關於作者
周柯文 ,知乎 Android 資深基礎架構工程師,目前負責知乎 Android App 效能監控相關工作。