0%

逆向学习 0x0C so逆向分析

so逆向分析

先回忆一下NDK开发的时候分析的那个apk

抓个包

1
2
3
4
5
6
7
8
9
10
11
POST /user/api/v1/login/app/mobile HTTP/1.1
pa: MTczMjAxNzY1MzAzMyxhMTZhM2ZmOWVmYzU0M2IxYjlkMTBiMGI3NjY3NTk4MiwzZjg3MjNlMWNiMzBhYzYwM2RiYjY1ZGQ4N2QxMTA3Miw=
appInfo: {"IMEI":"bf1821d5634d0ec8","appBuild":"21070602","appVersion":"6.2.0","deviceId":"bf1821d5634d0ec8","deviceName":"NX627J","osType":"android","osVersion":"9","phoneName":"NX627J","phoneSystemVersion":"9","vendor":"nubia"}
User-Agent: PocketFans201807/6.2.0_21070602 (NX627J:Android 9;nubia PQ3B.190801.03191204 release-keys)
Content-Type: application/json; charset=UTF-8
Content-Length: 43
Host: pocketapi.48.cn
Connection: Keep-Alive
Accept-Encoding: gzip

{"mobile":"13112345678","pwd":"a123123123"}
image-20241119200211946
1
2
3
4
5
6
感觉只有 pa 字段值得注意, MT ey 开头的一般是base64
MTczMjAxNzY1MzAzMyxhMTZhM2ZmOWVmYzU0M2IxYjlkMTBiMGI3NjY3NTk4MiwzZjg3MjNlMWNiMzBhYzYwM2RiYjY1ZGQ4N2QxMTA3Miw=

base64解码
1732017653033,a16a3ff9efc543b1b9d10b0b76675982,3f8723e1cb30ac603dbb65dd87d11072,
看到有三节数据,第一节感觉像是时间戳

image-20241119201034054

那么直觉告诉我剩下两个就是账号和密码了

1
2
3
a16a3ff9efc543b1b9d10b0b76675982
3f8723e1cb30ac603dbb65dd87d11072
32位密文感觉和MD5有关,但是不完全是,可能加了什么东西

image-20250317190338898

image-20250317190446640

加载的是 encryptlib 这个so文件,解压出来,扔IDA中

从导出表内可以看到这个方法 Java_com_pocket_snh48_base_net_utils_EncryptlibUtils_MD5

可以看到初始化常量还要 0x80 的填充

image-20250317191112196

image-20250317191213640

so层的分析就是hook关键函数,方法有六个参数,传进来四个,第一个是content是个对象,不用管,剩下三个字符串。分析的时候完全可以从下往上分析,return了 v39,v39是从result转换出来的,result显然是作为接收结果的参数传入 MakePassMD5 的,v48应该是content,v37和v38按照C语言的习惯应该是明文和明文长度,这个时候就可以尝试hook MakePassMD5

image-20250317192908916

so层的hook只需要得到一个地址,有函数地址就能hook与主动调用

frida的Java层hook和so层hook的环境配置是一样的,直接写代码hook即可。

这个时候就需要考虑如何得到想要hook的函数的地址了,通过frida提供的api来获取地址,这个有一个 前提,就是该函数必须有符号才行(出现在符号表,导出表或者导入表中)。还可以通过计算来得到地址,如果这个函数没有符号,就只能通过计算地址来hook了:so基址+函数在so中的偏移[+1]。

枚举

枚举获取so文件中导入表中的所有函数

1
2
3
4
5
var improts = Module.enumerateImports('libencryptlib.so')

for (let i = 0; i < improts.length; i++) {
console.log(JSON.stringify(improts[i]))
}

直接打印的是object,需要转换成json类型的数据,里面包含函数的名字和地址,这个地址是在内存中的地址,不是偏移地址,偏移地址还需要这个地址-so基址,也可以光打印名称和地址

1
console.log(improts[i].name, improts[i].address)

image-20250318102521222

枚举导出表

1
2
3
4
5
var improts = Module.enumerateExports('libencryptlib.so')

for (let i = 0; i < improts.length; i++) {
console.log(JSON.stringify(improts[i]))
}

枚举符号表

1
2
3
4
5
var improts = Module.enumerateSymbols('libencryptlib.so')

for (let i = 0; i < improts.length; i++) {
console.log(JSON.stringify(improts[i]))
}

一般系统函数去枚举符号表,自定义的一些函数去枚举导出表即可

枚举模块,再枚举模块内的导出表,可以快速定位某个导入函数出自哪个so,一般用不上。

1
2
3
var moduel = Process.enumerateModules();
console.log(JSON.stringify(moduel[0]));
console.log(JSON.stringify(moduel[0].enumerateExports()[0]));

hook导出函数

以上文的 MakePassMM5 函数为例

hook导出表,搜索这个函数,获得函数名和地址

image-20250318110627196

在导出表中的函数,也可以使用frida的API从IDA中拿函数名进行搜索地址

1
2
3
// 搜索地址
var funcAddress = Module.findExportByName('libencryptlib.so', '_ZN7MD5_CTX11MakePassMD5EPhjS0_')
console.log(funcAddress)

得到函数地址之后就可以开始hook了,用 Interceptor.attach 来对函数进行hook,先传入要hook的地址,然后一个花括号,里面有两个方法,onEnter是在原函数执行前执行,onLeave在函数执行后执行

1
2
3
4
5
6
7
8
9
// 根据地址hook函数
Interceptor.attach(funcAddress, {
onEnter: function (args) {

},
onLeave: function (retval) {

}
});

直接打印args的内容可能只有一个地址,或者只有一个十六进制数,可以利用 hexdump 函数去找这个地址对于的内容,十六进制数可以转换成十进制显示

image-20250318112130502

按照之前的分析content不管他,只看 123

1
2
3
4
5
6
7
8
9
10
Interceptor.attach(funcAddress, {
onEnter: function (args) {
console.log("args[1]: \n" ,hexdump(args[1]));
console.log("args[2]: \n" ,args[2].toInt32());
console.log("args[3]: \n" ,hexdump(args[3]))
},
onLeave: function (retval) {
console.log("result: " ,retval, retval.toInt32())
}
});

args[1] 显然是需要去MD5加密的内容,args[2]是45,是上一个参数的长度。args[3]目前为空,准备接收加密结果

image-20250318112728608

这个时候打印的返回值只有一个长度信息 32,我们需要看 args[3],执行完毕之后的结果,这个时候需要提前将args[3]这块地址保存下来,在执行完毕之后再查看,出现MD5计算之后的结果

1
2
3
4
5
6
7
8
9
10
11
12
// 根据地址hook函数
Interceptor.attach(funcAddress, {
onEnter: function (args) {
console.log("args[1]: \n" ,hexdump(args[1]));
console.log("args[2]: \n" ,args[2].toInt32());
console.log("args[3]: \n" ,hexdump(args[3]))
this.args3 = args[3]
},
onLeave: function (retval) {
console.log("result: " ,hexdump(this.args3))
}
});

image-20250318113128487

得到了后面的加密出处,而且还要意外发现,入参 args[1] 就是前两个参数拼接起来的

image-20250318113831651

image-20250318114148694

运算一下,也是标准的MD5

image-20250318114233715

模块基址的获取方式

如果函数没有出现在导出表、导入表、符号表内,就不能通过以上的frida的API来获取地址,只能通过计算来获取

地址:so基址+函数在so中的偏移[+1]

先获取so的基址

1
2
3
4
5
6
7
8
// 获取模块基址
var module1 = Process.findModuleByName('libencryptlib.so')
var module2 = Process.getModuleByName('libencryptlib.so')
var module3 = Module.findBaseAddress('libencryptlib.so')

console.log(module1.base)
console.log(module2.base)
console.log(module3)

base就是基址

image-20250318115036757

还可以用上文API获取所有模块,去遍历获取地址

1
var moduel = Process.enumerateModules();

还可以已知基址来寻找模块,找到模块即可去调用下述方法

1
2
Process.findModuleByAddress(address)
Process.getModuleByAddress(address)

函数地址计算

找偏移地址的时候注意,去表中找对于函数跳转,不要去某些函数内,找这个函数调用的地址,要去hook定义的部分的地址 也就是 1FA38 这个地址

image-20250318173557336

地址:so基址+函数在so中的偏移[+1]

地址+1 是分情况的,如果是 thumb 指令需要 +1 ,如果是 arm 指令则不需要 +1。

在安卓中32位的so中的函数,基本都是 thumb 指令。64位的so中的函数,基本都是 arm 指令。

也可以通过显示汇编指令对应的 opcode bytes 来进行判断指令类型

选项 -> 常规 -> 操作码字节数(非图表) 改成 4

这个时候出现了四个字节的汇编指令,说明这个函数使用的 arm 指令

这四个字节就是 opcode bytes,就是这一条汇编对应指令的机器码

image-20250318175226280

如果是两个字节,或者有两个有四个的,一般是thumb,出现四个字节一般是两个字节不够用了,将两条指令拼接在一起了,32位的只会越来越少。整不明白,可以加1和不加1都试一下呗。

1
2
var soAddr = Module.findBaseAddress('libencryptlib.so')
var funcAddr = soAddr.add(0x1FA38)

image-20250318203725878

打印出来是两个地址,但是不想直接写一个地址然后 .add 因为 Module.findBaseAddress 获取的是一个指针,直接写一个地址会报错,没有add这个方法,需要加上 ptr()

1
console.log(ptr(0x7c9975d00).add(0x1FA38))

通过计算函数在内存中的地址,函数hook只需要一个地址,那么任意函数都可以进行hook了

image-20250318204103612

封装hook

hook方法基本上就那一个,为了提高复用,封装一下函数,但是也不是一招鲜吃遍天的

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
function print_arg(addr) {
// 有些Java类型在这里可能出问题
var module = Process.findRangeByAddress(addr)
if (module != null) return hexdump(addr) + "\n";
return ptr(addr) + "\n";
}


function hookFunc(funcAddr) {
var module = Process.findModuleByAddress(funcAddr)
Interceptor.attach(funcAddr, {
onEnter: function (args) {
this.logs = [];
this.data = [];
// 打印模块名称以及函数偏移
this.logs.push("call " + module.name + "!" + ptr(funcAddr).sub(module.base) + "\n");
// 保存参数地址,如果作为返回值使用了这个参数,后面可以直接打印
for (var i = 0; i < args.length; i++) {
this.data.push(args[i]);
this.logs.push("this.args" + i + "onEnter: " + print_arg(args[i]));
}
},
onLeave: function (retval) {
for (var i = 0; i < this.data.length; i++) {
this.logs.push("this.args" + i + "onLeave: " + print_arg(this.data[i]));
}
this.logs.push("return: " + retval);
console.log(this.logs);
}
});
}

实战

本贴仅作技术交流,如有侵权请在Github的issue联系我立即删帖

淘最热点

看下这个抓包结果 sign 四十位,盲猜是sha1

1
2
3
4
5
6
7
8
9
10
11
12
13
14
POST /api/v1/auth/login/sms HTTP/1.1
Content-Type: application/json
Connection: close
Charset: UTF-8
User-Agent: Dalvik/2.1.0 (Linux; U; Android 10; Pixel XL Build/QP1A.190711.019)
Host: api.taozuiredian.com
Accept-Encoding: gzip
Content-Length: 653

{"app_ver":"100",
"sign":"5d660709e3136cf8300792cde67fe9ee202718f9",
"nonce":"c40vv91742382752834",
"tzrd":"BwzXzSGFyiPstMIVuzTZb7LzTZzbXRJOFzpbQiIaT7t\/KDL9RCbvH\/vr0qdSE1IMBjy6D\/SQoXbTBgojGsCYv2+yA2ocToOyRo6AzjqzrJyeS1w0pRKovlZvnuEgWm\/CCVBkyWtUVaQRXVUEfMqTZ01BqKZ9AwngiYRXHEJ7tby3Yvi86oyyor6lkH8QxOqreKCsAYw44WHaqh832FsZInk+ZFv+Sja3vlS6i8QdzD5G+3Y87kHLkb6gdNw6yM606OYGoK3dHvCvWC1GT4XS3DvCETMrNSEooXKhaHKKz\/SxQ\/zoeXRRjueJoGwtyWR5mwfCJUM29iuwzpbGtMg+uNFd4Bj\/TXwePYb1Lu8huPcJDJ8ziEL4lZOAUUw+cAKAoDYF1C26RTlKPPnrxjPinliyU\/IUYhLYNtNeLJwLItZLuRJfOZbd3GvrhvfsTBt6lLLLuXh92i1BUltOcg1HTrAYYjx+dxbwTiv3EaDlVXgjtca+0Lpt2PG68\/GEAash",
"timestamp":"1742382752"}

tzrd使用的AES加密直接梭出来了,没有看到 SHA1,应该是在so层,从AES的堆栈进去看看

image-20250319191748542

image-20250319192311296

传入了一个字符串,先去 libtre.so 中找sign方法吧,这里hook a函数的结果,不如留着去so层hook传入值

image-20250319192349922

从导出表中找到这个方法,有三个参数

image-20250319192643930

看到五个初始化常量,和SHA1的一模一样

image-20250319195728415

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function print_arg(addr) {
var module = Process.findRangeByAddress(addr)
if (module != null) return hexdump(addr) + "\n";
return ptr(addr) + "\n";
}


