目录
  1. 1. 前言
  2. 2. 一、resources.arsc 是什么?
    1. 2.1. 1.1 定位
    2. 2.2. 1.2 解决的问题
  3. 3. 二、整体架构
    1. 3.1. 2.1 Chunk 层级
    2. 3.2. 2.2 所有相关 Chunk 类型
  4. 4. 三、ResourceTable — 根节点
  5. 5. 四、Package Chunk
    1. 5.1. 4.1 结构
    2. 5.2. 4.2 typeStrings — Type 名称表
    3. 5.3. 4.3 keyStrings — 资源名称表
  6. 6. 五、TypeSpec — 配置声明
    1. 6.1. 配置位掩码
  7. 7. 六、Type Chunk — 实际资源数据
    1. 7.1. 6.1 结构
    2. 7.2. 6.2 ResTable_config
    3. 7.3. 6.3 配置匹配规则
    4. 7.4. 6.4 entriesStart 与 offset 数组
  8. 8. 七、ResTable_entry 与 Res_value
    1. 8.1. 7.1 简单的 ResTable_entry
    2. 8.2. 7.2 Res_value
    3. 8.3. 7.3 复杂类型:ResTable_map_entry
  9. 9. 八、资源 ID 编码(0xPPTTEEEE)
  10. 10. 九、配置系统详解
    1. 10.1. 9.1 多 Type chunk 组织
    2. 10.2. 9.2 资源查找流程
    3. 10.3. 9.3 配置标志组合
  11. 11. 十、完整 Python ARSC 解析器
  12. 12. 十一、逆向实战应用
    1. 12.1. 11.1 提取应用所有字符串资源
    2. 12.2. 11.2 资源混淆检测
    3. 12.3. 11.3 资源修复
    4. 12.4. 11.4 反编译对抗
  13. 13. 十二、AAPT2 编译流程详解
    1. 13.1. 12.1 AAPT2 的两阶段架构
    2. 13.2. 12.2 编译阶段:XML → flat
    3. 13.3. 12.3 链接阶段:flat → resources.arsc
      1. 13.3.1. 步骤 1: 收集与去重
      2. 13.3.2. 步骤 2: 资源 ID 分配
      3. 13.3.3. 步骤 3: 构建 StringPool
      4. 13.3.4. 步骤 4: 构建 TypeSpec + Type 层次
      5. 13.3.5. 步骤 5: 生成最终文件
    4. 13.4. 12.4 AAPT2 vs AAPT1 对比
  14. 14. 十三、配置匹配算法深入
    1. 14.1. 13.1 算法概览
    2. 14.2. 13.2 匹配优先级顺序
    3. 14.3. 13.3 ResTable_config::match() 评分细则
    4. 14.4. 13.4 isBetterThan() 比较逻辑
    5. 14.5. 13.5 密度匹配与缩放
    6. 14.6. 13.6 Locale 回退链
    7. 14.7. 13.7 完整匹配示例
  15. 15. 十四、Overlay 与 RRO (Runtime Resource Overlay)
    1. 15.1. 14.1 什么是 RRO
    2. 15.2. 14.2 RRO APK 结构
    3. 15.3. 14.3 Overlay 优先级与包 ID 映射
    4. 15.4. 14.4 AssetManager 合并逻辑
    5. 15.5. 14.5 Overlay 的 resources.arsc 特点
    6. 15.6. 14.6 Static vs Runtime Overlay
  16. 16. 十五、资源混淆 (AndResGuard) 原理
    1. 16.1. 15.1 为什么需要资源混淆
    2. 16.2. 15.2 AndResGuard 工作流
    3. 16.3. 15.3 AndResGuard 修改 resources.arsc 的关键步骤
      1. 16.3.1. (a) 修改 StringPool
      2. 16.3.2. (b) 同步更新资源 ID 引用
      3. 16.3.3. (c) 更新 entriesStart 偏移
    4. 16.4. 15.4 检测 APK 是否被资源混淆
    5. 16.5. 15.5 对抗与绕过
  17. 17. 十六、实战: 提取与对比多版本 APK 的资源变化
    1. 17.1. 16.1 需求背景
    2. 17.2. 16.2 资源 Diff 脚本
    3. 17.3. 16.3 扩展用法
      1. 17.3.1. 检测新增字符串
      2. 17.3.2. 提取特定语言的字符串
  18. 18. 十七、Stale/loose 资源条目
    1. 18.1. 17.1 什么是 Stale 资源
    2. 18.2. 17.2 产生原因
      1. 18.2.1. (a) 编译残留
      2. 18.2.2. (b) aapt2 –no-auto-version
      3. 18.2.3. (c) 手动 ARSC 注入
      4. 18.2.4. (d) 增量构建不一致
    3. 18.3. 17.3 检测方法
    4. 18.4. 17.4 逆向工程意义
    5. 18.5. 17.5 利用与缓解
  19. 19. 面试常考问题
  20. 20. 参考
【逆向安全技术-基础篇】resource.arsc文件格式解析

前言

resources.arsc 是 Android APK 中最核心的资源索引文件。如果说 AndroidManifest.xml 声明了 APK 的身份,那么 resources.arsc 就是APK 的资源数据库——它将文本形式的资源引用编译为紧凑的二进制格式,使运行时能以 O(1) 复杂度根据资源 ID 和当前设备配置找到对应的资源值。

本文基于 AOSP 15 的 androidfw 源码,从二进制层面完整解析 resources.arsc 的结构,并提供生产可用的 Python 解析器。

参考: frameworks/base/libs/androidfw/include/androidfw/ResourceTypes.h (AOSP 15+ 头文件路径), frameworks/base/libs/androidfw/ResourceTypes.cpp (ResTable 实现), frameworks/base/tools/aapt2/ (编译/链接工具)


一、resources.arsc 是什么?

1.1 定位

resources.arsc编译后的资源表(Resource Table),记录了 APK 中所有非 assets 资源的索引信息。它本身不包含资源的实际数据(图片在 res/drawable/ 中,布局在 res/layout/ 中),而是提供资源的逻辑组织信息——名称、类型、配置、值。

1.2 解决的问题

开发者写的代码:
val title = resources.getString(R.string.app_name)

编译后的代码:
const-string v0, 0x7F020001 // app:string/app_name 的资源ID
invoke-virtual {v0}, getString()

运行时:
1. 从资源ID 0x7F020001解析: PP=0x7F, TT=0x02, EEEE=0x0001
2. 在 resources.arsc 的 Package[PP] → Type[TT] → Entry[EEEE] 中查找
3. 根据当前设备配置(语言、屏幕密度、方向…)选择最优匹配的值

resources.arsc 的核心价值在于将人类可读的资源名称映射为整数 ID,并支持多配置适配的高效查找。


二、整体架构

2.1 Chunk 层级

resources.arsc 使用与 AXML 相同的 ResChunk_header 体系。其顶层为嵌套的 chunk 树:

resources.arsc
└── RES_TABLE_TYPE (0x0002) ← 根 chunk
├── StringPool (0x0001) ← 全局资源值字符串池
├── Package (0x0200) ← 包 "com.example.app" (packageId=0x7F)
│ ├── TypeStringPool (内嵌) ← Type 名称 ("string","drawable","layout"…)
│ ├── KeyStringPool (内嵌) ← 资源 Key 名称 ("app_name","icon"…)
│ ├── TypeSpec (0x0202) ← Type #01 (attr)
│ │ ↑ 声明该 type 有哪些配置
│ ├── Type (0x0201) [config=默认] ← Type #01, config=默认
│ ├── Type (0x0201) [config=zh] ← Type #01, config=中文
│ ├── TypeSpec (0x0202) ← Type #02 (string)
│ ├── Type (0x0201) [config=默认] ← Type #02, config=默认
│ ├── Type (0x0201) [config=en] ← Type #02, config=英文
│ ├── TypeSpec (0x0202) ← Type #03 (color)
│ └── Type (0x0201) [config=默认]
└── Package (0x0200) ← 包 "android" (packageId=0x01)
└── ...

2.2 所有相关 Chunk 类型

