步步高家教机加密安装包 BPK 研究 (已弃坑)
0x00 设备准备
设备 | 版本 |
---|---|
步步高家教机 S5 | Android 9.0 / StudyOS V6.3.0 / V1.5.2_220712 |
0x00 加密说明
步步高家教机内部软件使用特殊的 BPK 加密格式,只有步步高定制的 StudyOS 内的 PackageManager、ActivityManager 可以打开此加密格式,其他未经修改的 PM 和 AM 均无法读取此加密格式,也无法安装它。
步步高定制系统可以同时读取 APK 以及加密 BPK 文件,BPK 在被安装后依旧是 BPK,并不会被解密为 APK。
0x01 加密规律分析
众所周知,APK 软件包的本质就是一个 ZIP 压缩包,而 BPK 同理
APK (ZIP) 文件头 | BPK 文件头 | 释义 |
---|---|---|
50 4B 01 02 | 42 50 4B 01 | 目录开始标记 |
50 4B 03 04 | 42 50 4B 03 | ZIP 文件头标记 |
50 4B 05 06 | 42 50 4B 05 | 目录结束标记 |
50 4B 07 08 | 42 50 4B 07 | ZIP 分卷标记 |
BPK 打包并不会修改任何 classes.dex 中的字节码和任何资源文件,同时不会破坏原有 APK 的签名。换句话说,BPK 打包是完全通过操作 ZIP 文件特性实现的。
通过对比相同版本的 BPK、APK 文件 (Google Play Store 10.2.98 036-146496160),发现两者的大小是完全一致的,并且通过之前得到的结论,BPK 打包的文件并不会破坏原有的 APK 的签名,所以文件的差异就只能在 ZIP 的头部和尾部,一旦动了中间的数据部分,签名就会失效。已经知道了头部的规律,那么查看尾部的最后一段差异,发现和 APK 有较大的区别
0x03 逆向分析
既然 StudyOS 基于 Android,那么必定逃不出 Android 底层运行的规律。借此突破口,我们可以找到一些方法来进行逆向分析。
0. 开启 USB 调试
要开始分析 Android,通过 Adb 调试是最方便的方式,第一步自然是开启 Adb 调试授权。
快速点击五次 设置 -> 关于设备 中的传播名称,会提示 "检查USB调试授权",最终当然是返回没有权限的,那么这个授权是如何实现的?正好设置 (Settings.apk) 并未经过加密,反编译设置中的相关类可以发现一些端倪
com.android.settings.AboutSettingsEx
点击传播名称后,系统会向云控后端请求
POST /os-app/app/usbScreenDebug/getUsbScreenDebugStatusByMachineId h2
Host: os-app.eebbk.net
accountid: 1#######9 # 步步高账号
devicemodel: S5 # 机器型号
machineid: 7H7#########K # 机器序列号
apkversioncode: 4010200 # 设置版本号
apkpackagename: com.android.settings # 设置包名
deviceosversion: V1.4.3_200818 # 机器固件版本
content-type: application/x-www-form-urlencoded
content-length: 23
accept-encoding: gzip
user-agent: okhttp/3.11.0
machineId=7H7#########K # 机器序列号
云控则会返回
h2 200
date: ###, ## ### 2022 ##:##:## GMT
content-type: application/json;charset=UTF-8
content-length: 80
strict-transport-security: max-age=15724800; includeSubDomains
{"stateCode":"0","stateInfo":"成功","data":"4c3f15b8e914f5d20bb108bf2ad8da5b"}
此处的 data
被 DES 加密了,通过工具类 com.android.settings.remoteusb.DesUtil
即可解密
import java.security.Key;
import javax.crypto.Cipher;
import javax.crypto.SecretKeyFactory;
import javax.crypto.spec.DESedeKeySpec;
import javax.crypto.spec.IvParameterSpec;
public class DesUtil {
public static String decrypt(String keyStr, String hexStr) {
return decrypt(keyStr, hexStr, "UTF-8");
}
public static String decrypt(String keyStr, String hexStr, String encoding) {
try {
byte[] desBytes = decrypt(keyStr.getBytes(encoding), hexStr2ByteArr(hexStr));
return new String(desBytes, encoding);
} catch (Exception e) {
e.printStackTrace();
return "";
}
}
public static byte[] decrypt(byte[] keyBytes, byte[] hexBytes) throws Exception {
try {
Key deskey = getKey(keyBytes);
Cipher decryptCipher = Cipher.getInstance("desede/CBC/PKCS5Padding");
IvParameterSpec ips = new IvParameterSpec("xgspring".getBytes());
decryptCipher.init(2, deskey, ips);
return decryptCipher.doFinal(hexBytes);
} catch (Exception e) {
throw new Exception(e.getMessage());
}
}
private static Key getKey(byte[] keyBytes) throws Exception {
DESedeKeySpec spec = new DESedeKeySpec(keyBytes);
SecretKeyFactory keyfactory = SecretKeyFactory.getInstance("desede");
Key deskey = keyfactory.generateSecret(spec);
return deskey;
}
public static byte[] hexStr2ByteArr(String strIn) {
byte[] arrB = strIn.getBytes();
int iLen = arrB.length;
byte[] arrOut = new byte[iLen / 2];
for (int i = 0; i < iLen; i += 2) {
String strTmp = new String(arrB, i, 2);
arrOut[i / 2] = (byte) Integer.parseInt(strTmp, 16);
}
return arrOut;
}
}
解密的结果类似 {"status":0}
,这个 status 还要进行一个三目运算,然后会被返回
if (usbStateBean != null) {
String str = HttpManager.TAG;
Log.i(str, "Serial = " + sn + " state = " + usbStateBean.getStatus());
return usbStateBean.getStatus() == 1 ? 1 : 0;
}
继续追踪 AboutSettingsEx
流程,可以分析出,1 是允许开启 USB 调试,而 0 是不允许
private void handleSuccess() {
AboutSettingsEx fragment = (AboutSettingsEx) this.fragmentWeakReference.get();
if (fragment != null && !fragment.getActivity().isFinishing()) {
fragment.setDevelopmentItemEnable(true);
Toast.makeText(fragment.getActivity(), "USB调试授权成功", 0).show();
}
}
private void handleFailed(int failedCode) {
AboutSettingsEx fragment = (AboutSettingsEx) this.fragmentWeakReference.get();
if (fragment != null && !fragment.getActivity().isFinishing()) {
fragment.setDevelopmentItemEnable(false);
Activity activity = fragment.getActivity();
Toast.makeText(activity, "USB调试授权失败,code " + failedCode, 0).show();
}
}
再追踪,发现最终执行 setDevelopmentItemEnable
的语句
public void setDevelopmentItemEnable(boolean enable) {
if (enable) {
getPreferenceScreen().addPreference(this.mPreference_development_item);
} else {
etPreferenceScreen().removePreference(this.mPreference_development_item);
}
this.isRemoteUsbDebugMode = enable;
DevelopmentSettingsEnabler.setDevelopmentSettingsEnabled(getContext(), enable);
writeAdbSetting(enable);
PreferencesDataHelper.setUsbDebugMode(getContext(), enable);
}
那么这就是允许 USB 调试的部分了,只要复现这部分就可以实现 USB 调试的开启,继续跟踪,发现核心代码
// com.android.settings.AboutSettingsEx
public void setDevelopmentItemEnable(boolean enable) {
if (enable) {
getPreferenceScreen().addPreference(this.mPreference_development_item);
} else {
getPreferenceScreen().removePreference(this.mPreference_development_item);
}
this.isRemoteUsbDebugMode = enable;
DevelopmentSettingsEnabler.setDevelopmentSettingsEnabled(getContext(), enable);
writeAdbSetting(enable);
PreferencesDataHelper.setUsbDebugMode(getContext(), enable);
}
protected void writeAdbSetting(boolean enabled) {
Settings.Global.putInt(getContext().getContentResolver(), "adb_enabled", enabled ? 1 : 0);
}
// com.android.settingslib.development.DevelopmentSettingsEnabler
public class DevelopmentSettingsEnabler {
public static void setDevelopmentSettingsEnabled(Context context, boolean enable) {
Settings.Global.putInt(context.getContentResolver(), "development_settings_enabled", enable ? 1 : 0);
LocalBroadcastManager.getInstance(context).sendBroadcast(new Intent("com.android.settingslib.development.DevelopmentSettingsEnabler.SETTINGS_CHANGED"));
}
public static boolean isDevelopmentSettingsEnabled(Context context) {
UserManager um = (UserManager) context.getSystemService("user");
boolean settingEnabled = Settings.Global.getInt(context.getContentResolver(), "development_settings_enabled", Build.TYPE.equals("eng") ? 1 : 0) != 0;
boolean hasRestriction = um.hasUserRestriction("no_debugging_features");
boolean isAdminOrDemo = um.isAdminUser() || um.isDemoUser();
return isAdminOrDemo && !hasRestriction && settingEnabled;
}
}
代码关键在于两个 Settings.Global.putInt
,在终端中手动执行就行了
settings put global development_settings_enabled 1
settings put global adb_enabled 1
最终成功取得 adb 调试权限
root@azwhikaru:/~# adb devices
List of devices attached
7H7********K device
root@azwhikaru:/~# adb shell
H7000:/ $ su
H7000:/ #
1. 尝试 Dump classes.dex
虽然目前并不知道步步高的加密解密具体如何实现,但可以肯定的是既然要在 Android 系统中运行,肯定需要有一个解密 / 转换的过程,将 classes.dex 释放到内存或者本地的某个 cache 目录。
而首先需要逆向的是什么?自然是 PackageInsteller.apk (软件包安装程序),我们安装软件的时候,第一个接触到的就是它;
要从内存中 Dump 出来 classes.dex,这个方法是不是有脱壳内味了?那么就先尝试用脱壳工具吧:
0. 反射大师
反射大师是老牌脱壳软件了,需要依赖 Xposed 框架运行。但遗憾的是年久失修,并不支持 Android 9 中的脱壳,故放弃
1. BlackDex
BlackDex 是一个较新的开源脱壳工具,虽然支持 Android 5.0 ~ 12.X,但并不能脱出 PackageInsteller
2. Frida
Frida 是一款基于 Python 的 HOOK 框架,支持多平台,分为运行在调试机的客户端和运行在被调试端的服务器,通过注入进程的方式来实现劫持应用。这里用一个专门脱壳的开源脚本 frida-dexdump 就行了 (Frida 环境安装教程、Frida 安卓调试教程)
脚本非常简单,在被调试机上启动 server,把要脱壳的程序置于前台,然后在调试机中运行脚本,等几十秒就成功脱壳了
脱完之后可能有好几个 dex,凭直觉都知道应该选最大的。把 dex 拖入 Jadx 中反编译,发现成功脱出了 PackageInstaller 的 classes.dex
2. 分析资源文件
虽然成功 Dump 出来了 classes.dex,但是这样并不能一并 Dump 出 APK 中的资源文件,就无法结合实际 Activity 来分析。这边还需要借助其他工具来分析 Activity 中的组件: 开发者助手
得到了相应 TextView 的 ID 之后,就可以在 Java 代码中寻找 findViewById 之类的方法,来找到 Activity 了。
3. 分析格式识别相关
得到安装界面的 Activity 之后,就可以在 Java 代码中找到相应的类了 com.android.packageinstaller.PackageInstallerActivity
提取关键部分后,得到识别安装包格式的主要代码
// 传入打开的文件路径 packageUri
public void processPackageUri(Uri packageUri) {
char c;
PackageUtil.AppSnippet as;
this.mPackageURI = packageUri;
String scheme = packageUri.getScheme(); // 获取文件类型
int hashCode = scheme.hashCode();
if (hashCode == -807062458) {
if (scheme.equals("package")) { // 如果文件类型是 package
c = 0;
}
c = 65535;
} else if (hashCode != 3143036) {
if (hashCode == 951530617 && scheme.equals("content")) { // 如果文件类型是 content
c = 2;
}
c = 65535;
} else {
if (scheme.equals("file")) { // 如果文件类型是 file
c = 1;
}
c = 65535;
}
switch (c) {
case 0:
try { // 当文件类型是 package 时
this.mPkgInfo = this.mPm.getPackageInfo(packageUri.getSchemeSpecificPart(), 12288); // 当文件类型是 package 时
} catch (PackageManager.NameNotFoundException e) {
}
if (this.mPkgInfo == null) { // 如果 pm 无法获取文件信息 (不是有效安装包)
Log.w("BPackageInstaller", "Requested package " + packageUri.getScheme() + " not available. Discontinuing installation"); // 输出 Log
showDialogInner(2); // 展示安装失败对话框
setPmResult(-2); // 广播安装失败结果
return; // 返回
}
as = new PackageUtil.AppSnippet(this.mPm.getApplicationLabel(this.mPkgInfo.applicationInfo), this.mPm.getApplicationIcon(this.mPkgInfo.applicationInfo));
break;
case 1:
File sourceFile = new File(packageUri.getPath()); // 当文件类型是 file 时
PackageParser.Package parsed = PackageUtil.getPackageInfo(this, sourceFile); // 尝试解析为 package
if (parsed == null) { // 如果解析结果为空 (不是有效安装包)
Log.w("BPackageInstaller", "Parse error when parsing manifest. Discontinuing installation"); // 同上
showDialogInner(2);
setPmResult(-2);
return;
}
this.mPkgInfo = PackageParser.generatePackageInfo(parsed, (int[]) null, 4096, 0L, 0L, (Set) null, new PackageUserState());
as = PackageUtil.getAppSnippet(this, this.mPkgInfo.applicationInfo, sourceFile, this.mPkgInfo.versionName);
break;
case 2:
this.mStagingAsynTask = new StagingAsyncTask();
this.mStagingAsynTask.execute(packageUri);
return;
default:
Log.w("BPackageInstaller", "Unsupported scheme " + scheme); // 如果不是 package、content、file
setPmResult(-3);
clearCachedApkIfNeededAndFinish();
return;
}
PackageUtil.initSnippetForNewApp(this, as, 2131361851, this.mCallingLabel); // 获取 package 相关信息 (名称、图标...)
if (isAllowInstall(packageUri.getPath())) { // 是否允许安装
this.mInstallPrepareView.setText(getResources().getString(2131755439, as.size));
this.mAllowButton.setVisibility(0);
} else {
this.mInstallPrepareView.setText(getResources().getString(2131755311));
this.mAllowButton.setVisibility(8);
Map<String, String> dateMap = notAllowInstallExtraMap(isBBKEncrypted(packageUri.getPath()), this.mPkgInfo);
Gson gson = new Gson();
DaInsert.onClickEvent("PackageInstallerActivity", getString(2131755441), getString(2131755442), gson.toJson(dateMap));
}
this.mInstallPrepareView.setVisibility(0);
this.mDenyButton.setVisibility(0);
this.mOriginatingUid = getOriginatingUid(getIntent());
initiateInstall();
}
private boolean isAllowInstall(String filePath) {
if (isBBKEncrypted(filePath)) { // 如果是 BBK 加密安装包 (BPK)
String apkSignatures = getAPKSignatures(this.mPackageURI.getPath()); // 获取安装包签名信息
String apkSignatures2 = apkSignatures == null ? "" : apkSignatures; // 将 apkSign 赋值给 apkSign2
// 返回对比结果,对比是否符合三个签名
return apkSignatures2.equals("89:3E:2B:8C:1B:B6:9A:DB:DF:36:F4:0F:59:0C:62:82") || apkSignatures2.equals("14:08:7C:E9:ED:28:A7:4F:A1:77:AA:89:14:47:57:4E") || apkSignatures2.equals("40:59:05:EB:57:5C:99:63:4C:87:4A:EE:26:FE:E4:9D");
}
return true; // 如果不是 BBK 解密安装包 (APK) 则直接返回 true
}
private Map<String, String> notAllowInstallExtraMap(boolean isBBKEncrypted, PackageInfo packageInfo) {
Map<String, String> dateMap = new HashMap<>();
dateMap.put("versionName", packageInfo.versionName);
dateMap.put("packageName", packageInfo.packageName);
dateMap.put("isAlreadyInstall", this.isAlreadyInstall + "");
dateMap.put("isEncryptedAPK", isBBKEncrypted + "");
return dateMap;
}
// 获取安装包签名
private String getAPKSignatures(String apkPath) {
PackageManager packageManager = getPackageManager();
PackageInfo packageInfo = packageManager.getPackageArchiveInfo(apkPath, 64);
String hexString = null;
try {
Signature[] signatures = packageInfo.signatures;
byte[] cert = signatures[0].toByteArray();
InputStream input = new ByteArrayInputStream(cert);
CertificateFactory cf = CertificateFactory.getInstance("X509");
X509Certificate c = (X509Certificate) cf.generateCertificate(input);
MessageDigest md = MessageDigest.getInstance("MD5");
byte[] publicKey = md.digest(c.getEncoded());
hexString = byte2HexFormatted(publicKey);
Log.d("BPackageInstaller", "getAPKSignatures: " + hexString);
return hexString;
} catch (Exception e) {
e.printStackTrace();
return hexString;
}
}
此处进行逆向的是 S5 最新固件 V1.6.0_220823 中的 PackageInstaller,这个版本中加入了签名的验证,导致第三方制作的 "直装包" 失效。
4. PM/AM 相关
分析 PackageInstaller 后,发现并没有单独实现软件安装过程,而是直接使用了 PackageManager、PackageInfo。尝试在 Shell 中用 pm 直接安装加密 BPK 也成功了,使用 am 也可以直接调用加密包的 Activity。这意味着整个安装过程中并不涉及解密步骤,解密部分是通过内存操作完成的。
幸好步步高并没有针对 framework 层的东西进行加密,我们可以很轻松地逆向分析 framework 部分。我们提取 /system/framework 中的 service.jar、framework.jar,使用 Jadx 反编译操作。至于 PM 整个安装流程的解析,网上一抓一大把 (Android包管理机制(五)APK是如何被解析的)
在没有开始安装应用前,PackageInstaller 就会通过 PackageManager、PackageInfo 对应用中 AndroidManifest.xml 清单文件精选解析,来获得 APK 的版本号、名称等信息
public void apkInfo(String absPath, Context context) {
PackageManager pm = context.getPackageManager();
PackageInfo pkgInfo = pm.getPackageArchiveInfo(absPath,PackageManager.GET_ACTIVITIES);
if (pkgInfo != null) {
ApplicationInfo appInfo = pkgInfo.applicationInfo;
appInfo.sourceDir = absPath;
appInfo.publicSourceDir = absPath;
String appName = pm.getApplicationLabel(appInfo).toString();// 得到应用名
String packageName = appInfo.packageName; // 得到包名
String version = pkgInfo.versionName; // 得到版本信息
String pkgInfoStr = String.format("PackageName:%s, Vesion: %s, AppName: %s", packageName, version, appName);
Log.d("Debug", pkgInfoStr);
}
}
那么既然正常的 PM 无法解析 BPK,线索肯定就在步步高定制的 PM 中了。我们先针对最容易下手的 getPackageArchiveInfo、getPackageInfo、getApplicationLabel 等方法分析;
通过在源码中搜索,发现了 getPackageArchiveInfo 方法的具体位置,位于 frameworks.jar 的 android.content.pm.PackageManager (frameworks/base/core/java/android/content/pm/PackageManager.java)
反编译 frameworks.jar,定位到相应的类。发现这块代码先在 1 处创建了一个 PackageParser,然后在 2 处调用 parseMonolithicPackage 方法对 APK 进行了解析,最终在 3 处调用 generatePackageInfo 生成并返回一个 PackageInfo
既然对 APK 的解析是通过 PackageParser 来完成的,那么我们继续寻找 PackageParser 的源码。源码位置在 frameworks.jar 的 android.content.pm.PackageParser (frameworks/base/core/java/android/content/pm/PackageParser.java)
这段代码在源码中是有一段注释的: Parse the given APK file, treating it as as a single monolithic package。意思是将给定的 APK 包作为一个整体来解析,除此之外,还有 parseClusterPackage 方法是用来解析 Multiple Apk 的 (Multiple APK support)
分析 parseMonolithicPackage,发现前面有一个判断 coreApp 的部分。coreApp 指的是系统的核心应用,在系统以最小启动 (例如安全模式,或厂商定义的特殊模式) 时就只会启动这些 coreApp,这个属性可以在 AndroidManifest.xml 中定义。为了满足 "最小启动",这些 Apk 会以 parseMonolithicPackageLite 方法被解析,顾名思义,这是一个比较轻量快速的解析方法,它不需要解析 Apk 的所有部分。
显然日常安装的程序并不属于 coreApp 的范畴,那么我们只需要继续分析 parseBaseApk 部分就行了
2023/01/10 弃坑