function hookFunc(funcAddr, paramsNum) {
var module = Process.findModuleByAddress(funcAddr)
Interceptor.attach(funcAddr, {
onEnter: function (args) {
this.logs = [];
this.data = [];
this.logs.push("call " + module.name + "!" + ptr(funcAddr).sub(module.base) + "\n");
for (var i = 0; i < paramsNum; i++) {
this.data.push(args[i]);
this.logs.push("this.args" + i + " onEnter: \n" + print_arg(args[i]));
}
},
onLeave: function (retval) {
for (var i = 0; i < this.data.length; i++) {
this.logs.push("this.args" + i + " onLeave: \n" + print_arg(this.data[i]));
}
this.logs.push("return: " + retval);
console.log(this.logs);
}
});
}

var funcAddress = Module.findExportByName('libtre.so', 'Java_com_maihan_tredian_util_TreUtil_sign')
console.log(funcAddress)
hookFunc(funcAddress, 3)

有点奇怪,hook之后没有任何东西,传入的字符串也是空的,换个思路在Java层hook sign 方法

1
2
3
4
5
6
7
8
9
10
Java.perform(function() {
var TreUtils = Java.use('com.maihan.tredian.util.TreUtil')
TreUtils.sign.implementation = function (args) {
console.log("TreUtils.signStr: " + args);
var retval = this.sign(args)
console.log(retval)
return retval;
}
})

并非直接了SHA1,还进行了其他处理

1
2
3
4
5
传入值:app_ver=100&nonce=s5s1nx1742387030874&timestamp=1742387030&tzrd=BwzXzSGFyiPstMIVuzTZb7LzTZzbXRJOFzpbQiIaT7t/KDL9RCbvH/vr0qdSE1IM4+4+OnwWsQLlqfn5RxB2XC6PAW/JAMGv7TJG0yQkskekdkA25pE0vT7z4MUC5q8qEHhdCGwN67G/Qlx+hHYmGvvD/T6MpRkySeQBJJLtFIUgwBChwmTMBJgszYSJ2Oq6kO/Px0VmRW+cOmY8pxlCUir2Lt5P9frak1mJ0o2QfMC+a/yNUoJF3n1KawOb3LQHf8eIN3RsySOugxO9kqg5Cvgywtazy0I8gJQOzlTc03DleSEDXwxYRrFy7hXkHe0OuLUx+cZ5fKpIPR49uPwkxTLhQeMB1UAPeJ5mp8sV7Q/QAMeyb41dPbb2eIVxWH2D4GCF8iVi4HrEqT9UFaODOTIo75Q9Uku4bgRDr8GuFG0=

返回值:868e5772a27f523020f536dc6bfbf59d3a94a8bf

SHA1值:6419812ea6ff5715773fe5a2e8664fd3b43bd4a2

v20是env,结果是v25,v25由v23拼接而来,v23由v17转换而来,v17循环取出v26的字节,v26应该是作为返回值的参数执行了 j_SHA1Result , 既然和标准的 SHA1结果不一样,那就去这个方法中看看。之前hook没有hook出传入值是 print_arg 函数处理的问题,参数是Java类型的字符串

image-20250319203646060

hook SHA1Result 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
function print_arg(addr) {
var module = Process.findRangeByAddress(addr)
if (module != null) return hexdump(addr) + "\n";
return ptr(addr) + "\n";
}


function hookFunc(funcAddr, paramsNum) {
var module = Process.findModuleByAddress(funcAddr)
Interceptor.attach(funcAddr, {
onEnter: function (args) {
this.logs = [];
this.data = [];
this.logs.push("call " + module.name + "!" + ptr(funcAddr).sub(module.base) + "\n");
for (var i = 0; i < paramsNum; i++) {
this.data.push(args[i]);
this.logs.push("this.args" + i + " onEnter: \n" + print_arg(args[i]));
}
},
onLeave: function (retval) {
for (var i = 0; i < this.data.length; i++) {
this.logs.push("this.args" + i + " onLeave: \n" + print_arg(this.data[i]));
}
this.logs.push("return: " + retval);
console.log(this.logs);
}
});
}


var funcAddress = Module.findExportByName('libtre.so', 'SHA1Result')
console.log(funcAddress)
hookFunc(funcAddress, 2)


Java.perform(function() {
var TreUtils = Java.use('com.maihan.tredian.util.TreUtil')
TreUtils.sign.implementation = function (args) {
console.log("TreUtils.signStr: " + args);
var retval = this.sign(args)
console.log(retval)
return retval;
}
})

第二个参数作为返回值和sign的返回值相同,参数1是content一个结构体,里面有五个初始化常量,还有两个参数表示明文位数

image-20250319210023411

来看 SHA1Input 这里是加密函数,有三个参数第一个是content,然后就是明文以及明文长度了,hook了一下明文超长了,显示不出来,自己单独写一个,不hexdump了

1
2
3
4
5
6
7
8
Interceptor.attach(Module.findExportByName('libtre.so', 'SHA1Input'), {
onEnter: function (args) {
console.log("SHA1Input onEnter: " + args[1].readCString())
},
onLeave: function (retval) {

}
})

这个时候将v12计算SHA1值,结果和sign的结果相同,说明明文经过处理之后变成这个的类似于base64的形式,再进行SHA1,得到和终端一样的结果

image-20250319212048747

那么就需要再去往上找,看这个base64数据怎么来的了,v12在这里经过了处理,hook这个 base64函数

image-20250319212351692

1
2
3
4
5
6
7
8
9
10
11
12
Interceptor.attach(Module.findExportByName('libtre.so', 'base64_encode_new'), {
onEnter: function (args) {
console.log("base64_encode_new onEnter: " + args[0].readCString() + "\n")
console.log("base64_encode_new onEnter: " + args[1].readCString() + "\n")
this.args0 = args[0]
this.args1 = args[1]
},
onLeave: function (retval) {
console.log("base64_encode_new onLeave: " + this.args0.readCString() + "\n")
console.log("base64_encode_new onEnter: " + this.args1.readCString() + "\n")
}
})

看下sign传入值和base64的传入值的区别,只有后面多了一行东西,这个函数经过验证就是普通的base64编码,将传入的数据base64编码之后计算SHA1值,得出结果,继续往上看添加的部分内容从哪里来。

1
2
3
4
5
6
7
8
9
10
sign函数传入值:app_ver=100&nonce=dzljud1742391074744&timestamp=1742391074&tzrd=BwzXzSGFyiPstMIVuzTZb7LzTZzbXRJOFzpbQiIaT7t/KDL9RCbvH/vr0qdSE1IM0JgX7EeFKMidFPp7uNNpzeTMNlGeAywlVhFRI1/rf3ha27vGJ7wi59YFsHm+TwWHdXfcQQznBsXajsevBoLGbvW4A2gQEj4MRhFVMJr8wZ396k/+TeAq00WbUJSnVUWbdlbNvZFYNFjVsOXdhMLbeY80y5zjpowJYYTjK8sMdDF5IyeqG+ikl7gGjFxhrWhvldlar6qKQTjBto/ZWICrdbMrqT7D0XAPTMGBGbL1Xfcb/NLIF3vd270249aTbquqP8SwrpnlYaLH+/XiDIP533YIAN38HUmVg0ke9v6t72lHqQkZNnImJ+sNeu/VmRhkr7RGrOLtOLVKGIqQNjXoXPUXYXjYUOpJqloo782X/VFeeD+cVBAiODlszxgZQWHPPQQgoXS59acrA4uEFMS3vR72cjitpNJ4auKySpDS2GjGWaFoAAHIG6Hl/5dVQCSD


hook出的传入值:app_ver=100&nonce=dzljud1742391074744&timestamp=1742391074&tzrd=BwzXzSGFyiPstMIVuzTZb7LzTZzbXRJOFzpbQiIaT7t/KDL9RCbvH/vr0qdSE1IM0JgX7EeFKMidFPp7uNNpzeTMNlGeAywlVhFRI1/rf3ha27vGJ7wi59YFsHm+TwWHdXfcQQznBsXajsevBoLGbvW4A2gQEj4MRhFVMJr8wZ396k/+TeAq00WbUJSnVUWbdlbNvZFYNFjVsOXdhMLbeY80y5zjpowJYYTjK8sMdDF5IyeqG+ikl7gGjFxhrWhvldlar6qKQTjBto/ZWICrdbMrqT7D0XAPTMGBGbL1Xfcb/NLIF3vd270249aTbquqP8SwrpnlYaLH+/XiDIP533YIAN38HUmVg0ke9v6t72lHqQkZNnImJ+sNeu/VmRhkr7RGrOLtOLVKGIqQNjXoXPUXYXjYUOpJqloo782X/VFeeD+cVBAiODlszxgZQWHPPQQgoXS59acrA4uEFMS3vR72cjitpNJ4auKySpDS2GjGWaFoAAHIG6Hl/5dVQCSDb2qKgtaW4,9z9D`Fmst?K5JZbLYOY]NP6ssGf2U~;zk9oCNgoytV!}wW7ia+`w9g


hook出的结果值:YXBwX3Zlcj0xMDAmbm9uY2U9ZHpsanVkMTc0MjM5MTA3NDc0NCZ0aW1lc3RhbXA9MTc0MjM5MTA3NCZ0enJkPUJ3elh6U0dGeWlQc3RNSVZ1elRaYjdMelRaemJYUkpPRnpwYlFpSWFUN3QvS0RMOVJDYnZIL3ZyMHFkU0UxSU0wSmdYN0VlRktNaWRGUHA3dU5OcHplVE1ObEdlQXl3bFZoRlJJMS9yZjNoYTI3dkdKN3dpNTlZRnNIbStUd1dIZFhmY1FRem5Cc1hhanNldkJvTEdidlc0QTJnUUVqNE1SaEZWTUpyOHdaMzk2ay8rVGVBcTAwV2JVSlNuVlVXYmRsYk52WkZZTkZqVnNPWGRoTUxiZVk4MHk1empwb3dKWVlUaks4c01kREY1SXllcUcraWtsN2dHakZ4aHJXaHZsZGxhcjZxS1FUakJ0by9aV0lDcmRiTXJxVDdEMFhBUFRNR0JHYkwxWGZjYi9OTElGM3ZkMjcwMjQ5YVRicXVxUDhTd3JwbmxZYUxIKy9YaURJUDUzM1lJQU4zOEhVbVZnMGtlOXY2dDcybEhxUWtaTm5JbUorc05ldS9WbVJoa3I3UkdyT0x0T0xWS0dJcVFOalhvWFBVWFlYallVT3BKcWxvbzc4MlgvVkZlZUQrY1ZCQWlPRGxzenhnWlFXSFBQUVFnb1hTNTlhY3JBNHVFRk1TM3ZSNzJjaml0cE5KNGF1S3lTcERTMkdqR1dhRm9BQUhJRzZIbC81ZFZRQ1NEYjJxS2d0YVc0LDl6OURgRm1zdD9LNUpaYkxZT1ldTlA2c3NHZjJVfjt6azlvQ05nb3l0ViF9d1c3aWErYHc5Zw==


3078a71b38656dac7f798e5ed91370a4e05b15a8

一眼顶针,破案了

1
b2qKgtaW4,9z9D`Fmst?K5JZbLYOY]NP6ssGf2U~;zk9oCNgoytV!}wW7ia+`w9g

image-20250319213927557

马蜂窝

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
POST /rest/app/user/login/ HTTP/1.1
User-Agent: Dalvik/2.1.0 (Linux; U; Android 10; Pixel XL Build/QP1A.190711.019) mfwappcode/com.mfw.roadbook mfwappver/8.1.6 mfwversioncode/535 mfwsdk/20140507 channel/GROWTH-WAP-LC-3 mfwjssdk/1.1 mfwappjsapi/1.5
Connection: close
X-HTTP-METHOD-OVERRIDE: PUT
Content-Type: application/x-www-form-urlencoded; charset=UTF-8
Host: mapi.mafengwo.cn
Accept-Encoding: gzip
Cookie: __openudid=40:4E:36:1F:FF:FA; PHPSESSID=kn4g46rulhlngld4hh1kt22st3; mfw_uuid=67da9c03-cce6-c4b3-cafb-bea1c5e1f15e; oad_n=a%3A3%3A%7Bs%3A3%3A%22oid%22%3Bi%3A1029%3Bs%3A2%3A%22dm%22%3Bs%3A16%3A%22mapi.mafengwo.cn%22%3Bs%3A2%3A%22ft%22%3Bs%3A19%3A%222025-03-19+18%3A27%3A15%22%3B%7D
Content-Length: 668

x_auth_username=13112345678
&o_lat=36.651429
&device_type=android
&oauth_version=1.0
&oauth_signature_method=HMAC-SHA1
&screen_height=2392
&open_udid=40%3A4E%3A36%3A1F%3AFF%3AFA
&put_style=default
&app_version_code=535
&x_auth_mode=client_auth
&sys_ver=10
&o_lng=117.530121
&brand=google
&app_code=com.mfw.roadbook
&screen_scale=3.5
&screen_width=1440
&time_offset=480
&device_id=40%3A4E%3A36%3A1F%3AFF%3AFA
&oauth_signature=XVbVjoMGaGvmLS%2FKFS9WfYuiEL8%3D
&x_auth_password=132465879
&oauth_consumer_key=5
&oauth_timestamp=1742380119
&oauth_nonce=b2de4730-cc2a-486a-846e-11241d0cc79d
&mfwsdk_ver=20140507
&app_ver=8.1.6
&hardware_model=Pixel+XL
&channel_id=GROWTH-WAP-LC-3
&after_style=default&