常量 type 值 说明
RES_TABLE_TYPE 0x0002 资源表根节点
RES_STRING_POOL_TYPE 0x0001 字符串常量池
RES_TABLE_PACKAGE_TYPE 0x0200 包节点
RES_TABLE_TYPE_SPEC_TYPE 0x0202 Type 配置声明
RES_TABLE_TYPE_TYPE 0x0201 Type 具体配置的数据
RES_TABLE_LIBRARY_TYPE 0x0203 共享库声明

三、ResourceTable — 根节点

struct ResTable_header {
ResChunk_header header; // type=0x0002
uint32_t packageCount; // 包的数量
};

packageCount 定义了紧跟其后的 Package chunk 数量。通常为 1(应用自有资源)或 2(应用 + android 系统资源引用)。


四、Package Chunk

4.1 结构

struct ResTable_package {
ResChunk_header header; // type=0x0200
uint32_t id; // 包 ID (0x01=android, 0x7F=app, 0x00-0x0F 保留)
char16_t name[128]; // 包名,UTF-16,null-terminated
uint32_t typeStrings; // Type 名称字符串池偏移(从 header 起始算)
uint32_t lastPublicType; // 最后一个 public type ID
uint32_t keyStrings; // Key 名称字符串池偏移
uint32_t lastPublicKey; // 最后一个 public key ID
};

4.2 typeStrings — Type 名称表

一个内嵌的 StringPool,将 type ID 映射到名称:

typeStrings[1] = "attr"
typeStrings[2] = "string"
typeStrings[3] = "color"
typeStrings[4] = "layout"
typeStrings[5] = "drawable"
typeStrings[6] = "mipmap"
typeStrings[7] = "id"
typeStrings[8] = "style"
typeStrings[9] = "dimen"
typeStrings[10] = "integer"
typeStrings[11] = "bool"
typeStrings[12] = "anim"
typeStrings[13] = "xml"
...

4.3 keyStrings — 资源名称表

另一个内嵌 StringPool,将 entry ID 映射到名称:

keyStrings[1] = "app_name"
keyStrings[2] = "ic_launcher"
keyStrings[3] = "main_activity"
keyStrings[4] = "primary_color"
...

五、TypeSpec — 配置声明

struct ResTable_typeSpec {
ResChunk_header header; // type=0x0202
uint8_t id; // Type ID (0x01=attr, 0x02=string…)
uint8_t res0; // 保留 = 0
uint16_t res1; // 保留 = 0
uint32_t entryCount; // 该 type 下有多少个 entry
};

TypeSpec 后面紧跟一个 uint32_t[entryCount] 数组,每个 uint32 是位掩码,声明该 entry 存在哪些配置变体

配置位掩码

AOSP 在 ResourceTypes.h 中定义了配置标志位,每个位代表一种配置维度。这些位值直接取自 ACONFIGURATION_* 系列常量(定义于 <androidfw/Configuration.h>):

常量 (Hex) 含义
0 ACONFIGURATION_MCC (0x0001) 移动国家码
1 ACONFIGURATION_MNC (0x0002) 移动网络码
2 ACONFIGURATION_LOCALE (0x0004) 语言和地区
3 ACONFIGURATION_TOUCHSCREEN (0x0008) 触摸屏类型
4 ACONFIGURATION_KEYBOARD (0x0010) 键盘类型
5 ACONFIGURATION_KEYBOARD_HIDDEN (0x0020) 键盘隐藏状态(硬键盘是否可见)
6 ACONFIGURATION_NAVIGATION (0x0040) 导航类型(方向键 / 触控板 / 轨迹球)
7 ACONFIGURATION_ORIENTATION (0x0080) 屏幕方向(竖屏 / 横屏 / 方屏)
8 ACONFIGURATION_DENSITY (0x0100) 屏幕密度(ldpi / mdpi / hdpi / xhdpi …)
9 ACONFIGURATION_SCREEN_SIZE (0x0200) 屏幕尺寸(small / normal / large / xlarge)
10 ACONFIGURATION_SMALLEST_SCREEN_SIZE (0x0400) 最小宽度(swdp)
11 ACONFIGURATION_SCREEN_LAYOUT (0x0800) 屏幕布局(长屏 / 宽屏 / 圆屏标志位)
12 ACONFIGURATION_UI_MODE (0x1000) UI 模式(普通 / 车载 / 桌面 / 电视 / 手表 / VR)
13 ACONFIGURATION_LAYOUTDIR (0x2000) 布局方向(LTR / RTL)
14 ACONFIGURATION_COLOR_MODE (0x4000) 色彩模式(广色域 / HDR)
15 ACONFIGURATION_GRAMMATICAL_GENDER (0x8000) 语法性别(AOSP 15 新增)
16+ 更高位 留给未来扩展

源码参考: frameworks/base/libs/androidfw/include/androidfw/ResourceTypes.h 中定义 ResTable_configCONFIG_* 枚举别名(如 CONFIG_MCC = ACONFIGURATION_MCC);frameworks/native/include/android/configuration.h 定义原始 ACONFIGURATION_* 值。

示例:如果一个 entry 有默认配置和 values-zh 两种变体,它的配置掩码会在 CONFIG_LOCALE 位(bit 2)被置位。如果有 values-zh-rCN-hdpi 变体,则同时置位 bit 2(LOCALE)和 bit 8(DENSITY)。


六、Type Chunk — 实际资源数据

这是 resources.arsc最核心、体积最大的部分。每个 Type chunk 对应一种 type 的一个具体配置。

6.1 结构

struct ResTable_type {
ResChunk_header header; // type=0x0201
uint8_t id; // Type ID
uint8_t flags; // 0=normal, 1=sparse
uint16_t reserved; // 保留 = 0
uint32_t entryCount; // entry 数量
uint32_t entriesStart; // entry 偏移量数组的起始偏移(从 type chunk 头算起)
ResTable_config config; // 此 type 对应的设备配置(变长,嵌入)
// 接下来是 uint32_t[entryCount] 偏移量数组
// 每个偏移量指向 ResTable_entry
};

6.2 ResTable_config

struct ResTable_config {
uint32_t size; // sizeof(ResTable_config) = 最多可扩展
// 以下按 MASK 顺序排列
uint16_t mcc; // 移动国家码
uint16_t mnc; // 移动网络码
char language[2]; // ISO 639-1 语言代码
char country[2]; // ISO 3166-1 国家/地区代码
uint8_t orientation; // ORIENTATION_PORTRAIT, ORIENTATION_LANDSCAPE, ORIENTATION_SQUARE
uint8_t touchscreen; // TOUCHSCREEN_NOTOUCH, TOUCHSCREEN_FINGER…
uint16_t density; // DENSITY_LOW=120, DENSITY_MEDIUM=160, DENSITY_HIGH=240…
uint8_t keyboard; // 键盘类型
uint8_t navigation; // 导航类型
uint8_t inputFlags; // 输入标志
uint8_t inputPad0;
uint16_t screenWidth; // 屏幕宽度 (dp)
uint16_t screenHeight; // 屏幕高度 (dp)
uint16_t sdkVersion; // 最低 SDK 版本
uint16_t minorVersion; // 次要版本
uint8_t screenLayout; // 屏幕布局
uint8_t uiMode; // UI 模式
uint16_t smallestScreenWidthDp; // 最小宽度
uint16_t screenWidthDp; // 当前宽度 (dp)
uint16_t screenHeightDp; // 当前高度 (dp)

// 扩展字段(size > sizeof(以上字段) 时存在)
char localeScript[4]; // 文字系统
char localeVariant[8]; // 变体
uint8_t screenLayout2; // 屏幕布局(第 2 部分)
uint8_t colorMode; // 广色域
uint16_t screenConfig; // 屏幕配置扩展
...
};

6.3 配置匹配规则

当运行时请求资源时,Android 遍历同 type 的多个 Type chunk(每种配置一个),按以下规则评分:

  1. 精确匹配优先:如果 language 和 country 完全匹配,得分最高
  2. 无语言指定次之:如果 Type chunk 的 language 为 \0\0(即 values/ 目录,未指定语言),则是兜底配置
  3. 屏幕密度降级:如果请求 xxhdpi 但只有 xhdpi,系统自动缩放
  4. 方向匹配:横屏/竖屏

评分算法在 ResourceTypes.cppResTable_config::match()isBetterThan() 中实现。

6.4 entriesStart 与 offset 数组

