不要将注意力集中在未来需要做的一百件事情上,而要将注意力集中在此刻可以做的一件事情上。
——《当下的力量》
科普:AOSP - Android Open Source Project
AOSP即Android Open Source Project(安卓开源项目),每一名Android开发者对它都应该不陌生。它是由Google主导的开源项目,包含Android操作系统源代码以及一系列工具。任何一家Android手机厂商的商用操作系统,都是基于AOSP进行定制开发的。
作为世界上影响力最大的开源项目之一,AOSP对于提交者的代码质量、代码规范、提交记录有着严苛的要求,根据我的经验,在身边的Android开发者中,有过AOSP代码贡献经验的不会超过百分之一。
因此,如何识别并修复Google的Android源码漏洞,并且成功提交到AOSP代码仓库,是一项不小的挑战。
背景和问题简述
整个事情来源于一次对线上问题的分析定位,其中既有运气和机会的成分——我所在的手机厂是国内最早一批开发基于Android 15系统版本的公司,同时也离不开自己平时对于Android Framework源码的探究和理解,以及排查内存问题过程中所积累的Java虚拟机经验。
问题的表现是,公司的APM系统搜集到了一条不常见的泄漏,关键类为ApplicationPackageManager和PackageParser2。出于信息安全的考虑,我这里没有直接贴出原始堆栈,而是用自己的demo app复现了出来:
在分析问题、识别漏洞以至于最终提交并成功合入AOSP的过程中,经过反思和复盘,我认为最关键的要素有两点。
关键点一:综合运用理论知识识别并修复漏洞
这部分所需要的技能点有 1.Android Profiler工具的使用,2.JVM基础知识,3.ThreadLocal实现原理,4.匿名内部类导致内存泄漏
根据上述截图中的引用路径,结合JVM中关于引用链、GC roots的相关知识,我们可以得知,是ThreadLocal中的ThreadLocalMap通过层层引用,持有Activity对象,导致后者泄漏。ThreadLocal机制中通过Entry保存对象,在使用后必须显式释放,否则必然引起泄漏。经查阅cs.android.com的源码发现,在Android 15中,PackageManager类里面解析APK文件的默认实现由PackageManager替换为了PackageManager2。
// PackageManager.java
@Nullable
public PackageInfo getPackageArchiveInfo(@NonNull String archiveFilePath,
@NonNull PackageInfoFlags flags) {
final File apkFile = new File(archiveFilePath);
// irrelavent code ...
final PackageParser2 parser2 = new PackageParser2(/*separateProcesses*/ null,
/*displayMetrics*/ null,/*cacher*/ null,
new PackageParser2.Callback() {
@Override
public boolean hasFeature(String feature) {
return PackageManager.this.hasSystemFeature(feature);
}
@NonNull
@Override
public Set<String> getHiddenApiWhitelistedApps() {
return Collections.emptySet();
}
@NonNull
@Override
public Set<String> getInstallConstraintsAllowlist() {
return Collections.emptySet();
}
@Override
public boolean isChangeEnabled(long changeId,
@androidx.annotation.NonNull ApplicationInfo appInfo) {
return false;
}
});
try {
ParsedPackage pp = parser2.parsePackage(apkFile, parserFlags, false);
return PackageInfoCommonUtils.generate(pp, flagsBits, UserHandle.myUserId());
} catch (PackageParserException e) {
Log.w(TAG, "Failure to parse package archive apkFile= " +apkFile);
return null;
}
}
而在PackageParser2里,维护了mSharedAppInfo和mSharedResult两个类型为ThreadLocal的成员变量。我们知道对于ThreadLocal类型的变量,必须在使用完成后手动释放,否则必然引起对象泄漏。PackageParser2类的维护者显然也知道这一点,并提供了close()方法供使用方调用。
// PackageParser2.java
private final ThreadLocal<ApplicationInfo> mSharedAppInfo =
ThreadLocal.withInitial(() -> {
ApplicationInfo appInfo = new ApplicationInfo();
appInfo.uid = -1; // Not a valid UID since the app will not be installed yet
return appInfo;
});
private final ThreadLocal<ParseTypeImpl> mSharedResult;
@Nullable
protected IPackageCacher mCacher;
private final ParsingPackageUtils mParsingUtils;
public PackageParser2(String[] separateProcesses, DisplayMetrics displayMetrics,
@Nullable IPackageCacher cacher, @NonNull Callback callback) {
if (displayMetrics == null) {
displayMetrics = new DisplayMetrics();
displayMetrics.setToDefaults();
}
List<PermissionManager.SplitPermissionInfo> splitPermissions = null;
final Application application = ActivityThread.currentApplication();
if (application != null) {
final PermissionManager permissionManager =
application.getSystemService(PermissionManager.class);
if (permissionManager != null) {
splitPermissions = permissionManager.getSplitPermissions();
}
}
if (splitPermissions == null) {
splitPermissions = new ArrayList<>();
}
mCacher = cacher;
mParsingUtils = new ParsingPackageUtils(separateProcesses, displayMetrics, splitPermissions,
callback);
ParseInput.Callback enforcementCallback = (changeId, packageName, targetSdkVersion) -> {
ApplicationInfo appInfo = mSharedAppInfo.get();
//noinspection ConstantConditions
appInfo.packageName = packageName;
appInfo.targetSdkVersion = targetSdkVersion;
return callback.isChangeEnabled(changeId, appInfo);
};
mSharedResult = ThreadLocal.withInitial(() -> new ParseTypeImpl(enforcementCallback));
}
@Override
public void close() {
mSharedResult.remove();
mSharedAppInfo.remove();
}
分析到这里,问题的原因就很明显了:
- App业务里调用了
Activity.packageManager.getPackageArchiveInfo()函数,来获取APK文件的manifest信息 PackageManager对象将调用者Activity的Context保存为成员变量mContextPackageManager在创建PackageParser2对象时,传入一个PackageParser2.Callback()类型的匿名内部类- 由于该匿名内部类需要访问外部对象
return PackageManager.this.hasSystemFeature(feature),因此它持有外部对象的引用,从而与mContext之间建立了强引用关系 PackageParser2在使用完毕后,未显式释放,导致匿名内部对象无法回收,最终导致Activity泄漏
相应的解决方案并不复杂,可以直接看我在Google代码仓库中的提交。在使用完毕PackageParser2对象后,显式调用其close()方法。
// PackageManager.java
try {
ParsedPackage pp = parser2.parsePackage(apkFile, parserFlags, false);
return PackageInfoCommonUtils.generate(pp, flagsBits, UserHandle.myUserId());
} catch (PackageParserException e) {
Log.w(TAG, "Failure to parse package archive apkFile= " +apkFile);
return null;
} finally {
parser2.close();
}
关键点二:高效配置环境、提交代码并推动合入到AOSP主线
这部分所需要的技能点有 1.AOSP代码库管理方式,2.repo环境配置和工具使用,3.AOSP代码提交规范,4.如何快速推进自己的PR被合入主线
单纯从源码上修复漏洞并不复杂,但如何将它提交到Google的AOSP代码仓,提PR并且合入,也是一件让人头疼的事。
Windows环境使用不了repo工具
首当其冲的是环境问题,我的个人电脑使用的是Windows 11系统,剩余空间只有200G。AOSP的源码由于模块众多,功能繁杂,并不是直接通过git进行版本控制,而是通过repo工具进行版本维护的。repo可以理解为集成了众多git指令的批量工具。在开始使用时需要下载repo并配置到系统的环境变量里。
很遗憾,我的Windows 11环境无法使用repo。考虑到短期内我不可能购置一台MacBook,终究还是要从手上这一台Windows笔记本电脑上想办法。
2012年前后,我有过使用VirtualBox安装Ubuntu系统的经验,如今Windows已经内置了WSL2,可以作为VirtualBox的上位替代,自由安装Ubuntu操作系统,其内部仍然是虚拟机的实现。
安装过程比较简单,这里不详述。在安装完成Ubuntu后,进入系统,通过wget下载repo工具,然后依次执行init和sync命令:
repo init --partial-clone -b main -u https://android.googlesource.com/platform/manifest
repo sync -j8 -c
控制台开始下载代码了,目前看来一切正常。
下载代码量高达几十个G,网速/硬盘扛不住
但几分钟之后,我就发现事情并不简单——AOSP的代码仓库实在是太大了!!!高达100GB的、部署在Google服务器的代码,凭我的电信小水管怕不是要下载整整一晚。这个方案不太行,必须要找workaround!
凭借对repo工具的理解,我灵光一现——
既然repo相当于批量化git操作,那么我是否可以直接使用git来clone某个模块的代码,并且进行提交呢?
已知执行完repo init命令后,代码库的基本信息会被初始化在.repo目录下的default.xml文件中,我从中找到PackageManager.java所属的framework/base文件路径:
<!-- default.xml -->
<project path="frameworks/base" name="platform/frameworks/base" groups="pdk-cw-fs,pdk-fs,sysui-studio" />
在本地直接对该模块单独下载,速度快了何止一大截。并且由于clone后默认是master分支,所以不需要额外的分支切换操作。
git clone https://android.googlesource.com/platform/frameworks/base
随后就可以通过vscode对源码进行修复了。但使用git有一个大问题,会直接导致代码被打回,问题就是git提交时没有执行repo默认的commit hook,从而无法生成change-id。
解决无法生成change-id的问题
在repo upload时,会自动执行.git/hooks/commit-msg命令,在commit msg里自动生成如下包含change-id的信息:
$ git log -1
commit 29a6bb1a059aef021ac39d342499191278518d1d
Author: A. U. Thor <author@example.com>
Date: Thu Aug 20 12:46:50 2009 -0700
Improve foo widget by attaching a bar.
We want a bar, because it improves the foo by providing more
wizbangery to the dowhatimeanery.
Bug: #42
Change-Id: Ic8aaa0728a43936cd4c6e1ed590e01ba8f0fbf5b
Signed-off-by: A. U. Thor <author@example.com>
CC: R. E. Viewer <reviewer@example.com>
与commit id不同,change-id以大写I开头,用来表示一次修复或者提交,即使在不同的代码仓库里,只要是同一笔修复,都会具有完全相同的change-id。
由于我们是手动git clone下来的代码,在进行commit时没有执行repo默认的hook,导致无法带上change-id。解决方法是,将repo根目录下的.git/hooks/commit-msg复制到我们的framework/base目录下,这样在进行commit时就会自动执行其中的hook脚本了。
至此已经可以提交代码到Google的代码仓库,同时创建PR。创建PR时会根据代码模块不同,自动关联相应的代码owner,我的PR如下图所示:
合理利用规则,推进PR合入AOSP代码仓主线
虽然提交了PR,但直到代码合入之前,都不算成功提交。由于Android 15的源代码对全体开发者都是可见的,任何人都可以赶在我之前,提交相应修复到Google的代码仓,这场赛跑里,只有第一名才有资格成为漏洞的修复者。
要怎么让自己的PR尽快被+2并且Merge进主线呢?我想到了Google官方的IssueTracker。
Google 的 Issue Tracker 是 Google 用来管理项目问题和任务的内部和外部工具,主要用于跟踪和管理错误报告(bug reports)、功能请求、任务等。它广泛应用于 Google 的开源项目、开发者平台、Google Cloud 服务以及内部开发项目中
我在IssueTracker上建立了一个bug单,详细标明了源码中漏洞的位置、原因与解决方法,并在bug单第一行醒目位置填上了自己对AOSP源码提交的的PR链接。这样Google的QA在看到bug单的第一时间,就可以分配相应的责任人。有了bug单,也会促使责任人更快地进行代码审查和合入。
果然,在创建bug没多久,我就收到邮件通知,bug已经变为Assigned状态,意味着已经指派给相应的模块负责人。
后续大概过了半天左右,我就看到自己的PR已经被两个Google的模块开发者+2,并且Merge进了main主线分支。我也可以自豪地讲,自己是一名AOSP代码贡献者了。
这件事教会了我什么
这个泄漏的问题早在Android 15的正式版之前就已经存在了,我相信自己并不是第一个遇到ApplicationPackageManager和PackageParser2导致内存泄漏的开发者,然而,为什么Google的软件工程师犯了如此严重的一个错误而不自知?为什么全网都没有人定位到这个问题的根本原因呢?
我认为,一方面是PackageParser2类的维护者没有提供明确的使用指南,导致Google开发人员把它当成一个普通的类来使用,误以为JVM会自动对其进行回收。另一方面,也许国内有同行发现了这一问题,但在下载、提交代码到AOSP的过程中遇到一些阻碍。作为替代方案,他们采用ApplicationContext来进行调用,可以临时解决泄漏的问题。
解决这件事情,花掉了我一个周末的时间,我认为这是相当值得的。