这个 oauth_signture 看起来像是加密之后的东西,hook一下加密算法,有一个请求上面包含了username

image-20250320184541264

找到了这个 oauth_signture hook这个函数直接

image-20250320185454584

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Java.perform(function () {
let LoginRequestModel = Java.use("com.mfw.uniloginsdk.rest.LoginRequestModel");
LoginRequestModel["getParams"].implementation = function () {
console.log(`LoginRequestModel.getParams is called`);
let result = this["getParams"]();

// 因为是Map类型,直接打印只有 [object, object] 需要调用 toString 方法,但是 Map接口没有这个方法,需要使用他的子类 HashMap
try {
var a = Java.cast(result, Java.use("java.util.HashMap"));
console.log(`LoginRequestModel.getParams result=${a.toString()}`);
} catch (e) {
console.log(e)
}
return result;
};
})



/// 结果
result={x_auth_username=19386737821, o_lat=36.651534, device_type=android, oauth_version=1.0, oauth_signature_method=HMAC-SHA1, screen_height=2392, open_udid=40:4E:36:1F:FF:FA, put_style=default, app_version_code=535, x_auth_mode=client_auth, sys_ver=10, o_lng=117.529731, brand=google, app_code=com.mfw.roadbook, screen_scale=3.5, screen_width=1440, time_offset=480, device_id=40:4E:36:1F:FF:FA,
oauth_signature=xxTbfIYa5irlipggblueouaKB3k=,
x_auth_password=dghiutrrp07, oauth_consumer_key=5, oauth_timestamp=1742468520, oauth_nonce=afc3c969-b626-44bd-9bcb-8e0936cd5d48, mfwsdk_ver=20140507, app_ver=8.1.6, hardware_model=Pixel XL, channel_id=GROWTH-WAP-LC-3, after_style=default}

貌似hook多余了,进去分析吧

曲里拐弯的到这个 cryptoParams 这里面,传入四个参数,之前添加的数组就是其中一个参数,红线表示方法执行方向,蓝线表示传入的数据。

image-20250320191238238

image-20250320191502608

这里就可以看到已经进入so层的,分析之前前将 source 的内容hook一下,在进这个方法前执行了一个方法,将数据变成了一个字符串,不用管直接hook看看内容,再进so

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
Java.perform(function () {
let SecurityTools = Java.use("com.mfw.uniloginsdk.util.SecurityTools");
SecurityTools["encodePramas"].implementation = function (method, baseUri, params) {
try {
var a = Java.cast(params, Java.use("java.util.HashMap"))
console.log(`SecurityTools.encodePramas is called: method=${method}, baseUri=${baseUri}, params=${a.toString()}`);
} catch (error) {
console.log(error)
}

let result = this["encodePramas"](method, baseUri, params);
console.log(`SecurityTools.encodePramas result=${result}`);
return result;
};
})

看结果是将三部分拼接在一起,然后进url编码

1
2
3
4
5
6
7
8
参数:
method=PUT,
baseUri=https://mapi.mafengwo.cn/rest/app/user/login/,
params={x_auth_username=19386737821, o_lat=36.651534, device_type=android, oauth_version=1.0, oauth_signature_method=HMAC-SHA1, screen_height=2392, open_udid=40:4E:36:1F:FF:FA, put_style=default, app_version_code=535, x_auth_mode=client_auth, sys_ver=10, o_lng=117.529731, brand=google, app_code=com.mfw.roadbook, screen_scale=3.5, screen_width=1440, time_offset=480, device_id=40:4E:36:1F:FF:FA, x_auth_password=dghiutrrp07, oauth_consumer_key=5, oauth_timestamp=1742469539, oauth_nonce=9951eaa6-af5c-43d2-bd3d-9a3271e31a23, mfwsdk_ver=20140507, app_ver=8.1.6, hardware_model=Pixel XL, channel_id=GROWTH-WAP-LC-3, after_style=default}


返回值:
result=PUT&https%3A%2F%2Fmapi.mafengwo.cn%2Frest%2Fapp%2Fuser%2Flogin%2F&after_style%3Ddefault%26app_code%3Dcom.mfw.roadbook%26app_ver%3D8.1.6%26app_version_code%3D535%26brand%3Dgoogle%26channel_id%3DGROWTH-WAP-LC-3%26device_id%3D40%253A4E%253A36%253A1F%253AFF%253AFA%26device_type%3Dandroid%26hardware_model%3DPixel%2520XL%26mfwsdk_ver%3D20140507%26o_lat%3D36.651534%26o_lng%3D117.529731%26oauth_consumer_key%3D5%26oauth_nonce%3D9951eaa6-af5c-43d2-bd3d-9a3271e31a23%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1742469539%26oauth_version%3D1.0%26open_udid%3D40%253A4E%253A36%253A1F%253AFF%253AFA%26put_style%3Ddefault%26screen_height%3D2392%26screen_scale%3D3.5%26screen_width%3D1440%26sys_ver%3D10%26time_offset%3D480%26x_auth_mode%3Dclient_auth%26x_auth_password%3Ddghiutrrp07%26x_auth_username%3D19386737821

根据这个返回值写一个主动调用方法

1
2
3
4
5
6
7
8
9
10
11
12
function call_java() {
Java.perform(function () {
var Authori = Java.use("com.mfw.tnative.AuthorizeHelper");
var data = "PUT&https%3A%2F%2Fmapi.mafengwo.cn%2Frest%2Fapp%2Fuser%2Flogin%2F&after_style%3Ddefault%26app_code%3Dcom.mfw.roadbook%26app_ver%3D8.1.6%26app_version_code%3D535%26brand%3Dgoogle%26channel_id%3DGROWTH-WAP-LC-3%26device_id%3D40%253A4E%253A36%253A1F%253AFF%253AFA%26device_type%3Dandroid%26hardware_model%3DPixel%2520XL%26mfwsdk_ver%3D20140507%26o_lat%3D36.651534%26o_lng%3D117.529731%26oauth_consumer_key%3D5%26oauth_nonce%3Dc04dd3a1-dbaf-483f-8534-eb3a578d95ab%26oauth_signature_method%3DHMAC-SHA1%26oauth_timestamp%3D1742469964%26oauth_version%3D1.0%26open_udid%3D40%253A4E%253A36%253A1F%253AFF%253AFA%26put_style%3Ddefault%26screen_height%3D2392%26screen_scale%3D3.5%26screen_width%3D1440%26sys_ver%3D10%26time_offset%3D480%26x_auth_mode%3Dclient_auth%26x_auth_password%3Ddghiutrrp07%26x_auth_username%3D19386737821"
var current_application = Java.use('android.app.ActivityThread').currentApplication();
var cpntext = current_application.getApplicationContext();

// 需要new一个对象,而且有构造函数,需要传入一个字符串,代码中这个传入的字符串是"com.mfw.roadbook"
var result = Authori.$new("com.mfw.roadbook").xAuthencode(cpntext, data, "", "com.mfw.roadbook", true);
console.log(result);
});
}

so层传了五个参数,第一个是一个context,第二个是url编码后的数据,第三个是 “” 第四个传入的是true

第二个参数在so中是 a4 这个结果就是将 jstring 数据类型转换成c语言中的字符串,然后压如SHA1计算,计算结果转换成 base64 ,再转换成Java类型字符串返回出去

image-20250320194425926

先hook一下这个base64方法

1
2
3
4
5
6
7
8
9
// hook base64_encode
Interceptor.attach(Module.findExportByName('libmfw.so', '_ZN3mfw6Base6413base64_encodeEPKci'), {
onEnter: function (args) {
console.log("base64_encode_new onEnter: " + hexdump(args[1]) + "\n")
},
onLeave: function (retval) {
console.log("base64_encode_new onLeave: " + hexdump(retval) + "\n")
}
})

上面疑似是SHA1,取40位十六进制数,进行hex to base64编解码,结果和正常运行相同

image-20250322192251632

这个base64方法就是一个简单的 hex to base64方法,继续向上,hook update方法,看到是直接传入的原文没问题。

1
2
3
4
5
6
7
8
9
10
11
12
// hook Sha1 final
Interceptor.attach(Module.findExportByName('libmfw.so', '_ZN3mfw4Sha15CHmac6UpdateEPKhj'), {
onEnter: function (args) {
console.log("Sha1 final onEnter: " + hexdump(args[0]) + "\n")
console.log("Sha1 final onEnter: " + args[1].readCString() + "\n")
this.arg1 = args[0]
},
onLeave: function (retval) {
console.log("Sha1 final onLeave: " + hexdump(this.arg1) + "\n")
console.log("Sha1 final onLeave: " + retval + "\n")
}
})

image-20250322194011120

可以注意到的是,方法名是 sha1:chmac 什么的,这个是一个基于密钥的信息摘要算法,还有一个setkey方法来设置密钥,hook这个方法,分析一下第三个参数应该是密钥长度

1
2
3
4
5
6
7
8
9
10
11
12
Interceptor.attach(Module.findExportByName('libmfw.so', '_ZN3mfw4Sha15CHmac6SetKeyEPKhj'), {
onEnter: function (args) {
console.log("setkey onEnter: " + hexdump(args[0]) + "\n")
console.log("setkey onEnter: " + hexdump(args[1]) + "\n")
console.log("setkey onEnter: " + args[2] + "\n")
this.arg1 = args[0]
},
onLeave: function (retval) {
console.log("setkey onLeave: " + hexdump(this.arg1) + "\n")
console.log("setkey onLeave: " + retval + "\n")
}
})

长度33位

image-20250322201329904

拿过来去 hamc-sha1 运算一下,标准结果

image-20250322201233593

过简单root检测(海博TV)

image-20250322202602752

拿这个字符串爆搜一下,找到了相关代码

image-20250322203339145

hook掉run函数,或者让 systemUtils.checkSuDile() 返回false,让 SystemUtils.checkRootFile() == null

两种方法均可,第二种稳重有点,自写的检测root方法hook掉了,也能防止其他地方再进行调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
Java.perform(function () {
// 不执行run方法,直接return
// let AnonymousClass1 = Java.use("com.hoge.android.factory.welcome.WelcomeActivity$2$1");
// AnonymousClass1["run"].implementation = function () {
// console.log(`AnonymousClass1.run is called`);
// return;
// };

// 令checkSuFile方法返回false
let SystemUtils = Java.use("com.hoge.android.factory.util.system.SystemUtils");
SystemUtils["checkSuFile"].implementation = function () {
console.log(`SystemUtils.checkSuFile is called`);
return false;
};


// 令checkRootFile方法返回null
SystemUtils["checkRootFile"].implementation = function () {
console.log(`RootUtils.checkRootFile is called`);
return null;
};
})

image-20250322210037646

以上纯属我静态玩习惯了,这个提示可以hook一下toast的show方法试试,找到堆栈后再进行操作

1
2
3
4
5
6
var toast = Java.use("android.widget.Toast")
toast.show.implementation = function () {
showStack()
console.log('toast.show: ');
return this.show();
}

在Java层检测root一般有两种方式,一种是检测 su 文件,另一种是执行 su 命令

image-20250322212112068

搬运一个检测检测root的脚本,通过检测是否在检测su文件和执行su命令,再打印堆栈,快速定位。

上方的代码就是使用了 java.io.File 中的检测su文件的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
Java.perform(function () {
function showStacks() {
console.log(
Java.use("android.util.Log")
.getStackTraceString(
Java.use("java.lang.Throwable").$new()
)
);
}

Java.use("java.io.File").$init.overload("java.lang.String").implementation = function (str) {
if (str.toLowerCase().endsWith("/su") || str.toLowerCase() == "su") {
console.log("发现检测su文件");
showStacks();
}
return this.$init(str);
}
Java.use("java.lang.Runtime").exec.overload("java.lang.String").implementation = function (str) {
if (str.endsWith("/su") || str == "su") {
console.log("发现尝试执行su命令的行为");
showStacks();
}
return this.exec(str);
}
Java.use("java.lang.Runtime").exec.overload("[Ljava.lang.String;").implementation = function (stringArray) {
for (var i = 0; i < stringArray.length; i++){
if (stringArray[i].includes("su") || stringArray[i].includes("/su") || stringArray[i] == "su"){
console.log("发现尝试执行su命令的行为");
showStacks();
break;
}
}
return this.exec(stringArray);
}
Java.use("java.lang.ProcessBuilder").$init.overload("[Ljava.lang.String;").implementation = function (stringArray){
for (var i = 0;i < stringArray.length; i++) {
if (stringArray[i].includes("su") || stringArray[i].includes("/su") || stringArray[i] == "su") {
console.log("发现尝试执行su命令的行为");
showStacks();
break;
}
}
return this.$init(stringArray);
}
});