紧接 ResTable_config 之后是 uint32_t[entryCount] 偏移量数组:

  • 如果 offset[i] == NO_ENTRY (0xFFFFFFFF),该 entry 在此配置下不存在
  • 否则 offset[i] 指向从 type chunk 头部算起的 ResTable_entry 位置

七、ResTable_entry 与 Res_value

7.1 简单的 ResTable_entry

struct ResTable_entry {
uint16_t size; // 本 entry 大小(用于跳过)
uint16_t flags; // FLAG_COMPLEX=0x0001, FLAG_PUBLIC=0x0002
uint32_t key; // Package keyStrings 中的索引
// Res_value 紧随其后(简单类型)
};

7.2 Res_value

struct Res_value {
uint16_t size; // sizeof(Res_value) = 8
uint8_t res0; // 始终=0
uint8_t dataType; // 数据类型
uint32_t data; // 数据值
};

dataType 的含义与 AXML 中的 Res_value 一致。对于 TYPE_STRING (0x03)data 是全局 StringPool 中的索引;对于 TYPE_REFERENCE (0x01)data 是资源 ID。

7.3 复杂类型:ResTable_map_entry

对于 stylearrayattr 等含子元素的复杂资源,使用 FLAG_COMPLEX 标记的扩展 entry:

struct ResTable_map_entry : public ResTable_entry {
uint32_t parent; // 父 style 的资源 ID(或 0)
uint32_t count; // 后续的子条目数
// 紧随其后:ResTable_map[count]
};

struct ResTable_map {
uint32_t name; // 子属性资源 ID
Res_value value; // 子属性值
};

例如一个 style 定义:

<style name="AppTheme" parent="Theme.AppCompat">
<item name="colorPrimary">#FF0000</item>
<item name="colorAccent">#00FF00</item>
</style>

编码为:

ResTable_map_entry:
key = keyStrings["AppTheme"] = 3
flags = FLAG_COMPLEX
parent = 0x7F080001 (style/Theme_AppCompat)
count = 2
ResTable_map[0]: { name=0x01010001 (attr/colorPrimary), value={TYPE_INT_COLOR_ARGB8, 0xFFFF0000} }
ResTable_map[1]: { name=0x01010002 (attr/colorAccent), value={TYPE_INT_COLOR_ARGB8, 0xFF00FF00} }

八、资源 ID 编码(0xPPTTEEEE)

Android 资源 ID 使用 32 位紧凑编码,将资源所属的包、类型、索引信息压缩为一个整数:

 31        24 23        16 15         0
┌────────────┬────────────┬────────────┐
│ PP │ TT │ EEEE │
│ Package ID │ Type ID │ Entry ID │
└────────────┴────────────┴────────────┘

PP (8 bits): 0x01 = android.R, 0x7F = app.R, 0x00-0x0F = reserved
TT (8 bits): 0x01=attr, 0x02=string, 0x03=color, 0x04=layout, 0x05=drawable...
EEEE (16 bits): 条目序号(0x0000-0xFFFF,最多 65536 个条目/type)

示例解码:

0x7F040012:
PP = 0x7F (app)
TT = 0x04 (layout)
EEEE = 0x0012 (第18个 layout)
→ R.layout.activity_main
def decode_resource_id(resid):
pp = (resid >> 24) & 0xFF
tt = (resid >> 16) & 0xFF
eeee = resid & 0xFFFF
return pp, tt, eeee

包 ID 分配规则:

PP 用途
0x01 android 系统资源
0x02-0x6F 保留(未来扩展)
0x7F 应用主包
0x80-0xFE 动态资源 / 共享库

九、配置系统详解

resources.arsc 的配置系统是 Android 资源适配的核心。

9.1 多 Type chunk 组织

同一个 Type 可以存在多个 Type chunk,每个对应不同的设备配置。例如:

res/values/strings.xml         → Type[0x02], config={language='\0', density=0}
res/values-en/strings.xml → Type[0x02], config={language='en', density=0}
res/values-zh-rCN/strings.xml → Type[0x02], config={language='zh', country='CN'}
res/values-hdpi/strings.xml → Type[0x02], config={density=240}

同一个资源 app_name 可能在这些 Type chunk 中有不同的值(或者只在部分 chunk 中存在)。

9.2 资源查找流程

给定: resourceId=0x7F020001, 当前设备 config={lang=zh, density=480}

1. 解析 resourceId → PP=0x7F, TT=0x02, EEEE=0x0001
2. 找到 Package[0x7F]
3. 遍历所有 id=0x02 的 Type chunk
4. 对每个 Type chunk:
a. 计算其 ResTable_config 与设备 config 的匹配分数
b. 检查 entry[1] 是否存在(offset != NO_ENTRY)
c. 记录最高分匹配
5. 返回最高分解码出的 Res_value

9.3 配置标志组合

TypeSpec 中的配置掩码提供了快速判断:如果某个配置位不匹配,可直接跳过该 Type chunk 而非逐个 entry 检查:

TypeSpec[0x02].configMask[entry=1] = 0x0104
→ bit 2 (LOCALE=语言/地区), bit 8 (DENSITY=屏幕密度)
→ 该 entry 有两种配置维度,可能对应以下 Type chunk:
Type[config=默认] (language='\0', density=0)
Type[config=zh] (language='zh', density=0)
Type[config=en] (language='en', density=0)
Type[config=hdpi] (language='\0', density=240)
→ 总共 4 个 Type chunk 包含该 entry 的值

十、完整 Python ARSC 解析器

#!/usr/bin/env python3
"""resources.arsc 解析器 — dump 所有资源定义"""
import struct
import sys

TYPE_NULL = 0; TYPE_REFERENCE = 1; TYPE_STRING = 3
TYPE_INT_DEC = 0x10; TYPE_INT_HEX = 0x11
TYPE_INT_BOOLEAN = 0x12; TYPE_INT_COLOR_ARGB8 = 0x1C

CHUNK_TABLE = 0x0002
CHUNK_STRING_POOL = 0x0001
CHUNK_PACKAGE = 0x0200
CHUNK_TYPE_SPEC = 0x0202
CHUNK_TYPE = 0x0201

class ARSCParser:
def __init__(self, path):
with open(path, 'rb') as f:
self.data = f.read()
self.pos = 0
self.string_pool = []
self.packages = {}

def parse_header(self):
typ, hdr_size, size = struct.unpack_from('<HHI', self.data, self.pos)
return typ, hdr_size, size

def parse_stringpool(self, start):
"""解析 StringPool,返回字符串列表"""
hdr = self.data[start:start+28]
_, hdr_sz, chunk_sz, str_cnt, style_cnt, flags, str_start, style_start = \
struct.unpack_from('<HHIIIII', hdr, 0)
utf8 = (flags >> 8) & 1

strings = []
off_table = start + hdr_sz
off_data = start + str_start

for i in range(str_cnt):
str_off = struct.unpack_from('<I', self.data, off_table + i * 4)[0]
if utf8:
# UTF-8 编码的字符串
pos = off_data + str_off
# 读取两个 uleb128: utf16_len, utf8_byte_len
result = 0; shift = 0
while True:
b = self.data[pos]; pos += 1
result |= (b & 0x7F) << shift
if b & 0x80 == 0: break
shift += 7
char_count = result
result = 0; shift = 0
while True:
b = self.data[pos]; pos += 1
result |= (b & 0x7F) << shift
if b & 0x80 == 0: break
shift += 7
byte_len = result
s = self.data[pos:pos+byte_len-1].decode('utf-8', errors='replace')
strings.append(s)
else:
# UTF-16 编码的字符串
pos = off_data + str_off
result = 0; shift = 0
while True:
b = self.data[pos]; pos += 1
result |= (b & 0x7F) << shift
if b & 0x80 == 0: break
shift += 7
char_count = result
raw = self.data[pos:pos + char_count * 2]
s = raw.decode('utf-16-le', errors='replace')
strings.append(s)
return strings, start + chunk_sz

