0%

Android V2/V3 签名下写入 APK 信息

自 Android 7.0 开始引入的 APK 签名方案 V2 不再使用以往的 Jar 签名方式 (V1),V1 签名方案会对 APK 中大部分文件做单个的摘要计算,而 V2 签名方案是将数据分割成每个 1MB 做摘要计算,速度更快。
以往 V1 签名方案不会校验 META-INF 中的数据,所以在不修改签名的情况下往 APK 中写入数据是很容易的,直接放在该目录下就可以,而 V2/V3 签名就需要使用其他方式。

0x1 ZIP 文件结构

APK 文件本质上就是修改了扩展名的 ZIP 格式。根据 Android 官方文档所写,V2 签名方案会在 APK 文件的「中央目录」部分之前,插入一个 APK 签名分块,在APK 签名分块内,存储签名信息。

原始 APK V2 签名后
Local File Header n Local File Header n
File Data n File Data n
Data Description n Data Description n
APK Signing Block
Central Directory Central Directory
End Of Central Directory Record End Of Central Directory Record

0x2 APK 签名分块结构

APK 签名分块的两端存放的是分块中数据区的长度,这两个长度是一样的。尾端存放 V2 签名的 magic 魔数,用于 Android 系统识别此 APK 是否为 V2 签名。数据区则是一个个 数据分块长度-(ID-Value) 的键值对。

Type Byte Description
uint64 8 数据区长度
uint64 8 键值对长度(不包含自身)
uint32 4 ID
可变长度 Value
uint64 8 键值对长度(不包含自身)
uint32 4 ID
可变长度 Value
uint64 8 数据区长度
string 16 “APK Sig Block 42”

其中,V2 签名信息存储在 ID 为 0x7109871A 的键值对中。V3 签名信息则是存储在 ID 为 0xF05368C0 的键值对中。

官方文档所写,在解析该数据分块时,会忽略未知 ID 的键值对。那么我们就可以将自己所需要的信息以 ID-Value 键值对的形式,写入到 Android 的签名信息之后。注意不要使用 V2 和 V3 签名的 ID,以免破坏签名信息。

0x3 指向中央目录起始位置的指针

工欲善其事,必先利其器。为了方便解析,首先定义一些工具函数:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
/**
* unsigned int32 转换为小端 ByteArray
*/
func uint32ToByteArrayLE(value uint32) []byte {
result := make([]byte, 4)
binary.LittleEndian.PutUint32(result, value)
return result
}