回归主线任务,抓个包

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
POST /api/v1/m_login.php?app_version=4.0.0&latitude=36.6571&phone_models=PixelXL&client_id_android=8e1eb7aa6447bd52a7610367efe3743f&language=Chinese&client_type=android&version=4.0.0&locating_city=%E6%B5%8E%E5%8D%97%E5%B8%82&system_version=10&appid=9&device_token=e594f4c3a2ee38fbece4cb9bc0d25db1&location_city=%E6%B5%8E%E5%8D%97&package_name=com.hoge.android.app.fujian&appkey=OU4VuJgmGkqFzelCaueFLHll1sZJpOG4&longitude=117.536288 HTTP/1.1
Accept-Language: zh-CN,zh;q=0.8
User-Agent: Dalvik/2.1.0 (Linux; U; Android 10; Pixel XL Build/QP1A.190711.019) m2oSmartCity_468 1.0.0
X-API-VERSION: 4.0.0
X-API-SIGNATURE: N2ZmYmI1NDg2MDIwZWY4MDg1NzllMGJhMDhiMmVjYjkwZGE1N2I4Ng==
X-API-KEY: 877a9ba7a98f75b90a9d49f53f15a858
X-API-TIMESTAMP: 17426501216701KEgjq
X-AUTH-TYPE: sha1
Content-Type: application/x-www-form-urlencoded
Content-Length: 93
Host: mapi-plus.fjtv.net
Connection: Keep-Alive
Accept-Encoding: gzip

password=123456789&client_id_android=8e1eb7aa6447bd52a7610367efe3743f&member_name=13112345678
1
2
看名字就知道是密文
X-API-SIGNATURE: N2ZmYmI1NDg2MDIwZWY4MDg1NzllMGJhMDhiMmVjYjkwZGE1N2I4Ng==

看方法名应该是一个SHA1加密,结合上文base64解密之后得到40位十六进制数,基本可以确定,hook算法一看全是md5,sha1应该在so层了

顺着md5堆栈进去看看

image-20250326191154833

没找到,hook一下hashmap,打印 x-api的堆栈

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
Java.perform(function () {
function showStacks() {
console.log(
Java.use("android.util.Log")
.getStackTraceString(
Java.use("java.lang.Throwable").$new()
)
);
}

// hook HashMap.put方法
var HashMap = Java.use('java.util.HashMap');
HashMap.put.implementation = function(a, b) {
if (a.equals("X-API-SIGNATURE")) {
showStacks();
console.log("hashMap.put :", a, b);
}

console.log("put: ", a, b);
return this.put(a, b);
}

})

image-20250326192113496

image-20250326200345200

使用了反射,获取com.hoge.android.jni.Utils 这个类,调用了下面的 verify 方法,下面是base64编码

image-20250326201535820

分别调用了 verify 和 signature 两个方法,进so

image-20250326201805424

image-20250326202743143

传入sha1_encode方法的值是由固定值+版本号+时间戳拼接出来的,第二个参数表示第一个参数的长度,不管

image-20250326204709972

试一下就可以得出密文,和主动调用方法的返回值相同

image-20250326205159753

但是和sh1_code返回的不同,看看后面进行了什么操作

image-20250326205234734

传进来的p是一个二级指针,还需要进行一个dump操作

image-20250326205818222

二级指针

对于二级指针dump完毕之后再read一下,就可以得到最终结果

image-20250326210108258