def parse_config(self, offset):
"""解析 ResTable_config 并返回 (config_dict, next_offset)

ResTable_config 字段布局 (from AOSP ResourceTypes.h):
size(4) mcc(2) mnc(2) language[2] country[2]
orientation(1) touchscreen(1) density(2)
keyboard(1) navigation(1) inputFlags(1) inputPad0(1)
screenWidth(2) screenHeight(2) sdkVersion(2) minorVersion(2)
screenLayout(1) uiMode(1)
smallestScreenWidthDp(2) screenWidthDp(2) screenHeightDp(2)
// 扩展字段通过 size 判断是否存在
"""
sz = struct.unpack_from('<I', self.data, offset)[0]
# 先解析基础部分 (36 bytes = 到 screenHeightDp)
base_fmt = '<IHH2s2sBBHBBBBHHHHBBHHH'
base_len = struct.calcsize(base_fmt) # = 40 (含 size 字段)
if sz >= base_len:
fields = struct.unpack_from(base_fmt, self.data, offset)
size, mcc, mnc, lang_raw, country_raw, orient, touch, density, \
kbd, nav, inp_flags, pad0, sw, sh, sdk, minor, scr_layout, ui_mode, \
sw_dp, w_dp, h_dp = fields

lang = lang_raw.decode('ascii', errors='replace').rstrip('\0')
country = country_raw.decode('ascii', errors='replace').rstrip('\0')

config = {}
if mcc: config['mcc'] = mcc
if mnc: config['mnc'] = mnc
if lang: config['language'] = lang
if country: config['country'] = country
if density: config['density'] = density
if sdk: config['sdk'] = sdk
if orient: config['orientation'] = orient
if ui_mode: config['uiMode'] = ui_mode

return config, offset + size

def parse_value(self, offset):
"""解析 Res_value"""
sz, res0, dtype, data = struct.unpack_from('<H B B I', self.data, offset)
dtype = dtype & 0xFF
return {'type': dtype, 'data': data}, offset + 8

def parse(self):
resources = []
while self.pos < len(self.data):
typ, hdr_size, chunk_size = self.parse_header()
chunk_end = self.pos + chunk_size

if typ == CHUNK_TABLE:
pkg_cnt = struct.unpack_from('<I', self.data, self.pos + 8)[0]
print(f"ResourceTable: {pkg_cnt} packages")
self.pos += hdr_size

elif typ == CHUNK_STRING_POOL:
self.string_pool, self.pos = self.parse_stringpool(self.pos)

elif typ == CHUNK_PACKAGE:
pkg_id = struct.unpack_from('<I', self.data, self.pos + 8)[0]
pkg_name_raw = self.data[self.pos+12 : self.pos+12+256]
pkg_name = pkg_name_raw.decode('utf-16-le', errors='replace').rstrip('\0')
type_str_start = struct.unpack_from('<I', self.data, self.pos + 12+256)[0]
key_str_start = struct.unpack_from('<I', self.data, self.pos + 12+256+8)[0]

pkg_start = self.pos
# 解析 typeStrings
type_strings, _ = self.parse_stringpool(pkg_start + type_str_start)
# 解析 keyStrings
key_strings, _ = self.parse_stringpool(pkg_start + key_str_start)

print(f"\nPackage: {pkg_name} (id=0x{pkg_id:02X})")
print(f" Types: {type_strings[1:11]}...")
self.pos = pkg_start + hdr_size

elif typ == CHUNK_TYPE_SPEC:
tid = self.data[self.pos + 8]
entry_cnt = struct.unpack_from('<I', self.data, self.pos + 12)[0]
# 跳过配置掩码数组
self.pos = chunk_end

elif typ == CHUNK_TYPE:
tid = self.data[self.pos + 8]
entry_cnt = struct.unpack_from('<I', self.data, self.pos + 12)[0]
entries_start = struct.unpack_from('<I', self.data, self.pos + 16)[0]
config, _ = self.parse_config(self.pos + 20)

# 遍历 entry
off_base = self.pos + entries_start
for i in range(entry_cnt):
ent_off = struct.unpack_from('<I', self.data, off_base + i * 4)[0]
if ent_off == 0xFFFFFFFF:
continue # 此配置下不存在
ent_pos = self.pos + ent_off
esize, eflags, ekey = struct.unpack_from('<HHI', self.data, ent_pos)
key_name = key_strings[ekey] if ekey < len(key_strings) else f"key[{ekey}]"

if eflags & 0x0001: # FLAG_COMPLEX
resources.append({
'type': type_strings[tid] if tid < len(type_strings) else f'type{tid}',
'key': key_name,
'config': config,
'complex': True,
'resid': (0x7F << 24) | (tid << 16) | i,
})
else:
val, _ = self.parse_value(ent_pos + 8)
resources.append({
'type': type_strings[tid] if tid < len(type_strings) else f'type{tid}',
'key': key_name,
'config': config,
'value': val,
'resid': (0x7F << 24) | (tid << 16) | i,
})
self.pos = chunk_end
else:
self.pos = chunk_end

return resources


if __name__ == '__main__':
parser = ARSCParser(sys.argv[1] if len(sys.argv) > 1 else 'resources.arsc')
results = parser.parse()
for r in results[:50]: # 只显示前50条
cfg_str = ','.join(f'{k}={v}' for k, v in r.get('config', {}).items())
if r.get('complex'):
print(f" 0x{r['resid']:08X} {r['type']}/{r['key']} [{cfg_str}] (complex)")
else:
v = r['value']
print(f" 0x{r['resid']:08X} {r['type']}/{r['key']} = [{v['type']}]{v['data']} [{cfg_str}]")
print(f"\nTotal: {len(results)} resources")

十一、逆向实战应用

11.1 提取应用所有字符串资源

# 使用 aapt2 dump
aapt2 dump resources app.apk | grep "string/"

# 使用上述 Python 解析器
python3 arsc_parser.py resources.arsc | grep "type=string"

11.2 资源混淆检测

商业加固工具(如 AndResGuard)会混淆资源 ID,将 R.string.app_name 重命名为短的随机名称(如 R.string.a)。通过对比原始和混淆后的 resources.arsc 的 keyStrings,可以检测是否被混淆。

11.3 资源修复

反编译后修改资源值(如替换图片引用、修改颜色、更改字符串),可以通过操作 ResTable_entryRes_value.data 实现,无需完全反编译 APK。

11.4 反编译对抗

某些 APP 会检测 resources.arsc 的完整性(checksum/hash),在混淆后加入资源校验。绕过方式:重新计算并修补校验值,或 Hook 校验函数。


十二、AAPT2 编译流程详解

12.1 AAPT2 的两阶段架构

AAPT2(Android Asset Packaging Tool 2)从 Android Gradle Plugin 3.0.0 开始成为默认资源编译器。它采用分离的编译-链接两阶段设计,替代了 AAPT1 的单次全量处理:

源文件 (res/values/strings.xml, res/layout/activity.xml ...)

▼ 阶段 1: 编译 (compile)
┌───────────────────────────────────────┐
│ aapt2 compile │
│ 每个资源文件 → 独立的 *.flat 文件 │
│ - XML 文本 → 二进制 ResXML* 结构 │
│ - 字符串 → Value 结构 │
│ - 图片资源 → 记录路径引用 │
│ 输出: build/intermediates/compiled_res/ │
│ ├── values_strings.arsc.flat │
│ ├── layout_activity.xml.flat │
│ └── drawable_icon.png.flat │
└───────────────────────────────────────┘

▼ 阶段 2: 链接 (link)
┌───────────────────────────────────────┐
│ aapt2 link │
│ 1. 收集所有 *.flat 文件 │
│ 2. 汇总所有资源定义 │
│ 3. 分配资源ID (0xPPTTEEEE 编号体系) │
│ 4. 构建 StringPool:去重、排序、编码 │
│ 5. 构建层次结构: │
│ ResourceTable → Package → │
│ TypeSpec → Type (×N 配置) │
│ 6. 输出: resources.arsc + │
│ 压缩后的各类资源文件 │
└───────────────────────────────────────┘

12.2 编译阶段:XML → flat

