bili sign算法分析
选择一个老版本的bili练手,本文选择的是8.14.0 。还是走unidbg模拟执行分析算法的流程。
粗略分析
- 抓包发现参数
2. 参数位置定位
3. frida hook 确定。
上面是随意一个hook记录可以看出输入的是一个map,返回一个对象类型,也可以直接hook返回类型的构造函数,能验证结果即可。
哔哩哔哩app一直有frida检测,这里也尝试了不少网上的内容但是这个版本不知道为何都跳不过检测。最后总结所有网上的线索很粗略的绕过了。线索如下:- 检测在libmsaoaidsec.so文件的
.init_proc()
函数中。 - 使用
pthread_create
函数创建检测进程,不管是通过地址还是通过dlsym
函数调用
- 检测在libmsaoaidsec.so文件的
所以结论就是在libmsaoaidsec.so .init_proc()
函数中调用dlsym
的函数大概率就是检测函数,直接给检测函数置空就能跳过frida检测。
所以在如上所示的伪代码中瞎翻翻,练手时候运气不错直接就碰到了。当然这是运气,实际可以去看看关于此文件的检测手段,然后看交叉引用啥的也可以判断出检测函数位置。本文这里直接替换的函数是sub_1B924()
。
let skip_functions = [];
function hook_dlopen(soName='') {
Interceptor.attach(Module.findExportByName(null, "android_dlopen_ext"),
{
onEnter: function (args) {
var pathptr = args[0];
if (pathptr !== undefined && pathptr != null) {
var path = ptr(pathptr).readCString();
console.log("load " + path);
if (path.indexOf(soName) !== -1) {
console.log("locate init.")
locate_init(soName);
this.canHook = true;
}
// if (path.indexOf("libbili.so") !== -1) {
// this.canHook = true;
// }
}
},
onLeave: function (retval) {
if (this.canHook) {
// hook_sign();
hook_libbili();
}
}
}
);
}
function locate_init(soName) {
let secmodule = null
Interceptor.attach(Module.findExportByName(null, "__system_property_get"),
{
// _system_property_get("ro.build.version.sdk", v1);
onEnter: function (args) {
secmodule = Process.findModuleByName(soName)
var name = args[0];
if (name !== undefined && name != null) {
name = ptr(name).readCString();
if (name.indexOf("ro.build.version.sdk") >= 0) {
hook_detect(soName);
}
}
}
}
);
}
function hook_detect(soName) {
var mo = Process.findModuleByName(soName);
console.log("libmsaoaidsec.so --- " + mo.base)
hook_anti_frida_replace(mo.base.add(0x1b924));
}
function hook_anti_frida_replace(addr){
console.log('replace anti_addr :',addr);
if (addr !== undefined && addr != null && !skip_functions.includes(addr) ) {
Interceptor.replace(addr,new NativeCallback(function(a1){
console.log('replace success');
return;
},'pointer',[]));
skip_functions.push(addr);
}
}
function hook_libbili() {
var LibBili = Java.use("com.bilibili.nativelibrary.LibBili");
LibBili["so"].overload('java.util.SortedMap', 'int', 'int').implementation = function (sortedMap, i14, i15) {
var Map = Java.use('java.util.TreeMap');
var args_x = Java.cast(sortedMap, Map);
console.log(`LibBili.m159700so is called: sortedMap=${args_x.toString()}, i14=${i14}, i15=${i15}`);
var result = this["so"](sortedMap, i14, i15);
console.log(`LibBili.m159700so result=${result}\n`);
return result;
};
}
function hook_sign() {
var SignedQuery = Java.use("com.bilibili.nativelibrary.SignedQuery");
SignedQuery["$init"].implementation = function (str, str2) {
console.log(`SignedQuery.$init is called: str=${str}, str2=${str2}`);
this["$init"](str, str2);
};
}
Java.perform(() => {
hook_dlopen("libmsaoaidsec.so");
// hook_libbili();
})
上面是随意瞎写的hook代码,当然还有很大的问题,但是已经可以hook验证了。
sign算法分析
unidbg 模拟执行
主要是使用unidbg模拟执行。
- 搭建一个unidbg基础架子,直接调用目标so
public class Sign814Demo1 extends AbstractJni {
private final AndroidEmulator emulator;
private final VM vm;
private final Module module;
private final Memory memory;
private DvmClass libBili;
public Sign814Demo1() {
emulator = AndroidEmulatorBuilder
.for64Bit()
.setProcessName("tv.danmaku.bili")
.addBackendFactory(new Unicorn2Factory(true)) // 为了添加多线程支持
.build();
memory = emulator.getMemory();
memory.setLibraryResolver(new AndroidResolver(23));
vm = emulator.createDalvikVM();
vm.setJni(this);
vm.setVerbose(true);
DalvikModule dm = vm.loadLibrary(new File("data/bili/libbili.so"), true);
module = dm.getModule();
dm.callJNI_OnLoad(emulator);
libBili = vm.resolveClass("com/bilibili/nativelibrary/LibBili");
}
public static void main(String[] args) {
Sign814Demo1 demo = new Sign814Demo1();
}
}
- 调用目标函数
public void callSign() {
// trace 日志
// try {
// PrintStream out = new PrintStream(new File("bili814sign.log"));
// emulator.traceCode().setRedirect(out);
// } catch (Exception e) {
// e.printStackTrace();
// }
String method = "so(Ljava/util/SortedMap;II)Lcom/bilibili/nativelibrary/SignedQuery;";
// user space
SortedMap<String, String> map = new TreeMap<String, String>();
map.put("ad_extra", "E86F4CFF1F8FA890A75155EEAA51E6AE4FA9DBE62FCE708186D0CE5EF37B86948620D8BA1D991685B1288E2EDE09C6D52F8C2D33D59872EAE1EB776D11F71523CE1AF2112D8A950B98F6A1A48F848BC6871A849C3ED14308F46431A85625726A929A8906FA0C16FEE2CEB33209AE6F1E0C6856961045F53A0FE3470E4E223F48DAE7923040EAC4541BE6F728DEA350329AC40887CB773083BB4D6D91DCCCDF8DF936B6F029F2D92E3F28B59F61591BBDBCCAD20B53FAEFA0EAC13FA6BA72D6EE45A5C2B4A9F5A7675BD3F46E4B748B95D391B22A94E3D4833064D1BD98279C61199DFC1FA847D228E9C2C81C0FA849E87B15DDA9BE05AE714074ECEB3ADBFA1CADA5AF3643A798D3C600841AAE3CC453BFEA6D3BFB9461F7D2C80148A2F8363356607A174332DC3B6AC4AB173ED08A0F88895E53414EB78930E8FF8174DEEDAAAFE51E52D57EE33A7734B9B5BC1708D4136F5285663C23BD7A026E9E4C5409BF97E1A10EA592289BF48B59C9D16E8B1F4EA3A9C6EEC852DE7E8B8079521A0D07727EDCC0CCCC04A2ACEDEB103BF6C2F788CC5633189C0390C4D1D88424634DED6A504ACDEBE7B62FEBE53C1019080B6839064AFF97A9CDF5B0D9910C28DCC46FAE01A37D39793FDD07E87B50FDF4782E2F0B03F4D1EB2AE32E0826394EC88177B7085B318ED7C9C5B57D30831CE51CAF68D23290A17468894CE1FB890F48DF11794536A70CB168164F477C7B9A207B7A90C4EE1C1D745A4749558425163C8169F4ED12A9614AD9D5CB903E29B8B76990ADF66381C04797A03974EC00E3AB8682CE221F20B8D4093A3B2739D3518AB3B7F2202D4E6D3CFE973EF285D495F08498559BFF7E0364EC08406920436229679C8B02A43415F836D96BD459C6D9826EDDC7243BF7E34E1F68B6E4472C14DDF52B");
map.put("appkey", "1d8b6e7d45233436");
map.put("build", "8140300");
map.put("c_locale", "zh-Hans_CN");
map.put("channel", "alifenfa");
map.put("disable_rcmd", "0");
map.put("fnval", "464");
map.put("fnver", "0");
map.put("force_host", "0");
map.put("fourk", "1");
map.put("from", "0");
map.put("local_time", "8");
map.put("mobi_app", "android");
map.put("platform", "android");
map.put("player_net", "1");
map.put("qn", "32");
map.put("qn_policy", "1");
map.put("s_locale", "zh-Hans_CN");
map.put("statistics", "{\"appId\":1,\"platform\":3,\"version\":\"8.14.0\",\"abtest\":\"\"}");
map.put("ts", "1727231035");
map.put("vmid", "37754047");
map.put("voice_balance", "1");
// 下面的值和接口有关
int i14 = 0;
int i15 = 0;
String result = (String) libBili.callStaticJniMethodObject(emulator, method, ProxyDvmObject.createObject(vm, map), i14, i15).getValue();
System.out.println("sign: " + result);
}
- 补环境
// 补环境
@Override
public boolean callBooleanMethod(BaseVM vm, DvmObject<?> dvmObject, String signature, VarArg varArg) {
switch (signature) {
case "java/util/Map->isEmpty()Z": {
TreeMap<String, String> map = (TreeMap<String, String>) dvmObject.getValue();
System.out.println(map.toString());
return map.isEmpty();
}
}
return super.callBooleanMethod(vm, dvmObject, signature, varArg);
}
@Override
public DvmObject<?> callObjectMethod(BaseVM vm, DvmObject<?> dvmObject, String signature, VarArg varArg) {
switch (signature) {
case "java/util/Map->get(Ljava/lang/Object;)Ljava/lang/Object;": {
TreeMap<String, String> map = (TreeMap<String, String>) dvmObject.getValue();
String key = (String) varArg.getObjectArg(0).getValue();
if (key.equals("ts")) {
return new StringObject(vm, "1727226066");
}
System.out.println(key + ": " + map.get(key));
return new StringObject(vm, map.get(key));
}
}
return super.callObjectMethod(vm, dvmObject, signature, varArg);
}
@Override
public DvmObject<?> callStaticObjectMethod(BaseVM vm, DvmClass dvmClass, String signature, VarArg varArg) {
switch (signature) {
case "com/bilibili/nativelibrary/SignedQuery->r(Ljava/util/Map;)Ljava/lang/String;": {
TreeMap<String, String> map = (TreeMap<String, String>) varArg.getObjectArg(0).getValue();
return new StringObject(vm, SignedQueryR(map));
}
}
return super.callStaticObjectMethod(vm, dvmClass, signature, varArg);
}
@Override
public DvmObject<?> newObject(BaseVM vm, DvmClass dvmClass, String signature, VarArg varArg) {
switch (signature) {
case "com/bilibili/nativelibrary/SignedQuery-><init>(Ljava/lang/String;Ljava/lang/String;)V": {
String arg = (String) varArg.getObjectArg(0).getValue();
String sign = (String) varArg.getObjectArg(1).getValue();
System.out.println("arg: " + arg + " , sign: " + sign);
return vm.resolveClass("com/bilibili/nativelibrary/SignedQuery").newObject(sign);
}
}
return super.newObject(vm, dvmClass, signature, varArg);
}
// 下面是apk中的Java代码逻辑
public String SignedQueryR(Map<String, String> map) {
String urlEncode;
if (!(map instanceof SortedMap)) {
map = new TreeMap(map);
}
StringBuilder sb4 = new StringBuilder(256);
for (Map.Entry<String, String> entry : map.entrySet()) {
String key = entry.getKey();
if (!key.isEmpty()) {
sb4.append(urlEncode(key, null));
sb4.append("=");
String value = entry.getValue();
if (value == null) {
urlEncode = "";
} else {
urlEncode = urlEncode(value, null);
}
sb4.append(urlEncode);
sb4.append("&");
}
}
int length = sb4.length();
if (length > 0) {
sb4.deleteCharAt(length - 1);
}
if (length == 0) {
return null;
}
return sb4.toString();
}
public String urlEncode(String str, String str2) {
StringBuilder sb4 = null;
if (str == null) {
return null;
}
int length = str.length();
int i14 = 0;
while (i14 < length) {
int i15 = i14;
while (i15 < length && m159716a(str.charAt(i15), str2)) {
i15++;
}
if (i15 == length) {
if (i14 == 0) {
return str;
}
sb4.append((CharSequence) str, i14, length);
return sb4.toString();
}
if (sb4 == null) {
sb4 = new StringBuilder();
}
if (i15 > i14) {
sb4.append((CharSequence) str, i14, i15);
}
i14 = i15 + 1;
while (i14 < length && !m159716a(str.charAt(i14), str2)) {
i14++;
}
try {
byte[] bytes = str.substring(i15, i14).getBytes("UTF-8");
int length2 = bytes.length;
for (int i16 = 0; i16 < length2; i16++) {
sb4.append('%');
char[] cArr = "0123456789ABCDEF".toCharArray();
sb4.append(cArr[(bytes[i16] & 240) >> 4]);
sb4.append(cArr[bytes[i16] & 15]);
}
} catch (UnsupportedEncodingException e14) {
throw new AssertionError(e14);
}
}
if (sb4 != null) {
return sb4.toString();
}
return str;
}
public boolean m159716a(char c14, String str) {
if ((c14 < 'A' || c14 > 'Z') && ((c14 < 'a' || c14 > 'z') && ((c14 < '0' || c14 > '9') && "-_.~".indexOf(c14) == -1 && (str == null || str.indexOf(c14) == -1)))) {
return false;
}
return true;
}
除了部分和map操作有关的环境,剩下都是和apk中的结果类相关代码,直接粘贴出来就行了。接着运行和接口抓包的结果完全一致。
算法分析
先trace一份日志。
- 9ff87310ed2f4b3af0b342e2309e6776从日志中的结果出发。
- 看着像是md5结果,选取后四字节小端序搜索。
3. 根据日志分析看一眼静态地址。
4. 更像是md5 update计算的部分了,用unidbg调试一下或者使用debugger hook 一下结果。
trace代码如下:
public void analysis() {
Debugger debugger = emulator.attach(DebuggerType.CONSOLE);
// debug md5 update input
// debugger.addBreakPoint(module.base + 0x106c4);
// hook md5 result
debugger.addBreakPoint(module.base + 0x12dc8, new BreakPointCallback() {
@Override
public boolean onHit(Emulator<?> emulator, long address) {
byte[] bytes = emulator.getBackend().mem_read(0xbffff508L, 0x10);
StringBuilder hexString = new StringBuilder();
for (byte b : bytes) {
hexString.append(String.format("%02x", b & 0xff));
}
System.out.println(hexString);
return true;
}
});
}
通过这部分分析,基本确定就是一个md5但是不确定是不是标准的算法,并且输入的数据是map数据类似urlencode操作的字符串。md5每组计算512比特。
上面案例中的计算组数比输入数据多一组,可能是在原始数据末尾加了盐,所以为了方便算法还原,先减少输入数据的长度,把字符串长度控制到低于64字节长度,尽可能的短因为可能还有盐。
将数据map中只保留appkey那一对数据。
从上图可以看出中间只计算了一组。方便分析。接着看看输入数据,找出盐值:
从上图可以看出salt 是 560c52ccd288fed045859ed18bffd973
。验证一下是否是标准md5
非常幸运是个标准的MD5,不需要多分析了。
验证
- 用python简单验证,先验证unidbg调用中的那个计算结果。
import hashlib
salt = "560c52ccd288fed045859ed18bffd973"
message = "ad_extra=E86F4CFF1F8FA890A75155EEAA51E6AE4FA9DBE62FCE708186D0CE5EF37B86948620D8BA1D991685B1288E2EDE09C6D52F8C2D33D59872EAE1EB776D11F71523CE1AF2112D8A950B98F6A1A48F848BC6871A849C3ED14308F46431A85625726A929A8906FA0C16FEE2CEB33209AE6F1E0C6856961045F53A0FE3470E4E223F48DAE7923040EAC4541BE6F728DEA350329AC40887CB773083BB4D6D91DCCCDF8DF936B6F029F2D92E3F28B59F61591BBDBCCAD20B53FAEFA0EAC13FA6BA72D6EE45A5C2B4A9F5A7675BD3F46E4B748B95D391B22A94E3D4833064D1BD98279C61199DFC1FA847D228E9C2C81C0FA849E87B15DDA9BE05AE714074ECEB3ADBFA1CADA5AF3643A798D3C600841AAE3CC453BFEA6D3BFB9461F7D2C80148A2F8363356607A174332DC3B6AC4AB173ED08A0F88895E53414EB78930E8FF8174DEEDAAAFE51E52D57EE33A7734B9B5BC1708D4136F5285663C23BD7A026E9E4C5409BF97E1A10EA592289BF48B59C9D16E8B1F4EA3A9C6EEC852DE7E8B8079521A0D07727EDCC0CCCC04A2ACEDEB103BF6C2F788CC5633189C0390C4D1D88424634DED6A504ACDEBE7B62FEBE53C1019080B6839064AFF97A9CDF5B0D9910C28DCC46FAE01A37D39793FDD07E87B50FDF4782E2F0B03F4D1EB2AE32E0826394EC88177B7085B318ED7C9C5B57D30831CE51CAF68D23290A17468894CE1FB890F48DF11794536A70CB168164F477C7B9A207B7A90C4EE1C1D745A4749558425163C8169F4ED12A9614AD9D5CB903E29B8B76990ADF66381C04797A03974EC00E3AB8682CE221F20B8D4093A3B2739D3518AB3B7F2202D4E6D3CFE973EF285D495F08498559BFF7E0364EC08406920436229679C8B02A43415F836D96BD459C6D9826EDDC7243BF7E34E1F68B6E4472C14DDF52B&appkey=1d8b6e7d45233436&build=8140300&c_locale=zh-Hans_CN&channel=alifenfa&disable_rcmd=0&fnval=464&fnver=0&force_host=0&fourk=1&from=0&local_time=8&mobi_app=android&platform=android&player_net=1&qn=32&qn_policy=1&s_locale=zh-Hans_CN&statistics=%7B%22appId%22%3A1%2C%22platform%22%3A3%2C%22version%22%3A%228.14.0%22%2C%22abtest%22%3A%22%22%7D&ts=1727231035&vmid=37754047&voice_balance=1"
print(hashlib.md5((message + salt).encode()).hexdigest())
- 验证通过完全没有问题,再找一个用户接口测试一下
接口也是通的。
结果
def get_sign(params: dict) -> str:
## 先对输入数据进行按键排序
params = sorted(params.items(), key=lambda d: d[0])
## 拼接, 并按照urlencode转义字符
message = urlencode(params)
salt = "560c52ccd288fed045859ed18bffd973"
message += salt
return hashlib.md5(message.encode()).hexdigest()
上面是个简单的测试版本,还需要注意python urlencode函数对于某些特殊转义字符的处理。