逆向工程深度剖析:短视频SDK中get_token协议的三重加密机制与动态调试实战
在移动应用安全研究领域,协议逆向分析始终是理解客户端与服务器交互逻辑的核心环节。近期,某主流短视频应用的SDK中get_token协议因其复杂的加密流程引起了安全社区的广泛关注。这个协议不仅承载着设备身份认证的关键功能,更采用了zlib压缩、自定义TEA算法、AES加密三重防护机制,形成了相当坚固的防御体系。对于逆向工程师和安全研究人员而言,完整还原这一加密流程不仅能加深对现代移动安全防护的理解,更能为自动化协议分析、风控对抗等场景提供宝贵的技术积累。
本文将从一个实战逆向工程师的视角,系统性地拆解get_token协议的全套加密流程。我不会简单重复已有的分析结果,而是结合最新的动态调试技巧、ARM64架构特性以及实际工程经验,为你呈现一套可操作、可复现的完整分析方案。无论你是想深入了解移动端协议加密的防御思路,还是需要在实际项目中应对类似的保护机制,这篇文章都将提供切实可行的技术路径。
1. 环境搭建与初步侦查
在开始深入分析之前,搭建一个稳定可靠的调试环境至关重要。与传统的静态分析不同,面对现代加固的SDK,动态调试往往是唯一可行的突破口。
1.1 设备与工具准备
我推荐使用真机调试而非模拟器,因为许多设备指纹获取和加密逻辑在模拟器上可能无法正常工作。以下是我的标准配置清单:
- 测试设备:Android 9-12版本的手机(建议Root权限)
- 抓包工具:Charles或mitmproxy,配置SSL证书绕过
- 逆向框架:Frida 15.x以上版本,支持ARM64指令集
- 反汇编工具:IDA Pro 7.7+或Ghidra,两者各有优势
- 辅助脚本:自定义的Frida脚本库,包含常用Hook函数
注意:在实际操作中,目标应用可能会检测Frida等调试工具。你需要准备相应的反检测方案,比如修改Frida-server的端口、名称,或者使用定制版的Frida。
1.2 协议入口定位
通过抓包分析,我们可以清晰地看到get_token请求的基本特征:
POST /api/v2/get_token HTTP/1.1
Host: api.example.com
Content-Type: application/octet-stream
User-Agent: Dalvik/2.1.0 (Linux; U; Android 10; MI 6 Build/OPM1.171019.011)
[加密的二进制数据]
请求体是经过多层加密的二进制数据,无法直接解析。我们的首要任务是找到SDK中处理这个请求的代码位置。
快速定位技巧:
- 使用Frida Hook网络库的关键函数,如
OkHttpClient.newCall()或底层socket相关函数 - 通过堆栈回溯找到业务层代码
- 搜索特征字符串,如"get_token"、"mssdk"等
我常用的Frida Hook脚本框架如下:
// 网络请求拦截基础脚本
Interceptor.attach(Module.findExportByName("libc.so", "send"), {
onEnter: function(args) {
var fd = args[0].toInt32();
var buffer = args[1];
var length = args[2].toInt32();
if (length > 100) { // 过滤小数据包
var data = buffer.readByteArray(length);
console.log("send called, fd=" + fd + ", length=" + length);
// 检查是否包含特定协议特征
var hexData = hexdump(data, { offset: 0, length: Math.min(length, 64) });
if (hexData.indexOf("0a9d05") !== -1) { // 协议特征
console.log("Found get_token request!");
console.log(hexData);
// 打印调用栈
console.log(Thread.backtrace(this.context, Backtracer.ACCURATE)
.map(DebugSymbol.fromAddress).join('\n'));
}
}
}
});
通过这种方式,我很快定位到了加密函数位于libmetasec_ov.so这个安全模块中。这个发现很有价值——说明加密逻辑被放在了独立的加固模块中,这是现代移动安全防护的常见做法。
2. 加密流程的三层架构解析
经过动态跟踪和静态分析,我确认get_token协议采用了典型的三层加密架构。这种设计不仅增加了逆向难度,也提高了协议的整体安全性。
2.1 第一层:zlib压缩与数据预处理
在加密之前,原始协议数据会先经过zlib压缩。这步操作看似简单,但实际上隐藏着几个关键细节:
压缩前的数据格式: 原始数据是Protobuf序列化后的二进制流,包含以下关键字段:
| 字段编号 | 字段类型 | 含义 | 示例值 |
|---|---|---|---|
| 1 | string | 设备ID | "216e6f74736574" |
| 2 | string | 设备型号 | "Xiaomi MI 6" |
| 3 | string | Android版本 | "11" |
| 4 | int32 | 屏幕分辨率 | 1080x1920 |
| 5 | bytes | 设备指纹 | [动态生成] |
| 6 | string | 时区 | "America/Los_Angeles" |
压缩实现细节: SDK使用的是标准zlib库,但调用方式有些特殊:
// 伪代码表示压缩流程
size_t compressed_size = compressBound(original_size);
unsigned char* compressed_data = malloc(compressed_size);
// 关键参数:压缩级别为Z_BEST_COMPRESSION
int result = compress2(compressed_data, &compressed_size,
original_data, original_size,
Z_BEST_COMPRESSION);
提示:在实际调试中,你可以Hook
compress2函数来捕获压缩前后的数据。这对于验证后续加密步骤的正确性非常有帮助。
压缩后的特殊处理: 压缩后的数据并不是直接进入下一层加密,而是会添加一个可变长度的头部。这个头部的生成逻辑相当巧妙:
def generate_header(compressed_data, random_address):
"""
生成压缩数据的头部
:param compressed_data: zlib压缩后的数据
:param random_address: 内存随机地址(用于增加随机性)
:return: 头部字节 + 压缩数据
"""
data_len = len(compressed_data)
# 第一个字节:基于随机地址和长度计算
w9 = (-data_len) & 0x7
w10 = w9 ^ 7
w9 = (w9 << 1) & 0b111
w24 = (w9 + w10) & 0xFFFFFFFF
w9 = (w24 + 3) if w24 < 0 else w24
w9 = w9 & 0xFFFFFFFC
header_len = (w24 - w9) & 0xFFFFFFFF
# 计算校验值
checksum = calculate_checksum(compressed_data)
# 构建头部
header = bytearray()
header.append((random_address & 0xFF)) # 随机地址末字节
header.extend(checksum.to_bytes(header_len, 'big'))
return bytes(header) + compressed_data
这个头部设计有几个精妙之处:
- 长度可变:根据压缩数据长度动态计算,增加分析难度
- 引入随机性:使用内存地址作为随机源,相同输入可能产生不同输出
- 校验机制:头部包含对压缩数据的校验,确保数据完整性
2.2 第二层:自定义TEA算法加密
这是整个加密流程中最复杂的一环。SDK没有使用标准的TEA或XTEA,而是实现了一个高度自定义的变种算法。
2.2.1 算法核心逻辑
通过逆向0x113854处的函数,我还原出了完整的加密算法:
class CustomTEA:
def __init__(self, key: bytes):
"""
初始化TEA密钥
:param key: 16字节密钥,前8字节为动态计算,后8字节为固定值
"""
if len(key) != 16:
raise ValueError("Key must be 16 bytes")
self.key = key
self.delta = 0x9E3779B9 # 标准TEA的delta值
def _encrypt_block(self, v0: int, v1: int, rounds: int) -> tuple:
"""
加密单个64位块
"""
sum_val = 0
for _ in range(rounds):
# 第一轮变换
temp1 = ((v1 << 4) & 0xFFFFFFFF) ^ (v1 >> 5)
temp2 = ((v1 << 4) & 0xFFFFFFFF) & (v1 >> 5)
temp1 = (temp1 + temp2) & 0xFFFFFFFF
key_index = sum_val & 0x3
key_part = int.from_bytes(self.key[key_index*4:key_index*4+4], 'little')
temp1 = (temp1 + key_part) & 0xFFFFFFFF
sum_val = (sum_val + self.delta) & 0xFFFFFFFF
v0 = (v0 + temp1) & 0xFFFFFFFF
# 第二轮变换
temp1 = ((v0 << 4) & 0xFFFFFFFF) ^ (v0 >> 5)
temp2 = ((v0 << 4) & 0xFFFFFFFF) & (v0 >> 5)
temp1 = (temp1 + temp2) & 0xFFFFFFFF
key_index = (sum_val >> 11) & 0x3
key_part = int.from_bytes(self.key[key_index*4:key_index*4+4], 'little')
temp1 = (temp1 + key_part) & 0xFFFFFFFF
sum_val = (sum_val + self.delta) & 0xFFFFFFFF
v1 = (v1 + temp1) & 0xFFFFFFFF
return v0, v1
def encrypt(self, data: bytes, xor_key: bytes) -> bytes:
"""
完整加密流程
"""
# 数据填充到8字节对齐
padding_len = (8 - len(data) % 8) % 8
padded_data = data + bytes([0] * padding_len)
# 计算加密轮数(基于随机数)
random_seed = int.from_bytes(xor_key[:4], 'big')
rounds = self._calculate_rounds(random_seed)
# CBC模式的加密
result = bytearray()
prev_block = xor_key
for i in range(0, len(padded_data), 8):
block = padded_data[i:i+8]
# 与前一个密文块异或
489


&spm=1001.2101.3001.11974&articleId=151243342&d=1&t=3&u=8b0cb63d0b1f4b1c9b8247e2233a05ea)

被折叠的 条评论
为什么被折叠?