在编译阶段,每个资源 XML 文件被独立编译为中间二进制格式 .flat(Android 11+ 改为 .asrc.flat 扩展名,但本质相同)。编译过程:

  1. XML 解析:将文本 XML 解析为 DOM 树
  2. 字符串提取:提取所有 android:name 和文本内容,分配临时的局部索引
  3. 类型推断:根据元素标签推断资源值类型(<string> → TYPE_STRING, <color> → TYPE_INT_COLOR_ARGB8, <dimen> → TYPE_DIMENSION 等)
  4. 二进制编码
    • 普通值:直接编码为 Res_value 结构(dataType + data)
    • 复杂值(style/array):编码为 ResTable_map_entry + ResTable_map[] 结构
    • 引用:保持为 @type/name 字符串形式,链接阶段才解析为资源 ID
  5. 输出ResTable 格式的扁平文件(类似一个小型 resources.arsc),含局部 StringPool + 资源项
<!-- 输入: res/values/strings.xml -->
<resources>
<string name="app_name">My App</string>
<string name="welcome">Hello, %1$s!</string>
</resources>

编译为 flat 后(伪代码表示):

FlatFile {
StringPool: ["app_name", "welcome", "My App", "Hello, %1$s!"]
Entry[0]: key=0("app_name"), value={TYPE_STRING, data=2("My App")}
Entry[1]: key=1("welcome"), value={TYPE_STRING, data=3("Hello, %1$s!")}
}

关键:编译阶段不分配最终的资源 ID,entry 仅以 key 名称索引。

12.3 链接阶段:flat → resources.arsc

链接阶段将所有 .flat 文件合并为最终的 resources.arsc

步骤 1: 收集与去重

aapt2 link 扫描所有 .flat 文件 →
- 合并同名资源(不同配置的同一资源视为同一 entry 的不同 config)
- 检测冲突:同配置下同名的资源定义会触发编译错误
- 合并 StringPool:所有字符串去重并重新编码

步骤 2: 资源 ID 分配

分配策略(自动递增):
┌──────────┬──────────┬──────────────────────┐
│ Package │ Type ID │ Entry ID 分配范围 │
├──────────┼──────────┼──────────────────────┤
│ 0x01 │ (系统) │ 由 framework-res 定义 │
│ 0x7F │ 0x01 │ attr: 0x0000..0xFFFF │
│ 0x7F │ 0x02 │ string: 0x0000..0xFFFF│
│ 0x7F │ 0x03 │ color: 0x0000..0xFFFF │
│ ... │ ... │ ... │
└──────────┴──────────┴──────────────────────┘

分配规则:
1. 从 0x00 开始按文件中出现顺序递增分配 entry ID
2. 如果指定了 public.xml,优先分配其中声明的固定 ID
3. Type ID 按首次遇到的资源类型递增分配
4. 保留 0x01 到 0x0F 给 AOSP 系统类型(attr, string, color, layout,
drawable, mipmap, id, style, dimen, integer, bool, anim, xml, raw, interpolator)

步骤 3: 构建 StringPool

全局资源值 StringPool(位于 ResourceTable 根级别):
StringPool[0] = ""(保留)
StringPool[1] = "My App"
StringPool[2] = "Hello, %1$s!"
...

类型名称 StringPool(位于 Package 内部):
StringPool[1] = "attr"
StringPool[2] = "string"
StringPool[3] = "color"
...

资源 Key StringPool(位于 Package 内部):
StringPool[1] = "app_name"
StringPool[2] = "welcome"
...

StringPool 使用增量编码:所有字符串排序后,后面的字符串可以引用前面字符串的子串作为前缀,通过字节偏移量来节省空间。

步骤 4: 构建 TypeSpec + Type 层次

对于每个 (Package, Type) 组合:
1. 创建 TypeSpec chunk:
- 声明 entryCount
- 为每个 entry 计算配置掩码(位图表示存在哪些配置变体)

2. 为每种存在的配置创建 Type chunk:
- 写入 ResTable_config
- 构建 entriesStart + offset 数组
- 写入各 entry 的 Res_value 或 ResTable_map_entry

步骤 5: 生成最终文件

最终 resources.arsc 二进制布局:
ResTable_header (packageCount=N)
StringPool(全局)
Package[0]:
TypeStringsPool
KeyStringsPool
TypeSpec[type=attr] → Type[attr][默认] → Type[attr][zh] → ...
TypeSpec[type=string] → Type[string][默认] → Type[string][en] → ...
...
Package[1]: ...

12.4 AAPT2 vs AAPT1 对比

维度 AAPT1 AAPT2
编译模型 单次全量处理 分离编译 + 链接
增量编译 不支持 支持(只编译变更文件)
中间格式 *.flat 可缓存
资源 ID 固定 编译时立即分配 链接时统一分配
产物 APK 直接产出 可输出未签名的中间 APK
性能 大项目慢 多核并行编译,显著更快

AOSP 源码参考: frameworks/base/tools/aapt2/compile/ — 编译阶段;frameworks/base/tools/aapt2/link/ — 链接阶段;frameworks/base/tools/aapt2/ResourceTable.cpp — 资源表构建核心逻辑。


十三、配置匹配算法深入

13.1 算法概览

resources.arsc 的核心价值之一是自动配置匹配:当同一资源有多个配置变体时,Android 运行时选择一个”最优”匹配。匹配算法在 ResourceTypes.cppResTable_config::match()isBetterThan() 中实现。

输入: 设备当前配置 (requestedConfig)
资源表中有 N 个同 type 的 Type chunk,每个对应一种目标配置

算法: for each Type_chunk in Type[requested_type]:
score = ResTable_config::match(targetConfig, requestedConfig)
if score != NO_MATCH:
记录当前最佳匹配 = isBetterThan(current, best) ? current : best
返回最佳 Type chunk 中 entry[entryIndex] 的值

13.2 匹配优先级顺序

配置字段的匹配按以下顺序依次比较,先比较的字段优先级更高

1.  MCC (移动国家码)
2. MNC (移动网络码)
3. Locale (语言+地区)
4. LayoutDirection (布局方向)
5. SmallestScreenWidthDp (最小宽度)
6. AvailableWidthDp (可用宽度) // Android 3.2+
7. AvailableHeightDp (可用高度) // Android 3.2+
8. ScreenSize (屏幕尺寸)
9. Density (屏幕密度)
10. Orientation (屏幕方向)
11. UIMode (UI 模式)
12. NightMode (夜间模式)

核心原则: 更”具体”的配置优先级高于更”通用”的配置。例如 values-zh-rCNvalues-zh 更具体,values-zhvalues(默认)更具体。

13.3 ResTable_config::match() 评分细则

// 伪代码,来源于 ResourceTypes.cpp
ssize_t ResTable_config::match(const ResTable_config& settings) const {
// 每个字段匹配得分:
// 匹配: +N 分(N 为该字段在优先级中的权重)
// 不匹配: +0 分,但标记该维度不匹配
// target 为 null/0: +0 分,不标记不匹配(视为通配)

if (mcc != 0) {
if (mcc == settings.mcc) score += MCC_SCORE;
else return NO_MATCH; // MCC/MNC 不匹配直接淘汰
}

if (mnc != 0) {
if (mnc == settings.mnc) score += MNC_SCORE;
else return NO_MATCH;
}

// Locale: 三层匹配
if (locale != "") {
if (language == settings.language) {
if (country == settings.country) score += LOCALE_EXACT_SCORE + LOCALE_REGION_SCORE;
else score += LOCALE_EXACT_SCORE; // 仅语言匹配
} else return NO_MATCH;
}

// 密度匹配:允许降级
if (density != 0) {
if (density == settings.density) score += DENSITY_SCORE;
else if (density < settings.density) score += DENSITY_LOWER_SCORE; // 降级
else score += DENSITY_HIGHER_SCORE; // 升级(需要缩放,惩罚更多)
}

// ... 其余字段类似
}

13.4 isBetterThan() 比较逻辑

当两个配置都能匹配时,isBetterThan() 决定哪个”更好”:

bool ResTable_config::isBetterThan(const ResTable_config& o, 
const ResTable_config* requested) const {
// 1. 如果当前是 NULL 配置(完全无要求)而另一个不是,另一个更好
if (isNullConfig()) return false;
if (o.isNullConfig()) return true;

// 2. 按优先级顺序逐个字段比较
// 更精确匹配 requested 的配置更好

// MCC
if (mcc != o.mcc) {
return (mcc != 0) && (mcc == requested->mcc);
}

// MNC
if (mnc != o.mnc) {
return (mnc != 0) && (mnc == requested->mnc);
}

// Locale: 精确语言+国家匹配 > 仅语言匹配 > 完全不指定
// ...

// Density: 精确密度 > 接近密度(无需缩放) > 高密度(缩小显示) > 低密度(放大显示)
}

