步步高加密 APK 格式 BPK 研究 : 续
闲得蛋疼又开始研究这个了,主要是目前网上没搜到有人公开解密方法,心里还是痒痒的,虽然我菜,但是每次都能进步一点点嘛
这次继续从 Android ROM 上开刀,但是这次没有实体机了,只能慢慢摸索,没法调试咯
这次选了家教机 A6 开刀,因为官网上能下载到 ROM 的,A6 是最新的机型,基于 Android 11,紫光展锐平台
前已经大概确定了 BPK 文件是通过魔改 Android 四大件实现对特殊加密格式的支持,所以这次直接就从 framework 下手了
值得一提的是,从步步高团队在 Github 上泄露的存储库上来看,BPK 的加密似乎是借由其他的脚本或者工具完成的,在 Android Studio 打包输出的时候,文件名中含有 "-unencrypted" 字样,可惜并没有找到加密用的脚本
反编译 framework.jar,可以在 android.util.apk.ZipUtils
找到一些端倪
abstract class ZipUtils {
private static final int UINT16_MAX_VALUE = 65535;
private static final int ZIP64_EOCD_LOCATOR_SIG_REVERSE_BYTE_ORDER = 1347094023;
private static final int ZIP64_EOCD_LOCATOR_SIZE = 20;
private static final int ZIP_EOCD_CENTRAL_DIR_OFFSET_FIELD_OFFSET = 16;
private static final int ZIP_EOCD_CENTRAL_DIR_SIZE_FIELD_OFFSET = 12;
private static final int ZIP_EOCD_COMMENT_LENGTH_FIELD_OFFSET = 20;
private static final int ZIP_EOCD_REC_BBKSIG = 88821826;
private static final int ZIP_EOCD_REC_MIN_SIZE = 22;
private static final int ZIP_EOCD_REC_SIG = 101010256;
private static final byte[] xorCodeEOCD = "END_OF_CENTRAL_DIRECTORY_XOR_CODE_OF_BBK_APK_ENCRYPTION".getBytes();
private ZipUtils() {
}
/* JADX INFO: Access modifiers changed from: package-private */
public static Pair<ByteBuffer, Long> findZipEndOfCentralDirectoryRecord(RandomAccessFile zip) throws IOException {
long fileSize = zip.length();
if (fileSize < 22) {
return null;
}
Pair<ByteBuffer, Long> result = findZipEndOfCentralDirectoryRecord(zip, 0);
if (result != null) {
return result;
}
return findZipEndOfCentralDirectoryRecord(zip, 65535);
}
private static Pair<ByteBuffer, Long> findZipEndOfCentralDirectoryRecord(RandomAccessFile zip, int maxCommentSize) throws IOException {
if (maxCommentSize < 0 || maxCommentSize > 65535) {
throw new IllegalArgumentException("maxCommentSize: " + maxCommentSize);
}
long fileSize = zip.length();
if (fileSize < 22) {
return null;
}
ByteBuffer buf = ByteBuffer.allocate(((int) Math.min(maxCommentSize, fileSize - 22)) + 22);
buf.order(ByteOrder.LITTLE_ENDIAN);
long bufOffsetInFile = fileSize - buf.capacity();
zip.seek(bufOffsetInFile);
zip.readFully(buf.array(), buf.arrayOffset(), buf.capacity());
int eocdOffsetInBuf = findZipEndOfCentralDirectoryRecord(buf);
if (eocdOffsetInBuf == -1) {
return null;
}
buf.position(eocdOffsetInBuf);
ByteBuffer eocd = buf.slice();
eocd.order(ByteOrder.LITTLE_ENDIAN);
return Pair.create(eocd, Long.valueOf(eocdOffsetInBuf + bufOffsetInFile));
}
private static int findZipEndOfCentralDirectoryRecord(ByteBuffer zipContents) {
assertByteOrderLittleEndian(zipContents);
int archiveSize = zipContents.capacity();
if (archiveSize < 22) {
return -1;
}
int maxCommentLength = Math.min(archiveSize - 22, 65535);
int eocdWithEmptyCommentStartPosition = archiveSize - 22;
for (int expectedCommentLength = 0; expectedCommentLength <= maxCommentLength; expectedCommentLength++) {
int eocdStartPos = eocdWithEmptyCommentStartPosition - expectedCommentLength;
if (zipContents.getInt(eocdStartPos) == 101010256) {
int actualCommentLength = getUnsignedInt16(zipContents, eocdStartPos + 20);
if (actualCommentLength == expectedCommentLength) {
return eocdStartPos;
}
} else if (zipContents.getInt(eocdStartPos) == 88821826) {
for (int i = 4; i < 22; i++) {
byte tmp = zipContents.get(eocdStartPos + i);
byte[] bArr = xorCodeEOCD;
zipContents = zipContents.put(eocdStartPos + i, (byte) (bArr[(i - 4) % bArr.length] ^ tmp));
}
int i2 = eocdStartPos + 20;
int actualCommentLength2 = getUnsignedInt16(zipContents, i2);
if (actualCommentLength2 == expectedCommentLength) {
return eocdStartPos;
}
} else {
continue;
}
}
return -1;
}
public static final boolean isZip64EndOfCentralDirectoryLocatorPresent(RandomAccessFile zip, long zipEndOfCentralDirectoryPosition) throws IOException {
long locatorPosition = zipEndOfCentralDirectoryPosition - 20;
if (locatorPosition < 0) {
return false;
}
zip.seek(locatorPosition);
return zip.readInt() == 1347094023;
}
public static long getZipEocdCentralDirectoryOffset(ByteBuffer zipEndOfCentralDirectory) {
assertByteOrderLittleEndian(zipEndOfCentralDirectory);
return getUnsignedInt32(zipEndOfCentralDirectory, zipEndOfCentralDirectory.position() + 16);
}
public static void setZipEocdCentralDirectoryOffset(ByteBuffer zipEndOfCentralDirectory, long offset) {
assertByteOrderLittleEndian(zipEndOfCentralDirectory);
setUnsignedInt32(zipEndOfCentralDirectory, zipEndOfCentralDirectory.position() + 16, offset);
}
public static long getZipEocdCentralDirectorySizeBytes(ByteBuffer zipEndOfCentralDirectory) {
assertByteOrderLittleEndian(zipEndOfCentralDirectory);
return getUnsignedInt32(zipEndOfCentralDirectory, zipEndOfCentralDirectory.position() + 12);
}
private static void assertByteOrderLittleEndian(ByteBuffer buffer) {
if (buffer.order() != ByteOrder.LITTLE_ENDIAN) {
throw new IllegalArgumentException("ByteBuffer byte order must be little endian");
}
}
private static int getUnsignedInt16(ByteBuffer buffer, int offset) {
return buffer.getShort(offset) & 65535;
}
private static long getUnsignedInt32(ByteBuffer buffer, int offset) {
return buffer.getInt(offset) & 4294967295L;
}
private static void setUnsignedInt32(ByteBuffer buffer, int offset, long value) {
if (value < 0 || value > 4294967295L) {
throw new IllegalArgumentException("uint32 value of out range: " + value);
}
buffer.putInt(buffer.position() + offset, (int) value);
}
}
在这个类中,发现了两个比较可疑的东西
-
常量
private static final byte[] xorCodeEOCD = "END_OF_CENTRAL_DIRECTORY_XOR_CODE_OF_BBK_APK_ENCRYPTION".getBytes();
-
方法
findZipEndOfCentralDirectoryRecord()
这个常量的内容让人非常眼熟,因为 BPK 中含有大量由它组成的内容,只是在有些地方它的顺序,或者说字节是被打乱的
在 BPK 文件的末尾也可以找到它
常量 xorCodeEOCD
被方法 findZipEndOfCentralDirectoryRecord
所使用,那么就继续看这个方法吧
先从它的名字看起,Find ZIP End of Central Directory Record,寻找 ZIP 中的中心目录标识,那是什么?
从这里开始,就涉及到 ZIP 格式的特性和文件结构了,先看一张图
一个 ZIP 文件可以分为以上三个区域,而刚才方法的用处就是寻找最下面的中心目录标识 —— EOCD
EOCD 的结构如上表所示,在一个没有注释的 ZIP 文件中,EOCD 的长度为 22 字节,也就是一个 ZIP 文件末尾的 22 个字节。EOCD 将告诉我们 Central Directory 的偏移、长度等信息
EOCD 的头 4 个字节是 50 4B 05 06
(因为 ZIP 使用小端序存储,所以这里是反着写的),有没有很熟悉?回顾一下上次找到的 BPK 与 ZIP 之间文件头的规律
APK (ZIP) 文件头 | BPK 文件头 |
---|---|
50 4B 01 02 | 42 50 4B 01 |
50 4B 03 04 | 42 50 4B 03 |
50 4B 05 06 | 42 50 4B 05 |
50 4B 07 08 | 42 50 4B 07 |
我们随便找一个 BPK,看看文件末尾的 22 个字节
这 22 个字节以 42 50 4B 05
开头,正好对应上了 ZIP 的 50 4B 05 06
但是很显然,这个 EOCD 并不是常规的 ZIP 的 EOCD,它也经过了一定程度的加密,我们继续看代码
方法 findZipEndOfCentralDirectoryRecord
被 android.util.apk.ApkSigningBlockUtils.getEocd
所引用
static Pair<ByteBuffer, Long> getEocd(RandomAccessFile apk) throws IOException, SignatureNotFoundException {
Pair<ByteBuffer, Long> eocdAndOffsetInFile = ZipUtils.findZipEndOfCentralDirectoryRecord(apk);
if (eocdAndOffsetInFile == null) {
throw new SignatureNotFoundException("Not an APK file: ZIP End of Central Directory record not found");
}
return eocdAndOffsetInFile;
}
这个方法判断了传入的文件能不能被找到 EOCD,也就是说,它是不是一个 ZIP 文件。而方法 getEocd
继续被 android.util.apk.ApkSigningBlockUtils
所引用
public static SignatureInfo findSignature(RandomAccessFile apk, int blockId) throws IOException, SignatureNotFoundException {
Pair<ByteBuffer, Long> eocdAndOffsetInFile = getEocd(apk);
ByteBuffer eocd = eocdAndOffsetInFile.first;
long eocdOffset = eocdAndOffsetInFile.second.longValue();
if (ZipUtils.isZip64EndOfCentralDirectoryLocatorPresent(apk, eocdOffset)) {
throw new SignatureNotFoundException("ZIP64 APK not supported");
}
long centralDirOffset = getCentralDirOffset(eocd, eocdOffset);
Pair<ByteBuffer, Long> apkSigningBlockAndOffsetInFile = findApkSigningBlock(apk, centralDirOffset);
ByteBuffer apkSigningBlock = apkSigningBlockAndOffsetInFile.first;
long apkSigningBlockOffset = apkSigningBlockAndOffsetInFile.second.longValue();
ByteBuffer apkSignatureSchemeBlock = findApkSignatureSchemeBlock(apkSigningBlock, blockId);
return new SignatureInfo(apkSignatureSchemeBlock, apkSigningBlockOffset, centralDirOffset, eocdOffset, eocd);
}
这个方法的用处是寻找签名,而这个类中的另一个方法,verifyIntegrity
又给我们提供了一点信息
public static void verifyIntegrity(Map<Integer, byte[]> expectedDigests, RandomAccessFile apk, SignatureInfo signatureInfo) throws SecurityException, EncryptedByEEBBKException {
if (expectedDigests.isEmpty()) {
throw new SecurityException("No digests provided");
}
if (signatureInfo.eocd.getInt(0) == 88821826) {
throw new EncryptedByEEBBKException("is bbk encrypted apk, check with v1 signed");
}
...
}
如果 EOCD 为 88821826,也就是 42 50 4B 05
,则判断为 "is bbk encrypted apk, check with v1 signed"。看来步步高加密包使用了 V1 签名
在这边先打住一下,因为再按照引用关系上去就是验证签名的方法了。回去继续看 findZipEndOfCentralDirectoryRecord
方法
public static Pair < ByteBuffer, Long > findZipEndOfCentralDirectoryRecord(RandomAccessFile zip) throws IOException
{
long fileSize = zip.length();
if(fileSize < 22)
{
return null;
}
Pair < ByteBuffer, Long > result = findZipEndOfCentralDirectoryRecord(zip, 0);
if(result != null)
{
return result;
}
return findZipEndOfCentralDirectoryRecord(zip, 65535);
}
private static Pair < ByteBuffer, Long > findZipEndOfCentralDirectoryRecord(RandomAccessFile zip, int maxCommentSize) throws IOException
{
if(maxCommentSize < 0 || maxCommentSize > 65535)
{
throw new IllegalArgumentException("maxCommentSize: " + maxCommentSize);
}
long fileSize = zip.length();
if(fileSize < 22)
{
return null;
}
ByteBuffer buf = ByteBuffer.allocate(((int) Math.min(maxCommentSize, fileSize - 22)) + 22);
buf.order(ByteOrder.LITTLE_ENDIAN);
long bufOffsetInFile = fileSize - buf.capacity();
zip.seek(bufOffsetInFile);
zip.readFully(buf.array(), buf.arrayOffset(), buf.capacity());
int eocdOffsetInBuf = findZipEndOfCentralDirectoryRecord(buf);
if(eocdOffsetInBuf == -1)
{
return null;
}
buf.position(eocdOffsetInBuf);
ByteBuffer eocd = buf.slice();
eocd.order(ByteOrder.LITTLE_ENDIAN);
return Pair.create(eocd, Long.valueOf(eocdOffsetInBuf + bufOffsetInFile));
}
private static int findZipEndOfCentralDirectoryRecord(ByteBuffer zipContents)
{
assertByteOrderLittleEndian(zipContents);
int archiveSize = zipContents.capacity();
if(archiveSize < 22)
{
return -1;
}
int maxCommentLength = Math.min(archiveSize - 22, 65535);
int eocdWithEmptyCommentStartPosition = archiveSize - 22;
for(int expectedCommentLength = 0; expectedCommentLength <= maxCommentLength; expectedCommentLength++)
{
int eocdStartPos = eocdWithEmptyCommentStartPosition - expectedCommentLength;
if(zipContents.getInt(eocdStartPos) == 101010256)
{
int actualCommentLength = getUnsignedInt16(zipContents, eocdStartPos + 20);
if(actualCommentLength == expectedCommentLength)
{
return eocdStartPos;
}
}
else if(zipContents.getInt(eocdStartPos) == 88821826)
{
for(int i = 4; i < 22; i++)
{
byte tmp = zipContents.get(eocdStartPos + i);
byte[] bArr = xorCodeEOCD;
zipContents = zipContents.put(eocdStartPos + i, (byte)(bArr[(i - 4) % bArr.length] ^ tmp));
}
int i2 = eocdStartPos + 20;
int actualCommentLength2 = getUnsignedInt16(zipContents, i2);
if(actualCommentLength2 == expectedCommentLength)
{
return eocdStartPos;
}
}
else
{
continue;
}
}
return -1;
}
这个方法被重载了 3 次,它的作用是输入一个 ZIP 文件,返回 Pair 形式的变量,Pair.first 为 ByteBuffer 类型,内容为 EOCD 的 22 个字节,Pair.second 为 Long 类型,内容为 EOCD 的偏移
这并不是步步高单独定义的方法,而是魔改了 AOSP 的代码,为了审计方便,先删掉了注释
private static final int ZIP_EOCD_REC_MIN_SIZE = 22;
private static final int ZIP_EOCD_REC_SIG = 0x06054b50;
private static final int ZIP_EOCD_CENTRAL_DIR_SIZE_FIELD_OFFSET = 12;
private static final int ZIP_EOCD_CENTRAL_DIR_OFFSET_FIELD_OFFSET = 16;
private static final int ZIP_EOCD_COMMENT_LENGTH_FIELD_OFFSET = 20;
private static final int ZIP64_EOCD_LOCATOR_SIZE = 20;
private static final int ZIP64_EOCD_LOCATOR_SIG_REVERSE_BYTE_ORDER = 0x504b0607;
private static final int UINT16_MAX_VALUE = 0xffff;
static Pair < ByteBuffer, Long > findZipEndOfCentralDirectoryRecord(RandomAccessFile zip)
throws IOException
{
long fileSize = zip.getChannel().size();
if(fileSize < ZIP_EOCD_REC_MIN_SIZE)
{
return null;
}
Pair < ByteBuffer, Long > result = findZipEndOfCentralDirectoryRecord(zip, 0);
if(result != null)
{
return result;
}
return findZipEndOfCentralDirectoryRecord(zip, UINT16_MAX_VALUE);
}
private static Pair < ByteBuffer, Long > findZipEndOfCentralDirectoryRecord(RandomAccessFile zip, int maxCommentSize) throws IOException
{
if((maxCommentSize < 0) || (maxCommentSize > UINT16_MAX_VALUE))
{
throw new IllegalArgumentException("maxCommentSize: " + maxCommentSize);
}
long fileSize = zip.getChannel().size();
if(fileSize < ZIP_EOCD_REC_MIN_SIZE)
{
return null;
}
maxCommentSize = (int) Math.min(maxCommentSize, fileSize - ZIP_EOCD_REC_MIN_SIZE);
ByteBuffer buf = ByteBuffer.allocate(ZIP_EOCD_REC_MIN_SIZE + maxCommentSize);
buf.order(ByteOrder.LITTLE_ENDIAN);
long bufOffsetInFile = fileSize - buf.capacity();
zip.seek(bufOffsetInFile);
zip.readFully(buf.array(), buf.arrayOffset(), buf.capacity());
int eocdOffsetInBuf = findZipEndOfCentralDirectoryRecord(buf);
if(eocdOffsetInBuf == -1)
{
// No EoCD record found in the buffer
return null;
}
// EoCD found
buf.position(eocdOffsetInBuf);
ByteBuffer eocd = buf.slice();
eocd.order(ByteOrder.LITTLE_ENDIAN);
return Pair.create(eocd, bufOffsetInFile + eocdOffsetInBuf);
}
private static int findZipEndOfCentralDirectoryRecord(ByteBuffer zipContents)
{
assertByteOrderLittleEndian(zipContents);
int archiveSize = zipContents.capacity();
if(archiveSize < ZIP_EOCD_REC_MIN_SIZE)
{
return -1;
}
int maxCommentLength = Math.min(archiveSize - ZIP_EOCD_REC_MIN_SIZE, UINT16_MAX_VALUE);
int eocdWithEmptyCommentStartPosition = archiveSize - ZIP_EOCD_REC_MIN_SIZE;
for(int expectedCommentLength = 0; expectedCommentLength <= maxCommentLength; expectedCommentLength++)
{
int eocdStartPos = eocdWithEmptyCommentStartPosition - expectedCommentLength;
if(zipContents.getInt(eocdStartPos) == ZIP_EOCD_REC_SIG)
{
int actualCommentLength = getUnsignedInt16(zipContents, eocdStartPos + ZIP_EOCD_COMMENT_LENGTH_FIELD_OFFSET);
if(actualCommentLength == expectedCommentLength)
{
return eocdStartPos;
}
}
}
return -1;
}
不同之处在于最后一个 findZipEndOfCentralDirectoryRecord
,步步高的代码多出了一块
if (zipContents.getInt(eocdStartPos) == 101010256) {
...
}
else if (zipContents.getInt(eocdStartPos) == 88821826) {
for (int i = 4; i < 22; i++) {
byte tmp = zipContents.get(eocdStartPos + i);
byte[] bArr = xorCodeEOCD;
zipContents = zipContents.put(eocdStartPos + i, (byte) (bArr[(i - 4) % bArr.length] ^ tmp));
}
int i2 = eocdStartPos + 20;
int actualCommentLength2 = getUnsignedInt16(zipContents, i2);
if (actualCommentLength2 == expectedCommentLength) {
return eocdStartPos;
}
}
这段代码终于到了区别 ZIP 和 BPK 的地方,第一个 if 判断 EOCD 的首 4 个字节是否为 50 4B 05 06
(依旧是十进制 + 小端序),而第二个 else-if 则判断 EOCD 的首 4 个字节是否为 50 4B 05 06
,也就是 BPK 的 EOCD。如果是 BPK,则从 EOCD 的第 5 个字节开始进行异或解密
运行这段程序,并输出 Pair.second,我们得到了一个 22 字节大小的文件,使用十六进制编辑器打开
因为刚才的解密操作并没有操作 EOCD 的前 4 个字节,所以我们手动把它替换为 ZIP 文件的 50 4B 05 06
,使用 010 Editor 的模板功能,我们发现这就是 BPK 文件所对应的 EOCD 了,EOCD 的几个参数也如数呈现
最让人关心的,自然是 Central Directory 了
用 EOCD 提供的偏移跳转,我们来到位置 30343168
这里的 APK Sig Block 42
是从 V2 签名开始,在 Central Directory 区块之前的单独小区块,用于存储签名信息,看来这里正是 Central Directory,中心目录区
到了这一步之后,就不再有涉及 Central Directory 的部分了,因为 Android 的目的只是获得 APK Signing Block,所以只需要获得 Central Directory 的偏移即可,至于 Central Directory 部分的数据要如何处理,它并不关心
目前已经知道了通过异或解密 EOCD 部分的方法,那么 CD (为了少打几个字,Central Directory 下面统一简写为 CD) 部分呢?我猜步步高依旧是魔改了 Android 的某个库来实现对加密格式的兼容。用 Everything 在 system 分区中搜索 ZIP 相关库
发现了一个 libziparchive.so
库,这个库正是 Android 中用来处理 ZIP 文件的。使用 IDA32 打开它,因为之前已经摸清了 EOCD 的套路,那么 CD 应该也是用一个字符串 + 异或的方式实现加密的,我们直接 Shift + F12 查看字符串,搜索 BBK
果然,除了 END_OF_CENTRAL_DIRECTORY_XOR_CODE_OF_BBK_APK_ENCRYPTION
之外,还有一个 CENTRAL_DIRECTORY_XOR_CODE_OF_BBK_APK_ENCRYPTION
,显然这个字符串是用来异或 CD 区域的,按 X 查看交叉引用
这段文本被 OpenArchiveFd
函数调用,双击进入这个函数
看伪代码是一件非常痛苦的事情,不过好在也不需要完全看懂
v56 = v54;
dest[0] = '\0';
dest[1] = '\0';
*(_QWORD *)((char *)v105 + 6) = '\0';
*(_QWORD *)((char *)&v105[1] + 6) = '\0';
dest[2] = '\0';
v105[0] = '\0';
v57 = *(_DWORD *)v54;
v98 = v54;
if ( *((_BYTE *)v45 + 72) ) {
v58 = 0;
LODWORD(dest[0]) = *(_DWORD *)v54;
do {
*((_BYTE *)dest + v58 + 4) = *((_BYTE *)v54 + v58 + 4) ^ aCentralDirecto[v58 + -48 * (v58 / 0x30)];
++v58;
}
while ( v58 != 42 );
v57 = dest[0];
v59 = 21712962; // 42 50 4B 01
v60 = 1;
v56 = dest;
} else {
v59 = 33639248; // 50 4B 01 02
v60 = 0;
}
上面这段代码中又发现了异或语句,同时 v59 的数值也很重要。看来这里又是区分 ZIP 和 BPK 的地方
乍一看,异或的算法是一样的,只是异或用的字典有些不同,我们尝试直接把 CENTRAL_DIRECTORY_XOR_CODE_OF_BBK_APK_ENCRYPTION
替换到 Java 实现的版本中
public class Main {
public static final byte[] xorCodeEOCD = "CENTRAL_DIRECTORY_XOR_CODE_OF_BBK_APK_ENCRYPTION".getBytes();
public static void main(String[] args) throws IOException {
System.out.println("Hello World");
File file = new File("E:\\SSDBACK\\Awsl\\BPK\\BPackageInstaller.CD.BPK");
try {
FileInputStream fis = new FileInputStream(file);
byte[] data = new byte[(int) file.length()];
fis.read(data);
fis.close();
ByteBuffer zipContents = ByteBuffer.wrap(data);
for (int i = 4; i < zipContents.capacity(); i++) {
byte tmp = zipContents.get(i);
byte[] bArr = xorCodeEOCD;
zipContents = zipContents.put(i, (byte) (bArr[(i - 4) % bArr.length] ^ tmp));
}
File outputFile = new File("E:\\SSDBACK\\Awsl\\BPK\\BPackageInstaller.CD.APK");
FileOutputStream fos = new FileOutputStream(outputFile);
byte[] byteArray = new byte[zipContents.remaining()];
zipContents.get(byteArray);
fos.write(byteArray);
fos.close();
} catch (IOException e) {
e.printStackTrace();
}
}
}
现在我们得到了一个可以把 BPK 的 CD 部分通过异或还原为 APK 的方法
首先,我们先从一个 BPK 文件中提取一段 CD 区域,42 50 4B 01
在 BPK 中用来标记一个 CD 区域的起始,在 ZIP 中则是 50 4B 01 02
单独提取这段 CD 区域到新的文件,保存为 BPackageInstaller.CD.BPK
。现在运行 Java 程序,输出的内容在 BPackageInstaller.CD.APK
打开两个文件,惊喜地发现 CD 区域的内容已经被解密出来了,把头部 42 50 4B 01
替换为 ZIP 的 50 4B 01 02
,执行 010 Editor 的 ZIP 模板
现在 CD 区域的内容已经被如数还原
BPK | ZIP | 解释 |
---|---|---|
42 50 4B 01 | 50 4B 01 02 | Central directory file header 标记 |
42 50 4B 03 | 50 4B 03 04 | File Record 标记 |
42 50 4B 05 | 50 4B 05 06 | End of Central Directory Record (EOCD) 标记 |
一个完整的 ZIP 包还需要 File Record 区域的数据,这点官方的 BPK 和第三方制作的 "直装包" 有点区别,官方包是加密了这块的,但是第三方包没有加密,只是替换了标记
所以我猜这块不加密也可以,替换标记就行。不过目前手上没有机器,还有待验证 (2023/08/13)
2023/08/15 更新:
目前已经验证按照以上方法加密的 BPK 包可以正常被步步高机器识别并安装,不过步步高在 Android 9 及以上的系统中更新了软件包安装程序,加入了签名和联网包名黑名单验证,如果不满足签名和黑名单依旧会被拦截
虽然不加密 FR 区也能被正常解析了,但是官方系统内的 BPK 包是加密了 FR 区的,所以想要解密这块,还需要继续研究研究
打开一个官方的 BPK 包,发现文件开头的 FR 区域也是被加密的,不过依旧有部分明文内容可读
看看 ZIP 格式 Local File Header 区域的结构
为了便于理解,我们新建一个文件名为 flag.txt 的文件,内容为 helloworld,然后将其压缩,使用 010 Editor 解析它
一个 FR 记录的前 30 个字节为 Local File Header,后面依次是文件名、文件的内容
继续在 BPK 文件中下翻,发现了一个 PNG 文件
这更加佐证了 BPK 并不会对资源本身加密,而是加密了 Local File Header。把这一段 FR 提取出来
单独提取了下面的 PNG 文件,发现文件是可以正常打开的,也可以被 010 Editor 的模板识别
现在已知的信息是,前 30 个字节是 Local File Header,后 64 个字节是文件名,它们都是被加密的
因为 Local File Header (为了偷懒,以下简称LFH) 中有 FOCIL 的字样,但是没有看到 END,所以我猜这段也是用相同的异或算法加密的
似乎可以用 xorCode LOCAL_FILE_HEADER_XOR_CODE_OF_BBK_APK_ENCRYPTION
解密,表现为解密后的 LFH 中的文件修改日期与 CD 能对应 (2009/01/01 00:00),LFH 中的文件长度能与 CD 对应
不过显然文件名并不是通过异或加密的,懒得继续看伪代码了,因为 CD 和 FR 区域的东西都是一一对应的,所以直接把 CD 区的文件名搬过来就行了
暂时肝不动了,FR 区域的解密有点复杂,大概的思路如下
1、截取一个 FR
2、解密 FR 前 30 个字节,得到 LFH
3、从对应的 CD 中获取文件名的长度 (offset)
4、将对应的 CD 中的文件名在 FR 中替换 FR[31:offset]
5、得到解密的 FR
遍历 FR 比较麻烦,先缓缓再说 (2023/08/15)
目前已经可以完整解密官方 BPK,方法如上面所示的一样,但是直接异或出来的 LFH 并不正确,手动把 CD 里面的替换进去就可以了 (2023/08/16)
完整加密流程
归纳一下完整的加解密流程和原理
文件头对照
首先,对照 ZIP 和 BPK 的文件头
ZIP 文件头 | BPK 文件头 | 作用 |
---|---|---|
50 4B 01 02 | 42 50 4B 01 | Central Directory |
50 4B 03 04 | 42 50 4B 03 | Local File Header |
50 4B 05 06 | 42 50 4B 05 | End of Central Directory Record |
50 4B 07 08 | 42 50 4B 07 | Data Descriptor |
各文件头加密规则
所有文件头及其描述的内容都使用异或算法
End of Central Directory Record
EOCD 是 ZIP 文件的最后 22 个字节,全部异或即可加解密
文件注释不加密
Central Directory
CD 在 ZIP 文件中有多个,其偏移由 EOCD 记录,解密 EOCD 后找到 CD 偏移,再遍历使用异或解密即可
Local File Header
从 LFH 开始,记录文件的具体内容
LFH 共 30 个字节,前 14 个字节使用异或加解密,中间 12 个字节置 0,最后 4 个字节从 Central Directory 中取值即可
2023/08/19 更新:
LFH 的前 30 个字节中,取前 28 个字节进行异或,最后的 2 字节扩展字段长度不加密。加密完成后,中间的 12 个字节置 FF 即可得到加密的 LFH
目前暂时不知道文件名怎么加密
文件名加密:单独提取出文件名的字节,头部补 4 个空字节,和 xorCodeLFH 作异或
扩展字段不加密
Data descriptor
不是所有文件都有 Data descriptor,这点要看 LFH 的标志位,不过也可以用偏移计算
LFH 偏移 + LFH 长度 (30) + 文件名长度 + 文件内容长度,如过偏移开始的 4 个字节为 50 4B 07 08 则代表有 Data descriptor
如果有 Data descriptor,则 LFH 中置 FF 的 12 个字节改为置 00
加密脚本
支持加密成和官方一模一样的完整 BPK,End of Central Directory Record、Central Directory、Local File Header、Data descriptor 都会加密,不会破坏注释、套娃 ZIP 包,纯 16 进制操作
脚本就先不给了