/**
* unsigned int64 转换为小端 ByteArray
*/
func uint64ToByteArrayLE(value uint64) []byte {
result := make([]byte, 8)
binary.LittleEndian.PutUint64(result, value)
return result
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
type ByteArrayFile struct {
*os.File
}

/**
* 获取文件长度
*/
func (that *ByteArrayFile) Length() int64 {
stat, _ := that.Stat()
return stat.Size()
}

/**
* 从文件指定偏移位置读取定长二进制数据
*/
func (that *ByteArrayFile) ReadAt(offset int64, length int) []byte {
result := make([]byte, length)
that.File.ReadAt(result, offset)
return result
}

/**
* 从文件指定偏移位置插入二进制数据
*/
func (that *ByteArrayFile) Insert(offset int64, value []byte) {
tailBytes := that.ReadAt(offset, int(that.Length()-offset))
that.Seek(offset, io.SeekStart)
that.Write(value)
that.Write(tailBytes)
}

/**
* 从文件指定偏移位置覆盖同大小的二进制数据
*/
func (that *ByteArrayFile) Overwrite(offset int64, value []byte) {
tailOffset := offset + int64(len(value))
tailBytes := that.ReadAt(tailOffset, int(that.Length()-tailOffset))
that.Seek(offset, io.SeekStart)
that.Write(value)
that.Write(tailBytes)
}

然后进行初始化:

1
2
3
4
5
path := "x.apk"

zipFile, _ := zip.OpenReader(path)
rawFile, _ := os.OpenFile(path, os.O_RDWR, os.FileMode(0))
file := &ByteArrayFile{rawFile}

在 ZIP 文件的 End Of Central Directory Record 结构中,有一个数据保存了中央目录起始位置的偏移量,我们可以通过它来获取中央目录的位置。

Offset Byte Description
16 4 中央目录起始位置的偏移量
20 2 注释长度
22 可变长度 注释内容

保存中央目录起始位置指针的数据在文件末尾,和 ZIP 文件的注释信息相邻,可以通过文件长度减去注释信息获得其位置:

1
2
3
4
5
6
7
8
9
// 通过 Go 的 ZIP API 获取到注释长度
commentLength := int64(len(zipFile.Comment))

// 当 ZIP 文件没有注释时,End Of Central Directory Record 的原始长度为 22
// 所以 End Of Central Directory Record 的实际长度为注释长度 + 22
endOfCentralDirectoryLength := commentLength + 22
endOfCentralDirectoryOffset := file.Length() - endOfCentralDirectoryLength

centralDirectoryPointer := endOfCentralDirectoryOffset + 16

然后就可以顺利的获取到中央目录起始位置的偏移量了:

1
centralDirectoryOffset := int64(binary.LittleEndian.Uint32(file.ReadAt(centralDirectoryPointer, 4)))

0x4 验证 Magic

magic 魔数保存在中央目录前方 16 个字节的位置,通过验证 magic 的内容来判断 APK 是否为 V2 签名:

1
2
3
4
magic := string(file.ReadAt(centralDirectoryOffset-16, 16))
if magic != "APK Sig Block 42" {
log.Fatalln("Not v2 signature")
}

0x5 获取 APK 签名分块中两个数据区的长度

1
2
3
4
5
6
// 尾部数据区长度的起始位置偏移
idValueBlockOffset2 := centralDirectoryOffset - 24
// 读取尾部数据区长度
idValueBlockLength := int64(binary.LittleEndian.Uint64(file.ReadAt(idValueBlockOffset2, 8)))
// 头部数据区长度的起始位置偏移
idValueBlockOffset1 := idValueBlockOffset2 - idValueBlockLength + 16

0x6 插入自定义 ID-Value 键值对

1
2
3
4
5
6
7
8
/**
* 构建 Length-ID-Value 格式的 ByteArray
*/
func buildItem(intId uint32, value []byte) []byte {
id := uint32ToByteArrayLE(intId)
idValueBlockLength := uint64ToByteArrayLE(4 + uint64(len(value)))
return append(append(idValueBlockLength, id...), value...)
}
1
2
3
4
5
newItem := buildItem(0x10551, []byte("Test Data"))
newItemLength := int64(len(newItem))

// 追加到数据区的尾部,避免破坏 Android 签名信息
file.Insert(idValueBlockOffset2, newItem)

0x7 更新 APK 签名分块中数据区的长度

由于插入了自定义的数据,整个数据区的长度已经改变,所以需要更新 APK 签名分块中前后两个保存的数据区长度:

1
2
3
4
5
newIdValueBlockLength := uint64ToByteArrayLE(uint64(idValueBlockLength + newItemLength))
// 更新头部数据区长度
file.Overwrite(idValueBlockOffset1, newIdValueBlockLength)
// 更新尾部数据区长度
file.Overwrite(idValueBlockOffset2+newItemLength, newIdValueBlockLength)

0x8 更新 APK 签名分块中数据区的长度

与上文相同,中央目录的位置也会向后偏移。所以 End Of Central Directory Record 中保存了中央目录起始位置偏移量的数据也需要进行更新:

1
2
3
4
5
newCentralDirectoryOffset := uint32ToByteArrayLE(uint32(centralDirectoryOffset + newItemLength))
newCentralDirectoryPointer := centralDirectoryPointer + newItemLength
file.Overwrite(newCentralDirectoryPointer, newCentralDirectoryOffset)

file.Close()

这样就已经在不破坏 Android 签名的情况下将自定义数据成功的写入 APK 文件了。

0x9 读取 APK 签名分块中的 ID-Value

读取操作一般需要在 Android 端进行,这里使用 Kotlin 实现。
同样,首先定义一些工具方法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
/**
* 小端 ByteArray 转换为 Int
*/
fun ByteArray.toIntLE(): Int {
return ByteBuffer.wrap(this).order(ByteOrder.LITTLE_ENDIAN).int
}

/**
* 小端 ByteArray 转换为 Long
*/
fun ByteArray.toLongLE(): Long {
return ByteBuffer.wrap(this).order(ByteOrder.LITTLE_ENDIAN).long
}

/**
* 从文件指定偏移位置读取定长二进制数据
*/
fun File.readAt(offset: Long, length: Int): ByteArray {
val inputStream = FileInputStream(this)

inputStream.skip(offset)

val buffer = ByteArray(length)
inputStream.read(buffer, 0, length)

inputStream.close()

return buffer
}

获取到 APK 签名分块中数据区偏移位置的操作大致是相似的,这里不再过多描述:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
val file = File(PATH)

val commentLength = ZipFile(PATH).comment?.toByteArray()?.size ?: 0
val endOfCentralDirectoryLength = commentLength + 22
val endOfCentralDirectoryOffset = file.length() - endOfCentralDirectoryLength

val centralDirectoryPointer = endOfCentralDirectoryOffset + 16
val centralDirectoryOffset = file.readAt(centralDirectoryPointer, 4).toIntLE()
val magic = file.readAt((centralDirectoryOffset - 16).toLong(), 16).decodeToString()

if (magic != "APK Sig Block 42") {
println("Not v2 signature")
return
}

// 尾部数据区长度的起始位置偏移
val idValueBlockOffset2 = centralDirectoryOffset - 24
val idValueBlockLength = file.readAt(idValueBlockOffset2.toLong(), 8).toLongLE()
// 头部数据区长度的起始位置偏移
val idValueBlockOffset1 = idValueBlockOffset2 - idValueBlockLength + 16

这里已经获取到头部和尾部数据区长度的起始位置偏移,可以开始遍历读取了:

1
2
3
4
5
6
7
8
9
10
11
12
13
// 计算数据区起始位置的偏移量
var offset = idValueBlockOffset1 + 8
while (offset < idValueBlockOffset2) {
val length = file.readAt(offset, 8).toLongLE()
val id = file.readAt(offset + 8, 4).toIntLE()
val value = file.readAt(offset + 12, length.toInt() - 4)

println("Length = $length")
println("ID = ${id.toString(16)}")
println("Value = ${value.decodeToString()}")

offset += length + 8
}