Loading

步步高家教机加密安装包 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 有较大的区别

image.png

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,把要脱壳的程序置于前台,然后在调试机中运行脚本,等几十秒就成功脱壳了

image.png

脱完之后可能有好几个 dex,凭直觉都知道应该选最大的。把 dex 拖入 Jadx 中反编译,发现成功脱出了 PackageInstaller 的 classes.dex

image.png

image.png

2. 分析资源文件

虽然成功 Dump 出来了 classes.dex,但是这样并不能一并 Dump 出 APK 中的资源文件,就无法结合实际 Activity 来分析。这边还需要借助其他工具来分析 Activity 中的组件: 开发者助手

Screenshot_20230109-025916.png

Screenshot_20230109-025922.png

得到了相应 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)

image.png

反编译 frameworks.jar,定位到相应的类。发现这块代码先在 1 处创建了一个 PackageParser,然后在 2 处调用 parseMonolithicPackage 方法对 APK 进行了解析,最终在 3 处调用 generatePackageInfo 生成并返回一个 PackageInfo

image.png

既然对 APK 的解析是通过 PackageParser 来完成的,那么我们继续寻找 PackageParser 的源码。源码位置在 frameworks.jar 的 android.content.pm.PackageParser (frameworks/base/core/java/android/content/pm/PackageParser.java)

image.png

这段代码在源码中是有一段注释的: Parse the given APK file, treating it as as a single monolithic package。意思是将给定的 APK 包作为一个整体来解析,除此之外,还有 parseClusterPackage 方法是用来解析 Multiple Apk 的 (Multiple APK support)

image.png

分析 parseMonolithicPackage,发现前面有一个判断 coreApp 的部分。coreApp 指的是系统的核心应用,在系统以最小启动 (例如安全模式,或厂商定义的特殊模式) 时就只会启动这些 coreApp,这个属性可以在 AndroidManifest.xml 中定义。为了满足 "最小启动",这些 Apk 会以 parseMonolithicPackageLite 方法被解析,顾名思义,这是一个比较轻量快速的解析方法,它不需要解析 Apk 的所有部分。

image.png

显然日常安装的程序并不属于 coreApp 的范畴,那么我们只需要继续分析 parseBaseApk 部分就行了

2023/01/10 弃坑

posted @ 2023-02-15 13:21  20206675  阅读(1323)  评论(2编辑  收藏  举报