喜马拉雅(jnitrace)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
POST /mobile/login/pwd/v3 HTTP/1.1
Cookie: 1&_device=android&56b0117a-f35a-3ef9-a446-f7bc8106b073&6.6.99;
channel=and-f5;
impl=com.ximalaya.ting.android;
osversion=29;
fp=009527657x2022q22564v0500000000000000000000000000000000000000;
device_model=Pixel+XL;
XUM=QE42H//6;
XIM=;
c-oper=%E6%9C%AA%E7%9F%A5;
net-mode=WIFI;
freeFlowType=0;
res=1440%2C2392;
NSUP=;
AID=MIUzwY58O3A=;
manufacturer=Google;
XD=vSynL63b/8L6A1owosXQAEGW6RvWQoLw/TQOroqGMWk+nzIYa/r4LmaYTBIWpO3jcF3hAT+pHBGeYLYEUF+Dv/AMzoGQ6FnuQLpf0J4nUziKa0h9dV/eaAz7/vpklecYwEWcd7mmZIGMWLJRdW781Q==;
umid=ai3f952a188260a7f741a05185654a2f35;
xm_grade=0;
minorProtectionStatus=0;
domain=.ximalaya.com;
path=/;
Cookie2: $version=1
Accept: */*
user-agent: ting_6.6.99(Pixel+XL,Android29)
Content-Type: application/json; charset=utf-8
Content-Length: 555
Host: passport.ximalaya.com
Connection: Keep-Alive
Accept-Encoding: gzip

{"password":"WPx31AvNzAvMp68QxTeJpc0eIqM+FJ1EAhoD2zqvu7kl9uer9++Gir90u40LLLrLVqMbqVNu2Rwa\nmdBbwHo1g09pGIKi6+4e4to+rruVisM+nG6MsaKzFSOSivcYcAIQWWHr46LDz4RyrhXRT5Sfyg3A\n7ZkzJ0nGacHT3wszpkM\u003d\n",
"fdsOtp":"5984527305712495266",
"signature":"3f9dac222d8aa8aa3fb24f20550195b6da6c89b8",
"nonce":"0-D127A1F78429163784d3cafa706f7c3c7581deb0c963482b9528b37ca091f7",
"account":"aVI9JVHbk7R5gShGpeR0TQOOat8Y7au93nezmiQWXJrW318z+z0laHFgCxjjkGR5FF70OYtT81D9\nOJOTNVmTidroFXb0+x7yDJdqeBI0/9lGcORyz21a39lv1a+CS3T9CNy+E5U9IMEQBcYAH1MdtvoZ\nm89MsO+7T3fy9To81BU\u003d\n"}

password和account使用的RSA加密,signature使用的SHA1摘要

image-20250328085949170

顺着堆栈进来,这个比上个好找多了,进so

image-20250328090712978

先hook一下Java层的native方法,获取输出,根据输出构建一个主动调用,先看SHA1吧,RSA没法固定,因为是pkcs随机填充,即使明文相同加密结果也不同

成功固定SHA1的值

注意换行符不要取消掉,换行符也会被处理成base64或者hex的形式参与运算,SHA1的结果改变了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function call_java_rsa() {
Java.perform(function () {
// 获取context
var current_application = Java.use('android.app.ActivityThread').currentApplication();
var context = current_application.getApplicationContext();


let LoginEncryptUtil = Java.use("com.ximalaya.ting.android.loginservice.LoginEncryptUtil");
let res = LoginEncryptUtil.$new().AQqfJzIZgx(context, false, `account=IzhO9KzTZEaefWPEqFnDbeEhlH+du6o5uL25XphTC58lYPavTozEtGck9sO//RsOjxJs7tttBoee
o0WqWFmdhvLBCgbD1nlMisS7RQWDvLaxLwTZ0qNnl0o3sucgPutB5C2ha2JeXiZ5bSsqC/mBImIc
63o/0a+N0jj8wBYlMZM=
&fdsOtp=8146851191835527943&nonce=0-C28C2BB39498353157107a7aefdbd9fde44e9488a6c16f12e019d7809537d3&password=HH/flt6sLjwF4pOg1dSD5Re4/2yBMvDqkhz5m4ZQ3msZ65gAff79PXkTjRjbVNMATKNlcKekwG9I
zPyHGsy1fr0uExhdpu8ilD66hrzJmnpcV1xlEDUiWE+l820OpAz5EXrpaRHef6VWxcTSIcxADjLL
lpCsG4xYcSahB2rVDc8=
&`)
console.log("res:", res)
})
}


// RSA密钥
MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCVhaR3Or7suUlwHUl2Ly36uVmboZ3+HhovogDjLgRE9CbaUokS2eqGaVFfbxAUxFThNDuXq/fBD+SdUgppmcZrIw4HMMP4AtE2qJJQH/KxPWmbXH7Lv+9CisNtPYOlvWJ/GHRqf9x3TBKjjeJ2CjuVxlPBDX63+Ecil2JR9klVawIDAQAB

image-20250328093112872

这个so是进行过混淆的,不能之间点击函数名来进行跳转查看字符串的信息,而且代码十分的抽象,十分的不想看,这个时候就需要hook这个字符串地址了

jnitrace

可以使用大佬写好的工具 jnitrace,先下载安装,注意看要求的frida版本,GitHub:https://github.com/chame1eon/jnitrace

1
2
3
pip install jnitrace

// 还有一些其他配置,比如frida hexdump 等库,之前配置frida的时候都安装好了

使用方式

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
jnitrace -l [so名] [包名]
// jnitrace是支持hook多个so文件的使用 -l [so] -l [so] 或者 -l *
// 这种方式是进行重启的相当于 -U -l -f。如果要hook的so文件执行很多的话,可能导致app崩溃或者软件一直处于加载状态,直到全部信息输出完毕


-m <spawn|attach>
// 有两个值,分别是 spawn、attach。spawn是生成,attach是附着,一般使用attach,相当于 -F

-o path/output.json
// 将结果输出成json格式

-p path/script.js
// jnitrace执行前先注入frida脚本

-i [函数名]
// 仅追踪特定的函数

这个东西比较玄学,我碰到了三报错,忙活了一天也没解决

image-20250328213852049

ollvm字符串解密

对于这种加密了的,看不见明文的可以根据地址值dump一下

image-20250329144646183

1
2
var soAddr = Module.findBaseAddress('liblogin_encrypt.so')
console.log(hexdump(soAddr.add('0xD060')))

可能会报的错误: TypeError: cannot read property 'add' of null 这个就是so未加载,以这个案例为例,登录一次之后就会加载,这个错误就不存在了

image-20250329144835438

这个两个加密的字符串分别是 RSA 和 java/security/KeyFactory ,先获取java/security/KeyFactory类,后面的点不是了 00 就代表字符串结尾了 getInstance 是 getstaticMethodId 里的内容,去获取了这个方法,然后方法名后面是方法签名 (Ljava/lang/String;)Ljava/security/KeyFactory,传入字符串返回一个Java类,看起来是一个构造方法

so dump

主包主包,挨个打印确实很犀利,但是太吃操作了,有没有更强势的打法?有的兄弟,有的。还可以将内存中的so dump下来,毕竟so总是要执行的

直接打印内存中的字符串,一般都是解密状态,但是太过繁琐

使用jnitrace,缺点是只能查看jni相关的函数,最大的缺点是我配置不上。。。

从内存中dump整个so,确实爽,缺点是需要修复。

还有一个方法就是分析so中的解密函数,进行还原。这个比较有难度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function dump_so(so_name) {
Java.perform(function() {
var currentApplication = Java.use("android.app.ActivityThread").currentApplication();
var dir = currentApplication.getApplicationContext().getFilesDir().getPath();
var libso = Process.getModuleByName(so_name);
console.log("[name]:", libso.name);
console.log("[base]:", libso.base);
console.log("[size]:", ptr(libso.size));
console.log("[path]:", libso.path);

var file_path = dir + "/" + libso.name + "_" + libso.base + "_" + libso.size + "_" + new Date().getTime() + ".so";
var file_handle = new File(file_path, "wb");
if (file_handle && file_handle != null) {
Memory.protect(ptr(libso.base), libso.size, "rwx")
var libso_buffer = ptr(libso.base).readByteArray(libso.size);
file_handle.write(libso_buffer);
file_handle.flush();
file_handle.close();
console.log("[dump path]:", file_path);
}
})
}

主动执行完方法后,去对于路径pull出来so文件,这个路径应该是没权限的,需要转移到其他路径下进行pull

image-20250329165504941

1
2
3
4
adb shell
su
cd /data/user/0/com.ximalaya.ting.android/files
mv liblogin_encrypt.so_0xb304f000_57344_1743237978631.so /sdcard
1
adb pull /sdcard/liblogin_encrypt.so_0xb304f000_57344_1743237978631.so

这个时候放到ida中一看,内容好像没啥区别,还有报错,这个so文件需要修复一下

image-20250329170340012

so修复

https://github.com/qweraqq/frida-dump-android-so

下载工具

image-20250329190809624

写一个脚本,将上面从手机pull的操作和修复指令结合在一起

注意脚本中的导出的位置,以及sofixer的位置是我电脑上的位置,可以改成自己的就可以正常使用了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
const { execSync, spawn } = require('child_process');

function auto(url, num) {
let so_name = url.split('/').pop();
try {
execSync('adb shell');
console.log("adb shell success");

execSync(`adb shell su -c "mv ${url} /sdcard/ && exit && exit"`);
console.log("su and mv success");

execSync(`adb pull /sdcard/${so_name} D:/desktop`);
console.log("pull success");

let addressList = so_name.split('_');
let address = addressList[addressList.length - 3]
let child = spawn(`D:/SoFixer/SoFixer_x${num}.exe`, ['-s', `D:/desktop/${so_name}`, '-o', `D:/desktop/fix.so`, '-m', address, '-d'])
child.stdout.on('data', (data) => {
console.log(data.toString());
});
child.stderr.on('data', (data) => {
console.error(data.toString());
});
child.on('exit', (code) => {
console.log(`child process exited with code ${code}`);
});
console.log('Done!');
} catch (error) {
console.error(`exec error: ${error.message}`);
}
}

// auto("/data/user/0/com.ximalaya.ting.android/files/liblogin_encrypt.so_0xb304f000_57344_1743244730235.so", 32);

image-20250329191238781

修复之后的so,就显得很正常了,已经很诗人了,不像之前的加密的字符串,完全不诗人

image-20250329191400817

dump下来的so仅可用于静态分析,是没办法给他塞回去运行的。

https://bbs.kanxue.com/thread-272077.htm

https://bbs.kanxue.com/thread-191649.htm

so何时被加载?

在so进行hook的时候如果这个so没有被加载,可能就会报错 ==cannot read property add of null==

因为so没有被加载 Module.findBaseAddress 获取到的就是null,而null是没有add方法的,就会报错

so是在Java类中的static静态代码块中加载的,也就是说Java类中的静态代码块中加载了so,才能找到并hook这个so。

因此就需要先让so加载,再hook,还可以hookso的加载函数,监听函数,一加载上就hook

修改函数数值参数和返回值

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
var soAddr = Module.findBaseAddress('libxiaojianbang.so');
var address = soAddr.add(0x165C);

Interceptor.attach(address, {
onEnter: function (args) {
args[2] = ptr(0x1000)
console.log("args[2]: " + args[2]);
console.log("args[3]: " + args[3]);
console.log("args[4]: " + args[4]);
},
onLeave: function (retval) {
retval.replace(0x1000);
console.log("return: " + retval);
}
});

// 数值参数使用 ptr(num) 来修改
// ptr 是 new NativePointer 的简写
// 返回值利用 retval.replace() 来修改

image-20250330172148686

修改字符串参数

修改字符串比修改数值要麻烦的多,因为 ptr 需要的是一个指针即可,args的存的也是一个指针,这个指针指向字符串

function 1 修改地址指向的字符串(了解)

将char *指向的字符串修改掉。

新字符串一般不超过源字符串长度,因为地址中的字符串是连着的,如果超过了原先的字符串长度,可能将其他的字符串也修改掉,那就不好了。还需要主要字符串结尾是否存在两个字节0,用作字符串结束标志。还需要对要更改的内容进行判断以免导致后面的代码不能正常运行。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
function stringToHex(str) {
return str.split("").map(function (c) {
return ("0" + c.charCodeAt(0).toString(16)).slice(-2);
}).join("");
}

function stringToBytes(str) {
return hexToBytes(stringToHex(str))
}

function hexToBytes(hex) {
for (var bytes = [], c = 0; c < hex.length; c+=2)
bytes.push(parseInt(hex.substr(c, 2), 16));

return bytes;
}

function hexToString(hexStr) {
var hex = hexStr.toString();
var str = "";
for (var i = 0; i < hex.length; i += 2) {
str += String.fromCharCode(parseInt(hex.substr(i, 2), 16));
}
return str;
}


// 修改字符串参数
// 直接修改char *内容
var soAddr = Module.findBaseAddress('libxiaojianbang.so');
var address = soAddr.add(0x1D68);

Interceptor.attach(address, {
onEnter: function (args) {
if (args[1].readCString() == "xiaojianbang") {
var newStr = "hello world";
// console.log(hexToBytes(stringToBytes(newStr)))
args[1].writeByteArray(hexToBytes(stringToHex(newStr) + "00"));
console.log("args[1]: " + hexdump(args[1]));
args[2] = ptr(newStr.length);
console.log(args[2].toInt32());
}
},
onLeave: function (retval) {

}
});

image-20250331163934726

function 2 修改地址(了解)

将so中已有的字符串地址传给函数

这种方法也是很有局限性,只能选择内存中已有的字符串

shift + F12 挑一个喜欢的字符串

image-20250331172720113

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
var soAddr = Module.findBaseAddress('libxiaojianbang.so');
var address = soAddr.add(0x1D68);

Interceptor.attach(address, {
onEnter: function (args) {
if (args[1].readCString() == "xiaojianbang") {
// function 2 将已有的字符串地址传递给args[1](狸猫换太子)
args[1] = soAddr.add(0xBB1);
args[2] = ptr(soAddr.add(0xBB1).readCString().length)
console.log("args[1]: " + hexdump(args[1]));
console.log("args[2]: " + args[2].toInt32());
}
},
onLeave: function (retval) {

}
});

image-20250331173732081

function 3 修改结构体(了解)

修改结构体(只针对MD5算法)结构体中的buffer

结构体定义的时候定义了一个两字节的表示字符数量的,还有四个初始化常量,占4字节

(2+4)*8 = 48 那就是24个十六进制对,从第25位开始取就是明文,可以在 onLeave 中更改字符串,达到更改的目的。

image-20250331191331466

其实这个主要还是了解一下结构体,掌握如果更改结构体

function 4 构建字符串(重要)

利用frida的API来构建字符串

1
2
3
Memory.alloc()
Memory.allocUtf8String()
Memory.allocUtf16String()

利用这个API将字符串处理返回一个指针类型(NativePointer),但是有一个问题就是使用的时候需要考虑作用域的问题,如果是在hook so层函数的时候的 onEnter中接收存储这个指针的话,随着onEnter函数执行完毕,内存指针就会被释放,释放之后再执行要hook的函数,hook了个寂寞。因此需要用全局变量,或者在函数外定义参数来接收指针,防止释放。

使用this保障指针在函数执行前不被释放

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
var soAddr = Module.findBaseAddress('libxiaojianbang.so');
var address = soAddr.add(0x1D68);

Interceptor.attach(address, {
onEnter: function (args) {
this.args0 = args[0];
this.args1 = args[1];
if (args[1].readCString() == "xiaojianbang") {
// function 4 构建字符串
var newStr = "skjhgjsdhgf';dlfsdlkhskd"

// 错误示范一,直接给予args[1]一个指针的话会出现异常,打印出来的args[1]异常
// args[1] = Memory.allocUtf8String(newStr)

// 错误示范二,用变量接收指针,再将变量给args[1],虽然打印结果正常,但是onEnter执行完毕指针内存释放了,导致后续操作异常
// var a = Memory.allocUtf8String(newStr)
// args[1] = a

// 正确操作
this.newStrAdd = Memory.allocUtf8String(newStr)
args[1] = this.newStrAdd
console.log("args[1]: " + hexdump(args[1]));
args[2] = ptr(newStr.length);
console.log("args[2]: " + args[2].toInt32());
}

},
onLeave: function (retval) {

}
});

这个原理和之前将arg[0],使用 this.xxx 接收,然后再在 onLeave 中打印使用是一样的,因为 var定义的参数会在 onEnter 执行结束后释放,要使用this来延长生命周期。

image-20250401172536122

或者在函数外构建字符串,在hook时使用(定义生命周期更长的变量?)

hook_dlopen

之前的hook方法有一个bug就是需要so被加载之后才能进行hook操作,否则报错

==cannot read property add of null== ,这个时候就有问题,如果有的so只执行一次,那不寄了,这个时候就需要hook_dlopen了

dlopen,此物在NDK开发中亦有记载

hook_dlopen 的作用是视奸so何时被加载,加载之后立马hook。

image-20250401183540864

原理就是so的加载需要调用这个dlopen函数,只需要hook掉这个函数,看看什么so文件被加载了,监听一下成功被加载就执行hook函数,这样就避免了上方的报错了。dlopen存在两个一个用于低版本(dlopen)一个用于高版本(android_dlopen_ext)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
var dlopen = Module.findExportByName(null, 'dlopen');
console.log(dlopen)

var android_dlopen_ext = Module.findExportByName(null, 'android_dlopen_ext');
console.log(android_dlopen_ext)

Interceptor.attach(dlopen, {
onEnter: function (args) {
var soPath = args[0].readCString();
if (soPath.indexOf("libxiaojianbang.so") != -1) this.hook = true;
},
onLeave: function (retval) {
if (this.hook) hookFunc()
}
})

Interceptor.attach(android_dlopen_ext, {
onEnter: function (args) {
var soPath = args[1].readCString();
if (soPath.indexOf("libxiaojianbang.so") != -1) this.hook = true;
},
onLeave: function (retval) {
if (this.hook) hookFunc()
}
});

稍微改吧改吧封装成一个function,只有两个参数一个是要hook的so的名称,一个是so加载后执行的hook方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
function hook_dlopen(soName, hook_func) {
var dlopen = Module.findExportByName(null, 'dlopen');
var android_dlopen_ext = Module.findExportByName(null, 'android_dlopen_ext');
Interceptor.attach(dlopen, {
onEnter: function (args) {
var soPath = args[0].readCString();
if (soPath.indexOf(soName) != -1) this.hook = true;
},
onLeave: function (retval) {
if (this.hook) hook_func()
}
})
Interceptor.attach(android_dlopen_ext, {
onEnter: function (args) {
var soPath = args[0].readCString();
if (soPath.indexOf(soName) != -1) this.hook = true;
},
onLeave: function (retval) {
if (this.hook) hook_func()
}
})
}

内存读写

这些方法已经使用过了,做个总结

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
var soAddr = Module.findBaseAddress("libc.so")

// 读取指定地址的字符串 readCString()
console.log(soAddr.add(0x2C10).readCString())

// dump指定地址内存 hexdump()
console.log(hexdump(soAddr.add(0x2C10)))

// 读指定地址的内存 readByteArray()
console.log(soAddr.add(0x2C10).readByteArray(16));
console.log(Memory.readByteArray(soAddr.add(0x2C10), 16))

// 写指定地址的内存 writeByteArray()
soAddr.add(0x2C10).writeByteArray(stringToBytes("test"));
console.log(hexdump(soAddr.add(0x2C10)))

// 申请新内存写入
Memory.alloc()
Memory.allocUtf8String()

// 修改内存权限
Memory.protect(ptr(libso.base), libso.size, 'rwx')

readCString是读取到字符串结尾 00 为止,readByteArray是读取给定的字节。还有readPointer(读取一个指针长度,返回NativePointer指针类型)readInt(读取四个字节,当作整数来读返回number类型,还会有字节序的处理)还有readS8、readS16、readS32等,这个S表示有符号数,意思是读取一个有正负的字节,将最高位当作符号位,还有U8、U16等,U表示无符号位,最高位正常解析。方法有很多,还是根据需要用

write也是有一套和read差不多的方法。

writeByteArray() 接收两种参数类型,一种是ArrayBuffer,,另一种是number,加上之前封装的方法,就是一个用 stringToBytes 转换,一个用 hexToBytes 转换。

申请内存写入的时候常用的是 alloc还有allocUtf8String,如果使用 alloc 写入字符串的话需要手动添加结尾 \0 使用 allocUtf8String 就不需要

ARM汇编

so文件的hook中是可以使用API修改汇编达到修改方法的目的的,因此还需要了解一下ARM汇编。

so中有使用ARM32、ARM64、X86、X86_64的汇编。

在ARM64中有 x0-x30 的寄存器,这些 x 开头的寄存器是64位的

w 开头的寄存器是32位的,一般64位的代码也会出现 w 寄存器,因为要考虑兼容为题,w 型的寄存器是从 x 寄存器中分离出来的,可以理解为 w2 寄存器是 x2 寄存器的一部分。在64为中,如果使用的数值比较少就会被放到 w 寄存器里,如果是一个指针什么的用到的数值比较大才会被放到 x 寄存器内

sp 属于栈寄存器,这里的SP 是一个申请栈空间的操作,SUB 表示操作减,因为在ARM64汇编中栈地址是从高地址到低地址(先进后出,压栈)(栈帧是从低地址到高地址),因此需要减去空间。减的时候减 0x10 的倍数,因为ARM汇编要求汇编对齐,要是16的整数倍

把大端、小端与堆、栈的生长方向联系起来记忆 - 蝉蝉 - 博客园

放一篇可以增强堆栈方向记忆的文章

栈为什么要由高地址向低地址扩展? - 知乎

ARM汇编参数是在寄存器中传递的,arm32中的参数通过R0-R3传递,如果参数超过四个就会通过栈传递 ,arm64中参数传递通过X0-X7传递,如果参数超过8个就会通过栈传递

进入函数的第一步就是使用 STR 将函数参数保存。

提升栈空间,保存参数之后,才真正的开始执行代码, LDR 表示读内存,将内存中的内容加载到寄存器中去,数据只有在寄存器中才可以进行运算。

ADD 表示相加 W8+W9 结果放到W8中

最终的结果会放到X0中去,X0寄存器也是用来存放最终结果的,因为返回值是int类型,32位就够用了,就使用了W0

1
2
3
4
5
LDR             W8, [SP,#0x20+var_14]
LDR W9, [SP,#0x20+var_18]
ADD W8, W8, W9
LDR W9, [SP,#0x20+var_1C]
ADD W0, W8, W9

栈结束的时候使用 ADD 将栈空间恢复

RET 用于函数返回

image-20250401202642596

跟大佬了解了一下exe汇编的一些操作,STR的操作相当于 push,Inter汇编中有两个指针 ebp 和 esp ARM中只有一个 sp 类似于 esp。

image-20250402110250128

ARM指令转换Hex https://armconverter.com

image-20250402160118080

同样的ARM汇编也是可以更改的,如果我想要更改 ADD 为 SUB 只需要将hex的 00 01 09 0B 更改为 00 01 09 4B

image-20250402160236638

frida修改so层代码

在Java层hook中是以函数为单位的,只能修改函数的参数返回值,进行主动调用等一些操作,但是不能直接对函数内的函数体进行修改,而so层的hook可以直接修改汇编指令,达到修改函数体的目的。非常强大。

上文也提到过一些,原理就是修改汇编代码。做练习frida labs0x0B 的时候也需要修改汇编达到目的

dump一下函数所在的地址可以看到hex的结果就是IDA左边的汇编指令,更改这个hex就可以达到更改汇编代码的效果

image-20250402162511809

使用writer

1
2
3
4
5
6
7
8
9
10
11
var soAddr = Module.findBaseAddress('libxiaojianbang.so');
var address = soAddr.add(0x1684);

// 修改内存权限为可读可写可执行
Memory.protect(address, 0x1000, 'rwx');
// address.writeByteArray(hexToBytes("0001094B")); 两种方法都可以
Memory.writeByteArray(address, [0x00, 0x01, 0x09, 0x4B]);
console.log(hexdump(address))

// 将对应地址的指令解释成汇编
console.log(Instruction.parse(address).toString())

确实生效了

image-20250402164936392

使用frida API

frida内是有API进行汇编指令修改的操作的

也是有一大串put方法,可以参考frida API文档食用。putBytes和putU8比较常用,都是传入十六进制数一个是hex 一个是 0x,如果这写put方法是拿出来单独使用的话最后还要补上 flush()。putRet() 是直接写入ret返回这个函数了

1
2
3
var soAddr = Module.findBaseAddress('libxiaojianbang.so');
new Arm64Writer(soAddr.add(0x167C)).putNop()
console.log(Instruction.parse(soAddr.add(0x167C)).toString())

image-20250402170520698

使用Memory.patchCode

也是官方文档里比较推荐的一种写法

1
2
3
4
5
6
7
8
9
10
var soAddr = Module.findBaseAddress('libxiaojianbang.so');
var address = soAddr.add(0x1684);
Memory.protect(address, 0x1000, 'rwx');

Memory.patchCode(address, 4, function (code) {
// 这里的pc是寄存器,指示代码运行到的位置
var writer = new Arm64Writer(code, { pc: address });
writer.putBytes(hexToBytes("0001094B"));
writer.flush();
})

so主动调用

之前的hook使用 Interceport.attach 那种方法是函数被执行时才进行hook,和Java层中的 类名.方法名.implementation 一样

回忆一下Java层主动调用的方法,主要是获取(创建)对象,调用对象内的Java方法

image-20250402173121170

so层的主动调用类似于NDK开发中的一个so去调用另一个so的操作,使用dlopen找到地址,强转成函数指针,然后进行调用。so的主动调用则是需要先拿到一个函数地址,然后声明函数指针,最后进行函数调用

声明函数指针

https://frida.re/docs/javascript-api/#nativefunction

1
2
// 声明函数指针,第一个参数传入函数地址,第二个参数传入函数返回值类型,第三个数组参数传入函数参数类型
var jstr2cstr = new NativeFunction(address, 'pointer', ['pointer', 'pointer'])

构建参数

构建env参数的时候有两个获取env方法,分别是 getEnv()tryGetEnv() ,getEnv如果没获取到env的话会报错,tryGetEnv 如果没获取到 env的话会返回一个null。

image-20250402185945047

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
var soAddr = Module.findBaseAddress('libxiaojianbang.so');
var address = soAddr.add(0x124C);

// 声明函数指针,第一个参数传入函数地址,第二个参数传入函数返回值类型,第三个数组参数传入函数参数类型
var jstr2cstr = new NativeFunction(address, 'pointer', ['pointer', 'pointer'])

// 构建参数
// 如果这里得不到env可以尝试将整个代码放到 Java.perfrom 中去,一般带有 Java 的API需要在Java.perfrom中运行
var env = Java.vm.tryGetEnv();
console.log("env: ",JSON.stringify(env))
// jstring 使用 env 来构建
var jstr = env.newStringUtf('hello world');
console.log("jstr: ",JSON.stringify(jstr))

var cstr = jstr2cstr(env, jstr);
console.log(hexdump(cstr))

在so中可以直接看到的都是CString,Java类型的string在虚拟机里正常,在so中是显示不出来的,比如代码中创建的jstr在hook的时候打印出来的结果是0x1,查看不了字符串内容

image-20250402191558203

hook libc 读写文件

frida的API仅有写出文件的操作

1
2
3
4
var file_handle = new File(file_path, "wb");
file_handle.write()
file_handle.flush()
file_handle.close()

没有读文件的操作,想要读文件就只能去hook libc了,libc也支持写文件的操作

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
// 使用
function readTxt() {
var fopenAddr = Module.findExportByName('libc.so', 'fopen')
var fputsAddr = Module.findExportByName('libc.so', 'fputs')
var floseAddr = Module.findExportByName('libc.so', 'fclose')
console.log(fopenAddr, fputsAddr, floseAddr)

var fopen = new NativeFunction(fopenAddr, 'pointer', ['pointer', 'pointer'])
var fputs = new NativeFunction(fputsAddr, 'int', ['pointer', 'pointer'])
var fclose = new NativeFunction(floseAddr, 'int', ['pointer'])

// 注意路径要在包名所在的文件夹下,其他文件夹八成没有权限进行写入操作
var fileName = Memory.allocUtf8String('/data/data/com.xiaojianbang.app/test.txt')
var openModule = Memory.allocUtf8String('w')
var str = Memory.allocUtf8String('hello world\n')

var file = fopen(fileName, openModule);
fputs(str, file)
fclose(file)
}

image-20250402205425905

jni函数的hook

jnitreach非常好,要是我能用就更好了。学一下怎么手动hook jni的函数吧

首先是地址获取问题

F1:编译后的jni是在 libart.so 中的,可以去这个so中去寻找jni函数的地址

F2:jni函数来自于jnienv,而这个jnienv本质上是一个结构体,结构体内都是函数指针,只要能够定位到结构体,再根据函数在结构体内的偏移,就可以获取函数的地址值

hook libart

libart.so 并不是在apk内部的,而是在手机中

Android10 之前的应该是在 /system/lib/system/lib64

Android10 以及Android10以后 在 /system/apex/com.android.runtime.release/lib/system/apex/com.android.runtime.release/lib64

image-20250402211405820

枚举符号表

查找字符表,然后根据名称筛选方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function hook_jni() {
var symbols = Process.getModuleByName("libart.so").enumerateSymbols();
var NSUAddr = null;
for (let i = 0; i < symbols.length; i++) {
var symbol = symbols[i]
if (symbol.name.indexOf('CheackJNI') == -1 && symbol.name.indexOf("NewStringUTF") != -1) {
console.log(symbol.name, symbol.address);
NSUAddr = symbol.address;
}
}
Interceptor.attach(NSUAddr, {
onEnter: function (args) {
console.log("NSUAddr: ", args[1].readCString());
},
onLeave: function (retval) {
console.log("NSUAddr return: ", retval);
}
})
}

计算地址

看个乐呵,主要还是枚举符号表进行hook,因为计算地址比较麻烦,并且在32位和64位的so中的指针分别为4字节、8字节

jni函数本质上来说是一个结构体,既然是结构体指针就可以根据函数定义在结构体中的位置来计算偏移找到对应的函数地址,但是这个可能会有版本问题,比如使用jni定义的位置不一样。

对应C语言来说 jnienv 直接就是一个 jninactiveinterface 的指针。C++来说 jnienv 是指向了一个结构体,结构体中定义了一个function,这个function是 jninactiveinterface 的指针。

image-20250507161605436

因此可以说是都是 jninativeinterface的指针。因此在hook的时候可以先得到 jninative interface 这个结构体的地址

console.log(JSON.stringify(Java.vm.tryGetEnv()))

执行之后得到两个地址

1
{"handle":"0x7408e1c500","vm":{"handle":"0x7499b191c0"}}
1
2
var env = Java.vm.tryGetEnv()
env.handle

env和env.handle在一定程度上是通用的,或者说会自动完成转换。但是在使用frida封装的JNI相关的API,必须使用 env

当参数需求JNIEnv*时,可以使用 env 或者 env.handle 。简述一下能用env就用env

0x7408e1c500 这个地址指向的是 jninativeinterface 的指针

从dump下来的内容可以看到三个指针,很显然是不符合 jninativeinterface 结构体中全是指针的情况,这里面第一个指针是 jninativeinterface 的指针,他指向 jninativeinterface 这个结构体

image-20250507161912573

读取一个指针长度,再进行dump,就可以看到与 jninativeinterface 相符的情况了,结构体前四个指针保留。这个时候才定位到 jninativeinterface 的原始结构体

image-20250507162401735

image-20250507162315845

想要hook对应的方法,还需要数偏移,比如 FindClass 方法,64位中一个指针8个字节,需要偏移 48 字节,偏移完 48 字节之后,得到 findclass方法的地址,将这个地址当作指针来读取,就可以看到方法体的具体内容,如果只是进行hook的话,上一步已经得到了方法地址。

image-20250507163243014

还可以对一下arm汇编指令,四个字节一条arm汇编

image-20250507164353078

hook findclass 方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function hook_jni2() {
// console.log(JSON.stringify(Java.vm.tryGetEnv()))
// console.log(hexdump(Java.vm.tryGetEnv().handle.readPointer()))
var envAddr = Java.vm.tryGetEnv().handle.readPointer()
// console.log(hexdump(envAddr.add(48).readPointer()))
var funcAddr = envAddr.add(48).readPointer()
// console.log(Instruction.parse(funcAddr.add(4)).toString())
// console.log(Instruction.parse(funcAddr.add(8)).toString())
// console.log(Instruction.parse(funcAddr.add(12)).toString())
// console.log(Instruction.parse(funcAddr.add(16)).toString())
Interceptor.attach(funcAddr, {
onEnter: function (args) {
console.log("FindClass args[1]: ", args[1].readCString());
},
onLeave: function (retval) {}
})
}

这里第一次执行的时候多打印了一行,再执行就没有了

image-20250507164807174

原因是因为加载so,执行 JNI_Onload 方法中使用了 findclass 加载了这个so,之后在点击不会进行加载操作了

主动调用jni函数

f1:使用frida封装的函数来调用jni

上文有提到过主动调用函数,通过声明函数指针,构建参数

1
2
3
4
5
6
7
8
9
10
11
12
13
var soAddr = Module.findBaseAddress('libxiaojianbang.so');
var address = soAddr.add(0x124C);

// 声明函数指针
var jstr2cstr = new NativeFunction(address, 'pointer', ['pointer', 'pointer'])
// 构建参数
var env = Java.vm.tryGetEnv();
// jstring 使用 env 来构建
var jstr = env.newStringUtf('hello world');

// 主动调用
var cstr = jstr2cstr(env, jstr);
console.log(hexdump(cstr))

这里面就用到了 frida 封装的 jni 方法,即 newStringUtf 这种方法需要frdia获取的env才行,即 Java.vm.tryGetEnv() 很多常用的jni方法都已经被封装好了

image-20250507173151163

f2:使用nativefunction方式调用

第一步就是先获取到这个方法的真实地址,也有两种方法,hook libart(通过枚举符号表,筛选地址) 和手工计算地址

没算出来具体的地址,这玩意逮着 jni.h 挨个数啊,整不了

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
function call_jni2() {
var symbols = Process.getModuleByName("libart.so").enumerateSymbols();
var NSUAddr = null;
for (let i = 0; i < symbols.length; i++) {
var symbol = symbols[i]
if (symbol.name.indexOf('CheackJNI') == -1 && symbol.name.indexOf("NewStringUTF") != -1) {
console.log(symbol.name, symbol.address);
NSUAddr = symbol.address;
}
}
if (NSUAddr == null) return;
var nsufun = new NativeFunction(NSUAddr, 'pointer', ['pointer', 'pointer'])

// 这里是需要一个指针的,不能直接给字符串,需要利用frida的API申请内存,将内存的地址传入
var res = nsufun(Java.vm.tryGetEnv().handle, Memory.allocUtf8String('hello world'))
console.log(res)


var envAddr = Java.vm.tryGetEnv().handle.readPointer()
var getStringUtfChars = envAddr.add(0x550).readPointer()
var get_func = new NativeFunction(getStringUtfChars, 'pointer', ['pointer', 'pointer', 'pointer'])
var csting = get_func(Java.vm.tryGetEnv().handle, res, ptr(0))
console.log(csting.readCString())
}

构建二级指针

主动调用的时候,有可能需要的参数是一个二级指针

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function call_func() {
var soAddr = Module.findBaseAddress('libxiaojianbang.so');
var xiugaistr = soAddr.add(0x17D0);
var xiugaistr_func = new NativeFunction(xiugaistr, 'int64', ['pointer'])

// 构建参数的一级指针
var strAdd = Memory.allocUtf8String("sagdkjsahgbdjsha")
console.log(hexdump(strAdd))

// 再构建一个指针,指针指向刚构建的一级指针
var finalAdd = Memory.alloc(8).writePointer(strAdd)
xiugaistr_func(finalAdd)
console.log(hexdump(strAdd))
}

堆栈打印

前面hook了系统函数,查看了系统函数的调用,那么包需要看一下堆栈的,找到方法

frida中已经有现成的了

1
console.log(Thread.backtrace(this.context, Backtracer.FUZZY).map(DebugSymbol.fromAddress).join("\n") + '\n');

打印有一个特性,他给出的地址-so的基址不是准确的调用位置的,而是调用位置的一下行,因为存储的是 lor 的地址,因为方法的ARM指令是 BL,函数执行完毕会有返回的,返回的地址会存到lor寄存器中,正常就是返回之后的下一条指令的地址。因此需要注意,打印堆栈看到的函数地址,比实际的函数地址多了一条指令的地址,64位情况下是4

image-20250508142513917

Backtracer有两个选项

FUZZY 对于任何二进制文件都有效,但是可能出现地址的误报

ACCURATE 并不是对任意二进制文件有效,地址会更准确

image-20250508143054019

将这一行拆开来看

1
2
console.log(soAddr)     // 输出so基址
console.log(Thread.backtrace(this.context, Backtracer.FUZZY)); // 获取函数栈中所有地址

看一下这个backtrace

要两个参数,返回值是一个数组,如果这样直接执行的话,打印出来的就是一个数组,毫无阅读性。

image-20250517203007663

image-20250517203751335

这个时候就需要 DebugSymbol.fromAddress

通过 DebugSymbol.fromAddress 方法来获取传入的地址附近的调试信息,返回值如下

image-20250517204316462

map 和 join 就是js方法了 ,map将数组中的每一个成员都传递进后面函数作为参数,用方法的返回值替换成员,join,拼接字符串,了解完这个之后,就可以根据喜好来改造堆栈打印的结果了

1
2
3
console.log(Thread.backtrace(this.context, Backtracer.FUZZY).map(function (value) {
return DegbugSymbol.fromAddress(value) + "offset:" + value.sub(soAddr);
}))

确认native函数在哪个so中

之前确定是通过静态分析来找so的,先关键代码快速定位,然后找到对应的native函数,再结合上下文看有没有加载so,这种方式找到的so可能是错的,如:函数所在的so早就加载了,因为so只要在函数执行前加载即可,并不需要一定加载在方法的附近,有的app就会在一个类中加载所有的so,其他函数调用使用的时候就不进行加载了,只剩下一个方法名,找不到对应的so

可以通过hook系统函数来得到绑定的native函数地址,然后再得到so地址

jni函数动态注册,可以hook RegisterNative

jni函数静态注册,可以hook dlsym

jni函数静态注册,其实定义静态方法的时候也不需要注册,只需要注意命名格式,当首次执行native函数时,是没有对应关系的,系统会去遍历所有的so,从导出表中找到符合命名规则的函数,如果成功找到就获取方法地址,和native函数绑定。在寻找的时候会使用到 dlsym,因此可以hook dlsym

jni函数动态注册的时候需要用到jni函数RegisterNative,

hook dlsym 静态注册

dlsym 和 dlopen 是在同一个so中的

1
2
3
4
5
6
7
8
9
10
11
12
function hook_dlsym() {
var dlsymAddr = Module.findExportByName(null, 'dlsym')
console.log(dlsymAddr)
Interceptor.attach(dlsymAddr, {
onEnter: function (args) {
this.args1 = args[1];
},
onLeave: function (retval) {
console.log(this.args1.readCString(), retval)
}
})
}

dlsym需要重新打开app hook,因为有些方法是在app加载的时候就可以绑定好了的,不需要额外触发绑定,第一次执行so层方法时触发 dlsym 方法去寻找绑定方法。只要是静态注册的方法都需要调用 dlsym

image-20250508184018725

再根据得到的地址,去获取所在的模块地址,打印so名称,函数偏移

image-20250508185153361

hook RegisterNative 动态注册

RegisterNative 方法有四个参数,第一个是 jnienv ,第二个是函数所在的类名称,第三个是一个结构体,结构体内是三个指针,第四个是注册的方法个数

先获取类名以及方法数,然后根据方法数来分离结构体中的三个参数

image-20250508192242651

兼容一下32和64位的,用 Process.pointerSize 来表示一个指针的长度

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
function hook_RegisterNatives() {
let regAddr = null;
Process.getModuleByName("libart.so").enumerateSymbols().forEach(function (symbol) {
if (symbol.name.indexOf('RegisterNatives') != -1 && symbol.name.indexOf('CheckJNI') != -1) {
// console.log(symbol.name, symbol.address);
regAddr = symbol.address;
}
})
if (regAddr == null) return;
Interceptor.attach(regAddr, {
onEnter: function (args) {

var env = Java.vm.tryGetEnv();
var classname = env.getClassName(args[1])
var methodCount = args[3].toInt32();
for (let i = 0; i < methodCount; i++) {
var methodName = args[2].add(Process.pointerSize * 3 * i).readPointer().readCString();
var methodSignature = args[2].add(Process.pointerSize * 3 * i + Process.pointerSize).readPointer().readCString();
var methodAddr = args[2].add(Process.pointerSize * 3 * i + Process.pointerSize * 2).readPointer();
var module = Process.findModuleByAddress(methodAddr)
// if (module == null) continue;
console.log(classname, methodName, methodSignature, methodAddr, module.name, methodAddr.sub(module.base));
}
},
onLeave: function (retval) {
}
})
}

image-20250508193835539

inline hook

inlinehook 的目标是汇编指令,例如 ADD W8, W8, W9

可以通过 inlinehook来查看W8寄存器中的数值,具体思路和操作ARM汇编差不多

1
2
3
4
5
6
7
8
9
10
11
12
13
function inlineHook () {
var nativePointer = Module.findBaseAddress('libxiaojianbang.so');
var address = nativePointer.add(0x17BC);

Interceptor.attach(address, {
onEnter: function (args) {
console.log("onEnter: ", this.context.x8)
},
onLeave: function (retval) {
console.log("onLeave: ", this.context.x8)
}
})
}

定位到指令所在地址,读取寄存器中的数字,同样的指针也可以读取

image-20250509085324392

inlinehook的作用比较微操了,在函数执行到某一行汇编的时候打印查看参数,功能性方面感觉像是断点了,inline hook在32位的时候不稳定

ART下的System.loadLibrary

System.loadLibrary 是Java层加载so使用的函数,目的是了解三个函数的运行 ,JNI_Onload ,init,initarray

找一个在线网站,我手机是 Android 10.0.0 r1。这个的 loadLibrary 方法在 libcore/ojluni/src/main/java/java/lang/System.java 中,其他版本也可以搜索一下 system.java 中的 loadLibrary 方法

从system中的loadLibrary进入到 runtime.java 的 loadlibrary0 方法

image-20250509093543463

在 loadlibrary0 方法中,处理了传入的字符串,方法是 loader.findLibrary ,接下来去找 classload 这个类,发现中间并没有进行什么操作,那么就可以看哪些类继承了 classload 复写了 findLibraay 方法

image-20250509113836112

又调用了 system 中的 mapLibraryName 方法,这个方法是一个native方法

image-20250509114635117

在这个C文件中,对函数进行了动态注册,NATIVE_METHOD 这个方法是将第一个和第二个参数拼接起来 使用的是 ## _ ## ,拼接成 system_mapLibraryName 。

然后再看这个方法, JNI_LIB_PREFIX 对应 ‘lib’ JNI_LIB_SUFFIX 对应 ‘.so’

这个方法将字符串拼接成了 lib+str+.so 的形式,就是需要加载的so的名称了,字数超过40的报错。然后将Cstring转成jstring返回

image-20250509114623082

得到返回值之后,后面的操作是去寻找这个so的全路径,在往上走

在nativeLoad方法中进行加载,返回一个error判断是否加载成功

image-20250509203720021

在去寻找 JVM_nativeload

image-20250509204244280

先判断了是否为空,然后将 filename 转换成 cstring 调用 loadnativelibrary

image-20250509204314125

先对第一个参数进行的处理 librarier 中存放的是已经加载的so,检测该so是否已经加载,如果已经加载 进入 library != nullptr 这个一步,未加载的没截全

image-20250509204553714

如果未加载执行

在1005行中的 opennativelibrary 开始进行加载so的操作了

image-20250509205115355

opennativelibrary 方法中还是调用了 Android_dlopen_ext 和 dlopen 方法

image-20250509205420488

从 android_dlopen_ext 到 dlopen_ext 再到 do_dlopen 。到 do_dlopen 的时候已经和dlopen的方法差不多了,do_dlopen方法不必多看,只需要注意最后的地方

image-20250509210243620

在 find_library 的时候完成了整个so的加载,加载完成之后,进行了一个判断,如果不为空

取出这个so的handle地址,还执行了一个 call_constructors 方法

image-20250509210456894

在这个方法中调用了两个方法 DT_INIT 其实就是 init 方法 DT_INIT_ARRAY 是initarray 方法。这就是这两个方法被调用的时机。这个两个方法在 do_dlopen 内部被调用,因此hook do_dlopen 是不能看出变化的

image-20250509210844613

再去寻找 jni_onload 的调用时机

循着上面 opennativelibrary 往下找,调用 findsymbol 去寻找 jni_onload 符号

image-20250509212911045

通过 findsymbol 也可以看到,是走 dlsym 的,之前hook dlsym 的时候也的确有 jni_onload 的输出。

函数中是允许找不到符号的,因为也可能出现没定义 jni_onload 的情况,如果找到的话,就去 call jni_onload 也就是说 jni_onload 是在 LoadNativeLibrary 方法中,在dlopen方法完全执行完毕,so加载完毕之后通过 dlsym 进行调用的

image-20250509212529673

调用方法,定义一个指针,将获取到的地址强转成指针,然后调用。然后按照规范返回 jni 的版本号。如果版本号不符号要求,报错。符合要求结束。这个时候也执行结束了

image-20250509213207012

dlopen 执行完毕之后 通过 dlsym 方法来调用 jni_onload,如果要hook init 和 initarray 方法的话,就需要等待so加载完毕之后,init执行之前

image-20250509213813604

hook_initarray

之前看到 init 函数和initarray函数是在 do_dlopen 方法中的 find_library 执行完毕之后,执行的 call_constructors 方法中进行调用的。

因此要想hook initarray的时机就是在 call_constructors 方法执行之前,在 call_constructors 执行之前so已经加载完毕了,且init和initarray方法还没有被执行。并且call_constructors 也是可以在符号表中找到的,不需要去计算地址。

call_constructors 方法是在 do_dlopen 方法中执行的,hook call_constructors 的时候,所有so加载的时候都会有执行,那是很糟糕的,不知道是否是自己想要hook的那个 initarray 所在的so。因此还是需要hook一下dlopen,来监听so的加载

这里hook的dlopen和之前hook的方式不同,之前是等待dlopen执行完毕之后再进行hook,这是在dlopen执行之前进行hook,因为init和initarray方法在dlopen执行的过程中就执行了,时机晚了就hook不到了,还有就是为了防止出现重复hook的报错(低版本报错 ==already replaced this function== ,高版本执行多次),还有一点是hook call_constructors方法,call_constructors方法执行的时候so已经加载完毕,这个时候也就不存在,之前将hook代码放到dlopen方法之后的原因了,可以顺利找到so的基址,因此下面可以直接根据so名称来搜索。这个是一个时机问题。

还有一处新的地方是根据地址,重写方法, Interceptor.replace 先将地址给他,在写一个new NativeCallback,传入返回值类型以及参数类型。我的版本写 Interceptor.attach 貌似也可以

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
function hook_initarray(soName) {
function hook_dlopen2() {
var dlopen = Module.findExportByName(null, 'dlopen');
var android_dlopen_ext = Module.findExportByName(null, 'android_dlopen_ext');
Interceptor.attach(dlopen, {
onEnter: function (args) {
var soPath = args[0].readCString();
if (soPath.indexOf(soName) != -1) hook_call_constructors()
},
onLeave: function (retval) {
}
})
Interceptor.attach(android_dlopen_ext, {
onEnter: function (args) {
var soPath = args[0].readCString();
if (soPath.indexOf(soName) != -1) hook_call_constructors()
},
onLeave: function (retval) {
}
})
}

hook_dlopen2()
var ishook = false;

function hook_call_constructors() {
var call_constructors = null;
Process.getModuleByName("linker64").enumerateSymbols().forEach(function (symbol) {
if (symbol.name.indexOf('call_constructors') != -1) {
call_constructors = symbol.address;
console.log("call constructors: ", call_constructors)
}
})
Interceptor.attach(call_constructors, {
onEnter: function (args) {
if (!ishook) {
var Addr = Module.findBaseAddress(soName);
var f1 = Addr.add(0x1C54);
var f2 = Addr.add(0x1C7C);
var f3 = Addr.add(0x1C2C);

Interceptor.replace(f1, new NativeCallback(function () {
console.log("f1 is requested")
}, 'void', []))

Interceptor.replace(f2, new NativeCallback(function () {
console.log("f2 is requested")
}, 'void', []))

Interceptor.replace(f3, new NativeCallback(function () {
console.log("f3 is requested")
}, 'void', []))
ishook = true;
}
},
onLeave: function (retval) { }
})
}
}

init和initarray的执行时机很贴近,init和initarray的hook的时机是一样的

hook_JNIOnload

jnionload是在so文件的加载方法dlopen执行完毕之后再进行执行的,这个时候已经可以找到so的基址了,仅需要在dlopen执行完毕之后进行hook即可,不需要像initarray那样操作。但是hook必须使用dlopen,因为执行的时机很早,而且不是触发式的方法

可以直接hook_JNIOnload重写方法,也可以hook JNIOnload 中调用的方法,比如,开了多线程,进行检材,直接给检测方法重写掉。在实战中,还是后者比较常用

1
2
3
4
5
6
7
8
9
function hook_JNIOnload() {
var addr = Module.findBaseAddress('libxiaojianbang.so')
var funtaddr = addr.add(0x1D68)
Interceptor.replace(funtaddr, new NativeCallback(function (env) {
console.log("JNI_OnLoad: ", env)
}, 'void', []))
}

hook_dlopen("libxiaojianbang.so", hook_JNIOnload)

如图,hook掉了一个线程,没有输出十个数字

image-20250517194231089

hook_pthread_create(多线程)

pthread_create是开启线程的方法,之前提到过了,如果软件要进行什么检测,肯定是开启子线程进行检测,不可能在主线程中进行。一般开启子线程是使用 pthread_create 的。

pthread_create在 libc.so 中

pthread_create有四个参数,第一个是线程ID,第二个是线程属性,第三个才是需要的函数名,第四个是函数参数

image-20250517195136003

1
2
3
4
5
6
7
8
9
10
11
12
13
function hook_pthread_create() {
var pthread_create = Module.findExportByName(null, 'pthread_create');
console.log("pthread_create_addr: ",pthread_create)
Interceptor.attach(pthread_create, {
onEnter: function (args) {
console.log("pthread_create: ", args[0], args[1], args[2], args[3])
var Module = Process.findModuleByAddress(args[2])
if (Module != null) console.log(Module.name, args[2].sub(Module.base))
},
onLeave: function (retval) {
}
})
}

一些系统函数也是会调用 pthread_create 的,注意辨别哪个 so 是apk的so,再根据打印出来的地址寻找方法

image-20250517195740682

替换函数

之前有用到过 Interceptor.replace 进行替换多线程的函数方法

Interceptor.replace 接收两个参数,第一个是要进行操作的地址指针,第二个是方法指针,说白了就是传入了俩指针

image-20250518200334077

image-20250518205309392

之前传入第二个参数的时候使用的是 new NativeCallback

NativeCallback 继承 NativePointer 所以可以当作参数传入

func是函数的方法体

retType 是返回值类型

argTypes是参数列表

abi表示支持的平台,?表示可省略

image-20250518205457685

进行替换的时候最好保证返回值类型相同,还需要注意参数类型和原方法的参数类型的一致性,否则包出错的,本来pointer类型需要读八个字节,整成int读四个字节,能一样吗

这种替换函数其实并没有真正的替换掉,原函数在内存中还是存在的,还是可以通过 NativeFunction 来进行调用原方法

可以通过这种方式实现一种另类的查看参数,其实这种方法和Java层的hook方法是一样的。获取类->改写类中的某个方法-> 打印参数->调用原方法 ->返回。只不过Java中的方法是不能进行详细的更改的,限制还是比较大的

1
2
3
4
5
6
var funt = new NativeFunction(funtaddr, 'void', ['pointer'])
Interceptor.replace(funtaddr, new NativeCallback(function (env) {
console.log("JNI_OnLoad: ", env)
var res = funt(env)
return res
}, 'void', ['pointer']))

hexdump

hexdump用于查看内存中的内容,之前用到很多,显示格式是十六进制查看,默认dump出来16*16 256个字节。这个方法是可以整一点花活的,比如信息更多,信息更少,不要十六进制头

offset表示从给定的地址偏移多少字节开始读取。

length表示一行显示多少字节,这里给默认是256,超过16字节自动换行,其实就是用来控制输出长度的。

header表示是否显示十六进制头

ansi表示进行彩色显示,只有这一种颜色,false或者true,也没啥意思

image-20250518213213690

主要用到的还是 length 查看显示不完全的信息

frida-trace

之前配置魔改过一个IDA插件traceNative,插件是将 .text 段的所有函数进行一个筛选之后输出到一个txt文件中,筛选代码行数超过10行的方法。

这个txt的hook就是使用 frida-trace 来完成的

1
frida-trace -UF -o .txt

自动生成的txt文件中全部是 -a 参数,-a 在frida-trace中表示hook后面的地址,就是相当于用frida来hook函数

image-20250519185710731

执行一下会自动生成一些js文件,前面输出的 sub 偏移地址就是文件中的偏移地址

image-20250519191342968

文件的内容也是比较简单的,就是简单的hook了当前地址,就是参数有点不一样,log就相当于 console.log ,args和正常hook 的含义相同

整个函数的hook和 Interceptor.attach 的作用是相同的,这个的执行就是为了查看函数的执行流程,因为他只在函数执行前打印函数的地址。

image-20250519191559123

如图,会展示出一个清晰的函数调用顺序的流程图

image-20250519192029777

生成的js文件是可更改的,目录下没有对应的js文件时,执行 frida-trace 会自动生成一个 Auto-generated 如果已经存在了那就是加载 Loaded 也就是说这个流程图是可以在后期进行一些修改,再输出一些方法参数,函数的符号信息等等。

image-20250519192754371

但是后期生成的文件有点多了貌似,手动改很麻烦的啊,而且重复性高,不如写个脚本改,有能力的大佬可以修改 frida-trace 的源码,再重新打包

image-20250519201523928

我比较废物,只能写了一个小脚本来手动修改,想了半天因为so层函数的变量具有太多的不确定性了,需要手动进行分析,如果直接打印参数出来,反而会变的很乱,影响整个流程图的观看,所有就加了一个函数的符号表信息,方便查看函数名称。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
import os

def modify_js_files(folder_path):
if not os.path.exists(folder_path):
print(f"{folder_path}路径不存在")
return
for root, dirs, files in os.walk(folder_path):
for file in files:
if file.endswith('.js'):
file_path = os.path.join(root, file)
with open(file_path, 'r') as f:
content = f.readlines()
# print(content)
file_name = os.path.basename(file_path)
address = file_name.split('_')[1].split('.')[0]
if len(content) > 10:
soName = folder_path.split('\\')[-1]
content[9] = f" var soAddr = Module.findBaseAddress('{soName}').add(0x{address}); \n log('{file_name.split('.')[0]}' ,DebugSymbol.fromAddress(soAddr).name)\n"
with open(file_path, 'w', encoding='utf-8') as f:
f.writelines(content)
print(f"已修改{file_name}文件内容")
else:
print(f"文件行数不足,请检查{file_name}文件内容")

内存读写监控(了解)

firda的内存读写监控了解即可,一般使用unidbg的内存读写监控

frida的读写监控是利用的一个异常处理函数

Process.setExceptionHandler 注入之后只要触发异常就进入定义的方法,注意方法体内要对异常进行处理,然后返回 true,如果不处理返回 true,会陷入死循环

这个方法再配合 Memory.protect 修改内存权限的API

Memory.protect 修改权限并不是修改某一个指针地址的权限,而是修改内存页的权限,因为内存过大的话,以字节为单位难以管理。因此内存是按照某一个单位来分内存页来进行管理的。比如说 4096字节一个单位

1
2
3
4
5
6
7
8
9
10
11
12
function set_read_write_break() {
Process.setExceptionHandler(function (details) {
console.log(JSON.stringify(details, null, 2));
console.log('lr', DebugSymbol.fromAddress(details.context.lr));
console.log('pc', DebugSymbol.fromAddress(details.context.pc));
Memory.protect(details.memory.address, Process.pointerSize, 'rwx');
console.log(Thread.backtrace(details.context, Backtracer.ACCURATE).map(DebugSymbol.fromAddress).join("\n") + '\n')
return true;
})
var addr = Module.findBaseAddress('libxiaojianbang.so').add(0x3DE0)
Memory.protect(addr, 8, '---') // 修改内存页权限
}

为什么说这两个可以监控内存读写呢,先给指定的地址的权限取消掉,如此这一段内存是不允许读写执行的,当有程序需要读、写、执行的话就会报无权限的错,这个时候再进入Process.setExceptionHandler 打印函数栈和寄存器信息,将权限变更为可读可写可执行,解决报错返回true,程序正常运行。

这种方法就是针对一个地址,通过权限问题来监控内存的读写情况。这种方式感觉和断点很像啊🤔

以此类推,构造一个异常就可以进行这个操作,不一定是非法访问异常

还支持中止(流产是什么鬼,蜜汁翻译),非法指令什么的

image-20250519211502144

details中包含错误信息 message ,哪个地址访问的 address 访问的方式,以及访问错误的地址 memory ,寄存器信息 context

image-20250519212015469

这个东西的缺点还是很明显的,只能触发一次,只有首次内存访问的时候才会被监控,记录下来信息,报错之后会恢复权限,下次再执行就不会报错的。

而且根据提示访问无权限的信息,大概率不是给出的地址信息,只要和地址信息在同一个内存页的任何一行代码被访问到都会触发报错,并且代码触发后失效

挺鸡肋的,监控不够精确,了解即可

Frida监测

image-20250519213431230

占坑式

开启一个子进程附加父进程

一个app只能被一个进程附加,在app加载的时候自己附加自己,这样frida就无法附加了

1
ptrace(0,0,0,0)

如果软件使用这种方法,进行 -UF 附加注入的话会报一个错误

Failed to attach: unable to access process with pid xxxx due to system restrictions; try sudo sysctl kernel.yama.ptrace_scope=0 , or run Frida as root

我实操的时候报错是这样的,提示未找到进程

image-20250519215420099

使用 -f 确实会注入成功,但是马上进程就会中止

image-20250519215329911

这个时候就要稍微说一下两种方式的区别了,之前已经很熟悉的是 -f 会重新启动app -F 直接附加。

-f 注入的时候是先调用一下 ptrace 然后就释放了,而 -F 是一直 ptrace 附加在进程上,因此退出app frida会立即中断,而 -f 则不会

image-20250519215916472

使用指令查看app进程情况

1
ps -A |grep 包名

看到有两个进程,下面是上面的附加进程,第一个数字是 pid 第二个是 ppid ppid表示该进程的父进程

image-20250519220254981

但是呢,软件又会有附加进程的情况,不单是占坑防止frida。比如:

守护进程:防止app崩溃,保证app服务不会kill

普通双进程:单线程资源访问有限,虽然手机内存大,但是一个线程可以使用的资源是有限的,因此有时候会使用双进程,多进程,进程之间相互通信

还有就是防止注入了,其实也防不住,可以使用frida解决掉的,注入成功了就有操作空间的

对应多进程的app,可以使用 pid 来进行注入,因为多线程可能是有同名进程的 -f 是注入不了的

还可能会有进程名检测和端口检测

进程名检测

进程名检测就属于是防君子不防小人了,操作就是遍历进程列表,找到疑似frida-server的,打死你hifrida-server的进程名就是文件的命名,避开frida-server的常规命名就检测不到了

我的命名就是frida-server,纯小白命名,可以根据自己的喜好微操一下

image-20250520175226846

端口检测

frida-server的默认TCP端口是27042,检测27042端口是否开放,用处也不大,因为默认 27042 不一定就是 27042

D-Bus协议通信

frida使用D-Bus协议通信,可以遍历 /proc/xxxx/net/tcp 文件(xxx表示app的pid,软件运行的时候才可以进入)

image-20250520180644721

然后向每个端口发送D-Bus认证信息,哪个端口回复了 REJECT ,就是frida-server的端口

或者说之间遍历所有端口,发送认证信息

image-20250520180532824

但是这个也是可以hook了,将 strcmp hook掉,更改第二个参数

遍历maps

可以看到有被加载的so,之所以一个so可能会有多个,是因为so的权限不一样,但是地址是连续的 ,从797c2f9000 - 797c2fa000 - 797c2fb000

frida注入之后会出现 frida-agent.so,这个也是一个检测点

image-20250520181224549

扫描task目录

task目录下有很多文件夹,表示app开启的线程

image-20250520181738858

文件夹内的status文件中有一些信息

如果进程被附加了,这个 TracerPid 就不为 0

state 为 S 表示当前未被附加,也未被调试,被调试是 T

image-20250520182000589

name为 gmain、gdbus、gum-js-loop、pool-frida 这些都是frida注入的特征

image-20250520182549554

可以通过 readlink 方法查看 /proc/xxxx/self/fd、/proc/xxxx/self/task/pid/fd 下所有打开的文件,检测是否有frida相关的文件

还可以扫描内存中是否有frida库特征的存在,例如字符串 LIBFRIDA 。不同版本的特征还是有些不同的

常用于检测frida的函数有 ==strstr、strcmp、open、read、fread、readlink==

函数都放这了,过frida检测就试试呗

补充、总结

通常比较会被检测的文件

riru的特征文件

​ /system/lib/libmemtrack.so

​ /system/lib/libmemtrack_real.so

cmdline 检测进程名,防止重打包

status 检测进程是否被附加

stat 检测进程是否被附加

task/xxxx/stat

task/xxxx/status 检测线程 name 是否包括

fd/xx 检测app是否打开了frida相关文件 (frida版本16.5.2,frdia注入之后好像没有看到frida相关文件,难道是优化了?)

maps 检测app是否加载的依赖库里是否有frida

net/tcp 检测app开放的端口,进行 D-Bus协议通信

还有可能检测 /data/local/tmp 目录,一般app是不会加载这个目录的,但是frdia-server一般都是放在这个目录。如果frida-server不放在 /data/local/tmp 目录那么基本可以不用关心 fd 和 maps 的检测