应用安全 --- apk加固 之 流量签名
什么是流量签名,是一种流量安全加固方法,通过签名流量可以防止数据篡改和流量重放攻击。流量签名的核心就是算法分析,要找到这个签名是如何加密生成的,从而模拟生成的过程。
技术原理简要拆解
-
发送方:
-
准备数据:生成要发送的原始数据(例如:
amount=100&to=Bob
)。 -
生成签名:使用只有发送方和接收方才知道的密钥(Secret Key),对一个由“原始数据 + 时间戳/随机数”组合成的字符串,通过签名算法(如HMAC-SHA256)计算得到一个唯一的、固定长度的字符串,这个字符串就是签名。
-
发送:将
原始数据
、时间戳/随机数
和计算得到的签名
一起发送给接收方。
-
-
接收方:
-
接收数据:收到数据包,里面包含
原始数据
、时间戳/随机数
和签名
。 -
验证重放:检查
时间戳
是否在有效时间窗口内(如5分钟内),或检查随机数
是否之前已经使用过。如果不符合,直接拒绝请求。 -
验证签名:使用同样的密钥和签名算法,对自己收到的
原始数据
和时间戳/随机数
重新计算一次签名。 -
对比:将自己计算出的签名与收到的签名进行比对。如果完全一致,说明数据完整且来自可信方;如果不一致,说明数据被篡改或来源不可信,立即丢弃。
-
举例说明:一个API支付请求
假设有一个支付API,没有签名时,请求可能是这样的:
POST /pay HTTP/1.1 Host: api.example.com Content-Type: application/json { "from": "user_123", "to": "merchant_456", "amount": 1000.00 }
攻击者可以轻松截获这个请求,修改 amount
为 10000.00
,然后重放发送,就会造成巨大损失。
加入了流量签名后,请求会变成这样:
1. 客户端(发送方)构造请求:
-
原始数据:
{"from":"user_123","to":"merchant_456","amount":1000.00}
-
加时间戳:
1678886400
(一个当前的UNIX时间戳) -
生成签名字符串:将原始数据和时间戳按约定格式拼接。
-
例如:
1678886400|{"from":"user_123","to":"merchant_456","amount":1000.00}
-
-
计算签名:使用密钥
my_secret_key
,通过HMAC-SHA256算法计算上述字符串的签名。-
得到签名:
f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8
-
-
发送最终请求:
POST /pay HTTP/1.1 Host: api.example.com Content-Type: application/json X-Signature: f7bc83f430538424b13298e6aa6fb143ef4d59a14946175997479dbc2d1a3cd8 X-Timestamp: 1678886400 { "from": "user_123", "to": "merchant_456", "amount": 1000.00 }
2. 服务端(接收方)验证:
-
检查时间戳:发现时间戳
1678886400
是2秒前发出的,在允许的60秒时间窗口内,不是重放请求。 -
验证签名:
-
同样使用密钥
my_secret_key
,将收到的时间戳1678886400
和收到的请求体原文拼接成1678886400|{...}
。 -
用同样的HMAC-SHA256算法计算签名。
-
将自己计算出的签名与请求头中的
X-Signature
值进行对比。
-
-
结果:
-
如果一致:处理支付请求。
-
如果不一致:返回
401 Unauthorized
错误,拒绝请求。
-
总结:流量签名的优点
-
防篡改:任何对数据(URL参数、请求体等)的修改都会导致签名验证失败。
-
防重放:通过时间戳或随机数机制,使得截获的请求无法被再次使用。
-
验证身份:因为只有拥有密钥的合法客户端才能生成正确的签名,所以服务器可以确认请求来自可信的客户端。
它是一种非常有效且广泛应用的安全手段,常见于API接口设计、移动App与后端通信、支付系统等关键场景。它与SSL/TLS加密(HTTPS)是互补的关系:HTTPS保证传输过程不被窃听,而流量签名保证数据本身不被篡改和伪造。因为你不知道这个签名的生成规则导致你无法修改数据保证签名也正确。但是如果签名规则知道后这个安全就不复存在。
我们用一个具体的app去举例说明复现完整的签名生成流程:
详细解析了newSign、X-Auth-Token和uuid(duuid)的生成机制:
核心发现:
newSign算法:
newSign = MD5(AES_ENCRYPT(sorted_params_string))
参数按key字典序排序拼接
使用JNI实现的AES加密 AES密钥:存储在native库libJNIEncrypt.so中,这是最大的技术难点
最后进行MD5哈希
newSign 加密解密流程分析
基于so目录下的反汇编代码和Java层的实现,我来详细解释newSign的加密解密流程:
1. 整体架构
Java层调用链:
WebRequestInterceptor → RequestUtils → AESEncrypt (JNI) → libJNIEncrypt.so
2. newSign生成流程
第一步:参数收集和排序
在WebRequestInterceptor.java中,系统会:
收集请求参数(GET参数或POST body)
添加固定参数:
map.put("uuid", DuHttpConfig.d.getUUID());
map.put("platform", "android");
map.put("v", DuHttpConfig.d.getAppVersion());
map.put("loginToken", DuHttpConfig.d.getLoginToken());
map.put("timestamp", String.valueOf(currentTimeMillis));
第二步:字符串拼接
在RequestUtils.java中:
将参数按key排序
拼接成字符串:key1value1key2value2...
不同的请求类型有不同的拼接方式:
RequestUtils.a(): 使用FastJson处理复杂对象
RequestUtils.b(): 使用Gson处理
RequestUtils.c(): 处理简单字符串参数
第三步:AES加密
调用AESEncrypt.encode(context, stringToSign):
获取密钥:通过getByteValues()获取密钥字符串
密钥处理:将密钥中的'0'替换为'1','1'替换为'0'
AES加密:调用JNI层的encodeByte()方法
第四步:JNI层加密实现
在libJNIEncrypt.so中:
AES-128-ECB-PKCS5Padding加密:
char* AES_128_ECB_PKCS5Padding_Encrypt(const char* plainText, const char* key)
对明文进行PKCS5填充
使用AES-128-ECB模式加密
将加密结果进行Base64编码
核心AES实现:
AES128_ECB_encrypt(): 执行标准AES轮函数
包含SubBytes、ShiftRows、MixColumns、AddRoundKey等操作
第五步:最终签名
对AES加密+Base64编码的结果进行MD5哈希
返回32位小写MD5字符串作为newSign
3. 安全机制
应用完整性检查
int check_signature(JNIEnv *env, jobject context, jclass clazz)
获取应用签名的hashCode
与硬编码值-625603286比较
防止应用被重新打包
模拟器检测
int check_is_emulator(JNIEnv *env, jobject thiz)
检查Build.FINGERPRINT、MODEL、MANUFACTURER等
识别"generic"、"google_sdk"、"Emulator"等特征
防止在模拟器环境下运行
4. 解密流程
解密是加密的逆过程:
Base64解码:b64_decode_ex()
AES解密:AES_128_ECB_PKCS5Padding_Decrypt()
去除PKCS5填充
还原明文
5. 关键要点
密钥来源:通过JNI的getByteValues()获取,具体值在so文件中硬编码
加密算法:AES-128-ECB + PKCS5Padding + Base64 + MD5
参数顺序:严格按字典序排序,确保签名一致性
时间戳:使用服务器时间偏移量校正的时间戳
防护机制:签名校验 + 模拟器检测 + JNI混淆
这个newSign机制通过多层加密和校验,有效防止了API请求的伪造和重放攻击,是一个相对完善的移动端API安全方案。
X-Auth-Token:
就是JWT Token,通过ServiceManager.a().getJwtToken()获取
需要有效的登录状态
UUID (duuid):
UUID机制分析
1. UUID在签名中的作用
- UUID作为网络请求签名的核心参数之一
- 参与AES加密签名的生成过程
- 在HTTP请求头中以"duuuid"字段传输
2. UUID生成的真实实现
关键发现: 在 RestClientTask.java
的 CoreHeaderConfigImpl
类中找到了UUID的真实实现:
@Override
public String getUUID() {
PatchProxyResult proxy = PatchProxy.proxy(new Object[0], this, changeQuickRedirect, false, 782, new Class[0], String.class);
return proxy.isSupported ? (String) proxy.result : HPDeviceInfo.b(DuHttpConfig.f15798c).a((Activity) null);
}
核心逻辑:
- UUID通过
HPDeviceInfo.b(context).a(null)
方法生成 - 这是一个基于设备信息的UUID生成器
- 传入应用上下文,返回设备唯一标识
3. HPDeviceInfo完整分析
UUID的生成依赖于 HPDeviceInfo
类的具体实现:
public class HPDeviceInfo {
private Context context;
private String cachedUUID; // f15205b字段
@SuppressLint({"CheckResult", "MissingPermission", "HardwareIds"})
@Deprecated
public String a(Activity activity) {
// 如果已有缓存的UUID,直接返回
String str = this.f15205b;
if (str != null) {
return str;
}
if (activity != null) {
// 有Activity时,尝试获取READ_PHONE_STATE权限
TelephonyManager telephonyManager = (TelephonyManager) activity.getApplication().getSystemService("phone");
new RxPermissions(activity).c("android.permission.READ_PHONE_STATE").subscribe(new Consumer() {
@Override
public void accept(Boolean hasPermission) {
if (hasPermission) {
// 有权限时使用IMEI作为UUID
f15205b = telephonyManager.getDeviceId();
} else {
// 无权限时使用Android ID作为UUID
f15205b = a(); // 调用获取Android ID的方法
}
}
});
} else {
// 无Activity时直接使用Android ID
this.f15205b = a();
}
return this.f15205b;
}
@SuppressLint({"HardwareIds"})
@Deprecated
public String a() {
// 获取Android ID作为UUID
return Settings.Secure.getString(this.context.getContentResolver(), "android_id");
}
}
4. UUID生成逻辑总结
UUID生成的优先级:
- 缓存优先:如果已有UUID缓存,直接返回
- IMEI优先:如果有READ_PHONE_STATE权限,使用设备IMEI
- Android ID兜底:无权限或无Activity时,使用Android ID
关键特点:
- UUID是设备级别的唯一标识
- 优先使用IMEI(需要权限)
- 兜底使用Android ID(无需权限)
- 生成后会缓存在内存中
3. UUIDpj方法
方法1:Hook获取真实UUID
// 使用Xposed或Frida Hook getUUID方法
public class UUIDHook {
public void hookUUID() {
// Hook DuHttpConfig.CoreHeaderConfig.getUUID()
XposedHelpers.findAndHookMethod(
"com.shizhuang.duapp.common.net.DuHttpConfig.CoreHeaderConfig",
lpparam.classLoader,
"getUUID",
new XC_MethodHook() {
@Override
protected void afterHookedMethod(MethodHookParam param) {
String uuid = (String) param.getResult();
Log.d("UUID_HOOK", "Real UUID: " + uuid);
}
}
);
}
}
方法2:逆向分析存储位置
# 查找UUID存储位置
adb shell
cd /data/data/com.shizhuang.duapp/
find . -name "*.xml" -o -name "*.db" | xargs grep -l "uuid\|UUID"
# 检查MMKV存储
cd shared_prefs/
ls -la | grep -E "(device|uuid|du_)"
方法3:网络抓包分析
# 抓包分析UUID规律
import re
import hashlib
def analyze_uuid_pattern(captured_requests):
uuids = []
for request in captured_requests:
uuid = request.headers.get('duuuid')
if uuid:
uuids.append(uuid)
# 分析UUID长度、格式、规律
print(f"UUID样本数量: {len(uuids)}")
print(f"UUID长度: {len(uuids[0]) if uuids else 0}")
print(f"UUID格式: {uuids[0] if uuids else 'None'}")
return uuids
4. 实际pj步骤
步骤1:动态调试获取UUID
// Frida脚本获取UUID
Java.perform(function() {
var CoreHeaderConfig = Java.use("com.shizhuang.duapp.common.net.DuHttpConfig$CoreHeaderConfig");
CoreHeaderConfig.getUUID.implementation = function() {
var result = this.getUUID();
console.log("[+] UUID获取: " + result);
return result;
};
});
步骤2:分析UUID生成算法
// 可能的UUID验证逻辑
public boolean validateUUID(String uuid) {
// 检查UUID格式
if (uuid.length() != 32) return false;
// 检查是否为纯数字或包含字母
if (!uuid.matches("[a-f0-9]{32}")) return false;
// 可能的校验逻辑
String deviceFingerprint = getDeviceFingerprint();
String expectedUUID = MD5Utils.md5(deviceFingerprint);
return uuid.equals(expectedUUID);
}
步骤3:构造有效UUID
import hashlib
import uuid
def generate_valid_uuid():
# 方案1:随机UUID
random_uuid = str(uuid.uuid4()).replace('-', '')
# 方案2:基于设备信息
device_info = "imei_androidid_oaid" # 替换为实际设备信息
device_uuid = hashlib.md5(device_info.encode()).hexdigest()
# 方案3:时间戳UUID
timestamp = str(int(time.time() * 1000))
time_uuid = hashlib.md5(timestamp.encode()).hexdigest()
return random_uuid, device_uuid, time_uuid
5. 防护绕过
绕过签名校验
// Hook签名校验方法
public void bypassSignatureCheck() {
XposedHelpers.findAndHookMethod(
"com.shizhuang.duapp.common.utils.RequestUtils",
lpparam.classLoader,
"c",
Map.class,
long.class,
new XC_MethodHook() {
@Override
protected void beforeHookedMethod(MethodHookParam param) {
Map<String, String> params = (Map<String, String>) param.args[0];
// 替换UUID为我们构造的值
params.put("uuid", "your_constructed_uuid");
}
}
);
}
6. 总结
UUIDpj的关键在于:
- 理解UUID在签名算法中的作用
- 找到UUID的生成和存储逻辑
- 通过动态调试获取真实UUID
- 分析UUID的生成规律
- 构造符合规律的有效UUID
建议使用Frida进行动态分析,这是最直接有效的方法。
5.
UUIDpj方法
方法1:直接获取设备标识符
// 根据源码逻辑,直接获取UUID
public String getDeviceUUID(Context context) {
try {
// 优先尝试获取IMEI(需要权限)
if (ContextCompat.checkSelfPermission(context, Manifest.permission.READ_PHONE_STATE)
== PackageManager.PERMISSION_GRANTED) {
TelephonyManager tm = (TelephonyManager) context.getSystemService(Context.TELEPHONY_SERVICE);
String imei = tm.getDeviceId();
if (!TextUtils.isEmpty(imei)) {
return imei;
}
}
// 兜底使用Android ID
return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
} catch (Exception e) {
// 异常时返回Android ID
return Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID);
}
}
方法2:Frida Hook获取UUID
// Frida脚本Hook UUID生成
Java.perform(function() {
// Hook HPDeviceInfo的UUID生成方法
var HPDeviceInfo = Java.use("com.shizhuang.duapp.common.helper.HPDeviceInfo");
HPDeviceInfo.a.overload('android.app.Activity').implementation = function(activity) {
var result = this.a(activity);
console.log("[+] HPDeviceInfo.a() UUID: " + result);
return result;
};
HPDeviceInfo.a.overload().implementation = function() {
var result = this.a();
console.log("[+] HPDeviceInfo.a() Android ID: " + result);
return result;
};
// Hook最终的getUUID调用
var CoreHeaderConfigImpl = Java.use("com.shizhuang.duapp.common.base.delegate.tasks.net.RestClientTask$CoreHeaderConfigImpl");
CoreHeaderConfigImpl.getUUID.implementation = function() {
var result = this.getUUID();
console.log("[+] Final UUID: " + result);
return result;
};
});
方法3:系统命令获取
# 直接通过ADB获取Android ID
adb shell settings get secure android_id
# 获取设备IMEI(需要权限)
adb shell service call iphonesubinfo 1 | cut -c 52-66 | tr -d '.[:space:]'
6. 实际pj步骤
步骤1:确定UUID类型
# 检查设备是否有READ_PHONE_STATE权限
adb shell dumpsys package com.shizhuang.duapp | grep "android.permission.READ_PHONE_STATE"
# 如果有权限,UUID = IMEI
# 如果无权限,UUID = Android ID
步骤2:获取对应的设备标识
# 获取Android ID(通用方法)
adb shell settings get secure android_id
# 获取IMEI(如果应用有权限)
adb shell dumpsys telephony.registry | grep mImei
步骤3:验证UUID
// 使用Frida验证获取的UUID是否正确
Java.perform(function() {
var CoreHeaderConfigImpl = Java.use("com.shizhuang.duapp.common.base.delegate.tasks.net.RestClientTask$CoreHeaderConfigImpl");
CoreHeaderConfigImpl.getUUID.implementation = function() {
var realUUID = this.getUUID();
var myUUID = "your_obtained_uuid_here"; // 替换为你获取的UUID
console.log("[+] Real UUID: " + realUUID);
console.log("[+] My UUID: " + myUUID);
console.log("[+] Match: " + (realUUID === myUUID));
return realUUID;
};
});
7. 总结
UUIDpj的核心发现:
-
UUID就是设备标识符:
- 有READ_PHONE_STATE权限时 = IMEI
- 无权限时 = Android ID
-
获取方法简单直接:
- 不需要复杂的算法逆向
- 直接读取系统设备标识即可
-
验证方法:
- 使用Frida Hook验证
- 对比网络请求中的duuuid字段
-
实用价值:
- 可用于构造有效的网络请求
- 绕过基于UUID的设备验证
- 理解应用的设备指纹机制
最简单的获取方法:
# 大多数情况下,UUID就是Android ID
adb shell settings get secure android_id
这个发现大大简化了UUID的pj过程,不需要复杂的逆向分析,直接获取系统设备标识符即可。
PatchProxy.proxy() 调用是美团Robust热修复框架的核心机制,让我通俗地解释一下: 🔧 PatchProxy的作用 简单理解:代码的"替身演员" 想象一下电影拍摄: 原始演员 = 原始的方法代码 替身演员 = 热修复补丁代码 导演 = PatchProxy.proxy() PatchProxyResult proxy = PatchProxy.proxy( new Object[0], // 方法参数(这里是空数组,因为getUUID()无参数) this, // 当前对象实例 changeQuickRedirect, // 热修复开关(是否启用补丁) false, // 是否是静态方法 782, // 方法的唯一ID标识 new Class[0], // 参数类型(空数组) String.class // 返回值类型 ); 工作流程: @Override public String getUUID() { // 1. 首先检查:有没有热修复补丁? PatchProxyResult proxy = PatchProxy.proxy(...); // 2. 判断结果 if (proxy.isSupported) { // 有补丁!使用补丁代码的结果 return (String) proxy.result; } else { // 没有补丁,执行原始代码 return HPDeviceInfo.b(DuHttpConfig.f15798c).a((Activity) null); } } 🎯 实际意义 1. 热修复能力 // 假设线上发现UUID生成有bug,可以推送补丁: // 补丁代码可能是: public String getUUID_patch() { return "fixed_uuid_logic_here"; } 2. 动态替换 不需要重新发版 不需要用户更新App 服务器推送补丁即可修复 3. 开发调试 // 开发时可以动态替换方法实现 if (DEBUG_MODE) { // 返回测试UUID return "test_uuid_12345"; } else { // 返回正常UUID return realUUID; } 📝 代码模式解析 这是得物App中每个方法都有的标准模式: public ReturnType methodName(params...) { // 第一步:检查热修复 PatchProxyResult proxy = PatchProxy.proxy( new Object[]{params...}, // 传入所有参数 this, // 当前对象 changeQuickRedirect, // 热修复开关 false, // 是否静态方法 UNIQUE_ID, // 方法唯一标识 new Class[]{ParamTypes...}, // 参数类型 ReturnType.class // 返回类型 ); // 第二步:判断是否有补丁 if (proxy.isSupported) { return (ReturnType) proxy.result; // 使用补丁结果 } // 第三步:执行原始逻辑 // ... 原始方法代码 ... } 🚀 对逆向分析的影响 好消息: 大部分情况下 proxy.isSupported 为 false 所以会执行原始代码逻辑 我们分析的 HPDeviceInfo.b().a() 就是真实逻辑 需要注意: 如果App推送了UUID相关的热修复补丁 实际运行的可能不是我们看到的代码 可以通过Frida Hook来验证实际执行的逻辑 🔍 验证方法 // Frida脚本验证是否有热修复补丁 Java.perform(function() { var PatchProxy = Java.use("com.meituan.robust.PatchProxy"); PatchProxy.proxy.implementation = function(args, obj, redirect, isStatic, methodId, paramTypes, returnType) { var result = this.proxy(args, obj, redirect, isStatic, methodId, paramTypes, returnType); if (methodId == 782) { // getUUID方法的ID console.log("[+] getUUID方法调用"); console.log("[+] 是否有补丁: " + result.isSupported); if (result.isSupported) { console.log("[+] 补丁返回值: " + result.result); } } return result; }; }); 总结: PatchProxy就像一个"智能开关",决定是执行原始代码还是补丁代码,这是现代Android App常见的热修复机制。