13.5 密度匹配与缩放

密度匹配有特殊规则:

设备请求 480 dpi (xxhdpi):
1. 有 xxhdpi 资源 → 直接使用(得分最高)
2. 有 xxxhdpi (640) → 缩小显示(得分次高,图片质量好但浪费内存)
3. 有 xhdpi (320) → 放大显示(得分第三,可能模糊)
4. 有 hdpi (240) → 大幅放大(得分最低)
5. 有 nodpi 资源 → 直接使用(不缩放)

scaling_ratio = target_density / resource_density
例如: 480/320 = 1.5x 放大
480/640 = 0.75x 缩小

13.6 Locale 回退链

Locale 匹配的三级回退:
1. language + country 完全匹配: zh-rCN == zh-rCN → 最佳
2. 仅 language 匹配: zh-rCN vs zh → 次佳
3. 无 language 指定: zh-rCN vs (空) → 兜底

特殊规则:
- zh-rCN 可以回退到 zh,但不能回退到 zh-rTW(不同地区)
- en 是特殊语言:en-rUS 的回退链:en-rUS → en → (空)
- 脚本(script)匹配:Latn, Hans, Hant 等也参与匹配(AOSP 21+ 扩展字段)

13.7 完整匹配示例

设备: zh-rCN, xxhdpi (480dpi), 竖屏, sw360dp

资源表中有:
Type[config=默认] → app_name = "Default"
Type[config=zh] → app_name = "你好"
Type[config=zh-rCN] → app_name = "你好中国"
Type[config=zh-rCN-hdpi] → app_name = "你好中国 hdpi"

匹配过程:
vs 默认: score = 完全通用, 最低
vs zh: score = locale 语言匹配
vs zh-rCN: score = locale 完全匹配 ★ 赢
vs zh-rCN-hdpi: locale 匹配但 density 不匹配 (240 vs 480),
需要缩放,得分不如 zh-rCN 的精确 locale 匹配

最终选择: "你好中国" (Type[config=zh-rCN])

源码参考: frameworks/base/libs/androidfw/ResourceTypes.cppResTable_config::match()isBetterThan() 实现,约 300 行的完整评分逻辑。


十四、Overlay 与 RRO (Runtime Resource Overlay)

14.1 什么是 RRO

RRO(Runtime Resource Overlay)是 Android 8.0(API 26)引入的运行时资源替换机制。它允许独立的 APK 包在运行时覆盖目标 APK 的资源值,无需修改原始 APK。RRO 广泛用于:

  • 系统主题(Android 深色主题通过 overlay 实现)
  • OEM 定制(厂商替换系统 UI 资源)
  • Carrier 定制(运营商特定配置)
  • 动态换肤(第三方主题引擎)

14.2 RRO APK 结构

RRO APK 是一个轻量级 APK,通常只包含:

overlay.apk
├── AndroidManifest.xml ← 声明 overlay 目标包和优先级
├── resources.arsc ← 仅包含需要覆盖的资源条目
└── res/
└── values/
└── strings.xml ← 仅定义要覆盖的字符串

AndroidManifest.xml 关键声明:

<manifest package="com.example.overlay">
<!-- 声明这是一个 overlay -->
<overlay
android:targetPackage="com.example.target"
android:targetName="MyOverlay"
android:priority="10"
android:isStatic="false" />

<application>
<!-- overlay APK 通常没有 Activity -->
</application>
</manifest>

14.3 Overlay 优先级与包 ID 映射

RRO 的核心机制是包 ID 映射:overlay 的 resources.arsc 使用自己的 package ID(通常是 0x7F),但运行时 AssetManager 将其映射到目标包的 ID

Overlay APK 编译时:
resources.arsc 使用 Package ID = 0x7F
R.string.app_name → 0x7F020001

运行时 AssetManager 加载:
1. 读取 overlay 的 AndroidManifest.xml
2. 确认 targetPackage = "com.example.target"
3. 建立 ID 映射: overlay 0x7F → target 0x7F
4. 当请求 target 0x7F020001 时,优先返回 overlay 中映射后的 0x7F020001

优先级规则:

overlay priority 值越大 → 优先级越高
同优先级 → 按安装顺序,后安装的优先
static overlay (isStatic=true) → 不可被禁用,始终生效
runtime overlay → 可通过 OverlayManager API 动态启用/禁用

14.4 AssetManager 合并逻辑

AssetManager.java 在加载资源时按以下顺序查找:

1. Runtime Overlays(动态启用,优先级从高到低)
2. Static Overlays(编译时声明,优先级从高到低)
3. 目标 APK 自身的 resources.arsc(最后回退)
4. 系统 framework-res.apk(兜底)

查找流程:
getString(0x7F020001)
→ AssetManager 遍历 APK 列表
→ 每个 overlay: 检查是否存在映射为 0x7F020001 的资源
→ 第一个找到的值即为最终结果
→ 如果所有 overlay 都不包含该 entry,使用目标 APK 自身值

14.5 Overlay 的 resources.arsc 特点

Overlay APK 的 resources.arsc 与普通 APK 有显著区别:

正常 APK 的 resources.arsc:
TypeSpec[string] → entryCount = 200(声明所有 200 个字符串)
Type[string][默认] → 200 个 entry

Overlay APK 的 resources.arsc:
TypeSpec[string] → entryCount = 3(只声明需要覆盖的 3 个字符串)
Type[string][默认] → 仅 3 个 entry(app_name, welcome, goodbye)
  • Overlay 的 entry ID 必须与目标 APK 中相同资源的 ID 完全一致
  • 未被覆盖的 entry 在 overlay ARSC 中标记为 NO_ENTRY (0xFFFFFFFF)
  • Overlay 可能只覆盖特定配置(如仅覆盖 values-zh 的语言资源)

14.6 Static vs Runtime Overlay

