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"}
1 2 3 4 5 6 感觉只有 pa 字段值得注意, MT ey 开头的一般是base64 MTczMjAxNzY1MzAzMyxhMTZhM2ZmOWVmYzU0M2IxYjlkMTBiMGI3NjY3NTk4MiwzZjg3MjNlMWNiMzBhYzYwM2RiYjY1ZGQ4N2QxMTA3Miw= base64解码 1732017653033,a16a3ff9efc543b1b9d10b0b76675982,3f8723e1cb30ac603dbb65dd87d11072, 看到有三节数据,第一节感觉像是时间戳
那么直觉告诉我剩下两个就是账号和密码了
1 2 3 a16a3ff9efc543b1b9d10b0b76675982 3f8723e1cb30ac603dbb65dd87d11072 32位密文感觉和MD5有关,但是不完全是,可能加了什么东西
加载的是 encryptlib 这个so文件,解压出来,扔IDA中
从导出表内可以看到这个方法 Java_com_pocket_snh48_base_net_utils_EncryptlibUtils_MD5
可以看到初始化常量还要 0x80 的填充
so层的分析就是hook关键函数,方法有六个参数,传进来四个,第一个是content是个对象,不用管,剩下三个字符串。分析的时候完全可以从下往上分析,return了 v39,v39是从result转换出来的,result显然是作为接收结果的参数传入 MakePassMD5 的,v48应该是content,v37和v38按照C语言的习惯应该是明文和明文长度,这个时候就可以尝试hook MakePassMD5
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)
枚举导出表
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导出表,搜索这个函数,获得函数名和地址
在导出表中的函数,也可以使用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 Interceptor .attach (funcAddress, { onEnter : function (args ) { }, onLeave : function (retval ) { } });
直接打印args的内容可能只有一个地址,或者只有一个十六进制数,可以利用 hexdump 函数去找这个地址对于的内容,十六进制数可以转换成十进制显示
按照之前的分析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]目前为空,准备接收加密结果
这个时候打印的返回值只有一个长度信息 32,我们需要看 args[3],执行完毕之后的结果,这个时候需要提前将args[3]这块地址保存下来,在执行完毕之后再查看,出现MD5计算之后的结果
1 2 3 4 5 6 7 8 9 10 11 12 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 )) } });
得到了后面的加密出处,而且还要意外发现,入参 args[1] 就是前两个参数拼接起来的
运算一下,也是标准的MD5
模块基址的获取方式 如果函数没有出现在导出表、导入表、符号表内,就不能通过以上的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就是基址
还可以用上文API获取所有模块,去遍历获取地址
1 var moduel = Process .enumerateModules ();
还可以已知基址来寻找模块,找到模块即可去调用下述方法
1 2 Process .findModuleByAddress (address)Process .getModuleByAddress (address)
函数地址计算 找偏移地址的时候注意,去表中找对于函数跳转,不要去某些函数内,找这个函数调用的地址,要去hook定义的部分的地址 也就是 1FA38 这个地址
地址:so基址+函数在so中的偏移[+1]
地址+1 是分情况的,如果是 thumb 指令需要 +1 ,如果是 arm 指令则不需要 +1。
在安卓中32位的so中的函数,基本都是 thumb 指令。64位的so中的函数,基本都是 arm 指令。
也可以通过显示汇编指令对应的 opcode bytes 来进行判断指令类型
选项 -> 常规 -> 操作码字节数(非图表) 改成 4
这个时候出现了四个字节的汇编指令,说明这个函数使用的 arm 指令
这四个字节就是 opcode bytes,就是这一条汇编对应指令的机器码
如果是两个字节,或者有两个有四个的,一般是thumb,出现四个字节一般是两个字节不够用了,将两条指令拼接在一起了,32位的只会越来越少。整不明白,可以加1和不加1都试一下呗。
1 2 var soAddr = Module .findBaseAddress ('libencryptlib.so' )var funcAddr = soAddr.add (0x1FA38 )
打印出来是两个地址,但是不想直接写一个地址然后 .add 因为 Module.findBaseAddress 获取的是一个指针,直接写一个地址会报错,没有add这个方法,需要加上 ptr()
1 console .log (ptr (0x7c9975d00 ).add (0x1FA38 ))
通过计算函数在内存中的地址,函数hook只需要一个地址,那么任意函数都可以进行hook了
封装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 ) { 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的堆栈进去看看
传入了一个字符串,先去 libtre.so 中找sign方法吧,这里hook a函数的结果,不如留着去so层hook传入值
从导出表中找到这个方法,有三个参数
看到五个初始化常量,和SHA1的一模一样
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×tamp=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类型的字符串
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一个结构体,里面有五个初始化常量,还有两个参数表示明文位数
来看 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,得到和终端一样的结果
那么就需要再去往上找,看这个base64数据怎么来的了,v12在这里经过了处理,hook这个 base64函数
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×tamp=1742391074&tzrd=BwzXzSGFyiPstMIVuzTZb7LzTZzbXRJOFzpbQiIaT7t/KDL9RCbvH/vr0qdSE1IM0JgX7EeFKMidFPp7uNNpzeTMNlGeAywlVhFRI1/rf3ha27vGJ7wi59YFsHm+TwWHdXfcQQznBsXajsevBoLGbvW4A2gQEj4MRhFVMJr8wZ396k/+TeAq00WbUJSnVUWbdlbNvZFYNFjVsOXdhMLbeY80y5zjpowJYYTjK8sMdDF5IyeqG+ikl7gGjFxhrWhvldlar6qKQTjBto/ZWICrdbMrqT7D0XAPTMGBGbL1Xfcb/NLIF3vd270249aTbquqP8SwrpnlYaLH+/XiDIP533YIAN38HUmVg0ke9v6t72lHqQkZNnImJ+sNeu/VmRhkr7RGrOLtOLVKGIqQNjXoXPUXYXjYUOpJqloo782X/VFeeD+cVBAiODlszxgZQWHPPQQgoXS59acrA4uEFMS3vR72cjitpNJ4auKySpDS2GjGWaFoAAHIG6Hl/5dVQCSD hook出的传入值:app_ver=100&nonce=dzljud1742391074744×tamp=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
马蜂窝 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
找到了这个 oauth_signture hook这个函数直接
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" ](); 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 这里面,传入四个参数,之前添加的数组就是其中一个参数,红线表示方法执行方向,蓝线表示传入的数据。
这里就可以看到已经进入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 (); 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类型字符串返回出去
先hook一下这个base64方法
1 2 3 4 5 6 7 8 9 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编解码,结果和正常运行相同
这个base64方法就是一个简单的 hex to base64方法,继续向上,hook update方法,看到是直接传入的原文没问题。
1 2 3 4 5 6 7 8 9 10 11 12 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" ) } })
可以注意到的是,方法名是 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位
拿过来去 hamc-sha1 运算一下,标准结果
过简单root检测(海博TV)
拿这个字符串爆搜一下,找到了相关代码
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 ( ) { let SystemUtils = Java .use ("com.hoge.android.factory.util.system.SystemUtils" ); SystemUtils ["checkSuFile" ].implementation = function ( ) { console .log (`SystemUtils.checkSuFile is called` ); return false ; }; SystemUtils ["checkRootFile" ].implementation = function ( ) { console .log (`RootUtils.checkRootFile is called` ); return null ; }; })
以上纯属我静态玩习惯了,这个提示可以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 命令
搬运一个检测检测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堆栈进去看看
没找到,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() ) ); } 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); } })
使用了反射,获取com.hoge.android.jni.Utils
这个类,调用了下面的 verify 方法,下面是base64编码
分别调用了 verify 和 signature 两个方法,进so
传入sha1_encode方法的值是由固定值+版本号+时间戳拼接出来的,第二个参数表示第一个参数的长度,不管
试一下就可以得出密文,和主动调用方法的返回值相同
但是和sh1_code返回的不同,看看后面进行了什么操作
传进来的p是一个二级指针,还需要进行一个dump操作
二级指针 对于二级指针dump完毕之后再read一下,就可以得到最终结果
喜马拉雅(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摘要
顺着堆栈进来,这个比上个好找多了,进so
先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 ( ) { 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) }) } MIGfMA 0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQCVhaR3Or7suUlwHUl2Ly36uVmboZ3+HhovogDjLgRE9CbaUokS2 eqGaVFfbxAUxFThNDuXq/fBD+SdUgppmcZrIw4HMMP4AtE2 qJJQH/KxPWmbXH7Lv +9CisNtPYOlvWJ/GHRqf9 x3TBKjjeJ2CjuVxlPBDX63+Ecil2JR9 klVawIDAQAB
这个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 [函数名] // 仅追踪特定的函数
这个东西比较玄学,我碰到了三报错,忙活了一天也没解决
ollvm字符串解密 对于这种加密了的,看不见明文的可以根据地址值dump一下
1 2 var soAddr = Module .findBaseAddress ('liblogin_encrypt.so' )console .log (hexdump (soAddr.add ('0xD060' )))
可能会报的错误: TypeError: cannot read property 'add' of null
这个就是so未加载,以这个案例为例,登录一次之后就会加载,这个错误就不存在了
这个两个加密的字符串分别是 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
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文件需要修复一下
so修复 https://github.com/qweraqq/frida-dump-android-so
下载工具
写一个脚本,将上面从手机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} ` ); } }
修复之后的so,就显得很正常了,已经很诗人了,不像之前的加密的字符串,完全不诗人
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 需要的是一个指针即可,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; } 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" ; 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 ) { } });
function 2 修改地址(了解) 将so中已有的字符串地址传给函数
这种方法也是很有局限性,只能选择内存中已有的字符串
shift + F12 挑一个喜欢的字符串
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" ) { 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 ) { } });
function 3 修改结构体(了解) 修改结构体(只针对MD5算法)结构体中的buffer
结构体定义的时候定义了一个两字节的表示字符数量的,还有四个初始化常量,占4字节
(2+4)*8 = 48 那就是24个十六进制对,从第25位开始取就是明文,可以在 onLeave 中更改字符串,达到更改的目的。
其实这个主要还是了解一下结构体,掌握如果更改结构体
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" ) { var newStr = "skjhgjsdhgf';dlfsdlkhskd" 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来延长生命周期。
或者在函数外构建字符串,在hook时使用(定义生命周期更长的变量?)
hook_dlopen 之前的hook方法有一个bug就是需要so被加载之后才能进行hook操作,否则报错
==cannot read property add of null== ,这个时候就有问题,如果有的so只执行一次,那不寄了,这个时候就需要hook_dlopen了
dlopen,此物在NDK开发中亦有记载
hook_dlopen 的作用是视奸so何时被加载,加载之后立马hook。
原理就是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" )console .log (soAddr.add (0x2C10 ).readCString ())console .log (hexdump (soAddr.add (0x2C10 )))console .log (soAddr.add (0x2C10 ).readByteArray (16 ));console .log (Memory .readByteArray (soAddr.add (0x2C10 ), 16 ))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
用于函数返回
跟大佬了解了一下exe汇编的一些操作,STR的操作相当于 push,Inter汇编中有两个指针 ebp 和 esp ARM中只有一个 sp 类似于 esp。
ARM指令转换Hex https://armconverter.com
同样的ARM汇编也是可以更改的,如果我想要更改 ADD 为 SUB 只需要将hex的 00 01 09 0B
更改为 00 01 09 4B
frida修改so层代码 在Java层hook中是以函数为单位的,只能修改函数的参数返回值,进行主动调用等一些操作,但是不能直接对函数内的函数体进行修改,而so层的hook可以直接修改汇编指令,达到修改函数体的目的。非常强大。
上文也提到过一些,原理就是修改汇编代码。做练习frida labs0x0B 的时候也需要修改汇编达到目的
dump一下函数所在的地址可以看到hex的结果就是IDA左边的汇编指令,更改这个hex就可以达到更改汇编代码的效果
使用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' );Memory .writeByteArray (address, [0x00 , 0x01 , 0x09 , 0x4B ]);console .log (hexdump (address))console .log (Instruction .parse (address).toString ())
确实生效了
使用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 ())
使用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 ) { var writer = new Arm64Writer (code, { pc : address }); writer.putBytes (hexToBytes ("0001094B" )); writer.flush (); })
so主动调用 之前的hook使用 Interceport.attach
那种方法是函数被执行时才进行hook,和Java层中的 类名.方法名.implementation
一样
回忆一下Java层主动调用的方法,主要是获取(创建)对象,调用对象内的Java方法
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。
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' ])var env = Java .vm .tryGetEnv ();console .log ("env: " ,JSON .stringify (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,查看不了字符串内容
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) }
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
中
枚举符号表 查找字符表,然后根据名称筛选方法
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 的指针。
因此可以说是都是 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 这个结构体
读取一个指针长度,再进行dump,就可以看到与 jninativeinterface 相符的情况了,结构体前四个指针保留。这个时候才定位到 jninativeinterface 的原始结构体
想要hook对应的方法,还需要数偏移,比如 FindClass 方法,64位中一个指针8个字节,需要偏移 48 字节,偏移完 48 字节之后,得到 findclass方法的地址,将这个地址当作指针来读取,就可以看到方法体的具体内容,如果只是进行hook的话,上一步已经得到了方法地址。
还可以对一下arm汇编指令,四个字节一条arm汇编
hook findclass 方法
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 function hook_jni2 ( ) { var envAddr = Java .vm .tryGetEnv ().handle .readPointer () var funcAddr = envAddr.add (48 ).readPointer () Interceptor .attach (funcAddr, { onEnter : function (args ) { console .log ("FindClass args[1]: " , args[1 ].readCString ()); }, onLeave : function (retval ) {} }) }
这里第一次执行的时候多打印了一行,再执行就没有了
原因是因为加载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 ();var jstr = env.newStringUtf ('hello world' );var cstr = jstr2cstr (env, jstr);console .log (hexdump (cstr))
这里面就用到了 frida 封装的 jni 方法,即 newStringUtf
这种方法需要frdia获取的env才行,即 Java.vm.tryGetEnv()
很多常用的jni方法都已经被封装好了
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' ]) 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
Backtracer有两个选项
FUZZY 对于任何二进制文件都有效,但是可能出现地址的误报
ACCURATE 并不是对任意二进制文件有效,地址会更准确
将这一行拆开来看
1 2 console .log (soAddr) console .log (Thread .backtrace (this .context , Backtracer .FUZZY ));
看一下这个backtrace
要两个参数,返回值是一个数组,如果这样直接执行的话,打印出来的就是一个数组,毫无阅读性。
这个时候就需要 DebugSymbol.fromAddress
通过 DebugSymbol.fromAddress 方法来获取传入的地址附近的调试信息,返回值如下
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
再根据得到的地址,去获取所在的模块地址,打印so名称,函数偏移
hook RegisterNative 动态注册 RegisterNative 方法有四个参数,第一个是 jnienv ,第二个是函数所在的类名称,第三个是一个结构体,结构体内是三个指针,第四个是注册的方法个数
先获取类名以及方法数,然后根据方法数来分离结构体中的三个参数
兼容一下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 ) { 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) console .log (classname, methodName, methodSignature, methodAddr, module .name , methodAddr.sub (module .base )); } }, onLeave : function (retval ) { } }) }
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 ) } }) }
定位到指令所在地址,读取寄存器中的数字,同样的指针也可以读取
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 方法
在 loadlibrary0 方法中,处理了传入的字符串,方法是 loader.findLibrary ,接下来去找 classload 这个类,发现中间并没有进行什么操作,那么就可以看哪些类继承了 classload 复写了 findLibraay 方法
又调用了 system 中的 mapLibraryName 方法,这个方法是一个native方法
在这个C文件中,对函数进行了动态注册,NATIVE_METHOD 这个方法是将第一个和第二个参数拼接起来 使用的是 ## _ ## ,拼接成 system_mapLibraryName 。
然后再看这个方法, JNI_LIB_PREFIX 对应 ‘lib’ JNI_LIB_SUFFIX 对应 ‘.so’
这个方法将字符串拼接成了 lib+str+.so 的形式,就是需要加载的so的名称了,字数超过40的报错。然后将Cstring转成jstring返回
得到返回值之后,后面的操作是去寻找这个so的全路径,在往上走
在nativeLoad方法中进行加载,返回一个error判断是否加载成功
在去寻找 JVM_nativeload
先判断了是否为空,然后将 filename 转换成 cstring 调用 loadnativelibrary
先对第一个参数进行的处理 librarier 中存放的是已经加载的so,检测该so是否已经加载,如果已经加载 进入 library != nullptr 这个一步,未加载的没截全
如果未加载执行
在1005行中的 opennativelibrary 开始进行加载so的操作了
opennativelibrary 方法中还是调用了 Android_dlopen_ext 和 dlopen 方法
从 android_dlopen_ext 到 dlopen_ext 再到 do_dlopen 。到 do_dlopen 的时候已经和dlopen的方法差不多了,do_dlopen方法不必多看,只需要注意最后的地方
在 find_library 的时候完成了整个so的加载,加载完成之后,进行了一个判断,如果不为空
取出这个so的handle地址,还执行了一个 call_constructors 方法
在这个方法中调用了两个方法 DT_INIT 其实就是 init 方法 DT_INIT_ARRAY 是initarray 方法。这就是这两个方法被调用的时机。这个两个方法在 do_dlopen 内部被调用,因此hook do_dlopen 是不能看出变化的
再去寻找 jni_onload 的调用时机
循着上面 opennativelibrary 往下找,调用 findsymbol 去寻找 jni_onload 符号
通过 findsymbol 也可以看到,是走 dlsym 的,之前hook dlsym 的时候也的确有 jni_onload 的输出。
函数中是允许找不到符号的,因为也可能出现没定义 jni_onload 的情况,如果找到的话,就去 call jni_onload 也就是说 jni_onload 是在 LoadNativeLibrary 方法中,在dlopen方法完全执行完毕,so加载完毕之后通过 dlsym 进行调用的
调用方法,定义一个指针,将获取到的地址强转成指针,然后调用。然后按照规范返回 jni 的版本号。如果版本号不符号要求,报错。符合要求结束。这个时候也执行结束了
dlopen 执行完毕之后 通过 dlsym 方法来调用 jni_onload,如果要hook init 和 initarray 方法的话,就需要等待so加载完毕之后,init执行之前
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掉了一个线程,没有输出十个数字
hook_pthread_create(多线程) pthread_create是开启线程的方法,之前提到过了,如果软件要进行什么检测,肯定是开启子线程进行检测,不可能在主线程中进行。一般开启子线程是使用 pthread_create 的。
pthread_create在 libc.so 中
pthread_create有四个参数,第一个是线程ID,第二个是线程属性,第三个才是需要的函数名,第四个是函数参数
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,再根据打印出来的地址寻找方法
替换函数 之前有用到过 Interceptor.replace
进行替换多线程的函数方法
Interceptor.replace
接收两个参数,第一个是要进行操作的地址指针,第二个是方法指针,说白了就是传入了俩指针
之前传入第二个参数的时候使用的是 new NativeCallback
NativeCallback 继承 NativePointer 所以可以当作参数传入
func是函数的方法体
retType 是返回值类型
argTypes是参数列表
abi表示支持的平台,?表示可省略
进行替换的时候最好保证返回值类型相同,还需要注意参数类型和原方法的参数类型的一致性,否则包出错的,本来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,也没啥意思
主要用到的还是 length 查看显示不完全的信息
frida-trace 之前配置魔改过一个IDA插件traceNative,插件是将 .text 段的所有函数进行一个筛选之后输出到一个txt文件中,筛选代码行数超过10行的方法。
这个txt的hook就是使用 frida-trace 来完成的
自动生成的txt文件中全部是 -a 参数,-a 在frida-trace中表示hook后面的地址,就是相当于用frida来hook函数
执行一下会自动生成一些js文件,前面输出的 sub 偏移地址就是文件中的偏移地址
文件的内容也是比较简单的,就是简单的hook了当前地址,就是参数有点不一样,log就相当于 console.log ,args和正常hook 的含义相同
整个函数的hook和 Interceptor.attach
的作用是相同的,这个的执行就是为了查看函数的执行流程,因为他只在函数执行前打印函数的地址。
如图,会展示出一个清晰的函数调用顺序的流程图
生成的js文件是可更改的,目录下没有对应的js文件时,执行 frida-trace 会自动生成一个 Auto-generated
如果已经存在了那就是加载 Loaded
也就是说这个流程图是可以在后期进行一些修改,再输出一些方法参数,函数的符号信息等等。
但是后期生成的文件有点多了貌似,手动改很麻烦的啊,而且重复性高,不如写个脚本改,有能力的大佬可以修改 frida-trace 的源码,再重新打包
我比较废物,只能写了一个小脚本来手动修改,想了半天因为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 osdef 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() 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,程序正常运行。
这种方法就是针对一个地址,通过权限问题来监控内存的读写情况。这种方式感觉和断点很像啊🤔
以此类推,构造一个异常就可以进行这个操作,不一定是非法访问异常
还支持中止(流产是什么鬼,蜜汁翻译),非法指令什么的
details中包含错误信息 message
,哪个地址访问的 address
访问的方式,以及访问错误的地址 memory
,寄存器信息 context
这个东西的缺点还是很明显的,只能触发一次,只有首次内存访问的时候才会被监控,记录下来信息,报错之后会恢复权限,下次再执行就不会报错的。
而且根据提示访问无权限的信息,大概率不是给出的地址信息,只要和地址信息在同一个内存页的任何一行代码被访问到都会触发报错,并且代码触发后失效
挺鸡肋的,监控不够精确,了解即可
Frida监测
占坑式 开启一个子进程附加父进程
一个app只能被一个进程附加,在app加载的时候自己附加自己,这样frida就无法附加了
如果软件使用这种方法,进行 -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
我实操的时候报错是这样的,提示未找到进程
使用 -f 确实会注入成功,但是马上进程就会中止
这个时候就要稍微说一下两种方式的区别了,之前已经很熟悉的是 -f 会重新启动app -F 直接附加。
-f 注入的时候是先调用一下 ptrace 然后就释放了,而 -F 是一直 ptrace 附加在进程上,因此退出app frida会立即中断,而 -f 则不会
使用指令查看app进程情况
看到有两个进程,下面是上面的附加进程,第一个数字是 pid 第二个是 ppid ppid表示该进程的父进程
但是呢,软件又会有附加进程的情况,不单是占坑防止frida。比如:
守护进程:防止app崩溃,保证app服务不会kill
普通双进程:单线程资源访问有限,虽然手机内存大,但是一个线程可以使用的资源是有限的,因此有时候会使用双进程,多进程,进程之间相互通信
还有就是防止注入了,其实也防不住,可以使用frida解决掉的,注入成功了就有操作空间的
对应多进程的app,可以使用 pid 来进行注入,因为多线程可能是有同名进程的 -f 是注入不了的
还可能会有进程名检测和端口检测
进程名检测 进程名检测就属于是防君子不防小人了,操作就是遍历进程列表,找到疑似frida-server的,打死你hifrida-server的进程名就是文件的命名,避开frida-server的常规命名就检测不到了
我的命名就是frida-server,纯小白命名,可以根据自己的喜好微操一下
端口检测 frida-server的默认TCP端口是27042,检测27042端口是否开放,用处也不大,因为默认 27042 不一定就是 27042
D-Bus协议通信 frida使用D-Bus协议通信,可以遍历 /proc/xxxx/net/tcp 文件(xxx表示app的pid,软件运行的时候才可以进入)
然后向每个端口发送D-Bus认证信息,哪个端口回复了 REJECT ,就是frida-server的端口
或者说之间遍历所有端口,发送认证信息
但是这个也是可以hook了,将 strcmp hook掉,更改第二个参数
遍历maps 可以看到有被加载的so,之所以一个so可能会有多个,是因为so的权限不一样,但是地址是连续的 ,从797c2f9000 - 797c2fa000 - 797c2fb000
frida注入之后会出现 frida-agent.so,这个也是一个检测点
扫描task目录 task目录下有很多文件夹,表示app开启的线程
文件夹内的status文件中有一些信息
如果进程被附加了,这个 TracerPid 就不为 0
state 为 S 表示当前未被附加,也未被调试,被调试是 T
name为 gmain、gdbus、gum-js-loop、pool-frida 这些都是frida注入的特征
可以通过 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 的检测