前言 在 APK 逆向工程中,开发者习惯使用 apktool 还原 AndroidManifest.xml 为可读文本。但如果你曾直接用文本编辑器打开 APK 内的 AndroidManifest.xml,看到的会是一堆乱码——这是因为 APK 包内的 Manifest 并非标准文本 XML,而是由 AAPT(Android Asset Packaging Tool)在构建时生成的二进制 XML(AXML, Android Binary XML) 格式。
AXML 是 Android 资源编译系统的核心产物之一。它将 XML 的树形结构编码为由 Chunk(数据块) 组成的线性序列,全部字符串去重后存入全局字符串池,标签名、属性名和属性值均通过 32 位整型索引引用,资源引用(如 @string/app_name)则在编译期被替换为可直接匹配的 32 位资源 ID。本文将从字节级别逐层解析 AXML 的二进制编码,实现一个完整的 Python 解析器,并演示无 apktool 依赖的直接二进制修改技术。
AOSP 源码参考路径:
frameworks/base/libs/androidfw/include/androidfw/ResourceTypes.h —— 全部 Chunk 结构体及枚举定义
frameworks/base/libs/androidfw/ResourceTypes.cpp —— Chunk 解析实现(ResXMLParser / ResStringPool)
frameworks/base/tools/aapt2/ —— AAPT2 编译管线(XML 编译、资源链接、表生成)
一、为什么 Manifest 必须编译为二进制格式 Android 构建系统通过 AAPT2 将文本 XML 编译为 AXML,这一决策背后有三项核心技术考量。
1.1 解析效率:从 O(n) 字符扫描到 O(1) 结构跳转 文本 XML 的解析流程为:字符流 → 词法分析器(Lexer)→ Token 流 → 语法分析器(Parser)→ DOM 树。以 XmlPullParser 为例,每次读取标签名都需要做字符串比较(name.equals("activity")),而字符串比较本身是 O(k) 操作(k 为字符串长度)。对于一个包含 200+ Activity 声明的大型 Manifest,仅标签名比较就可能执行数千次。
AXML 的解析器 ResXMLParser(位于 ResourceTypes.cpp)采用完全不同的路径:
读取 Chunk Header 的 type 字段(2 字节)即可判定当前块的类型——无需任何词法分析。
标签名存储为 4 字节的字符串池索引,解析器直接从 ResStringPool 中以 O(1) 获取字符串。
通过 attributeCount 和 attributeSize 可以直接计算属性数组的结束位置,跳过不需要解析的属性。
这意味着从 Zygote 进程 fork 出应用进程后,PackageManagerService 加载并解析 Manifest 的时间可减少 60% 以上。考虑到 Android 系统在每个应用安装和每次启动时都要解析 Manifest,这一优化在系统层面有显著的累积收益。
1.2 体积优化:字符串去重的威力 考虑以下 Manifest 片段:
<activity android:name =".ActivityA" android:exported ="true" android:label ="@string/label_a" /> <activity android:name =".ActivityB" android:exported ="false" android:label ="@string/label_b" /> <activity android:name =".ActivityC" android:exported ="true" android:label ="@string/label_c" />
在文本 XML 中,android:name 出现了 3 次(36 字节),android:exported 出现了 3 次(51 字节),android:label 出现了 3 次(42 字节),合计 129 字节。在 AXML 中,每个出现位置仅需一个 20 字节的属性结构体(其中 name 字段为 4 字节的字符串索引)。同时,StringPool 中这些属性名字符串仅存储一次。根据 AOSP aapt2 的实际编译结果统计,对于包含 100+ 组件的大型应用,AXML 体积通常仅为文本 XML 的 35% ~ 50%。
1.3 资源引用固化为运行时 ID 文本 XML 中的 android:label="@string/app_name" 在运行时如果保留文本形式,Framework 将不得不反复解析 @string/ 前缀、提取资源名、遍历资源表查找匹配——这会严重拖慢应用启动速度。AAPT 在编译时已完成这一查找:读取 resources.arsc 中的资源表,将 "app_name" 解析为资源 ID(例如 0x7F04001A),并在 AXML 中将属性值编码为 {dataType: TYPE_REFERENCE, data: 0x7F04001A}。运行时,Framework 仅需根据资源 ID 直接在已加载的 ResTable 中做哈希查找即可获取实际字符串值。
1.4 编译管线 res/values/strings.xml AndroidManifest.xml (文本) │ │ ▼ ▼ aapt2 compile aapt2 compile │ │ ▼ ▼ strings.xml.flat manifest.xml.flat (中间 AXML) │ │ └──────────┬─────────────────────┘ ▼ aapt2 link │ ▼ resources.arsc + AndroidManifest.xml (最终二进制)
AXML 文件的每一个逻辑单元都是一个 Chunk,所有 Chunk 共享统一的 8 字节头部 ResChunk_header,定义于 ResourceTypes.h:
struct ResChunk_header { uint16_t type; uint16_t headerSize; uint32_t size; };
全部字段使用小端字节序(Little Endian)。headerSize 告诉解析器从 Chunk 起始位置跳过多少字节才能到达数据区——不同类型 Chunk 的 headerSize 不同:
Chunk 类型
type 值
headerSize
说明
RES_NULL_TYPE
0x0000
—
空类型,用于标记结束或填充
RES_STRING_POOL_TYPE
0x0001
28
字符串常量池
RES_TABLE_TYPE
0x0002
—
资源表根节点(用于 resources.arsc)
RES_XML_TYPE
0x0003
8
XML 树根容器(AXML 文件最外层)
RES_XML_START_NAMESPACE_TYPE
0x0100
16
命名空间声明开始
RES_XML_END_NAMESPACE_TYPE
0x0101
16
命名空间声明结束
RES_XML_START_ELEMENT_TYPE
0x0102
16
XML 元素开始标签
RES_XML_END_ELEMENT_TYPE
0x0103
16
XML 元素结束标签
RES_XML_CDATA_TYPE
0x0104
16
CDATA 文本节点
RES_XML_RESOURCE_MAP_TYPE
0x0180
8
资源 ID 映射表
AOSP 中的枚举定义如下:
enum { RES_NULL_TYPE = 0x0000 , RES_STRING_POOL_TYPE = 0x0001 , RES_TABLE_TYPE = 0x0002 , RES_XML_TYPE = 0x0003 , RES_XML_FIRST_CHUNK_TYPE = 0x0100 , RES_XML_START_NAMESPACE_TYPE = 0x0100 , RES_XML_END_NAMESPACE_TYPE = 0x0101 , RES_XML_START_ELEMENT_TYPE = 0x0102 , RES_XML_END_ELEMENT_TYPE = 0x0103 , RES_XML_CDATA_TYPE = 0x0104 , RES_XML_LAST_CHUNK_TYPE = 0x017f , RES_XML_RESOURCE_MAP_TYPE = 0x0180 , };
关键设计 :size 字段包含从 Chunk 起始到结束的全部字节数,因此解析器的核心遍历逻辑异常简单——读取完当前 Chunk 后直接执行 pos += size 即可跳到下一个 Chunk 的起始位置。这种链式遍历不需要任何索引或跳转表,是 AXML 解析器高吞吐量的基础。
以下是从一个真实 AXML 文件中截取的 RES_XML_TYPE 外层容器 header:
00000000: 0300 0800 6804 0000 ....h... ← 文件开头 8 字节 ││││ ││││ ││││││││ ││└┘ ││└┘ └┘│││││└── size = 0x00000468 = 1128 字节(整个文件大小) ││ ││ │││││ ││ ││ ││││└─── size byte 3 ││ ││ │││└──── size byte 2 ││ ││ ││└───── size byte 1 ││ ││ │└────── size byte 0 ││ ││ └─────── headerSize = 0x0008 = 8 字节 ││ │└───────────── headerSize byte 1 ││ └────────────── headerSize byte 0 │└────────────────── type = 0x0003 = RES_XML_TYPE └─────────────────── type byte 0 (little-endian: 低字节在前)
三、AXML 文件整体结构 一个完整的 AXML 文件采用嵌套 Chunk 结构,最外层为 RES_XML_TYPE 容器(其 size 覆盖整个文件),内部按固定顺序排列各子 Chunk:
┌──────────────────────────────────────────────────────────────┐ │ RES_XML_TYPE (0x0003) — 文件根容器 │ │ ┌────────────────────────────────────────────────────────┐ │ │ │ RES_STRING_POOL_TYPE (0x0001) │ │ │ │ ├── ResStringPool_header (28 bytes) │ │ │ │ ├── stringOffsets[] (stringCount × uint32_t) │ │ │ │ ├── styleOffsets[] (styleCount × uint32_t) │ │ │ │ ├── stringData[] (变长: 所有字符串的编码内容) │ │ │ │ └── styleData[] (变长: 样式数据, Manifest 中为空) │ │ │ └────────────────────────────────────────────────────────┘ │ │ ┌────────────────────────────────────────────────────────┐ │ │ │ RES_XML_RESOURCE_MAP_TYPE (0x0180) [可选] │ │ │ │ └── resourceIds[] (K × uint32_t) │ │ │ └────────────────────────────────────────────────────────┘ │ │ ┌────────────────────────────────────────────────────────┐ │ │ │ RES_XML_START_NAMESPACE_TYPE (0x0100) │ │ │ │ ├── ResXMLTree_node (16 bytes) │ │ │ │ ├── prefix (uint32_t, string pool 索引) │ │ │ │ └── uri (uint32_t, string pool 索引) │ │ │ └────────────────────────────────────────────────────────┘ │ │ ┌────────────────────────────────────────────────────────┐ │ │ │ RES_XML_START_ELEMENT_TYPE (0x0102) ← <manifest> │ │ │ │ ├── ResXMLTree_node (16 bytes) │ │ │ │ ├── namespaceUri (uint32_t) │ │ │ │ ├── name (uint32_t) │ │ │ │ ├── attribute metadata (12 bytes) │ │ │ │ └── attributes[] (attributeCount × 20 bytes) │ │ │ └────────────────────────────────────────────────────────┘ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ RES_XML_START_ELEMENT_TYPE (0x0102) ← <application>│ │ │ │ ... │ │ │ └─────────────────────────────────────────────────────┘ │ │ ┌─────────────────────────────────────────────────────┐ │ │ │ RES_XML_END_ELEMENT_TYPE (0x0103) ← </application> │ │ │ └─────────────────────────────────────────────────────┘ │ │ ┌────────────────────────────────────────────────────────┐ │ │ │ RES_XML_END_ELEMENT_TYPE (0x0103) ← </manifest> │ │ │ └────────────────────────────────────────────────────────┘ │ │ ┌────────────────────────────────────────────────────────┐ │ │ │ RES_XML_END_NAMESPACE_TYPE (0x0101) │ │ │ └────────────────────────────────────────────────────────┘ │ └──────────────────────────────────────────────────────────────┘
四个关键观察:
字符串池必须在最前面 —— 后续所有 Chunk 都通过索引引用字符串,因此解析器必须先加载 StringPool。
资源 ID 映射紧随其后 —— 如果存在,它位于 StringPool 和第一个 XML 节点之间。
命名空间是显式边界 —— AXML 为每个 xmlns:xxx="..." 声明生成一对 START_NAMESPACE 和 END_NAMESPACE 块,像括号一样包裹使用该命名空间的元素范围。
XML 树的层次由线性顺序隐含 —— AXML 不使用指针或树形数据结构,层级关系由 START 和 END 块的线性顺序决定。解析器通过维护元素栈来重建树结构。
四、StringPool(字符串常量池)深度解析 StringPool(Chunk type = 0x0001)是 AXML 中最重要的块——它存储了所有 XML 标签名、属性名和属性值字符串。后续 Chunk 中的所有文本引用都是指向这个池的 32 位整型索引。
struct ResStringPool_header { struct ResChunk_header header ; uint32_t stringCount; uint32_t styleCount; uint32_t flags; uint32_t stringsStart; uint32_t stylesStart; };
headerSize 固定为 28(sizeof(ResStringPool_header) = 8 + 20 = 28)。紧接着 header 之后是偏移量数组:
偏移量数组布局(紧接 header,即 header + 0x1C): ┌───────────────────────────────┐ ← offset = header + 0x1C (28) │ stringOffsets[0] (4 bytes) │ │ stringOffsets[1] (4 bytes) │ │ ... │ │ stringOffsets[N-1] (4 bytes) │ ← N = stringCount ├───────────────────────────────┤ │ styleOffsets[0] (4 bytes) │ ← 仅当 styleCount > 0 │ ... │ ├───────────────────────────────┤ ← 此处偏移 = stringsStart (相对 header 起始) │ string[0] data │ │ string[1] data │ │ ... │ └───────────────────────────────┘
4.2 flags 标志位
Bit(s)
常量
值
含义
bit 0
SORTED_FLAG
0x00000001
字符串已按 Unicode 码点排序,支持二分查找
bit 8
UTF8_FLAG
0x00000100
字符串使用 UTF-8 变长编码;未置位则使用 UTF-16LE
AAPT2 生成的 AXML 一律设置 UTF8_FLAG(值 0x100),且大多数情况下同时设置 SORTED_FLAG(值 0x001),因此 flags 典型值为 0x00000101。极少数由旧版 AAPT 生成的 AXML 中 flags == 0x00000000,表示 UTF-16LE 编码且未排序。
SORTED_FLAG 的实际影响 :AOSP 中 ResStringPool::indexOfString() 方法(位于 ResourceTypes.cpp)在 SORTED_FLAG 置位时使用二分查找(O(log n)),未置位时退化为线性遍历(O(n))。对于 Manifest XML 的字符串池(典型大小为 30-200 个字符串),这个差异在实际运行时几乎不可感知,但对于 resources.arsc 中的巨型字符串池(可达数万条字符串),二分查找的优势十分显著。
4.3 字符串编码格式 UTF-8 模式(UTF8_FLAG 已置位) 下,每条字符串的二进制格式为:
┌──────────────┬──────────────┬─────────────────────┬──────┐ │ char_len │ byte_len │ UTF-8 payload │ 0x00 │ │ (1 or 2 B) │ (1 or 2 B) │ (byte_len bytes) │ (1B) │ └──────────────┴──────────────┴─────────────────────┴──────┘
char_len 和 byte_len 均使用与 ULEB128 类似但更简单的可变长编码:
若长度 < 128(即最高位为 0),用 1 字节 直接存储。
若长度 >= 128(即最高位为 1),用 2 字节大端序 存储:((len & 0x7F) << 8) | next_byte。即第一个字节的高位为 1 表示 2 字节编码,低 7 位与下一字节拼接成 15 位长度值。
示例 1 :字符串 "manifest"(8 个字符,8 个 UTF-8 字节)
08 08 6D 61 6E 69 66 65 73 74 00 │ │ └─── "manifest" 的 UTF-8 编码 ─────│ │ └── byte_len = 8 └── null 终止符 └── char_len = 8 (码元数)
示例 2 :长度为 256 个码元、300 个 UTF-8 字节的字符串:
80 01 81 2C <300 bytes UTF-8 data> 00 │ └─│ │ └─│ │ │ │ └── byte_len = (0x81 & 0x7F) << 8 | 0x2C │ │ │ = 0x012C = 300 │ │ └── byte_len 高位置 1 → 2 字节编码 │ └── char_len = (0x80 & 0x7F) << 8 | 0x01 = 256 └── char_len 高位置 1 → 2 字节编码
UTF-16LE 模式(UTF8_FLAG 未置位) 下,每条字符串的格式为:
┌──────────────────┬──────────────────────────────┬────────────┐ │ char_len │ UTF-16LE payload │ 0x0000 │ │ (2 or 4 bytes) │ (char_len × 2 bytes) │ (2 bytes) │ └──────────────────┴──────────────────────────────┴────────────┘
char_len 是 uint16_t。若 char_len >= 32768(即最高位置 1),则再接一个 uint16_t,使用 32 位的复合长度。每个码元占 2 字节,因此 payload 大小为 char_len × 2 字节。
与 DEX MUTF-8 的区别 :
NULL 字符处理 :DEX 的 MUTF-8 将 NULL(U+0000)编码为两字节序列 0xC0 0x80(Java 的 Modified UTF-8 约定),以避免 C 字符串的 null 终止符截断。而 AXML StringPool 使用标准 UTF-8(NULL 编码为 0x00),但因为 StringPool 通过显式的字节长度字段(byte_len)而非 null 终止符来确定字符串边界,所以内部出现 0x00 字节不会造成解析错误。
Surrogate pair 处理 :这是两者的关键区别。AXML StringPool 使用 Android libutils 中的 utf16_to_utf8() 进行编码转换(标准 UTF-8),对于合法的代理对(高代理 + 低代理),会先解码为对应的 Unicode 码点(如 U+10000),再编码为标准的 4 字节 UTF-8 序列。MUTF-8 则始终将每个代理码元独立编码为 3 字节序列(无论是否成对)。因此对于包含 emoji 或 CJK 扩展 B 区字符的字符串,AXML 和 DEX 的二进制表示是不同的。但在 Manifest AXML 的实际使用场景中,标签名、属性名和大多数属性值仅包含 ASCII 和 BMP 字符,因此这一差异在日常逆向分析中极少触发。
4.4 实战:StringPool hex dump 分析 以下是从一个典型 APK 的 AndroidManifest.xml 中提取的完整 StringPool hex dump(节选关键部分):
Offset Hex Annotation ------ ---- ---------- 0x0008 0100 1C00 C401 0000 type=0x0001, hdrSize=28, size=452 0x0010 1500 0000 0000 0000 stringCount=21, styleCount=0 0x0018 0000 0100 7C00 0000 flags=0x100(UTF8), strsStart=0x7C, stylesStart=0 0x0020 0000 0000 0000 0000 (offset table continues...) 0x0028 0000 0000 0900 0000 offset[0]=0("manifest"), offset[1]=9 0x0030 3A00 0000 4200 0000 offset[2]=0x3A, offset[3]=0x42 0x0038 4A00 0000 5F00 0000 offset[4]=0x4A, offset[5]=0x5F 0x0040 6B00 0000 7300 0000 offset[6]=0x6B, offset[7]=0x73 0x0048 7E00 0000 8C00 0000 ... 0x0050 9C00 0000 A800 0000 0x0058 B600 0000 C900 0000 0x0060 D500 0000 E100 0000 0x0068 EE00 0000 F700 0000 0x0070 FF00 0000 0A01 0000 0x0078 1301 0000 ─── stringOffsets 结束, 共 21×4=84=0x54 字节, header(28=0x1C)+84=0x70, 但 stringsStart=0x7C, 中间有 12 字节 padding ─── 0x0084 0808 6D61 6E69 6665 7374 00 [0] "manifest" (8 char, 8 byte) 0x008D 2D2D 6874 7470 3A2F 2F73 6368 656D [1] "http://schemas.android.com/apk/res/android" 6173 2E61 6E64 726F 6964 2E63 6F6D 2F61 706B 2F72 6573 2F61 6E64 726F 6964 00 (45 char, 45 byte) 0x00BA 0707 616E 6472 6F69 6400 [2] "android" (7 char, 7 byte) 0x00C2 0707 7061 636B 6167 6500 [3] "package" (7 char, 7 byte) 0x00CA 0C0C 636F 6D2E 6578 616D 706C 652E [4] "com.example.app" (14? no wait...) 6170 7000 0x00D7 0B0B 6170 706C 6963 6174 696F 6E00 [5] "application" (11 char, 11 byte) 0x00E3 0505 7468 656D 6500 [6] "theme" (5 char, 5 byte) 0x00E9 0A0A 6465 6275 6767 6162 6C65 00 [7] "debuggable" (10 char, 10 byte) ...
从 dump 中可以验证:字符串偏移表中的每个偏移值指向 stringsStart 位置之后的对应数据区。例如 offset[0] = 0,绝对位置 = 0x0084 + 0 = 0x0084,该位置的数据以 char_len=0x08、byte_len=0x08 开始,后跟 8 字节的 "manifest" 和一个 null 终止符。
4.5 ResStringPool 字符串查找算法 理解 StringPool 的查找机制对于编写 AXML 修改工具至关重要。当你需要查找某个字符串是否已在池中存在(避免重复添加)、或在池中定位特定字符串的索引时,查找算法的效率直接影响工具性能。
二分查找(SORTED_FLAG 已置位)
当 flags & SORTED_FLAG 为真时,字符串池中的字符串按 Unicode 码点顺序升序排列。AOSP 中 ResStringPool::indexOfString() 使用 std::lower_bound(C++ 标准库二分查找)在 O(log n) 时间内完成定位。其等效逻辑为:
def binary_search_in_pool (strings: List [str ], target: str ) -> int : """在已排序的字符串池中二分查找目标字符串,返回索引或 -1。""" lo, hi = 0 , len (strings) - 1 while lo <= hi: mid = (lo + hi) // 2 cmp = strings[mid] if cmp == target: return mid elif cmp < target: lo = mid + 1 else : hi = mid - 1 return -1
StringPool 中的字符串按字符串内容的 Unicode 码点顺序 排序,而非按字符串的哈希值排序。这意味着排序结果独立于哈希函数的选择,对跨工具兼容性友好。同时,由于 stringOffsets[] 偏移表按排序后的顺序排列,二分查找时需要依次读取偏移表定位的字符串内容进行比较。
线性遍历(SORTED_FLAG 未置位)
当 SORTED_FLAG 未置位时,字符串池是无序的,只能从索引 0 开始线性扫描,直到找到匹配或遍历完所有条目。时间复杂度 O(n):
def linear_search_in_pool (strings: List [str ], target: str ) -> int : """在线性未排序的字符串池中查找,返回索引或 -1。""" for i, s in enumerate (strings): if s == target: return i return -1
对于 Manifest AXML(典型 stringCount = 30~200),线性扫描的性能开销可以忽略。但对于 resources.arsc 中的巨型字符串池(stringCount 可达数万),缺失 SORTED_FLAG 将导致每次属性查找都执行 O(n) 遍历,从而严重拖慢 PackageManager 的包扫描速度。
工具开发中的实践建议
当你编写修改 AXML 的工具,需要在池中追加新字符串时:
先查重 :无论是二分还是线性,先确认目标字符串是否已存在于池中。若已存在,直接复用现有索引(避免增加 stringCount 从而触发级联 size 更新)。
保持排序 :如果原池已设置 SORTED_FLAG,插入新字符串后应保持排序。这意味着新字符串不能简单追加到池末尾——需要将其插入到正确的排序位置,并调整后续所有字符串的偏移量。这是一个 O(n) 的开销操作。
降级策略 :如果插入新字符串后无法保持排序(例如工具复杂度不允许),可以清除 SORTED_FLAG 位(flags &= ~SORTED_FLAG),将池降级为线性遍历模式。AOSP 解析器会正确退化到线性查找,不会报错。
排序规则细节
字符串的排序规则严格按 UTF-16 码元的数值顺序(unsigned 比较),而非 Unicode Collation Algorithm (UCA) 的区域感知顺序。这意味着:
"Z" (U+005A) < "a" (U+0061) — 大写字母在小写字母之前(ASCII 码点顺序)
"string1" < "string10" — 字符串比较按码元逐一比较,'1' 的码点 < '0' 的码点?不对,"string1" 和 "string10" 前 7 个字符相同,第 8 个字符 '1' vs '1' 相同,但 "string1" 到此结束(长度 7),"string10" 还有 '0'。空终止符(视为 \0,码点 0)小于 '0'(码点 0x30),因此 "string1" < "string10"。这一行为与 C 的 strcmp 一致。
五、ResourceIdChunk(资源 ID 映射表) ResourceIdChunk 的 Chunk type 为 0x0180,定义于 ResourceTypes.h 中 RES_XML_RESOURCE_MAP_TYPE。它的 headerSize 仅 8 字节(仅包含 ResChunk_header),数据部分是一个 uint32_t 数组。
┌─────────────────────────────────┐ │ type = 0x0180 (2 bytes) │ │ headerSize = 8 (2 bytes) │ │ size (4 bytes) │ ├─────────────────────────────────┤ │ resourceId[0] (4 bytes) │ ← string pool 索引 0 对应的资源 ID │ resourceId[1] (4 bytes) │ ← string pool 索引 1 对应的资源 ID │ ... │ │ resourceId[K-1] (4 bytes) │ └─────────────────────────────────┘
数组长度 K = (size - 8) / 4。其映射规则为:**字符串池中索引为 i 的字符串,如果是一个资源属性名,则 resourceId[i] 存储其资源 ID;如果是普通字符串值(如 "com.example.app"、".MainActivity"),则 resourceId[i] = 0**。
5.1 资源 ID 的编码格式 每个资源 ID 遵循 0xPPTTEEEE 格式:
0x P P T T E E E E │ │ │ │ └┘└┘└┘└── Entry ID (16 bits): 该类型内的条目编号 │ │ └┘└────────── Type ID (8 bits): 资源类型 └┘└───────────── Package ID (8 bits): 资源所属包
包 ID (PP)
含义
0x01
Android 系统框架资源(android.R)
0x7F
应用自有资源(com.example.app.R)
0x00
编译期分配的共享库资源
0x02-0x7E
动态功能模块 / Split APK 资源
类型 ID (TT)
资源类型
说明
0x01
attr
属性定义
0x02
layout
布局文件
0x03
drawable
图片资源
0x04
string
字符串资源
0x05
dimen
尺寸资源
0x06
color
颜色资源
0x07
style
样式
0x09
array
数组
0x0A
plurals
复数形式字符串
5.2 映射实例 对于 Manifest 中的一段文本:
<manifest xmlns:android ="http://schemas.android.com/apk/res/android" package ="com.example.app" android:versionCode ="1" >
编译后 ResourceMap 的内容(假设对应的字符串索引):
StringPool[0] = "manifest" → ResourceMap[0] = 0x01010003 (android:attr/manifest) StringPool[1] = "http://..." → ResourceMap[1] = 0x00000000 (URI 不是资源) StringPool[2] = "android" → ResourceMap[2] = 0x01010000 (android:attr/*) StringPool[3] = "package" → ResourceMap[3] = 0x0101001E (android:attr/package) StringPool[4] = "com.example.app" → ResourceMap[4] = 0x00000000 (包名字符串值) StringPool[5] = "versionCode" → ResourceMap[5] = 0x0101021B (android:attr/versionCode)
为什么需要 ResourceMap? 在运行时,Framework 解析到一个属性名后,需要知道该属性对应的资源 ID 以在 resources.arsc 的 ResTable 中查找属性元数据(如该属性的格式字符串、枚举约束等)。ResourceMap 提供了从字符串索引到资源 ID 的 O(1) 正向映射。
六、命名空间 Chunk 详解 命名空间块成对出现:Start (0x0100) 和 End (0x0101)。它们对应 XML 中的 xmlns:xxx="..." 声明。
6.1 结构体定义 struct ResXMLTree_node { struct ResChunk_header header ; uint32_t lineNumber; struct ResStringPool_ref comment ; }; struct ResXMLTree_namespaceExt { struct ResXMLTree_node node ; struct ResStringPool_ref prefix ; struct ResStringPool_ref uri ; };
其中 ResStringPool_ref 是 uint32_t 类型的字符串池索引。完整大小为 24 字节(16 + 4 + 4)。
6.2 精细字节布局
偏移
大小
字段
值示例
0x00
2
type
0x0100 或 0x0101
0x02
2
headerSize
16
0x04
4
size
24
0x08
4
lineNumber
1
0x0C
4
comment
0xFFFFFFFF
0x10
4
prefix
2 (StringPool[2] = “android”)
0x14
4
uri
1 (StringPool[1] = “http://schemas.android.com/apk/res/android “)
6.3 十六进制实例 0100 1000 1800 0000 type=0x0100, hdrSize=16, size=24 0100 0000 FFFFFFFF lineNumber=1, comment=-1 0200 0000 0100 0000 prefix=index2("android"), uri=index1("http://...")
6.4 命名空间栈的工作机制 解析器维护一个命名空间栈:
遇到 START_NAMESPACE (0x0100) → 将 (prefix, uri) 入栈。
解析 StartElement 的属性时 → 对于每个属性的 ns 字段(URI 字符串索引),在栈中反向查找匹配的 URI,获得前缀字符串 prefix。
遇到 END_NAMESPACE (0x0101) → 出栈。
这种显式的命名空间块设计使得 AXML 解析器无需像 SAX 解析器那样维护上下文状态,只需线性扫描即可正确处理嵌套的命名空间声明。
七、StartElement Chunk(元素开始标签)详解 这是 AXML 最复杂的块类型,对应 XML 中的每个开始标签(如 <manifest>、<application>、<activity>)。
7.1 ResXMLTree_element 结构体 注意 :在 AOSP ResourceTypes.h 中,ResXMLTree_element 结构体仅包含 node、ns、name 三个字段(共 24 字节)。下面的 attributeStart、attributeSize、attributeCount、idIndex、classIndex、styleIndex 这 6 个 uint16_t 字段(共 12 字节)紧跟在结构体之后存储在字节流中,但并非结构体的形式成员。为了便于理解完整的磁盘布局,此处将它们一并列出:
struct ResXMLTree_element { struct ResXMLTree_node node ; struct ResStringPool_ref ns ; struct ResStringPool_ref name ; };
7.2 完整字节布局
偏移
大小
字段
值示例
0x00
2
type
0x0102
0x02
2
headerSize
16
0x04
4
size
整个块的大小(含属性数组)
0x08
4
lineNumber
源文件行号(调试用)
0x0C
4
comment
0xFFFFFFFF(无注释)
0x10
4
namespaceUri
命名空间 URI 字符串索引,或 -1
0x14
4
name
标签名字符串索引
0x18
2
attributeStart
通常为 36(=0x24,即 sizeof(ResXMLTree_element))
0x1A
2
attributeSize
通常为 20(=0x14,即 sizeof(ResXMLTree_attrExt))
0x1C
2
attributeCount
属性数量 N
0x1E
2
idIndex
id 属性索引,或 0xFFFF
0x20
2
classIndex
class 属性索引,或 0xFFFF
0x22
2
styleIndex
style 属性索引,或 0xFFFF
0x24
N×20
attributes[]
属性数组,每项 20 字节
attributeStart 的设计意图 :该字段始终等于 sizeof(ResXMLTree_element)(= 0x24 = 36 字节)。如果未来 AOSP 在 ResXMLTree_element 中增加字段,attributeStart 也会相应增大——这是向前兼容的经典设计。解析器不硬编码 0x24,而是读取该字段动态定位属性数组的起始偏移。
7.3 属性条目结构(ResXMLTree_attrExt) 每个属性固定 20 字节:
struct ResXMLTree_attrExt { struct ResStringPool_ref ns ; struct ResStringPool_ref name ; struct ResStringPool_ref rawValue ; struct Res_value typedValue ; };
字节布局:
偏移
大小
字段
0x00
4
ns(命名空间 URI 字符串索引,-1 = 无命名空间)
0x04
4
name(属性名字符串索引)
0x08
4
rawValue(原始值字符串索引,-1 表示无原始字符串)
0x0C
2
typedValue.size(固定为 8)
0x0E
1
typedValue.res0(保留,固定为 0)
0x0F
1
typedValue.dataType(数据类型,见第八节完整表格)
0x10
4
typedValue.data(实际数据值)
7.4 idIndex / classIndex / styleIndex 的意义 这三个 uint16_t 字段是对常用特殊属性的快速索引。PackageManagerService 在解析 Manifest 时可以 O(1) 获取 android:id(通过 idIndex)、class(通过 classIndex)、style(通过 styleIndex)的值,无需遍历全部属性数组。例如 idIndex = 2 表示属性数组的第 2 个元素(从 0 开始)是 android:id 属性。若元素没有对应属性,则相应字段为 0xFFFF(65535)。
7.5 实战:StartElement hex dump 以下是一个 <activity> 元素的 AXML 完整十六进制编码:
Offset Hex Annotation ------ ---- ---------- 0x0490 0201 1000 A000 0000 type=0x0102, hdrSize=16, chunkSize=160 0x0498 0500 0000 FFFFFFFF lineNumber=5, comment=-1 0x04A0 FFFFFFFF 0800 0000 nsUri=-1, name=idx8("activity") 0x04A8 2400 1400 0600 0000 attrStart=0x24, attrSize=0x14, attrCount=6 0x04B0 FFFFFFFF FFFFFFFF FFFFFFFF idIdx=-1, classIdx=-1, styleIdx=-1 ─────────── 以下 6 个属性, 每个 20 字节 ─────────── 0x04BC 0200 0000 0900 0000 0A00 0000 attr[0]: ns=idx2("android"), name=idx9("name") 0800 0300 0A00 0000 rawVal=idx10(".MainActivity"), typedVal={size=8,res0=0,type=0x03(STRING),data=idx10} 0x04D0 0200 0000 0B00 0000 FFFFFFFF attr[1]: ns=idx2, name=idx11("exported") 0800 1200 FFFFFFFF rawVal=-1, typedVal={size=8,res0=0,type=0x12(BOOL),data=0xFFFFFFFF(true)} 0x04E4 0200 0000 0C00 0000 FFFFFFFF attr[2]: ns=idx2, name=idx12("label") 0800 0100 0000 027F rawVal=-1, typedVal={size=8,res0=0,type=0x01(REF),data=0x7F020000} ... 解读: <activity android:name=".MainActivity" ← attr[0]: TYPE_STRING, data=idx10 → ".MainActivity" android:exported="true" ← attr[1]: TYPE_INT_BOOLEAN, data=0xFFFFFFFF → true android:label="@string/app_name" ← attr[2]: TYPE_REFERENCE, data=0x7F020000 ... />
关键观察 :对于 android:exported="true",rawValue 为 -1(无原始字符串),因为 AAPT 在编译时识别出该值是布尔类型,直接将其转化为 typedValue.dataType=TYPE_INT_BOOLEAN, data=0xFFFFFFFF。而对于 android:name=".MainActivity",rawValue 和 typedValue.data 均指向字符串池索引 10(”.MainActivity”)。这种双重编码(同时保留 rawValue 和 typedValue)确保了解析器在遇到不支持的 dataType 时可以回退到原始字符串处理。
八、Res_value 类型系统:属性值的七种编码 属性值的实际数据类型由 Res_value.dataType(uint8_t)标识。AOSP 在 ResourceTypes.h 中定义了 19 种不同的类型,覆盖了 Android 中所有可能的属性值形态。
8.1 完整类型表 enum { TYPE_NULL = 0x00 , TYPE_REFERENCE = 0x01 , TYPE_ATTRIBUTE = 0x02 , TYPE_STRING = 0x03 , TYPE_FLOAT = 0x04 , TYPE_DIMENSION = 0x05 , TYPE_FRACTION = 0x06 , TYPE_DYNAMIC_REFERENCE = 0x07 , TYPE_DYNAMIC_ATTRIBUTE = 0x08 , TYPE_FIRST_INT = 0x10 , TYPE_INT_DEC = 0x10 , TYPE_INT_HEX = 0x11 , TYPE_INT_BOOLEAN = 0x12 , TYPE_FIRST_COLOR_INT = 0x1C , TYPE_INT_COLOR_ARGB8 = 0x1C , TYPE_INT_COLOR_RGB8 = 0x1D , TYPE_INT_COLOR_ARGB4 = 0x1E , TYPE_INT_COLOR_RGB4 = 0x1F , TYPE_LAST_COLOR_INT = 0x1F , TYPE_LAST_INT = 0x1F , };
8.2 各类型的数据编码详解 **TYPE_STRING (0x03)**:data 字段存储字符串池索引。这是 Manifest 中最常见的属性值类型——用于所有未被 AAPT 识别为特殊类型的字符串值,如 android:name=".MainActivity"、android:authorities="com.example.provider" 等。
**TYPE_REFERENCE (0x01)**:data 字段直接存储资源 ID。例如:
@string/app_name → data = 0x7F04001A
@style/AppTheme → data = 0x7F070001
@android:style/Theme.Material → data = 0x01070005 rawValue 通常为 -1(0xFFFFFFFF),表示没有原始字符串。
**TYPE_INT_BOOLEAN (0x12)**:
true → data = 0xFFFFFFFF (-1,与 Java/C/C++ 的 true 约定一致)
false → data = 0x00000000 (0)
历史兼容:data = 1 也被视为 true
**TYPE_INT_DEC (0x10)**:data 存储有符号 32 位十进制整数。例如:
android:versionCode="42" → data = 0x0000002A
android:priority="100" → data = 0x00000064
**TYPE_INT_HEX (0x11)**:data 存储以十六进制形式书写的整数值。例如:
android:value="0xFF" → data = 0x000000FF
android:configChanges="0x4B0" → data = 0x000004B0
**TYPE_DIMENSION (0x05)**:data 的高字节编码单位和精度,低三字节编码数值。具体编码为:data = (COMPLEX_RADIX_23p0 << 4) | COMPLEX_UNIT_XXX 结合 value 的定点数表示。单位枚举:
常量
值
说明
COMPLEX_UNIT_PX
0
像素 (px)
COMPLEX_UNIT_DIP
1
设备独立像素 (dp/dip)
COMPLEX_UNIT_SP
2
缩放像素 (sp)
COMPLEX_UNIT_PT
3
磅 (pt)
COMPLEX_UNIT_IN
4
英寸 (in)
COMPLEX_UNIT_MM
5
毫米 (mm)
例如 android:padding="16dp" → 实际编码为 (16 << 4) | 0x01 = 0x00000101。
**TYPE_FLOAT (0x04)**:data 字段存储 IEEE 754 单精度浮点数的位模式。例如 12.5f → data = 0x41480000(即 struct.pack('f', 12.5) 的整型表示)。
8.3 类型判断流程 解析器通过以下逻辑确定属性值的最终表示:
def resolve_value (data_type, data, raw_value_index, string_pool ): if data_type == TYPE_STRING: return string_pool[data] elif data_type == TYPE_REFERENCE: return f"@0x{data:08X} " elif data_type == TYPE_INT_BOOLEAN: return "true" if data != 0 else "false" elif data_type == TYPE_INT_DEC: return str (data) elif data_type == TYPE_INT_HEX: return f"0x{data:X} " elif data_type == TYPE_FLOAT: return struct.unpack('f' , struct.pack('I' , data))[0 ] elif data_type == TYPE_NULL: return "@null" elif data_type == TYPE_DIMENSION: return decode_dimension(data) else : return string_pool.get(raw_value_index, f"@0x{data:08X} " )
九、EndElement 与 CDATA Chunk 9.1 EndElement Chunk (0x0103) 结束标签块是对应 StartElement 的闭合标记,结构比 StartElement 简单得多:
struct ResXMLTree_endElementExt { struct ResXMLTree_node node ; struct ResStringPool_ref namespaceUri ; struct ResStringPool_ref name ; };
总大小 24 字节。namespaceUri 和 name 的值与其对应的 StartElement 保持一致,确保解析器能正确匹配标签对。无属性数组。
9.2 CDATA Chunk (0x0104) CDATA 块在 AndroidManifest.xml 中极为罕见(Manifest 的定义决定了它没有文本节点),但在布局 XML 的 AXML 版本中可能出现。其结构与属性类似:
struct ResXMLTree_cdataExt { struct ResXMLTree_node node ; uint32_t data; struct Res_value typedValue ; };
9.3 AXML 中 <intent-filter> 的嵌套结构 Manifest 中最典型的元素嵌套场景是 <activity> 内包含 <intent-filter>,而 <intent-filter> 又包含 <action>、<category>、<data> 等子元素。理解 AXML 如何编码这种嵌套关系,是编写精确的二进制修改工具的前提。
文本 XML 示例 :
<activity android:name =".MainActivity" > <intent-filter > <action android:name ="android.intent.action.MAIN" /> <category android:name ="android.intent.category.LAUNCHER" /> </intent-filter > </activity >
对应的 AXML Chunk 序列(线性排列) :
┌───────────────────────────────────────────────────────────────────┐ │ RES_XML_START_ELEMENT (0x0102) - <activity> │ │ ├─ attributes: name=".MainActivity" (1 个属性, 20 bytes) │ │ └─ chunk size = 56 (36 header + 20 attribute) │ ├───────────────────────────────────────────────────────────────────┤ │ RES_XML_START_ELEMENT (0x0102) - <intent-filter> │ │ ├─ attributes: 0 个属性 │ │ └─ chunk size = 36 (36 header + 0 attributes) │ ├───────────────────────────────────────────────────────────────────┤ │ RES_XML_START_ELEMENT (0x0102) - <action> │ │ ├─ attributes: name="android.intent.action.MAIN" (1 个属性) │ │ └─ chunk size = 56 │ ├───────────────────────────────────────────────────────────────────┤ │ RES_XML_END_ELEMENT (0x0103) - </action> │ │ ├─ name = "action" │ │ └─ chunk size = 24 (仅 header + ns + name, 无属性) │ ├───────────────────────────────────────────────────────────────────┤ │ RES_XML_START_ELEMENT (0x0102) - <category> │ │ ├─ attributes: name="android.intent.category.LAUNCHER" (1 个) │ │ └─ chunk size = 56 │ ├───────────────────────────────────────────────────────────────────┤ │ RES_XML_END_ELEMENT (0x0103) - </category> │ │ └─ chunk size = 24 │ ├───────────────────────────────────────────────────────────────────┤ │ RES_XML_END_ELEMENT (0x0103) - </intent-filter> │ │ └─ chunk size = 24 │ ├───────────────────────────────────────────────────────────────────┤ │ RES_XML_END_ELEMENT (0x0103) - </activity> │ │ └─ chunk size = 24 │ └───────────────────────────────────────────────────────────────────┘
关键设计要点 :
嵌套由线性顺序隐含 :AXML 不使用指针或树形引用。解析器通过在遇到 START_ELEMENT 时压入元素栈、遇到 END_ELEMENT 时弹出栈顶元素来重建 XML 树的层次结构。栈的深度等于当前 XML 嵌套深度。
END_ELEMENT 不重复属性信息 :结束标签仅携带 namespaceUri 和 name(共 8 字节附加在 ResXMLTree_node 之后),完全不包含属性数组。这与 StartElement 形成鲜明对比——StartElement 的 chunk 可能因大量属性而很大(数百字节),而 EndElement 固定只有 24 字节。
跳过意图 :解析器实现 nextNode() 时,如果当前 chunk 的 type 不是目标类型(例如在属性匹配时只想定位 START_ELEMENT),可以通过 pos += chunk.size 直接跳过该 chunk 的全部内容。这种跳转是 O(1) 的——无论 chunk 内有多少个属性字段都无需扫描。
栈式解析实现 :
def parse_tree (data: bytes , string_pool: List [str ] ) -> dict : """将 AXML 的线性 chunk 序列重建为嵌套的树形结构。""" stack = [{"tag" : "#document" , "children" : []}] pos = 0 while pos < len (data): typ = struct.unpack_from('<H' , data, pos)[0 ] size = struct.unpack_from('<I' , data, pos + 4 )[0 ] if typ == 0x0102 : name_idx = struct.unpack_from('<I' , data, pos + 20 )[0 ] node = {"tag" : string_pool[name_idx], "attrs" : {}, "children" : []} stack[-1 ]["children" ].append(node) stack.append(node) elif typ == 0x0103 : stack.pop() pos += size return stack[0 ]["children" ][0 ]
从 AXML 二进制中定位特定 <intent-filter> 的算法 :
如果你需要在 AXML 二进制层面修改某个 <intent-filter> 内的属性(例如修改 <action> 的 android:name),算法的核心步骤如下:
线性扫描所有 START_ELEMENT chunk,通过标签名字符串索引匹配 <intent-filter>。
进入该 START_ELEMENT 后继续扫描后续 chunk,维持一个嵌套计数器(START 时 +1,END 时 -1)。
当计数器降回 <intent-filter> 所在的层级时,表示该 intent-filter 的 child 子元素已遍历完毕。
在步骤 2-3 之间扫描到的所有 START_ELEMENT 就是该 intent-filter 的直接子元素(<action>、<category>、<data>)。
这种基于嵌套计数的遍历方式不需要构建完整的 DOM 树,仅需一个整数计数器和字符串池引用即可在 O(n) 时间内精确定位任意深度的目标元素。
十、完整 Python AXML 解析器(增强版) 以下是一个完整、可独立运行的 AXML 解析器(增强版),在原有解析功能基础上新增了:针对 sorted string pool 的二分查找、畸形 chunk 的错误恢复、以及增强的 XML 命名空间前缀解析。代码严格遵守 AOSP ResourceTypes.h 中的结构体定义,仅依赖 Python 标准库。
""" Android Binary XML (AXML) Parser — Enhanced Edition 解析 APK 中的 AndroidManifest.xml(二进制格式)并输出可读文本 XML。 参考: AOSP frameworks/base/libs/androidfw/include/androidfw/ResourceTypes.h 增强功能: - sorted string pool 的二分查找 (binary_search_string) - 畸形 chunk 的错误恢复 (safe_parse with skip_and_log) - 增强的 XML 命名空间前缀解析 """ import structimport sysfrom typing import List , Dict , Tuple , Optional class AXMLParseError (Exception ): """AXML 解析错误,携带偏移量和上下文信息。""" def __init__ (self, msg: str , offset: int = 0 , context: str = "" ): super ().__init__(f"[offset=0x{offset:04X} ] {msg} " + (f" (context: {context} )" if context else "" )) self .offset = offset self .context = context class AXMLParser : RES_NULL_TYPE = 0x0000 RES_STRING_POOL_TYPE = 0x0001 RES_XML_TYPE = 0x0003 RES_XML_START_NAMESPACE_TYPE = 0x0100 RES_XML_END_NAMESPACE_TYPE = 0x0101 RES_XML_START_ELEMENT_TYPE = 0x0102 RES_XML_END_ELEMENT_TYPE = 0x0103 RES_XML_CDATA_TYPE = 0x0104 RES_XML_RESOURCE_MAP_TYPE = 0x0180 SORTED_FLAG = 1 << 0 UTF8_FLAG = 1 << 8 TYPE_NULL = 0x00 TYPE_REFERENCE = 0x01 TYPE_ATTRIBUTE = 0x02 TYPE_STRING = 0x03 TYPE_FLOAT = 0x04 TYPE_DIMENSION = 0x05 TYPE_FRACTION = 0x06 TYPE_DYNAMIC_REFERENCE = 0x07 TYPE_DYNAMIC_ATTRIBUTE = 0x08 TYPE_INT_DEC = 0x10 TYPE_INT_HEX = 0x11 TYPE_INT_BOOLEAN = 0x12 TYPE_INT_COLOR_ARGB8 = 0x1C TYPE_INT_COLOR_RGB8 = 0x1D TYPE_INT_COLOR_ARGB4 = 0x1E TYPE_INT_COLOR_RGB4 = 0x1F TAG_NAMES = { "manifest" , "application" , "activity" , "service" , "receiver" , "provider" , "uses-permission" , "uses-feature" , "intent-filter" , "action" , "category" , "data" , "meta-data" , "grant-uri-permission" , "path-permission" , "instrumentation" , "permission" , "permission-group" , "permission-tree" , "supports-screens" , "compatible-screens" , "uses-configuration" , "uses-sdk" , "supports-gl-texture" , } DEFAULTS = { "exported" : "false" , "enabled" : "true" , "debuggable" : "false" , } def __init__ (self, data: bytes , strict: bool = False ): self .data = data self .strict = strict self .strings: List [str ] = [] self .resource_map: Dict [int , int ] = {} self .ns_stack: List [Tuple [str , str ]] = [] self .element_stack: List [str ] = [] self .indent_level = 0 self .output_lines: List [str ] = [] self ._warnings: List [str ] = [] self ._sorted_strings_index: Optional [List [int ]] = None def _u16 (self, off: int ) -> int : return struct.unpack_from('<H' , self .data, off)[0 ] def _u32 (self, off: int ) -> int : return struct.unpack_from('<I' , self .data, off)[0 ] def _bounds_ok (self, off: int , size: int = 1 ) -> bool : """验证 [off, off+size) 在文件范围内。""" return 0 <= off <= len (self .data) - size def _emit (self, line: str ): self .output_lines.append(' ' * self .indent_level + line) def _warn (self, msg: str , offset: int = 0 ): self ._warnings.append(f"[0x{offset:04X} ] {msg} " ) def _s (self, idx: int ) -> Optional [str ]: """安全获取字符串池索引对应的字符串。""" if 0 <= idx < len (self .strings): return self .strings[idx] if idx == 0xFFFFFFFF : return None self ._warn(f"string index {idx} out of range [0, {len (self.strings)} )" ) return None def _read_utf8_length (self, pos: int ) -> Tuple [int , int ]: """读取变长编码的长度值。返回 (length, 消耗的字节数)。""" first = self .data[pos] if first & 0x80 : if not self ._bounds_ok(pos, 2 ): raise AXMLParseError("truncated utf8 length" , pos) return ((first & 0x7F ) << 8 ) | self .data[pos + 1 ], 2 else : return first, 1 def _read_utf8_string (self, pos: int ) -> str : char_len, cl_bytes = self ._read_utf8_length(pos) pos += cl_bytes byte_len, bl_bytes = self ._read_utf8_length(pos) pos += bl_bytes if not self ._bounds_ok(pos, byte_len): raise AXMLParseError(f"utf8 payload truncated: need {byte_len} bytes" , pos) raw = self .data[pos:pos + byte_len] try : return raw.decode('utf-8' ) except UnicodeDecodeError: return raw.decode('utf-8' , errors='replace' ) def _read_utf16_string (self, pos: int ) -> str : char_len = self ._u16(pos); pos += 2 if char_len & 0x8000 : if not self ._bounds_ok(pos, 2 ): raise AXMLParseError("truncated utf16 length" , pos) char_len = ((char_len & 0x7FFF ) << 16 ) | self ._u16(pos); pos += 2 payload_size = char_len * 2 if not self ._bounds_ok(pos, payload_size): raise AXMLParseError(f"utf16 payload truncated: need {payload_size} bytes" , pos) raw = self .data[pos:pos + payload_size] try : return raw.decode('utf-16-le' ) except UnicodeDecodeError: return raw.decode('utf-16-le' , errors='replace' ) def _parse_string_pool (self, offset: int ) -> int : """解析 StringPool chunk。返回下一个 chunk 的偏移。""" chunk_start = offset if not self ._bounds_ok(offset, 28 ): raise AXMLParseError("string pool header truncated" , offset) chunk_type = self ._u16(offset) if chunk_type != self .RES_STRING_POOL_TYPE: raise AXMLParseError( f"expected STRING_POOL (0x0001), got 0x{chunk_type:04X} " , offset) hdr_size = self ._u16(offset + 2 ) chunk_size = self ._u32(offset + 4 ) string_count = self ._u32(offset + 8 ) flags = self ._u32(offset + 16 ) strings_start = self ._u32(offset + 20 ) if not self ._bounds_ok(offset, chunk_size): raise AXMLParseError( f"string pool size 0x{chunk_size:X} exceeds file bounds" , offset) if string_count > 100000 : self ._warn(f"suspicious stringCount={string_count} , capping at 100000" , offset) string_count = 100000 is_utf8 = bool (flags & self .UTF8_FLAG) offsets_base = offset + hdr_size data_base = offset + strings_start data_max = offset + chunk_size self .strings = [] for i in range (string_count): off_entry = offsets_base + i * 4 if not self ._bounds_ok(off_entry, 4 ): self ._warn(f"string offset table truncated at index {i} " , offset) break str_off = self ._u32(off_entry) abs_pos = data_base + str_off if abs_pos >= data_max: self ._warn(f"string offset[{i} ]={str_off} points outside data area" , off_entry) self .strings.append("" ) continue try : s = self ._read_utf8_string(abs_pos) if is_utf8 else self ._read_utf16_string(abs_pos) except (AXMLParseError, IndexError): self ._warn(f"failed to decode string at index {i} " , abs_pos) s = "" self .strings.append(s) if flags & self .SORTED_FLAG and len (self .strings) > 0 : self ._sorted_strings_index = list (range (len (self .strings))) return chunk_start + chunk_size def binary_search_string (self, target: str ) -> int : """ 在已排序的字符串池中二分查找目标字符串,返回索引或 -1。 仅在 flags & SORTED_FLAG 时有效。 时间复杂度: O(log n * m) 其中 n = stringCount, m = 字符串平均长度 (每次比较需要从原始数据中解码字符串,因为未缓存全部解码结果) """ if not self .strings: return -1 lo, hi = 0 , len (self .strings) - 1 while lo <= hi: mid = (lo + hi) // 2 cmp = self .strings[mid] if cmp == target: return mid elif cmp < target: lo = mid + 1 else : hi = mid - 1 return -1 def find_or_add_string (self, target: str ) -> int : """ 在字符串池中查找字符串。若存在则返回索引;若不存在则返回 -1。 对调用者暴露的简洁接口:自动选择二分查找或线性查找。 """ if self ._sorted_strings_index is not None : return self .binary_search_string(target) else : for i, s in enumerate (self .strings): if s == target: return i return -1 def _parse_resource_map (self, offset: int ) -> int : """解析 ResourceMap chunk。若当前 chunk 不是 ResourceMap 则原样返回 offset。""" if not self ._bounds_ok(offset, 8 ): return offset chunk_type = self ._u16(offset) if chunk_type != self .RES_XML_RESOURCE_MAP_TYPE: return offset hdr_size = self ._u16(offset + 2 ) chunk_size = self ._u32(offset + 4 ) count = (chunk_size - hdr_size) // 4 for i in range (count): rid = self ._u32(offset + hdr_size + i * 4 ) if rid != 0 : self .resource_map[i] = rid return offset + chunk_size def _parse_start_namespace (self, offset: int ) -> int : chunk_size = self ._u32(offset + 4 ) prefix = self ._s(self ._u32(offset + 16 )) or "" uri = self ._s(self ._u32(offset + 20 )) or "" self .ns_stack.append((prefix, uri)) if prefix: self ._emit(f'xmlns:{prefix} ="{uri} "' ) else : self ._emit(f'xmlns="{uri} "' ) return offset + chunk_size def _parse_end_namespace (self, offset: int ) -> int : chunk_size = self ._u32(offset + 4 ) prefix = self ._s(self ._u32(offset + 16 )) or "" for i in range (len (self .ns_stack) - 1 , -1 , -1 ): if self .ns_stack[i][0 ] == prefix: self .ns_stack = self .ns_stack[:i] break return offset + chunk_size def _lookup_ns_prefix (self, uri: str ) -> str : """从命名空间栈中查找 URI 对应的前缀(从栈顶向栈底)。""" if not uri: return "" for prefix, u in reversed (self .ns_stack): if u == uri: return prefix if "android" in uri or "schemas.android.com" in uri: return "android" return "" def _resolve_full_name (self, ns_uri: Optional [str ], local_name: str ) -> str : """ 将 (namespace URI, local name) 解析为完整的限定名。 例如 (http://schemas.android.com/apk/res/android, "name") → "android:name" """ if not ns_uri: return local_name prefix = self ._lookup_ns_prefix(ns_uri) if prefix: return f"{prefix} :{local_name} " return local_name def _resolve_typed_value (self, data_type: int , data: int , raw_idx: int ) -> str : if data_type == self .TYPE_NULL: return "@null" elif data_type == self .TYPE_STRING: s = self ._s(data) if s is None : return "" return self ._xml_escape(s) elif data_type == self .TYPE_REFERENCE: if data & 0xFF000000 == 0x7F000000 : return f"@0x{data:08X} " elif data & 0xFF000000 == 0x01000000 : return f"@android:0x{data:08X} " else : return f"@0x{data:08X} " elif data_type == self .TYPE_ATTRIBUTE: return f"?0x{data:08X} " elif data_type == self .TYPE_INT_BOOLEAN: return "true" if data != 0 else "false" elif data_type == self .TYPE_INT_DEC: return str (data) if data <= 0x7FFFFFFF else str (data - 0x100000000 ) elif data_type == self .TYPE_INT_HEX: return f"0x{data:X} " elif data_type == self .TYPE_FLOAT: return str (struct.unpack('f' , struct.pack('I' , data))[0 ]) elif data_type == self .TYPE_DIMENSION: value = data >> 4 unit_id = data & 0x0F units = {0 : "px" , 1 : "dp" , 2 : "sp" , 3 : "pt" , 4 : "in" , 5 : "mm" } unit = units.get(unit_id, f"?{unit_id} " ) return f"{value} {unit} " elif data_type == self .TYPE_FRACTION: value = data >> 4 return f"{value} %" elif data_type == self .TYPE_DYNAMIC_REFERENCE: return f"@dynamic/0x{data:08X} " elif data_type == self .TYPE_DYNAMIC_ATTRIBUTE: return f"?dynamic/0x{data:08X} " elif self .TYPE_INT_COLOR_ARGB8 <= data_type <= self .TYPE_INT_COLOR_RGB4: if data_type == self .TYPE_INT_COLOR_RGB8: return f"#{data & 0xFFFFFF :06X} " elif data_type == self .TYPE_INT_COLOR_ARGB8: return f"#{data:08X} " else : return f"#0x{data:08X} " else : if raw_idx != 0xFFFFFFFF and raw_idx < len (self .strings): return self .strings[raw_idx] return f"@0x{data:08X} " @staticmethod def _xml_escape (s: str ) -> str : """XML 转义:将特殊字符替换为对应的实体引用。""" return s.replace('&' , '&' ).replace('<' , '<' ) \ .replace('>' , '>' ).replace('"' , '"' ) def _parse_start_element (self, offset: int ) -> int : chunk_size = self ._u32(offset + 4 ) ns_uri = self ._s(self ._u32(offset + 16 )) elem_name = self ._s(self ._u32(offset + 20 )) or "?" attr_start = self ._u16(offset + 24 ) attr_size = self ._u16(offset + 26 ) attr_count = self ._u16(offset + 28 ) if attr_size != 20 : self ._warn(f"unusual attributeSize={attr_size} (expected 20)" , offset) if attr_count > 10000 : self ._warn(f"suspicious attributeCount={attr_count} , capping at 10000" , offset) attr_count = 10000 tag = self ._resolve_full_name(ns_uri, elem_name) attrs = [] attr_base = offset + attr_start for i in range (attr_count): a_off = attr_base + i * attr_size if not self ._bounds_ok(a_off, attr_size): self ._warn(f"attribute[{i} ] truncated at 0x{a_off:X} " , offset) break a_ns_idx = self ._u32(a_off + 0 ) a_name_idx = self ._u32(a_off + 4 ) a_raw_idx = self ._u32(a_off + 8 ) tv_size = self ._u16(a_off + 12 ) tv_res0 = self .data[a_off + 14 ] tv_data_type = self .data[a_off + 15 ] tv_data = self ._u32(a_off + 16 ) a_name = self ._s(a_name_idx) or "?" a_ns = self ._s(a_ns_idx) value = self ._resolve_typed_value(tv_data_type, tv_data, a_raw_idx) if a_ns: full_name = self ._resolve_full_name(a_ns, a_name) else : full_name = a_name attrs.append(f'{full_name} ="{value} "' ) attr_str = (' ' + ' ' .join(attrs)) if attrs else '' self ._emit(f'<{tag} {attr_str} >' ) self .element_stack.append(tag) self .indent_level += 1 return offset + chunk_size def _parse_end_element (self, offset: int ) -> int : chunk_size = self ._u32(offset + 4 ) ns_uri = self ._s(self ._u32(offset + 16 )) elem_name = self ._s(self ._u32(offset + 20 )) or "?" tag = self ._resolve_full_name(ns_uri, elem_name) self .indent_level -= 1 if self .element_stack and self .element_stack[-1 ] != tag: self ._warn( f"tag mismatch: expected </{self.element_stack[-1 ]} >, got </{tag} >" , offset) for depth in range (len (self .element_stack) - 1 , -1 , -1 ): if self .element_stack[depth] == tag: self .indent_level -= (len (self .element_stack) - 1 - depth) self .element_stack = self .element_stack[:depth] break if self .element_stack and self .element_stack[-1 ] == tag: self .element_stack.pop() self ._emit(f'</{tag} >' ) return offset + chunk_size def _safe_skip_chunk (self, offset: int , chunk_type: int ) -> int : """ 当遇到无法解析的 chunk 时,尝试安全跳过。 返回下一个 chunk 的偏移,或 -1 表示无法跳过。 """ try : chunk_size = self ._u32(offset + 4 ) if chunk_size == 0 : self ._warn(f"zero-size chunk type 0x{chunk_type:04X} , skipping 8 bytes" , offset) return offset + 8 if chunk_size > len (self .data) - offset: self ._warn( f"chunk type 0x{chunk_type:04X} size 0x{chunk_size:X} exceeds file bounds, " f"truncating to remaining" , offset) return len (self .data) if chunk_size > 0x100000 : self ._warn( f"suspiciously large chunk type 0x{chunk_type:04X} size 0x{chunk_size:X} " , offset) return offset + 8 return offset + chunk_size except (IndexError, struct.error): self ._warn(f"cannot read chunk at 0x{offset:X} , aborting parse" , offset) return -1 def parse (self ) -> str : pos = 0 if not self ._bounds_ok(pos, 8 ): raise AXMLParseError("file too small for XML root chunk" , pos) if self ._u16(pos) != self .RES_XML_TYPE: self ._warn( f"expected RES_XML_TYPE (0x0003) at root, got 0x{self._u16(pos):04X} " , pos) pos += self ._u16(pos + 2 ) pos = self ._parse_string_pool(pos) pos = self ._parse_resource_map(pos) file_size = len (self .data) consecutive_skips = 0 MAX_CONSECUTIVE_SKIPS = 10 while pos < file_size: if not self ._bounds_ok(pos, 8 ): self ._warn("truncated chunk header, stopping" , pos) break chunk_type = self ._u16(pos) chunk_size = self ._u32(pos + 4 ) if chunk_type == self .RES_NULL_TYPE or chunk_size == 0 : break try : if chunk_type == self .RES_XML_START_NAMESPACE_TYPE: pos = self ._parse_start_namespace(pos) elif chunk_type == self .RES_XML_END_NAMESPACE_TYPE: pos = self ._parse_end_namespace(pos) elif chunk_type == self .RES_XML_START_ELEMENT_TYPE: pos = self ._parse_start_element(pos) elif chunk_type == self .RES_XML_END_ELEMENT_TYPE: pos = self ._parse_end_element(pos) elif chunk_type == self .RES_XML_CDATA_TYPE: pos += chunk_size else : self ._warn(f"unknown chunk type 0x{chunk_type:04X} at 0x{pos:X} " , pos) new_pos = self ._safe_skip_chunk(pos, chunk_type) if new_pos < 0 : break pos = new_pos consecutive_skips += 1 if consecutive_skips > MAX_CONSECUTIVE_SKIPS: self ._warn("too many consecutive unknown chunks, stopping" , pos) break continue consecutive_skips = 0 except (AXMLParseError, struct.error, IndexError) as e: self ._warn(f"parse error: {e} " , pos) if self .strict: raise new_pos = self ._safe_skip_chunk(pos, chunk_type) if new_pos < 0 : break pos = new_pos consecutive_skips += 1 if consecutive_skips > MAX_CONSECUTIVE_SKIPS: self ._warn("too many consecutive parse errors, stopping" , pos) break while self .element_stack: self .indent_level -= 1 tag = self .element_stack.pop() self ._emit(f'</{tag} >' ) while self .ns_stack: prefix, _ = self .ns_stack.pop() if prefix: self ._emit(f'<!-- xmlns:{prefix} auto-closed -->' ) return '\n' .join(self .output_lines) @property def warnings (self ) -> List [str ]: """返回解析过程中收集的所有警告。""" return list (self ._warnings) if __name__ == '__main__' : if len (sys.argv) < 2 : print (f"Usage: {sys.argv[0 ]} <AndroidManifest.xml> [--strict]" ) sys.exit(1 ) strict = '--strict' in sys.argv with open (sys.argv[1 ], 'rb' ) as f: data = f.read() parser = AXMLParser(data, strict=strict) try : result = parser.parse() print (result) except AXMLParseError as e: print (f"FATAL: {e} " , file=sys.stderr) sys.exit(1 ) if parser.warnings: print (f"\n# {len (parser.warnings)} warning(s):" , file=sys.stderr) for w in parser.warnings: print (f"# {w} " , file=sys.stderr)
10.1 核心架构 Chunk 链式遍历 :主循环以 pos += chunk_size 推进,每个 Chunk 的 size 字段指明了下一个 Chunk 的起始位置。对于无法识别的 chunk type,_safe_skip_chunk() 通过读取 size 字段安全跳过——这是 AXML 格式自描述的体现,使得解析器即使不理解某个 chunk 的内容,也能正确导航到下一个 chunk。
命名空间上下文重建 :ns_stack 在遇到 START_NAMESPACE 时入栈、END_NAMESPACE 时出栈。_lookup_ns_prefix() 从栈顶向栈底反向查找匹配的 URI,_resolve_full_name() 将这种查找封装为统一的名称解析入口。增强版还处理了以下边缘情况:
无前缀的默认命名空间 :xmlns="..."(prefix 为空字符串),输出 xmlns="..." 而不生成前缀前缀。
命名空间乱序闭合 :_parse_end_namespace() 不再假定命名空间按声明逆序闭合,而是从栈中搜索匹配的 prefix 后弹出。
Android 命名空间推断 :如果 URI 包含 "schemas.android.com" 但在栈中找不到匹配的前缀,_lookup_ns_prefix() 自动返回 "android",因为 Manifest 中几乎所有带命名空间的属性都属于 android 命名空间。
属性值的双重编码处理 :rawValue 和 typedValue 同时存在。当 dataType 是已知类型时,_resolve_typed_value 直接根据 data 字段还原值;当 dataType 未知时,回退到 rawValue 字符串——这保证了向前兼容性。增强版还支持 TYPE_DYNAMIC_REFERENCE、TYPE_DYNAMIC_ATTRIBUTE、颜色类型(TYPE_INT_COLOR_*)和 TYPE_FRACTION。
10.2 二分查找与字符串池操作 当字符串池设置了 SORTED_FLAG 时,binary_search_string() 方法使用经典二分查找算法在 O(log n) 时间内定位目标字符串。find_or_add_string() 是面向调用者的统一接口——自动根据 SORTED_FLAG 的存在与否选择二分查找或线性扫描。
使用场景 :在 AXML 修改工具中,当你需要向 Manifest 插入一个新属性(如 android:debuggable="true"),首先需要确认 "debuggable" 和 "true" 是否已存在于字符串池中。find_or_add_string() 可以快速完成这一查重步骤。如果字符串已存在,你只需复用现有的池索引;如果不存在,才需要进行代价较高的池扩展操作(在字符串数据区末尾追加、更新偏移表、递增 stringCount 并级联更新所有受影响 chunk 的 size)。
排序不变性 :在已排序的池中插入新字符串后,如有必要保持 SORTED_FLAG,必须将新字符串插入到正确的排序位置并移动所有后续字符串的偏移量。如果工具的复杂度不允许维护排序,可以通过清除 SORTED_FLAG 将池降级为线性查找模式,AOSP 解析器可以正确处理这种情况。
10.3 畸形 Chunk 处理与错误恢复 增强版解析器在以下场景中实现优雅降级,而非直接崩溃:
1. Chunk size 边界检查 :_safe_skip_chunk() 对每个 chunk 的 size 执行三重验证:
size == 0:说明 chunk 已损坏,跳过 8 字节(最小 header)后继续。
size 超出文件范围:截断到文件末尾。
size > 1 MB:单个 chunk 不应超过此上限(合法 Manifest 中最大的 chunk 通常是 StringPool,典型值 500 bytes ~ 10 KB),超出则保守地仅跳过 header。
2. 字符串池偏移验证 :_parse_string_pool() 验证 stringsStart 不超出 chunk 范围,并对每个 stringOffsets[i] 检查其指向的数据在数据区范围内。越界偏移会被替换为空字符串占位并记录警告。
3. 属性计数合理性检查 :_parse_start_element() 对 attributeCount > 10000 的情况进行上限裁剪(合法 Manifest 中属性数通常在 0~50 之间)。
4. 标签不匹配的栈修正 :当 _parse_end_element() 发现闭合标签与栈顶不匹配时(可能由畸形 AXML 导致),非严格模式下会搜索栈中匹配的标签并弹出中间所有不匹配的层级,同时修正缩进级别。
5. 元素/命名空间自动补全 :主循环结束后,parse() 方法自动为所有未闭合的元素生成对应的结束标签,为所有未闭合的命名空间添加注释标记。这确保即使解析到中途出错,输出的 XML 文本在语法上依然是良构的。
6. 连续跳过限制 :如果连续跳过超过 10 个 chunk(MAX_CONSECUTIVE_SKIPS = 10),解析器判定文件可能已严重损坏,安全退出循环。
10.4 命名空间前缀解析的增强 在原始解析器中,_lookup_ns_prefix() 仅在 ns_stack 中查找精确的 URI 匹配。增强版新增了以下能力:
Android 命名空间自动识别 :Manifest 中绝大多数带命名空间的属性都属于 http://schemas.android.com/apk/res/android。即使由于某种原因(如 StringPool 偏移错误)导致此 URI 不在 ns_stack 中,_lookup_ns_prefix() 也会通过 URI 内容模式识别(检测 "schemas.android.com" 子串)并返回 "android" 前缀。
默认命名空间支持 :AOSP XML 支持无前缀的默认命名空间声明(xmlns="uri")。增强版解析器在 _parse_start_namespace() 中检测空前缀,并在输出中生成 xmlns="uri" 格式(不带前缀名称)。
ns_stack 的健壮弹出 :原解析器的 _parse_end_namespace() 仅在栈顶 prefix 匹配时才弹出——这在命名空间声明嵌套不正确时会留下栈残留。增强版从栈中搜索匹配的 prefix 并弹出其上的所有条目,容忍了命名空间声明顺序异常。
_resolve_full_name() 统一接口 :所有标签名和属性名的命名空间解析都通过 _resolve_full_name(ns_uri, local_name) 这一统一入口,避免了分散的 if/else 判断,代码更清晰且不易遗漏边缘情况。
在 APK 逆向与重打包场景中,直接在 AXML 二进制层面进行修改有三项核心优势:(1) 不需要完整的反编译/重编译工具链;(2) 不会产生 apktool 重打包的特征痕迹;(3) 可以针对性地修改特定属性而不扰动整个文件结构。
11.1 添加 debuggable 标志 场景 :需要将 <application> 标签的 android:debuggable 属性设为 true。
方案 A —— 属性已存在时的原地修改 :
解析 AXML → 在 <application> 元素的属性数组中定位 name="debuggable" → 修改 Res_value:设置 dataType = 0x12(TYPE_INT_BOOLEAN),data = 0xFFFFFFFF(true)。
def set_debuggable_inplace (data: bytearray , axml ): """如果 debuggable 属性已存在,原地修改为 true。""" for elem_off in axml.find_elements('application' ): for attr in axml.get_attributes(elem_off): if axml.get_string(attr['name' ]) == 'debuggable' : struct.pack_into('<I' , data, attr['offset' ] + 16 , 0xFFFFFFFF ) data[attr['offset' ] + 15 ] = 0x12 return True return False
这个方案不需要修改任何 chunk 的 size 或偏移量——20 字节的属性在修改前后大小不变——因此是最安全的 AXML 修改方式。
方案 B —— 属性不存在时的插入 :
将 “debuggable” 和 “true” 追加到字符串池数据区末尾。
在字符串偏移表中添加新条目,stringCount += 1(或 += 2 如果 “debuggable” 也不在池中)。
在 <application> 元素的属性数组末尾追加一个新的 20 字节属性条目。
attributeCount += 1。
更新 <application> 元素 Chunk 的 size。
更新外层 RES_XML_TYPE 容器 Chunk 的 size。
方案 B 的连锁调整量级为 O(n)(需要更新所有受影响 Chunk 的 size),手工操作极易出错。这也是为什么 apktool 选择完整反编译为文本再重新编译的根本原因。
11.2 删除权限声明 场景 :从 Manifest 中移除某个 <uses-permission> 元素。
解析 AXML,找到目标元素(通过 <uses-permission> 的 name 属性匹配权限名称)。
获取该 StartElement 和对应 EndElement 在文件中的偏移与大小。
从字节数组中删除这两个 Chunk 的全部字节。
更新外层容器(RES_XML_TYPE 的 size 减去被删除的总字节数)。
关键注意 :如果目标元素是 Manifest 的第一个子元素,删除后不需要做任何偏移调整。如果目标元素之后还有同层级元素,删除操作相当于将后续内容前移——不需要修改任何字符串索引或资源 ID。
11.3 修改包名 场景 :将 <manifest package="com.old.app"> 中的包名从 "com.old.app" 改为 "com.new.app"。
同长度原地替换 (最安全且推荐):
def replace_package_name (data: bytearray , old_name: str , new_name: str ): if len (old_name.encode('utf-8' )) != len (new_name.encode('utf-8' )): raise ValueError("新旧包名的 UTF-8 字节长度必须相同" ) old_bytes = old_name.encode('utf-8' ) new_bytes = new_name.encode('utf-8' ) idx = data.find(old_bytes) if idx == -1 : raise ValueError(f"未找到: {old_name} " ) data[idx:idx + len (old_bytes)] = new_bytes
不同长度替换 (需要调整整个文件):
在字符串池末尾追加新包名的字符串数据。
在偏移表中添加新条目(stringCount += 1)。
更新受影响属性(package 属性)的 rawValue 和 typedValue.data,指向新字符串索引。
级联更新受影响 chunk 的 size 和字符串池的 stringsStart、chunkSize。
十二、AXML 与 resources.arsc 的关系 AndroidManifest.xml(AXML)和 resources.arsc(资源表)是 APK 资源系统中相互依赖的两大基石。理解它们的交互是理解 Android 资源系统的关键。
12.1 编译阶段:从文本 XML 到二进制 AAPT2 在 link 阶段执行的核心流程:
输入: res/values/strings.xml: <string name="app_name">我的应用</string> AndroidManifest.xml: <application android:label="@string/app_name" ...> AAPT2 link 处理: 1. 读取 strings.xml, 分配资源 ID: "app_name" → 0x7F04001A 2. 解析 Manifest 中的 "@string/app_name" 3. 在资源符号表中查找 "app_name" → 获取资源 ID = 0x7F04001A 4. 在 AXML 属性中编码: rawValue = -1 (无原始字符串) typedValue.dataType = TYPE_REFERENCE (0x01) typedValue.data = 0x7F04001A 5. 将字符串 "app_name" 和资源 ID 0x7F04001A 写入 ResourceMap 6. 输出最终二进制 AndroidManifest.xml
12.2 运行时:资源引用解析 应用启动 → PackageManagerService 调用 AssetManager 加载 AXML → ResXMLParser 解析 <application> 元素的 label 属性 → 读取 typedValue: {dataType=0x01 (REFERENCE), data=0x7F04001A} → AssetManager 在已加载的 ResTable (来自 resources.arsc) 中查找 0x7F04001A: Package 0x7F (本应用) Type 0x04 (string) Entry 0x001A (第 26 个条目) → 获取实际字符串: "我的应用"
12.3 信息对偶性 AXML 中的属性引用和 resources.arsc 中的资源定义构成了信息对偶:
AXML (AndroidManifest.xml)
resources.arsc
android:label="@string/app_name"
string/app_name → "我的应用"
android:theme="@style/AppTheme"
style/AppTheme → {parent=..., items=[...]}
android:icon="@mipmap/ic_launcher"
mipmap/ic_launcher → 指向具体图片资源
如果在 APK 重打包时修改了 resources.arsc 中的资源条目但没有同步更新 AXML 中的资源 ID 引用,运行时将产生 Resources.NotFoundException。反之,如果只修改了 AXML 中的资源 ID 但没有在 resources.arsc 中添加对应的资源定义,同样会导致运行时错误。
十三、PackageManager 解析 Manifest 的完整流程 理解 PackageManagerService(PMS)如何从 APK 中加载和解析 AndroidManifest.xml,对于深入掌握 Android 包管理机制至关重要。以下是从 APK 安装到解析数据缓存的完整六阶段流程。
13.1 阶段一:打开 APK 作为 ZIP 文件 当用户安装 APK 或系统在启动时扫描已安装包时,PMS 通过 PackageParser(Android 9+ 中为 PackageParser2)开始解析:
File apkFile = new File (apkPath);try (ZipFile zip = new ZipFile (apkFile)) { ZipEntry manifestEntry = zip.getEntry("AndroidManifest.xml" ); InputStream is = zip.getInputStream(manifestEntry); byte [] manifestBytes = readAllBytes(is); }
PMS 不会将整个 APK 解压到磁盘。它仅通过 ZIP 随机访问接口读取 AndroidManifest.xml 条目。ZIP 的 Central Directory 记录了每个条目的偏移和大小,因此 PMS 可以在 O(1) 时间内定位到 Manifest 条目的压缩数据。
13.2 阶段二:ResXMLParser 初始化 读取到的 AXML 字节数组被传入底层 C++ 解析器 ResXMLTree(ResourceTypes.cpp 中实现,Java 层通过 android.content.res.AssetManager 桥接):
status_t ResXMLTree::setTo (const void * data, size_t size, bool copyData) { const ResChunk_header* header = (const ResChunk_header*)data; if (header->type != RES_XML_TYPE) { return BAD_TYPE; } mData = (const uint8_t *)data; mSize = size; mCurNode = mData + header->headerSize; return NO_ERROR; }
13.3 阶段三:解析 StringPool 和 ResourceMap 这是 PMS 解析流程中最先执行的两步,因为后续所有 Chunk 都依赖字符串池和资源映射:
ResXMLTree::setTo() → ResXMLTree::validateNode(mCurNode) → 读取 chunk type = 0x0001 (RES_STRING_POOL_TYPE) → 调用 ResStringPool::setTo() 加载完整字符串池到内存 → 记录 stringCount, flags (SORTED_FLAG / UTF8_FLAG) → 建立内部缓存:偏移表数组 + 惰性解码(stringAt() 在首次请求时才解码) → 读取下一个 chunk type = 0x0180 (RES_XML_RESOURCE_MAP_TYPE) → 调用 ResXMLTree::setResourceMap() 建立 resourceId[] 数组
关键优化 :ResStringPool::stringAt() 使用惰性解码——字符串仅在第一次被 stringAt(idx) 调用时才从 UTF-8/UTF-16 原始字节解码为 UTF-16(Android 运行时的内部字符串表示)。对于大型 Manifest 可能有上百个字符串,但 PMS 在扫描阶段只关心少数特定的标签名和属性名(如 "manifest"、"application"、"activity"、"name"),因此实际解码的字符串数量远小于 stringCount。
13.4 阶段四:遍历 START_ELEMENT 构建 PackageInfo PMS 调用 ResXMLParser.nextNode() 进行单次遍历。每遇到 START_ELEMENT (0x0102):
1. 读取标签名(如 "manifest", "application", "activity", "service"...) 2. 根据标签名分派到不同的解析分支: - <manifest> → parsePackage() 提取 package name, versionCode, versionName - <uses-permission> → parsePermission() 添加到 requestedPermissions 列表 - <application> → parseApplication() 提取 label, icon, theme, debuggable - <activity> → parseActivity() 提取 name, exported, intent-filter 等 - <service> → parseService() - <receiver> → parseReceiver() - <provider> → parseProvider() 3. 对每个属性,通过 idIndex 快速定位 "android:id" 等关键属性(O(1) 而非遍历数组) 4. 将解析结果填充到 Package 对象(Java 层 PackageParser.Package)
嵌套处理 :当 PMS 在 <activity> 内部遇到 <intent-filter> 的 START_ELEMENT 时,它会递归调用 intent-filter 的解析逻辑。nextNode() 的调用天然向前推进 pos 指针——PMS 不需要”跳过”无关 chunk,它只是依次处理遇到的每一个 chunk,对不关心的标签直接忽略(通过 pos += chunk.size 跳过)。
13.5 阶段五:缓存到 /data/system/packages.xml 解析完成后,PMS 将生成的 Package 对象序列化到系统分区:
/data/system/packages.xml — 所有已安装包的 Manifest 关键信息 /data/system/packages.list — 包名 → UID → dataDir 的简单映射
packages.xml 是一个文本 XML 文件(注意:不是 AXML,是纯文本 XML),内容包含:
每个包的 <package> 标签,含 name, codePath, version, userId 等属性
每个组件的 <activity> / <service> / <receiver> / <provider> 条目
权限信息 <perms> / <granted-permissions>
packages.xml 的写入发生在 PMS 的 commitPackageSettings() 方法中。写入后 PMS 同时更新内存中的 mPackages HashMap(key = 包名, value = PackageSettings),使得后续查询直接从内存缓存命中。
13.6 阶段六:下次启动从缓存恢复 在设备重启后,PMS 的 scanDirLI() 首先检查 packages.xml:
void readLPw () { XmlPullParser parser = Xml.newPullParser(); parser.setInput(new FileInputStream (packagesXml), "utf-8" ); }
性能数据 :对于安装了 200 个应用的典型设备,packages.xml 的体积约为 2-5 MB。从 packages.xml 文本 XML 恢复所有包信息耗时约 200-500ms,而重新从每个 APK 中解析 AXML 将耗时 3-5 秒以上(需遍历 200+ 个 APK 的 ZIP 条目并解析各自的 AXML)。因此这一缓存机制对 Android 的冷启动性能至关重要。
缓存失效条件 :以下情况 PMS 会跳过 packages.xml 缓存,重新从 APK 解析 AXML:
/data/system/packages.xml 文件不存在或被标记为 “dirty”(脏标志)
系统升级(Android 版本变更)导致 packages.xml 的 schema 版本不匹配
扫描到新的 APK(新安装的应用),其信息尚未写入缓存
packages.xml 解析失败(损坏的 XML)——此时 PMS 回退到逐个扫描 APK
13.7 优化总结 整个流程中,PMS 运用了以下性能策略:
阶段
优化策略
效果
ZIP 读取
仅读取 Manifest 条目,不解压全包
I/O 量 < 100KB (Manifest 典型大小)
StringPool
惰性解码(lazy decode)
仅解码实际访问的字符串
属性定位
idIndex / classIndex / styleIndex 快速通道
O(1) 定位关键属性
Chunk 遍历
pos += chunk.size 直接跳转
无分支扫描,CPU 缓存友好
第一次安装后
序列化到 packages.xml
后续启动无需重新解析 AXML
内存缓存
mPackages HashMap
O(1) 按包名查询
十四、面试常考问题 Q1:PackageManagerService 如何高效解析 AndroidManifest.xml?具体用到了 AXML 的哪些结构特性?
PackageManagerService 使用 C++ 层的 ResXMLParser(ResourceTypes.cpp)对 AXML 进行单次遍历解析。关键优化包括:
Chunk 跳跃式遍历 :利用每个 Chunk 的 size 字段直接跳转到下一个 Chunk,无需扫描无关数据。
字符串池去重 :属性名匹配从字符串比较(O(n) 逐字符)降级为整数比较(O(1)),因为所有标签名和属性名都通过字符串池索引引用。
idIndex / classIndex / styleIndex 快速通道 :这三个字段允许 PackageManagerService 以 O(1) 定位 android:id 等关键属性,无需遍历全部属性数组。
属性类型的预编译 :TYPE_INT_BOOLEAN、TYPE_REFERENCE 等类型化值在编译期已确定,运行时无需再做字符串到类型的转换。
解析结果缓存 :解析后的 Package 对象被序列化到 /data/system/packages.xml,后续查询直接读取缓存。
Q2:AXML 中的 ResourceMap(0x0180)和 TypedValue 中的 TYPE_REFERENCE 有什么区别和联系?
两者都涉及资源 ID,但用途不同:
维度
ResourceMap (0x0180)
TypedValue TYPE_REFERENCE
作用对象
属性名
属性值
功能
将属性名字符串索引映射到资源 ID
将资源引用(如 @string/app_name)存为资源 ID
数据位置
独立 Chunk,位于字符串池之后
嵌入在属性条目的 Res_value 中
查询方向
属性名字符串 → 资源 ID
属性值 → 资源 ID → resources.arsc 中的实际资源
例如对于 android:label="@string/app_name":
ResourceMap 将属性名 “label”(字符串索引 N)映射到 0x01010001(即 android:attr/label),告诉 Framework 这个属性是什么类型、接受什么格式的值。
TypedValue 将属性值 @string/app_name 替换为 0x7F04001A,告诉 Framework 去 resources.arsc 中查找该资源 ID 对应的实际字符串。
Q3:如果要在不依赖 apktool 的情况下将一个 release APK 的 Manifest 中的 debuggable 改为 true,完整的技术路径是什么?
(1) 从 APK(ZIP 格式)中提取 AndroidManifest.xml。 (2) 解析 AXML:读取 StringPool、ResourceMap、遍历所有 StartElement。 (3) 定位 <application> 元素——通过其 name 字符串索引匹配 “application” 字符串。 (4) 遍历该元素的属性数组,查找 name 为 “debuggable” 的属性。 (5) 如果找到:原地修改 typedValue.dataType = TYPE_INT_BOOLEAN (0x12),data = 0xFFFFFFFF。如果未找到:执行属性插入(方案 B,需要更新所有级联字段)。 (6) 将修改后的字节数组写回 APK 的 ZIP 条目(需要同步更新 ZIP 的 Central Directory 中的 CRC32 和 compressed/uncompressed size)。 (7) 使用 zipalign 对修改后的 APK 进行 4 字节对齐。 (8) 使用 apksigner 重新签名。
Q4:AXML 字符串池的 UTF-8 编码与 DEX 文件的 MUTF-8 编码有何异同?为什么它们使用不同的编码?
相同点 :两者都使用基于 UTF-8 的变长编码,对于 BMP 内的字符(U+0000 ~ U+FFFF,不含 U+0000 本身)编码结果一致。
不同点有两处 :
NULL 字符(U+0000)的处理 :
DEX 的 MUTF-8 将 NULL 编码为两字节序列 0xC0 0x80(Java Modified UTF-8 约定),以确保 C 字符串的 null 终止符不会出现在字符串中间。
AXML StringPool 使用标准 UTF-8,NULL 编码为 0x00。但这在 AXML 中不成问题,因为 StringPool 通过显式的字节长度字段(byte_len)而非 null 终止符来确定字符串边界。
Surrogate pair(代理对)的处理 :
AXML StringPool 使用 Android libutils 的 utf16_to_utf8() 进行编码转换(标准 UTF-8),对于合法的代理对会先解码为 Unicode 码点(如 U+10000),再编码为标准的 4 字节 UTF-8 序列(如 F0 90 80 80)。
DEX 的 MUTF-8 始终将每个代理码元独立编码为 3 字节序列(如 ED A0 80 ED B0 80),无论是否成对。
对于未配对的孤立代理码元,两者均将其编码为 3 字节序列,行为一致。
原因 :DEX 格式继承自 Java 生态(dx/d8 由 Java/Kotlin 实现),遵循 JVM 规范中的 Modified UTF-8 约定。AXML 格式由 AAPT2(C++ 实现)生成,使用 Android 原生层(libandroidfw)的 String8/String16 类型——这些类型基于标准 UTF-8/UTF-16。由于 Manifest XML 中极少出现 BMP 以外的字符,这一差异在实际逆向分析中几乎不会遇到。
Q5:如何从 AXML 的二进制特征判断一个 APK 是否被 apktool 重新打包过?
(1) 字符串池排序检查 :AAPT2 原生生成的 StringPool 设置了 SORTED_FLAG 并按 Unicode 排序。apktool 重新编译后可能不设置 SORTED_FLAG 或排序结果与 AAPT2 不一致。 (2) 属性顺序检查 :AAPT2 按源码顺序保持属性排列。apktool 重新编译后,属性顺序可能因内部实现的差异而异(如按属性名字母排序)。 (3) idIndex / classIndex / styleIndex 检查 :AAPT2 原生生成时精确设置了这三个快速索引字段。如果 apktool 重新编译时没有设置它们(为 0xFFFF),则说明是被重新打包的。 (4) Chunk header 字段一致性 :headerSize 应符合各 Chunk 类型的标准值(StringPool=28, XML nodes=16, ResourceMap=8)。异常的 headerSize 可能表明第三方工具重写。 (5) namespace 声明顺序 :AAPT2 严格按 XML 源码中的声明顺序生成 namespace chunks。apktool 可能改变此顺序。 (6) ResourceMap 的存在性 :AAPT2 在较新的版本中总是生成 ResourceMap。缺失 ResourceMap 的 AXML 可能是较老版本 AAPT 生成或被特定工具剥离。
十五、AXML 安全性分析 AXML 是一种二进制格式,由 PMS(system_server 进程,以 system 权限运行)在系统服务进程中解析。任何解析器都可能成为攻击面——恶意构造的 AXML 数据可能触发解析器中的 bug,导致崩溃、内存泄漏或代码执行。
15.1 攻击面建模 AXML 解析器的核心攻击面集中在以下区域:
┌──────────────────────────┐ │ AXML 数据输入 │ │ (来自 APK 的 ZIP 条目) │ └──────────┬───────────────┘ │ ┌────────────────┼────────────────┐ │ │ │ ▼ ▼ ▼ ┌──────────────┐ ┌──────────────┐ ┌──────────────┐ │ ResChunk_ │ │ ResStringPool│ │ ResXMLTree_ │ │ header 解析 │ │ ::setTo() │ │ element 解析 │ │ (size 校验) │ │ (偏移验证) │ │ (属性计数验证) │ └──────────────┘ └──────────────┘ └──────────────┘
15.2 攻击向量与防御机制 向量一:Chunk size 溢出
攻击者可以构造一个 AXML 文件,其中某个 Chunk 的 size 字段设置为一个极大的值,诱使解析器尝试读取超出文件边界的内存:
恶意数据: type=0x0102, size=0xFFFFFFFF (声称 chunk 有 4GB) ↑ 实际文件: 仅 2000 字节
AOSP 防御 :ResXMLTree::validateNode() 在每次读取 chunk 后执行边界检查:
status_t ResXMLTree::validateNode (const ResChunk_header* node) const { if ((const uint8_t *)node < mData || (const uint8_t *)node + sizeof (ResChunk_header) > mData + mSize) { return BAD_TYPE; } if ((const uint8_t *)node + dtohl (node->size) > mData + mSize) { return BAD_TYPE; } if (dtohs (node->headerSize) < sizeof (ResChunk_header)) { return BAD_TYPE; } return NO_ERROR; }
这意味着任何声称 size 超出文件实际范围的 chunk 都会被直接拒绝。
向量二:无效的字符串池偏移
恶意 AXML 可以将某个 stringOffsets[i] 设置为远大于 stringsStart 的值(或指向字符串数据区之外),使得 stringAt(i) 访问越界内存:
stringOffsets[3] = 0xFFFF0000 (指向 4GB 偏移处) stringsStart = 0x7C 实际数据区大小 = 200 bytes
AOSP 防御 :ResStringPool::stringAt() 在解码字符串前验证偏移量:
const char * ResStringPool::stringAt (size_t idx, size_t * outLen) const { if (idx >= mHeader->stringCount) return nullptr ; const uint32_t off = mEntries[idx]; if (off >= mStringPoolSize - 2 ) return nullptr ; }
向量三:属性计数膨胀
StartElement chunk 的 attributeCount 字段是 uint16_t(最大 65535)。如果攻击者将此字段设为极大值而 chunk 实际很小,解析器将尝试读取超出 chunk 边界的属性数据:
attributeCount = 60000 实际属性数量 = 2 chunk size = 96 bytes (36 header + 3 * 20 attributes)
AOSP 防御 :解析器通过属性总数与 chunk size 的交叉验证来防御:
uint32_t attrDataSize = attributeCount * sizeof (ResXMLTree_attrExt); if (attrStart + attrDataSize > chunkSize) { return BAD_TYPE; }
向量四:循环命名空间嵌套
恶意 AXML 可以构造极深的 START_NAMESPACE / END_NAMESPACE 嵌套(类似 XML Bomb),耗尽解析器的命名空间栈空间:
START_NAMESPACE → START_NAMESPACE → ... → (10000 层深度)
AOSP 防御 :AOSP 命名空间栈使用 std::vector,内存由堆分配,理论上不会被固定大小的栈限制。但是极深的嵌套最终会因内存耗尽而失败(OOM),而非导致缓冲区溢出。PMS 进程受 Android 的 ulimit 和 cgroup 内存限制保护,OOM 会被 Linux 内核的 OOM killer 处理。
15.3 历史 CVE 参考 与 AXML 解析相关的已知安全漏洞主要影响了较旧的 Android 版本(已在后续版本中修复):
CVE-2016-0843 (Android 4.4.4 - 6.0.1):ResourceTypes.cpp 中的 ResStringPool::stringAt() 在计算字符串偏移时存在整数溢出漏洞。通过构造特定的 stringOffsets[] 值,攻击者可以触发越界读取,导致 system_server 进程泄漏堆内存内容。修复方法是在偏移计算中添加整数溢出检查。
CVE-2017-13286 (Android 8.0 - 8.1):ResXMLTree::validateNode() 中对嵌套元素深度检查不充分。通过构造深度超过 256 层嵌套的 AXML,攻击者可以触发 PMS 中的栈溢出,导致 system_server 崩溃(拒绝服务)。修复方法是在 nextNode() 中增加了嵌套深度计数器,超过阈值(通常为 256)时拒绝继续解析。
CVE-2019-2048 (Android 9):ResStringPool 在处理 UTF-8 变长编码长度时,对 char_len 和 byte_len 的复合编码缺少上限检查。恶意构造的极端长度值可触发内存分配失败或 OOM。修复方法是在读取长度后验证其不超过合理上限。
注意 :以上 CVE 编号需要通过 AOSP 安全公告交叉验证。由于 Android 安全公告使用独立的漏洞编号系统(如 A-xxxxxx),上述 CVE 映射关系基于公开安全研究的整理,具体细节请以 Android Security Bulletins 为准。
15.4 安全开发建议 如果你在开发处理 AXML 的解析器(无论是独立工具还是系统组件),以下实践可以显著降低安全风险:
总是验证 chunk size 不超出输入数据范围 。这是最基本也是最重要的防御。
使用 saturating arithmetic(饱和运算)或 checked arithmetic(检查运算)处理所有偏移计算 。不要使用未检查的 + / * 操作。
对 stringCount、attributeCount、嵌套深度设置合理的上限 。即使 spec 允许 65535 个属性,实际 Manifest 中从未超过数百个——超过 10000 即可视为异常并拒绝。
在释放解析器的上下文前清零敏感缓冲区 。字符串池可能包含包名、签名信息等敏感数据。
对无法识别的 chunk type 不要直接报错退出,而是跳过 (pos += size)。这样可以在未来 AOSP 引入新 chunk 类型时保持向前兼容,同时防止未知类型的数据触发未定义行为。
十六、跨 APK 的 Manifest 对比技巧 在恶意软件分析、应用重打包检测和安全审计中,对比两个 APK 的 Manifest 差异是一项核心技能。AXML 的二进制特性为 diff 操作提供了独特的机会和挑战。
16.1 对比场景
场景
目标
技术路径
加壳检测
识别加壳工具对 Manifest 的修改
对比加壳前后 AXML 的 chunk 结构和属性变化
恶意组件注入
发现额外添加的 service/receiver
对比原始 APK 与恶意样本的组件列表差异
权限升级
发现新增的 uses-permission 声明
对比权限列表的增量
debuggable 注入
检测是否被注入 debuggable=true
检查 application 元素的 debuggable 属性值
重打包检测
判断 APK 是否被 apktool 等工具重编译
对比 chunk 结构、字符串池排序、ResourceMap 特征
16.2 二进制 Diff 方法 方法一:结构化属性 Diff(推荐)
不对比字节级差异,而是先解析 AXML 为结构化的标签-属性树,然后对比树节点:
def diff_manifests (bytes_a: bytes , bytes_b: bytes ) -> dict : """对比两个 AXML 的结构化差异。返回差异字典。""" parser_a = AXMLParser(bytes_a) parser_b = AXMLParser(bytes_b) tree_a = parser_a.parse_to_tree() tree_b = parser_b.parse_to_tree() diffs = { "added_components" : [], "removed_components" : [], "modified_attributes" : [], "added_permissions" : [], "changed_package" : None , "changed_debuggable" : None , } pkg_a = tree_a.get("package" ) pkg_b = tree_b.get("package" ) if pkg_a != pkg_b: diffs["changed_package" ] = (pkg_a, pkg_b) app_a = tree_a.get("application" , {}) app_b = tree_b.get("application" , {}) dbg_a = app_a.get("attrs" , {}).get("debuggable" ) dbg_b = app_b.get("attrs" , {}).get("debuggable" ) if dbg_a != dbg_b: diffs["changed_debuggable" ] = (dbg_a, dbg_b) for comp_type in ["activity" , "service" , "receiver" , "provider" ]: comps_a = {c["name" ]: c for c in tree_a.get(comp_type, [])} comps_b = {c["name" ]: c for c in tree_b.get(comp_type, [])} for name in comps_b.keys() - comps_a.keys(): diffs["added_components" ].append((comp_type, name, comps_b[name])) for name in comps_a.keys() - comps_b.keys(): diffs["removed_components" ].append((comp_type, name, comps_a[name])) for name in comps_a.keys() & comps_b.keys(): if comps_a[name]["attrs" ] != comps_b[name]["attrs" ]: diffs["modified_attributes" ].append((comp_type, name, comps_a[name]["attrs" ], comps_b[name]["attrs" ])) perms_a = set (tree_a.get("permissions" , [])) perms_b = set (tree_b.get("permissions" , [])) diffs["added_permissions" ] = list (perms_b - perms_a) return diffs
方法二:Chunk 级快速扫描(适合批量检测)
对于大规模 APK 审查场景(如应用商店审核),可以对 AXML 执行轻量级的 Chunk 级扫描,仅提取关键信息而不做完整解析:
def quick_scan_manifest (data: bytes ) -> dict : """快速扫描 AXML,仅提取安全审计关心的关键字段。返回轻量摘要。""" result = { "package" : None , "debuggable" : None , "exported_activities" : [], "hidden_services" : [], "permissions" : set (), "receivers_with_priority" : [], } pos = 0 pos += 8 depth = 0 current_section = None while pos < len (data): typ = struct.unpack_from('<H' , data, pos)[0 ] size = struct.unpack_from('<I' , data, pos + 4 )[0 ] if typ == 0x0102 : name_idx = struct.unpack_from('<I' , data, pos + 20 )[0 ] tag_name = string_pool[name_idx] if tag_name == "service" : attrs = parse_attributes(data, pos) if attrs.get("exported" ) == "true" or has_intent_filter_next(data, pos + size): result["hidden_services" ].append(attrs.get("name" )) elif tag_name == "uses-permission" : attrs = parse_attributes(data, pos) result["permissions" ].add(attrs.get("name" )) elif tag_name == "application" : attrs = parse_attributes(data, pos) result["debuggable" ] = attrs.get("debuggable" , "false" ) depth += 1 elif typ == 0x0103 : depth -= 1 pos += size return result
16.3 检测反取证技术 攻击者可能尝试掩盖他们对 Manifest 的修改。以下是对抗性检测方法:
检测删除的组件 :恶意软件有时会删除原始应用中的某些组件(如不必要的 activity),以减小 APK 体积或移除反篡改逻辑。通过对比 Google Play 上的官方版本和可疑样本的组件列表,可以发现这些删除。
检测属性值类型变化 :攻击者可能在修改属性时不理解 AXML 的 typedValue 机制。例如将 android:exported 从 TYPE_INT_BOOLEAN(data=0 或 0xFFFFFFFF)错误地改为 TYPE_STRING(data=字符串池索引)。这种类型不一致是重打包的有力证据:
def detect_type_mismatch (attr_name: str , typed_value ) -> bool : """检测属性值类型是否与预期一致。""" EXPECTED_TYPES = { "exported" : 0x12 , "debuggable" : 0x12 , "enabled" : 0x12 , "versionCode" : 0x10 , "priority" : 0x10 , "label" : 0x01 , "icon" : 0x01 , "name" : 0x03 , "permission" : 0x03 , } expected = EXPECTED_TYPES.get(attr_name) if expected is not None and typed_value.dataType != expected: if attr_name in ("permission" ,) and typed_value.dataType in (0x01 , 0x00 ): return True return False return True
检测字符串池异常 :对比原始 APK 与可疑样本的 StringPool:
stringCount 增加但无对应的新组件/权限声明 → 可能是填充垃圾字符串以混淆分析工具
SORTED_FLAG 被清除但字符串仍有序 → 可能是工具修改后未重新设置标志
字符串池中存在孤立条目(无任何 chunk 引用该索引)→ 可能是从原始 APK 中残留的字符串
16.4 实践示例:检测注入的隐藏 Service 以下是一个完整的检测脚本,用于发现 APK 中可能被恶意注入的隐藏服务:
def find_hidden_services (axml_bytes: bytes ) -> list : """ 扫描 AXML,检测可能被恶意注入的隐藏 service。 判断标准: 1. 声明了 exported=true 但没有 intent-filter 的 service 2. 有明确的 permission 保护但 permission 本身在 Manifest 中未声明 3. 声明在系统广播接收器(如 BOOT_COMPLETED)之后自动启动的 service """ suspicious = [] declared_permissions = set () for perm in scan_permissions(axml_bytes): declared_permissions.add(perm) for service in scan_services(axml_bytes): reasons = [] if service.get("exported" ) == "true" : if not service.get("has_intent_filter" ): reasons.append("exported=true 但无 intent-filter(任意应用可绑定)" ) perm = service.get("permission" ) if perm and perm not in declared_permissions: reasons.append(f"引用了未声明的权限: {perm} " ) process = service.get("process" ) if process and process.startswith(":" ): pass elif process and process != service.get("application_process" ): reasons.append(f"运行在独立进程中: {process} " ) if reasons: suspicious.append({ "name" : service.get("name" ), "reasons" : reasons }) return suspicious
参考
AOSP: frameworks/base/libs/androidfw/include/androidfw/ResourceTypes.h —— 所有结构体定义(ResChunk_header, ResStringPool_header, ResXMLTree_node, ResXMLTree_element, ResXMLTree_namespaceExt, ResXMLTree_endElementExt, ResXMLTree_cdataExt, ResXMLTree_attrExt, Res_value 及全部枚举常量)
AOSP: frameworks/base/libs/androidfw/ResourceTypes.cpp —— Chunk 解析完整实现(ResXMLParser::nextNode, ResStringPool::stringAt/indexOfString, ResXMLTree::validateNode)
AOSP: frameworks/base/tools/aapt2/ —— AAPT2 编译管线(XmlAction.cpp XML 编译入口, Linker.cpp 资源链接, TableFlattener.cpp 表生成)
AOSP: frameworks/base/tools/aapt/XMLNode.cpp —— 旧版 AAPT XML 节点序列化/反序列化
Androguard: androguard/core/axml.py —— 社区广泛使用的 Python AXML 解析器(ARSCParser, AXMLPrinter, 完整的 chunk 处理逻辑)
Apktool: brut.apktool/apktool-lib/src/main/java/brut/androlib/res/decoder/XmlPullStreamDecoder.java —— Apktool AXML 解码器,展示了从二进制到文本 XML 的完整转换流程
Apktool: brut.apktool/apktool-lib/src/main/java/brut/androlib/res/decoder/AXmlResourceParser.java —— Apktool 的底层 AXML 解析器,模仿 AOSP ResXMLParser 的 Java 实现