特性 Static Overlay Runtime Overlay
声明方式 android:isStatic="true" android:isStatic="false"
生效时机 编译时 / 系统启动时 运行时动态切换
能否禁用 否(始终生效) 可动态启用/禁用
是否需要签名 需要平台签名 需要系统权限 (CHANGE_OVERLAY_PACKAGES)
典型用途 厂商硬件定制 主题引擎 / 动态换肤
IDMAP 文件 需要(/data/resource-cache/ 需要
AOSP 支持 Android 8.0+ Android 10+ 完善

idmap 文件是编译 overlay 时生成的二进制映射表,记录 overlay 资源 ID 到目标资源 ID 的映射关系,由 idmap2 工具生成。

源码参考: frameworks/base/core/java/android/content/res/AssetManager.java — 资源加载与 overlay 合并;frameworks/base/services/core/java/com/android/server/pm/PackageManagerService.java — Overlay 包管理;frameworks/base/cmds/idmap2/ — idmap 生成工具源码。


十五、资源混淆 (AndResGuard) 原理

15.1 为什么需要资源混淆

APK 中的资源名称(如 R.string.app_nameR.drawable.ic_launcher_background)在 resources.arsc 的 keyStrings 中以明文存储。这些名称泄露了:

  • 应用功能模块(activity_login, fragment_payment…)
  • 第三方 SDK(com_facebook_*, google_api_*…)
  • 内部命名规范

资源混淆通过缩短这些名称来减小 APK 体积并增加逆向难度。

15.2 AndResGuard 工作流

AndResGuard 是最主流的开源 Android 资源混淆工具,其核心流程:

原始 APK


1. 解压 APK → 获取 resources.arsc + res/ 文件


2. 解析 resources.arsc
│ ├── 读取全局 StringPool
│ ├── 读取 Package → TypeStringsPool, KeyStringsPool
│ └── 记录所有资源引用关系


3. 混淆字符串
│ ├── 对 KeyStringsPool: "app_name" → "a"(最短可用名称)
│ ├── 对 TypeStringsPool: 通常保留("string","drawable"... 不变)
│ ├── 对全局 StringPool: 文件路径字符串缩短
│ │ "res/drawable/ic_launcher.png" → "r/d/a.png"
│ └── 白名单保留:不混淆 proguard-android.txt 中引用的资源


4. 重写 resources.arsc
│ ├── 重新编码 StringPool(UTF-8/UTF-16)
│ ├── 更新所有偏移量引用
│ ├── 更新 entriesStart(entry 数组偏移可能因 pool 变大/变小而改变)
│ └── 重新计算所有 chunk size


5. 重命名 res/ 目录下的文件
│ res/layout/activity_main.xml → res/layout/b.xml
│ res/drawable/ic_launcher.png → res/drawable/c.png


6. 重新打包 APK + 签名

15.3 AndResGuard 修改 resources.arsc 的关键步骤

(a) 修改 StringPool

# 伪代码: AndResGuard 的 StringPool 混淆
def obfuscate_key_pool(key_pool):
mapping = {}
used_names = set()
for i, name in enumerate(key_pool):
if is_whitelisted(name): # 白名单保留
new_name = name
else:
# 生成最短唯一名称: "a", "b", ..., "aa", "ab", ...
new_name = generate_short_name(i, used_names)
mapping[name] = new_name

# 重建 StringPool
return rebuild_pool(mapping.values()), mapping

(b) 同步更新资源 ID 引用

虽然资源的 Package ID 和 Type ID 保持不变,但 Entry ID 可能因排序变化

原始:
string/app_name → 0x7F020000
string/welcome → 0x7F020001
string/goodbye → 0x7F020002

混淆后(按字母序重排 entry):
string/b → 0x7F020000 (原 goodbye, 现最短名)
string/a → 0x7F020001 (原 app_name)
string/c → 0x7F020002 (原 welcome)

关键一致性维护

  • resources.arsc 中的 entry 顺序和 entry ID 必须一致
  • classes.dex 中硬编码的资源 ID 常量也必须同步更新
  • AndroidManifest.xml 中引用的资源 ID 同步更新
  • 其他二进制 XML(layout, menu…)中的 @type/name 引用需重新映射

(c) 更新 entriesStart 偏移

混淆后 StringPool 的大小可能改变(通常因为字符串缩短而变小),导致 Type chunk 中的 entriesStart 偏移量需要重新计算:

修改前:
Type[type=string]:
header: 8 bytes
id/flags/reserved: 4 bytes
entryCount/entriesStart: 8 bytes
config: ~40 bytes
entriesStart = 60 ← offset 数组从 chunk 头偏移 60 开始

修改后 (StringPool 缩小):
entriesStart = 56 ← 新偏移(config 小了 4 字节)

修改后 (StringPool 增大):
entriesStart = 64 ← 新偏移(config 大了 4 字节)

15.4 检测 APK 是否被资源混淆

以下特征可以检测 APK 的资源是否经过混淆:

1. 检查 keyStrings 中的名称长度:
正常: "app_name", "ic_launcher_background", "activity_main"
混淆: "a", "b", "c", "aa", "ab"(1-3 个字符的无意义名称)

2. 检查 TypeStrings:
正常: ["", "attr", "string", "color", "layout", "drawable", "mipmap", "id", "style", "dimen", "integer", "bool"]
混淆(部分工具): ["", "a", "b", "c", "d", ...](连类型名也混淆)

3. 检查资源文件路径:
正常: res/drawable-xxhdpi/ic_launcher.png
混淆: r/d/a.png(无意义的短路径)

4. 检查 resourceID 规律:
正常: 0x7F020000 → 0x7F020001 → 0x7F020002(连续递增)
混淆后: 可能打乱顺序,entry ID 不再连续递增

5. 比对 apktool 反编译结果:
apktool 反编译后的 res/values/public.xml 会展示混淆后的资源名称

15.5 对抗与绕过

对于逆向工程师:
- 混淆后的资源命名对自动化分析无实质影响(资源 ID 仍然有效)
- 可以通过语义分析推断资源用途(字符串内容、引用关系)
- 部分工具提供白名单还原(如果知道原始 APK 的映射关系)

对于加固开发者:
- 结合资源混淆 + DEX 混淆 + 字符串加密 形成多层防护
- 但需注意:混淆后仍需保证资源引用一致性,否则运行时崩溃
- public.xml 中声明的资源不能混淆(保持 ID 不变)

源码参考: github.com/shwenzhang/AndResGuard — 开源资源混淆工具,包含 ResourceProguard.cpp (核心混淆逻辑)、AxmlEdit.cpp (AXML 编辑)、SevenZipWrapper.cpp (APK 解压/重打包)。


十六、实战: 提取与对比多版本 APK 的资源变化

16.1 需求背景

在逆向分析中,经常需要对比同一应用两个版本的资源变化:

  • 恶意软件分析:新版本是否添加了钓鱼字符串?是否修改了权限描述?
  • SDK 审计:第三方 SDK 升级后新增了哪些资源?
  • 国际化审查:某个语言的翻译是否被篡改?
  • 竞品分析:追踪功能迭代对应的资源变化

16.2 资源 Diff 脚本

以下 Python 脚本基于前文的 ARSC 解析器,实现两个 resources.arsc 的差异对比:

#!/usr/bin/env python3
"""resources.arsc Diff Tool — 对比两个 APK 版本的资源变化"""
import sys
import json
from collections import defaultdict

# 复用前文的 ARSCParser 类(略去重复代码,假设已导入)
# from arsc_parser import ARSCParser

def build_resource_index(parser_results):
"""构建 资源ID → {config: value} 的索引"""
index = defaultdict(dict)
for r in parser_results:
resid = r['resid']
cfg_key = tuple(sorted(r.get('config', {}).items()))
index[resid][cfg_key] = r
return index

def diff_resources(old_index, new_index):
"""找出新增、删除、变更的资源"""
old_ids = set(old_index.keys())
new_ids = set(new_index.keys())

print("=" * 60)
print("资源差异报告")
print("=" * 60)

# 新增资源
added = new_ids - old_ids
if added:
print(f"\n[+] 新增资源: {len(added)} 项")
for rid in sorted(added):
entry = list(new_index[rid].values())[0]
print(f" 0x{rid:08X} ({entry['type']}/{entry['key']})")

# 删除资源
removed = old_ids - new_ids
if removed:
print(f"\n[-] 移除资源: {len(removed)} 项")
for rid in sorted(removed):
entry = list(old_index[rid].values())[0]
print(f" 0x{rid:08X} ({entry['type']}/{entry['key']})")

# 值变更
common = old_ids & new_ids
changed = []
for rid in common:
old_entry = old_index[rid]
new_entry = new_index[rid]

# 检查每个配置下的值
for cfg in old_entry:
if cfg in new_entry:
old_val = old_entry[cfg].get('value', {})
new_val = new_entry[cfg].get('value', {})
if old_val != new_val:
changed.append((rid, cfg, old_val, new_val))

if changed:
print(f"\n[*] 值变更: {len(changed)} 项")
for rid, cfg, old_val, new_val in changed:
r = old_index[rid][cfg] # or new_index
cfg_str = ','.join(f'{k}={v}' for k, v in dict(cfg).items())
print(f" 0x{rid:08X} ({r['type']}/{r['key']}) [{cfg_str}]")
print(f" 旧值: {old_val}")
print(f" 新值: {new_val}")

# 配置变体变化
print(f"\n[#] 配置变体变化:")
for rid in sorted(common):
old_cfgs = set(old_index[rid].keys())
new_cfgs = set(new_index[rid].keys())
added_cfgs = new_cfgs - old_cfgs
removed_cfgs = old_cfgs - new_cfgs
if added_cfgs or removed_cfgs:
r = list(new_index[rid].values())[0] if new_index[rid] else list(old_index[rid].values())[0]
if added_cfgs:
print(f" + 0x{rid:08X} ({r['type']}/{r['key']}): 新增 {len(added_cfgs)} 种配置")
if removed_cfgs:
print(f" - 0x{rid:08X} ({r['type']}/{r['key']}): 移除 {len(removed_cfgs)} 种配置")

def compare_languages(old_parser, new_parser, resource_name, type_name="string"):
"""对比特定资源在多语言下的值变化"""
# 需要先通过 keyStrings 查找资源 ID
print(f"\n[语言对比] {type_name}/{resource_name}:")
# ... 实现略(根据 key name 遍历所有配置比较值)

if __name__ == '__main__':
if len(sys.argv) < 3:
print("Usage: python arsc_diff.py old.apk new.apk")
sys.exit(1)

parser1 = ARSCParser(sys.argv[1])
parser2 = ARSCParser(sys.argv[2])
results1 = parser1.parse()
results2 = parser2.parse()

idx1 = build_resource_index(results1)
idx2 = build_resource_index(results2)
diff_resources(idx1, idx2)

16.3 扩展用法

检测新增字符串

# 提取所有 string 类型资源
python3 arsc_parser.py app_v1.apk > v1_strings.txt
python3 arsc_parser.py app_v2.apk > v2_strings.txt

# 对比输出
diff v1_strings.txt v2_strings.txt | grep "^>" | head -20

提取特定语言的字符串

# 提取所有中文 (zh) 配置下的字符串
def extract_zh_strings(parser_results):
zh_strings = {}
for r in parser_results:
if r.get('type') == 'string' and 'value' in r:
cfg = r.get('config', {})
lang = cfg.get('language', '')
if lang == 'zh':
resid = r['resid']
str_val = parser.string_pool[r['value']['data']] if r['value']['type'] == 3 else r['value']['data']
zh_strings[r['key']] = str_val
return zh_strings

十七、Stale/loose 资源条目

17.1 什么是 Stale 资源

resources.arsc 中可能包含没有对应实际文件的资源条目,即 ARSC 中有引用但 res/ 目录下不存在对应文件。这种情况称为 stale resource entryloose resource

17.2 产生原因

(a) 编译残留

场景:删除 res/drawable/old_icon.png 后未 clean 构建
结果:
resources.arsc:
Type[drawable][默认]:
entry[5]: key="old_icon", value={TYPE_REFERENCE → res/drawable/old_icon.png}

res/drawable/:
old_icon.png → 已删除,不存在

运行时: getDrawable(R.drawable.old_icon) → Resources.NotFoundException

(b) aapt2 –no-auto-version

AAPT2 的 --no-auto-version 标志会抑制自动版本化资源(如 values-v21/ 的自动选择),可能留下只存在于 ARSC 中但无对应配置文件的条目。

(c) 手动 ARSC 注入

攻击者或加固工具可以手动修改 resources.arsc,插入:

  • 虚假资源引用:指向不存在的文件,用于反逆向诱饵
  • 隐藏字符串:在 ARSC StringPool 中插入但在正常反编译中不可见的字符串(反编译工具可能忽略未引用的池条目)
  • 后门配置:仅在特定极端配置下生效的恶意值

(d) 增量构建不一致

多模块项目中,如果资源表在不同模块间不同步,可能出现:

模块 A 的 flat 文件引用了 res/drawable/module_a_icon.png
模块 B 的编译产物中不包含该文件
链接后 ARSC 中保留了引用,但最终 APK 中无对应文件

17.3 检测方法

def detect_stale_resources(arsc_path, apk_res_dir):
"""检测 ARSC 中引用但 res/ 目录下未找到的文件"""
parser = ARSCParser(arsc_path)
results = parser.parse()

# 提取所有 drawable 引用
for r in results:
if r.get('type') in ('drawable', 'layout', 'xml', 'anim', 'raw'):
key = r['key']
# 检查对应文件是否存在
expected_paths = find_files(apk_res_dir, key)
if not expected_paths:
print(f"[STALE] {r['type']}/{r['key']} (0x{r['resid']:08X}) "
f"— ARSC 中引用但未找到对应文件")

17.4 逆向工程意义

对于逆向分析师:
1. stale 条目可能是已废弃功能的残留线索
2. 隐藏的 StringPool 条目可能包含未使用的调试/后门字符串
3. 分析 entry 与文件的对齐关系可判断 APK 是否被二次打包修改

对于安全工程师:
1. 检测 ARSC 中的可疑条目(指向非标准路径的资源)
2. 验证 ARSC 中声明资源与实际文件的完整性
3. 甄别 RRO overlay 注入的额外资源

17.5 利用与缓解

利用:
- 在 ARSC StringPool 中隐藏反逆向字符串(不通过 entry 引用)
- 创建仅特定配置下才激活的隐藏资源
- 伪造资源引用干扰自动化分析工具

检测:
- 扫描 StringPool 中未被任何 entry 引用的字符串
- 交叉验证 ARSC 中的文件引用与 APK 内的实际文件列表
- 使用 androguard 的 arsc.py 模块进行完整性分析

面试常考问题

Q1: resources.arscR.java 的关系?
A: R.java 是编译时生成的资源 ID 常量类,供 Java/Kotlin 代码引用(如 R.string.app_name → 0x7F020001)。resources.arsc 是运行时资源查找表,根据 ID 找到实际值和配置适配。两者都必须存在且一致:R.java 在编译时链接到代码中,resources.arsc 在 APK 运行时被 AssetManager 加载。

Q2: TypeSpec 和 Type 的关系是什么?
A: TypeSpec 是元数据层,声明一个 type 有多少个 entry、每个 entry 有哪些配置变体(通过配置位掩码)。Type 是数据层,每个 Type 实例对应一种具体配置,包含该配置下所有 entry 的实际值。例如 values-zh 对应一个 Type 实例,values-en 对应另一个 Type 实例,两者共享同一个 TypeSpec。

Q3: 为什么不同的资源类型使用不同的 type ID?
A: type ID 是资源查找的第一级索引。AssetManager 在获取资源时,通过 (resourceId >> 16) & 0xFF 快速定位 type ID。不同资源类型的行为不同:string 需要字符串值,layout 需要 XML 文件路径,drawable 需要图片 bitmap。分离 type ID 使得每个类型有独立的处理逻辑和解析器。

Q4: TYPE_DYNAMIC_REFERENCE (0x07) 和 TYPE_REFERENCE (0x01) 有什么区别?
A: TYPE_DYNAMIC_REFERENCE 用于运行时动态生成的资源 ID(如 Resources.getIdentifier() 获取的资源或通过 Overlay 注入的资源)。与编译时静态确定的 TYPE_REFERENCE 不同,动态引用需要在运行时验证目标资源是否存在,且不保证在所有配置下都有一个有效值。

Q5: Android 如何处理资源缺失?
A: 当某个配置下资源不存在时:(1) 首选最接近匹配的配置(如请求 values-zh-rCN 但只有 values-zh,则使用 values-zh);(2) 退回默认配置(无语言/密度/方向指定的配置);(3) 如果完全不存在,抛出 Resources.NotFoundExceptionTypeSpec 中的配置掩码可以快速判断是否需要遍历某个 Type chunk。


参考

  • AOSP: frameworks/base/libs/androidfw/include/androidfw/ResourceTypes.h — 所有结构体定义(AOSP 10+)
  • AOSP: frameworks/base/libs/androidfw/ResourceTypes.cpp — ResTable 完整解析与配置匹配实现(match(), isBetterThan()
  • AOSP: frameworks/base/tools/aapt2/ — AAPT2 编译/链接工具源码
  • AOSP: frameworks/base/tools/aapt2/compile/ — 资源编译(XML → .flat 中间格式)
  • AOSP: frameworks/base/tools/aapt2/link/ — 资源链接(.flat → resources.arsc)
  • AOSP: frameworks/base/tools/aapt2/ResourceTable.cpp — 资源表构建核心逻辑
  • AOSP: frameworks/base/core/java/android/content/res/AssetManager.java — Java 层资源加载与 Overlay 合并
  • AOSP: frameworks/base/services/core/java/com/android/server/pm/PackageManagerService.java — RRO 包管理
  • AOSP: frameworks/base/cmds/idmap2/ — idmap 生成工具(Overlay ID 映射)
  • AOSP: frameworks/native/include/android/configuration.h — AConfiguration 常量定义
  • AOSP: frameworks/base/core/jni/android_util_AssetManager.cpp — JNI 桥接层
  • AOSP: frameworks/native/libs/arect/include/android/asset_manager.h — Native AssetManager API
  • 第三方: AndResGuard — 开源 Android 资源混淆工具
  • 第三方: apktool — APK 反编译工具(含 ARSC 解析实现)
  • 第三方: androguard — Python Android 逆向框架(androguard/core/resource/arsc.py
打赏
  • 微信
  • 支付宝

评论