-
Notifications
You must be signed in to change notification settings - Fork 0
/
content.json
1 lines (1 loc) · 472 KB
/
content.json
1
[{"title":"JS逆向案例——某音X-Bogus参数逆向分析之补环境","date":"2023-06-02T09:47:13.000Z","path":"JS逆向案例——某音X-Bogus参数逆向分析之补环境/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 逆向目标 目标:抖音网页端用户信息接口 X-Bogus 参数 接口:aHR0cHM6Ly93d3cuZG91eWluLmNvbS9hd2VtZS92MS93ZWIvdXNlci9wcm9maWxlL290aGVyLw== 参数: X-Bogus: DFSzswVYwR2ANJV5ttm-TDok/RBL msToken: XdvBn3ow8atTxF5IT6Nozn_D976Sh-fQQais1pUkC0U-…3iUKTT_yGo4Q1A9KBUXxYALyw== ttwid: 1%7CwRK6LHw2rKAyHM8EeD4WLyVctABf-…5f35595a6e1f587dcc9f09b4fe 虽然接口入参有很多,但是实际上必不可少且需要逆向的就只有X-Bogus,msToken和Cookie中的ttwid。本文主要介绍X-Bogus的逆向,关于msToken和ttwid,后续会发文。 关于JSVMPJSVMP参考文章JSVMP是什么? JS加密的研究背景和意义 JS混淆和JS压缩的前端代码攻防机制分析 代码虚拟化保护原理分析 深入了解JS 加密技术及JSVMP保护原理分析 给”某音”的js虚拟机写一个编译器 JSVMP代码特征$jsvmprt,_$webrt_1668687510之类的,第一个参数很长的一串字节码。 JSVMP逆向分析有哪些方法?就目前来讲,除去Playwright等自动化工具,JSVMP 的逆向方法有三种:RPC 远程调用,补环境,日志断点还原算法。其中日志断点也称为插桩,找到关键位置,输出关键参数的日志信息,从结果往上倒推生成逻辑,以达到算法还原的目的,之前介绍过,参考《JS逆向案例——利用插桩分析某音X-Bogus参数》。RPC的方式以后有时间再写,本文主要介绍如何使用补环境的方法来生成签名参数。 逆向过程签名参数定位先抓包,进入博主主页之后,刷新,看到相应的接口和入参: 打下XHR断点,然后刷新页面重新请求主页: 成功打上了断点,并且此时已经生成了msToken和X-Bogus参数。往前跟栈,来到一个叫 webmssdk.js 的JS文件,这里就是生成参数的主要JS逻辑了,也就是JSVMP,整体上做了一个混淆如图: 我们用v_jstools做一个简单的还原,还原之后用浏览器的override功能替换,之后重新加载,如图: 往上跟栈到符号函数$这里: 在往上跟栈: 可以看到很明显的JSVMP的特征,参数a是请求的params。 确认一下定位的加密方法有没有问题: 没有问题,至此我们就成功定位到加密函数。 补环境本文介绍手动补环境,关于自动补环境框架,半自动补环境框架后边发文介绍。 将整个webmssdk.es5.js的代码抠出来,然后定义全局变量__0x5a8f25,将_0x5a8f25方法导出,然后运行,如下: 提示window未定义,补上window定义: 1var window = global; 接着运行: 提示document未定义,补上document: 12window.document = {} 后边的过程就不一一贴了,整理下最终全部的补环境代码: 123456789101112131415161718192021222324252627282930313233var window = global;window.Request = function() {};window.Headers = function() {};window.document = { referrer: "", addEventListener: function() {}, createElement: function() { return {}; },}window.navigator = { userAgent: "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/113.0.0.0 Safari/537.36"};window.location = { "ancestorOrigins": {}, "href": "https://www.douyin.com/", "origin": "https://www.douyin.com", "protocol": "https:", "host": "www.douyin.com", "hostname": "www.douyin.com", "port": "", "pathname": "/", "search": "", "hash": ""};window.screen = { availHeight: 900, availLeft: 0, availTop: 0, availWidth: 1440, colorDepth: 30, height: 900} 运行与测试补好环境之后,运行,结果如下: 成功生成X-Bogus参数。 若需要完整代码或者讨论,扫描加微信。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"某音","slug":"某音","permalink":"http://example.com/tags/%E6%9F%90%E9%9F%B3/"},{"name":"JSVMP","slug":"JSVMP","permalink":"http://example.com/tags/JSVMP/"},{"name":"补环境","slug":"补环境","permalink":"http://example.com/tags/%E8%A1%A5%E7%8E%AF%E5%A2%83/"}]},{"title":"JS逆向案例——利用插桩分析某音X-Bogus参数","date":"2023-05-25T10:43:27.000Z","path":"JS逆向案例——利用插桩分析某音-X-Bogus参数/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 逆向目标 目标:抖音网页端用户信息接口 X-Bogus 参数 接口:aHR0cHM6Ly93d3cuZG91eWluLmNvbS9hd2VtZS92MS93ZWIvdXNlci9wcm9maWxlL290aGVyLw== 参数: X-Bogus: DFSzswVYwR2ANJV5ttm-TDok/RBL msToken: XdvBn3ow8atTxF5IT6Nozn_D976Sh-fQQais1pUkC0U-…3iUKTT_yGo4Q1A9KBUXxYALyw== ttwid: 1%7CwRK6LHw2rKAyHM8EeD4WLyVctABf-…5f35595a6e1f587dcc9f09b4fe 虽然接口入参有很多,但是实际上必不可少且需要逆向的就只有X-Bogus,msToken和Cookie中的ttwid。本文主要介绍X-Bogus的逆向,关于msToken和ttwid,后续会发文。 逆向过程基本分析首先抓包定位到我们需要的接口,如图: 这个请求是一个XHR请求,我们打上一个XHR断点,选择URL中包含X-Bogus,然后刷新页面重新请求主页,如图: 成功打上了断点,并且此时已经生成了msToken和X-Bogus参数。往前跟栈,来到一个叫 webmssdk.js 的 JS 文件,这里就是生成参数的主要 JS 逻辑了,也就是 JSVMP,整体上做了一个混淆如图: 我们用v_jstools做一个简单的还原,还原之后用浏览器的override功能替换,之后重新加载,如图: 往上跟栈到w,如图: 可以看到w是一个数组,此时X-Bogus已经生成。 console控制台看下其长度为28,X-Bogus长度是否固定为28?我们不妨在这个地方设置一个条件断点,让它的长度为28的时候断一下,如下图: 断上之后有一个黄色的?标志,如图: 刷新发现成功断上,此时console中继续查看X-bogus长度,发现果然是28位,如下图: 并且X-bogus每次都是不一样的。 继续往上跟栈,来到一个符号函数,如图: 这个符号函数就是JSVMP的初始化函数,这些参数就是字节码和函数机址等。 在最后一行打上断点,单步向下,发现这里就是生成X-Bogus的地方,如图: 可以看到w函数就是调用入口。 接着往上跟栈,如图: 可以看到这里的b是一个数组,包含了X-Bogus。 接着往上跟栈,如图: image-20230526151501816 前面一个三元表达式是字节系常见的环境检测,判断window对象的类型检测,检测是否在浏览器环境中运行,如果global只在node环境中才有,如果补环境的话需要注意这个检测。后边有一个webrt,在巨量或者头条里面,这里直接就写的jsvmprt了。jsvmp构建了一个运行js的vm,它有个最大的特点,就是一长串的字节码。 插桩分析前面说过,$符号函数就是JSVMP初始化的地方,而w方法就是调用入口,我们抠出w方法看下,如下: 可以看到基本上整个流程就是在2个大的if分支中走动,这个y控制走哪个分支,y是什么呢?看下调用w的地方: 可以看到y是一个布尔值,为0或者1。 单步调试的话会发现代码会一直走这个 if-else 的逻辑,几乎每一步都有O数组的参与,不断往里面增删改查值,for循环里面的j值,决定着后续if语句的走向,这里也就是插桩的关键所在,如下图所示: 接下来对if-else两个分支进行插桩,如下: 说一下这个日志格式,位置1和2用来区分if和else分支,索引j和索引A为关键索引,所以需要打印出来,o数组转化成JSON字符串,方便查看内容。 简单说一下JSON.stringify传入的那个函数有什么作用。stringify方法原型为:JSON.stringify(value[, replacer [, space]]),如果 replacer 为函数,则 JSON.stringify 将调用该函数,并传入每个成员的键和值,在函数中可以对成员进行处理,最后返回处理后的值,如果此函数返回 undefined,则排除该成员,举个例子: 1234567var a = {"k1": "v1", "k2": "v2"};var b = JSON.stringify(a, function(k, v) { if (v === "v2") return "changed"; return v;})console.log(b);// 输出{"k1": "v1", "k2": "changed"}; 接下来我们演示一下当 value 为 window 时,会发生什么: 可以看到这里由于循环引用导致异常,要知道在插桩的时候,如果插桩内容有报错,就会导致不能正常输出日志,这样就会缺失一部分日志,这种情况我们就可以加个函数处理一下,让 value 为 window 的时候,JSON 处理的时候函数返回 undefined,排除该成员,其他成员正常输出,如下图所示: 回到正题,插好桩之后,去掉其它无关的断点,然后刷新主页,console控制台就会一直打印日志,直到断点断住,如图: 保存日志并用编辑器打开,然后拉到最后一条日志,如下图: 可以看到X-Bogus已经生成。 分析第七组字符如何生成将X-Bogus每四个一组,分成七组,即:DFSz swVY zpUA NJV5 tSwJ Rfok /RsR。先来看看第七组字符也就是最后一组字符如何生成。 搜索X-Bogus第一次生成的位置,如下图: 可以看到最后一个R是在索引j为10,索引A为714的地方生成的。 接下来根据这两个索引打下条件断点,位置为2则断点应该断在else分支中。然后索引j为10,索引A为714在编辑器中查找了一下匹配到很多,所以仅靠j和A并不能唯一确定。我们拷贝下来数组O,看下其内容: 最后一个元素o[7]为21,我们把这个条件带上,然后再打上条件断点:j==10 && A==714 && O[7]==21,然后刷新就断住了: 单步往下走,断到这个地方: 依次看下m,w还有S: image-20230526193146347 可以看到S初始值为5,w是在O的第5个位置索引,m是在O的第4个位置索引, 所以w=O[5],m=O[4],P方法是charAt方法。 从之前的日志中复制出数组O,然后调试: 这个R刚好是生成的X-Bogus最后一个字符。 接下来看下24是怎么来的。一样的方法,找到前面一行日志: 重新打上条件断点j==47 && A==708 && O[7]==21,然后刷新,如下: image-20230526203407368 断上之后,单步跟踪,跟到这里: image-20230526202719857 看下O[S],g以及O[S]&g的值,如下: 回到日志,用3768920&63正好是24。 image-20230526203908745 再看下上一行的63是如何生成的。依据同样的方法,单步运行到如下: 可以看到取F数组704的位置,其中F是一个大数组,F[704]刚好是63。 整理下到目前为止生成X-Bogus最后一个字符的逻辑: 123456倒数第一个字符RF = [null,...9];str = "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe="F[704] ----> 633768920&63 ----> 24str.charAt(24) ----> "R" 先挖个坑在这里,3768920这个数字怎么生成的先放着,下边会填坑。 x-bogus倒数第二个字符是s,去掉最后一个字符之后看看第一次出现的位置,按之前的方式单步跟踪,如下图: image-20230526235442697 S为5,取出当前日志下的O数组,发现w为9,m依然为那固定的字符串Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe=,p方法也依然为charAt,得到的结果刚好是s。 接下来看下这个9,找到紧挨着的第一次出现9的上面一行,如图: image-20230527000130596 同样的断点然后单步跟踪到相应的地方: image-20230527001359345 可以看到O[S]是用通过右移运算得到,将日志中的576>>6刚好就是9。 再看6,方法一样,跟踪到如下地方: F依然是那个大数组,A是646,F[646]刚好为6。 用同样的方式,可以算出576是由3768920&4032得出。而4032则是由F[A]得出,此时的A为638。 整理下到目前为止生成X-Bogus最后2个字符的逻辑: 1234567891011121314倒数第二个字符sF[638] ----> 40323768920&4032 ----> 576F[646] ----> 6576 >> 6 ----> 9str.charAt(9) ----> "s"倒数第一个字符RF = [null,...9];str = "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe="F[704] ----> 633768920&63 ----> 24str.charAt(24) ----> "R" 用同样的方法,整理第七组剩下的两个字符R和/: 12345678910111213141516171819202122232425X-Bogus: DFSz swVY zpUA NJV5 tSwJ Rfok /RsRF = [null,...9];str = "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe="----------------------------------第7组 /RsR -------------------------------------F[498] ----> 165150723768920&16515072 ----> 36700123670016>>18 ----> 18str.charAt(14) ----> "/"F[568] ----> 2580483768920&258048 ----> 9830498304>>12 ----> 24str.charAt(24) ----> "R"F[638] ----> 40323768920&4032 ----> 576F[646] ----> 6576 >> 6 ----> 9str.charAt(9) ----> "s"F[704] ----> 633768920&63 ----> 24str.charAt(24) ----> "R" 分析第六组字符如何生成同样的方法,逆向最需要的是耐心,整个过程就不贴了,直接贴结果,如下: 123456789101112131415161718192021222324X-Bogus: DFSz swVY zpUA NJV5 tSwJ Rfok /RsRF = [null,...9];str = "Dkdpgh4ZKsQB80/Mfvw36XI1R25-WUAlEi7NLboqYTOPuzmFjJnryx9HVGcaStCe="----------------------------------第6组 Rfok -------------------------------------F[498] ----> 165150726359425&16515072 -----> 62914566291456>>18 ----> 24str.charAt(24) ----> "R"F[568] ----> 2580486359425&258048 ----> 6553665536>>12 ----> 12str.charAt(16) ----> "f"F[638] ----> 40326359425&4032 ----> 2432F[646] ----> 62432>>6 ----> 38str.charAt(38) ----> "o"F[704] ----> 636359425&63 ----> 1str.charAt(1) ----> "k" 每一个字符生成都有一个常数6359425,这个放在下边分析。 填之前的坑之前挖过2个坑,现在来填坑,看看3768920和6359425这2个数字是怎么来的。同样为了节省篇幅,直接上分析结果: 1234567891011str2 = "\\u0002ÿ-%.(´7^�\\u001e\\u001a÷ıa\\t�9�X"str2.charCodeAt(18) ----> 57F[320] ----> 1657<<16 ----> 3735552str2.charCodeAt(19) ----> 130F[386] ----> 8130<<8 ----> 332803735552|33280 ----> 3768832str2.charCodeAt(20) ----> 8888|3768832 ----> 3768920 1234567891011str2 = "\\u0002ÿ-%.(´7^�\\u001e\\u001a÷ıa\\t�9�X"str2.charCodeAt(15) ----> 97F[320] ----> 1697<<16 ----> 6356992str2.charCodeAt(16) ----> 9F[386] ----> 89<<8 ----> 23046356992|2304 ----> 6359296str2.charCodeAt(17) ----> 129129|6359296 ----> 6359425 填完坑这里再埋个坑,先不管这个乱码字符串怎么来的,就假设这个乱码字符串是一个永远不变的常量,我们后边再分析。到此第七组和第六组字符生成逻辑就全部分析完了。我们整理一下,然后对比一下这两组伪代码: image-20230527200739957 将流程对比一下就可以发现,每个步骤F里面的取值都是一样的,这个可以直接写死,不同之处就在于最开始的 charCodeAt() 操作,也就是返回乱码字符串指定位置字符的Unicode编码,第7组依次是18、19、20,第6组依次是15、16、17,以此类推,第1组刚好是0、1、2。 还可以看到,每一组的逻辑都是一样的,我们就可以写个通用方法,依次生成七组字符串,最后拼接成完整的 X-Bogus: 1234567891011121314151617181920function getXBogus(originalString){ // 生成乱码字符串 var garbledString = getGarbledString(originalString); var XBogus = ""; // 依次生成七组字符串 for (var i = 0; i <= 20; i += 3) { var charCodeAtNum0 = garbledString.charCodeAt(i); var charCodeAtNum1 = garbledString.charCodeAt(i + 1); var charCodeAtNum2 = garbledString.charCodeAt(i + 2); // 常数 var baseNum = charCodeAtNum2 | charCodeAtNum1 << 8 | charCodeAtNum0 << 16; // 依次生成四个字符 var str1 = short_str[(baseNum & 16515072) >> 18]; var str2 = short_str[(baseNum & 258048) >> 12]; var str3 = short_str[(baseNum & 4032) >> 6]; var str4 = short_str[baseNum & 63]; XBogus += str1 + str2 + str3 + str4; } return XBogus;} 分析乱码字符串如何生成用同样的方法,找到这串乱码字符串第一次出现的地方: image-20230527013146563 注意到这里位置变为1,断点需要断到if分支,数组O的长度也变了,变为22了,且最后一个元素为62,断点的时候要注意。此时条件断点为:j==16 && A==2038 && O[22]==62,单步跟踪如下: image-20230527014949639 看下w,m以及P分别代表什么: 可以看到这里通过调用自定义的方法生成乱码字符串,传入的参数分别为2,255和另一串乱码。抠出这个方法如下: 123456function _0x94582(a, b, c) { return _0x86cb82(a) + _0x86cb82(b) + c;}function _0x86cb82(a) { return String.fromCharCode(a);} 现在又产生了2,255以及新的乱码字符。先看看255如何生成:同理找到第一次255变为null的日志处: image-20230527121043553 断点后单步跟踪: 整理逻辑为: 1234567a ----> "484..."A ----> 2020l = function(a, b) { return parseInt("" + a[b] + a[b + 1], 16);}T = l(a, A);O[S][T] ----> 255 同样看下2是如何生成的,方法不变,整理下逻辑为: 1234a ----> "484..."A ----> 2012T = l(a, A)O[S][T] ----> 2 跟255的生成逻辑是一致的。 再看下乱码字符串-%.(´7^\\u001e\\u001a÷ıa\\t9X如何生成: 整理逻辑为: 12345678910111213141516function _0x25788b(a, b) { for (var c, e = [], d = 0, t = "", f = 0; f < 256; f++) { e[f] = f; } for (var r = 0; r < 256; r++) { d = (d + e[r] + a.charCodeAt(r % a.length)) % 256, c = e[r], e[r] = e[d], e[d] = c; } var n = 0; d = 0; for (var o = 0; o < b.length; o++) { d = (d + e[n = (n + 1) % 256]) % 256, c = e[n], e[n] = e[d], e[d] = c, t += String.fromCharCode(b.charCodeAt(o) ^ e[(e[n] + e[d]) % 256]); } return t;}_0x25788b('ÿ', "@\\u0000\\u0001\\fÄdE?'Qdpu\\u000eÔ\\u001dÑ>¨") ----> '-%.(´7^\\u001e\\u001a÷ıa\\t9X' 整理下’ÿ’和”@\\u0000\\u0001\\fÄdE?’Qdpu\\u000eÔ\\u001dÑ>¨”的生成逻辑: 123456789a ----> 255 固定值String.fromCharCode(a) ----> 'ÿ';function _0x398111(a, b, c, e, d, t, f, r, n, o, i, _, x, u, s, l, v, h, p) { var y = new Uint8Array(19); return y[0] = a, y[1] = i, y[2] = b, y[3] = _, y[4] = c, y[5] = x, y[6] = e, y[7] = u, y[8] = d, y[9] = s, y[10] = t, y[11] = l, y[12] = f, y[13] = v, y[14] = r, y[15] = h, y[16] = n, y[17] = p, y[18] = o, String.fromCharCode.apply(null, y);} ----> "@\\u0000\\u0001\\fÄdE?'Qdpu\\u000eÔ\\u001dÑ>¨" _0x398111方法,看下入参:[64,0.00390625,1,12,196,100,69,63,39,81,100,112,117,14,212,29,209,62,16],数组里面每一个value值可能是动态的,具体的每一个值接下来都会一一分析。 动态数组成员分析分析数组arr1 = [64,1,196,69,39,100,117,212,209,168,0.00390625,12,100,63,81,112,14,29,62]生成规则,结果如下: 12345678910111213141516171819202122232425262728293031323334353637383962: l(a, 1922) ----> T (59) O[S][T] ----> 62 (S=22)29: l(a, 1914) ----> T (57) O[S][T] ----> 29 (S=21)14: l(a, 1906) ----> T (55) O[S][T] ----> 14 (S=20)112: l(a, 1898) ----> T (53) O[S][T] ----> 112 (S=19)81: l(a, 1890) ----> T (51) O[S][T] ----> 81 (S=18)63: l(a, 1882) ----> T (49) O[S][T] ----> 63 (S=17)100: l(a, 1874) ----> T (47) O[S][T] ----> 100 (S=16)12: l(a, 1866) ----> T (45) O[S][T] ----> 12 (S=15)0.00390625: l(a, 1858) ----> T (43) O[S][T] ----> 0.00390625 (S=14)168: l(a, 1850) ----> T (60) O[S][T] ----> 168 (S=13)209: l(a, 1842) ----> T (58) O[S][T] ----> 209 (S=12)212: l(a, 1834) ----> T (56) O[S][T] ----> 212 (S=11)117: l(a, 1826) ----> T (54) O[S][T] ----> 117 (S=10)100: l(a, 1818) ----> T (52) O[S][T] ----> 100 (S=9)39: l(a, 1810) ----> T (50) O[S][T] ----> 39 (S=8)69: l(a, 1802) ----> T (48) O[S][T] ----> 69 (S=7)196: l(a, 1794) ----> T (46) O[S][T] ----> 196 (S=6)1: l(a, 1786) ----> T (44) O[S][T] ----> 1 (S=5)64: l(a, 1778) ----> T (42) O[S][T] ----> 64 (S=4) 不难看出这一段逻辑是将一个数组的奇数索引与偶数索引重新组成一个新的数组,arr1就是重组之后的数组。 将arr1重新排列,得到arr2为arr2=[64,0.00390625,1,12,196,100,69,63,39,81,100,112,117,14,212,29,209,62,168]。 arr2的索引值从T=42~T=60,刚好长度为19。 接下来就是分析这个arr2数组了。方法依旧是一模一样,先看最后一个也就是索引为18的元素。 12345678910111213141516171819168:O[S][T] ----> 64 (T=42, arr2[42]) 64O[S][T] ----> 0.00390625 (T=43, arr2[43]) 64^0.00390625 -----> 64O[S][T] ----> 1 (T=44, arr2[44]), 64^1 ----> 65O[S][T] ----> 12 (T=45, arr2[45]), 65^12 ----> 77 O[S][T] ----> 196 (T=46, arr2[46]), 77^196 ----> 137O[S][T] ----> 100 (T=47, arr2[47]), 137^100 ----> 237O[S][T] ----> 69 (T=48, arr2[48]), 237^69 ----> 168O[S][T] ----> 63 (T=49, arr2[49]), 168^63 ----> 151O[S][T] ----> 39 (T=50, arr2[50]), 151^39 ----> 176O[S][T] ----> 81 (T=51, arr2[51]), 176^81 ----> 225O[S][T] ----> 100 (T=52, arr2[52]), 225^100 ----> 133O[S][T] ----> 112 (T=53, arr2[53]), 133^112 ----> 245O[S][T] ----> 117 (T=54, arr2[54]), 245^117 ----> 128O[S][T] ----> 14 (T=55, arr2[55]), 128^14 ----> 142 O[S][T] ----> 212 (T=56, arr2[56]), 142^212 ----> 90 O[S][T] ----> 29 (T=57, arr2[57]), 90^29 ----> 71O[S][T] ----> 209 (T=58, arr2[58]), 71^209 ----> 150O[S][T] ----> 62 (T=59, arr2[59]), 150^62 ----> 168 看下逻辑,就是对arr2数组从前往后做位运算。代码为: 1arr2.reduce(function(a, b) { return a ^ b; }) 测试一下: 没有问题。 接着分析索引为14~17的元素的生成逻辑,伪代码如下: 12345678910113558723902>>24 ----> -44-44&255 ----> 2123558723902>>16 ----> -11235-11235&255 ----> 293558723902>>8 ----> -2875951-2875951&255 ----> 2093558723902>>0 ----> -736243394-736243394&255 ----> 62 这四个元素生成逻辑大同小异,都是将固定数字3558723902经过2次位运算得到。 而这个固定字符3558723902是调用_0x5bc542生成的。抠出代码并微做调整如下: 12345678910111213141516function _0x5bc542() { const canvas = createCanvas(48, 16); var e = canvas.getContext("2d"); e.font = "14px serif"; e.fillText("龘ฑภ경", 2, 12); e.shadowBlur = 2; e.showOffsetX = 1; e.showColor = "lime"; e.arc(8, 8, 8, 0, 2); e.stroke(); const b = canvas.toDataURL(); for (var d = 0; d < 32; d++) { a = 65599 * a + b.charCodeAt(a % b.length) >>> 0; } return a;} 用到了canvas库,需要安装依赖:npm i canvas 然后分析索引为10~13的元素的生成逻辑,伪代码如下: 12345678910111685091598.763>>24 ----> 100100&255 ----> 1001685091598.763>>16 ----> 2571225712&255 ----> 1121685091598.763>>8 ----> 65823896582389&255 ----> 1171685091598.763>>0 ----> 16850915981685091598&255 ----> 14 逻辑跟上边四个字符一样,只不过固定的字符串生成规则不一样,是由一个13位的时间戳/1000得到。 索引为6~9和0~3的元素为定值。 最后分析下索引为4~5的元素: 1234originalString为URL后边的请求字符串拼接_0x1f3b8d(md5(_0x1f3b8d(md5(originalString)))) ----> uint8Arrayuint8Array[14] ----> 196uint8Array[15] ----> 100 uint8Array方法为: 1234567_0x1f3b8d = function(a) { const _0x19ae48 = []; //内容自己补 for (var b = a.length >> 1, c = b << 1, e = new Uint8Array(b), d = 0, t = 0; t < c; ) { e[d++] = _0x19ae48[a.charCodeAt(t++)] << 4 | _0x19ae48[a.charCodeAt(t++)]; } return e;} 到此乱码字符串的生成逻辑就完成了,总结下来就是X-Bogus会对params,form-data,user-agent,时间,canvas进行校验。 运行与测试运行结果如下: 成功生成X-Bogus,通过生成的X-Bogus拿到博主主页数据。 若需要完整代码或者讨论,扫描加微信。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"插桩","slug":"插桩","permalink":"http://example.com/tags/%E6%8F%92%E6%A1%A9/"},{"name":"某音","slug":"某音","permalink":"http://example.com/tags/%E6%9F%90%E9%9F%B3/"},{"name":"JSVMP","slug":"JSVMP","permalink":"http://example.com/tags/JSVMP/"}]},{"title":"JS逆向案例——极验无感验证逆向分析","date":"2023-05-24T14:25:50.000Z","path":"JS逆向案例——极验无感验证逆向分析/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 逆向目标 目标:极验五子棋验证码逆向 主页:https://gt4.geetest.com/ 接口: https://gcaptcha4.geetest.com/load https://gcaptcha4.geetest.com/verify 逆向参数: Get Param captcha_id: 54088bb07d2df3c46b79f80300b0abbe challenge: e924d75f-7817-4ecc-9387-57eeefd060ce lot_number: 56076d56745d43489287d7465d4d0101 payload: 太长,略 process_token:太长,略 w:太长,略 逆向过程无感验证属于极验验证码中最简单的了,w参数生成过程基本跟滑块,消消乐,五子棋,文字点选一致。如果看了前面的滑块,消消乐,文字点选等逆向过程,这个无感基本就是小儿科,并且之前生成w的代码只需稍加修改就可以直接拿来用。 看下e结构: 12345678910111213141516171819{ "device_id":"9f5faf6dc7a77e1d394c8634f0893812", "lot_number":"89b5360c20ea4820b7c098bea3f291bf", "pow_msg":"1|0|md5|2023-05-24T22:14:37.410573+08:00|54088bb07d2df3c46b79f80300b0abbe|89b5360c20ea4820b7c098bea3f291bf||f62dd1e38c706b4d", "pow_sign":"6b5ebf3f4fd0e1223a5f93d80196715a", "geetest":"captcha", "lang":"zh", "ep":"123", "biht":"1426265548", "em":{ "ph":0, "cp":0, "ek":"11", "wd":1, "nt":0, "si":0, "sc":0 }} 与前面不同的是,没了userresponse和pastime,这点不难理解,既然是无感验证,自然不需要人工操作,而之前userresponse保存的要么是滑块的轨迹,要么是坐标,要么是位置比例。在无感验证里userresponse自然没有意义,没有人工操作的话,passtime也不需要。 直接贴一下全部的w生成的代码吧。如下: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596const crypto = require('crypto');const md5 = crypto.createHash('md5');const CryptoJS = require('crypto-js');const deviceId = "9f5faf6dc7a77e1d394c8634f0893812";function s(lot_number, guid) { const chapterId = "54088bb07d2df3c46b79f80300b0abbe"; const hashFunc = "md5"; const version = 1; const bits = 0; let _ = version + "|" + bits + "|" + hashFunc + "|" + new Date().toISOString() + "|" + chapterId + "|" + lot_number + "|" + "" + "|"; let l = _ + guid; return { "pow_msg": l, "pow_sign": md5.update(l).digest('hex') }}function uuid () { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { var r = Math.random() * 16 | 0; var v = c === 'x' ? r : r & 0x3 | 0x8; return v.toString(16); });};function encrypt(word, key, iv) { let src = CryptoJS.enc.Utf8.parse(word); let encrypted = CryptoJS.AES.encrypt(src, CryptoJS.enc.Utf8.parse(key), { iv: CryptoJS.enc.Utf8.parse(iv), mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return encrypted.ciphertext;}function FvBQ(t) { var e = 5381; var n = t.length; var o = 0; while (n--) { e = (e << 5) + e + t.charCodeAt(o++); } e &= ~(1 << 31); return e;}function GRmF(t) { t['e0vm'] = FvBQ(GRmF.toString() + FvBQ(FvBQ.toString())) + ''; return FvBQ(FvBQ.toString());}function get_e(lotNumber, guid) { let e = {}; e["device_id"] = deviceId; e["lot_number"] = lotNumber; const pow = s(lotNumber, guid); e["pow_msg"] = pow['pow_msg']; e["pow_sign"] = pow['pow_sign']; e["geetest"] = "captcha"; e["lang"] = "zh"; e["ep"] = "123"; e["e0vm"] = GRmF({ "geetest": "captcha", "lang": "zh", "ep": "123" }); e["em"] = { "ph": 0, "cp": 0, "ek": "11", "wd": 1, "nt": 0, "si": 0, "sc": 0 } return e;}function get_w(lotNumber, guid) { let e = get_e(lotNumber, guid); let c = encrypt(JSON.stringify(e), guid, "0000000000000000"); let o = []; for(let a = 0, i = c.sigBytes; a < i; a++) { var u = c.words[a >>> 2] >>> 24 - a % 4 * 8 & 255; o.push(u); } return arrayToHex(o)//, JSON.stringify(e);}function arrayToHex(e) { for (var t = [], n = 0, s = 0; s < 2 * e["length"]; s += 2) t[s >>> 3] |= parseInt(e[n], 10) << 24 - s % 8 * 4, n++; for (var r = [], i = 0; i < e["length"]; i++) { var o = t[i >>> 2] >>> 24 - i % 4 * 8 & 255; r["push"]((o >>> 4)["toString"](16)), r["push"]((15 & o)["toString"](16)); } return r["join"]("");} 运行与测试 运行结果如上,无感验证通过率一定是100%。 若需要完整代码,扫描加微信。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"验证码","slug":"验证码","permalink":"http://example.com/tags/%E9%AA%8C%E8%AF%81%E7%A0%81/"},{"name":"AES","slug":"AES","permalink":"http://example.com/tags/AES/"},{"name":"RSA","slug":"RSA","permalink":"http://example.com/tags/RSA/"},{"name":"MD5","slug":"MD5","permalink":"http://example.com/tags/MD5/"},{"name":"极验","slug":"极验","permalink":"http://example.com/tags/%E6%9E%81%E9%AA%8C/"}]},{"title":"JS逆向案例——极验文字点选验证码逆向分析","date":"2023-05-23T16:05:07.000Z","path":"JS逆向案例——极验文字点选验证码逆向分析/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 逆向目标 目标:极验五子棋验证码逆向 主页:https://gt4.geetest.com/ 接口: https://gcaptcha4.geetest.com/load https://gcaptcha4.geetest.com/verify 逆向参数: Get Param captcha_id: 54088bb07d2df3c46b79f80300b0abbe challenge: e924d75f-7817-4ecc-9387-57eeefd060ce lot_number: 56076d56745d43489287d7465d4d0101 payload: 太长,略 process_token:太长,略 w:太长,略 逆向过程逆向过程基本上与消消乐与五子棋一致。说说不同的几点。 关于验证码加载接口 入参只有risk_type不一样,其余都一致。文字点选的risk_type为word,消消乐的为match,五子棋的是winlinze。接口返回的数据也基本一致,不同的是imgs对应的是点选验证码的底图,即要点击的图片,而ques这是要点击的文字的图片。如下图: 可以看到,ques中图片的顺序与要点击的文字的顺序一一对应。 关于验证码验证接口 入参完全一致,生成w参数的e对象结构也一致,如下图: 123456789101112131415161718192021{ "passtime":2617, "userresponse":[[993, 1025], [1793, 5472], [3793, 1874]], "device_id":"9f5faf6dc7a77e1d394c8634f0893812", "lot_number":"dac26749dcc54f758675ffa7280d52b3", "pow_msg":"1|0|md5|2023-05-24T00:19:56.722398+08:00|54088bb07d2df3c46b79f80300b0abbe|dac26749dcc54f758675ffa7280d52b3||f1c8f88b905d4249", "pow_sign":"57c50f085e4c13fe6d23c870b96d5b1b", "geetest":"captcha", "lang":"zh", "ep":"123", "mrc5":"103342051", "em":{ "ph":0, "cp":0, "ek":"11", "wd":1, "nt":0, "si":0, "sc":0 }} userresponse正是点击背景图上三个文字产生的坐标,但是这个坐标数值比较大,而实际的背景图大小是宽为300px,高为200px的,所以这个坐标肯定是经过处理的。 扒一扒userresponse的生成过程: 添加如下断点: 一路跟踪到这里: 其中,t[left],t[top],t[width],t[height]都是固定值,分别为:103,320,300, 200。简单分析一下知,r和i分别是点击的位置坐标在整个背景图上的横纵坐标所占百分比,也即是某个汉字在整个背景图上横纵坐标的占比。最后,把这个百分比扩大100倍取整即可。 分析出来了userresponse坐标的生成过程,接下来就是文字点选最关键的目标检测与识别。 文字位置的检测与识别 首先是底图文字位置的检测,使用ddddocr库,代码如下: 123456789101112131415161718import ddddocrimport cv2det = ddddocr.DdddOcr(det=True)with open("4138cab002ce453696bd84d92cc5322f.jpg", 'rb') as f: image = f.read()poses = det.detection(image)print(poses)im = cv2.imread("4138cab002ce453696bd84d92cc5322f.jpg")for box in poses: x1, y1, x2, y2 = box im = cv2.rectangle(im, (x1, y1), (x2, y2), color=(0, 0, 255), thickness=2)cv2.imwrite("result.jpg", im) 输出三个坐标,同时标记了验证码图片上文字的位置。 举例几张图片如下: image-20230524101252194 并输出坐标为:[[78, 18, 131, 71], [122, 102, 174, 152], [175, 89, 227, 139]]。 接下来就是文字识别,根据每个文字的四个顶点坐标把文字依次裁剪下来,代码如下: 1234import cv2img = cv2.imread("4138cab002ce453696bd84d92cc5322f.jpg")cropped = img[18:71, 78:131] # 裁剪坐标为[y0:y1, x0:x1] 得到的图片依次为: result1](https://raw.githubusercontent.com/lyy077/blg-pic/main/pic/202305241745081.jpg)![result2](https://raw.githubusercontent.com/lyy077/blg-pic/main/pic/202305241746166.jpg)![result3 然后再用ddddocr依次对其做文字识别: 123456789import ddddocrocr = ddddocr.DdddOcr()with open("1.png", 'rb') as f: image = f.read()res = ocr.classification(image)print(res) 识别结果如下: 识别还是挺准确的。 除了底图的文字位置检测与识别,还有上边的标题需要识别,如下图: 这些文字都是比较规整,并且同一个汉子的图片的md5的文件名基本上是不变的,大约只有400多个汉字,所以提前跑一个脚本,把它们收集起来即可。如下图: 承载汉字的md5码名称的图片文件与汉字一一对应,并保存到data.pickle文件中。 运行与测试运行与测试结果如下: 识别2个或者3个汉字,才能验证成功。通过测试可知,汉字的识别率还是挺低的,还是需要自己收集数据训练。后边会专门出一篇文章介绍自己训练这一块。 若需要完整代码,扫描加微信。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"验证码","slug":"验证码","permalink":"http://example.com/tags/%E9%AA%8C%E8%AF%81%E7%A0%81/"},{"name":"AES","slug":"AES","permalink":"http://example.com/tags/AES/"},{"name":"RSA","slug":"RSA","permalink":"http://example.com/tags/RSA/"},{"name":"MD5","slug":"MD5","permalink":"http://example.com/tags/MD5/"},{"name":"极验","slug":"极验","permalink":"http://example.com/tags/%E6%9E%81%E9%AA%8C/"}]},{"title":"JS逆向案例——极验五子棋验证码逆向分析","date":"2023-05-23T02:04:47.000Z","path":"JS逆向案例——极验五子棋验证码逆向分析/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 逆向目标 目标:极验五子棋验证码逆向 主页:https://gt4.geetest.com/ 接口: https://gcaptcha4.geetest.com/load https://gcaptcha4.geetest.com/verify 逆向参数: Get Param captcha_id: 54088bb07d2df3c46b79f80300b0abbe challenge: e924d75f-7817-4ecc-9387-57eeefd060ce lot_number: 56076d56745d43489287d7465d4d0101 payload: 太长,略 process_token:太长,略 w:太长,略 逆向过程抓包分析有2个接口,一个是获取验证码的接口https://gcaptcha4.geetest.com/load,一个是进行验证的接口https://gcaptcha4.geetest.com/verify。 获取验证码接口入参参数如下: 12345678{ "captcha_id": "54088bb07d2df3c46b79f80300b0abbe", "challenge": "10cea755-08d2-4c7f-900f-d30d81301aa5", "client_type": "web", "risk_type": "winlinze", "lang": "zh", "callback": "geetest_1684505012579"} 入参除了risk_type与消消乐不同外,其它都一样,消消乐的risk_type为match,这里为winlinze。消消乐验证码逆向分析见JS逆向案例——极验消消乐验证码逆向分析 接口返回值123456{ "lot_number": "5a1ad5b0399c4a55b416f81d94e112e5", "payload": "11-UPJ-Jb2g3IpmYoaJlOw5fEieqchiSh9mIS5Ifj...", "process_token": "50e908f0ea51c1a16ac0f8a1af6270903d45a4c68572760378125e308c5d8727", "ques": [[0, 0, 3, 4, 3], [1, 2, 4, 4, 4], [1, 2, 4, 3, 4], [1, 2, 4, 2, 0], [0, 2, 4, 0, 3]]} 接口返回与五子棋基本一致,只是ques数组第二维里面的元素个数不一样,代表的含义也不一样。 如上图,0代表空格,1~4代表4种棋子。 验证码验证接口入参参数如下: 1234567891011{ "lot_number": "224a2186c59f470cb73897f377843df5", "payload": "11-UPJ-Jb2g3IpmYoaJlOw5fEieqchiSh9mIS5Ifj...", "process_token": "d4a636b32cee705e5314b90bce43f71ead1f2b6a0b5cb44c50b71bf28f6f9423", "w": "73bfc2bde060aac064f99128586dc53c9ee05cc25840aa1dc77445727e2e86c342b8cde...", "callback": "geetest_1684505905348", "client_type": "web", "risk_type": "winlinze", "payload_protocol": 1, "pt": 1} 其中入参与消消乐的都一模一样,同样只是risk_type不是match而是winlinze。 另外所有加密方法与对象都与消消乐一致,就连对象e都一模一样,对比消消乐e结构和五子棋的e结构: 五子棋的: 12345678910111213{ "passtime":450, "userresponse": [[1,3], [0,3]], "device_id":"9f5faf6dc7a77e1d394c8634f0893812", "lot_number":"5553a2835b7046d2abd595e82fc62703", "pow_msg":"1|0|md5|2023-05-23T10:41:08.568519+08:00|54088bb07d2df3c46b79f80300b0abbe|5553a2835b7046d2abd595e82fc62703||a19a8f9a8bdc0db7", "pow_sign":"ab3742f987977542c73a36692fdd5a08", "geetest":"captcha", "lang":"zh", "ep":"123", "e0vm":"2135175515", "em":{"ph":0,"cp":0,"ek":"11","wd":1,"nt":0,"si":0,"sc":0}} 消消乐的: 12345678910111213{ "passtime": 550, "userresponse": [[1, 0], [2, 0]], "device_id": "9f5faf6dc7a77e1d394c8634f0893812", "lot_number": "224a2186c59f470cb73897f377843df5", "pow_msg": "1|0|md5|2023-05-22T11:10:02.686802+08:00|54088bb07d2df3c46b79f80300b0abbe|224a2186c59f470cb73897f377843df5||62b052493785e2b7", "pow_sign": "506fe742ff81d4bb3bf34892714fa2fc", "geetest": "captcha", "lang": "zh", "ep": "123", "e0vm": "915661778", "em": {"ph": 0, "cp": 0, "ek": "11", "wd": 1, "nt": 0, "si": 0, "sc": 0}} 两者结构虽然一致,但是userresponse的逻辑含义有区别,五子棋是将一个棋子移动到空格位置的坐标信息,而消消乐则是要交换的两个图案信息。 五子棋算法由于是5x5的五子棋棋盘,并且只需要交换一对棋子就可完成五子连珠,所以直接采取暴力穷举即可。算法代码如下: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126function winlinze_by_row(ques) { // 统计每一个数字出现的次数 let count = function (arr, target) { let c = 0; for (let j = 0; j < arr.length; j++) { if (arr[j] === target) c++; } return c; } // 查找除指定行之外相同元素的位置 let checkRow = function (arr, besides, target) { for (let i = 0; i < arr.length; i++) { if (i === besides) continue; for (let j = 0; j < arr[i].length; j++) { if (arr[i][j] === target) return [i, j] } } } // 查找指定行的空格位置 let checkEmpty = function (arr, row) { for (let i = 0; i < arr[row].length; i++) { if (arr[row][i] === 0) return [row, i] } } // 看每一行是否存在四颗一样的棋子 for (let i = 0; i < ques.length; i ++){ let c = []; let emptySite = 0; // 统计1-4号棋子以及空格的个数 for (let m = 0; m < ques[i].length; m++) c[m] = count(ques[i], m); for (let m = 0; m < ques[i].length; m++) { // 该行上有四颗颜色一样的棋子,并且有空格 if (c[m] === 4 && c[0] === 1) { // 查找指定 let t = checkRow(ques, i, m); if (t.length !== 0) return [t, checkEmpty(ques, i)] } } }}function winlinze_by_diagonal(ques) { let leftSites = [[0, 0], [1, 1], [2, 2], [3, 3], [4, 4]]; let rightSites = [[0, 4], [1, 3], [2, 2], [3, 1], [4, 0]]; let checkEmpty = function (ques, sites) { for (let i = 0; i < sites.length; i++) { if (ques[sites[i][0]][sites[i][1]] === 0) return [i, sites[i]] } } let count = function (ques, sites, target) { let c = 0; for (let j = 0; j < sites.length; j++) { if (ques[sites[j][0]][sites[j][1]] === target) c++; } return c; } let indexOf = function (sites, i, j) { for (let k = 0; k < sites.length; k++) { if (sites[k][0] === i && sites[k][1] === j) return true; } return false } let search = function (ques, sites, target) { for (let i = 0; i < ques.length; i++) { for (let j = 0; j < ques[i].length; j++) { if (ques[i][j] === target && !indexOf(sites,i, j)) return [i, j] } } } let sites = [leftSites, rightSites]; for (let i = 0; i < sites.length; i++) { let e = checkEmpty(ques, sites[i]); let s = new Set(); for (let j = 0; j < sites[i].length; j++) s.add(ques[sites[i][j][0]][sites[i][j][1]]); if (s.size === 2 && count(ques, sites[i], ques[e[1][0]][e[1][1]]) === 1) { let rand = (e[0]+1) % 5; let w = search(ques, sites[i], ques[sites[i][rand][0]][sites[i][rand][1]]); if (w && w.length > 0) return [e[1], w] } }}function get_userresponse(ques) { // 根据行去判断是否可以五子连线 let arr = winlinze_by_row(ques); if (arr && arr.length > 0) return arr; // 如果按行不行,则按列 if (arr === undefined || arr.length === 0) { // 行列互换 let new_ques = []; for (let index = 0; index < ques.length; index++) new_ques[index] = [ques[0][index], ques[1][index], ques[2][index], ques[3][index], ques[4][index]]; arr = winlinze_by_row(new_ques); // 得到的结果再将列转化为行 let new_arr = []; for (let index = 0; arr && index < arr.length; index++) new_arr[index] = [arr[index][1], arr[index][0]] if (new_arr.length > 0) return new_arr; // 按列也不行,查找对角线是否可以五子连线 return winlinze_by_diagonal(ques) }} 运行与测试运行结果如下: 同样的,只要五子棋算法没问题,五子棋验证的通过率就是百分百。 若需要完整代码,扫描加微信。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"验证码","slug":"验证码","permalink":"http://example.com/tags/%E9%AA%8C%E8%AF%81%E7%A0%81/"},{"name":"AES","slug":"AES","permalink":"http://example.com/tags/AES/"},{"name":"RSA","slug":"RSA","permalink":"http://example.com/tags/RSA/"},{"name":"MD5","slug":"MD5","permalink":"http://example.com/tags/MD5/"},{"name":"极验","slug":"极验","permalink":"http://example.com/tags/%E6%9E%81%E9%AA%8C/"}]},{"title":"JS逆向案例——极验消消乐验证码逆向分析","date":"2023-05-18T08:04:33.000Z","path":"JS逆向案例——极验消消乐验证码逆向分析/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 逆向目标 目标:极验消消乐验证码逆向 主页:https://gt4.geetest.com/ 接口: https://gcaptcha4.geetest.com/load https://gcaptcha4.geetest.com/verify 逆向参数: Get Param captcha_id: 54088bb07d2df3c46b79f80300b0abbe challenge: e924d75f-7817-4ecc-9387-57eeefd060ce lot_number: 56076d56745d43489287d7465d4d0101 payload: 太长,略 process_token:太长,略 w:太长,略 逆向过程抓包分析有2个接口,一个是获取验证码的接口https://gcaptcha4.geetest.com/load,一个是进行验证的接口https://gcaptcha4.geetest.com/verify。 获取验证码接口入参参数如下: 12345678{ "captcha_id": "54088bb07d2df3c46b79f80300b0abbe", "challenge": "10cea755-08d2-4c7f-900f-d30d81301aa5", "client_type": "web", "risk_type": "match", "lang": "zh", "callback": "geetest_1684505012579"} client_type表示客户端类型为web,risk_type表示验证码类型为消消乐,lang表示语言为中文,callback为固定字符串geetest和时间戳做了一个拼接。 再看另外两个参数,captcha_id和challenge,通过浏览器内存漫游,轻松定位到challenge生成的位置: challenge由uuid这个方法生成,抠出相关代码如下: 1234567var uuid = function () { return 'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, function (c) { var r = Math.random() * 16 | 0; var v = c === 'x' ? r : r & 0x3 | 0x8; return v.toString(16); });}; 而另一个参数captcha_id则是固定值,为54088bb07d2df3c46b79f80300b0abbe。 接口返回值123456{ "lot_number": "224a2186c59f470cb73897f377843df5", "payload": "11-UPJ-Jb2g3IpmYoaJlOw5fEieqchiSh9mIS5Ifj...", "process_token": "d4a636b32cee705e5314b90bce43f71ead1f2b6a0b5cb44c50b71bf28f6f9423", "ques": [[0, 1, 3], [3, 3, 0], [3, 2, 2]]} 其中lot_number,payload,process_token在验证接口都有用到,而ques则是消消乐对应的图案,不同的数值对应不同的图案。 验证码验证接口入参参数如下: 1234567891011{ "lot_number": "224a2186c59f470cb73897f377843df5", "payload": "11-UPJ-Jb2g3IpmYoaJlOw5fEieqchiSh9mIS5Ifj...", "process_token": "d4a636b32cee705e5314b90bce43f71ead1f2b6a0b5cb44c50b71bf28f6f9423", "w": "73bfc2bde060aac064f99128586dc53c9ee05cc25840aa1dc77445727e2e86c342b8cde...", "callback": "geetest_1684505905348", "client_type": "web", "risk_type": "match", "payload_protocol": 1, "pt": 1} 其中lot_number,payload和process_token三个参数由获取验证码接口返回,只有w参数需要逆向。 w参数逆向将gcaptcha4.js文件反混淆之后全局搜索"w":,定位到w参数生成位置,并且打上断点,如下图: w=d.default(JSON.stringify(e), s)。 对象e对象e的结构如下: 123456789101112131415161718192021{ "passtime": 550, "userresponse": [[1, 0], [2, 0]], "device_id": "9f5faf6dc7a77e1d394c8634f0893812", "lot_number": "224a2186c59f470cb73897f377843df5", "pow_msg": "1|0|md5|2023-05-22T11:10:02.686802+08:00|54088bb07d2df3c46b79f80300b0abbe|224a2186c59f470cb73897f377843df5||62b052493785e2b7", "pow_sign": "506fe742ff81d4bb3bf34892714fa2fc", "geetest": "captcha", "lang": "zh", "ep": "123", "e0vm": "915661778", "em": { "ph": 0, "cp": 0, "ek": "11", "wd": 1, "nt": 0, "si": 0, "sc": 0 }} 需要解析出passtime,userresponse,device_id,pow_msg,pow_sign。 device_id 搜索deviceId,找到如下代码片段: 可以看到device_id是由一个base64编码的图片经过md5加密形成,而这个图片是一个固定的图片,所以device_id也是一个固定的值。 pow_sign和pow_msg 搜索powSign,找到如下代码片段: 跟踪进v.default这个方法,可以看到: pow_msg是由一些固定的值和captcha_id,lot_number,16位的随机字符串以及当前时间做的一个字符拼接,而pow_sign则是对pow_msg做了一个md5加密。 整理代码如下: 123456789101112131415161718192021const crypto = require('crypto');const md5 = crypto.createHash('md5');const guid = e() + e() + e() + e();function e() { return (65536 * (1 + Math["random"]()) | 0)["toString"](16)["substring"](1);}function s(lot_number) { const chapterId = "54088bb07d2df3c46b79f80300b0abbe"; const hashFunc = "md5"; const version = 1; const bits = 0; let _ = version + "|" + bits + "|" + hashFunc + "|" + new Date().toISOString() + "|" + chapterId + "|" + lot_number + "|" + "" + "|"; let l = _ + guid; return { "pow_msg": l, "pow_sign": md5.update(l).digest('hex') }} passtime passtime表示验证码验证花费的时间,整个过程包括拖动第一个图案到与第二个图案交换完成消消乐的时间。这里没有特殊的检测,所以直接随机一个1s内的时间即可。 1passtime = Math.round(Math.random() * 1000) userresponse 经过分析,userresponse数组就是消消乐需要进行交换的图案的坐标。前面说过load接口返回的ques数组是消消乐的每个图案,,比如说ques = [[0, 2, 2], [1, 0, 0], [3, 1, 2]]。 如下图: 进行验证码验证时,userresponse正好是2个图案的坐标: 可以看到(0, 0)和(1, 0)正好是上边的金字塔和昆虫的坐标,而交换第一行的金字塔和昆虫刚好第三行消除,完成消消乐。 用穷举的方法,写了一个消消乐查找算法,代码如下: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273function getCol(ques) { function checkCol(ques, i, j) { let other = (j + 1) % 3 // 第1列出现重复元素 if (i === 0) { // 看后一列 if (ques[i][other] === ques[i + 1][j]) return [[i, j], [i + 1, j]] } else if (i === 1) { // 第2列出现重复元素 // 看后一列 if (ques[i][other] === ques[i + 1][j]) return [[i, j], [i + 1, j]] // 看前一列 if (ques[i][other] === ques[i - 1][j]) return [[i, j], [i - 1, j]] } else { // 第3列出现重复元素 // 看前一列 if (ques[i][other] === ques[i - 1][j]) return [[i, j], [i, j - 1]] } return [] } // 看每一列是否经过一次交换就可消除 for (let i = 0; i < ques.length; i ++){ // 前面2个元素相同,看第三个元素是否可以经过一次交换产生相同元素 if (ques[i][0] === ques[i][1]) { let check = checkCol(ques, i, 2) // 第i列第三个元素可以与其隔壁交换 if (check.length !== 0) return check } // 第1个元素与第3个元素相同,看第2个元素是否可以与其隔壁交换 if (ques[i][0] === ques[i][2]) { let check = checkCol(ques, i, 1) // 第i列第二个元素可以与其隔壁交换 if (check.length !== 0) return check } // 第2个元素与第3个元素相同,看第1个元素是否可以与其隔壁交换 if (ques[i][1] === ques[i][2]) { let check = checkCol(ques, i, 0) // 第i列第一个元素可以与其隔壁交换 if (check.length !== 0) return check } }}function get_userresponse(ques) { // 根据列去判断是否可以消除 let arr = getCol(ques); // 如果按列不可消除,按照行去判断 if (arr === undefined || arr.length === 0) { // 把按照行判断转化为按照列判断 let new_ques = []; for (let index = 0; index < ques.length; index++) { new_ques[index] = [ques[0][index], ques[1][index], ques[2][index]]; } arr = getCol(new_ques); // 得到的结果再将列转化为行 let new_arr = []; for (let index = 0; index < arr.length; index++) { new_arr[index] = [arr[index][1], arr[index][0]] } return new_arr } else return arr;} e0vm watch变量e,发现代码运行过此处之后,e对象才有e0vm属性,如下图: 跟踪进去_gct方法,调试并整理代码如下: 1234567891011121314151617function FvBQ(t) { var e = 5381; var n = t.length; var o = 0; while (n--) { e = (e << 5) + e + t.charCodeAt(o++); } e &= ~(1 << 31); return e;}function GRmF(t) { t['e0vm'] = FvBQ(GRmF.toString() + FvBQ(FvBQ.toString())) + ''; return FvBQ(FvBQ.toString());}console.log(GRmF({"geetest": "captcha", "lang": "zh", "ep": "123"})); d.default抠完对象e之后,看下整个得到w参数的加密算法d.default,之所以不着急去解剖s,是因为s这个对象属性太多,可以先跟踪进去d.default,看下d.default方法里面用了s对象的哪些属性,然后再反过来看下这些值是怎样生成的。 跟进去d.default方法,代码如下: 参数e是前边逆向出来的e,上边红框的部分可以看到t值只是一种特殊情况,所以不必对t也就是前边说的s进行逆向。下边的红框可以看到w是由两部分组成,前半部分是由e和s经过AES算法加密得到的,整理代码如下: 12345678910111213141516171819function get_w(ques, lotNumber, guid) { let e = get_e(ques, lotNumber, guid); let c = encrypt(JSON.stringify(e), guid, "0000000000000000"); let o = []; for(let a = 0, i = c.sigBytes; a < i; a++) { var u = c.words[a >>> 2] >>> 24 - a % 4 * 8 & 255; o.push(u); } return arrayToHex(o)}function arrayToHex(e) { for (var t = [], n = 0, s = 0; s < 2 * e["length"]; s += 2) t[s >>> 3] |= parseInt(e[n], 10) << 24 - s % 8 * 4, n++; for (var r = [], i = 0; i < e["length"]; i++) { var o = t[i >>> 2] >>> 24 - i % 4 * 8 & 255; r["push"]((o >>> 4)["toString"](16)), r["push"]((15 & o)["toString"](16)); } return r["join"]("");} 后半部分则是u,跟进去可以看到是一个RSA加密,如下图: 跟进去这个对象,可以看到公钥,如下图: 公钥与滑块验证码的公钥一致。 生成u的部分代码如下: 12345def rsa_encrypt(wb): rsa = RSAKey() rsa.setPublic("00C1E3934D16144...打码...66D59CEEFA5F2748EA80BAB81", "10001") return rsa.encrypt(wb) 运行与测试运行结果如下: 验证成功,不同于滑块验证码,只要交换的图案完成消消乐,成功率就是百分百。 若需要完整代码或者讨论,扫描加微信。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"验证码","slug":"验证码","permalink":"http://example.com/tags/%E9%AA%8C%E8%AF%81%E7%A0%81/"},{"name":"AES","slug":"AES","permalink":"http://example.com/tags/AES/"},{"name":"RSA","slug":"RSA","permalink":"http://example.com/tags/RSA/"},{"name":"MD5","slug":"MD5","permalink":"http://example.com/tags/MD5/"},{"name":"极验","slug":"极验","permalink":"http://example.com/tags/%E6%9E%81%E9%AA%8C/"}]},{"title":"JS逆向案例——百度旋转验证码","date":"2023-05-16T17:17:47.000Z","path":"JS逆向案例——百度旋转验证码/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 逆向目标 目标:百度旋转验证码 主页:https://wappass.baidu.com/#/password_login 接口:https://passport.baidu.com/viewlog 逆向参数: Get Param ak: 1e3f2dd1c81f2075171a547893391274 as: 02a62d7d fs: 太长,略 tk: 太长,略 逆向过程四个参数中,ak是固定的,as和tk是接口返回的,所以只需要找出fs的生成方法即可。 从调用栈中进入相关代码进行调试: 然后文件中搜索fs = ,发现fs是经过encrypt方法加密生成,如下图: encrypt方法简单的封装了一下aes加密算法,接受一个参数i,是一个JSON字符串,进入这个方法,代码如下: 其中的key是t,而t则是由as和固定的字符串appsapi0拼接而成,i则是传入进来的JSON字符串。所以只需要解决这个JSON字符串是如何生成的,也就知道fs是如何生成的了。 全局搜索rzData,找到一处定义的地方,如下图: 可以看到simu是检测到webdriver,这里默认为0就行。 看下关键的ac_c,ac_c是由i.percentage赋值。 跟进去看下: 其中o是旋转的角度,a是固定值212。 rzData中还有一个backstr,来自于其它请求。 至于其它的字段,比如cl,mv等不影响旋转验证码的验证。自此,整个fs的逆向过程就完成了。 旋转角度的识别关于旋转验证码的识别,网上找的模型,地址如下:https://github.com/chencchen/RotateCaptchaBreak 测试测试结果如下: op为1表示验证通过,为0则表示验证失败。 若需要完整代码,扫描加微信。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"验证码","slug":"验证码","permalink":"http://example.com/tags/%E9%AA%8C%E8%AF%81%E7%A0%81/"},{"name":"AES","slug":"AES","permalink":"http://example.com/tags/AES/"}]},{"title":"JS逆向案例——极验滑块验证码新思路","date":"2023-05-16T17:07:47.000Z","path":"JS逆向案例——极验滑块验证码新思路/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 逆向目标 目标:极验滑块验证码逆向 主页:https://www.tianyancha.com/ 接口:https://api.geetest.com/ajax.php 逆向参数: Get Param gt: f5c10f395211c77e386566112c6abf21 challenge: 156aa94ea88422263a3e9653f49dea52ln w: 太长,略 callback: geetest_1683637194585 逆向过程把要做的事情拆分为几个步骤,分别为梳理请求关系,滑块验证码底图还原,滑块验证码w参数逆向,补环境,自动过验证码。 请求关系梳理请求罗列 第一个请求:https://napi-huawei.tianyancha.com/validate/init?_=1683636853751 入参:当前时间戳; 返回值:gt和challenge。 第二个请求:https://api.geevisit.com/gettype.php? 入参:第一个请求得到的gt和一个由时间戳拼接成的固定的参数callback; 返回值:验证码的类型及其相关的资源文件。 第三个请求:https://api.geevisit.com/get.php? 入参:gt,challenge,callback以及第二个请求得到的资源文件信息。 返回值:新的challenge,带缺口的乱序错位验证码底图,不带缺口的乱序错位验证码底图以及滑块图 第四个请求:https://api.geetest.com/ajax.php? 入参:gt,新的challenge,w值,callback 返回值:滑块验证码是否验证通过 关系梳理为了描述方便分别把第一至四个请求命名为A~D。A从天眼查服务端拿到一个gt和challenge,B用拿到的gt,challenge以及当前时间戳向极验服务器请求并拿到验证码js文件,C利用B拿到的资源文件信息以及gt,challenge去请求极验服务器拿到滑块验证码带缺口和不带缺口的乱序底图,这些底图经过前段js文件渲染就呈现出我们看到的滑块验证码的样子;D请求通过传入gt,challenge,w值和callback请求极验服务器完成对滑块验证码的验证。 滑块验证码底图还原与滑动距离计算底图还原要想计算滑块需要移动的距离,就需要先将乱序的验证码图片变成有序。 先看下滑块的大小: 图片大小 w = 260px, h = 116px。我们点击图片选择审查元素,可以看到底图是由52个div组成,每个div的w = 10px,h = 58px。分为上下两个半区,每个半区26个div。刚好组成260px * 116px的矩形验证码。如下图: 可以看到第一个div,即上半区左上角的第一个div,background-position = -157px -58px。表示将background-image向左偏移157个像素,向上偏移58个像素,作为第一个div放在上半区最左边。由于前面分析过,每个div的宽是10px,高是58px。所以第一个div四个顶点在background-image上的相对坐标是(157, 58), (167, 58), (157, 116), (167, 116)。 同理,我们推测上半区第二个div的四个顶点的相对坐标分别是(145, 0), (155, 0), (145, 58), (155, 58)。 此外,background-image就是我们抓包分析的第三步获取到的乱序图。 知道了每一个个div的坐标,以及乱序的背景图,就可以通过从乱序图上裁剪出一个个div,然后再拼接到一起,这样不就构成了正确有序的图片。 知道了原理,代码实现如下: 1234567891011121314151617181920212223242526272829import mathfrom PIL import Imagediv_offset = [ {"x": -157, "y": -58}, # 省略若干行 {"x": -205, "y": 0}]def restore_pic(pic_path, new_pic_path): unordered_pic = Image.open(pic_path) ordered_pic = unordered_pic.copy() # 裁剪并拼接 for i, d in enumerate(div_offset): im = unordered_pic.crop((math.fabs(d['x']), math.fabs(d['y']), math.fabs(d['x']) + 10, math.fabs(d['y']) + 58)) # 上半区 if d['y'] != 0: ordered_pic.paste(im, (10 * (i % (len(div_offset) // 2)), 0), None) else: ordered_pic.paste(im, (10 * (i % (len(div_offset) // 2)), 58), None) ordered_pic.save(new_pic_path)if __name__ == '__main__': restore_pic("img.png", "new_img.png") 解释一下上面用到的PIL库的几个方法:copy表示复制一张图片;crop表示以矩形区域裁剪,入参是一个四个元素的元组,分别是矩形左上角顶点的x坐标,左上角顶点的y坐标,右下角顶点的x坐标,右下角顶点的y坐标;paste表示粘贴图片。 测试效果如下: 不带缺口的乱序背景图以及还原后的图片: 带缺口的乱序背景图以及还原后的图片: 还原图后边依然存在乱序的部分,但是这些乱序的地方已经超出260px,实际上不会展示到页面上,也即没有影响。 计算滑动距离既然底图已经还原了,接下来就是缺口位置的计算,从而得到滑块需要滑动的距离。缺口计算有2种方式,一种是采用深度模型识别缺口坐标,参考文章如何利用深度学习识别滑块验证码缺口位置。 第二种是计算图片的每个像素点位置的色差去判断缺口,代码如下: 123456789101112131415161718def diff_rgb(rgb1, rgb2): return math.fabs(rgb1[0] - rgb2[0]) + math.fabs(rgb1[1] - rgb2[1]) + math.fabs(rgb1[2] - rgb2[2]) > 255def get_moving_dst(complete_pic_path, incomplete_pic_path): complete_pic = Image.open(complete_pic_path) incomplete_pic = Image.open(incomplete_pic_path) w, h = complete_pic.size for i in range(0, w): for j in range(0, h): complete_pic_pixel_rgb = complete_pic.getpixel((i, j)) incomplete_pic_pixel_rgb = incomplete_pic.getpixel((i, j)) if diff_rgb(complete_pic_pixel_rgb, incomplete_pic_pixel_rgb): return i return 0 w参数逆向与滑块轨迹模拟首先对geetest.6.0.9.js这个文件进行反混淆,将二进制数字转化为十进制数,将base64编码的字符串转化为ASCII码字符,最后将字面量还原。将Js文件进行反混淆处理后在浏览器中进行override,覆盖线上版本,进行本地调试。 w参数逆向前面分析过,最终向极验后端提交的四个参数中,gt和challenge都是通过其它接口返回的,callback参数是当前时间戳生成的,只有w参数需要逆向。 全局搜索"w":,发现如下代码: 可以看到,w是由r7z和H7z拼接而成。 逆向r7z整理下代码逻辑如下: 1r7z = p7B.Ha(n0B.encrypt(h7B.stringify(Y7z), v7z.wb())) 先看最里面的,全局搜索wb,发现wb实际上调用的是C7B方法,如下图: 再全局搜索下C7B(搜索的时候注意区分大小写,这样排除了很多干扰项),发现C7B实际上是四次调用H1W方法,并把四次返回结果拼接在一起,如下图: 再看下H1W方法的源码,如下: 不再调用其它封装的方法,综上,整理出来wb方法,如下: 12345var H1W = function() { return (65536 * (1 + Math.random()) | 0).toString(16).substring(1)}var wb = H1W() + H1W() + H1W() + H1W(); 然后是h7B.stringify,经过测试,h7B.stringify这个方法的作用等同于JSON.stringify。 接着看下n0B.encrypt,定位到代码如图所示: 传入的三个参数,第一个r7W是JSON.stringify(Y7z),第二个m7W是随机的字符串即上边的wb,第三个变量P7W未使用。简单分析下代码,不难看出这个encrypt方法实际上是对AES加密算法做了一个封装,然后自定义了一些逻辑。其中,m7W用作生成key,0000000000000000用作生成iv。 代码修改如下: 123456789101112131415161718192021const CryptoJS = require('crypto-js'); function Encrypt(word, key, iv) { let srcs = CryptoJS.enc.Utf8.parse(word); let encrypted = CryptoJS.AES.encrypt(srcs, key, { iv: iv, mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return encrypted.ciphertext;}function encrypt(r7W, m7W, P7W) { var p2r = 0; const key = CryptoJS.enc.Utf8.parse(m7W); const iv = CryptoJS.enc.Utf8.parse('0000000000000000'); //十六位十六进制数作为密钥偏移量 for (var W7W = Encrypt(r7W, key, iv), Z7W = W7W.words, H7W = W7W.sigBytes, d7W = [], l7W = 0; p2r * (p2r + 1) * p2r % 2 == 0 && l7W < H7W; l7W++) { var q7W = Z7W[l7W >>> 2] >>> 24 - l7W % 4 * 8 & 255; d7W["push"](q7W); p2r = p2r > 33997 ? p2r / 5 : p2r * 5; } return d7W;} 这段代码用到了crypto-js包,需要用命令npm install crypto-js安装。 再接着看下p7B.Ha这个方法,代码如下: Ha调用T6B.Ga方法,跟进去: 代码如图,直接抠出来整个Ga代码,只不过图中圈出的this在node环境中需要补一下,经过调试,this中包含如下的方法和值友用到: 经过流程平坦化之后,完整代码为: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657var Ga = function (o6B, t6B) { var D5r = 27; var I9z = 3; var X6B = { "wa": 7274496, "xa": 9483264, "ya": 19220, "za": 235, "Aa": 24, } X6B.Da = function(r0B) { var v9z = 1; var h0B = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789()"; return (r0B < 0 || r0B >= h0B["length"]) && v9z * (v9z + 1) % 2 + 8 ? "." : h0B["charAt"](r0B); } X6B.Fa = function(R0B, C0B) { return R0B >> C0B & 1; } t6B || (t6B = X6B); var N6B = function (Q6B, x6B) { var I6B = 0, v6B = t6B["Aa"] - 1; while (v6B >= 0) { if (v6B >= 0) { 1 === X6B["Fa"](x6B, v6B) && (I6B = (I6B << 1) + X6B["Fa"](Q6B, v6B)); v6B -= 1; } else { return I6B; } } return I6B; }, j6B = "", K6B = "", c6B = o6B["length"], f6B = 0; while (f6B < c6B && I9z * (I9z + 1) % 2 + 3) { var B6B; if (f6B + 2 < c6B) { B6B = (o6B[f6B] << 16) + (o6B[f6B + 1] << 8) + o6B[f6B + 2], j6B += X6B["Da"](N6B(B6B, t6B["wa"])) + X6B["Da"](N6B(B6B, t6B["xa"])) + X6B["Da"](N6B(B6B, t6B["ya"])) + X6B["Da"](N6B(B6B, t6B["za"])); } else { var n6B = c6B % 3; 2 === n6B ? (B6B = (o6B[f6B] << 16) + (o6B[f6B + 1] << 8), j6B += X6B["Da"](N6B(B6B, t6B["wa"])) + X6B["Da"](N6B(B6B, t6B["xa"])) + X6B["Da"](N6B(B6B, t6B["ya"])), K6B = t6B["r"]) : 1 === n6B && (B6B = o6B[f6B] << 16, j6B += X6B["Da"](N6B(B6B, t6B["wa"])) + X6B["Da"](N6B(B6B, t6B["xa"])), K6B = t6B["r"] + t6B["r"]); } I9z = I9z > 53617 ? I9z - 7 : I9z + 7; f6B += 3; } return { "res": j6B, "end": K6B };}var Ha = function (M6B) { var L6B = Ga(M6B); return L6B["res"] + L6B["end"];} 最后看下压轴部分,Y7z的代码: 可以看到Y7z的属性有userresponse, passtime, imgload, aa, ep, rp。 先看下i7B.C方法,直接将这个方法扣下来拿来用即可,在node中测试不需要补环境。 接着看下c7B[“a”]这个方法,实际上是取得c7B[“Na”]这个对象的属性,为了方便跟踪这个对象,在浏览器watch栏中添加这个对象,然后给滑块添加一个断点,如下: 拖动滑块,进入单步调试,调试结果如下: 可以看到c7B[“Na”]对象的相关属性来自于L7z这个光标事件。其中有一个数组arr,里面每一个元素都是一个三维向量,分别代表x轴坐标,y轴坐标和经过的时间,这个数组即用来保存滑块的移动轨迹。 其中passtime是滑块滑动所需的时间,可以由轨迹数组计算出来。ep是版本号,这里写死为{v: "6.0.9"}即可。aa则是由轨迹数组经过加密生成的字符串。imgload是加载的图片数量,经过测试,给一个随机值即可。userresponse是调用i7B[“C”]生成的,这个方法已经扣下来了,参数g7z和challenge,challenge是由接口返回,所以只需要计算g7z即可。 经过调试,passtime和g7z的计算方法为: 1234567let passtime = 0;let g7z = 0;for (let index = 0; index < X1z.length; index++) { passtime += X1z[index][2]; g7z += X1z[index][0];}g7z -= X1z[0][0]; 紧接着看下aa,断点进入,代码如下: 整理下这个t方法,并且抠出其调用的方法,如下: 123456789101112131415161718192021222324252627282930313233343536373839404142434445O6z = function (r6z) { var d6z = "()*,-./0123456789:?@ABCDEFGHIJKLMNOPQRSTUVWXYZ_abcdefghijklmnopqr", m6z = d6z["length"], Z6z = "", H6z = Math["abs"](r6z), W6z = parseInt(H6z / m6z); W6z >= m6z && (W6z = m6z - 1), W6z && (Z6z = d6z["charAt"](W6z)), H6z %= m6z; var q6z = ""; return r6z < 0 && (q6z += "!"), Z6z && (q6z += "$"), q6z + Z6z + d6z["charAt"](H6z);}u6z = function (R6z) { var t8r = 27; var f5r = 9; var z6z = [[1, 0], [2, 0], [1, -1], [1, 1], [0, 1], [0, -1], [3, 0], [2, -1], [2, 1]], h6z = 0, C6z = z6z["length"]; while (h6z < C6z && f5r * (f5r + 1) % 2 + 7) { if (R6z[0] === z6z[h6z][0] && R6z[1] === z6z[h6z][1]) { return "stuvwxyz~"[h6z]; } else { f5r = f5r >= 62252 ? f5r - 6 : f5r + 6; h6z++; } } return 0;};var t = function (X1z) { var o5r = 6; var N1z, f1z = [], B1z = [], o1z = [], t1z = 0, j1z = X1z["length"]; while (o5r * (o5r + 1) % 2 + 8 && t1z < j1z) { N1z = u6z(X1z[t1z]), N1z ? B1z["push"](N1z) : (f1z["push"](O6z(X1z[t1z][0])), B1z["push"](O6z(X1z[t1z][1]))), o1z["push"](O6z(X1z[t1z][2])); o5r = o5r >= 17705 ? o5r / 3 : o5r * 3; t1z++; } return f1z["join"]("") + "!!" + B1z["join"]("") + "!!" + o1z["join"]("");} t方法调用传入的X1z就是滑块滑动产生的轨迹数组。 这里调试会发现,生成的aa与页面上的不一致,实际上F7z除了由上边的t方法修改之外,还有一个地方也修改了,如图: 调用了e7B.u方法,接收3个参数,第一个是上面t方法生成的初步的F7z,第二个是c,第三个是s,其中c和s都是通过接口拿到。所以这里的重点工作是抠出e7B.u方法: 12345678910111213141516var e7B = {};e7B.u = function (Q1z, v1z, T1z) { var K5r = 2, j5r = 4; if ((!v1z || !T1z) && j5r * (j5r + 1) * j5r % 2 === 0 ) return Q1z; var i1z, x1z = 0, c1z = Q1z, y1z = v1z[0], k1z = v1z[2], L1z = v1z[4]; while ((i1z = T1z["substr"](x1z, 2)) && K5r * (K5r + 1) * K5r % 2 === 0) { x1z += 2; var n1z = parseInt(i1z, 16), M1z = String["fromCharCode"](n1z), I1z = (y1z * n1z * n1z + k1z * n1z + L1z) % Q1z["length"]; c1z = c1z["substr"](0, I1z) + M1z + c1z["substr"](I1z); K5r = K5r > 10375 ? K5r / 8 : K5r * 8; } return c1z;} 最后看下rp属性,不难看出rp是由gt,challenge的前32位,passtime经过md5加密算法生成。代码如下: 1234var crypto = require('crypto');var md5 = crypto.createHash('md5');Y7z["rp"] = md5.update(gt + challenge.slice(0, 32) + passtime).digest('hex'); 至此,w参数的前半部分r7z剖析完了。 逆向H7z进入断点调试,可以看到H7z是调用V7z.Ub方法生成。 抠出V7z.Ub方法,并补全其中用到的RSA算法,代码如下: 12345678910from jsbn import RSAKeydef get_H7z(wb): rsa = RSAKey() rsa.setPublic("00C1E3934D1614465B33053E7F48EE4EC87B14B95EF88947713D25EECBFF7E74C7977D02DC1D9451F79DD" "5D1C10C29ACB6A9B4D6FB7D0A0279B6719E1772565F09AF627715919221AEF91899CAE08C0D686D748B20" "A3603BE2318CA6BC2B59706592A9219D0BF05C9F65023A21D2330807252AE0066D59CEEFA5F2748EA80BAB81", "10001") return rsa.encrypt(wb) 代码说明:需要安装RSA库,用pip install pyjsbn-rsa命令安装即可,因为没有找到合适的Node库,所以采用Python库。其中的RSA public key相关信息单步调试的时候可以拿到,wb是一个随机生成的字符串,产生随机字符串的代码在r7z部分已经逆向完成。特别注意:r7z和H7z通过这个随机字符串关联起来,生成r7z和H7z用到的随机字符串必须是同一个,所以wb最好定义为全局变量,并且全局生成一次。 滑块轨迹模拟极验滑块的轨迹主要有三种方式,一种是直接用网上现有的滑动轨迹模型,另一种是自己搭建模型自己训练。这里介绍第三种,手动滑动滑块得到正确的拼图,同时保存滑块轨迹,数据结构采用key-value形式,key是滑块需要的滑动的距离,value是轨迹数组,通过上百次的滑动,预先建立一个轨迹字典,下次滑动时通过距离从这个字典中直接拿到轨迹数组。 具体实现如下: 用Flask搭建一个轨迹收集服务 12345678910111213141516171819202122232425262728293031323334353637383940414243444546import jsonimport mathimport picklefrom flask import Flask, requestfrom flask_cors import cross_originapp = Flask(__name__)@app.post("/track")@cross_origin(supports_credentials=True, methods="*", allow_headers="*")@cross_origin()def track(): tracks = pickle.load(open("tracks.pkl", "rb")) d = json.loads(request.data.decode()) print(d) # 下载乱序的缺口图和完整图 download_image(d['bg'], "bg.png") download_image(d['fullbg'], "fullbg.png") # 还原乱序的缺口图和完整图 restore_pic("bg.png", "new_bg.png") restore_pic("fullbg.png", "new_fullbg.png") # 获取缺口的位置 x = get_moving_dst("new_bg.png", "new_fullbg.png") if tracks.get(x): tracks[x].append({'track': d['track'], 'g7z': d['g7z']}) else: tracks[x] = [{'track': d['track'], 'g7z': d['g7z']}] pickle.dump(tracks, open("tracks.pkl", "wb")) return 'ok'def download_image(url, image_file): with open(image_file, "wb") as f: f.write(requests.get(url).content)if __name__ == '__main__': track_data = {} pickle.dump(track_data, open("tracks.pkl", "wb")) app.run(host='0.0.0.0', port=8088) 注入Js代码 注入Js代码,每次滑动时向收集服务发送请求,将轨迹数组传递过去。代码如下: 12345678910111213const Http = new XMLHttpRequest();const url='http://127.0.0.1:8088/track';Http.open("POST", url);Http.onload = function () { console.log("请求成功, track: " + JSON.stringify(X1z)); // 请求结束后,在此处写处理代码};Http.send(JSON.stringify({ track: X1z, bg: /\\"(.*?)\\"/g.exec(document.getElementsByClassName("gt_cut_bg_slice")[0].style.backgroundImage)[1], fullbg: /\\"(.*?)\\"/g.exec(document.getElementsByClassName("gt_cut_fullbg_slice")[0].style.backgroundImage)[1], g7z})); 代码插入位置如图: 通过手动过滑块就可以把轨迹数组收集到tracks.pkl文件了。 完整代码测试及总结测试测试结果如下: 总结极验滑块之前发过相关的文章了,当时用到的方法是将整个Js文件抠下来然后补环境,整个代码有大几千行,这次换了个思路,只是抠调用到的代码,整个下来也就300行左右。 若需要完整代码,扫描加微信。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"验证码","slug":"验证码","permalink":"http://example.com/tags/%E9%AA%8C%E8%AF%81%E7%A0%81/"},{"name":"AES","slug":"AES","permalink":"http://example.com/tags/AES/"},{"name":"RSA","slug":"RSA","permalink":"http://example.com/tags/RSA/"},{"name":"极验","slug":"极验","permalink":"http://example.com/tags/%E6%9E%81%E9%AA%8C/"}]},{"title":"如何利用深度学习识别滑块验证码缺口位置","date":"2023-05-12T09:15:57.000Z","path":"如何利用深度学习识别滑块验证码缺口位置/","text":"极验滑块验证码缺口位置的计算有2种方式,第一种通过计算像素点差值来定位缺口位置,在JS逆向案例——天眼查过极验滑块验证码一文中介绍过,今天这篇文章介绍第二种方式,如何利用深度学习识别缺口的位置。这里采用华为云ModelArts云服务去训练缺口识别模型。 数据集的准备收集验证码图片先上代码,如下: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154import jsonimport mathimport reimport timefrom urllib.parse import urljoinimport requestsfrom PIL import Imagediv_offset = [ {"x": -157, "y": -58}, {"x": -145, "y": -58}, {"x": -265, "y": -58}, {"x": -277, "y": -58}, {"x": -181, "y": -58}, {"x": -169, "y": -58}, {"x": -241, "y": -58}, {"x": -253, "y": -58}, {"x": -109, "y": -58}, {"x": -97, "y": -58}, {"x": -289, "y": -58}, {"x": -301, "y": -58}, {"x": -85, "y": -58}, {"x": -73, "y": -58}, {"x": -25, "y": -58}, {"x": -37, "y": -58}, {"x": -13, "y": -58}, {"x": -1, "y": -58}, {"x": -121, "y": -58}, {"x": -133, "y": -58}, {"x": -61, "y": -58}, {"x": -49, "y": -58}, {"x": -217, "y": -58}, {"x": -229, "y": -58}, {"x": -205, "y": -58}, {"x": -193, "y": -58}, {"x": -145, "y": 0}, {"x": -157, "y": 0}, {"x": -277, "y": 0}, {"x": -265, "y": 0}, {"x": -169, "y": 0}, {"x": -181, "y": 0}, {"x": -253, "y": 0}, {"x": -241, "y": 0}, {"x": -97, "y": 0}, {"x": -109, "y": 0}, {"x": -301, "y": 0}, {"x": -289, "y": 0}, {"x": -73, "y": 0}, {"x": -85, "y": 0}, {"x": -37, "y": 0}, {"x": -25, "y": 0}, {"x": -1, "y": 0}, {"x": -13, "y": 0}, {"x": -133, "y": 0}, {"x": -121, "y": 0}, {"x": -49, "y": 0}, {"x": -61, "y": 0}, {"x": -229, "y": 0}, {"x": -217, "y": 0}, {"x": -193, "y": 0}, {"x": -205, "y": 0}]def recover_pic(pic_path, new_pic_path): unordered_pic = Image.open(pic_path) ordered_pic = unordered_pic.copy() # 裁剪并拼接 for i, d in enumerate(div_offset): im = unordered_pic.crop((math.fabs(d['x']), math.fabs(d['y']), math.fabs(d['x']) + 10, math.fabs(d['y']) + 58)) # 上半区 if d['y'] != 0: ordered_pic.paste(im, (10 * (i % (len(div_offset) // 2)), 0), None) else: ordered_pic.paste(im, (10 * (i % (len(div_offset) // 2)), 58), None) ordered_pic.save(new_pic_path)def download_pic(store_name): headers = { 'Accept': 'application/json, text/plain, */*', 'Accept-Language': 'zh-CN,zh;q=0.9', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Content-Type': 'application/json', 'Origin': 'https://www.tianyancha.com', 'Pragma': 'no-cache', 'Referer': 'https://www.tianyancha.com/', 'Sec-Fetch-Dest': 'empty', 'Sec-Fetch-Mode': 'cors', 'Sec-Fetch-Site': 'same-site', 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 ' '(KHTML, like Gecko) Chrome/112.0.0.0 Safari/537.36', 'X-TYCID': '70c68810ddbd11eda0a455532f9618b6', 'sec-ch-ua': '"Chromium";v="112", "Google Chrome";v="112", "Not:A-Brand";v="99"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"macOS"', } params = { '_': '1683799958183', } response = requests.get('https://napi-huawei.tianyancha.com/validate/init', params=params, headers=headers) resp = json.loads(response.text) data = json.loads(resp['data']) gt = data['gt'] challenge = data['challenge'] headers = { 'Accept': '*/*', 'Accept-Language': 'zh-CN,zh;q=0.9', 'Cache-Control': 'no-cache', 'Connection': 'keep-alive', 'Pragma': 'no-cache', 'Referer': 'https://www.tianyancha.com/', 'Sec-Fetch-Dest': 'script', 'Sec-Fetch-Mode': 'no-cors', 'Sec-Fetch-Site': 'cross-site', 'User-Agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) ' 'Chrome/112.0.0.0 Safari/537.36', 'sec-ch-ua': '"Chromium";v="112", "Gostiogle Chrome";v="112", "Not:A-Brand";v="99"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"macOS"', } response = requests.get( f'https://api.geevisit.com/get.php?gt={gt}&challenge={challenge}&product=popup&offline=false&' f'api_server=api.geevisit.com&protocol=https://&type=slide&path=/static/js/geetest.6.0.9.js' f'&callback=geetest_{str(round(time.time() * 1000))}', headers=headers, ) bg_url = urljoin("https://static.geetest.com/", re.findall("\\"bg\\": \\"(.*?)\\"", response.text)[0]) print("下载验证码图片,地址:" + bg_url) resp = requests.get(bg_url) with open(store_name, "wb+") as f: f.write(resp.content)if __name__ == '__main__': for i in range(10): name = "input/" + str(i) + ".jpg" download_pic(name) print("保存乱序验证码图片,保存位置:" + name) new_name = "output/" + str(i) + ".jpg" recover_pic(name, new_name) print("还原乱序验证码图片,保存位置:" + new_name) time.sleep(0.5) 解释主要用到的2个方法,download_pic下载乱序的验证码图片,recover_pic还原乱序的验证码图片。关于下载验证码的请求逻辑,参考JS逆向案例——极验滑块验证码底图还原。 运行效果如下: 下载好图片后,将图片上传到华为云对象存储,具体方法可以自行网上查阅。这里下载并上传了100张图片。 数据标注通过网址https://console.huaweicloud.com/modelarts/?region=cn-north-4#/dashboard打开ModelArts主界面,然后点击主动学习: 可以看到ModelArts支持的全部功能,这里选择第二个物体检测,点击创建项目: 项目名称和数据集名称可以采用自动生成的,数据集输入位置填刚才上传验证码的那个bucket的目录,数据集输出位置就在此bucket下新建一个空目录即可。最后点击创建项目。 然后点击未标注: 接着随意点击一张未标注的图片,对图片进行标注: 标注完成之后,后边会显示缺口相对于背景图的坐标: 标注完成之后点击选择下一张标注,数据集一共100张,大概10多分钟标注完。 模型训练数据集标注完之后,点击开始训练: 然后会提示模型训练中: 数据集样本比较少,训练大概5-10分钟,完成后界面如下: 注意:之所以准确率只有94%,有2个原因,一是样本数量少;二是数据标注为了节约时间没有太细致。改善这2点原因让准确率接近100%不是不可能。 模型部署点击部署按钮: 直接点击下一步即可。 模型测试上传一张图片测试: 可以看到缺口位置基本识别正确。 再上传一张带干扰的图片测试: 也正确识别。 至此,完结撒花。","tags":[{"name":"验证码","slug":"验证码","permalink":"http://example.com/tags/%E9%AA%8C%E8%AF%81%E7%A0%81/"},{"name":"深度学习","slug":"深度学习","permalink":"http://example.com/tags/%E6%B7%B1%E5%BA%A6%E5%AD%A6%E4%B9%A0/"},{"name":"云服务","slug":"云服务","permalink":"http://example.com/tags/%E4%BA%91%E6%9C%8D%E5%8A%A1/"}]},{"title":"JS逆向之浏览器内存漫游解决方案","date":"2023-04-12T14:40:21.000Z","path":"JS逆向之浏览器内存漫游解决方案/","text":"浏览器内存漫游所谓浏览器内存漫游就是通过内存中变量级别的抓包监控,把浏览器中js加载过程中的变量值全部存储下来,从而达到可以随意检索浏览器内存中的数据。 有了浏览器内存漫游,就可以通过某个接口的变量的值反查变量生成的地方,从而快速定位接口参数,这在JS逆向这一块非常方便。 在JS逆向之Fiddler编程猫插件使用一文中提到过一种浏览器内存漫游的方案,那就是使用Fiddler+编程猫插件,然而编程猫插件只支持windows平台,而且两个软件配置起来有点麻烦。这里介绍一个新的库:https://github.com/JSREI/ast-hook-for-js-RE,这个工具跨平台,并且配置简单。 安装配置以mac环境为例,其它环境大同小异。 克隆项目到本地文件夹 1git clone https://github.com/CC11001100/ast-hook-for-js-RE.git 安装依赖 进入项目文件夹,并安装依赖 12cd ast-hook-for-js-REnpm i 安装anyproxy并安装证书 由于ast-hook-for-js是依赖于anyproxy抓包的,所以需要先安装anyproxy: 1sudo npm install -g anyproxy 通过命令安装anyproxy。 要代理https,还需生成CA证书并添加信任,命令如下: 1anyproxy-ca 会在当前目录下生成一个rootCA.crt文件,双击这个文件,然后选择系统,如下图: 进入到系统,刚才添加的证书默认是不信任,如图: 双击这个文件,展开信任栏,选择始终信任,如下图: 然后退出保存修改即可。 启动项目 12cd src/proxy-servernode proxy-server.js 注:一定要进入到proxy-server.js所在的目录后再运行proxy-server.js,否则会出现莫名的错误。 配置系统代理 打开设置,搜索代理: 点击代理,配置如下: 至此,安装配置完成。 测试以极验为例,浏览器中打开https://gt4.geetest.com/,并且打开控制台,进入network栏。找到加载验证码的请求,并随便复制一个请求参数,比如challenge,如下图: 然后切换到console控制台,输入如下代码: 1hook.search("e7116cce-9779-4cad-be71-f0117681e781") 执行结果如下: 这样就根据值查找到了需要逆向的入参,点击最后的一个代码位置,进入到相关代码位置,如下: 可以看到challenge是由uuid方法生成。","tags":[{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"浏览器","slug":"浏览器","permalink":"http://example.com/tags/%E6%B5%8F%E8%A7%88%E5%99%A8/"},{"name":"内存漫游","slug":"内存漫游","permalink":"http://example.com/tags/%E5%86%85%E5%AD%98%E6%BC%AB%E6%B8%B8/"}]},{"title":"AST解JS混淆之去掉未被调用的函数","date":"2022-05-28T13:44:53.000Z","path":"AST解JS混淆之去掉未被调用的函数/","text":"本文接上一篇 AST解JS混淆之去掉未被使用的变量,去掉未被调用的函数,其思路与去掉未被使用的变量思路区别不大,在某些情况下,二者是通用的。如下: 需要清洗的代码依旧是: 12345678910function a() { var m, n; m++; return m;}function b() { let c = a() + 1; return c;}var c = "Wow"; 跟前面去掉未使用变量的代码基本一致,略作修改: 123456789101112131415const visitor = { "VariableDeclarator|FunctionDeclaration"(path) { const {id} = path.node; const binding = path.scope.getBinding(id.name); // 如果变量被修改过,不去掉 if (!binding || binding.constantViolations.length > 0) { return; } // 如果变量未被引用,去掉 if (binding.referencePaths.length === 0) { path.remove(); } }} 注意”VariableDeclarator|FunctionDeclaration”这种写法,如果是想匹配多个节点,用|分割即可,但是得用双引号括起来。这样子就会对变量和函数同时应用下面的规则,即看变量或函数是否被修改过,是否存在引用。 结果如下: 123456function a() { var m, k; m++; k++; return m;} 可以看到多余的变量以及未被调用的函数被去掉了。 但是有一种特殊情况需要考虑到,那就是当遇到函数体里面定义的变量与函数名同名时,就会存在作用域的问题,这种情况下,运用上面的代码去清晰函数和变量时,就不会起作用了。 比如说如下代码: 12345function a() { var a = "Hello,AST"; console.log(a); return 1;} 虽然函数a未被调用,但是变量a存在引用关系。我们使用Scope.dump()输出一下作用域与变量信息,如下: https://raw.githubusercontent.com/lyy077/blg-pic/main/pic/WX20220701-143413.png 可以看到有2个不同作用域的a,一个a是存在于作用域Program,另一个a是存在于作用域FunctionDeclaration。由于是同样的名字,所以我们在使用代码 12const {id} = path.node;const binding = path.scope.getBinding(id.name); 它应该会使用哪个a呢?我们来看看getBinding的源码: 12345678910getBinding(name) { let scope = this; do { const binding = scope.getOwnBinding(name); if (binding) { return binding; } } while (scope = scope.parent);} 可以看到,不停的在遍历父级作用域,直到获取 binding 为止,由于是 do-while循环,所以会先从当前的作用域开始遍历。而对于上面的特例来说,会优先遍历FunctionDeclaration的作用域,因为这里函数作用域本身就是Program。 所以只需要对之前的代码略加修改即可: 123456789101112131415const visitor = { "VariableDeclarator|FunctionDeclaration"(path) { const {id} = path.node; const binding = path.scope.parent.getBinding(id.name); // 如果变量被修改过,不去掉 if (!binding || binding.constantViolations.length > 0) { return; } // 如果变量未被引用,去掉 if (binding.referencePaths.length === 0) { path.remove(); } }} const binding = path.scope.parent.getBinding(id.name); 这个就是修改的地方,即直接从父作用域开始遍历,这样避免了同名导致的遍历错误作用域的问题。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"AST","slug":"AST","permalink":"http://example.com/tags/AST/"}]},{"title":"AST解JS混淆之AST基础","date":"2022-05-28T12:32:08.000Z","path":"AST解JS混淆之AST基础/","text":"认识AST打开 https://astexplorer.net/ ,选择语言Javascript,选择解析库@babel/parser。如图: image-20220630230656018 并在左侧输入以下代码: 123456789function somewhat() { let a = [1, 2, 3]; for (let i = 0; i < a.length; i++) { console.log(a[i]); }}let b = "Hello,AST";console.log(b); 折叠右边展示的Tree的所有子节点,可以看到主要结构如下: image-20220630231236304 其中File是整个树的根节点。然后基本上每一个子节点都包含type, start, end, loc。给出一个表格列出这几个字段的含义: 节点属性 记录的信息 type 当前节点的类型 start 当前节点的起始位 end 当前节点的末尾 loc 当前节点所在的行列位置 起始于结束的行列信息 errors File节点所持有的特有属性,可以不用理会 program 包含整个源代码,不包含注释节点 comments 源代码中所有的注释会显示在这里 我们通常关注的节点是program,因为源码对应的AST语法子树结构都在program节点中。我们展开program节点,然后对照着JS源码逐步分析: https://raw.githubusercontent.com/lyy077/blg-pic/main/pic/1656603410051.jpg 可以看到程序主要由三部分组成,一个是函数定义,一个是变量定义,一个是表达式语句。 我们接着展开FunctionDeclaration: image-20220630234230480 可以看到somewhat这个函数,主要由2部分组成,一个是变量定义,一个是for语句。 Code->AST@babel/parser能将javascript代码解析成AST,具体代码如下: 1234567891011const parser = require("@babel/parser");var code = `var a = 123;function somewhat() { console.log("Hello, AST");}`;let ast = parser.parse(code);console.log(JSON.stringify(ast, null, '\\t')); travel AST使用path进行遍历在使用 enter 遍历所有节点的时候,参数 path 会传入当前的路径,可以根据path进行各种判断,继而进行各类操作。 编写如下的遍历代码: 1234567891011121314151617181920212223242526272829303132//babel库及文件模块导入const fs = require('fs');//babel库相关,解析,转换,构建,生产const parser = require("@babel/parser");const traverse = require("@babel/traverse").default;const types = require("@babel/types");const generator = require("@babel/generator").default;//读取文件if (process.argv.length < 4) { console.log("Usage: node ${file}.js ${encode}.js ${decode}.js"); process.exit(1);}let input_file = process.argv[2], output_file = process.argv[3];let jscode = fs.readFileSync(input_file, {encoding: "utf-8"});//转换为ast树let ast = parser.parse(jscode);const visitor = { enter(path) { console.log('当前路径类型', path.type); // 打印当前路径类型 console.log('当前路径源码:', path.toString()); // 打印当前路径所对应的源代码 }}//some function code//调用插件,处理源代码traverse(ast, visitor); 深度遍历的过程中,输出每一个节点的类型与其对应的源码。结果如下: https://raw.githubusercontent.com/lyy077/blg-pic/main/pic/1656605283688.jpg 可以看到,使用path方式对AST遍历时,是从Program节点开始的,并不是File根节点开始。事实上,不止是采用path方式,下面介绍的采用节点方式对AST进行遍历,都是通过travel模块来进行的。而采用travel对AST进行遍历都是从Program节点开始。 使用节点进行遍历与使用path遍历不同,我们不用关心每个节点,只需要关注自己想要处理的那些节点。与path相同的是,path同样会作为参数传入。 修改之前遍历的代码,修改visitor如下: 123456const visitor = { ForStatement(path) { console.log('当前路径 源码:\\n', path.toString()); console.log('当前路径 节点:\\n', path.node.toString()); }} 我们只处理for-statement,输出其源码以及下面的节点。输出结果如下: image-20220701003649630 AST->Code在对AST进行遍历处理之后,需要把AST转化成我们需要的JS代码,用到的模块是@babel/generator。 以一段代码作为演示: 123456789101112131415161718192021222324252627282930313233343536373839404142//babel库及文件模块导入const fs = require('fs');//babel库相关,解析,转换,构建,生产const parser = require("@babel/parser");const traverse = require("@babel/traverse").default;const types = require("@babel/types");const generator = require("@babel/generator").default;//读取文件if (process.argv.length < 4) { console.log("Usage: node ${file}.js ${encode}.js ${decode}.js"); process.exit(1);}let input_file = process.argv[2], output_file = process.argv[3];let jscode = fs.readFileSync(input_file, {encoding: "utf-8"});//转换为ast树let ast = parser.parse(jscode);const visitor = { BinaryExpression(path) { // 寻找所有 二元表达式节点 if (path.node.operator == '*') { // 并且这个表达式节点的操作是做 乘法 path.node.operator = '+'; // 将操作改为 加 } }, Identifier(path) { if (path.node.name == 'squire') { path.node.name = 'plus'; // 将函数名改为plus } }}//some function code//调用插件,处理源代码traverse(ast, visitor);//生成新的js code,并保存到文件中输出let {code} = generator(ast);fs.writeFile(output_file, code, (err)=>{}); 这段代码的作用是修改方法squire,将其从平方变为加法,主要做2步,第一步是将乘法变为加法,第二步是将squire重命名为plus。可以看到generator的用法很简单,接受的第一个参数是一个AST语法树,返回一个字符串,这个字符串就是全部的JS代码。 节点节点类型给出一张图,列出节点的类型,如下: https://raw.githubusercontent.com/lyy077/blg-pic/main/pic/1403732-20200713201324374-2129914519.png 这些类型,都在@babel/types中定义。 当前节点的类型,通过path.type来获取。而判断当前节点的类型,有2种方式,如下: 12path.type === 'ForStatement'path.isForStatement() 第一种方式是比较节点的type属性与节点类型是否一致。第二种方式则是调用每个属性对应的判断类型的方法,规则是在每个节点类型加上前缀is,然后按照驼峰式命名即可。比如NumericLiteral对应的是isNumericLiteral,SwitchCase对应的是isSwitchCase。 对Node进行增删改创建node@babel/types包含了各个节点的定义,可以通过使用@babel/types的类型名,查阅@babel/types官方文档,获取对应类型的构造函数,创建对应类型的节点。 我们这里来做一个示范,比如创建一条语法console.log("Hello,AST")。我们先把这条语句放在 https://astexplorer.net/ 中看下这条语句应该对应的AST结构。这里说个小的Tips,在做反混淆的过程中,经常需要反复对照 https://astexplorer.net/ 这个网站去分析AST结构。分析结果如下: image-20220701020813706 可以看到,这条JS语句主要包含4个主要的部分,整个JS代码是一条表达式,所以最外层是一个ExpressStatement,然后具体是什么表达式呢?是一个CallExpress即一个方法调用。然后这个方法调用包含2部分:MemberExpression和Arguments,console.log显然是一个成员表达式,而Hello,AST则是这个方法调用传入的参数。 分析完之后,编写代码如下: 123456789const type = require("@babel/types");const generator = require("@babel/generator").default;var args = [type.StringLiteral("Hello,AST")]; // 方法调用参数var callee = type.memberExpression(type.identifier("console"), type.identifier("log")); // 成员表达式分2个部分var call_exp = type.callExpression(callee, args); // 方法调用,第一个参数是方法名,第二个参数是方法调用参数var exp_statement = type.ExpressionStatement(call_exp); // 表达式console.log(generator(exp_statement)['code']); 运行结果如下: https://raw.githubusercontent.com/lyy077/blg-pic/main/pic/WX20220701-022717.png 插入nodeNodePath.insertAfter()方法用于在当前path前面插入节点,NodePath.insertBefore()方法用于在当前path后面插入节点。下面用一个实例来演示这2个方法的使用。 假设有一行代码为var a = 1;,我们的任务是在这行代码之前插入let b = "Hello,AST",在其之后插入const c = 2;。方法一样,首先把这三行代码放到 https://astexplorer.net/ 上面分析,具体的分析过程不过多描述了,直接给出代码: 123456789101112131415161718192021222324252627282930313233343536const fs = require('fs');const parser = require("@babel/parser");const traverse = require("@babel/traverse").default;const types = require("@babel/types");const generator = require("@babel/generator").default;let jscode = "var a = 1;"//转换为ast树let ast = parser.parse(jscode);const visitor = { VariableDeclaration(path) { // 定位到a节点 if (path.node.kind == 'var' && path.node.declarations[0].id.name == 'a') { var variableDeclarator = types.variableDeclarator(id=types.Identifier("b"), init=types.StringLiteral("Hello,AST")); var nodeBefore = types.VariableDeclaration(kind='let', declarations=[variableDeclarator]); path.insertBefore(nodeBefore); variableDeclarator = types.variableDeclarator(id=types.Identifier("c"), init=types.NumericLiteral(1)); var nodeAfter = types.VariableDeclaration(kind='const', declarations=[variableDeclarator]); path.insertAfter(nodeAfter); } }}//some function code//调用插件,处理源代码traverse(ast, visitor);//生成新的js code,并保存到文件中输出let {code} = generator(ast);console.log(code); 输出结果: https://raw.githubusercontent.com/lyy077/blg-pic/main/pic/WX20220701-030118.png 替换nodeNodePath.replaceInline 方法用于替换对应path的节点。我们依旧给出一个例子。比如有一条JS语句let a = 1,想把它变为let a = 2。 代码如下: 12345678910111213141516171819202122232425262728const fs = require('fs');const parser = require("@babel/parser");const traverse = require("@babel/traverse").default;const types = require("@babel/types");const generator = require("@babel/generator").default;let jscode = "var a = 1;"//转换为ast树let ast = parser.parse(jscode);const visitor = { NumericLiteral(path) { path.replaceInline(types.NumericLiteral(2)); // 防止递归插入 path.stop(); }}//some function code//调用插件,处理源代码traverse(ast, visitor);//生成新的js code,并保存到文件中输出let {code} = generator(ast);console.log(code); 运行结果如下: image-20220701104832811 删除nodeNodePath.remove()用于删除路径对应的节点,由于是对path操作,所以务必注意不要误删。同样地,以一案例来讲解下删除节点,话不多说,直接上代码: 12345678910111213141516171819202122232425262728293031const fs = require('fs');const parser = require("@babel/parser");const traverse = require("@babel/traverse").default;const types = require("@babel/types");const generator = require("@babel/generator").default;let jscode = `function sum(a, b) { var c = 1; return a + b;} `//转换为ast树let ast = parser.parse(jscode);const visitor = { VariableDeclaration(path) { path.remove(); }}//some function code//调用插件,处理源代码traverse(ast, visitor);//生成新的js code,并保存到文件中输出let {code} = generator(ast);console.log(code); 逻辑比较简单,遍历到变量定义的节点,然后调用path.remove删除即可。 运行结果: https://raw.githubusercontent.com/lyy077/blg-pic/main/pic/WX20220701-112346.png 作用域Scope 与 被绑定量Binding作用域Scope@Babel解析出来的语法树节点对象会包含作用域信息,这个信息会作为节点Node对象的一个属性保存,这个属性本身是一个Scope对象,其定义位于node_modules/@babel/traverse/lib/scope/index.js中。 查看基本作用域与绑定信息: 123456789101112131415161718192021const parser = require("@babel/parser");const traverse = require("@babel/traverse").default;const jscode = `function a() { return "Hello,AST";}function b() { return 1 + 2;}var c = "Wow";`;let ast = parser.parse(jscode);const visitor = { "FunctionDeclaration"(path){ console.log("\\n\\n这里是函数 ", path.node.id.name + '()') path.scope.dump(); }}traverse(ast, visitor); 执行 Scope.dump(),会得到自底向上的 作用域与变量信息,得到结果: https://raw.githubusercontent.com/lyy077/blg-pic/main/pic/WX20220701-113832.png 输出查看方法: 每一个作用域都以#标识输出 每一个绑定都以-标识输出 对于单次输出,都是自底向上的先输出当前作用域,再输出父级作用域,再输出父级的父级作用域…… 对于单个绑定Binding,会输出4种信息 constant 表示声明后,是否会被修改 references 指被引用次数 violations 则是被重新定义的次数 kind 是指函数声明类型。param 参数, hoisted 提升,var 变量, local 内部。 绑定 BindingBinding 对象用于存储绑定的信息,这个对象会作为Scope对象的一个属性存在,同一个作用域可以包含多个 Binding。你可以在 @babel/traverse/lib/scope/binding.js 中查看到它的定义。 查看Binding信息: 1234567891011121314151617181920212223242526272829303132const parser = require("@babel/parser");const traverse = require("@babel/traverse").default;const jscode = `function a() { var m; m++; return m;}function b() { let c = a() + 1; return c;}var c = "Wow";`;let ast = parser.parse(jscode);const visitor = { BlockStatement(path) { var bindings = path.scope.bindings; for (let binding in bindings) { console.log("binding name: " + binding); binding = bindings[binding]; console.log("binding type: " + binding.kind); console.log("binding constant: " + binding.constant); console.log("binding constantViolations: " + binding.constantViolations); console.log("binding referenced: " + binding.referenced); console.log("binding references: " + binding.references); } }}traverse(ast, visitor); 输出信息如下: https://raw.githubusercontent.com/lyy077/blg-pic/main/pic/WX20220701-115258.png 可以看到,变量m类型是var,有被引用,且被引用次数是2;变量c则是let类型,也有被引用,被引用次数是1。 关于作用域与绑定的关系?一个代码块(比如函数,循环,逻辑判断分支等)就是一个作用域,而定义在作用域里面的变量就是一个绑定,绑定是依附在作用域上。 关于学习AST相关的资源整理 信息 地址 AST在线解析 https://astexplorer.net/ babel中文文档 https://www.babeljs.cn/docs/ babel英文文档 https://babeljs.io/docs/en/ Github https://github.com/babel/babel 插件手册 https://blog.csdn.net/weixin_33826609/article/details/93164633#toc-visitors babel各节点解释 https://github.com/babel/babylon/blob/master/ast/spec.md babel简单剖析 http://www.alloyteam.com/2017/04/analysis-of-babel-babel-overview/ 淘宝前端团队写的babel相关 https://fed.taobao.org/blog/taofed/do71ct/babel-plugins/ babel到底将代码转换成什么 http://www.alloyteam.com/2016/05/babel-code-into-a-bird-like/ babel在线源码 https://doc.esdoc.org/github.com/mason-lang/esast/class/src/ast.js~VariableDeclarator.html","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"AST","slug":"AST","permalink":"http://example.com/tags/AST/"}]},{"title":"AST解JS混淆之去掉未被使用的变量","date":"2022-05-28T12:32:08.000Z","path":"AST解JS混淆之去掉未被使用的变量/","text":"在JS混淆的过程中,加入很多无辜的变量,从头到尾都没有使用过,这样子可以冗余一部分代码,达到混淆视听的目的,这也是JS混淆的一种常见手段。那么如何去除这些“无辜”的变量呢?这就是本篇文章需要讨论的主题。 比如下面一段代码,显然变量n和全局变量c是可以删除的。 12345678910function a() { var m, n; m++; return m;}function b() { let c = a() + 1; return c;}var c = "Wow"; 删除没有使用的变量,核心代码如下: 12345678910111213141516const visitor = { VariableDeclarator(path) { const {id} = path.node; // 获取binding信息 const binding = path.scope.getBinding(id.name); // 如果变量被修改,则不能删除 if (!binding || binding.constantViolations.length > 0) { return; } // 如果变量没有被引用,则可以删除 if (binding.referencePaths.length === 0) { path.remove(); } }} 如果有阅读前面写过的一篇文章 AST解JS混淆之AST基础,了解了作用域与Binding,则上面的代码并不难理解。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"AST","slug":"AST","permalink":"http://example.com/tags/AST/"}]},{"title":"AST解JS混淆之删除所有注释","date":"2022-05-27T15:20:29.000Z","path":"AST解JS混淆之删除所有注释/","text":"当代码中有成段成段的注释,但是我们又不需要的时候,可以采用如下代码去删除JS源代码中的注释: 1const output = generator(ast, opts={"comments": false}, code); 测试如下: 例如源代码为: 1234567891011121314151617/*这是多行测试,第一行这是多行测试,第二行这是多行测试,第三行*/var a = "你好AST"; // 这也是单行测试// 这是单行测试let b = a + 1; /*这也是单行测试*/console.log(/*这是代码之间的测试*/b);/*这也是多行测试,第一行这也是多行测试,第二行这也是多行测试,第三行*/ 下面是输出: 123var a = "你好AST";let b = a + 1;console.log(b);","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"AST","slug":"AST","permalink":"http://example.com/tags/AST/"}]},{"title":"AST解JS混淆之还原中文的Unicode编码","date":"2022-05-27T14:56:32.000Z","path":"AST解JS混淆之还原中文的Unicode编码/","text":"前面一篇文章 AST解混淆之处理数值与字符串 介绍过unicode或者utf8编码的字符串还原的方法,但是如果这个字符串是中文,这个方法并不会奏效,比如有下面这段代码: 1var a = "\\u4f60\\u597d\\u0041\\u0053\\u0054"; 经过前面的插件处理之后,结果为: 1var a = "\\u4F60\\u597DAST"; 可以看到unicode编码的中文并没有还原。 要想还原unicode编码的中文,必须用到generate函数有个 options选项,下面是这个选项能完成的功能: 图片 所以修改我们的插件代码如下: 1234567891011121314151617181920212223242526272829303132333435363738//babel库及文件模块导入const fs = require('fs');//babel库相关,解析,转换,构建,生产const parser = require("@babel/parser");const traverse = require("@babel/traverse").default;const types = require("@babel/types");const generator = require("@babel/generator").default;//读取文件if (process.argv.length < 4) { console.log("Usage: node ${file}.js ${encode}.js ${decode}.js"); process.exit(1);}let input_file = process.argv[2], output_file = process.argv[3];let jscode = fs.readFileSync(input_file, {encoding: "utf-8"});//转换为ast树let ast = parser.parse(jscode);const visitor = { StringLiteral({node}) { if (node.extra && /\\\\[ux]/gi.test(node.extra.raw)) { node.extra = undefined; } },}//some function code//调用插件,处理源代码traverse(ast, visitor);//生成新的js code,并保存到文件中输出let {code} = generator(ast, opts = {jsescOption:{"minimal":true}});fs.writeFile(output_file, code, (err)=>{}); 运行结果: https://raw.githubusercontent.com/lyy077/blg-pic/main/pic/WX20220630-201530.png","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"AST","slug":"AST","permalink":"http://example.com/tags/AST/"}]},{"title":"AST解JS混淆之删除空行与空语句","date":"2022-05-27T13:59:20.000Z","path":"AST解JS混淆之删除空行与空语句/","text":"本文介绍如何删除空语句。有时候将源代码利用AST调整后,会有很多类似这样的代码: 123var a = 123;;var b = 456; 其中,中间的 ; 这一行是没必要存在了,那如何编写插件删除没啥用的这行呢? 同样的方法,先将这段代码放入 https://astexplorer.net/ 解析网站看看: image-20220630191917900 解析如上图。这里介绍一个小技巧:当代码行数过多时?将鼠标移动到我们想要迅速观察的那一行代码上 ,解析网站自动帮我们定位到这一行代码的AST结构。 可以看到它是一个 EmptyStatement,想要删除这个节点,方法很简单,直接遍历这个节点,再调用 remove 方法即可,代码如下: 12345const visitor = { EmptyStatement(path) { path.remove(); },} 就是这么的简单。 看下运行结果: image-20220630192204612","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"AST","slug":"AST","permalink":"http://example.com/tags/AST/"}]},{"title":"AST解JS混淆之处理数值与字符串","date":"2022-05-27T12:29:25.000Z","path":"AST解JS混淆之处理数值与字符串/","text":"比如有下面一段代码: 1234567m7z = { '\\x67\\x74': V7z[M9r.C8z(190)][M9r.C8z(189)], '\\x63\\x68\\x61\\x6c\\x6c\\x65\\x6e\\x67\\x65': V7z[M9r.R8z(190)][M9r.R8z(425)], '\\x77': r7z + H7z,};var a = 0x25,b = 0b10001001,c = 0o123456, e = "\\u0068\\u0065\\u006c\\u006c\\u006f\\u002c\\u0041\\u0053\\u0054"; 这种把字符串处理成unicode或者utf8编码,把数字处理成非10进制,不利于我们进行调试,我们可以对其进行反混淆,变成我们更加习惯的编码或者进制。 观察AST结构: image-20220630155927641 image-20220630160915492 可以看到在extra节点中的raw是utf-8编码的,而value的值是正常的。官网手册查询得知,NumericLiteral、StringLiteral类型的extra节点并非必需,这样在将其删除时,不会影响原节点。所以一种通用的解决方案是直接删除extra节点即可。 所以解混淆插件代码如下: 123456789101112131415const visitor ={ //TODO write your code here! NumericLiteral({node}) { if (node.extra && /^0[obx]/i.test(node.extra.raw)) { node.extra = undefined; } }, StringLiteral({node}) { if (node.extra && /\\\\[ux]/gi.test(node.extra.raw)) { node.extra = undefined; } },} 遍历遇到NumericLiteral节点时,判断extra是否为二进制,八进制,十六进制,如果是的话直接置空;同理,遍历遇到StringLiteral节点时,判断其extra是否为unicode编码或者utf8编码,如果是的话也置空。 最后解混淆的结果如下: image-20220630161728547","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"AST","slug":"AST","permalink":"http://example.com/tags/AST/"}]},{"title":"AST解JS混淆之反混淆代码模版","date":"2022-05-26T14:32:54.000Z","path":"AST解JS混淆之反混淆代码模版/","text":"话不多说,直接上模版: 12345678910111213141516171819202122232425262728293031323334//babel库及文件模块导入const fs = require('fs');//babel库相关,解析,转换,构建,生产const parser = require("@babel/parser");const traverse = require("@babel/traverse").default;const types = require("@babel/types");const generator = require("@babel/generator").default;//读取文件if (process.argv.length < 4) { console.log("Usage: node ${file}.js ${encode}.js ${decode}.js"); process.exit(1);}let input_file = process.argv[2], output_file = process.argv[3];let jscode = fs.readFileSync(input_file, {encoding: "utf-8"});//转换为ast树let ast = parser.parse(jscode);const visitor = { //TODO write your code here!}//some function code//调用插件,处理源代码traverse(ast, visitor);//生成新的js code,并保存到文件中输出let {code} = generator(ast);fs.writeFile(output_file, code, (err)=>{}); 上面代码保存成一个文件,比如decode_obfuscator.js ,然后执行以下命令即可完成反混淆: 1node decode_obfuscator.js input.js output.js input.js表示待解混淆的文件,output.js表示解混淆之后保存的结果文件。 接着看看decode_obfuscator.js文件的内容: 主要分三步,第一步是引入parser模块,并调用相关的方法把JS源码转化为AST;第二步,对AST进行遍历,处理相应的节点,主要用到的是babel库的traverse模块;第三步是调用generator模块的相关方法,把AST还原成JS代码。其中第二步可以调用多次traverse,编写多个visitor,因为下一次遍历AST可能需要上一次遍历并处理之后的结果。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"AST","slug":"AST","permalink":"http://example.com/tags/AST/"}]},{"title":"一文教你如何利用AST还原JS混淆","date":"2022-04-25T15:06:51.000Z","path":"一文教你如何利用AST还原JS混淆/","text":"如果不了解JS混淆原理以及常见的混淆手段,可以戳我以前写过的2篇文章:JS代码安全防护原理——AST混淆原理 和 JS逆向之代码混淆的原理 开篇先回答一个问题,为什么需要反混淆?因为一般具有防护的JS代码都会经过混淆处理,虽然经过混淆的代码完全可以不处理混淆依旧对其进行逆向,但是由于字符串,数字都是混淆的,同时源码中冗余了很多无关的代码,控制流程平台化的存在更是让我们在代码的阅读上有了很大的障碍,这样下来导致逆向源码耗费的时间需要成倍的增加。如果有一种方式,能够让我们获得的源码就是高可阅读性,也没有冗余的代码,能帮我们不止一点点地提高逆向源码的效率,你学还是不学? 什么是AST?所谓磨刀不误砍材功,既然反混淆是必要的,那跟AST有啥关系?不着急,容我慢慢解释。试想一下,要反混淆的话,是不是得处理JS源码?对于源码而言,如果只是一个文本文件,是不是非常不好处理(变量,常量,函数,分支什么的都是分散的)?所以我们必须把源码预处理成一种利于解混淆的形式,比如JSON格式?如果大学有接触过编译原理的话,一定有接触一个概念叫做语法树。没错,AST(Abstract Syntax Tree),中文抽象语法树,简称语法树(Syntax Tree),是源代码的抽象语法结构的树状表现形式,树上的每个节点都表示源代码中的一种结构。 让我们看看AST长啥样?打开AST提供的一个解析网站:https://astexplorer.net/ ,其顶部可以选择语言,编译器。语法树没有单一的格式,选择不同的语言、不同的编译器,得到的结果也是不一样的,在 JavaScript 中,编译器有 Acorn、Espree、Esprima、Recast、Uglify-JS 等,使用最多的是 Babel,后续的学习也是以 Babel 为例。如下图: image-20220629155332110 Babel简介Babel 是一个 JavaScript 编译器,也可以说是一个解析库。Babel 内置了很多分析 JavaScript 代码的方法,我们可以利用 Babel 将 JavaScript 代码转换成 AST 语法树,然后增删改查等操作之后,再转换成 JavaScript 代码。 在做逆向解混淆中,主要用到了 Babel 的以下几个功能包,本文也仅介绍以下几个功能包: @babel/core:Babel 编译器本身,提供了 babel 的编译 API; @babel/parser :将 JavaScript 代码解析成 AST 语法树; @babel/traverse: 遍历、修改 AST 语法树的各个节点; @babel/generator:将 AST 还原成 JavaScript 代码; @babel/types:判断、验证节点的类型、构建新 AST 节点等。 用一张图说明上面各个模块的功能: 640 babel库的安装安装完NodeJS之后,使用命令npm install @babel/core进行安装即可。 @babel/coreBabel 编译器本身,被拆分成了三个模块:@babel/parser、@babel/traverse、@babel/generator,比如以下方法的导入效果都是一样的: 12345const parse = require("@babel/parser").parse;const parse = require("@babel/core").parse;const traverse = require("@babel/traverse").defaultconst traverse = require("@babel/core").traverse @babel/parser@babel/parser 可以将 JavaScript 代码解析成 AST 语法树,其中主要提供了两个方法: parser.parse(code, [{options}]):解析一段 JavaScript 代码; parser.parseExpression(code, [{options}]):考虑到了性能问题,解析单个 JavaScript 表达式。 部分可选参数 options: 参数 描述 allowImportExportEverywhere 默认 import 和 export 声明语句只能出现在程序的最顶层,设置为 true 则在任何地方都可以声明 allowReturnOutsideFunction 默认如果在顶层中使用 return 语句会引起错误,设置为 true 就不会报错 sourceType 默认为 script,当代码中含有 import 、export 等关键字时会报错,需要指定为 module errorRecovery 默认如果 babel 发现一些不正常的代码就会抛出错误,设置为 true 则会在保存解析错误的同时继续解析代码,错误的记录将被保存在最终生成的 AST 的 errors 属性中,当然如果遇到严重的错误,依然会终止解析 好了,看完理论知识,来实践下: 12345const parser = require("@babel/parser");const code = "var a = '\\u0068\\u0065\\u006c\\u006c\\u006f\\u002c\\u0041\\u0053\\u0054'; \\n console['log'](a);";const ast = parser.parse(code, {sourceType: "module"})console.log(ast) 执行,结果如下,可以看到跟在 https://astexplorer.net/ 中看到的是一致的。 image-20220629171249989 @babel/generator@babel/generator 可以将 AST 还原成 JavaScript 代码,提供了一个 generate 方法:generate(ast, [{options}], code)。 部分可选参数 options: 参数 描述 auxiliaryCommentBefore 在输出文件内容的头部添加注释块文字 auxiliaryCommentAfter 在输出文件内容的末尾添加注释块文字 comments 输出内容是否包含注释 compact 输出内容是否不添加空格,避免格式化 concise 输出内容是否减少空格使其更紧凑一些 minified 是否压缩输出代码 retainLines 尝试在输出代码中使用与源代码中相同的行号 我们运行一段下面的代码: 123456789101112131415const parser = require("@babel/parser");const generate = require("@babel/generator").defaultconst code = "var a = 'Hello, world'";const ast = parser.parse(code, {sourceType: "module"})// 修改id名为bast.program.body[0].declarations[0].id.name = "b";// 修改变量值为Helloast.program.body[0].declarations[0].init.value = "Hello";// generate模块将AST语法树转换成JS代码const result = generate(ast, {minified: true})console.log(result.code) 最终输出结果var b="Hello";,变量名和值都成功更改了,由于加了压缩处理,等号左右两边的空格也没了。 代码里 {minified: true} 演示了如何添加可选参数,这里表示压缩输出代码,generate 得到的 result 得到的是一个对象,其中的 code 属性才是最终的 JS 代码。 代码里 ast.program.body[0].declarations[0].id.name 是 a 在 AST 中的位置,ast.program.body[0].declarations[0].init.value 是 a的值即字符串Hello 在 AST 中的位置,如下图所示: image-20220629175017218 @babel/traverse当代码多了,我们不可能像前面那样挨个定位并修改,对于相同类型的节点,我们可以直接遍历所有节点来进行修改,这里就用到了 @babel/traverse,它通常和 visitor 一起使用,visitor 是一个对象,这个名字是可以随意取的,visitor 里可以定义一些方法来过滤节点,这里还是用一个例子来演示: 123456789101112131415161718192021const parser = require("@babel/parser");const generate = require("@babel/generator").defaultconst traverse = require("@babel/traverse").defaultconst code = "var a = 'Hello, world'";const ast = parser.parse(code, {sourceType: "module"})const visitor = { Identifier(path) { path.node.name = path.node.name == 'a' ? 'a' : 'b'; }, StringLiteral(path){ path.node.value = "I Love JavaScript!" }}traverse(ast, visitor)const result = generate(ast, {minified: true})console.log(result.code) 输出为var b="I Love JavaScript!";,通过AST语法树,将变量a更名为变量b,同时将其值修改为”I Love JavaScript!”。 我们看看代码主逻辑,首先定义一个visitor,然后定义对应类型的处理方法,traverse 接收两个参数,第一个是 AST 对象,第二个是 visitor,当 traverse 遍历所有节点,遇到节点类型为 StringLiteral 和 Identifier 时,就会调用 visitor 中对应的处理方法。 之所以定义StringLiteral和Identifier两种类型,是因为在抽象语法树中变量a是一个标识符,其类型为Identifier,而a的值是一个字符串,其类型为StringLiteral。visitor 中的方法会接收一个当前节点的 path 对象,该对象的类型是 NodePath,该对象有非常多的属性,以下介绍几种最常用的: 属性 描述 toString() 当前路径的源码 node 当前路径的节点 parent 当前路径的父级节点 parentPath 当前路径的父级路径 type 当前路径的类型 path 对象除了有很多属性以外,还有很多方法,比如替换节点、删除节点、插入节点、寻找父级节点、获取同级节点、添加注释、判断节点类型等,可在需要时查询相关文档或查看源码,后续介绍 @babel/types 部分将会举部分例子来演示,以后的实战文章中也会有相关实例。 如果多个类型的节点,处理的方式都一样,那么还可以使用 | 将所有节点连接成字符串,将同一个方法应用到所有节点: 123456const visitor = { "Identifier|StringLiteral"(path) { path.node.name = path.node.name == 'a' ? 'a' : 'b'; path.node.value = path.node.name == 'a' ? path.node.value : "I Love JavaScript!" }} visitor 对象有多种写法,以下几种写法的效果都是一样的: 1234567891011121314151617181920212223242526272829303132333435363738394041const visitor = { Identifier(path) { path.node.name = path.node.name == 'a' ? 'a' : 'b'; }, StringLiteral(path){ path.node.value = "I Love JavaScript!" }}const visitor = { Identifier: function(path) { path.node.name = path.node.name == 'a' ? 'a' : 'b'; }, StringLiteral: function(path){ path.node.value = "I Love JavaScript!" }}const visitor = { Identifier: { enter(path) { path.node.name = path.node.name == 'a' ? 'a' : 'b'; } }, StringLiteral: { enter(path){ path.node.value = "I Love JavaScript!" } }}const visitor = { enter(path) { if (path.node.type == "Identifier") { path.node.name = path.node.name == 'a' ? 'a' : 'b'; } else if (path.node.type === "StringLiteral") { path.node.value = "I Love JavaScript!" } }} 以上几种写法中有用到了 enter 方法,在节点的遍历过程中,进入节点(enter)与退出(exit)节点都会访问一次节点,traverse 默认在进入节点时进行节点的处理,如果要在退出节点时处理,那么在 visitor 中就必须声明 exit 方法。 @babel/types前面提到过,babel/types主要2个作用,一是包含了许多节点的类型,经常用来做类型判断。比如: 123456types.stringLiteral("Hello World"); // stringtypes.numericLiteral(100); // numbertypes.booleanLiteral(true); // booleantypes.nullLiteral(); // nulltypes.identifier(); // undefinedtypes.regExpLiteral("\\\\.js?$", "g"); // 正则 输出如下: 123456"Hello World"100truenullundefined/\\.js?$/g 另一个作用是构建新的 AST 节点。通过一个简单的例子解释下吧。比如我们有如下代码 1const a="Hello"; 想在这句常量的定义之后插入let b=1; 这段代码,变成如下: 12const a="Hello";let b=1; 我们在 https://astexplorer.net/ 中输入这2行代码,然后对照着看下代码该如何写? image-20220629221958113 可以看到增加了一个变量声明之后,会在body下面增加一个VariableDeclaration节点。所以我们编写visitor的时候,相应的代码也应该在VariableDeclaration下面。 我们的思路就是在遍历节点时,遍历到 VariableDeclaration 节点,就在其后面增加一个 VariableDeclaration 节点,生成 VariableDeclaration 节点,可以使用 types.variableDeclaration() 方法,在 types 中各种方法名称和我们在 AST 中看到的是一样的,只不过首字母是小写的,所以我们不需要知道所有方法的情况下,也能大致推断其方法名,只知道这个方法还不行,还得知道传入的参数是什么,可以查文档。 通过文档,可以看到variableDeclarator需要kind 和 declarations 两个参数,其中 declarations 是 VariableDeclarator 类型的节点组成的列表。kind表示声明的类型,比如const, var, let。这里顺便问下为什么declarations是一个列表呢?因为JS是支持同时声明多个变量的,比如let a, b, c = 1,显然,declarations有多个。 接下来我们还需要进一步定义 declarator,也就是 VariableDeclarator 类型的节点。调用的是variableDeclarator方法,这个方法接受2个参数,第一个是类型,我们传入types.identifier("b"),表示一个名为b的标识符,第二个参数是变量的初始化方式,这里我们传入types.numericLiteral(1),因为我们是要给b赋值一个数值类型1。 最后我们要指明这条赋值表达式的插入位置,path.insertAfter() 插入节点语句后面加了一句 path.stop(),表示插入完成后立即停止遍历当前节点和后续的子节点,添加的新节点也是 VariableDeclaration,如果不加停止语句的话,就会无限循环插入下去。 完整的代码如下: 1234567891011121314151617181920212223const parser = require("@babel/parser");const generate = require("@babel/generator").defaultconst traverse = require("@babel/traverse").defaultconst types = require("@babel/types");const code = "const a = 'Hello'";const ast = parser.parse(code, {sourceType: "module"})const visitor = { VariableDeclaration(path){ let init = types.numericLiteral(1); // 变量的初始化方式 let declarator = types.variableDeclarator(types.identifier("b"), init) // 声明变量名 let declaration = types.variableDeclaration("let", [declarator]); // 声明类型 path.insertAfter(declaration) // 插入位置 path.stop() }}traverse(ast, visitor)const result = generate(ast, {minified: true})console.log(result.code) 小案例最后通过一个小案例来为这篇入门文章结尾吧。 我们有如下一段代码: 1234var a = "\\x44\\x61\\x74\\x65";const b = "\\u0068\\u0065\\u006c\\u006c\\u006f\\u002c\\u0041\\u0053\\u0054";let c = 0b10001001;let d = 0o123456; 这些数字或者字符串看起来非常不方便,所以我们需要对其进行解混淆处理,解混淆代码如下: 12345678910111213141516171819202122232425262728293031const parser = require("@babel/parser");const generate = require("@babel/generator").defaultconst traverse = require("@babel/traverse").defaultconst types = require("@babel/types");const code = "var a = '\\x44\\x61\\x74\\x65'; \\const b = '\\u0068\\u0065\\u006c\\u006c\\u006f\\u002c\\u0041\\u0053\\u0054'; \\let c = 0b10001001; \\let d = 0o123456;";const ast = parser.parse(code, {sourceType: "module"})const visitor = { NumericLiteral({node}) { if (node.extra && /^0[obx]/i.test(node.extra.raw)) { node.extra = undefined; } }, StringLiteral({node}) { if (node.extra && /\\\\[ux]/gi.test(node.extra.raw)) { node.extra = undefined; } },}traverse(ast, visitor)const result = generate(ast, {minified: true})console.log(result.code) 解混淆结果如下: image-20220629224644829","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"AST","slug":"AST","permalink":"http://example.com/tags/AST/"}]},{"title":"JS逆向案例——天眼查过极验滑块验证码","date":"2022-04-24T11:21:03.000Z","path":"JS逆向案例——天眼查过极验滑块验证码/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 前言本文是机器过极验滑块验证码系列文章的第四篇,通过一个实际网站介绍如何利用逆向分析自动过极验滑块,极验滑块系列包含乱序底图还原,验证码w参数生成,补环境,利用像素点RGB差值获取缺口位置以及通过机器学习获取缺口位置。而极验滑块系列只是验证码系列的第一个系列,后边会罗列市面上常用的验证码,然后发文一一解决。 上一篇文章见:JS逆向案例——极验滑块验证码补环境 模拟登录天眼查极验滑块验证码与登录接口的关系网址:aHR0cHM6Ly93d3cudGlhbnlhbmNoYS5jb20v 以天眼查的登录为例,在进行滑块验证时,进行抓包分析。 在极验系列第一篇 JS逆向案例——极验滑块验证码底图还原 中,说过geetest的几个请求之间的关系,这里重新梳理一下,然后顺便梳理下geetest请求与天眼查登录接口的关系。如下图: image-20220424224028967 可以看到每一次请求都是环环相扣,一共发起5次请求,下一次请求的入参或多或少都用到上一次请求的返回。 第一次请求封装一个geetest_xhtml请求,用于获取challenge和gt,代码如下: 12345678910111213141516171819202122import timeimport mathimport requestsdef geetest_xhtml(): cookies = { # cookie值 'acw_sc__v2': 'acw_sc__v2的值' } headers = { # header值 } json_data = { 'uuid': math.floor(time.time() * 1000), } response = requests.post('https://www.tianyancha.com/verify/geetest.xhtml', cookies=cookies, headers=headers, json=json_data) return response.text cookie值里面仅仅需要一个acw_sc__v2,而acw_sc__v2的值是有时效性的,所以在实际生产中肯定也要逆向去动态生成这个cookie值,但是本文重点放在极验滑动验证码上,所以cookie直接拷贝,并没有细究cookie是如何生成的,后边有空了可以回过头了看看这个cookie是如何生成的。 运行结果如下: image-20220425001609069 第二次请求封装一个get_type请求,入参是第一个请求返回的gt,用正则匹配get_type返回的path和type,这两个值将在下一个请求中用到,由于这里只是做一个简单的演示,所以并未考虑程序的健壮性,写的有点糙,略略略。 123456789101112131415def get_type(gt): headers = { # header值 } params = { 'gt': gt, 'callback': f'geetest_{math.floor(time.time() * 1000)}', } response = requests.get('https://api.geetest.com/gettype.php', params=params, headers=headers) print(response.text) path = re.findall('\\"path\\": \\"(.*?)\\"', response.text)[0] _type = re.findall('\\"type\\": \\"(.*?)\\"', response.text)[0] return path, _type 代码运行结果如下: image-20220425003036734 第三次请求封装一个get请求,如参是第一次请求返回的gt和challenge以及第二次请求返回的type和path,该请求返回新的challenge(实际上在之前的challenge后边拼接了2个字符串),c,s,bg,fullbg。 123456789101112131415161718192021222324def get(gt, challenge, _type, path): url = f"https://api.geetest.com/get.php?gt={gt}&challenge={challenge}&product=popup&offline=false&" \\ f"protocol=https://&type={_type}&path={path}&callback=geetest_{math.floor(time.time() * 1000)}" headers = { 'authority': 'api.geetest.com', 'accept': '*/*', 'accept-language': 'zh-CN,zh;q=0.9', 'cache-control': 'no-cache', 'pragma': 'no-cache', 'referer': 'https://www.tianyancha.com/', 'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"macOS"', 'sec-fetch-dest': 'script', 'sec-fetch-mode': 'no-cors', 'sec-fetch-site': 'cross-site', 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36', 'Cookie': 'GeeTestAjaxUser=feae0be0cdd2042a5e570e9d3245e949; GeeTestUser=52dc984495fafeb0d018864b96edccd9' } response = requests.get(url, headers=headers) return response.text 运行结果如下: image-20220425111939144 第四次请求第四次请求需要滑块的轨迹,轨迹数组需要通过滑块缺口的差值来生成,要想计算滑块缺口的差值,需要还原滑块底图。 滑块底图还原底图还原参考文章: JS逆向案例——极验滑块验证码底图还原 核心代码为: 1234567891011121314def restore_pic(pic_path, new_pic_path): unordered_pic = Image.open(pic_path) ordered_pic = unordered_pic.copy() # 裁剪并拼接 for i, d in enumerate(div_offset): im = unordered_pic.crop((math.fabs(d['x']), math.fabs(d['y']), math.fabs(d['x']) + 10, math.fabs(d['y']) + 58)) # 上半区 if d['y'] != 0: ordered_pic.paste(im, (10 * (i % (len(div_offset) // 2)), 0), None) else: ordered_pic.paste(im, (10 * (i % (len(div_offset) // 2)), 58), None) ordered_pic.save(new_pic_path) 缺口计算缺口计算的方式基本上分2种,一种是图片处理,计算图片的每个像素点位置的色差去判断缺口;一种是通过深度学习去识别缺口位置。会专门出一篇文章计算这2种方式。这里先贴第一种方式的代码: 123456789101112131415161718def diff_rgb(rgb1, rgb2): return math.fabs(rgb1[0] - rgb2[0]) + math.fabs(rgb1[1] - rgb2[1]) + math.fabs(rgb1[2] - rgb2[2]) > 255def get_moving_dst(complete_pic_path, incomplete_pic_path): complete_pic = Image.open(complete_pic_path) incomplete_pic = Image.open(incomplete_pic_path) w, h = complete_pic.size for i in range(0, w): for j in range(0, h): complete_pic_pixel_rgb = complete_pic.getpixel((i, j)) incomplete_pic_pixel_rgb = incomplete_pic.getpixel((i, j)) if diff_rgb(complete_pic_pixel_rgb, incomplete_pic_pixel_rgb): return i return 0 生成轨迹数组在网上找了一些轨迹算法,效果似乎不太好。就想着搭建一个本地的轨迹库,然后在实际使用的时候根据滑动距离从本地库中去拿。 具体思路是: 用Flask搭建一个轨迹收集服务 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647import jsonimport mathimport picklefrom flask import Flask, requestfrom flask_cors import cross_originapp = Flask(__name__)@app.post("/track")@cross_origin(supports_credentials=True, methods="*", allow_headers="*")@cross_origin()def track(): tracks = pickle.load(open("tracks.pkl", "rb")) d = json.loads(request.data.decode()) print(d) # 下载乱序的缺口图和完整图 download_image(d['bg'], "bg.png") download_image(d['fullbg'], "fullbg.png") # 还原乱序的缺口图和完整图 restore_pic("bg.png", "new_bg.png") restore_pic("fullbg.png", "new_fullbg.png") # 获取缺口的位置 x = get_moving_dst("new_bg.png", "new_fullbg.png") if tracks.get(x): tracks[x].append({'track': d['track'], 'g7z': d['g7z']}) else: tracks[x] = [{'track': d['track'], 'g7z': d['g7z']}] pickle.dump(tracks, open("tracks.pkl", "wb")) return 'ok'def download_image(url, image_file): with open(image_file, "wb") as f: f.write(requests.get(url).content)if __name__ == '__main__': track_data = {} pickle.dump(track_data, open("tracks.pkl", "wb")) app.run(host='0.0.0.0', port=8088) 先手动通过滑块验证100-200次,收集起来每一次g7z(g7z为实际滑动的距离)对应的轨迹方程。 使用 12345678910111213const Http = new XMLHttpRequest();const url='http://127.0.0.1:8088/track';Http.open("POST", url);Http.onload = function () { console.log("请求成功, track: " + JSON.stringify(X1z)); // 请求结束后,在此处写处理代码};Http.send(JSON.stringify({ track: X1z, bg: /\\"(.*?)\\"/g.exec(document.getElementsByClassName("gt_cut_bg_slice")[0].style.backgroundImage)[1], fullbg: /\\"(.*?)\\"/g.exec(document.getElementsByClassName("gt_cut_fullbg_slice")[0].style.backgroundImage)[1], g7z})); 在返回加密轨迹数组的之前去调用Flask服务,这样拿到的轨迹数组就是最终的轨迹数组,从而可以收集正确的轨迹数组。如下图: image-20220428155854981 通过手动过滑块就可以把轨迹数组收集到tracks.pkl文件了。 生成w参数w参数逆向分析与其服务搭建参考:JS逆向案例——极验滑块验证码w参数生成 和 JS逆向案例——极验滑块验证码补环境 鉴于我们早已经搭建好了w请求服务,这里直接调用接口就好了,代码如下: 123456789101112def get_w(gt, challenge, tracks, c, s, v): url = "http://127.0.01:8081/geetest/w" data = { "tracks": json.dumps(tracks), "c": json.dumps(c), "s": s, "challenge": challenge, "gt": gt, "v": v } response = requests.post(url, json=data) return response.json() 运行结果如下: image-20220425153143590 进行滑块的验证进行滑块验证的请求很简单: 1234567891011121314151617181920def ajax(gt, challenge, w): url = f'https://api.geetest.com/ajax.php?gt={gt}&challenge={challenge}&w={w}&callback=geetest_{t}' headers = { 'authority': 'api.geetest.com', 'accept': '*/*', 'accept-language': 'zh-CN,zh;q=0.9', 'cache-control': 'no-cache', 'pragma': 'no-cache', 'referer': 'https://www.tianyancha.com/', 'sec-ch-ua': '" Not A;Brand";v="99", "Chromium";v="100", "Google Chrome";v="100"', 'sec-ch-ua-mobile': '?0', 'sec-ch-ua-platform': '"macOS"', 'sec-fetch-dest': 'script', 'sec-fetch-mode': 'no-cors', 'sec-fetch-site': 'cross-site', 'user-agent': 'Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36', } response = requests.get(url, headers=headers) return response.text 就一个get请求,带上gt,challenge和w参数就行。 滑块验证的核心逻辑,主要调用前面写好的几个方法: 12345678910111213141516171819202122232425262728293031323334353637383940def main(tracks): # 获取gt, challenge d = geetest_xhtml() gt = d['data']['gt'] challenge = d['data']['challenge'] # 获取path, type path, _type = get_type(gt) m = get(gt, challenge, _type, path) # 下载乱序的缺口图和背景图 url = "https://static.geetest.com/" unordered_complete_pic = urljoin(url, m['fullbg']) unordered_incomplete_pic = urljoin(url, m['bg']) with open("image1.png", "wb") as f: f.write(requests.get(unordered_complete_pic).content) with open("image2.png", "wb") as f: f.write(requests.get(unordered_incomplete_pic).content) # 还原乱序图 restore_pic("image1.png", "new_image1.png") restore_pic("image2.png", "new_image2.png") # 获取缺口位置 dist = get_moving_dst("new_image1.png", "new_image2.png") # 从本地的tracks.pkl中读取轨迹数组 if tracks.get(dist): track = tracks[dist][0]['track'] g7z = tracks[dist][0]['g7z'] else: # 如果未拿到轨迹数组,直接返回 return # 调用Express服务获取w参数 w = get_w(m['gt'], m['challenge'], track, m['c'], m['s'], m['version'], g7z) # 进行验证 validator = ajax(w['gt'], w['challenge'], w['w']) print(validator) 运行结果: image-20220428160923233 登录天眼查别忘了我们这篇文章的最终目的是模拟登录天眼查,拿到滑块验证成功返回的validate之后,要请求天眼查的登录接口,先看下请求接口的代码如下: 1234567891011121314151617181920212223def login(mobile, password, challenge, validator): cookies = { 'acw_sc__v2': 'acw_sc__v2的值', } headers = { # header值 } json_data = { 'mobile': mobile, 'cdpassword': password, 'loginway': 'PL', 'autoLogin': False, 'type': '', 'challenge': challenge, 'validate': validator, 'seccode': f'{validator}|jordan', } response = requests.post('https://www.tianyancha.com/cd/login.json', cookies=cookies, headers=headers, json=json_data) return response.text 就是一个post请求,没什么说的。 接着在主程序main方法的末尾加入调用这个login的代码: 1234if validator: validate = re.findall("\\"validate\\": \\"(.*?)\\"", validator)[0] login_res = login('17777777777', '202cb962ac59075b964b07152d234b70', m['challenge'], validate) return login_res 还是前面提到的,我们的主要目的放在极验验证上,所以这里cookie的acw_sc__v2值跟前面一样,复制下来就可,只不过这个cookie和第一个请求的cookie保持一致就行。另外,password也是一个加密的字符串,32位很符合md5码的特征,但是管它是啥呢?我们复制过来用就行。还有一点要注意的是,challenge要传get请求返回过来的,不要传第一个请求返回的。 程序运行的结果如下: image-20220428163341351 提示密码错误,很显然,登录接口成功了。 总结极验滑块的总算是成功了,暂时告一段落。我们是针对6.0.9这个版本的极验js文件进行的逆向,小版本的极验逻辑都差不多。大版本的可能区别会大一些,不过思路也差不多。极验的最新版本好像是4,后边会看看4这个大版本有什么不同。然后除了极验的滑块,还会试着看看能否搞定极验的点选与推理验证。 若需要代码,扫描加微信即可。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"验证码","slug":"验证码","permalink":"http://example.com/tags/%E9%AA%8C%E8%AF%81%E7%A0%81/"},{"name":"极验","slug":"极验","permalink":"http://example.com/tags/%E6%9E%81%E9%AA%8C/"}]},{"title":"JS逆向案例——极验滑块验证码补环境","date":"2022-04-24T09:44:38.000Z","path":"JS逆向案例——极验滑块验证码补环境/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 前言本文是机器过极验滑块验证码系列文章的第三篇,接上篇w参数抠出来之后,补环境。后边还会陆陆续续发文,如何包括利用像素点RGB差值获取缺口位置以及通过机器学习获取缺口位置,最后会通过几个采用极验验证码的网站去完整的展示整个自动化过程。而极验滑块系列只是验证码系列的第一个系列,后边会罗列市面上常用的验证码,然后发文一一解决。 上一篇文章见:JS逆向案例——极验滑块w参数生成 补环境过程抠好w参数生成的代码之后,贴到VS中,先补上window和document: 1234var window = global;var Document = function() {};document = new Document();window.document = document; 运行之后报错,报navigator不存在 image-20220424180831738 补上navigator: 1234Navigator = function() {};navigator = new Navigator();window.navigator = navigator; 补好之后接着报错,document缺少方法createElement,可以看到createElement这个方法接受一个参数,并且返回为一个object。 image-20220424181756529 我们给document补上createElement方法: 123Document.prototype.createElement = function(a) { if (a == 'img') return {};} 运行下,竟然成功了。。。没想到补环境如此轻松。 image-20220424183013485 利用Express封装成一个服务将gt,challenge,c,s,版本v以及轨迹数组X1z作为参数传入,返回加密后的w参数,代码如下: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100var crypto = require("crypto");var md5 = crypto.createHash("md5");var express = require('express');var app = express();app.use(express.json());app.use(express.urlencoded({ extended: true }));var window = global;var Document = function() {};Document.prototype.createElement = function(a) { if (a == 'img') return {};}document = new Document();Navigator = function() {};navigator = new Navigator();window.document = document;window.navigator = navigator;var G0b = function() {}function H1W() { return (65536 * (1 + Math.random()) | 0).toString(16).substring(1);}var wb = H1W() + H1W() + H1W() + H1W();G0b.prototype.wb = function() { return wb;}var _v0B;var _n0B;var _e7B;var _i7B;var _p7B;var _I0B;/* 中间抠的JS代码省略*/function get_H7z() { let g0b = new G0b(); let aaa = new _v0B(); return aaa.encrypt(g0b.wb());}function get_q7z(X1z, c, s, gt, challenge, v) { let g0b = new G0b(); let passtime = 0; for (let index = 0; index < X1z.length; index++) { passtime += X1z[index][2]; } console.log(_e7B.u(_e7B.t(new Date().getTime(), X1z), c, s)); let Y7z = { "aa": _e7B.u(_e7B.t(new Date().getTime(), X1z), c, s), "userresponse": _i7B.C(Math.floor(Math.random() * 200), challenge), "passtime": passtime, "imgload": Math.floor(Math.random() * 200), "ep": {"v": v}, "rp": _I0B(gt + challenge.slice(0, 32) + passtime) }; return _n0B.encrypt(JSON.stringify(Y7z), g0b.wb());}function get_r7z(q7z) { return _p7B.Ha(q7z);}app.post('/geetest/w', function (req, res) { console.log(req.body.tracks); let X1z = JSON.parse(req.body.tracks); let c = JSON.parse(req.body.c); let s = req.body.s; let challenge = req.body.challenge; let gt = req.body.gt; let v = req.body.v; let h7z = get_H7z(); let q7z = get_q7z(X1z, c, s, gt, challenge, v); let r7z = get_r7z(q7z); let w = r7z + h7z; res.send(JSON.stringify({ w, gt, challenge }));});var server = app.listen(8081, function () { var port = server.address().port console.log("Server started, address: http://localhost:%s", port)}); 运行结果如下: image-20220424191932778","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"验证码","slug":"验证码","permalink":"http://example.com/tags/%E9%AA%8C%E8%AF%81%E7%A0%81/"},{"name":"极验","slug":"极验","permalink":"http://example.com/tags/%E6%9E%81%E9%AA%8C/"}]},{"title":"JS逆向案例——极验滑块验证码w参数生成","date":"2022-04-21T07:13:39.000Z","path":"JS逆向案例——极验滑块验证码w参数生成/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 前言本文是机器过极验滑块验证码系列文章的第二篇,提交验证请求的w参数逆向分析。后边还会陆陆续续发文,讲解如何补环境,如何利用像素点RGB差值获取缺口位置以及通过机器学习获取缺口位置,最后会通过几个采用极验验证码的网站去完整的展示整个自动化过程。而极验滑块系列只是验证码系列的第一个系列,后边会罗列市面上常用的验证码,然后发文一一解决。 上一篇文章见:JS逆向案例——极验滑块验证码底图还原 逆向分析过程网址:aHR0cHM6Ly93d3cudGlhbnlhbmNoYS5jb20v 以天眼查的登录为例,提交滑块验证请求时,w参数跟值。 抓包分析在底图还原的那篇文章中就提到过一共有四个重要的包,其中前面三个包都没有用到w参数,只有在向服务器提交验证码验证的请求时才需要w参数,如下: image-20220421230146225 我们点进去ajax.php这个请求的方法调用栈: image-20220421230321601 我们可以看到gt, challenge以及w参数生成位置,如下图: image-20220421230550163 确定逆向目标通过上边的抓包分析,可以确定的是w参数有2个变量r7z和H7z拼接而成,然后r7z是调用一个函数生成,这个函数接受一个参数q7z;H7z也是调用一个函数生成。这样逆向的目标也就确定了,就是抠出这2个函数以及参数q7z。 H7z生成规则H7z是由一个无参的函数V7z[M9r.C8z(92)]生成,所以先看这个函数。可以看到每次调用的结果都不同。 image-20220421231717931 可以看到这个函数采用了流程平坦化,对于流程平坦化,在调试时看下return语句返回的值,如果有多个return语句,则每个return语句都打上断点,如果只有一个return语句,看下return的那个变量,每个给这个变量赋值的语句都要打上断点,显然这里函数返回的是Y0B,则给这个变量赋值的几个地方都断上: image-20220421231912531 我们断进来发现,只走了M9r.k9r()[18][36][24]这个分支,实例化一个v0B对象,然后调用这个对象的某一个方法,返回一串加密后的字符串。 image-20220421232420203 利用console把Y0B = new v0B()[M9r.C8z(699)](g0B[M9r.C8z(818)](D0B))这行代码简化为Y0B = new v0B().encrypt(g0B.wb()),最后D0B为undefine所以省略。 所以这里要得到Y0B就先得抠出g0b的wb方法,然后抠出v0B的encrypt方法,我们一步步来吧。 g0b对象的wb方法 我们点进去wb方法,可以看到虽然是流程平坦化,但是只有一个case,最后返回的是一个逗号表达式,我们只看最后一个,也就是函数真正返回的是J0B变量,而J0B变量是调用C7B函数生成的。如下图: image-20220421233831380 没办法,我们只有接着追进去调试C7B函数,可以看到花里胡哨的其实只是调用了四次H1W函数,然后拼接成一个字符串返回。如图: image-20220421234115235 我们再扒一扒H1W函数的代码看下,将返回的那条语句反混淆之后结果为(65536 * (1 + Math.random()) | 0).toString(16).substring(1) 。 image-20220421234837015 到这里都是调用内置的函数了,而且算法比较简单,所以不需要进一步往下挖了。整理一下: 12345678910var G0b = function() {}G0b.prototype.wb = function() { return H1W() + H1W() + H1W() + H1W();}function H1W() { return (65536 * (1 + Math.random()) | 0).toString(16).substring(1);}g0b = new G0b(); v0B对象的encrypt方法 接着看下v0B对象的encrypt方法。点进去看这个方法体,发现这个方法还依赖了很多其它的方法,而且依赖的这些方法都不是内置方法,如果单抠这样的一个个方法就很麻烦了,更不用想去一个个理清楚这些方法的逻辑,然后用Python去自己实现了。遇到这样的情况一般比较简单的方法是全抠整个JS,然后想办法导出所需要的方法即可。 image-20220422002133796 想要导出V9B方法,就需要导出v0B对象,因为V9B方法属于v0B对象,而如何导出v0B对象呢?就要看这个对象的上一层是什么。如果遇到代码行数很多,代码层次比较多的时候推荐使用Notepad++方便去管理这种层次结构。下面介绍这个小技巧: 使用Notepad++查看JS代码层次结构小技巧:先拷贝整个文件至Notepad++,然后选择试图->折叠所有层次。 image-20220422003418919 接着CTRL+F搜索我们要的代码,比如这里是v0B = function() image-20220422003646204 可以看到搜索结果只展开了包含我们搜索代码的那些分支,其余的依旧没有展开,这样非常方便我们观察代码的层次结构。 image-20220422003746128 通过上图可以看到,只要加载这整个JS文件,就会执行流程平坦化中的代码,也就会得到v0B对象就会被定义,我们只需要全局定义一个变量接收v0B即可。如下图: image-20220422004359174 代码改好之后,我们放到console上试验一下,成功拿到encrypt方法。 image-20220422004733474 既然wb方法和encrypt方法都拿到了,那我们也就算是H7z的值。我们同样试验一下,也没问题。 image-20220422005005272 H7z的值拿到了,接下来就是r7z,而r7z依赖于q7z,所以我们先看q7z。 q7z生成规则抠出q7z生成的代码:q7z = n0B[M9r.R8z(699)](h7B[M9r.C8z(105)](Y7z), V7z[M9r.R8z(818)]()),反混淆之后为:q7z = n0B.encrypt(h7B.stringify(Y7z), V7z.wb()) encrypt,stringify,wb 🤔这几个怎么看起来那么眼熟? V7z.wb实际上跟我们前面抠的g0b.wb一毛一样,然后h7B.stringify实际上就等同于JSON.stringify image-20220422122917004 n0B.encrypt则显然与前面抠的v0B.encrypt不同,这两个方法参数个数不同,返回值类型也不同。 所以要解决q7z,则需要抠出Y7z是如何生成的以及抠出n0B.encrypt。看下我们前面抠v0B的方式,是不是可以如法炮制🤨 image-20220422124107011 运行结果如下,可以看到成功拿到了n0B.encrypt,只不过n0B.encrypt和之前的v0B.encrypt使用不一样。 image-20220422124505892 接下来就是Y7z了。我们先看下Y7z是个啥?🤓 image-20220422125036518 随机滑动滑块几次,观察这几个参数哪些是固定的,哪些是变化的。目测除了版本号v之外,其余几个参数都不是固定的,心累😅 image-20220422154448445 找到Y7z定义的地方,如下图,我们挨个看吧。 image-20220422155807811 先看看aa是如何生成的。 可以看到aa是由F7z变量赋值,我们在当前函数中找给F7z赋值的语句,并断上,如下图: image-20220422160401488 F7z = e7B[M9r.C8z(779)](V7z[M9r.R8z(602)])反混淆为F7z = e7B.t(V7z.b),V7z.b为一个13位的时间戳。我们跟进去这个方法: image-20220422172757864 我们先看返回值:f1z[M9r.R8z(592)](M9r.C8z(346)) + M9r.C8z(370) + B1z[M9r.R8z(592)](M9r.R8z(346)) + M9r.R8z(370) + o1z[M9r.R8z(592)](M9r.C8z(346))反混淆为f1z.join("") + "!!" B1z.join("") + "!!" + o1z.join(""),从这里可知,想要得到aa的值,就必须知道flz,Blz,olz的值。 我们再看看X1z:X1z是一个轨迹数组,每个元素都是一个包含三个向量的数组,分别是x坐标,y坐标,时间。经过分析知这个轨迹数组是浏览器监听鼠标事件得出来的,我们用机器去自动过验证码的时候是没办法通过这种方式得到的这个轨迹数组的,唯一的方式可能是写一种模拟拖动滑块的算法,生成这种轨迹,然后传给这个函数去计算aa的值。 image-20220422173201445 分析到这里,很显然我们要魔改这个生成aa的函数,传入一个轨迹数组,返回aa的值。由于这个函数也很复杂,所以考虑直接抠出这个函数,而不必去纠结具体的flz,Blz,olz是怎么得到的。 说干就干。操作如下,解释说明下几个标红的地方。开头定义一个全局变量_e7B用于导出生成aa的函数’\\x74’,然后我们定位到’\\x74’函数属于e7B对象,把这个对象赋值给_e7B,然后把X1z变量作为参数提上来,把里面的这个变量删掉。 image-20220422174419168 代码改好之后,我们测试一下,可以看到生成的aa与我们网站得到的一致。 image-20220422175211230 虽然我们拿到了aa的值,但是跟生成F7z的里面那个值有出入,那就是说定义aa的地方虽然调用了e7B.t方法生成aa,但是外面有其它地方对这个值进行了修改。 image-20220422182356210 我们在这个函数中搜索F7z,然后给所有包含F7z的赋值语句下断,一步步调试后发现修改F7z的地方如下: image-20220422185131813 我们抠出来这条语句,然后反混淆为:e7B.u(F7z, V7z.d.c, V7z.d.s),我们看下V7z.d.c和V7z.d.s: image-20220422185546434 看着又有点似曾相识,emm,没错分别对应get.php返回的c和s: image-20220422185646048 然后我们得抠下e7B.u这个方法,意外的发现,其实这个方法包含的对象前面抠过来,既然对象已经抠了,这个方法自然就有了。 image-20220422185947808 验证一下,结果没毛病: image-20220422190458150 再看看userresponse如何生成的 抠出生成userresponse的代码,然后反混淆为:i7B.C(g7z, V7z.d.challenge),g7z是拖拽鼠标滑动滑块的距离,可以通过轨迹数组计算出这个滑动的距离。 代码如下: 12345for (let index = 0; index < X1z.length; index++) { passtime += X1z[index][2]; g7z += X1z[index][0];}g7z -= X1z[0][0]; V7z.d.challenge是get.php返回的: image-20220422210127144 i7B.C这个函数的话,按照上边介绍的方法,先抠出i7B对象,自然就可以拿到C方法了。 passtime的计算 目测passtime是轨迹数组的每个向量的时间累积: image-20220422211356975 计算代码如下: 1234let passtime = 0;for (let index = 0; index < X1z.length; index++) { passtime += X1z[index][2];} imgload生成 imgload表示当前页面加载的图片数,这里我们用random随机一个值。 rp的计算 rp计算的代码为:I0B(V7z[M9r.R8z(190)][M9r.R8z(189)] + V7z[M9r.C8z(190)][M9r.C8z(425)][M9r.R8z(504)](0, 32) + Y7z[M9r.C8z(193)])反混淆之后为:I0B(gt + challenge.slice(0, 32) + passtime),gt,challenge,passtime都已经算出,抠出I0B方法即可,如何抠?参照上面的方式。 r7z生成规则有了q7z,r7z自然就很简单了。因为前面说过r7z = p7B.Ha(q7z),只需要抠出p7B即可,过程同理。 编写代码按照上面的抠出相应的方法后,然后编写生成w参数的代码,代码如下: 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263var G0b = function() {}function H1W() { return (65536 * (1 + Math.random()) | 0).toString(16).substring(1);}var wb = H1W() + H1W() + H1W() + H1W();G0b.prototype.wb = function() { return wb;}var _v0B;var _n0B;var _e7B;var _i7B;var _p7B;var _I0B;// 抠出对应的object function get_H7z() { let g0b = new G0b(); let aaa = new _v0B(); return aaa.encrypt(g0b.wb());}function get_q7z(X1z, c, s, gt, challenge) { let g0b = new G0b(); let passtime = 0; for (let index = 0; index < X1z.length; index++) { passtime += X1z[index][2]; } console.log(_e7B.u(_e7B.t(new Date().getTime(), X1z), c, s)); let Y7z = { "aa": _e7B.u(_e7B.t(new Date().getTime(), X1z), c, s), "userresponse": _i7B.C(Math.floor(Math.random() * 200), challenge), "passtime": passtime, "imgload": Math.floor(Math.random() * 200), "ep": {"v": "6.0.9"}, "rp": _I0B(gt + challenge.slice(0, 32) + passtime) }; return _n0B.encrypt(JSON.stringify(Y7z), g0b.wb());}function get_r7z(q7z) { return _p7B.Ha(q7z);}var X1z = [[21,30,0],[1,0,22],[2,0,8],[2,0,17],[3,0,17],[3,0,16],[2,0,17],[3,0,16],[2,0,17],[3,0,17],[2,0,17],[1,0,16],[1,0,17],[1,0,33],[1,0,17],[2,0,16],[2,0,17],[1,0,17],[1,0,16],[1,0,17],[1,1,50],[1,0,67],[0,0,18383]];var c = [12, 58, 98, 36, 43, 95, 62, 15, 12];var s = "424f4e78";var challenge = "bb56791bfda35fac04bd7f7b14a5c8654r";var gt = "f5c10f395211c77e386566112c6abf21";var h7z = get_H7z();var q7z = get_q7z(X1z, c, s, gt, challenge);var r7z = get_r7z(q7z);var w = r7z + h7z;console.log(w); 查找bug从开始逆向极验滑块,到完整的抠出w的算法只花了一天时间,本来一切顺风顺水,本来以为so easy,但是去用抠出来的w参数去发起ajax.php请求时,一直不成功。然后就是各种查找bug,查找bug花了我四天时间…期间各种猜想都尝试过了,感觉当时想着要不算了。但是想着自己前后花了快一个星期的时间,不能轻易言弃。这里列举主要的几个问题。 w参数的h7z和r7z两部分的关联性 前面说过,w参数是由2部分组成h7z和r7z。两部分看起来没有关联,其实这里有一个坑,这俩是有关联的。我们再理一下思路: 123h7z = V0B.encrypt(g0b.wb())q7z = n0B.encypt(JSON.stringify(Y7z), g0b.wb())r7z = p7B.Ha(q7z) h7z和r7z的生成这俩都用到了一个随机字符串wb,但是这两个随机字符串必须一致!!!,就是说后端会解密h7z和r7z然后比较这两个随机字符串是否一致,如果不一致就会不通过。我前期就是在生成h7z和r7z的地方调用了2次wb,导致验证一直不通过。正确的做法调用一次wb,并用一个全局变量保存,然后生成h7z和r7z的地方直接去拿这个全局变量即可。 同名的对象 另外导致一直通过的另外一部分原因是同名的对象有一些,再抠对象的时候一定要仔细,千万不能抠错。 aa轨迹的确定 跟某一个参数的值的时候,除了要在变量声明的地方分析变量值是如何变化的,还要注意在其它地方,尤其是逗号表达式的地方也隐藏着值的变化。比如:j1r = (F7z = e7B[M9r.R8z(544)](F7z, V7z[M9r.R8z(190)][M9r.C8z(540)], V7z[M9r.R8z(190)][M9r.R8z(6)])表面上是变量j1r的赋值,隐藏着变量F7z的值的变化。 这里介绍一些查找bug的技巧: 这里的w跟值包含2部分,r7z和h7z。可以先定位是h7z还是r7z的问题,定位到是哪半区的问题后,然后根据实际网站运行的结果和你自己编写的代码运行的结果一步步调试,2者结果为什么不一致,具体分析原因。 也可以利用浏览器的override功能或者hook尽可能的多输出一些变量的值,对比自己程序运行的结果和网站输出的值,看是哪一步出现问题。我们在调bug的时候就利用了override替换js文件输出了很多日志,如下图: image-20220428145928176","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"验证码","slug":"验证码","permalink":"http://example.com/tags/%E9%AA%8C%E8%AF%81%E7%A0%81/"},{"name":"极验","slug":"极验","permalink":"http://example.com/tags/%E6%9E%81%E9%AA%8C/"}]},{"title":"JS逆向案例——极验滑块验证码底图还原","date":"2022-04-20T02:41:37.000Z","path":"JS逆向案例——极验滑块验证码底图还原/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 前言本文是机器过极验滑块验证码系列文章的第一篇,底图的还原,包括带缺口的底图以及完整底图的还原。后边还会陆陆续续发文,讲解提交验证过程请求w参数的跟值,如何补环境,如何包括利用像素点RGB差值获取缺口位置以及通过机器学习获取缺口位置,最后会通过几个采用极验验证码的网站去完整的展示整个自动化过程。而极验滑块系列只是验证码系列的第一个系列,后边会罗列市面上常用的验证码,然后发文一一解决。 逆向分析网址:aHR0cHM6Ly93d3cudGlhbnlhbmNoYS5jb20v 以天眼查的登录为例,在进行滑块验证时,进行抓包分析。 抓包将一些重要的请求罗列如下: geetest.xhtml 传入的是一个13位时间戳的uuid image-20220420114316016 返回的是challenge和gt。 image-20220420114408595 gettype.php 获取验证码类型,传入的是第一个请求返回的gt。 image-20220420115339355 返回的是验证码类型以及一些JS文件: image-20220420115438377 get.php 入参同样是前边返回的challenge和gt。 image-20220420123131763 返回乱序之后的完整验证码底图和缺口验证码底图,同时返回了一个新的challenge。 image-20220420150236107 ajax.php 这个是我们手动进行验证码提交时发的包,重要的入参是gt,challenge以及w。其中w是加密字符串,包含滑块的轨迹。challenge是更新之后的那个新的值,即上一步get.php获取到的。 image-20220420150610485 返回滑块是否验证成功,如下: image-20220420150645777 逆向目标确定经过上边的抓包分析之后,可以发现几个关键点,首先是几个关键参数:challenge,gt,w,然后是乱序的完整底图和乱序的带缺口底图,最后是缺口位置的识别。 challenge和gt自始自终都是通过请求接口返回,所以这个不需要逆向。 w参数是最后一步向服务端提交验证码验证结果的值,包含了环境监测,滑块轨迹的加密。如果使用playwright这种模拟浏览器工具去提交验证码,这个参数可以不用逆向,如果是自己走JS或者Python发包,则需要逆向刨一下算法是怎么写的。 由于无法直接的获取正确的完整底图和带缺口的底图(有些网站上极验验证正确的底图可能是用canvas加载),所以需要进分析如何从无序的底图变为有序的底图。 缺口位置的识别,有2种方案。一种是通过比较完整底图和缺口底图,利用像素点的RGB像素差值,来判断缺口位置;另一种是通过机器学习进行训练,然后得到一个模型用于识别缺口位置。 上面几点,不管如何偷懒,第三步底图的还原是必然需要的,不然没办法计算缺口位置,没办法生成轨迹,自然没办法进行验证。所以底图还原是整个滑块验证的第一步。 底图缺口还原我们先看下滑块的大小: image-20220420163229256 图片大小 w = 260px, h = 116px。我们点击图片选择审查元素。 可以看到底图是由52个div组成,每个div的w = 10px,h = 58px。分为上下两个半区,每个半区26个div。刚好组成260px * 116px的矩形验证码。 image-20220420163621256 我们看第一个div,即上半区左上角的第一个div,background-position = -157px -58px。表示将background-image向左偏移157个像素,向上偏移58个像素,作为第一个div放在上半区最左边。由于前面分析过,每个div的宽是10px,高是58px。所以第一个div四个顶点在background-image上的相对坐标是(157, 58), (167, 58), (157, 116), (167, 116)。 同理,我们推测上半区第二个div的四个顶点的相对坐标分别是(145, 0), (155, 0), (145, 58), (155, 58)。 此外,background-image就是我们抓包分析的第三步获取到的乱序图。 知道了每一个个div的坐标,以及乱序的背景图,就可以通过从乱序图上裁剪出一个个div,然后再拼接到一起,这样不就构成了正确有序的图片? 编程实现123456789101112131415161718192021222324252627282930import mathfrom PIL import Imagediv_offset = [ {"x": -157, "y": -58}, # 省略若干行 {"x": -205, "y": 0}]def restore_pic(pic_path, new_pic_path): unordered_pic = Image.open(pic_path) ordered_pic = unordered_pic.copy() # 裁剪并拼接 for i, d in enumerate(div_offset): im = unordered_pic.crop((math.fabs(d['x']), math.fabs(d['y']), math.fabs(d['x']) + 10, math.fabs(d['y']) + 58)) # 上半区 if d['y'] != 0: ordered_pic.paste(im, (10 * (i % (len(div_offset) // 2)), 0), None) else: ordered_pic.paste(im, (10 * (i % (len(div_offset) // 2)), 58), None) ordered_pic.save(new_pic_path)if __name__ == '__main__': restore_pic("img.png", "new_img.png") 说一下用到的PIL库的几个方法:copy表示复制一张图片;crop表示以矩形区域裁剪,入参是一个四个元素的元组,分别是矩形左上角顶点的x坐标,左上角顶点的y坐标,右下角顶点的x坐标,右下角顶点的y坐标;paste表示粘贴图片。 罗列一下程序执行的结果,底图乱序与正序如下: image-20220420200318898 代码获取若需要代码,扫描加微信即可。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"验证码","slug":"验证码","permalink":"http://example.com/tags/%E9%AA%8C%E8%AF%81%E7%A0%81/"},{"name":"极验","slug":"极验","permalink":"http://example.com/tags/%E6%9E%81%E9%AA%8C/"}]},{"title":"JS逆向案例——网上管家婆","date":"2022-04-17T08:38:11.000Z","path":"JS逆向案例——网上管家婆/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 网址:aHR0cHM6Ly9sb2dpbi53c2dqcC5jb20uY24v 逆向目标:登录接口参数clientinfo,userName,password生成规则 先看下这几个参数长啥样: image-20220418163645276 userName和password长度都是128位,盲猜是AES。 全局搜索一下clientinfo,找到了clientinfo生成的地方: image-20220418163838625 点进去这个方法: image-20220418163925615 可以看到先是调用一个doGetInfo获取info信息,然后调用base64encode方法对info进行编码,直接抠出这2个方法即可。 抠出来之后,执行下doGetInfo函数,结果如下,其实就是把环境的一些参数信息用特殊符号拼接,为了不被检测出来,最好对比浏览器的实际结果,把相应的参数的值补上。 image-20220418172659541 接着看下userName和password,全局搜索下password: image-20220418173455546 发现 userName和password采用的一种加密,就以password为例吧。 encryptedString加密方法接受2个参数,第一个是key,第二个是加密的明文。key的生成,往上面看几行,就会找到var key = new RSAKeyPair();这行代码,然后去抠出RSAKeyPair这个对象,缺啥补啥,一直抠就完事了,比较简单。 这里有一个注意的就是,下面一行代码如果漏掉了,就会陷入死循环,导致加密结果出不来: image-20220418174523439 我们在抠主要逻辑的时候,一定要看下它前后的逻辑,一些看似无关的逻辑能保留的尽量保留,比如我们要的encryptedString方法,是在postLogin方法中调用的,postLogin前边部分是一些从html表单中取值的逻辑,后边部分是发包的逻辑,除去这2部分,中间一条setMaxDigits(129);这个逻辑不知道是干啥用的,就尽量保留。 抠完JS并补好环境后,整个执行的结果如下: image-20220418175307668 补环境的代码如下: 123456789101112var window = {};var document = {};var Navigator = function() {}Navigator.prototype.userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.88 Safari/537.36";Navigator.prototype.platform = "MacIntel";navigator = new Navigator();window = {"navigator": navigator}; 代码获取?若需要代码,扫描加微信即可。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"}]},{"title":"JS逆向神器v_jstools使用介绍","date":"2022-04-11T11:34:59.000Z","path":"JS逆向神器v-jstools使用介绍/","text":"工具介绍v_jstools是一款融合多种功能的chrome浏览器插件,具备hook,JS代码混淆与解混淆,JS压缩,文本对比等功能。 工具安装项目地址:https://github.com/cilame/v_jstools 安装步骤如下: 下载源码之后,解压到当前文件夹。 chrome浏览器开新页面打开chrome://extension,然后把右上角的开发者模式打开。如下图: 点击左上角的加载已解压的扩展程序,选择之前解压的v_jstools那个目录。 工具使用点击浏览器左上角的v_jstools小图标,页面如下: 这个ast工具页面,实际上是跳转到https://astexplorer.net/这个地址,这个是将js代码转化为AST树的一个网址,在做AST还原JS混淆中经常用到,收藏在这里也是很方便。 接着文本对比页面,就是对比两个或者三个文本,在js逆向中,经常需要对比我们生成的加密参数与网页原本的加密参数是否一致,有了这个工具也是非常方便。 最后点击打开配置页面,会进入到一个新的窗口页面,如下: 默认是进入hook配置页面,可以自定义需要的hook功能。 重点关注上面的第4栏,AST混淆解密,点击之后界面如下: 上边的是混淆后的代码,下边的是接混淆之后的代码。 功能先介绍到这吧,其实还有很多功能,后边慢慢摸索。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"工具","slug":"工具","permalink":"http://example.com/tags/%E5%B7%A5%E5%85%B7/"}]},{"title":"JS逆向之Fiddler编程猫插件使用","date":"2022-04-10T11:17:22.000Z","path":"JS逆向之Fiddler编程猫插件使用/","text":"安装FD,配置编程猫插件首先需要安装Fiddler,要求版本>=4.6.3。建议官网下载,下载地址:https://www.telerik.com/download/fiddler/fiddler4 下载安装完FD之后,找到FD的安装目录,打开一个叫做Scripts的文件夹: image-20220410204247917 解压FD编程猫插件(插件下载地址见文末),将里面的所有.dll扩展文件拷贝到上边的Scripts文件夹下: image-20220410204433530 然后关闭Fiddler,并重新启动Fiddler,注意第一次启动需要以管理员身份运行。打开之后界面如下表示安装成功: image-20220410204605262 Fiddler的使用Chrome配置Fiddler抓包安装SwitchyOmega代理管理Chrome浏览器插件,在Chrome扩展应用商城中搜索SwitchyOmega,然后安装。添加好插件后,打开SwitchyOmega点击新建情景模式: image-20220410222059063 我们就把新建的情景命名为Fiddler,然后第一行,代理协议填HTTP,代理服务器地址填:127.0.0.1(因为我是用的虚拟机,所以这里填虚拟机的ip地址),端口填8888。 在最左边选择应用选项,就保存了刚才的配置。 image-20220410222742171 打开一个页面,比如百度,然后点击SwitchyOmega,选择Fiddler插件,然后刷新页面。 image-20220410224632321 这个时候可以看到Fiddler中已经成功抓到了百度的页面请求,但是由于百度使用的是HTTPS协议,我们还没有配置证书,导致Fiddler抓包的数据不正常,并且百度首页也如同上边展示的那样。FD配置证书抓取HTTPS,也正是接下来要讲的。 image-20220410224830653 Fiddler配置抓取HTTPS打开Fiddler,点击工具栏的Tools->Options,点击HTTPS选项,按照如下图勾选相应的选项: image-20220410225612499 点击Trust Root Certificate,点击Export Root Certificate将FD的证书保存到桌面。这时候桌面上会出现证书FiddlerRoot.cer文件,点击OK设置成功,关闭fiddler。 打开Chrome浏览器,在浏览器地址中输入:chrome://settings/进入chrome的设置页面,在搜索栏中输入管理证书,点击管理证书会跳转到钥匙串管理程序(Mac系统)。 将桌面的FD证书拖拽到这个钥匙串管理程序中,拖拽成功后,证书默认是不信任的,如下图: image-20220410230101918 双击这个DO_NOT_TRUST_FiddlerRoot证书,在弹出的窗口中点击信任,选择始终信任。如下图: image-20220410230244807 点击左上角关闭按钮,弹出对话框输入root密码。 打开Fiddler,刷新百度首页,可以看到百度首页正常加载,Fiddler中也能正常的抓到百度的请求。 编程猫插件的使用点击右边的编程猫专用插件,会出现一列导航,如下: image-20220410234536919 生成易代码可以忽略,是易语言专用。JS调试工具,内置了一个V8引擎,可以在这个tab里面编辑JS代码,然后进行调试。JSON解析是只格式化JSON数据。数据加密解密,则提供一些常见的加密算法,比如MD5,Sha,AES,DES等。编码解码则提供了一些常见的编码工具,比如Base64。这个编程猫工具重点关注的是注入Hook与内存漫游。 首先说注入Hook,默认提供了一个Hook Cookie的代码,如下图。勾选左边的开启,然后地址栏为空表示对所有的地址都注入Hook代码,如果只对特定的地址注入代码,则填上地址即可。 image-20220410235308981 我们以百度首页为例,开启注入Hook之后,刷新百度首页,在console控制台上打印了日志信息,如下图: image-20220410235817134 除了注入Hook Cookie的代码,还可以注入Hook window属性,websocket,内置函数或者自定义函数。参考JS逆向之快速定位关键代码 接着说内存漫游。所谓浏览器内存漫游,其原理通过ast把浏览器中所有的变量,参数中间值在内存中的变化进行存储,然后我们就可以搜索这些值,从而根据值确定这个参数在浏览器中出现的位置,变化情况等等。 我们以极验的w参数为例。FD上点击内存漫游tab,然后点击下边的开启内存漫游按钮: image-20220411002857220 我们打开极验的测试网址:https://www.geetest.com/demo/slide-float.html,然后拖动滑块完成验证。找到这个ajax.php的请求,复制下来w参数的值,如下图: image-20220411003041687 接着在console面板输入hook.search(刚才复制下来的w的值),执行之后就可以看到w参数在浏览器中整个的运行情况: image-20220411003509171 可以看到w参数一共出现了2次,第一次是初始化,第二次是调用。我们随便点击一个的文件位置进去(比如点击第一个),然后打上断点: image-20220411003649162 刷新页面,再次进行验证码的验证,可以看到成功断上了,并且debugger面板上也可以看到我们跟的值是w这个参数: image-20220411003930689 编程猫插件下载地址扫码关注微信公众号——逆向一步步,然后公众号内回复“FD编程猫插件”,就可以获得插件下载地址。 qrcode_for_gh_509fdefd3c81_258","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"工具","slug":"工具","permalink":"http://example.com/tags/%E5%B7%A5%E5%85%B7/"}]},{"title":"JS逆向案例——问财网补环境","date":"2022-04-10T04:43:49.000Z","path":"JS逆向案例——问财网补环境/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 前言本篇文章通过一个案例介绍JS逆向过程中抠JS之后的补环境工作。 逆向过程目标网址:aHR0cDovL3d3dy5pd2VuY2FpLmNvbS91bmlmaWVkd2FwL3Jlc3VsdD93PSVFNyVCQiVCRiVFOCU4OSVCMiVFNyU5NCVCNSVFNSU4QSU5QiVFNiVBNiU4MiVFNSVCRiVCNSZxdWVyeXR5cGU9c3RvY2s= 逆向目标:cookie中的v值 Hook Cookie对于处理Cookie种某一个键值对生成这类问题第一反应应该是想到采用Hook的方式。这里介绍2种Hook的方式,一种是通过FD编程猫插件,一种是通过油猴插件。 使用油猴插件 油猴插件的使用参照:JS逆向之Tampermonkey工具篇 插件内容为: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546// ==UserScript==// @name hook iwencai// @namespace http://tampermonkey.net/// @version 0.1// @description try to take over the world!// @author You// @include *// @icon https://www.google.com/s2/favicons?sz=64&domain=aqistudy.cn// @grant none// ==/UserScript==(function() { 'use strict'; //endebug = function(off, code) {}; var cookie_cache = document.cookie; Object.defineProperty(document, 'cookie', { get: function() { console.log('Getting cookie'); return cookie_cache; }, set: function(val) { console.log('Setting cookie', val); if (val.indexOf("v=") != -1) { debugger; } var cookie = val.split(";")[0]; var ncookie = cookie.split("="); var flag = false; var cache = cookie_cache.split("; "); cache = cache.map(function(a){ if (a.split("=")[0] === ncookie[0]) { flag = true; return cookie; } return a; }); cookie_cache = cache.join("; "); if (!flag) { cookie_cache += cookie + "; "; } this._value = val; return cookie_cache; } }) // Your code here...})(); 使用FD编程猫插件 FD编程猫插件用法参照:JS逆向之Fiddler编程猫插件使用 1234567891011121314151617181920212223242526//当前版本hook工具只支持Content-Type为html的自动hook//下面是一个示例:这个示例演示了hook全局的cookie设置点(function() { //严谨模式 检查所有错误 'use strict'; //document 为要hook的对象 这里是hook的cookie var cookieTemp = ""; Object.defineProperty(document, 'cookie', { //hook set方法也就是赋值的方法 set: function(val) { if (val.indexOf("v=")) { debugger; } //这样就可以快速给下面这个代码行下断点 //从而快速定位设置cookie的代码 console.log('Hook捕获到cookie设置->', val); cookieTemp = val; return val; }, //hook get方法也就是取值的方法 get: function() { return cookieTemp; } });})(); 逆向分析无论是采用哪一种方式,都可以成功断住,如下图: image-20220412203406372 跟栈,进到o方法,发现第二个参数t就是需要的v的值。 image-20220412213516531 继续跟栈,进到D方法,setCookie就是上边的o方法,第二个参数n就是上边的t也就是Cookie v的值。而且n是由rt.update生成的。 image-20220412213620698 我们看下rt对象: image-20220412214401314 一个Init方法应该是对算法进行初始化,一个update方法则是生成v。 我们断进去update方法,update方法就是这个D方法,D方法又调用了O方法,我们跟进去。 image-20220413133045178 我们单步执行到S.toBuffer();可以看到S是一个l对象,并且包含一个base_fields属性。如下图: image-20220413133536272 那我们点进去S.toBuffer看看能不能找到l对象的原型,qn返回了l也就是前面的S,我们住需要抠出qn就可以得到l了,注意的是qn本来就执行了,返回一个逗号表达式,逗号表达式的最后是l,所以生成l对象的正确写法是new qn(xxx)。 image-20220413152301870 l对象在初始化的时候需要传一个r参数,我们全局搜索一下new qn看看是否能找到一个实例,看看这个r传的是啥。搜索结果如下: image-20220413153204291 我们断住这个地方,调试发现a固定为4,n固定为1,e固定为3,t固定为2。知道了如何抠出S,也知道了如果初始化S,下面我们进行检验。我们新建一个snippet然后拷贝全部的代码: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152var _qn;var TOKEN_SERVER_TIME = 1649765993.630;!function(n, t) { !function() { var r, e, a; // 省略若干行 var qn = function() { var n, t, r; n = t = r = a; var e, o, i; e = o = i = s; var u = o[15] , c = o[102] , f = e[103]; function l(r) { var a = o[102] , i = e[103]; this[n[76]] = r; for (var u = t[52], c = r[a + g + i]; u < c; u++) this[u] = t[52] } return l[e[104]][w + m + I + u] = function() { for (var a = e[105], u = this[a + y], c = [], s = -e[0], v = o[2], f = u[r[56]]; v < f; v++) for (var l = this[v], p = u[v], d = s += p; c[d] = l & parseInt(t[77], n[78]), --p != r[52]; ) --d, l >>= parseInt(n[79], i[106]); return c } , l[v(t[80], t[81], b)][ot(i[107])] = function(n) { for (var r = e[8], a = this[ot(e[108], e[109])], o = t[52], u = e[2], s = a[c + r + f]; u < s; u++) { var v = a[u] , l = i[2]; do { l = (l << t[82]) + n[o++] } while (--v > t[52]); this[u] = l >>> i[2] } } , l }(), zn; _qn = qn; // 省略若干行 }()}(["", 9527, /* 省略若干行 */ "V587"]); 定一个全局变量_qn,导出qn。运行文件,没有报错,然后我们生成一个S对象: image-20220413154327048 这里我们就成功的生成了S,并且可以看到decodeBuffer方法和mm.toBuffer方法也都有。别忘了我们的最终目标是得到Cookie中的v,我们回到前面的O方法,看初始化并得到S之后,后续生成v的逻辑。我们看到Jn.serverTimeNow()这一行代码,发现每次取出来的都是一个定值。 image-20220413155910667 其实这个获取的就是该JS文件最前面定义的TOKEN_SERVER_TIME,这个值可能会随着JS文件更新而发生变化,所以我们采取局部扣JS的方法可能会很麻烦,因为可能需要定期更新TOKEN_SERVER_TIME的这个值。那只能全扣了啊,问题是抠下来全部的JS,放在本地执行之后,去哪里拿这个Cookie呢?答案是从document中拿,但是本地没有document呀,所以接下来就是补环境了。 image-20220413161327925 补环境按照JS逆向之vscode无环境联调介绍的,把整个JS代码粘贴到VS Code中,然后开启DevTools,运行之后报document不存在: image-20220413164041346 像这种拷贝整个JS然后补环境,需要补头补尾,中间的整个JS文件不能动,这样做的好处是中间的文件可以用一个占位符表示,以后每次JS更新了,只需要更新这个占位符的内容,这样更加通用,维护起来更加容易 我们补好window和document之后,再次运行,接着报错: image-20220413165734237 这里r[51]是document,报错的一句r[51].getElementsByTagName(p + d)[r[52]]实际上是document.getElementsByTagName(‘head’)[0];继续补代码如下: 12345678910var window = this;var Document = function() {};Document.prototype.getElementsByTagName = function(x) { if (x == 'head') { return [{}] } return [{}]};var document = new Document(); 解释一下为什么这么补,补方法的时候关注3点,一是参数个数,二是返回值类型,三是对实际传入的参数进行特殊处理。因为getElementsByTagName只接受一个参数,所以只需要定义一个形参x。 image-20220413185332326 通过在原网站上调试知传入的实际参数为字符串head,且返回的是一个对象数组。所以上边也对head进行了特殊处理,而且方法的返回值是对象数组。 补好getElementsByTagName方法后,接着运行,接着报错。 image-20220413202853960 接着补createElement: 12345Document.prototype.createElement = function(x) { if (x == "div") { return function onwheel() {}; }}; 为什么这么补?我们看下面一张图,作说明: image-20220413203850845 首先很显然createElement要补到Document对象下,然后s[171]为div,并且调用createElement后要返回一个onwheel方法。 补好之后,运行接着报错,如图: image-20220413204248014 n.attachElement没有定义,补一下: 123Document.prototype.attachEvent = function(x, y) {}; 补好之后,依旧报错,嗯!逆向分析就是需要耐心: image-20220413212157946 补navigator,可以看到navigator.plugins是一个对象数组,我们这里补一个空就行。 image-20220413213604130 123Navigator = function() {};Navigator.prototype.plugins = [];navigator = new Navigator(); 补好之后,运行接着报错: image-20220413214446556 我们看看这个几个变量是什么,如图: image-20220413214519986 我们找一下l的声明处,如图: image-20220414235906611 可以看到l是取自window中的document,所以我们把document作为属性放到window对象下: 12document = new Document();var window = {"document": document}; 补好之后,再次运行,再次报错: image-20220415000927626 这里就很奇怪了,因为原始网站这里应该是直接进去上边的if分支,而不是进到这里的else if判断。我们跟进去这个方法m,看看这个m是什么? image-20220415001131494 可以看到o是localStorage,s[83]是window对象,这里的意思是判断window对象下是否存在localStorage,并判断这个属性是否为空。我们自己定义一个localStorage给到window。 123LocalStorage = function() {}localStorage = new LocalStorage();window = {"document": document, "localStorage": localStorage}; 补好之后运行,接着报错: image-20220415001954038 这里的f是localStorage,缺少getItem,我们补一下: 123456LocalStorage.prototype.getItem = function(x) { if (x == "hexin-v") { return null; } return null;} 我们通过原网站,看到该方法接受一个参数,并且只调用了一次,传的参数是hexin-v,返回null,我们照着补就行。 补好之后,接着运行: image-20220415002404829 通过调用栈我们看看这个n是啥?n实际是上层函数传入的参数,即navigator.userAgent,原始网站有值,我们没有定义。 image-20220415003226879 我们补上userAgent: 1Navigator.prototype.userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.75 Safari/537.36"; 补好之后,运行报错,如下图: image-20220415003446318 通过原始网站知,javaEnabled方法返回false,那我们也给navigator对象加一个方法javaEnabled返回false即可: 1Navigator.prototype.javaEnabled = function() { return false }; 补好之后运行报错: image-20220415003756208 这里a[65]是window对象,a[175]是navigator对象,上面报错是说window.navigator为undefine,我们把navigator作为window的属性即可,修改上面补环境的代码: 1var window = {"document": document, "localStorage": localStorage, "navigator": navigator}; 改好之后运行: image-20220415004026612 提示location未定义,我们定义一个location对象: 12Location = function() {}location = new Location(); 接着运行,依然报错: image-20220415004230766 这里的c[140]是href,location.href为空,我们查看原始网站知,location.href就是当前的页面地址,我们补上即可: 1Location.prototype.href = "http://www.iwencai.com/unifiedwap/result?w=%E7%BB%BF%E8%89%B2%E7%94%B5%E5%8A%9B%E6%A6%82%E5%BF%B5&querytype=stock"; 接着运行: image-20220415004504041 提示location.hostname不存在,我们根据原网站取到location.hostname的值补上: 1Location.prototype.hostname = "www.iwencai.com"; 补好运行,报错: image-20220415004845137 提示localStorage的setItem方法不存在,得嘞,补一个空方法: 123LocalStorage.prototype.setItem = function(x, y) {} 补好之后运行,没有报错!!! 此处应该有掌声表情包- 搜狗图片搜索 我们取一下cookie的值,也成功看到了v: image-20220415005229606 上面补环境的代码比较零散,这里统一整理如下: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647Document = function() {};Document.prototype.getElementsByTagName = function(x) { if (x == 'head') { return [{}] } return [{}]};Document.prototype.createElement = function(x) { if (x == "div") { return function onwheel() {}; } else if (x == "canvas") { return {getContext: function(y) { return undefined; }} }};Document.prototype.attachEvent = function(x, y) {};Document.prototype.documentElement = { addBehavior: function() {}};document = new Document();Navigator = function() {};Navigator.prototype.javaEnabled = function() { return false };Navigator.prototype.plugins = [];Navigator.prototype.userAgent = "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/100.0.4896.75 Safari/537.36";navigator = new Navigator();Location = function() {};Location.prototype.href = "http://www.iwencai.com/unifiedwap/result?w=%E7%BB%BF%E8%89%B2%E7%94%B5%E5%8A%9B%E6%A6%82%E5%BF%B5&querytype=stock";Location.prototype.hostname = "www.iwencai.com";location = new Location();LocalStorage = function() {}LocalStorage.prototype.getItem = function(x) { if (x == "hexin-v") { return null; } return null;}LocalStorage.prototype.setItem = function(x, y) {}localStorage = new LocalStorage();var window = {"document": document, "localStorage": localStorage, "navigator": navigator}; 总结本文通过一个例子,介绍了手动补环境的过程,总结如下:拷贝整个JS然后补环境,需要补头补尾,原则上中间的整个JS文件一点也不能动,这样做的好处是中间的文件可以用一个占位符表示,以后每次JS更新了,只需要更新这个占位符的内容,这样更加通用,维护起来更加容易。 关于代码的获取若需要代码,扫描加微信即可。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"}]},{"title":"JS逆向之vscode无环境联调","date":"2022-04-09T14:12:32.000Z","path":"JS逆向之vscode无环境联调/","text":"为啥使用VSCodeVSCode强大,支持多种语言。在JS逆向项目中,会涉及到Python和JS代码,如果Python使用Pycharm调试,JS使用WebStorm或者Hbuilder进行调试,那么需要在多个开发者工具中切换,比较麻烦,VSCode的多语言支持完美的解决这个麻烦事。此外,最重要的是VSCode可以配置JS代码的无环境联调,即可以与浏览器的dev-tools工具交互实现在VSCode中开发,浏览器工具中调试,这也是其它工具没有的。 安装VSCodeVSCode下载地址:https://code.visualstudio.com/ 安装好VSCode之后,可以配置语言为中文。具体方法是,点击下图的图标,选择Extension,然后在搜索中输入”Chinese”,选择简体中文,然后重启浏览器即可。 image-20220409223010250 配置无环境联调首先安装好NodeJS环境。 然后打开工作目录,选择任意一个JS文件,选择运行->启动调试就可以运行或者debug JS代码了。 image-20220409235241659 然后通过命令npm install -g node-inspect全局安装依赖包,如果是Linux/Mac系统,需要sudo权限。 输入命令node-inspect,查看node-inspect是否安装成功: image-20220410000634331 我们选择运行->打开配置: image-20220409235420113 出现一个新的文件launch.json文件,这个文件用来配置项目的运行环境,比如执行脚本,解释器,命令行参数等等。修改这个配置文件,内容如下: 123456789101112131415161718192021222324252627{ // 使用 IntelliSense 了解相关属性。 // 悬停以查看现有属性的描述。 // 欲了解更多信息,请访问: https://go.microsoft.com/fwlink/?linkid=830387 "version": "0.2.0", "configurations": [ { "type": "node", "request": "launch", "name": "启动程序", "skipFiles": [ "<node_internals>/**" ], "program": "${workspaceFolder}/aerfaying.js" }, { "type": "node", "request": "launch", "name": "无环境浏览器", "skipFiles": [ "<node_internals>/**" ], "runtimeExecutable": "node-inspect", "program": "${workspaceFolder}/aerfaying.js" }, ]} 把之前的运行环境改了个名,叫做启动程序,然后新建了一个环境,叫做无环境浏览器,这个环境除了名字之外唯一不同的是增加了一个runtimeExecutable,把我们上边安装的node-inspect包给引入进来。 保存好配置文件之后,这个时候我们左上角就出现2个环境供我们选择了: image-20220410001442317 我们点击无环境浏览器,就会启动无环境浏览器模式下的调试模式。打开任意一个Chrome浏览器窗口,打开开发者工具,在开发者工具的Elements面板左边出现一个绿色的图标,如下: image-20220410001701254 表示开启了VSCode与浏览器的联调模式,如果这个图标为灰色,表示关闭了联调模式。 点击这个图标,就弹出了DevTools: image-20220410001856382 可以看到已经帮我们把刚才调试的那个JS文件的内容自动导入,同时开启了debug模式。为什么叫做无环境浏览器?就是这个DevTools跟我们在浏览器中调试几乎是一模一样的,只不过一些浏览器的环境变量没有,比如window,document等等。这样既可以继续使用浏览器进行调试,又可以没有window/document等环境,模拟真实独立的开发环境,方便我们补环境。 image-20220410002734125 用VS Code进行调试首先看下左下角的这2个选项,如下图: image-20220418165953520 Caught Exception指的是对于加了try catch的代码块,如果执行了catch逻辑(即代码抛了异常),依然在抛异常的地方断住。如下图: image-20220418171006294 Uncaught Exceptions指的是忽略try catch中的异常,在try 之外的代码块报错,才会断住。如图: image-20220418171418283 一般会勾选Uncaught Exceptions,因为try中的代码抛异常属于正常逻辑。如果这俩选项都没勾选,也没有自定义断点,程序就会直接运行完,并不会断住。如下图: image-20220418171636116 再说说调试用的的一组按钮,如下图: image-20220418171746410 第一个按钮,表示继续,从当前停顿的地方一直执行到下一个断点,如果没有下一个断点,则按照程序的正常执行顺序,顺序执行一步。 第二个按钮,表示单步执行,从当前位置开始,顺序执行一步,如果遇到函数调用,不进入函数内部顺序执行。 第三个按钮,表示单步执行,从当前位置开始,顺序执行一步,如果遇到函数调用,进入到函数内部单步执行。 第四个按钮,表示单步执行,如果当前调试是在函数内部单步执行,则跳过函数剩余的执行代码,回到函数调用处,往下执行一步。 第五个按钮,重启程序。 第六个按钮,停止。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"}]},{"title":"JS逆向案例——阿尔法营webpack抠JS","date":"2022-04-06T12:35:48.000Z","path":"JS逆向案例——阿尔法营webpack抠JS/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 前言webpack抠JS的一个案例,同样用来熟练如何从webpack打包的JS代码中抠关键的JS代码。 抠JS过程网址:aHR0cHM6Ly9hZXJmYXlpbmcuY29tLw== 目标:登录接口的请求参数t和s分析。 老规矩,先抓包。 image-20220406154058629 可以看到请求参数中username和password都是明文,t参数目测是一个时间戳,s是一串加密之后的密文。 从请求堆栈Initiator中一个个点进去,看看能否找到s生成的地方。 image-20220406154534518 点击上面的文件,发现有个报错: image-20220406154640011 遇到这种Could not load content for webpack:///的错误,需要更改下浏览器的默认配置,Settings/Preferences/Sources,去掉Enable Javascript source maps的勾选。 image-20220406154623354 修改配置之后,就可以看到相关JS文件的代码了。找到一块疑似加密的代码,如下: image-20220406155132967 window.Blockey.SecuritySalt,w和n都是固定的值。 image-20220406160129468 根据上面生成s的代码,我们整理代码如下: 12345678var window = global;var n = "/WebApi/Users/Login";function get_sign(username, password) { let x = "DUE$DEHFYE(YRUEHD*&"; let w = "username=" + username + "&" + "password=" + password; return c.default((n + "?" + w + x));} 然后我们的目标就很明确了,就是抠出c.default的函数,填入到我们的JS文件,我们浏览器中点进去c.default函数: image-20220406160747460 我们上边猜想的t是一个时间戳,不准确,可以看到t是一个时间戳加上了一个固定的数值。然后s参数是一个Sha1算法生成的密文。 我们继续点进去Sha1.hash函数: image-20220406161134381 然后将抠出来的JS函数一个个填充到JS文件中去,缺啥抠啥。具体详细过程不一步步展示了。最终整理的代码如下: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546var n = "/WebApi/Users/Login";var c = {};var Sha1 = {};Sha1.hash = function(n, t) { // 代码较长,省略}Sha1.f = function(n, t, i, r) { switch (n) { case 0: return t & i ^ ~t & r; case 1: return t ^ i ^ r; case 2: return t & i ^ t & r ^ i & r; case 3: return t ^ i ^ r }}Sha1.ROTL = function(n, t) { return n << t | n >>> 32 - t}Sha1.utf8Encode = function(n) { return unescape(encodeURIComponent(n))};c.default = function(e, t) { var n = (new Date).getTime() + 2592e6 + (t || 3e4) , r = (e || "") + "&t=" + n; return { t: n, s: Sha1.hash(r) }}function get_sign(username, password) { let x = "DUE$DEHFYE(YRUEHD*&"; let w = "username=" + username + "&" + "password=" + password; return c.default((n + "?" + w + x));}console.log(get_sign("17777777777", "123456")); 输出结果,跟预期一致: image-20220406161810100 总结这个案例比较简单,没有什么难度,提这个案例主要目的有2点,第一就是遇到按照webpack方式组织的JS代码,不一定非得按照前面介绍的分五步走,先找模块加载器,然后编写自执行等等,一些简单的webpack可以直接去抠JS代码的。第二就是遇到``Could not load content for webpack:///`这种报错,需要去修改浏览器配置。 关于代码若需要代码,扫描加微信。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"WebPack","slug":"WebPack","permalink":"http://example.com/tags/WebPack/"}]},{"title":"JS逆向案例——某远海运公司webpack抠JS","date":"2022-04-03T15:28:00.000Z","path":"JS逆向案例——某远海运公司webpack抠JS/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 前言webpack抠JS的一个案例,用来熟练如何从webpack打包的JS代码中抠关键的JS代码。 抠JS过程网址:aHR0cHM6Ly9zeW5jb25odWIuY29zY29zaGlwcGluZy5jb20v 目标:登录接口的密码加密JS代码分析。 老规矩,先抓包。 image-20220404150944421 可以看到登录请求的密码这个参数是加密的。 搜索关键词password,找到一个疑似加密的函数,如下: image-20220404162426120 点进去看到加密的地方: image-20220404163306525 接着点进去看这个o.a函数: image-20220404163401493 可以看到,对密码采用了RSA加密,然后进行Base64编码。然后看下代码结构,很显然是按照webpack模块化编程进行组织的。 按照前面介绍的——JS逆向之webpack扣JS思路——这篇文章总结的方法: 找到模块加载器。 快速定位模块加载器,一般可以通过搜索}({,迅速定位到,但是包含加密函数的JS文件中并未找到模块加载器,没有找到的话,我们自己写一个好了。代码如下: 123456789101112function b(n) { if (e[n]) return e[n].exports; var u = e[n] = { i: n, l: !1, exports: {} }; return c[n].call(u.exports, u, u.exports, b), u.l = !0, u.exports} 构造自执行。 这个也比较简单,我们把上边的代码,稍微做修改: 123456789101112131415161718!function(c) { var e = {}; function b(n) { if (e[n]) return e[n].exports; var u = e[n] = { i: n, l: !1, exports: {} }; return c[n].call(u.exports, u, u.exports, b), u.l = !0, u.exports } encode = b;}({ // TODO}); 找到并抠出需要的模块。 抠JS就变成了一道填空题,把加密方法依赖到的模块抠出来,作为参数填到上边自执行函数中去。我们先抠出加密方法, 观察下依赖哪些模块: 123456789var r = n("XBrZ");var t = r.pki.publicKeyFromPem( "-----BEGIN PUBLIC KEY-----\\n MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsy4xppPDUT2eAOR5h0cyydzxtKB9O80A\\n GjUT6FmDgg6CwelpnE0C2h2JQyP1gCveJs6GDwSDn20RVVpD67f//YPYErjaH/CBOxNG3k5IkW1o\\n Qx04uqFNMtWvjzk0aFh2eJLsBi7Ha4elw3WySg00B8oZCL4VBay4ML9kyOAjjCj5jHCX8a2yxIMJ\\n IF+EjW3kBR68IMwBvuDL45Qa0oB24vTffaSEs+hGjMTQvoCciOfti3pmEAlVc438/cBgAhK5cIMf\\n IMElxYAVvmsDy0I7RCUTrajetKjX94Q+JuQUxnIHNC3IVtYsl1x0lNRtb93IhlRCkZ9djOu350eq\\n hZIOXQIDAQAB\\n -----END PUBLIC KEY-----").encrypt(e, "RSA-OAEP", { md: r.md.sha256.create(), mgf1: { md: r.md.sha1.create() }});return window.btoa(t) 通过debug知e是我们填入的密码——即123456,唯一用到的模块是键为XBrZ的模块。我们全局搜索XBrZ: 找到对应的模块定义: image-20220404165620085 点进去发现这个模块又引用了很多其它的模块,如果按照模块一个一个的抠的话,比较费时。所以我们将这整个文件中定义的模块全部抠下来,作为我们上边定义的自执行函数的参数。 image-20220404165643735 整理完了之后,我们将代码放到浏览器中检验一下,防止出错: image-20220404170102015 不出意外的话就不会出意外,没有报错。 导出相应的模块。 模块通过模块加载器加载,所以想要得到加密方法依赖的模块,只需要导出模块函数即可。定义一个全局变量,比如encode,然后将上边实现的模块加载函数赋值给这个全局变量即可。代码如下: 1234567891011121314151617181920var encode;!function(c) { var e = {}; function b(n) { if (e[n]) return e[n].exports; var u = e[n] = { i: n, l: !1, exports: {} }; return c[n].call(u.exports, u, u.exports, b), u.l = !0, u.exports } encode = b;}({ // 此处省略若干行模块函数的定义}); 编写测试代码。 对于这个案例而言,测试代码就是那一串加密代码,看能否如期的得到类似的加密字符串,就证明我们抠的JS没有问题,测试代码如下: 123456789101112function get_pass(passwd) { var r = encode("XBrZ"); // 通过模块加载器加载XBrZ模块。 var t = r.pki.publicKeyFromPem("-----BEGIN PUBLIC KEY-----\\n MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsy4xppPDUT2eAOR5h0cyydzxtKB9O80A\\n GjUT6FmDgg6CwelpnE0C2h2JQyP1gCveJs6GDwSDn20RVVpD67f//YPYErjaH/CBOxNG3k5IkW1o\\n Qx04uqFNMtWvjzk0aFh2eJLsBi7Ha4elw3WySg00B8oZCL4VBay4ML9kyOAjjCj5jHCX8a2yxIMJ\\n IF+EjW3kBR68IMwBvuDL45Qa0oB24vTffaSEs+hGjMTQvoCciOfti3pmEAlVc438/cBgAhK5cIMf\\n IMElxYAVvmsDy0I7RCUTrajetKjX94Q+JuQUxnIHNC3IVtYsl1x0lNRtb93IhlRCkZ9djOu350eq\\n hZIOXQIDAQAB\\n -----END PUBLIC KEY-----").encrypt(passwd, "RSA-OAEP", { md: r.md.sha256.create(), mgf1: { md: r.md.sha1.create() } }); return window.btoa(t)}console.log(get_pass("123456")); 运行测试代码,正常的输出了密码加密之后的密文: image-20220404170927135 总结从webpack组织的JS代码中抠JS,虽然看起来比较简单,但是如果遇到复杂一点的案例,并且对JS语法不太熟的话,还是有一定的难度的的,所以需要对这一块多多练习。后边关于webpack的,还会再出一些案例,一些更加复杂的案例。 关于代码的获取若需要代码,扫描加微信即可。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"WebPack","slug":"WebPack","permalink":"http://example.com/tags/WebPack/"}]},{"title":"JS逆向之webpack扣JS思路","date":"2022-03-28T08:59:43.000Z","path":"JS逆向之webpack扣JS思路/","text":"前言这篇文章通过站在逆向的角度,解决遇到JS文件如果通过webpack的方式去组织代码模块如何扣JS代码,进行逆向分析的问题。 关于webpackJS的自执行函数IIFE 全称 Immediately-invoked Function Expressions,即自执行函数。这种模式本质上就是函数表达式(命名的或者匿名的)在创建后立即执行。当函数变成立即执行的函数表达式时,表达式中的变量不能从外部访问。IIFE 主要用来隔离作用域,避免污染。 自执行函数的几种形式 匿名函数前面加上一元操作符,后面加上 ()。 123456789101112131415!function() {}();+function() { }();-function() { }();~function() { }(); 匿名函数后边加上(),然后再用()将整个括起来。 123(function() { console.log("Hello, world!");})(); 先用 () 将匿名函数括起来,再在后面加上 ()。 123(function () { console.log("Hello, world!");})(); 使用箭头函数表达式,先用 () 将箭头函数表达式括起来,再在后面加上 ()。 123(() => { console.log("Hello, world!");})(); 匿名函数前面加上 void 关键字,后面加上 (), void 指定要计算或运行一个表达式,但是不返回值。 123void function () { console.log("Hello, world!");}(); 有的时候,我们还有可能见到立即执行函数前面后分号的情况,比如: 1234567;(function () { console.log("Hello, world!");}());!function () { console.log("Hello, world!");}() 自执行函数传参将参数放在末尾的 () 里即可实现参数传递,如: 12345678910111213141516171819var list = [1, 2, 3, 4, 5];(function () { var sum = 0; for (var i = 0; i < list.length; i++) { sum += list[i]; } console.log(sum);})(list);var dict = {name: "Bob", age: "20"};(function () { console.log(dict.name);})(dict);(function (a, b, c, d) { console.log(a + b + c + d);})(1, 2, 3, 4); call, apply, bind三兄弟Function.prototype.call()、Function.prototype.apply()、Function.prototype.bind() 都是比较常用的方法。它们的作用一毛一样,即改变函数中的 this 指向,它们的区别如下: call() 方法会立即执行这个函数,接受一个多个参数,参数之间用逗号隔开; apply() 方法会立即执行这个函数,接受一个包含多个参数的数组; bind() 方法不会立即执行这个函数,返回的是一个修改过后的函数,便于稍后调用,接受的参数和 call() 一样。 callcall() 方法接受多个参数,第一个参数 thisArg 指定了函数体内 this 对象的指向,如果这个函数处于非严格模式下,指定为 null 或 undefined 时会自动替换为指向全局对象(浏览器中就是 window 对象),在严格模式下,函数体内的 this 还是为 null。从第二个参数开始往后,每个参数被依次传入函数,基本语法如下: 1function call(thisArg, arg1, arg2, ...) 比如: 1234567891011121314151617function func1(a, b) { return a + b;}console.log(func1.call(null, 1, 2)); // 3function func2() { return this[0] + this[1];}console.log(func2.call([1, 2])); // 3function func3() { return this.a + this.b;}console.log(func3.call({"a": 1, "b": 2})); // 3 applyapply() 方法接受两个参数,第一个参数 thisArg 与 call() 方法一致,第二个参数为一个带下标的集合,这个集合可以为数组,也可以为类数组,apply() 方法把这个集合中的元素作为参数传递给被调用的函数,基本语法如下: 1function.apply(thisArg, [arg1, arg2, ...]) 比如: 1234567891011function func2() { return this[0] + this[1];}console.log(func2.apply([1, 2])); // 3function func3() { return this.a + this.b;}console.log(func3.apply({"a": 1, "b": 2})); // 3 bindbind() 方法和 call() 接受的参数是相同的,只不过 bind() 返回的是一个函数,基本语法如下: 1function.bind(thisArg, arg1, arg2, ...) 比如: 1234567891011function func(a, b, c) { return a + b + c;}console.log(func.bind(null, 1, 2, 3)()); // 6function func1() { return this[0] + this[1];}console.log(func1.bind([1, 2])()); // 3 理解webpack有了以上知识后,我们再来理解一下模块化编程,也就是前面所说的 webpack 写法: 12345678910111213141516!function (allModule) { function useModule(whichModule, xxx, xxx, /*...*/) { allModule[whichModule].call(null, xxx, xxx, /*...*/); } useModule(0, 'abc', null, /*...*/)}([ function module0(param) { console.log("module0: " + param) }, function module1(param) { console.log("module1: " + param) }, function module2(param) { console.log("module2: " + param) },]); 所谓webpack模块化编程,就是把一类函数——这些函数服务于某个或者某几个功能起作用——以列表或者对象的形式放在一起,封装到一个自执行的函数中,这些函数对外是不可见的,并且只对外暴露一个函数,这个函数叫做模块加载函数,外部通过这个加载函数访问自执行函数内部的函数,从而起到模块化的作用。 webpack模块化编程的JS代码结构特点12345678910111213141516171819function (x) { /*加载模块的方法*/ function xx(yy) { x[yy].call(x1, x2, x3); // x[yy].apply([x1, x2, x3]); // x[yy].bind(x1, x2, x3)(); }([ // 可供加载的模块列表 function(x1, x2, x3) {}, function(x1, x2, x3) {} ] // 或者是 { "xxx": function(x1, x2, x3) {}, "xxx": function(x1, x2, x3) {} } );} webpack模块化编程的JS代码特点是包含2个部分,上面是一个加载模块的方法,也叫模块加载器。下面是可供加载的模块列表。可供加载的模块列表是一个类数组(可以是数组,可以是对象)。 webpack扣JS的步骤我们以这个网址——G妹游戏——为例来介绍webpack扣js的一般步骤。我们的目的是抠出密码加密算法的那一部分JS代码。 找到我们要扣JS的那个文件,抓包,打断点分析的过程就不赘述了,直接贴文件: image-20220329005533139 找到模块加载器(加载模块的方法) 根据前面提到的webpack模块化编程的JS代码结构特点,很显然这个模块加载为: 12345678910111213function e(s) { var i = {}; if (i[s]) return i[s].exports; var n = i[s] = { exports: {}, id: s, loaded: !1 }; return t[s].call(n.exports, n, n.exports, e), n.loaded = !0, n.exports} 构造一个自执行。可以是构造一个空的自执行,也可以是把网站的自执行JS扣下来,然后删除不必要的方法。如下: 123456789101112131415!function(t) { var i = {}; function e(s) { if (i[s]) return i[s].exports; var n = i[s] = { exports: {}, id: s, loaded: !1 }; return t[s].call(n.exports, n, n.exports, e), n.loaded = !0, n.exports }}(); 注意构造的这个自执行方法只需要保留模块加载方法。 找到并抠出调用的模块。从可供加载的模块列表中抠出包含我们需要的加密方法的模块。 image-20220329161258366 通过抓包,找请求调用栈,定位到密码加密相关的地方。打上断点,调试这个方法,如下: image-20220329161353804 接着断点,接着调试: image-20220329161453244 最终找到需要调用的模块如图: image-20220329161649646 接着就是抠出这个调用模块,我们可以把整个文件复制下来,我们发现可供加载的模块是一个对象,用数字作为键,模块作为值,为了方便我们把键为0的叫做模块0,键为1的叫做模块1,依此类推。搜索关键代码qe.prototype.encrypt,根据代码缩进,找到封装qe.prototype.encrypt这个方法的模块是模块4,我们拷贝整个模块4的代码,粘贴到我们第二步构建的那个自执行方法的可供加载的模块列表中去,注意是以object的方法,不要用列表形式,同时给这个模块取一个名字,比如就叫做encrypt。 注意到我们前面跟栈的示意图,生成密码的地方是调用模块3的encode 方法,而encode方法是调用模块4的encrypt方法,我们上边已经抠出来了模块4,不要忘了抠出模块3(虽然模块3比较简单,完全可以自己写)。同样的给模块3重新取个名为encode。 最终模块3和模块4抠出来的代码如下: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162!function(t) { function e(s) { if (i[s]) return i[s].exports; var n = i[s] = { exports: {}, id: s, loaded: !1 }; return t[s].call(n.exports, n, n.exports, e), n.loaded = !0, n.exports }}({ "encrypt": function(t, e, i) { var s, n, r; s = function(t, e, i) { /* 省略若干行代码 */ qe.prototype.decrypt = function(t) { try { return this.getKey().decrypt(ye(t)) } catch (t) { return !1 } } , qe.prototype.encrypt = function(t) { try { return be(this.getKey().encrypt(t)) } catch (t) { return !1 } } /* 省略若干行代码 */ }) } .call(e, i, e, t), !(void 0 !== s && (t.exports = s)) }, "encode": function(t, e, i) { var s; s = function(t, e, s) { function n() { "undefined" != typeof r && (this.jsencrypt = new r.JSEncrypt, this.jsencrypt.setPublicKey("-----BEGIN PUBLIC KEY-----MIGfMA0GCSqGSIb3DQEBAQUAA4GNADCBiQKBgQDq04c6My441Gj0UFKgrqUhAUg+kQZeUeWSPlAU9fr4HBPDldAeqzx1UR92KJHuQh/zs1HOamE2dgX9z/2oXcJaqoRIA/FXysx+z2YlJkSk8XQLcQ8EBOkp//MZrixam7lCYpNOjadQBb2Ot0U/Ky+jF2p+Ie8gSZ7/u+Wnr5grywIDAQAB-----END PUBLIC KEY-----")) } var r = i(4); n.prototype.encode = function(t, e) { var i = e ? e + "|" + t : t; return encodeURIComponent(this.jsencrypt.encrypt(i)) } , s.exports = n } .call(e, i, e, t), !(void 0 !== s && (t.exports = s)) }}); 扣完代码之后,我们将整个代码放到浏览器中执行一遍,验证下抠的JS代码没有问题: image-20220329163418015 导出相应的模块方法。我们只需要导出模块加载函数就行,因为加密中用到的encode和encrypt方法都是通过模块加载函数加载的。我们可以在自执行方法外面定义一个全局变量,然后把模块加载函数赋值给这个全局变量,这样就可以从自执行函数内部导出模块加载方法了。代码如下: 123456789101112131415161718192021222324// 对外暴露模块加载函数var _n = e;!function(t) { function e(s) { if (i[s]) return i[s].exports; var n = i[s] = { exports: {}, id: s, loaded: !1 }; return t[s].call(n.exports, n, n.exports, e), n.loaded = !0, n.exports }}({ "encrypt": function(t, e, i) { // 省略函数体 }, "encode": function(t, e, i) { // 省略函数体 }}); 修改完代码之后,我们在浏览器中验证一下: image-20220329181407362 可以看到成功加载了encrypt函数,加载encode函数的时候报错,我们打上断点调试,发现报错的那一行代码是: 1var r = i(4); 这里的i是encode的第三个参数,是t[s].call(n.exports, n, n.exports, e)的第四个参数即模块加载函数(前面提到过call的第一个参数是影响this指针的参数),表示调用模块4,然而模块4我们改名为encrypt(熟悉的模块3调用模块4,但是模块3盒模块4我们都改名了,😮💨真是给自己挖坑,其实完全跟原始JS的保持一致的命名)。所以这里我们只需要把这行代码改为: 1var r = i("encrpty"); 改好之后,我们再次在浏览器中调试,没有报错。 编写代码测试。 image-20220329200136091 可以看到测试结果符合预期。 output-onlinepngtools 总结这篇文章主要介绍了webpack模块化以及如何从中抠出相应的模块。抠JS的方法,总结起来分五步: 找到模块加载器 构造自执行 找到并抠出需要的模块 导出相应的模块方法 编写代码测试","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"WebPack","slug":"WebPack","permalink":"http://example.com/tags/WebPack/"}]},{"title":"JS逆向之WebSocket协议","date":"2022-03-27T09:45:51.000Z","path":"JS逆向之WebSocket协议/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 前言本文的目的不是着重讲WebSockets,而是解决在JS逆向过程中遇到网站使用WebSockets协议的时候如何处理。 WebSocket什么是WebSocketWebSocket是一种网络传输协议,可在单个TCP连接上进行全双工通信,位于OSI模型的应用层。WebSocket使得客户端和服务器之间的数据交换变得更加简单,允许服务端主动向客户端推送数据。WebSocket的最大特点就是浏览器和服务器只需要完成一次握手,两者之间就可以创建持久性的连接,并进行双向数据传输。 WebSocket协议规范将ws(WebSocket)和wss(WebSocket Secure)定义为两个新的统一资源标识符(URI)方案,分别对应明文和加密连接。除了方案名称和片段ID(不支持#)之外,其余的URI组件都被定义为此URI的通用语法。 WebSocket的其它特点: 建立在 TCP 协议之上,服务器端的实现比较容易。 与 HTTP 协议有着良好的兼容性。默认端口也是80和443,并且握手阶段采用 HTTP 协议,因此握手时不容易屏蔽,能通过各种 HTTP 代理服务器。 数据格式比较轻量,性能开销小,通信高效。 可以发送文本(json/xml/纯文本),也可以发送二进制数据(protobuf)。 没有同源限制,客户端可以与任意服务器通信。 协议标识符是ws(如果加密,则为wss),服务器网址就是 URL。如ws(wss)://example.com:80/some/path。 为什么需要WebSocket我们已经有了 HTTP 协议,为什么还需要另一个协议?它能带来什么好处?答案很简单,因为 HTTP 协议有一个缺陷:通信只能由客户端发起。试想一下,现在有个场景,比如聊天室。如果A用户与B用户聊天,如果用HTTP协议,只能是A用户客户端向服务器发起一个HTTP请求,询问是否有B用户的消息,同样的对于B用户也一样。这种单向请求的特点,注定了如果服务器有连续的状态变化,客户端要获知就非常麻烦。就只能用轮询的方式:每隔一段时间就发出一个询问,了解服务器有没有新的信息。 轮询的效率低,非常浪费资源(因为必须不停连接,或者 HTTP 连接始终打开)。因此,工程师们一直在思考,有没有更好的方法。WebSocket 就是这样发明的。 握手协议WebSocket 是独立的、创建在TCP上的协议。Websocket 通过HTTP 协议说明客户端请求: 12345678GET /chat HTTP/1.1Host: server.example.comUpgrade: websocketConnection: UpgradeOrigin: http://example.comSec-WebSocket-Key: dGhlIHNhbXBsZSBub25jZQ==Sec-WebSocket-Protocol: chat, superchatSec-WebSocket-Version: 13 服务器回应: 12345HTTP/1.1 101 Switching ProtocolsUpgrade: websocketConnection: UpgradeSec-WebSocket-Accept: s3pPLMBiTxaQ9kYGzzhZRbK+xOo=Sec-WebSocket-Protocol: chat 字段说明: Connection必须设置Upgrade,表示客户端希望连接升级。 Upgrade字段必须设置Websocket,表示希望升级到Websocket协议。 Sec-WebSocket-Key是随机的字符串,服务器端会用这些数据来构造出一个SHA-1的信息摘要,然后进行Base64编码。 Sec-WebSocket-Version 表示支持的Websocket版本。 Origin字段是必须的。如果缺少origin字段,WebSocket服务器需要回复HTTP 403 状态码(禁止访问)。 WebSocket NodeJS版的客户端API简单示例客户端代码: 12345678910111213const WebSocket = require('ws')const ws = new WebSocket('ws://localhost:3000')// 接受ws.on('message', (message) => { console.log(message.toString()) // 当数字达到 10 时,断开连接 if (Number.parseInt(message) === 10) { ws.send('close'); ws.close() }}) 服务端代码: 123456789101112131415const WebSocket = require('ws')const WebSocketServer = WebSocket.Server;// 创建 websocket 服务器 监听在 3000 端口const wss = new WebSocketServer({port: 3000})// 服务器被客户端连接wss.on('connection', (ws) => { // 通过 ws 对象,就可以获取到客户端发送过来的信息和主动推送信息给客户端 let i = 0; setInterval(function f() { ws.send(i++) // 每隔 1 秒给连接方报一次数 }, 1000)}) 详细API介绍 WebSocket 构造函数 WebSocket 对象作为一个构造函数,用于新建 WebSocket 实例。 1var ws = new WebSocket('ws://localhost:8080'); 执行上面语句之后,客户端就会与服务器进行连接。 webSocket.readyState readyState属性返回实例对象的当前状态,共有四种。 CONNECTING:值为0,表示正在连接。 OPEN:值为1,表示连接成功,可以通信了。 CLOSING:值为2,表示连接正在关闭。 CLOSED:值为3,表示连接已经关闭,或者打开连接失败。 下面是一个示例: 1234567891011121314151617switch (ws.readyState) { case WebSocket.CONNECTING: // do something break; case WebSocket.OPEN: // do something break; case WebSocket.CLOSING: // do something break; case WebSocket.CLOSED: // do something break; default: // this never happens break;} webSocket.onopen 实例对象的onopen属性,用于指定连接成功后的回调函数。 123ws.onopen = function () { ws.send('Hello Server!');} 如果要指定多个回调函数,可以使用addEventListener方法。 123ws.addEventListener('open', function (event) { ws.send('Hello Server!');}); webSocket.onclose 实例对象的onclose属性,用于指定连接关闭后的回调函数。 12345678910111213ws.onclose = function(event) { var code = event.code; var reason = event.reason; var wasClean = event.wasClean; // handle close event};ws.addEventListener("close", function(event) { var code = event.code; var reason = event.reason; var wasClean = event.wasClean; // handle close event}); webSocket.onmessage 实例对象的onmessage属性,用于指定收到服务器数据后的回调函数。 123456789ws.onmessage = function(event) { var data = event.data; // 处理数据};ws.addEventListener("message", function(event) { var data = event.data; // 处理数据}); 注意,服务器数据可能是文本,也可能是二进制数据(blob对象或Arraybuffer对象)。 12345678910ws.onmessage = function(event){ if(typeof event.data === String) { console.log("Received data string"); } if(event.data instanceof ArrayBuffer){ var buffer = event.data; console.log("Received arraybuffer"); }} 除了动态判断收到的数据类型,也可以使用binaryType属性,显式指定收到的二进制数据类型。 1234567891011// 收到的是 blob 数据ws.binaryType = "blob";ws.onmessage = function(e) { console.log(e.data.size);};// 收到的是 ArrayBuffer 数据ws.binaryType = "arraybuffer";ws.onmessage = function(e) { console.log(e.data.byteLength);}; webSocket.send() 实例对象的send()方法用于向服务器发送数据。 发送文本的例子。 1ws.send('your message'); 发送 Blob 对象的例子。 1234var file = document .querySelector('input[type="file"]') .files[0];ws.send(file); 发送 ArrayBuffer 对象的例子。 12345678// Sending canvas ImageData as ArrayBuffervar img = canvas_context.getImageData(0, 0, 400, 320);var binary = new Uint8Array(img.data.length);for (var i = 0; i < img.data.length; i++) { binary[i] = img.data[i];}ws.send(binary.buffer); webSocket.bufferedAmount 实例对象的bufferedAmount属性,表示还有多少字节的二进制数据没有发送出去。它可以用来判断发送是否结束。 12345678var data = new ArrayBuffer(10000000);socket.send(data);if (socket.bufferedAmount === 0) { // 发送完毕} else { // 发送还没结束} webSocket.onerror 实例对象的onerror属性,用于指定报错时的回调函数。 1234567socket.onerror = function(event) { // handle error event};socket.addEventListener("error", function(event) { // handle error event}); 逆向案例介绍蝌蚪聊天室网址:http://kedou.workerman.net/ 按照JS逆向的步骤,即抓包,分析包信息,调试,本地运行。先进行抓包,网络面板过滤器选择WS,如下: image-20220327225044186 点中这个WebSocket请求,然后切换到Message面板,如图: image-20220327225356124 这个Message信息是实时的消息,因为WebSocket一经连接,除非断开就会一直存在,会实时发送消息。上边每条消息前面都有一个箭头,红色的表示服务器发给客户端的,绿色的箭头表示我们发送给服务器的。随便选中一条信息,可以看到它的详细信息,很显然这里的消息格式是使用JSON。 接下来就是断点分析,我们可以通过这个WebSocket请求的Initiator找到相应的代码: image-20220327230133082 也可以通过全局搜索关键字.onopen,如图: image-20220327230318560 不管用什么方法,都会定位到如下的代码段: image-20220327230426306 点进去onMessage方法, 然后打上断点,然后刷新网页,成功断上: image-20220327230536234 这里的数据消息比较简单,明文JSON,没有任何的加密。 参考链接WebSocket 教程 总结WebSocket与HTTP都是网络传输协议,它们的相同点是: 建立在TCP之上,通过TCP协议来传输数据 都是可靠性传输协议 都是应用层协议 它们的不同点: WebSocket是HTML5中的协议,支持持久连接,HTTP不支持持久连接 HTTP是单向协议,只能由客户端发起,做不到服务器主动向客户端推送信息","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"WebSockets","slug":"WebSockets","permalink":"http://example.com/tags/WebSockets/"}]},{"title":"JS逆向之加解密进阶篇","date":"2022-03-22T12:43:08.000Z","path":"JS逆向之加解密进阶篇/","text":"不一样的加密算法栅栏密码栅栏密码(Rail-fence Cipher)就是把要加密的明文分为N个一组,然后把每组的第一个字符组合,每组的第二个字符组合…每组的第N个(最后一个分组可能不足N个)个字符组合,最后把他们全部连接起来就是密文。这里以2栏栅栏加密为例。 image-20220322162944513 以一句话Are you ok为例: 去空格,结果为Areyouok 分组,结果为Ar ey ou ok 重组 第一组:Aeoo 第二组:ryuk 生成密文,结果为:Aeooryuk Python代码演示 1234567891011121314151617181920212223242526272829303132333435363738def encode(flag, num): from math import ceil length = len(flag) lines = ceil(length / num) arr = [flag[i:i+num] for i in range(0, lines * num, num)] flag = '' for i in range(len(arr[0])): for j in arr: try: flag += j[i] except: pass return flagdef decode(flag, num): length = len(flag) # flag的长度 lines = length // num # 判断共有几层并减一 remainder = num * (lines + 1) - length # 相差的数量 # 补全flag result = flag[:length-lines*remainder] for i in range(remainder-1, -1, -1): result += flag[length - (i + 1) * lines:length - i * lines] + '*' # 还原flag lines += 1 arr = [result[i:i + lines] for i in range(0, len(result), lines)] flag = '' for i in range(len(arr[0])): for j in arr: flag += j[i] return flag[:length]if __name__ == '__main__': print(encode("Are you ok", 2)) # Aeyuor o k print(decode("Aeyuor o k", 2)) # Are you ok 列位移密码列位移密码(Columnar Transposition Cipher)是一种比较简单,易于实现的换位密码,通过一个简单的规则将明文打乱混合成密文。将明文填入事先约定填充的行列数,如果明文不能填充完表格,可以约定使用某个字母进行填充,然后根据密钥在字母表中出现的先后顺序进行编号,根据编码即可推出密文。 image-20220322194007474 还是以那句话Are you ok为明文,以car为密钥,x为填充字符: 去空格,结果为:Areyouok 因为密钥car的长度为3,所以以3个字符为一个单位分组,如下: c a r A r e y o u o k x 最后一组只有2个元素不足三个,所以用x填充。由于密钥car的字母顺序排序是a>c>r,所以上述列排序也要根据这个顺序重新排列,如下: a c r r A e o y u k o x 重新排序完成之后,从第一列到最后一列按列取值组成的字符串就是密文了,很显然密钥是不能出现在明文中的,填充字符可以出现在明文中,所以每一列的第一个字符被忽略,最终的密文为:rokAyoeux。 Python代码演示 12345from pycipher import ColTransColTrans("car").encipher('Are you ok') # ROKAYOEUColTrans("car").decipher('ROKAYOEU') # AREYOUOK 凯撒密码凯撒密码(Caesar Cipher或称凯撒加密,凯撒变换,变换加密,位移加密)是一种替换加密,明文中的所有字母都在字母表上向后(或向前)按照一个固定的数目进行偏移后被替换成密文。例,当偏移量是3的时候,所有的字母A都将被替换成D,B变成E,依此类推。 image-20220322220707572 依旧以Are you ok为例: 去除空格,变为Areyouok 以5作为偏移映射,A对应F,r对应w,e对应j,依此往下推,转换后的密文为Fwjdtztp。 Python代码演示 123456789101112131415161718192021222324252627282930313233def encrypt_char(char, key): return chr(ord('A') + (ord(char) - ord('A') + key) % 26)def encrypt_message(message, key): message = message.upper() cipher = '' for char in message: if char not in ' ,.': cipher += encrypt_char(char, key) else: cipher += char return cipherdef decrypt_char(char, key): return chr(ord('A') + (ord(char) - ord('A') + 26 - key) % 26)def decrypt_message(cipher, key): cipher = cipher.upper() message = '' for char in cipher: if char not in ' ,.': message += decrypt_char(char, key) else: message += char return messageif __name__ == '__main__': print(encrypt_message("Are you ok", 5)) # FWJ DTZ TP print(decrypt_message("FWJ DTZ TP", 5)) # ARE YOU OK 其它加密JSfuck仅使用6个字符,即[]+()!,来编写js程序。 jother是一种可以运用于javascript语言中利用少量字符构造精简的匿名函数方法对于字符串进行的编码方式。其中8个少量字符包括:!+()[]{}。只用这些字符就可以完成任意字符串的编码。 jjencode将JS代码转换成只有符号的字符串。 aaencode可以将JS代码转换成常用的网络表情,也就是我们说的颜文字JS加密。 JS加密上的实例分析与变化凯撒加密变种加密算法分析密文:lQyjcvi|dcQR pktg t 2 cxl 9tixQR0 wxujzzxg0 gxijgc cxl 9tixQR V t 3 ZYY0rQRR 算法: 123456789101112131415161718function _$aU(_$Fj) { var _$U4 = _$Fj.length; var _$_L, _$cu = new Array(_$U4 - 1), _$h9 = _$Fj.charCodeAt(0) - 97; for (var _$1B = 0, _$lp = 1; _$lp < _$U4; ++_$lp) { _$_L = _$Fj.charCodeAt(_$lp); if (_$_L >= 40 && _$_L < 92) { _$_L += _$h9; if (_$_L >= 92) _$_L -= 52; } else if (_$_L >= 97 && _$_L < 127) { _$_L += _$h9; if (_$_L >= 127) _$_L -= 30; } _$cu[_$1B++] = _$_L; } return String.fromCharCode.apply(null, _$cu);} 使用上述算法解密密文结果如下: image-20220323180744951 可以看到上面是一个无限debugger的字符串,然后通过eval调用可以达到无限debugger的目的,通过前面学习了凯撒密码之后,我们除了可以使用hook的方式绕过无限debugger,还可以使用凯撒密码达到目的,我们删除密文中的wxujzzxg0,然后执行: image-20220323181330460 可以看到,同样干掉了debugger关键字。 常规加密算法的变种借鉴古典密码学改造现在密码学看一段AES实际案例(某常见人机验证码代码部分案例) 1234567891011121314151617181920212223242526272829303132var CryptoJS = require("crypto-js");const key = "ABC1234567891234";const iv = "1234567812345678";function encrypt(text) { return CryptoJS.AES.encrypt(text, CryptoJS.enc.Utf8.parse(key), { iv: CryptoJS.enc.Utf8.parse(iv), mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 })}function decrypt(text) { var result = CryptoJS.AES.decrypt(text, CryptoJS.enc.Utf8.parse(key), { iv: CryptoJS.enc.Utf8.parse(iv), mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return result.toString(CryptoJS.enc.Utf8);}let encrypted = encrypt("123456");var s = [];for (var i = 0; i < encrypted.ciphertext.sigBytes; i++) { var x = encrypted.ciphertext.words[i >>> 2] >>> 24 - i % 4 * 8 & 255; s.push(x)}let data = encrypt(String.fromCharCode.apply(null, s)).toString();console.log(encrypted.toString()); // 6S3YRylMmp9vIFOplWxypw==console.log(data); // u79HHbvmZtoSScagKO2JsQzytZH1L5SJGSh338DbaMA=console.log(decrypt(data)); // é-ØG)L\u0000\u0000o S©\u0000lr§ 如上,假如加密代码出现了cryptojs关键字,然后让人以为是传统的cryptojs加密,但是实际上做了一点修改,导致如果直接拿data去做解密,会得到的乱码,达到混淆视听的目的。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"}]},{"title":"你不可不知的CSS反爬","date":"2022-03-20T10:19:57.000Z","path":"你不可不知的CSS反爬/","text":"为什么使用CSS做反爬 成本低 只需要前段混淆样式 不需要复杂的加密技术 不需要验证码,流量检测等额外配置 因此,对于企业来说,仅仅用一些CSS技巧就可以防住爬虫,可以不需要投入很多资源,这样会节省资金以及时间资源。 效果好 难以识别 抓取内容与预期内容相近,容易误导爬虫工程师 反爬措施不容易发觉 可以混淆竞争对手 没有成熟的破解套路 破解CSS混淆的反爬措施需要想象力 没有统一的破解套路,需要人工干预 用CSS的反爬效果好,所以企业可以花很少的时间成本来获取较大的反爬效益,这种措施显得尤为具有吸引力。 CSS反爬类别图片伪装反爬虫图片伪装指的是将带有文字的图片与正常文字混合在一起,以达到“鱼目混珠”的效果。这种混淆方式并不会影响用户阅读,但是可以让爬虫程序无法获得“所见”的文字内容。图片反爬虫通常会将一些关键信息以图片的形式展示出来。 举例 网址:aHR0cHM6Ly93d3cuZ3hyYy5jb20vY29tcGFueS9hYmFmMjU1Yy1mZjE3LTQ2YjAtOWM0Zi03YzkyOTZiY2FmMTc=,把电话信息通过图片的形式展示,如下: image-20220320193029220 对于这种图片的文字提取,需要用到光学字符识别技术OCR。 推荐几个好用的OCR库: PaddleOCR 。 EasyOCR pytesseract 本来想用PaddleOCR来进行文字提取的,但是我用的是MacOS M1芯,安装的时候有各种问题,所以这里也先不折腾,就用了EasyOCR进行识别,识别结果如下: image-20220320221502215 虽然爬虫工程师可以借助渲染工具获得页面渲染后的网页文本,但爬虫无法直接从图片这种媒体文件中获取字符。光学字符识别技术也有一定的缺陷,在面对扭曲文字、生僻字和有复杂干扰信息的图片时,它就无法发挥作用了。 利用字体反爬虫字体反爬原理: 主要利用了font-family这个属性,例如设置为my-font 在HTML里不常见的unicode 在CSS的字体中将其映射到常见的(可读的)字体,例如数字 爬虫在抓取数据的时候只能抓到unicode,而不是真实的数据 解决方案: 下载woff字体文件,转为tff文件 用百度字体编辑器打开,并确定其unicode与实际的映射关系 将下载的HTML内容按照映射关系进行替换 解析HTML并获取正确的数据 难点: 有些网站会动态的生成woff,这种反爬措施比较难以自动化绕开 举例 网址:aHR0cHM6Ly9jbHViLmF1dG9ob21lLmNvbS5jbi9iYnMvdGhyZWFkL2QxNzUxYzdiZDA1MzlkZTAvNzkyMjk2NjgtMS5odG1s,部分文字以特殊字体展示,如图: image-20220321200937272 我们通过抓包,拿到对应的字体文件,然后用百度文字编辑器打开: image-20220321201048528 拷贝大字对应的那一个奇怪的字符,转为unicode编码,如图: image-20220321201232378 可以看到ed68正好对应上边的$ED68这个汉子,即大。这个网站的字体是动态加载的,每次使用特殊字体的汉子是随机的,增加了反爬的难度。 CSS偏移反爬虫CSS 偏移反爬虫指的是利用 CSS 样式将乱序的文字排版为人类正常阅 读顺序的行为。这个概念不是很好理解,我们可以通过对比两段文字 来加深对这个概念的理解: HTML 文本中的文字:我的学号是 1308205,我在北京大学读书。 浏览器显示的文字:我的学号是 1380205,我在北京大学读书。 爬虫提取到的学号是 1308205,但用户在浏览器中看到的却是 1380205。如果不细心观察,爬虫工程师很容易被爬取结果糊弄。这种混淆方法和图片伪装一样,是不会影响用户阅读的。让人好奇的是浏览器如何将 HTML 文本中的数字按照开发者的意愿排序或放置呢? 这种放置规则是如何运作的呢? 举例 网址:http://www.porters.vip/confusion/flight.html# 可以看到航班机票不能直接拿到,而是用到CSS偏移: image-20220321204846610 我们可以看出规律,77764排成一列,每个宽度为16px,然后6这个数字左偏移32px就变成了76774,4这个数字向左偏移64个px就变成了46777,由于设置展示宽度为48px,所以只看到前三个数字,就刚好是467。 利用伪类反爬虫反爬原理: 不直接将内容展现到html的元素中 通过伪类的content属性将要展示的值展示出来。例如:鼠标悬浮的时候展示数据 解决方案: 利用playwright或者pyppeteer这样的自动化测试工具 在页面上执行下面的JS代码,即可获取content。注意:before是伪类,也可能是after。 123const el = document.querySelector("类选择器")const styles = getComputedStyle(el,'before')console.log(styles.content) # 打印数据值 利用字符切割反爬虫反爬原理: 将字符串用标签分割 由于是内联块级(inline-block),可以一行展示 通常还混淆有不现实的标签(display:none) 解决方案: 将内联块级标签的innerText拼接起来 注意过滤掉所有的display:none的属性 SVG映射反爬虫SVG 是用于描述二维矢量图形的一种图形格式。它基于 XML 描述图 形,对图形进行放大或缩小操作都不会影响图形质量。矢量图形的这 个特点使得它被广泛应用在 Web 网站中。 SVG反爬虫手段用矢量图形代替具体的文字,不会影响用户正常阅读,但爬虫程序 却无法像读取文字那样获得SVG图形中的内容。由于 SVG中的图形代表的也是一个个文字,所以在使用时必须在后端或前端将真实的文字与对应的SVG图形进行映射和替换,这种反爬虫手段被称为SVG映射反爬虫。 举例: 网址:http://www.porters.vip/confusion/food.html 可以看到,很多数字在原始HTML页面中都没有,而是以标签形式出现,如下图: image-20220321222654775 而商家电话号码处的显示就更奇怪了,一 个数字都没有。商家电话对应的 HTML 代码如下: 1234567891011<div class="col more">电话: <d class="vhkbvu"></d> <d class="vhk08k"></d> <d class="vhk08k"></d> <d class="">-</d> <d class="vhk84t"></d> <d class="vhk6zl"></d> <d class="vhkqsc"></d> <d class="vhkqsc"></d> <d class="vhk6zl"></d></div> 我们推测它是用d标签来占位代表一个数字,通过class属性来区分代表数字的含义。我们通过页面上已经展示的数字,来建立一个映射关系,如下: 1234567<d class="vhk08k"></d> <!-- 0 --><d class="vhk6zl"></d> <!-- 1 --><d class="vhk9or"></d> <!-- 2 --><d class="vhkbvu"></d> <!-- 4 --><d class="vhk84t"></d> <!-- 5 --><d class="vhkqsc"></d> <!-- 7 --><d class="vhkjj4"></d> <!-- 8 --> 那剩余的几个数字如3,6,9到哪里去找呢?既然是通过class区分,那么class的样式肯定会在CSS文件中有定义。 image-20220321224556130 我们去CSS文件中找,果然找到了以下代码: 123456789101112131415161718192021222324252627282930.vhk08k { background: -274px -141px;}.vhk6zl { background: -7px -15px;}.vhk9or { background: -330px -141px;}.vhkfln { background: -428px -15px;}.vhkbvu { background: -386px -97px;}.vhk84t { background: -176px -141px;}.vhkvxd { background: -246px -141px;}.vhkqsc { background: -288px -141px;}.vhkjj4 { background: -316px -141px;}.vhk0f1 { background: -316px -97px;} 从上到下依次与我们上边推断的几个数字相对应,因此,判定<d class="vhkfln"></d>对应数字3,<d class="vhkvxd"></d>对应数字6,<d class="vhk0f1"></d>对应数字9。至此,就破解了全部的数字了。 总结本文简单介绍了几种常见的CSS反爬,然后举了一些简单的例子加以分析,并没有涉及太多的代码层面,而且涉及到的知识也比较浅显,只是浅尝则止。因为本文是作为CSS反爬的开篇内容,后边会有专门的文章,利用更常见的网站,难度更大的网站,去深入分析每一种CSS反爬。","tags":[{"name":"CSS","slug":"CSS","permalink":"http://example.com/tags/CSS/"},{"name":"爬虫","slug":"爬虫","permalink":"http://example.com/tags/%E7%88%AC%E8%99%AB/"}]},{"title":"JS逆向之混淆JS手动逆向","date":"2022-03-08T12:24:52.000Z","path":"JS逆向之混淆JS手动逆向/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 前言写作目的:记录手动逆向一个JS高度混淆的网站的整个过程。 网址:aHR0cHM6Ly81NTI0OTY5Ni5jb206Nzc3Ny8= 逆向过程话不多说,直接开始调试。输入用户名17777777777和密码123456点击登录,弹出验证码: image-20220308163635586 一般来说网站如果出现复杂验证码都会配合JS参数加密增加防护等级。我们抓包抓到2个XHR请求: image-20220308163823432 初步推测第一个请求get是获取验证码,第二个请求check是校验验证码。今天的目标就是破解这两个请求的加密参数与返回值。 我们观察这2个接口,发现check请求的参数包含get请求的参数,所以只需要解决check请求的参数就行了。 我们直接看check请求,在Source面板打上XHR断点,断住checkv3.php请求: image-20220308170430516 在Call Stack中找到一个疑似加密点: image-20220308170529279 点击进去,可以看到JS代码基本上是高度混淆的: image-20220308170617796 我们把断点断到拼接请求参数的地方: image-20220309105805200 逆向URL生成规则先看下URL的生成,扣出代码: 1'url': _0x15a5d0[_0x59cb56(0x8bc, 0x763, 0xba1, '0jdF', 0x6bd)](_0x15a5d0[_0x59cb56(0x8ea, 0x8b8, 0x9b2, 'm*3l', 0xcfc)], _0x36b61f), 我们再取出_0x15a5d0[_0x59cb56(0x8bc, 0x763, 0xba1, '0jdF', 0x6bd)]放到console上输出,发现其是一个函数: image-20220309110157210 点击进去,看到它是一个花指令,就是把两个参数相加: image-20220309110253933 所以URL参数的生成实际上是调用一个加法,把两个参数相加。我们再看这个加法传入的2个参数。_0x15a5d0[_0x59cb56(0x8ea, 0x8b8, 0x9b2, 'm*3l', 0xcfc)]和_0x36b61f。 ``_0x15a5d0[_0x59cb56(0x8ea, 0x8b8, 0x9b2, ‘m*3l’, 0xcfc)]`是一个固定的地址。 image-20220309110445528 _0x36b61f是一个13位数的时间戳。 image-20220309110602734 所以URL实际上就是一个固定的地址拼接一个13位的时间戳,即/yzmtest/checkv3.php?t={13位时间戳}。 逆向data生成规则先扣出data生成那一部分代码: 1234567'data': _0xf828e5[_0x59cb56(0x7c7, 0x4a1, 0x425, 'F^5Z', 0x1f3)](JSON[_0x2e7c5c(0x9ff, 0x5fa, 0x813, 'Ra[M', 0x821) + _0x160f59(0x921, 0x690, 0x2a4, 'ZP*j', 0x657)]( { 'd': _0x15a5d0[_0x2e7c5c(0x2ce, 0x61d, 0xa5a, '4[E4', 0x33d)](_0x15a5d0[_0x160f59(0x4a2, 0x99e, 0xb7e, 'ZHhp', 0xb6b)](_0x15a5d0[_0x59cb56(0x92b, 0x683, 0x968, 'F^5Z', 0x9a7)](_0x15a5d0[_0x160f59(0x11f, 0x403, 0x29d, 'AVXK', 0x518)](_0x4fd69b[_0x8eb3(0xc7a, 0x728, 0x704, 'OBf4', 0xc4c)](''), ''), _0xf828e5[_0x8eb3(0x136, 0x44f, 0x748, 'HZxj', 0x2a3) + 'en']), _0x36b61f[_0x556be9(0x5cf, 0x870, 0x50b, 'Ra[M', 0x3af) + 'r'](-(0x2670 + -0x77d * -0x2 + -0x3568 * 0x1))), _0xf828e5[_0x160f59(0xae3, 0x5f9, 0x92d, 'NUpf', 0x8a5)]), 'username': _0xf828e5[_0x2e7c5c(0x241, 0x70d, 0x75c, 'o4oN', 0x1eb) + _0x8eb3(0xac, 0x90, 0x5c9, 'o4oN', 0x3a8)], 'password': _0xf828e5[_0x2e7c5c(0x4b3, 0x757, 0x5d1, '*EQ3', 0x86a) + _0x2e7c5c(0xb07, 0x6f1, 0xa86, 'ZP*j', 0x2bd)] })) 在console面板上,很容易看出,username是我们输入的账号17777777777前面拼接了e5。 image-20220309181634727 password则是明文: image-20220309181714546 所以上面的代码可以加以简化为: 1234567'data': _0xf828e5[_0x59cb56(0x7c7, 0x4a1, 0x425, 'F^5Z', 0x1f3)](JSON[_0x2e7c5c(0x9ff, 0x5fa, 0x813, 'Ra[M', 0x821) + _0x160f59(0x921, 0x690, 0x2a4, 'ZP*j', 0x657)]( { 'd': _0x15a5d0[_0x2e7c5c(0x2ce, 0x61d, 0xa5a, '4[E4', 0x33d)](_0x15a5d0[_0x160f59(0x4a2, 0x99e, 0xb7e, 'ZHhp', 0xb6b)](_0x15a5d0[_0x59cb56(0x92b, 0x683, 0x968, 'F^5Z', 0x9a7)](_0x15a5d0[_0x160f59(0x11f, 0x403, 0x29d, 'AVXK', 0x518)](_0x4fd69b[_0x8eb3(0xc7a, 0x728, 0x704, 'OBf4', 0xc4c)](''), ''), _0xf828e5[_0x8eb3(0x136, 0x44f, 0x748, 'HZxj', 0x2a3) + 'en']), _0x36b61f[_0x556be9(0x5cf, 0x870, 0x50b, 'Ra[M', 0x3af) + 'r'](-(0x2670 + -0x77d * -0x2 + -0x3568 * 0x1))), _0xf828e5[_0x160f59(0xae3, 0x5f9, 0x92d, 'NUpf', 0x8a5)]), 'username': 'e517777777777', 'password': '123456' })) 调试下_0x59cb56(0x7c7, 0x4a1, 0x425, 'F^5Z', 0x1f3) image-20220309182331379 再调试下_0x2e7c5c(0x9ff, 0x5fa, 0x813, 'Ra[M', 0x821) + _0x160f59(0x921, 0x690, 0x2a4, 'ZP*j', 0x657): image-20220309182359819 把上面的代码简化下: 1234567'data': _0xf828e5.enc.JSON.stringify( { 'd': _0x15a5d0[_0x2e7c5c(0x2ce, 0x61d, 0xa5a, '4[E4', 0x33d)](_0x15a5d0[_0x160f59(0x4a2, 0x99e, 0xb7e, 'ZHhp', 0xb6b)](_0x15a5d0[_0x59cb56(0x92b, 0x683, 0x968, 'F^5Z', 0x9a7)](_0x15a5d0[_0x160f59(0x11f, 0x403, 0x29d, 'AVXK', 0x518)](_0x4fd69b[_0x8eb3(0xc7a, 0x728, 0x704, 'OBf4', 0xc4c)](''), ''), _0xf828e5[_0x8eb3(0x136, 0x44f, 0x748, 'HZxj', 0x2a3) + 'en']), _0x36b61f[_0x556be9(0x5cf, 0x870, 0x50b, 'Ra[M', 0x3af) + 'r'](-(0x2670 + -0x77d * -0x2 + -0x3568 * 0x1))), _0xf828e5[_0x160f59(0xae3, 0x5f9, 0x92d, 'NUpf', 0x8a5)]), 'username': 'e517777777777', 'password': '123456' })) 可以看到,这里是用了某种加密算法对{“d”: xxx, “username”: “xxx”, “password”: “xxx”}进行加密从而得到data。这个加密算法到底是什么加密呢?我们console面板上输入_0xf828e5.enc然后点击进去: image-20220309184438821 看到了整个方法有2个分支,我们分别对2个分支打上断点,发现只进去else了。如果对JS常见的加密有过了解的话,这里看到iv,mode,padding这三个关键字立马会想到用的是AES加密算法。第一个参数_0x4d04a3是待加密的字符串: image-20220310100612354 第二个参数this[_0x19e2aa(0x93, 0x454, ‘cu5X’, 0x139, 0x43d) + ‘e’]是密钥。我们打印出其内容: 123456789var key = { "words": [ 1701066809, 929182054, 1698117986, 1697659188 ], "sigBytes": 16}; 第三个参数是AES加密的一些参数,mode一般是CBC,padding不重要可以不传。最后我们在console上输出iv的值: 123456789var iv = { "words": [ 1668053103, 875983984, 1731224932, 943273826 ], "sigBytes": 16}; 有了AES加密的这几个参数我们就可以很简单的还原出解密算法了。代码如下: 1234567891011121314151617181920212223242526272829var CryptoJS = require("crypto-js");var key = { "words": [ 1701066809, 929182054, 1698117986, 1697659188 ], "sigBytes": 16}; // 密钥,已经转化为128bit的格式。var iv = { "words": [ 1668053103, 875983984, 1731224932, 943273826 ], "sigBytes": 16}; // IV,已经转化为128bit的格式。function Decrypt(word) { let a = CryptoJS.AES.decrypt(word, key, { iv: iv, mode: CryptoJS.mode.CBC }); return CryptoJS.enc.Utf8.stringify(a);}let data = "";console.log(Decrypt(data)); 还记得前面我们提到过抓到2个XHR请求吗?一个是请求验证码的,一个是进行验证码验证的。我们看第一个请求验证码的请求。 image-20220310101530093 这个返回值c是不是就是用的AES加密呢?我们用上边的解码程序试验一下。果不其然,可以正确反解出加密内容: image-20220310101738862 里面的一串JSON正好是我们刚才出现的验证码的内容: 12345678[ {id: '90b389d8490d42a8', txt: '鸭子'}, {id: 'a7985fb229d9e935', txt: '长颈鹿'}, {id: 'c38548f7b6c0a3d8', txt: '小马'}, {id: '4c2c8bc886b8bf8d', txt: '海马'}, {id: '25897767b2ffc531', txt: '牛'}, {id: '24bafe8f4a1eac0e', txt: '斑马'},] 记住这个JSON,我们待会还有用。我们接着回到data破解的思路上去。data的生成代码进一步简化: 1234567'data': AES.encode( { 'd': _0x15a5d0[_0x2e7c5c(0x2ce, 0x61d, 0xa5a, '4[E4', 0x33d)](_0x15a5d0[_0x160f59(0x4a2, 0x99e, 0xb7e, 'ZHhp', 0xb6b)](_0x15a5d0[_0x59cb56(0x92b, 0x683, 0x968, 'F^5Z', 0x9a7)](_0x15a5d0[_0x160f59(0x11f, 0x403, 0x29d, 'AVXK', 0x518)](_0x4fd69b[_0x8eb3(0xc7a, 0x728, 0x704, 'OBf4', 0xc4c)](''), ''), _0xf828e5[_0x8eb3(0x136, 0x44f, 0x748, 'HZxj', 0x2a3) + 'en']), _0x36b61f[_0x556be9(0x5cf, 0x870, 0x50b, 'Ra[M', 0x3af) + 'r'](-(0x2670 + -0x77d * -0x2 + -0x3568 * 0x1))), _0xf828e5[_0x160f59(0xae3, 0x5f9, 0x92d, 'NUpf', 0x8a5)]), 'username': 'e517777777777', 'password': '123456' })) 接下来看看最核心的d的生成规则。_0x15a5d0[_0x2e7c5c(0x2ce, 0x61d, 0xa5a, ‘4[E4’, 0x33d)]是一个加法的花指令。 image-20220310102400570 _0x15a5d0[_0x59cb56(0x92b, 0x683, 0x968, ‘F^5Z’, 0x9a7)]也是一个加法的花指令: image-20220310102640758 _0x15a5d0[_0x59cb56(0x92b, 0x683, 0x968, ‘F^5Z’, 0x9a7)]也是加法花指令。 image-20220310102715978 _0x15a5d0[_0x160f59(0x11f, 0x403, 0x29d, ‘AVXK’, 0x518)]依旧是一个加法花指令。 image-20220310102811012 _0x4fd69b[_0x8eb3(0xc7a, 0x728, 0x704, ‘OBf4’, 0xc4c)]是内置的join方法。 image-20220310102934544 _0x4fd69b是个字符串: image-20220310105915453 _0x8eb3(0x136, 0x44f, 0x748, ‘HZxj’, 0x2a3) + ‘en’是字符串$strlen image-20220310103214178 _0xf828e5[_0x8eb3(0x136, 0x44f, 0x748, ‘HZxj’, 0x2a3) + ‘en’]则是取_0xf828e5这个object的$strlen属性,这里的值是3。 _0x556be9(0x5cf, 0x870, 0x50b, ‘Ra[M’, 0x3af) + ‘r’这里是substr。 image-20220310104111880 _0x36b61f是13位的时间戳。 image-20220310104215390 -(0x2670 + -0x77d * -0x2 + -0x3568 * 0x1)是固定的常量,-2。 image-20220310104254188 _0x160f59(0xae3, 0x5f9, 0x92d, ‘NUpf’, 0x8a5)是字符串$ver。这个ver其实在我们刚才解码第一个请求的返回值的里面就有了,值为3587,跟这里的吻合。 image-20220310110046938 最终简化代码如下: 12345678'data': AES.encode( { 'd': add(add(add(add("4c2c8bc886b8bf8d".join(''), ''), _0xf828e5.$strlen), _0x36b61f.substr(-2)), _0xf828e5.$ver), // 'd': "4c2c8bc886b8bf8d" + _0xf828e5.$strlen + _0x36b61f.substr(-2)) + _0xf828e5.$ver 'username': 'e517777777777', 'password': '123456' })) 到这里d的生成算法基本上一目了然了。d=字符串(这里是4c2c8bc886b8bf8d)+_0xf828e5.$strlen(这里是3)+13位时间戳的最后2位(这里是17)+版本号(第一个请求接口有返回为3587)=4c2c8bc886b8bf8d3173587。扣出d的生成代码,在console上输出,验证下: image-20220310111442039 完全吻合。现在唯一的问题是_0x4fd69b这个字符串怎么来的以及_0xf828e5.$strlen这个值怎么算出来的,解决了这两个问题,d的生成规则就破解了。 我们手动搜索_0x4fd69b这个字符串,总共找到三处: image-20220310112140608 扣出第二处的代码如下: 1_0x4fd69b[_0x219591('FZa9', 0x79c, 0x826, 0x6a9, 0x45f)](_0xf828e5[_0x59b553('W]B)', 0xd30, 0x809, 0x809, 0x2c4)][_0x19f5d9[_0x3ebe03('nCyg', 0xb6c, 0x868, 0x4ee, 0xd78)](parseInt, _0x1bf4a9[_0x59b553('#og4', 0xbb9, 0x8a2, 0x953, 0x441) + 'ce'](_0x19f5d9[_0x3ebe03('F^5Z', 0x448, 0xc1, -0x2ba, 0x8c)], ''))]['d']); 按照上边提供的方法,在console上分段调试代码含义,代码反混淆如下(为节省篇幅,从这里开始,代码反混淆过程都不会写了,直接给出反混淆的结果): 1_0x4fd69b.push(_0xf828e5.$list[parseInt("btncanv_3".replace("btncanv_", ""))]['d']); 这个代码的意思就是取上边我们提到的验证码数组中索引为3的值,即4c2c8bc886b8bf8d,把这个值push到数组_0x4fd69b(这里虽然取得是d这个属性,但是实际上d属性跟id属性的值是一样的,_0x281004[‘d’] = _0x3aa5c6[‘id’])。这里的这个btncanv_3恰好是验证码的答案,即正确答案的元素的id。 image-20220310140550569 那么这个btncanv_3是怎么确定的呢?是我们手动点选验证码图案的时候选中的,我们手动选中了验证码图案,JS代码会根据我们选中的图案,拿到它的id(如果有选中了多个,也只取第一个),然后进行JS加密,后端会根据相应的解密算法,拿到我们上传的那个验证码。我们开篇提到过“一般来说网站如果出现复杂验证码都会配合JS参数加密增加防护等级”,这里就验证了这句话。我们这里虽然破解了验证码验证接口表单数据的加密算法,但是验证码的点选,我们还需要辅助相应的验证码识别的算法,帮助我们完成验证码的识别与点选。这里主要是讲解手动反混淆方案,验证码后边会有专门的专题文章进行介绍,先埋一个坑后边补上。 接下来看下另外一个_0xf828e5.$strlen的生成规则。我们文件中全局搜索’en’(为什么要搜索这个?因为前面$strlen的字符串混淆是_0x8eb3(0x136, 0x44f, 0x748, ‘HZxj’, 0x2a3) + ‘en’),果然被我们找到了。代码如下: 1this[_0x2a85c2(0x8a2, 0x60c, 0xa3d, 0x7b0, '0NjW') + 'en'] = _0x15a5d0[_0x19b874(0xc8f, 0xe5a, 0x13b9, 0xb8b, 'W]B)')](Math[_0x26b2f7(0xf44, 0xc7b, 0xbc1, 0x1077, 'o4oN')](_0x15a5d0[_0x26b578(0xfa3, 0xcfb, 0x820, 0x11b8, 'm*3l')](-0x1bcd + 0x1 * 0x1b23 + -0xaf * -0x1, Math[_0x2a85c2(0x11fb, 0xf8b, 0xd69, 0x137a, '*EQ3') + 'm']())), -0x727 * 0x2 + 0x24d7 + -0x1f * 0xba) 代码反混淆之后整理如下: 1this.$strlen = Math.floor(5 * Math.random()) + 3 至此,整个data数据生成过程调试完了。最终的算法伪代码整理如下: 1234567891011121314151617181920let _0xf828e5.$strlen = Math.floor(5 * Math.random()) + 3;let _0x36b61f = new Date().getTime();let _0xf828e5.$ver = "3587";let code = [ {id: '90b389d8490d42a8', txt: '鸭子'}, {id: 'a7985fb229d9e935', txt: '长颈鹿'}, {id: 'c38548f7b6c0a3d8', txt: '小马'}, {id: '4c2c8bc886b8bf8d', txt: '海马'}, {id: '25897767b2ffc531', txt: '牛'}, {id: '24bafe8f4a1eac0e', txt: '斑马'},]; // 这个验证码的JSON从第一个接口中拿// AES的密钥以及IV值上边已经给出'data': AES.encode( { 'd': code[/*人工选中的第一个验证码的索引*/].id + _0xf828e5.$strlen + _0x36b61f.substr(-2) + _0xf828e5.$ver 'username': 'e517777777777', 'password': '123456' })) 逆向clientid生成规则扣出clientid生成相关的代码: 1this[_0x26b2f7(0x9fc, 0xa9b, 0xe1c, 0x6b2, '%jat') + _0x34ce5a(0x1321, 0xfb2, 0x11d0, 0x109c, 'm*3l')] = _0x1c3499[_0x2a85c2(0xf23, 0xc3d, 0x99d, 0xf50, 'OBf4')]('')[_0x26b2f7(0xf47, 0xb49, 0xe76, 0xc02, 'W]B)') + 'r'](0x4f * -0x1 + 0x1779 + -0x172a, -0x4ff * 0x3 + -0x3 * -0x75c + 0x1 * -0x70d) 代码反混淆如下: 12this.$clientid = _0x1c3499.join("").substr(0, 10)// _0x1c3499 = ['54mwjp6', 8, 'zvc'] $clientid的生成规则依赖_0x1c3499,我继续往下看,扣出_0x1c3499的相关代码: 1234_0x1c3499 = []_0x1c3499[_0x26b578(0xc37, 0xd61, 0x122c, 0xeeb, 'g(lc')](_0x3e49dd[_0x2a85c2(0x1ea, 0x608, 0x810, 0xf3, 'o4oN') + 'r'](-0x5f * 0x1b + -0x1 * 0x15c1 + 0x1fc6, _0x15a5d0[_0x26b578(0xee3, 0xb7c, 0x893, 0xddb, 'HZxj')](_0x3badf1, -0x1 * -0x14e3 + 0x26 * -0x56 + 0x81e * -0x1))),_0x1c3499[_0x34ce5a(0x10ca, 0xef3, 0xa62, 0x10c1, '[tJe')](_0x3badf1),_0x1c3499[_0x19b874(0x4bb, 0x55a, 0x35d, 0x7ce, 'R[NP')](_0x3e49dd[_0x2a85c2(0x11c, 0x5da, 0xde, 0x229, 'FrGG') + 'r'](_0x3badf1, _0x3e49dd[_0x26b578(0xf55, 0xbca, 0x95d, 0xd00, '0jdF') + 't'])), 代码反混淆如下: 123456_0x1c3499 = [];_0x1c3499.push(_0x3e49dd.substr(0, _0x3badf1 - 1)); //_0x1c3499.push(54mwjp6)_0x1c3499.push(_0x3badf1); //_0x1c3499.push(8)_0x3e49dd.push(_0x3e49dd.substr(_0x3badf1, _0x3e49dd.length)); //_0x1c3499.push(zvc)// _0x3e49dd = "54mwjp6tzvc"// _0x3badf1 = 8 可以看到__0x1c3499的值又依赖_0x3e49dd和_0x3badf1。我们再扣出相应的代码: 123_0x3e49dd = _0x15a5d0[_0x2a85c2(0x240, 0x6a9, 0x570, 0x230, 'ZP*j')](Number, _0x15a5d0[_0x26b578(0x8cc, 0xaab, 0xa86, 0xf1d, 'NUpf')](Math[_0x19b874(0x12ba, 0xd7c, 0xb35, 0xe72, 'o4oN') + 'm']()[_0x34ce5a(0xffa, 0xa91, 0x802, 0xe92, 'uUCz') + _0x19b874(0x174, 0x684, 0x9d7, 0x737, 'G0Im')]()[_0x26b2f7(0xaa8, 0x6bd, 0x562, 0x35a, 'F^5Z') + 'r'](0x10 * 0xc2 + 0x6d * -0x5 + -0x9 * 0x11c, 0xb3 * 0x35 + 0x13 * 0x1a5 + -0x444a * 0x1), Date[_0x26b2f7(0xbf3, 0xd8c, 0xaf2, 0xacf, 'g(lc')]()))[_0x26b578(0x326, 0x69f, 0xaa3, 0x78d, '*EQ3') + _0x26b2f7(0x8dc, 0x900, 0x8f0, 0xa3a, 'hROy')](0x143b + 0x53c + -0x1953) _0x3badf1 = _0x15a5d0[_0x34ce5a(0x11ce, 0xdf7, 0x9a2, 0xad1, '!OnF')](parseInt, _0x3577fe[Math[_0x19b874(0x871, 0xb0b, 0xf76, 0xb41, '#og4')](_0x15a5d0[_0x2a85c2(0x123a, 0xeb6, 0xbf1, 0x10d4, 'bsj&')](Math[_0x2a85c2(0x7ca, 0x776, 0x9aa, 0x847, 'R[NP') + 'm'](), _0x3577fe[_0x26b2f7(0x2ac, 0x73b, 0xbfc, 0x1ff, 'FrGG') + 'h']))]) 代码反混淆后结果如下: 12_0x3e49dd = Number(Math.random().toString().substr(3, 4) + Date.now().toString()).toString(36)_0x3badf1 = parseInt(_0x3577fe[Math.floor(Math.random() * _0x3577fe.length)]) _0x3577fe的值是固定的三个元素的数组,如下: 1_0x3577fe = [-0x2090 + -0x1b * 0x139 + 0x4197, 0x959 + -0x248c + 0x45 * 0x65, 0x2 * -0x1ea + 0x229f + -0x1ec3]; // 4 6 8 到这里为止,$clientid的生成规则就全部反混淆出来了,最终的代码整理如下: 1234567891011let _0x3577fe = [4, 6, 8];let _0x3e49dd = Number(Math.random().toString().substr(3, 4) + Date.now().toString()).toString(36);let _0x3badf1 = parseInt(_0x3577fe[Math.floor(Math.random() * _0x3577fe.length)]);let _0x1c3499 = [];_0x1c3499.push(_0x3e49dd.substr(0, _0x3badf1 - 1));_0x1c3499.push(_0x3badf1);_0x1c3499.push(_0x3e49dd.substr(_0x3badf1, _0x3e49dd.length));let clientid = _0x1c3499.join("").substr(0,10);console.log(clientid); 真是一层一层剥开你的心🥴 逆向token生成规则扣出token生成的相关代码: 1'token': _0xf828e5[_0x2e7c5c(0x3c9, 0x4c, -0x2fc, 'hROy', -0x1f8)](_0x15a5d0[_0x2e7c5c(0x78e, 0x6f8, 0x927, '(e@x', 0x3d0)](_0x15a5d0[_0x59cb56(0x398, 0x6f8, 0x7eb, '(e@x', 0x571)](_0x15a5d0[_0x160f59(0xbca, 0xad5, 0x7c2, 'FZa9', 0xa2c)](_0xf828e5[_0x2e7c5c(0x56b, 0x25a, 0x1e6, 'g(lc', -0x22c) + _0x556be9(0x527, 0x2b, 0x30c, 'su5h', 0x58f)], _0xf828e5[_0x8eb3(0x1bf, 0x17c, 0x58, 'Ra[M', -0xec) + _0x2e7c5c(0x203, 0x237, -0x41, 'Qm)6', 0x40c)]), _0xf828e5[_0x2e7c5c(0xd48, 0x8de, 0x717, 'j[vi', 0xd5e) + _0x59cb56(0x301, 0x4c3, 0x1e5, '0jdF', 0x8fb)]), _0x15a5d0[_0x59cb56(-0x1ee, 0xb3, 0x35, 'OBf4', 0x524)])) 反混淆之后的最终代码如下: 1'token': _0xf828e5["sign"](_0xf828e5.$clientid + _0xf828e5.$username + _0x15a5d0.sGZgF)) 庆幸的是,这里的_0x15a5d0.sGZgF是一个固定的字符串,内容为”x045783”。所以重点在于破解这个加密方法sign。我们扣出相应的代码: 12345678910111213_0x58b6e0[_0x339e58(0x61a, -0x24f, 0x94, 'nCyg', 0x267) + _0x18c3fa(0x813, 0x825, 0x36f, 'TEE1', 0x6be)][_0x1d90d1(0x18a, 0x8b2, -0x158, 'Qm)6', 0x348)] = function(_0x1c6621) { // ...此处省略若干行 var _0x4f239d = [] , _0x4996c2 = cjs[_0x8a0b67('FrGG', 0x9dc, 0x3fa, 0xaba, 0x868)](_0x15a5d0[_0x343f8b('ZiBy', 0x377, 0x152, 0x542, 0x4df)](_0x1c6621, _0x46b6c3[_0x8a0b67('TEE1', 0x3db, 0x7c8, 0x6a4, 0x347) + _0x429176('NUpf', 0x241, -0x1c1, 0x4ab, 0x81)][_0x343f8b('HZxj', 0x49c, 0x718, 0xa22, 0x854)][_0x429176('F^5Z', 0x472, 0x2c9, 0x687, 0x272) + _0x8a0b67('g(lc', 0x39e, 0x19b, 0x616, 0x5b8) + 'e']()))[_0x4b55c2('OBf4', -0x22b, -0x342, 0x13a, 0x2c) + _0x8a0b67('xVxp', -0x78, 0x45c, 0x287, 0x16b)](); return _0x4f239d[_0xafe698('Ra[M', 0x1d0, -0x66b, 0x2ad, -0x177)](_0x4996c2[_0xafe698('IF#P', 0x2d0, 0x958, 0x506, 0x55e) + 'r'](-0x15d + -0x49 * -0x45 + -0x1246, 0x8c2 + 0x25af + -0x2e6c)), _0x4f239d[_0x8a0b67('g(lc', 0x34a, 0x43e, 0x955, 0x683)](_0x4996c2[_0x4b55c2('ZiBy', 0x71, 0x10f, 0x9a7, 0x506) + 'r'](0x142b * -0x1 + 0x21b7 + 0xd85 * -0x1, -0xb69 * -0x3 + -0x61 * 0x58 + -0xde)), _0x4f239d[_0x429176('W]B)', 0x82f, 0x119, 0x27e, 0x4fb)](_0x4996c2[_0x4b55c2('AVXK', 0x3e8, 0x4fc, 0x6f2, 0x79f) + 'r'](0x61b + 0xc * -0x11e + 0x75c, -0x677 * 0x1 + -0x2272 * 0x1 + 0x28ee)), _0x4f239d[_0xafe698('0NjW', 0x3c5, 0x181, -0x47, 0x125)](_0x4996c2[_0x8a0b67('uUCz', 0xcea, 0x99c, 0x405, 0x8c3) + 'r'](-0x4ba + -0x59d * 0x1 + 0xa6b, -0x2 * 0xe80 + 0x1c01 + 0x104)), _0x4f239d[_0x429176('IF#P', 0x2b, -0x2cb, 0x33e, 0x20d)](_0x4996c2[_0xafe698('TEE1', 0xcb6, 0x668, 0x5fa, 0x833) + 'r'](-0x4b * -0x4 + -0x2a * 0x86 + 0x5 * 0x42e, -0xd * -0x119 + -0x1791 + 0x951)), _0x4f239d[_0x429176('j[vi', 0x5e6, 0x2bd, 0x5e2, 0x648)](_0x4996c2[_0x429176('#og4', 0xd5, 0x68e, 0x54b, 0x516) + 'r'](-0x193d + 0x399 * -0x5 + -0x1 * -0x2b55, -0x8a8 * -0x4 + 0xd6 * 0x6 + 0x3 * -0xd35)), _0x4f239d[_0x8a0b67('Cy2U', 0x689, 0x578, 0x485, 0x71e)](_0x4996c2[_0x4b55c2('4[E4', 0x272, 0x5ce, 0x4e1, 0x668) + 'r'](-0x97 * 0x17 + -0xe * 0x46 + 0x1166, -0x18f4 * -0x1 + -0x93 * 0x3b + 0x8ef * 0x1)), _0x4f239d[_0x343f8b('&zHf', -0xb, -0x5bc, -0x16b, -0x108)]('');} 看到这么大一段代码不要慌!!!我们慢慢开始剥洋葱。🙃,剥到最后,很简单。 1234_0x58b6e0.prototype.sign = function(_0x1c6621) { var _0x4f239d = [], _0x4996c2 = MD5(_0x1c6621 + document.location.href.toLowerCase()).toString(); return _0x4f239d.push(_0x4996c2.substr(10, 5)), _0x4f239d.push(_0x4996c2.substr(7, 5)), _0x4f239d.push(_0x4996c2.substr(15, 5)), _0x4f239d.push(_0x4996c2.substr(20, 5)), _0x4f239d.push(_0x4996c2.substr(22, 5)), _0x4f239d.push(_0x4996c2.substr(27, 5)), _0x4f239d.push(_0x4996c2.substr(1, 2)), _0x4f239d.join("");} 其实就是对传入的参数做了一个md5的加密,然后进行乱序处理。 到这里我们两个接口的所有请求参数加密算法,以及接口的返回值的解密算法都已经破解了。我们接下来简单验证下是否正确。 验证由于check接口需要机器学习对验证码进行识别,所以这里只验证get接口的参数。用NodeJS的Express框架搭建好获取token的服务,供Python调用。 运行结果如下: image-20220312220733810 image-20220312220820149 总结本文通过一个网站,手动对AST混淆代码进行了一个反混淆。在JS代码安全防护原理——AST混淆原理中提到的几种混淆原理基本都出现过了。比如数组混淆,数组逆向,花指令,流程平坦化,逗号表达式混淆,字符串加密,常量加密等。可以看到,手动混淆的过程是极其容易出错,工作量非常大且十分痛苦的。接下来会写相关文章,介绍如何通过工具对AST混淆代码进行自动反混淆,不过,手动混淆这种能力也是必须要掌握的,万一你使用的工具失效了,或者说遇到一些更加特殊的网站,只能通过手动混淆呢?如果想获取本文的完整代码,扫码关注公众号,然后公众号内回复关键字02即可获取。 qrcode_for_gh_509fdefd3c81_258","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"AST","slug":"AST","permalink":"http://example.com/tags/AST/"}]},{"title":"JS代码安全防护原理——AST混淆原理","date":"2022-03-03T14:22:11.000Z","path":"JS代码安全防护原理——AST混淆原理/","text":"常量的混淆原理本篇所用的demo如下。接下来的案例都是围绕这个demo进行混淆。 12345678910Date.prototype.format = function(formatStr) { var str = formatStr; var Week = ['日', '一', '二', '三', '四', '五', '六']; str = str.replace(/yyyy|YYYY/, this.getFullYear()); str = str.replace(/MM/, (this.getMonth() + 1) > 9 ? (this.getMonth() + 1).toString() : '0' + (this.getMonth() + 1)); str = str.replace(/dd|DD/, this.getDate() > 9 ? this.getDate().toString() : '0' + this.getDate()); return str;}console.log(new Date().format('yyyy-MM-dd')); 对象属性的两种访问方式看下面一段代码: 1234567891011function People(name) { this.name = name;}People.prototype.sayHello = function() { console.log('Hello');}var p = new People('zhang san');console.log(p.name); // zhang sanp.sayHello(); // Helloconsole.log(p['name']); // zhang sanp['sayHello'](); // Hello p.name这种方式name是一个标识符,必须明确出现在代码中,不能加密和拼接。 p[‘name’]这种方式name是一个字符串。由于是字符串,所以访问的时候可以进行拼接和加密等操作。所以在JS混淆中,一般会选择这种方式访问属性。 所以改变对象属性的访问方式,是代码混淆的前提。 所以开篇提到的demo改变对象属性的访问方式之后,代码修改如下: 1234567891011window['Date']['prototype']['format'] = function(formatStr) { var str = formatStr; var Week = ['日', '一', '二', '三', '四', '五', '六']; str = str['replace'](/yyyy|YYYY/, this['getFullYear']()); str = str['replace'](/MM/, (this['getMonth']() + 1) > 9 ? (this['getMonth']() + 1).toString() : '0' + (this['getMonth']() + 1)); str = str['replace'](/dd|DD/, this['getDate']() > 9 ? this['getDate']().toString() : '0' + this['getDate']()); return str;}console.log(new window['Date']()['format']('yyyy-MM-dd')); Date是JS的内置对象,在JS中很多内置对象都属于window的属性。另外,代码中定义的全局变量都是全局对象window的属性,代码中定义的全局方法都是全局对象window的方法。全局对象的属性或者方法在调用的时候可以省略全局对象名。比如,new window.Date()等同于new Date()。由于把Date变成了字符串,所以前面必须加window。 十六进制字符串在JS中支持字符串的十六进制形式表示,所以可以用字符串的十六进制形式来代替原有的字符串。比如’yyyy-MM-dd’可以表示成’\\x79\\x79\\x79\\x79\\x2d\\x4d\\x4d\\x2d\\x64\\x64’。其实,0x79就是字母y的ASCII码的十六进制形式,其余的字母类推。可以用一个方法来完成十六进制字符串的转换。 12345678function hexEnc(code) { let hexStr = [] for (let i = 0, s; i < code.length; i++) { s = code.charCodeAt(i).toString(16); hexStr += '\\\\x' + s; } return hexStr;} 开篇的demo转换为十六进制字符串之后如下: 12345678910window['\\x44\\x61\\x74\\x65']['\\x70\\x72\\x6f\\x74\\x6f\\x74\\x79\\x70\\x65']['\\x66\\x6f\\x72\\x6d\\x61\\x74'] = function(formatStr) { var str = formatStr; var Week = ['\\x65e5', '\\x4e00', '\\x4e8c', '\\x4e09', '\\x56db', '\\x4e94', '\\x516d']; str = str['\\x72\\x65\\x70\\x6c\\x61\\x63\\x65'](/yyyy|YYYY/, this['\\x67\\x65\\x74\\x46\\x75\\x6c\\x6c\\x59\\x65\\x61\\x72']()); str = str['\\x72\\x65\\x70\\x6c\\x61\\x63\\x65'](/MM/, (this['\\x67\\x65\\x74\\x4d\\x6f\\x6e\\x74\\x68']() + 1) > 9 ? (this['\\x67\\x65\\x74\\x4d\\x6f\\x6e\\x74\\x68']() + 1).toString() : '\\x30' + (this['\\x67\\x65\\x74\\x4d\\x6f\\x6e\\x74\\x68']() + 1)); str = str['\\x72\\x65\\x70\\x6c\\x61\\x63\\x65'](/dd|DD/, this['\\x67\\x65\\x74\\x44\\x61\\x74\\x65']() > 9 ? this['\\x67\\x65\\x74\\x44\\x61\\x74\\x65']().toString() : '\\x30' + this['\\x67\\x65\\x74\\x44\\x61\\x74\\x65']()); return str;}console.log(new window['\\x44\\x61\\x74\\x65']()['\\x66\\x6f\\x72\\x6d\\x61\\x74']('\\x79\\x79\\x79\\x79\\x2d\\x4d\\x4d\\x2d\\x64\\x64')); unicode字符串在JS中,字符串除了可以表示成十六进制的形式外,还支持使用unicode形式表示。比如: 以var Week = [‘日’, ‘一’, ‘二’, ‘三’, ‘四’, ‘五’, ‘六’]为例,可以表示成var Week = [‘\\u65e5’, ‘\\u4e00’, ‘\\u4e8c’, ‘\\u4e09’, ‘\\u56db’, ‘\\u4e94’, ‘\\u516d’] 非中文的情况,Date可以表示成’\\u0044\\u0061\\u0074\\u0065’ 从上述例子不难看出,unicode形式就是\\u开头,后面跟四位数的十六进制形式,不足四位的补0。可以通过以下代码完成unicode转换: 1234567function unicodeEnc(str) { var value = ''; for (var i = 0; i < str.length; i++) { value += "\\\\u" + ("0000" + parseInt(str.charCodeAt(i)).toString(16)).substr(-4); } return value;} 开篇的demo转换为unicode编码之后如下: 12345678910window['\\u0044\\u0061\\u0074\\u0065']['\\u0070\\u0072\\u006f\\u0074\\u006f\\u0074\\u0079\\u0070\\u0065']['\\u0066\\u006f\\u0072\\u006d\\u0061\\u0074'] = function(formatStr) { var str = formatStr; var Week = ['\\u65e5', '\\u4e00', '\\u4e8c', '\\u4e09', '\\u56db', '\\u4e94', '\\u516d']; str = str['\\u0072\\u0065\\u0070\\u006c\\u0061\\u0063\\u0065'](/yyyy|YYYY/, this['\\u0067\\u0065\\u0074\\u0046\\u0075\\u006c\\u006c\\u0059\\u0065\\u0061\\u0072']()); str = str['\\u0072\\u0065\\u0070\\u006c\\u0061\\u0063\\u0065'](/MM/, (this['\\u0067\\u0065\\u0074\\u004d\\u006f\\u006e\\u0074\\u0068']() + 1) > 9 ? (this['\\u0067\\u0065\\u0074\\u004d\\u006f\\u006e\\u0074\\u0068']() + 1).toString() : '\\u0030' + (this['\\u0067\\u0065\\u0074\\u004d\\u006f\\u006e\\u0074\\u0068']() + 1)); str = str['\\u0072\\u0065\\u0070\\u006c\\u0061\\u0063\\u0065'](/dd|DD/, this['\\u0067\\u0065\\u0074\\u0044\\u0061\\u0074\\u0065']() > 9 ? this['\\u0067\\u0065\\u0074\\u0044\\u0061\\u0074\\u0065']().toString() : '\\u0030' + this['\\u0067\\u0065\\u0074\\u0044\\u0061\\u0074\\u0065']()); return str;}console.log(new window['\\u0044\\u0061\\u0074\\u0065']()['\\u0066\\u006f\\u0072\\u006d\\u0061\\u0074']('\\u0079\\u0079\\u0079\\u0079\\u002d\\u004d\\u004d\\u002d\\u0064\\u0064')); unicode字符和十六进制字符串都能轻易的还原,即直接把字符串放到控制台打印即可还原。 字符串的ASCII码混淆首先关注2个方法。 123console.log('x'.charCodeAt(0)); //120console.log('b'.charCodeAt(0)); //98console.log(String.fromCharCode(120, 98)); //xb charCodeAt方法表示把字符串转换成ASCII编码,fromCharCode表示把ASCII码转换为字符串形式。 可以通过以下代码将字符串变成字节数组。 1234567function stringToByte(str) { var byteArr = []; for (var i = 0; i < str.length; i++) { byteArr.push(str.charCodeAt(i)); } return byteArr;} 比如demo中的format可以用字节数组表示为[102, 111, 114, 109, 97, 116],因此代码中的format可以表示成String.fromCharCode(102, 111, 114, 109, 97, 116)。注意,fromCharCode接受的参数类型不是数组,而是可变参数类型。如果非要传一个数组,可以使用String.fromCharCode.apply(null, [102, 111, 114, 109, 97, 116])。JS的函数也是对象,可以给函数定义属性和方法,而函数本身也自带一些属性和方法。apply就是从函数的原型对象Function.prototype继承过来的方法。 ASCII码混淆不仅可以用于混淆字符串,还可以用来做代码混淆。比如我们把代码str = str['replace'](/yyyy|YYYY/, this['getFullYear']());看作一个字符串: 12stringToByte("str = str['replace'](/yyyy|YYYY/, this['getFullYear']());");// [ 115, 116, 114, 32, 61, 32, 115, 116, 114, 91, 39, 114, 101, 112, 108, 97, 99, 101, 39, 93, 40, 47, 121, 121, 121, 121, 124, 89, 89, 89, 89, 47, 44, 32, 116, 104, 105, 115, 91, 39, 103, 101, 116, 70, 117, 108, 108, 89, 101, 97, 114, 39, 93, 40, 41, 41, 59 ] 然后再把这个字符串当作代码执行即可。在JS中把字符串当作代码执行的有2个方法,eval和Function。其中eval用来执行一段代码,Function用来生成一个函数。 所以我们之前的demo可以改写如下: 12345678910window['\\u0044\\u0061\\u0074\\u0065']['\\u0070\\u0072\\u006f\\u0074\\u006f\\u0074\\u0079\\u0070\\u0065']['\\u0066\\u006f\\u0072\\u006d\\u0061\\u0074'] = function(formatStr) { var str = formatStr; var Week = ['\\u65e5', '\\u4e00', '\\u4e8c', '\\u4e09', '\\u56db', '\\u4e94', '\\u516d']; eval(String.fromCharCode(115, 116, 114, 32, 61, 32, 115, 116, 114, 91, 39, 114, 101, 112, 108, 97, 99, 101, 39, 93, 40, 47, 121, 121, 121, 121, 124, 89, 89, 89, 89, 47, 44, 32, 116, 104, 105, 115, 91, 39, 103, 101, 116, 70, 117, 108, 108, 89, 101, 97, 114, 39, 93, 40, 41, 41, 59)) str = str['\\u0072\\u0065\\u0070\\u006c\\u0061\\u0063\\u0065'](/MM/, (this['\\u0067\\u0065\\u0074\\u004d\\u006f\\u006e\\u0074\\u0068']() + 1) > 9 ? (this['\\u0067\\u0065\\u0074\\u004d\\u006f\\u006e\\u0074\\u0068']() + 1).toString() : '\\u0030' + (this['\\u0067\\u0065\\u0074\\u004d\\u006f\\u006e\\u0074\\u0068']() + 1)); str = str['\\u0072\\u0065\\u0070\\u006c\\u0061\\u0063\\u0065'](/dd|DD/, this['\\u0067\\u0065\\u0074\\u0044\\u0061\\u0074\\u0065']() > 9 ? this['\\u0067\\u0065\\u0074\\u0044\\u0061\\u0074\\u0065']().toString() : '\\u0030' + this['\\u0067\\u0065\\u0074\\u0044\\u0061\\u0074\\u0065']()); return str;}console.log(new window['\\u0044\\u0061\\u0074\\u0065']()['\\u0066\\u006f\\u0072\\u006d\\u0061\\u0074']('\\u0079\\u0079\\u0079\\u0079\\u002d\\u004d\\u004d\\u002d\\u0064\\u0064')); 字符串常量加密字符串加密的最核心思想是先把字符串加密得到密文,然后在使用之前,调用对应的函数去解密,得到明文。代码中仅仅出现解密函数和明密文。当然也可以使用不同的加密方法去加密字符串,然后再调用不同的解密函数去解密。字符串加密最简单的方式是Base64编码。 浏览器中自带Base64的编码与解码的函数,其中btoa用来编码,atob用来解码。但是实际的应用中最好是自己去实现,然后加以混淆。注意,字符串加密之后,需要把对应的解码函数放入其中,方能正常运转。比如开头的demo可以改写如下: 12345678910window[atob('RGF0ZQ==')][atob('cHJvdG90eXBl')][atob('Zm9ybWF0')] = function(formatStr) { var str = formatStr; var Week = ['\\u65e5', '\\u4e00', '\\u4e8c', '\\u4e09', '\\u56db', '\\u4e94', '\\u516d']; str = str[atob('cmVwbGFjZQ==')](/yyyy|YYYY/, this[atob('Z2V0RnVsbFllYXI=')]()); str = str[atob('cmVwbGFjZQ==')](/MM/, (this[atob('Z2V0TW9udGg=')]() + 1) > 9 ? (this[atob('Z2V0TW9udGg=')]() + 1).toString() : atob('MA==') + (this[atob('Z2V0TW9udGg=')]() + 1)); str = str[atob('cmVwbGFjZQ==')](/dd|DD/, this[atob('Z2V0RGF0ZQ==')]() > 9 ? this[atob('Z2V0RGF0ZQ==')]().toString() : atob('MA==') + this[atob('Z2V0RGF0ZQ==')]()); return str;}console.log(new window[atob('RGF0ZQ==')]()[atob('Zm9ybWF0')](atob('eXl5eS1NTS1kZA=='))); 在实际的混淆应用中,标识符必须处理成没有语义的,不然很容易定位到关键代码。此外,建议减少使用系统函数,自己去实现相应的函数,因为不管怎样混淆,最终执行过程中,系统函数是固定的,通过Hook技术很容易定位到关键代码。 数值常量加密算法加密过程中,会使用一些固定的数值常量,如MD5中的常量0×67452301,0xefcdab89,0x98badcfe和0x10325476,以及SHA1中的常量0x67452301, 0xefcdab89,0x98badcte,0x10325476和0xc3d2e1f0。因此,在标准算法逆向中,会通过搜索这些数值量,来定位代码关键位置,或者确定使用的是哪个算法。当然,在代码中不一定会写十六进制形式,如0x67452301,在代码中可能会与成十进制的1732584193。 安全起见,可以把这些数值常量也进行简单加密。可以利用位异或的特性来加密。例如,如果a^b=c,那么c^b=a。以SHA1算法中的0xc3d2e1f0常量为例,0xc3d2elf0^0x12345678=0xd1e6b788,那么在代码中可以用0xd1e66788^0x12345678来代替0xc3d2e1f0,其中0x12345678可以理解成密钥,它可以随机生成。 增加JS逆向者的工作量数组混淆看一行代码: 1console.log(new window.Date().getTime()); 按照前面介绍的对象属性的2种访问方式,使用第二种改写如下: 1console['log'](new window['Date']()['getTime']()); 这样产生了三个字符串,我们把三个字符串放在数组里面。 12var bigArr = ['Date', 'getTime', 'log'];console[bigArr[2]](new window[bigArr[0]]()[bigArr[1]]()); 这就是数组混淆。当代码有上千行,那么数组可以提取的字符串可能也有上千个,然后在代码中引用字符串的时候,全部以bigArr[1001],bigArr[1002]这种去访问,这样更加不容易建立映射关系了。 在JS中,同一个数组可以存放各种类型,比如布尔,数值,字符串,数组,对象和函数等。因此可以把代码中的一部分函数提取到大数组中去。为了安全,通常对提取到数组中的字符串进行加密处理,把代码处理成字符串就可以进行加密了。比如,对于String.fromCharCode可以改写成: 1""["constructor"]["fromCharCode"] 最前面的表示任意字符串对象或者空字符串,constructor表示构造方法,这样""["constructor"]就相当于String。前面的demo处理成数组混淆的形式如下: 1234567891011121314var bigArr = [ '\\u65e5', '\\u4e00', '\\u4e8c', '\\u4e09', '\\u56db', '\\u4e94', '\\u516d', 'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'MA==', 'Z2V0RGF0ZQ==', 'RGF0ZQ==', 'Zm9ybWF0', 'eXl5eS1NTS1kZA==', ""['constructor']['fromCharCode']];window[atob('RGF0ZQ==')][atob('cHJvdG90eXBl')][atob('Zm9ybWF0')] = function(formatStr) { var str = formatStr; var Week = [bigArr[0], bigArr[1], bigArr[2], bigArr[3], bigArr[4], bigArr[5], bigArr[6]]; eval(bigArr[14](115, 116, 114, 32, 61, 32, 115, 116, 114, 91, 39, 114, 101, 112, 108, 97, 99, 101, 39, 93, 40, 47, 121, 121, 121, 121, 124, 89, 89, 89, 89, 47, 44, 32, 116, 104, 105, 115, 91, 39, 103, 101, 116, 70, 117, 108, 108, 89, 101, 97, 114, 39, 93, 40, 41, 41, 59)); str = str[atob(bigArr[7])](/MM/, (this[atob(bigArr[8])]() + 1) > 9 ? (this[atob(bigArr[8])]() + 1).toString() : atob(bigArr[9]) + (this[atob(bigArr[8])]() + 1)); str = str[atob(bigArr[7])](/dd|DD/, this[atob(bigArr[10])]() > 9 ? this[atob(bigArr[10])]().toString() : atob(bigArr[9]) + this[atob(bigArr[10])]()); return str;}console.log(new window[atob(bigArr[11])]()[atob(bigArr[12])](atob(bigArr[13]))); 数组乱序上边进行数组混淆之后,数组下标索引与数组成员是一一对应。比如引用bigArr[14]的地方,需要成员String.fromCharCode,而该数组的下标为14的成员刚好是这个方法。可以将数组成员打乱,这样在分析的时候就更加费力,然后在执行的时候,通过一个方法将打乱的数组还原,从而不影响正确的逻辑。 可以使用以下代码打乱数组: 123456789101112 var bigArr = [ '\\u65e5', '\\u4e00', '\\u4e8c', '\\u4e09', '\\u56db', '\\u4e94', '\\u516d', 'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'MA==', 'Z2V0RGF0ZQ==', 'RGF0ZQ==', 'Zm9ybWF0', 'eXl5eS1NTS1kZA==', ""['constructor']['fromCharCode']];(function(arr, num) { var shuffer = function(nums) { while (--nums) { arr.unshift(arr.pop()); } } shuffer(++num);}(bigArr, 0x20)); 可以使用以下代码还原数组: 123456789101112var bigArr = [ 'eXl5eS1NTS1kZA==', ""['constructor']['fromCharCode'], '\\u65e5', '\\u4e00', '\\u4e8c', '\\u4e09', '\\u56db', '\\u4e94', '\\u516d', 'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'MA==', 'Z2V0RGF0ZQ==', 'RGF0ZQ==', 'Zm9ybWF0'];(function(arr, num) { var shuffer = function(nums) { while (--nums) { arr['push'](arr['shift']()); } } shuffer(++num);}(bigArr, 0x20)); 所以最前面的demo经过数组混淆和数组乱序之后,可以改写为如下: 1234567891011121314151617181920212223var bigArr = [ 'eXl5eS1NTS1kZA==', ""['constructor']['fromCharCode'], '\\u65e5', '\\u4e00', '\\u4e8c', '\\u4e09', '\\u56db', '\\u4e94', '\\u516d', 'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'MA==', 'Z2V0RGF0ZQ==', 'RGF0ZQ==', 'Zm9ybWF0'];(function(arr, num) { var shuffer = function(nums) { while (--nums) { arr['push'](arr['shift']()); } } shuffer(++num);}(bigArr, 0x20));window[atob('RGF0ZQ==')][atob('cHJvdG90eXBl')][atob('Zm9ybWF0')] = function(formatStr) { var str = formatStr; var Week = [bigArr[0], bigArr[1], bigArr[2], bigArr[3], bigArr[4], bigArr[5], bigArr[6]]; eval(bigArr[14](115, 116, 114, 32, 61, 32, 115, 116, 114, 91, 39, 114, 101, 112, 108, 97, 99, 101, 39, 93, 40, 47, 121, 121, 121, 121, 124, 89, 89, 89, 89, 47, 44, 32, 116, 104, 105, 115, 91, 39, 103, 101, 116, 70, 117, 108, 108, 89, 101, 97, 114, 39, 93, 40, 41, 41, 59)); str = str[atob(bigArr[7])](/MM/, (this[atob(bigArr[8])]() + 1) > 9 ? (this[atob(bigArr[8])]() + 1).toString() : atob(bigArr[9]) + (this[atob(bigArr[8])]() + 1)); str = str[atob(bigArr[7])](/dd|DD/, this[atob(bigArr[10])]() > 9 ? this[atob(bigArr[10])]().toString() : atob(bigArr[9]) + this[atob(bigArr[10])]()); return str;}console.log(new window[atob(bigArr[11])]()[atob(bigArr[12])](atob(bigArr[13]))); 花指令所谓花指令,就是添加一些没有意义却可以混淆视听的代码。以前面提到的demo中的某一行代码为例: 1str = str.replace(/MM/, (this.getMonth() + 1) > 9 ? (this.getMonth() + 1).toString() : '0' + (this.getMonth() + 1)); 把this.getMonth() + 1这个二项式改写为: 1234function _0x20abefx1(a, b) { return a + b;}// str = str.replace(/MM/, (_0x20abefx1(new Date().getMonth(), 1)) > 9 ? (_0x20abefx1(new Date().getMonth(), 1)).toString() : '0' + (_0x20abefx1(new Date().getMonth(), 1))); 本质是把一个二项式拆成三个部分,最左边和最右边的参数,中间的运算符可以封装成一个函数,这个函数没有什么意义,但是能瞬间增加代码量,从而增加JS你逆向者的工作量。 二项式转成函数时,还可以多级嵌套: 1234567function _0x20abefx1(a, b) { return a + b;}function _0x20abefx2(a, b) { return _0x20abefx1(a, b);} 具有同样功能的二项式,可以调用不同的函数,比如: 12345678910111213function _0x20abefx1(a, b) { return a + b;}function _0x20abefx2(a, b) { return a + b;}function _0x20abefx3(a, b) { return _0x20abefx1(a, b);}// str = str.replace(/MM/, (_0x20abefx1(new Date().getMonth(), 1)) > 9 ? (_0x20abefx2(new Date().getMonth(), 1)).toString() : '0' + (_0x20abefx3(new Date().getMonth(), 1))); 除了二项式转为函数可以使用花指令,函数调用表达式也可以处理成类似的花指令: 12345678910111213141516function _0x20abefx1(a, b, c) { return a(b, c);}function _0x20abefx2(a, b, c) { return _0x20abefx1(a, b, c);}function _0x20abefx3(a, b) { return a + b;}str = str.replace( /MM/, _0x20abefx2(_0x20abefx3, new Date().getMonth(), 1) > 9 ? (_0x20abefx2(_0x20abefx3, new Date().getMonth(), 1)).toString() : _0x20abefx2(_0x20abefx3, '0', (_0x20abefx2(_0x20abefx3, new Date().getMonth(), 1)))); jsfuckjsfuck可以看作一种编码方式,可以把JS代码通过只用(),[],+,!这6种字符表示的代码,并且可以正常阅读。比如数值常量8可以表示成: 1(!+[]+!![]+!![]+!![]+!![]+!![]+!![]+!![]+[]) []表示空数组,+[]表示把空数组转换为数值然后进行运行,由于空数组是0,所以+[]表示数值0,!+[]表示对数值0取反为布尔值true。 JS中有七种值为false,其余都为true。这7种值为false, undefine, null, 0, -0, NaN和””。所以!![]为true。 JS中的+运算符作为一个二元运算符时,假如有一边是字符串则代表字符串拼接,否则代表数值相加。所以true + true = 2。 在实际的开发中,jsfuck的应用有限。只会应用于js文件中的一部分代码。主要它的代码量非常庞大,且易于还原,只需要将代码复制到console即可还原。在jsfuck的混淆中,通过用()来进行分组,如果我们遇到JS文件局部应用jsfuck的代码,可以先通过()将代码分组,然后逐组逐组的分析还原。 代码执行流程的防护原理流程平坦化在流程平坦化混淆中,会用到switch语句,因为switch语句中的case块是平级的,而且调换case块的前后顺序并不影响代码原先的执行逻辑。看一段代码: 123456789function test() { var a = 100; var b = a + 200; var c = b + 300; var d = c + 400; var e = d + 500; var f = e + 600; return f;} 混淆test方法的执行流程为:首先把代码分块,且打乱代码块的顺序,分别添加到不同的case块中,方便起见,这里处理场一行代码对应一个case块的形式,代码如下: 12345678910111213141516switch () { case '1': var c = b + 300; case '2': var e = d + 500; case '3': var d = c + 400; case '4': var f = e + 600; case '5': var b = a + 200; case '6': return f; case '7': var a = 100;} 可以看到,当代码块打乱之后,如果想跟原先的执行顺序一样,那么case块的跳转顺序应该是7,5,1,3,2,4,6。只有case块按照这个流程执行,才能跟原始代码块的顺序保持一致。其次,需要一个循环,因为switch语句只计算一次switch表达式。整个代码改写如下: 123456789101112131415161718192021222324while (!![]) { switch () { case '1': var c = b + 300; continue; case '2': var e = d + 500; continue; case '3': var d = c + 400; continue; case '4': var f = e + 600; continue; case '5': var b = a + 200; continue; case '6': return f; case '7': var a = 100; } break;} 这是一个死循环,假如函数有返回值,则执行到相应的case语句块后直接返回。假如函数没有返回值,则代码块执行到最后就需要让switch计算出来的表达式的值与每一个case的值都不匹配,那么就会执行最后的break来跳出循环。 接着我们需要构造一个分发器,里面记录代码块执行的真实顺序,例如var arrStr = ‘7|5|1|3|2|4|6’.split(‘|’),i=0。把这个字符串’7|5|1|3|2|4|6’通过split分割成一个数组。i作为计数器,每次递增,按顺序引用数组中的每一个成员。因此,switch中的表达式就可以写成switch(arrStr[i++]),完整代码如下: 123456789101112131415161718192021222324252627function test() { var arrStr = '7|5|1|3|2|4|6'.split('|'),i=0; while (!![]) { switch (arrStr[i++]) { case '1': var c = b + 300; continue; case '2': var e = d + 500; continue; case '3': var d = c + 400; continue; case '4': var f = e + 600; continue; case '5': var b = a + 200; continue; case '6': return f; case '7': var a = 100; } break; } } 如果函数没有返回值,即switch中没有return语句,最后一次递增i会导致数组越界,JS中数组越界不会报错,而是取出来为undefined,然后匹配不到任何的switch语句就会执行break跳出死循环。 在了解了流程平坦化之后,我们可以对之前的demo进一步混淆: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859var bigArr = [ 'eXl5eS1NTS1kZA==', ""['constructor']['fromCharCode'], '\\u65e5', '\\u4e00', '\\u4e8c', '\\u4e09', '\\u56db', '\\u4e94', '\\u516d', 'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'MA==', 'Z2V0RGF0ZQ==', 'RGF0ZQ==', 'Zm9ybWF0'];(function(arr, num) { var shuffer = function(nums) { while (--nums) { arr['push'](arr['shift']()); } } shuffer(++num);}(bigArr, 0x20));function _0x20abefx1(a, b, c) { return a(b, c);}function _0x20abefx2(a, b, c) { return _0x20abefx1(a, b, c);}function _0x20abefx3(a, b) { return a + b;}window[atob('RGF0ZQ==')][atob('cHJvdG90eXBl')][atob('Zm9ybWF0')] = function(formatStr) { var arrStr = '7|5|1|3|2|4'.split("|"), i = 0; while (!![]) { switch (arrStr[i++]) { case '1': eval(bigArr[14](115, 116, 114, 32, 61, 32, 115, 116, 114, 91, 39, 114, 101, 112, 108, 97, 99, 101, 39, 93, 40, 47, 121, 121, 121, 121, 124, 89, 89, 89, 89, 47, 44, 32, 116, 104, 105, 115, 91, 39, 103, 101, 116, 70, 117, 108, 108, 89, 101, 97, 114, 39, 93, 40, 41, 41, 59)); continue; case '2': str = str[atob(bigArr[7])]( /dd|DD/, this[atob(bigArr[10])] > 9 ? this[atob(bigArr[10])]().toString() : _0x20abefx2(_0x20abefx3, atob(bigArr[9]), this[atob(bigArr[10])]())); continue; case '3': str = str[atob(bigArr[7])]( /MM/, _0x20abefx2(_0x20abefx3, this[atob(bigArr[8])](), 1) > 9 ? (_0x20abefx2(_0x20abefx3, this[atob(bigArr[8])](), 1)).toString() : _0x20abefx2(_0x20abefx3, atob(bigArr[9]), (_0x20abefx2(_0x20abefx3, this[atob(bigArr[8])](), 1)))); continue; case '4': return str; case '5': var Week = [bigArr[0], bigArr[1], bigArr[2], bigArr[3], bigArr[4], bigArr[5], bigArr[6]]; continue; case '7': var \\u0073\\u0074\\u0072 = \\u0066\\u006f\\u0072\\u006d\\u0061\\u0074\\u0053\\u0074\\u0072; continue; } break; } return str;}console.log(new window[atob(bigArr[11])]()[atob(bigArr[12])](atob(bigArr[13]))); 逗号表达式混淆逗号运算符的主要作用是把多个表达式或语句连接成一个复合语句。比如前面提到的test方法,可以改写为: 1234function test() { var a, b, c, d, e, f; return a = 100, b = a + 200, c = b + 300, d = c + 400, e = d + 500, f = e + 600, f;} return语句通常只跟一个语句,但是逗号表达式可以把多个语句符合成一个语句,这样会依次执行前面的语句,然后把最后一条语句作为返回值。 再看一个例子: 12var a = (a = 100, a += 200)console.log(a); // 300 括号会作为一个整体,先把括号里面的运算完,然后把这个整体的值赋值给a。明白了这个道理,我们再改写下test方法: 1234function test() { var a, b, c, d, e, f; return f = (e = (d = (c = (b = (a = 100, a + 200), b + 300), c + 400), d + 500), e + 600)} 可以进一步优化,这里声明了一系列的变量,可以把这些变量作为参数传入,同时,在每一个变量的赋值的逗号表达式中可以插入花指令: 123function test(a, b, c, d, e, f) { return f = (e = (d = (c = (b = (a = 100, b + 1000, c + 2000, d + 3000, e + 4000, f + 5000, a + 200), c + 2000, d + 3000, e + 4000, f + 5000, b + 300), d + 3000, e + 4000, f + 5000, c + 400), e + 4000, f + 5000, d + 500), f + 5000, e + 600)} 虽然需要6个参数,但是实际上不传任何参数,依然是正确的代码逻辑。同时中间的花指令,b+1000,c+2000,d+3000,e+4000,f+5000是没有意义的。 image-20220308150018181 我们对前面给的demo进行改写: 1234567891011121314151617181920212223242526272829var bigArr = [ 'eXl5eS1NTS1kZA==', ""['constructor']['fromCharCode'], '\\u65e5', '\\u4e00', '\\u4e8c', '\\u4e09', '\\u56db', '\\u4e94', '\\u516d', 'cmVwbGFjZQ==', 'Z2V0TW9udGg=', 'MA==', 'Z2V0RGF0ZQ==', 'RGF0ZQ==', 'Zm9ybWF0'];(function(arr, num) { var shuffer = function(nums) { while (--nums) { arr['push'](arr['shift']()); } } shuffer(++num);}(bigArr, 0x20));window[atob('RGF0ZQ==')][atob('cHJvdG90eXBl')][atob('Zm9ybWF0')] = function(formatStr, str, Week) { return str = ( (str = ( Week = ( \\u0073\\u0074\\u0072 = \\u0066\\u006f\\u0072\\u006d\\u0061\\u0074\\u0053\\u0074\\u0072, [bigArr[0], bigArr[1], bigArr[2], bigArr[3], bigArr[4], bigArr[5], bigArr[6]] ), eval(bigArr[14](115, 116, 114, 32, 61, 32, 115, 116, 114, 91, 39, 114, 101, 112, 108, 97, 99, 101, 39, 93, 40, 47, 121, 121, 121, 121, 124, 89, 89, 89, 89, 47, 44, 32, 116, 104, 105, 115, 91, 39, 103, 101, 116, 70, 117, 108, 108, 89, 101, 97, 114, 39, 93, 40, 41, 41, 59)), str[atob(bigArr[7])](/MM/, (this[atob(bigArr[8])]() + 1) > 9 ? (this[atob(bigArr[8])]() + 1).toString() : atob(bigArr[9]) + (this[atob(bigArr[8])]() + 1)) ), str[atob(bigArr[7])](/dd|DD/, this[atob(bigArr[10])]() > 9 ? this[atob(bigArr[10])]().toString() : atob(bigArr[9]) + this[atob(bigArr[10])]()) ) )}console.log(new window[atob(bigArr[11])]()[atob(bigArr[12])](atob(bigArr[13]))); 其它代码防护方案eval加密看一段代码: 12345678910111213141516171819202122eval(function (p, a, c, k, e, r) { e = function (c) { return c.toString(36) }; if ('0'.replace(0, e) == 0) { while (c--) r[e(c)] = k[c]; k = [function (e) { return r[e] || e } ]; e = function () { return '[2-8a-f]' }; c = 1 }; while (c--) if (k[c]) p = p.replace(new RegExp('\\\\b' + e(c) + '\\\\b', 'g'), k[c]); return p}('7.prototype.8=function(a){b 2=a;b Week=[\\'日\\',\\'一\\',\\'二\\',\\'三\\',\\'四\\',\\'五\\',\\'六\\'];2=2.4(/c|YYYY/,3.getFullYear());2=2.4(/d/,(3.5()+1)>9?(3.5()+1).e():\\'0\\'+(3.5()+1));2=2.4(/f|DD/,3.6()>9?3.6().e():\\'0\\'+3.6());return 2};console.log(new 7().8(\\'c-d-f\\'));', [], 16, '||str|this|replace|getMonth|getDate|Date|format||formatStr|var|yyyy|MM|toString|dd'.split('|'), 0, {})) 传给eval的是一个匿名函数,而不是一个字符串,这就是说先通过匿名函数将加密的代码解密成字符串代码,然后再通过eval之心这串代码。所以eval加密跟eval关系不大,重要的是这个解密函数,eval只是执行解密后的结果,并不参与加解密。 通过解密函数解密出来的字符串代码为: image-20220308153442661 通过eval执行解密出来的字符串结果为: image-20220308153523029 内存爆破内存爆破是指在代码中加入死代码,正常情况下这段代码不执行。当检测到函数被格式化或者函数被Hook的时候,就跳转到这段代码执行,直到内存溢出,浏览器会提示Out of Memory程序奔溃。内存爆破的代码如下: 1234567var d = [0x1, 0x0, 0x0];function b() { for(var i = 0x0, c = d.length; i < c; i++) { d.push(Math.round(Math.round())); c = d.length; }} 上述代码中的for循环是一个死循环,但是代码写的又不像 while(true) 这样明显。尤其是代码混淆以后,会更具迷惑性。这段代码其实是从某网站简化而来,原先的代码如下: 12345678910this['NsTJKl'] = [0x1, 0x0, 0x0]......0x4b1809['prototype']['xTDWoN'] = function(_0x597ca7) { for (var _0x3e27c4 = 0x0, _0x192434 = this['NsTJKl']['length']; _0x3e27c4 < _0x192434; _0x3e27c4++) { this['NsTJKl']['push'](Math['round'](Math['random']())) _0x192434 = this['NsTJKl']['length'] } return _0x597ca7(this['NsTJKl'][0x0])} for循环的结束条件是 _03e27c4 < _0x92434,其中 _0x192434 的初始化值是数组的大小。看着像是一个遍历数组的操作,是在循环中,又往数组中push了成员,接着又重新给 _0x192434 赋值为数组的大小。这时这段代码就永远也不会结束了,直到内存溢出。 检测代码是否被格式化检测的思路很简单,在JS中,函数是可以转为字符串的。因此可以选择一个函数转为字符串,然后跟内置的字符串对比或者用正则匹配。函数转为字符串很简单: 123456function add (a, b) { return a + b}console.log(add + '')console.log(add.toString()) 在调试窗口使用格式化之后,会产生一个后缀为:formatted的文件。之后这个文件中设置断点,触发断点后,会停在这个文件中,选中这个函数,鼠标悬停在上面,会显示出他原来没有格式化之前的样子。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"}]},{"title":"JS逆向中用到的常见的编码和加密","date":"2022-03-02T13:51:19.000Z","path":"JS逆向中用到的常见的编码和加密/","text":"ASCII码Python代码演示12345_ = list(map(lambda x: print(f"ascii of {x} is", ord(x)), "abcd"))# ascii of a is 97# ascii of b is 98# ascii of c is 99# ascii of d is 100 Base64什么是Base64Base64是一种基于64个可打印字符来表示二进制数据的表示方法。 Base64的字符集 数字 {0, 1, 2, 3, 4, 5, 6, 7, 8, 9} 共10位 大小写字母 {A, B, C, D, E, F, G, H, I, J, K, L, M, N, O, P, Q, R, S, T, U, V, W, X, Y, Z, a, b, c, d, e, f, g, h, i, j, k, l, m, n, o, p, q, r, s, t, u, v, w, x, y, z} 共52位 特殊符号 {+, /} 共2位 Base64编码过程由于base64的字符集大小为64,那么,需要6个比特的二进制数作为一个基本单元表示一个base64字符集中的字符。因为6个比特有2^6=64种排列组合。 具体来说,编码过程如下: 将每三个字节作为一组,共24bit,若不足24bit在其后补充0; 将这24个bit分为4组,每一组6个bit; 在每组前加00扩展为8个bit,形成4个字节,每个字节表示base64字符集索引; 扩展后的8bit表示的整数作为索引,对应base64字符集的一个字符,这就是base64编码值;在处理最后的不足3字节时,缺一个字节索引字节取3个,最后填充一个=,;缺两个字节取2个索引字节,最后填充==。 解码时将过程逆向即可。 Base64字符集索引表: 图片来源维基百科 **** 编码示例Man的base64编码: 图片来源维基百科 第一步,’M’, ‘a’, ‘n’的ASCII值分别为77, 97, 110,对应的二进制值分别为:01001101, 01100001, 01101110;取三个字节共24bit:010011010110000101101110。 第二步,将这24bit分为4组,每组6个bit:010011, 010110, 000101, 101110。 每组前面加00,形成4个字节的,00010011, 00010110, 00000101, 00101110, 即19, 22, 5, 46。 根据索引表,对应的base64字符分别是T, W, F, u。 Python实现1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253545556575859606162636465666768697071727374757677"""base64实现"""import base64import string# base 字符集base64_charset = string.ascii_uppercase + string.ascii_lowercase + string.digits + '+/'def encode(origin_bytes): """ 将bytes类型编码为base64 :param origin_bytes:需要编码的bytes :return:base64字符串 """ # 将每一位bytes转换为二进制字符串 base64_bytes = ['{:0>8}'.format(str(bin(b)).replace('0b', '')) for b in origin_bytes] resp = '' nums = len(base64_bytes) // 3 remain = len(base64_bytes) % 3 integral_part = base64_bytes[0:3 * nums] while integral_part: # 取三个字节,以每6比特,转换为4个整数 tmp_unit = ''.join(integral_part[0:3]) tmp_unit = [int(tmp_unit[x: x + 6], 2) for x in [0, 6, 12, 18]] # 取对应base64字符 resp += ''.join([base64_charset[i] for i in tmp_unit]) integral_part = integral_part[3:] if remain: # 补齐三个字节,每个字节补充 0000 0000 remain_part = ''.join(base64_bytes[3 * nums:]) + (3 - remain) * '0' * 8 # 取三个字节,以每6比特,转换为4个整数 # 剩余1字节可构造2个base64字符,补充==;剩余2字节可构造3个base64字符,补充= tmp_unit = [int(remain_part[x: x + 6], 2) for x in [0, 6, 12, 18]][:remain + 1] resp += ''.join([base64_charset[i] for i in tmp_unit]) + (3 - remain) * '=' return respdef decode(base64_str): """ 解码base64字符串 :param base64_str:base64字符串 :return:解码后的bytearray;若入参不是合法base64字符串,返回空bytearray """ if not valid_base64_str(base64_str): return bytearray() # 对每一个base64字符取下标索引,并转换为6为二进制字符串 base64_bytes = ['{:0>6}'.format(str(bin(base64_charset.index(s))).replace('0b', '')) for s in base64_str if s != '='] resp = bytearray() nums = len(base64_bytes) // 4 remain = len(base64_bytes) % 4 integral_part = base64_bytes[0:4 * nums] while integral_part: # 取4个6位base64字符,作为3个字节 tmp_unit = ''.join(integral_part[0:4]) tmp_unit = [int(tmp_unit[x: x + 8], 2) for x in [0, 8, 16]] for i in tmp_unit: resp.append(i) integral_part = integral_part[4:] if remain: remain_part = ''.join(base64_bytes[nums * 4:]) tmp_unit = [int(remain_part[i * 8:(i + 1) * 8], 2) for i in range(remain - 1)] for i in tmp_unit: resp.append(i) return resp 特别注意:Base64默认的字符集索引是上边给出的那张图片,有一些JS反爬会在Base64的字符集上面做文章,比如打乱字符集的顺序,导致编码之后的字符串无法用原来的字符集索引进行解码,这样让人误以为不是Base64编码,实际上依然使用的是Base64编码,只不过解码的时候用的是跟编码一样的打乱之后的字符集索引。比如我们将上面生成字符集索引的代码改为如下: 123base64_charset = list(string.ascii_uppercase + string.ascii_lowercase + string.digits + '+/')random.shuffle(base64_charset)base64_charset = "".join(base64_charset) 然后测试一下程序,结果如下: image-20220322002411118 可以看到随着每次使用的字符集索引表不同,导致每次Base64编码的结果也不同,但是如果解码跟编码使用同一套字符集索引表,依然可以正确的解码得到编码之前的内容。 md5信息指纹MD5消息摘要算法(英语:MD5 Message-Digest Algorithm),一种被广泛使用的密码散列函数,可以产生出一个128位(16个字符(BYTES))的散列值(hash value),用于确保信息传输完整一致。 Python代码演示1234567891011121314151617181920import hashlibdef md5(text: str): m = hashlib.md5() m.update(text.encode("utf-8")) return m.hexdigest()def file_md5(filename: str): m = hashlib.md5() with open(filename, "rb") as f: for chunk in iter(lambda: f.read(4096), b""): m.update(chunk) return m.hexdigest()print(md5("123456")) # e10adc3949ba59abbe56e057f20f883eprint(file_md5("xxx.json")) # eea7fb16ddf174c2f92cac5adb6ce7bc AES 加密AES,高级加密标准(Advanced Encryption Standard)。是用来替代 DES,目前比较流行的对称加密算法。 对称加密一方通过密钥将信息加密后,把密文传给另一方,另一方通过这个相同的密钥将密文解密,转换成可以理解的明文。 非对称加密 A要向B发送信息,A和B都要产生一对用于加密和解密的公钥和私钥。 A的私钥保密,公钥告诉B;B的私钥保密,公钥告诉A。 A要给B发送信息时,A用B的公钥加密信息,因为A知道B的公钥。 A将这个信息发给B(已经用B的公钥加密信息)。 B收到信息后,B用自己的私钥解密A的信息,其它所有收到这个报文的人都无法解密,因为只有B才有解密的私钥。 反过来,B给A发送信息时一样。 对称加密与非对称加密的区别 对称加密与解密使用的同样的密钥,所以速度快,但由于需要将密钥在网络中传输,所以安全性不高。 非对称加密使用了一对密钥,公钥与私钥,所以安全性高,但加密与解密速度慢。 解决的方法是将对称加密使用非对称加密的公钥进行加密,然后发送出去,接收方使用私钥进行解密得到对称加密的密钥,然后双方使用对称加密进行信息的传输。 AES加密三要素密钥,填充和模式。 密钥 对称加密之所以对称就是因为这类算法对明文的加密和解密使用的是同一个密钥。AES支持三种长度的密钥:128位,192位,256位。 填充 说到填充一定要说一下,AES分组加密的特性,AES加密并不是一股脑将明文加密成密文,而是把明文拆成一个个独立的明文块,且每个明文块128bit。假如有一段明文长度是196bit,如果按照每128bit一个明文块来拆分的话,第二个明文块只有64bit,不足128bit。这个时候怎么办呢?就需要对明文块进行填充。 填充的种类: NoPadding - 不作任何填充,但是要求明文长度必须是128位的整倍数。 PKCS7Padding - 当明文块少于16个字节,在明文块末尾补充相应数量的字符(字符为缺少的字节数)。 ZeroPadding - 用0进行填充,但是这种方式不推荐,因为经常出现明文块最后一块也是0的时候,解密经常出现错误。 演示PKCS7Padding: 比如一段明文为1,2,4,5,6,1,2,3,4,5当这10个字节进行加密的时候是不足16个字节的,如果用PKCS7Padding进行填充,应该是:1,2,4,5,6,1,2,3,4,6,6,6,6,6,6(差6位,补6,且补6个;如果是差7位,则补足7个7)。 模式 AES的工作模式主要体现在把明文块加密成密文块的处理过程中,AES加密算法提供了五种不同的工作模式:CBC,ECB,CTR,CFB,OFB。 模式之间的主题思想是近似的,在处理细节上有一些差别。 ECB模式是最简单的工作模式,在该模式下,每一个明文块的加密都是完全独立,互不干涉。 这样做的好处?简单;有利于并行计算。 这样做的缺点?相同的明文块经过加密会变成相同的密文块,因此安全性较差。 CBC模式引入了一个新的概念:初始向量IV。 IV是用来做什么的呢?它的作用和MD5的加盐有些类似,目的是防止同样的明文块始终加密成同样的密文块。 CBC模式在每一个明文块加密前会让明文块和一个值先做异或操作。 IV作为初始化变量,参与第一个明文的异或,后续的每一个明文块和它前一个明文块所加密的明文块相异或。这样相同的明文块加密出来的密文块显然是不同的。 这样做的好处?安全性更高。 这样做的缺点?无法并行计算,性能上不如CBC模式;引入初始化向量IV,增加复杂度。 Python代码实现 ECB 模式 1234567891011121314151617181920212223242526272829303132333435363738394041424344454647484950515253from Crypto.Cipher import AESimport base64BLOCK_SIZE = 16 # Bytespad = lambda s: s + (BLOCK_SIZE - len(s) % BLOCK_SIZE) * \\ chr(BLOCK_SIZE - len(s) % BLOCK_SIZE)unpad = lambda s: s[:-ord(s[len(s) - 1:])]def aesEncrypt(key, data): ''' AES的ECB模式加密方法 :param key: 密钥 :param data:被加密字符串(明文) :return:密文 ''' key = key.encode('utf8') # 字符串补位 data = pad(data) cipher = AES.new(key, AES.MODE_ECB) # 加密后得到的是bytes类型的数据,使用Base64进行编码,返回byte字符串 result = cipher.encrypt(data.encode()) encodestrs = base64.b64encode(result) enctext = encodestrs.decode('utf8') print(enctext) return enctextdef aesDecrypt(key, data): ''' :param key: 密钥 :param data: 加密后的数据(密文) :return:明文 ''' key = key.encode('utf8') data = base64.b64decode(data) cipher = AES.new(key, AES.MODE_ECB) # 去补位 text_decrypted = unpad(cipher.decrypt(data)) text_decrypted = text_decrypted.decode('utf8') print(text_decrypted) return text_decryptedif __name__ == '__main__': key = '5c44c819appsapi0' data = 'herish acorn' ecdata = aesEncrypt(key, data) # 0FyQSXu3Q9Q13JGf4F74jA== aesDecrypt(key, ecdata) # herish acorn CBC 模式 123456789101112131415161718192021222324252627282930313233343536373839404142434445from Crypto.Cipher import AESimport base64# 密钥(key), 密斯偏移量(iv) CBC模式加密BLOCK_SIZE = 16 # Bytespad = lambda s: s + (BLOCK_SIZE - len(s) % BLOCK_SIZE) * \\ chr(BLOCK_SIZE - len(s) % BLOCK_SIZE)unpad = lambda s: s[:-ord(s[len(s) - 1:])]vi = '0102030405060708'def AES_Encrypt(key, data): data = pad(data) # 字符串补位 cipher = AES.new(key.encode('utf8'), AES.MODE_CBC, vi.encode('utf8')) encryptedbytes = cipher.encrypt(data.encode('utf8')) # 加密后得到的是bytes类型的数据,使用Base64进行编码,返回byte字符串 encodestrs = base64.b64encode(encryptedbytes) # 对byte字符串按utf-8进行解码 enctext = encodestrs.decode('utf8') return enctextdef AES_Decrypt(key, data): data = data.encode('utf8') encodebytes = base64.decodebytes(data) # 将加密数据转换位bytes类型数据 cipher = AES.new(key.encode('utf8'), AES.MODE_CBC, vi.encode('utf8')) text_decrypted = cipher.decrypt(encodebytes) # 去补位 text_decrypted = unpad(text_decrypted) text_decrypted = text_decrypted.decode('utf8') print(text_decrypted) return text_decryptedif __name__ == '__main__': key = '5c44c819appsapi0' data = 'herish acorn' enctext = AES_Encrypt(key, data) # svAg4qrFNphvwS47DLSb2A== print(enctext) # herish acorn AES_Decrypt(key, enctext) NodeJS代码实现 ECB模式 123456789101112131415161718192021222324var CryptoJS = require("crypto-js");const key = "ABC1234567891234";function encrypt(text) { return CryptoJS.AES.encrypt(text, CryptoJS.enc.Utf8.parse(key), { iv: '', mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 })}function decrypt(text) { var result = CryptoJS.AES.decrypt(text, CryptoJS.enc.Utf8.parse(key), { iv: '', mode: CryptoJS.mode.ECB, padding: CryptoJS.pad.Pkcs7 }); return result.toString(CryptoJS.enc.Utf8);}var text = "123456";var encoded = encrypt(text);console.log(encoded.toString()); // nWhAGHyLGTLV1dff9+PEUw==console.log(decrypt(encoded)); // 123456 CBC模式 12345678910111213141516171819202122232425var CryptoJS = require("crypto-js");const key = "ABC1234567891234";const iv = "1234567812345678";function encrypt(text) { return CryptoJS.AES.encrypt(text, CryptoJS.enc.Utf8.parse(key), { iv: CryptoJS.enc.Utf8.parse(iv), mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 })}function decrypt(text) { var result = CryptoJS.AES.decrypt(text, CryptoJS.enc.Utf8.parse(key), { iv: CryptoJS.enc.Utf8.parse(iv), mode: CryptoJS.mode.CBC, padding: CryptoJS.pad.Pkcs7 }); return result.toString(CryptoJS.enc.Utf8);}var text = "123456";var encoded = encrypt(text);console.log(encoded.toString()); // 6S3YRylMmp9vIFOplWxypw==console.log(decrypt(encoded)); // 123456 AES加密流程总结 把明文按照128bit拆分成若干个明文块。 按照选择的填充方式来填充最后一个明文块。 每一个明文块利用AES加密器和密钥加密成明文。 拼接所有的密文块成为最终的密文结果。 总结 AES加密","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"}]},{"title":"JS逆向之快速定位关键代码","date":"2022-03-01T14:23:00.000Z","path":"JS逆向之快速定位关键代码/","text":"快速定位之搜索 DOM元素或者当前页面内容的搜索 Element面板快捷键CTRL+F就可以打开搜索框,搜索DOM元素。 image-20220313194959329 全局搜索 image-20220313195223274 全局搜索包含页面搜索 image-20220313195307491 快速定位之断点 XHR断点 XHR断点只对请求内容为XHR的请求生效。在JS逆向之Chrome浏览器工具你知多少?中介绍过通过网络面板抓包可以看到请求的类型,如下: image-20220314002711032 通过抓包得到XHR请求的URL之后,就可以打上XHR断点了。开发者工具切换到Source面板,展开XHR/fetch Breakpoints点击+号新建一个XHR断点。 image-20220314003042713 XHR断点不填任何字符表示对任何XHR请求都会断上,也可以填一个完整的URL,也可以填URL的一部分,如上图。注意:通常会去掉URL后边的参数,因为URL后边的参数可能是动态变化的,如果是动态变化的会导致每次都匹配不上,就没法断上。 DOM断点 DOM断点一般指的是页面元素的断点。打DOM断点的方式是在Source面板,选中某一个元素右键选择Break on,如下图: image-20220314134609560 subtree modifications表示子树发生更改,attribute modifications表示节点属性值发生改变,node removal表示节点被移除。我们也第二个attribute modifications为例,更改百度首页的百度一下,将其替换为谷歌一下。如下图: image-20220314162541977 然后就成功断下了。DOM断点的应用场景在哪?比如说有一个Input输入框,然后每次点击submit按钮,Input输出框的value属性都会改变,同时把这个value值提交到后端,这个时候通过DOM断点就可以断到value值发生改变的地方,通过调用栈去查找对应改变value值的代码。 Event断点 Event断点在Source面板的Event Listener Breakpoints一栏。 image-20220314170733786 这里我们以鼠标的click事件为例,选择Mouse/Click事件,然后点击百度一下按钮: image-20220314171105093 可以看到成功断上了。 快速定位之hook json 1234567891011var my_stringfy = JSON.stringfy;JSON.stringfy = function(params) { console.log("xxx", params); return my_stringfy(params);}var my_parse = JSON.parse;JSON.parse = function(params) { console.log("xxx", params); return my_parse(params);} cookie 123456789101112131415161718192021222324252627var cookie_cache = document.cookie;Object.defineProperty(document, 'cookie', { get: function() { console.log('Getting cookie'); return cookie_cache; }, set: function(val) { console.log('Setting cookie', val); var cookie = val.split(";")[0]; var ncookie = cookie.split("="); var flag = false; var cache = cookie_cache.split("; "); cache = cache.map(function(a){ if (a.split("=")[0] === ncookie[0]) { flag = true; return cookie; } return a; }); cookie_cache = cache.join("; "); if (!flag) { cookie_cache += cookie + "; "; } this._value = val; return cookie_cache; }}) window attr 1234567891011121314151617181920212223242526272829303132var window_flag_1 = "_t";var window_flag_2 = "ccc";var key_value_map = {};var window_value = window[window_flag_1];Object.defineProperty(window, window_flag_1, { get: function() { console.log("Getting", window, window_flag_1, "=", window_value); return window_value; }, set: function(val) { console.log("Setting", window, window_flag_1, "=", val); window_value = val; key_value_map[window[window_flag_1]] = window_flag_1; set_obj_attr(window[window_flag_1], window_flag_2); },});function set_obj_attr(obj, attr) { var obj_attr_value = obj[attr]; Object.defineProperty(obj, attr, { get: function() { console.log("Getting", key_value_map[obj], attr, "=", obj_attr_value); return obj_attr_value; }, set: function(val) { console.log("Setting", key_value_map[obj], attr, "=", val); obj_attr_value = val; }, });} 简单测试如下: image-20220314174331478 eval/Function 123456789101112131415161718192021window.__cr_eval = window.eval;var my_eval = function(src) { console.log(src); console.log("============eval end==========="); return window.__cr_eval(src);}var _my_eval = eval.bind(null);_my_eval.toString = window.__cr_eval.toString;Object.defineProperty(window, 'eval', { value: _my_eval });window.__cr_fun = window.Function;var myfun = function() { var args = Array.prototype.slice.call(arguments, 0, -1).join(",").src = arguments[arguments.length - 1]; console.log(src); console.log("===========Function end========="); return window.__cr_fun.apply(this, arguments);}myfun.toString = function() { return window.__cr_fun + "" }Object.defineProperty(window, 'Function', { value: myfun }) 简单测试如下: image-20220315005512673 websocket 12345WebSocket.prototype.senda = WebSocket.prototype.send;WebSocket.prototype.send = function (data) { console.info("Hook WebSocket, data"); return this.senda(data);} 快速定位之分析 Elements Event Listeners 在Elements面板,右边Event Listeners可以看到全部的事件监听器,如图: image-20220315010815283 Network type initator 在Network网络面板,请求列表的第三列是网络类型,第四列是调用栈。如下图: image-20220315011120666 常见的网络类型有xhr(XHR类型的请求),gif,png,jpeg,stylesheet(css文件),script(JS文件),Json等。 鼠标悬浮任意一个请求的第四列,可以看到该请求的全部调用栈,如下图: image-20220315011435032 通常通过网络面板抓包抓到我们需要的请求,然后可以查看它的调用栈,迅速定位到相关的代码。 Console Log XMLHttpRequests 打开XMLHttpRequests日志的方法,点击开发者工具上的那个设置按钮: image-20220315012217598 找到Console那一部分,选中Log XMLHttpRequests,如下: image-20220315012414737 重新刷新页面,可以看到打印出了XMLRequests的请求日志了,点击可以看到调用栈: image-20220315012715597","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"}]},{"title":"JS逆向之无限Debugger","date":"2022-02-28T02:12:45.000Z","path":"JS逆向之无限Debugger/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 关于无限debugger在进入无限debugger之前先看下三个问题。 在什么情况下会遇到debugger在分析网络请求,查看元素的事件监听器,跟踪JS等需求下,第一步就是打开浏览器的开发者工具,而打开浏览器工具就可能会碰到无限debugger死循环,或者在我们的调试过程中也可能出现无限debugger。 为什么反爬虫会用到debugger因为分析代码逻辑,调试JS代码是JS逆向的必要手段,而分析调试则需要使用开发者工具。然后就可以在关键的地方设防,精准的设置第一道防线。 debugger反爬的优势在哪 实现比较简单,前端工程师可以不会写那种复杂的反人类的反爬代码,但是写个无限debugger是个基本操作。 效果比较明显,如果第一步都没法通过的话,就不会有下一步了。 一定程度上可以提高代码的安全性,可以阻止我们调试分析代码逻辑。 接下来正式进入无限debugger 常见无限debugger及解决方案 按代码逻辑划分 无限循环,比如for/while循环里面包含debugger关键字 无限递归,一个方法包含debugger关键字,并且递归调用自己 两个方法互调,两个方法彼此都包含debugger关键字,然后彼此调用彼此。 定时器,使用定时器执行一个方法,方法里面包含debugger关键字,就会定时执行debugger。 按是否混淆划分 直接在方法中使用debugger关键字或者通过eval执行,比如eval(“debugger”) 重度混淆,通过对debugger字符串进行混淆,然后使用eval执行,从而在全局搜索中搜索不到debugger关键字。 1234Function("debugger").call()/apply() 或赋值 bind()xxx.constructor("debugger").call("action")Fuction.constructor("debugger").call("action")(function(){return !{% image","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"}]},{"title":"JS逆向之Tampermonkey工具篇","date":"2022-02-24T03:11:22.000Z","path":"JS逆向之Tampermonkey工具篇/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 在很多情况下,我们可能需要在网页中自动执行某些复杂的代码,帮助我们完成一些操作。比如抢票,刷单,爬虫等。通常我们可以开发Chrome浏览器插件来实现这种功能,但是开发浏览器插件的话得先要去学习浏览器插件开发流程,原理等。有没有什么成本比较低的方式?有的,这里我们介绍的主角叫做 Tampermonkey,也叫油猴。这个插件的功能非常强大,利用它我们几乎可以在网页中执行任何 JavaScript 代码,实现我们想要的功能。同时,我们还可以将Tampermonkey 应用到 JavaScript 逆向分析中,去帮助我们更方便地分析一些 JavaScript 加密和混淆代码。 安装打开Chrome 网上商店,搜索Tampermonkey,然后点击添加至Chrome即可。 添加完之后,点击浏览器右上角的扩展程序小图标,然后点击Tampermonkey的固定按钮,将Tampermonkey固定到菜单栏,方便以后使用。 image-20220224153116053 使用Tampermonkey 运行的是 JavaScript 脚本,每个网站都能有对应的脚本运行,不同的脚本能完成不同的功能。这些脚本我们可以自定义,同样也可以用已经写好的很多脚本,毕竟有些轮子有了,我们就不需要再去造了。下面列举一些油猴脚本免费使用的网站: Userscript.Zone Search:是一个新网站,允许通过输入合适的URL或域来搜索用户脚本。 Greasy Fork:最受欢迎的后起之秀,提供用户脚本的网站,可实现去掉视频播放广告,去水印等多种功能,可以直接安装使用,储存库中有大量的脚本资源。 OpenUserJS:继 GreasyFork 之后开始创办,在其储存库中也拥有大量的脚本资源 。 serscripts Mirror 查看当前页面运行的脚本浏览器地址输入某一网址,然后观察浏览器右上角Tampermonkey小图标 image-20220224155127864 小图标上显示数字就是当前页面可以执行的脚本数量,点击这个图标进去可以看到具体执行的脚本,如上图。 管理脚本点击浏览器右上角Tampermonkey小图标,然后点击管理面板。 image-20220224155312475 然后可以看到我们已经添加的脚本: image-20220224155345080 我们可以在这个界面上对任意一个脚本控制开启与关闭,修改删除等。 我们也可以点击已安装脚本左边的+号来新建一个脚本: image-20220224155547270 我们观察下脚本内容里的几个标注我们挑几个常用的来讲。 @name表示该脚本的名称 @include表示脚本生效的域名,可以配置多个,每个单独一行,支持通配符。比如匹配所有的http:@include http://*,匹配所有的https:@include https://*,也可以将2者合并用一个简单的写法: @include *。 @grant表示授予的权限,比如GM_download表示下载权限,GM_openInTab表示打开tab的权限,GM_xmlhttpRequest表示发起异步请求的权限,GM_cookie表示获取cookie的权限等等 @run-at确定了脚本的注入时机,在js逆向中也很重要。 完整的脚本注释介绍如下: 属性名 作用 @name 油猴脚本的名字 @namespace 命名空间,用来区分相同名称的脚本,一般写成作者名字或者网址就可以了 @version 脚本版本,油猴脚本的更新会读取这个版本号 @description 描述,用来告诉用户这个脚本是干什么用的 @author 作者名字 @match 只有匹配的网址才会执行对应的脚本 @grant 指定脚本运行所需权限,如果脚本拥有相应的权限,就可以调用油猴扩展提供的API与浏览器进行交互。如果设置为none的话,则不使用沙箱环境,脚本会直接运行在网页的环境中,这时候无法使用大部分油猴扩展的API。如果不指定的话,油猴会默认添加几个最常用的API @require 如果脚本依赖其他js库的话,可以使用require指令,在运行脚本之前先加载其他库,常见用法是加载jquery,导库,和node差不多,相当于导入外部的脚本 @run-at 脚本注入时机,这个比较重要,有时候是能不能hook到的关键,document-start:网页开始时;document-body:body出现时;document-end:载入时或者之后执行;document-idle:载入完成后执行,默认选项 @connect 当用户使用GM_xmlhttpRequest请求远程数据的时候,需要使用connect指定允许访问的域名,支持域名、子域名、IP地址以及*通配符 @updateURL 脚本更新网址,当油猴扩展检查更新的时候,会尝试从这个网址下载脚本,然后比对版本号确认是否更新 我们自己创建一个脚本,内容如下: 1234567891011121314151617// ==UserScript==// @name Any Hello world// @namespace http://tampermonkey.net/// @version 0.1// @description try to take over the world!// @author You// @match https://*// @match http://*// @icon https://www.google.com/s2/favicons?sz=64&domain=baidu.com// @grant none// ==/UserScript==(function() { 'use strict'; console.log("Hello, world"); // Your code here...})(); 脚本命名为Any Hello world,然后可以匹配到任意的http或者https协议的页面,脚本内容比较简单,只是简单的在控制台输出了一下Hello, world。我们保存下,就可以看到多了一个脚本: image-20220224160944930 然后我们打开任意一个页面,比如百度的首页,同时打开开发者工具,可以看到控制台上正常输出: image-20220224161105604 一个简单的案例网址:aHR0cDovL3F5eHkuc2NqZ2ouYmVpamluZy5nb3YuY24vaG9tZQ== 以某征信信息网站为例,我们抓包查看请求: image-20220225165205261 可以看到请求头带有Authorization这个验证参数,我们利用油猴来Hook这个参数,准备好要注入的JS,如下: image-20220225165019457 简单解释下代码,先用一个对象保存原来的XMLRequest对象的setRequestHeader方法,然后重写setRequestHeader方法加入自己的debug逻辑,最后调用原来的setRequestHeader方法。 刷新网页,发现成功断上了 image-20220225173106567 接着看下调用栈,进入单步调试,这里再介绍一个技巧,就是当调用栈很多的时候,从JS文件名称上可以看到一些文件压根不需要进去单步调试,这个时候我们可以选中该文件右键点击Add script to ignore list,如下图: image-20220225170452743 然后那些无关的文件就会被折叠起来,看上图有一个Show ignore-listed frames就是忽略的文件。可以看到这样调用栈就精简不少,从原先的几十个变成只有几个。我们一步步跟进去会发现未登录时,Authorization是写死的: image-20220225173345834 登录之后,Authorization的值是根据用户名和密码做的一个简单的Base64编码: image-20220225173441870","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"工具","slug":"工具","permalink":"http://example.com/tags/%E5%B7%A5%E5%85%B7/"}]},{"title":"JS逆向从入门到头秃","date":"2022-02-22T16:28:09.000Z","path":"JS逆向从入门到头秃/","text":"JS逆向从入门到放弃基础篇JS逆向之调用JS的两种方式 JS逆向之Chrome浏览器工具你知多少? JS逆向之Charles工具篇 JS逆向之EditThisCookie工具篇 JS逆向之Toggle Javascript工具篇 JS逆向之Tampermonkey工具篇 JS逆向神器v_jstools使用介绍 JS逆向之代码混淆的原理 JS逆向之无限Debugger JS逆向之Fiddler编程猫插件使用 JS逆向之vscode无环境联调 JS逆向之webpack扣JS思路 JS逆向之WebSocket协议 JS逆向之加解密进阶篇 JS逆向中用到的常见的编码和加密 JS逆向之快速定位关键代码 JS逆向之常见代码混淆的处理 AST篇一文教你如何利用AST还原JS混淆 AST解JS混淆之反混淆代码模版 AST解JS混淆之处理数值与字符串 AST解JS混淆之删除空行与空语句 AST解JS混淆之还原中文的Unicode编码 AST解JS混淆之删除所有注释 AST解JS混淆之去掉未被使用的变量 AST解JS混淆之AST基础 AST解JS混淆之去掉未被调用的函数 案例篇极验系列JS逆向案例——极验消消乐验证码逆向分析 JS逆向案例——极验五子棋验证码逆向分析 JS逆向案例——极验文字点选验证码逆向分析 JS逆向案例——极验无感验证逆向分析 JS逆向案例——天眼查过极验滑块验证码 JS逆向案例——极验滑块验证码新思路 JS逆向案例——极验滑块验证码补环境 JS逆向案例——极验滑块验证码w参数生成 JS逆向案例——极验滑块验证码底图还原 字节系列JS逆向案例——利用插桩分析某音X-Bogus参数 百度JS逆向案例——百度旋转验证码 其它JS逆向简单案例一二 JS逆向之无限Debugger JS逆向案例——网上管家婆 JS逆向案例——问财网补环境 JS逆向案例——阿尔法营webpack抠JS JS逆向案例——某远海运公司webpack抠JS JS逆向之混淆JS手动逆向 推荐阅读JSVMP是什么? JS加密的研究背景和意义 JS混淆和JS压缩的前端代码攻防机制分析 代码虚拟化保护原理分析 深入了解JS 加密技术及JSVMP保护原理分析 V8引擎基础知识 给”某音”的js虚拟机写一个编译器 持续更新中…","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"验证码","slug":"验证码","permalink":"http://example.com/tags/%E9%AA%8C%E8%AF%81%E7%A0%81/"},{"name":"AST","slug":"AST","permalink":"http://example.com/tags/AST/"}]},{"title":"JS逆向简单案例一二","date":"2022-02-22T16:23:57.000Z","path":"JS逆向简单案例一二/","text":"免责声明:本文章中所有内容仅供学习交流,抓包内容、敏感网址、数据接口均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关,若有侵权,请联系我立即删除! 通过几个简单的案例对JS逆向从入门到放弃的基础篇的前6章的知识加以运用和巩固。 某道翻译网址:aHR0cHM6Ly9mYW55aS55b3VkYW8uY29tLw== 首先抓包,分析请求的类型,参数特点,返回值特点。 image-20220223111325964 通过网络面板Fetch/XHR,找到需要的网络请求。 查看返回结果为明文,不存在加密。 1{"translateResult":[[{"tgt":"I love you","src":"我爱你"}]],"errorCode":0,"type":"zh-CHS2en","smartResult":{"entries":["","I love you\\r\\n"],"type":1}} 查看参数,大部分参数都是常量,只有salt, sign, Its, bv看着像是动态生成的。Its从名字和内容看,推测是13位的时间戳。sign和bv都是32位,且是字母和数字的混合,初步推算是md5加密。salt前13位跟Its一样,多了一位,推测是14位时间戳?🤪 接下来断点调试参数生成的逻辑。通常有2种方式:第一种是根据参数名称,全局搜索查找相应的JS代码;第二种是根据请求的Initiator调用栈逐步分析参数生成的位置;第三种打上XHR/fetch Breakpoints断点进行调试,然后分析Call Stack。 第一种方法,打开开发者工具的全局搜索,搜索sign关键词: image-20220223140405239 然后很轻松的找到了发送XHR请求的参数data这个object image-20220223140657071 鼠标悬停到generateSaltSign(t)这个方法上,然后会展示点击进入到这个方法: image-20220223142733567 可以看到代码比较简单,将它改写成Python代码: 1234567891011def generate_salt_sign(content): n = content[:5000] t = md5(navigator["appVersion"]) r = str(int(time() * 1000)) i = r + str(random.randint(0, 9)) return { "lts": r, "bv": t, "salt": i, "sign": md5("fanyideskweb" + n + i + "Y2FYu%TNSbMCxc3t2u^XT") } 其中的生成sign的最后一串常量会根据用户的Cookie发生变化。我们之前的推测基本都是对的,除了salt。salt是13位时间戳加上随机的一个数字。 有了几个关键参数的生成代码,我们从Network面板复制出XHR请求的cURL,然后找一个在线的cURL to Python工具,快速生成Python代码,然后补上我们写好的generate_salt_sign方法,运行代码,结果如下: image-20220223145314773 某度翻译网址:aHR0cHM6Ly9mYW55aS55b3VkYW8uY29tLw== 老规矩,先抓包。 image-20220223150801765 找到一个XHR请求,返回结果为明文,不需要解密。参数有2个sign和token,token目测是md5。用第一种方式,打开开发者工具,全局搜索sign,发现搜索出来的结果非常多,主要是存在很多干扰,比如assign。 image-20220223152341564 这里介绍一个小技巧,如果搜索出来的干扰比较多的时候,不妨搜索“**,关键词:**”试试,为什么如此操作?因为发送XHR请求的时候,data都放在一个object中,我们知道,JS的Object格式都长这样: 1234object = { key1: value, key2: value2} 然后JS文件基本都会压缩,所以要搜索的参数自然前边有个逗号,后边有个封号。我们按照这种方式搜索,结果如下: image-20220223154110965 可以看到明显搜索结果更加精确了,结果也少了。然后我们逐条分析,最终定位到我们参数生成的代码地方,然后打上断点开始调试: image-20220223154356745 没有想到的是token竟然是一个常量。我们看他的值: image-20220223154444310 那就更简单了,只需要找到sign的生成方法即可。可以看到sign是调用了一个L方法生成,接受一个参数e,这个e是我们传入的要翻译的text。我们点击进入L方法: image-20220223154647115 好了,接下来就到了扣JS和补全JS的环节了。我们扣出来这个方法,然后用node去执行: image-20220223162130736 报错,i未定义,我们分析代码,补全i的定义: image-20220223163824429 运行接着报错,意料之中, image-20220223162330599 u没有定义,我们从这一行代码可以看到,u的值来自于window这个object: image-20220223162424396 这个l是一个固定的字符串,值为”gtk”,然后u这是取得object的这个gtk属性,也是一个定植,那么我们补全这个window即可: image-20220223162840470 image-20220223163853288 然后接着运行代码,接着报错: image-20220223163920006 n这个方法没有定义,我们回到源码,将n的定义拷贝进来: image-20220223164310865 然后再次运行代码,终于拿到了sign的值: image-20220223165713366 这里很明显看到我们扣出来的JS比较大, 如果对代码进行分析然后改写成Python会比较麻烦,所以利用JS逆向之调用JS的两种方式,将JS代码用express框架做成一个服务供Python端去调用。效果如下: image-20220223165732284 然后就是比较机械性的工作了,复制cURL转成Python代码,然后修改sign通过走接口去获取,最后贴一张最终执行的效果图: image-20220223170807972 总结通过几个简单的案例,回顾了下开发者面板的一些使用方式,包括全局搜索,断点调试跟踪,调用栈分析,扣JS以及Python调用JS的几种方式。若需要代码,扫描加微信即可。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"}]},{"title":"JS逆向之常见代码混淆的处理","date":"2022-02-18T08:37:23.000Z","path":"JS逆向之常见代码混淆的处理/","text":"加密分析流程总结在开始介绍常见代码混淆之前,看下加密分析的流程。 查看关键包——分析哪些参数是加密的。 搜索参数。 参数名= / 参数名 = / 参数名: / 参数名 : 查看网络面板的Initiator(发起) xhr断点调试 hook相关逻辑 分析加密。 补全加密逻辑。 颜文字网络表情编码解混淆将Javascript代码转换为颜文字网络表情的编码以达到混淆的目的。 原理:这类混淆通常都是使用构造函数将字符串作为代码运行。 例如: 12const sum = new Function('a', 'b', 'return a + b')console.log(sum(2, 6)) 解决办法: 直接将混淆后的代码粘贴至控制台通过VM查看源代码。 删除代码结尾的”(‘_’)”,替换为toString()或将修改后的代码粘贴至控制台执行。 符号组成的代码反混淆将Javascript代码转换成仅由符号组成的代码以达到混淆的目的。 原理:这类混淆同常使用构造函数将字符串作为代码运行。 转换流程: 123456Function("alert(1)")();(0)["constructor"]["constructor"]("alert(1)")();$ = "constructor";$$ = "alert(1)";$_ = ~[];($_)[$][$]($$)(); 解决办法: 直接将混淆后的代码贴到控制台通过VM查看源代码。 删除代码结尾的(),替换为toString(),将修改后的代码粘贴至控制台运行。 Jsfuck反混淆将JS代码转换成只有6种符号,()[]!+,的编码,以达到混淆的目的。 解决办法: 直接将混淆后的代码粘贴至控制台通过VM查看源码。 删除代码结尾的()替换为toString,修改后粘贴至控制台执行。 如果代码最后不是(),而是),找到最后一对()包裹的代码,将其抽离出来单独执行。 Packed混淆将JS代码打包成以eval开头,特征字符串是function(p, a, c, k, e, r)或者是function(p, a, c, k, e, d)的代码。 12345678910111213141516171819eval(function (p, a, c, k, e, d) { e = function (c) { return (c < a ? "" : e(parseInt(c / a))) + ((c = c % a) > 35 ? String.fromCharCode(c + 29) : c.toString(36)) }; if (!''.replace(/^/, String)) { while (c--) d[e(c)] = k[c] || e(c); k = [function (e) { return d[e] }]; e = function () { return '\\\\w+' }; c = 2; } ; while (c--) if (k[c]) p = p.replace(new RegExp('\\\\b' + e(c) + '\\\\b', 'g'), k[c]); return p;}('0', 3, 3, 'hello!||'.split('|'), 0, {})) 反混淆方法: 将eval改成console.log,在控制台上输出。 AST混淆反混淆通过字符串编码,字符串常量编码,数值编码,数组混淆,数组乱序,花指令,流程平坦化,逗号表达式混淆等方式,对JS代码进行混淆。 处理方式:(扣JS,补JS) 找到入口方法 整理主要逻辑 修改逻辑代码补全缺失的逻辑 完成代码的整理 总结以上几种混淆中常见的是AST混淆,后边会有专门的文章介绍AST混淆的原理,如何手动反混淆以及借助相关的工具实现反混淆。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"}]},{"title":"JS逆向之代码混淆的原理","date":"2022-02-18T05:54:47.000Z","path":"JS逆向之代码混淆的原理/","text":"对JS代码保护的方式 代码压缩:去除空格,换行等。 加密代码:eval,emscripten,WebAssembly等。 代码混淆:变量混淆,常量混淆,控制流扁平化,调试保护等。 Javascript加密实现 eval加密 利用Javascript中的eval方法执行一些不太可读的JS代码。 eval方法就是JS的一个执行器,它可以把其中的参数按照JS的语法进行解释并执行。所以这个加密只是把JS的代码变成eval的参数,其中的一些字符都会被按照特定的格式编码。 比如有一段代码: 1console.log("hello, world") 利用一些在线的eval加密网站进行加密,结果如下: 1eval(function(p,a,c,k,e,d){e=function(c){return(c<a?'':e(parseInt(c/a)))+((c=c%a)>35?String.fromCharCode(c+29):c.toString(36))};if(!''.replace(/^/,String)){while(c--)d[e(c)]=k[c]||e(c);k=[function(e){return d[e]}];e=function(){return'\\\\w+'};c=1};while(c--)if(k[c])p=p.replace(new RegExp('\\\\b'+e(c)+'\\\\b','g'),k[c]);return p}('0.2("1, 3")',62,4,'console|hello|log|world'.split('|'),0,{})) 可以看到可读性很差,但是在console中可以正确执行: image-20220218144118421 加密后的字符串可以解密得到原始的JS代码。 Emscripten 将JS的核心逻辑用C/C++来实现,然后通过Emscripten项目进行编译生成asm.js代码,最后被JS调用。 WebAssembly 跟Emscripten类似,将核心代码用C/C++来实现,然后生成wasm文件,最后用JS来调用。与前面不同的是,生成的中间文件格式不同,asm.js是文本文件,wasm是二进制格式。wasm运行速度更快,体积更小。 Javascript混淆技术 变量混淆 把变量名或者常量名变成一些无意义的,或者看起来比较乱的字符串,比如说16进制的字符串,从而达到降低代码可读性的目的。 字符串混淆 把字符串进行md5,base64或者rc4加密或者编码,确保代码里面不会通过搜索的功能从而得到原始字符串。降低了通过字符串寻找入口的风险。 属性加密 把JS的Object的key-value的映射关系混淆,从而更加难以寻找里面的一些逻辑。 控制流扁平化 额外增加一些控制流,或者无意义的控制流,从而使代码流程看起来更加复杂,可读性更加差。 僵尸代码注入 不会被执行的代码或对上下文没有任何影响的代码注入到我们的代码,可以形成对现有JS代码的阅读干扰。 代码压缩 去除代码的一些空格,回车,调试语句等扽,可以使文件变得更小,可以将多行代码变成一行代码变得更加难读。 反调试 基于浏览器的特性,对当前环境进行一些检验,然后让它无限debug或者定时debug,通过一些断点进行干扰。 多态变异 JS代码被调用时,一旦被调用,代码立刻发生变化,变成和原来完全不同的代码,代码功能不变,只是代码形式发生变化,避免代码被动态的分析和调试。 锁定域名 有一个检测机制,使得我们的Javascript代码只能在特定的域名下执行。 反格式化 如果我们对JS代码格式化之后,会有一些机制使得我们无法顺利的运行。 特殊编码 将JS代码编码成一些特别难读的代码,比如说中括号,叹号等等。 Javascript混淆的开源项目 UglifyJS https://github.com/mishoo/UglifyJS2 解析JS的抽象语法树,然后根据抽象的语法树,对变量进行重命名,然后进行一些压缩或者变异。 terser https://github.com/terser/terser 和UglifyJS功能类似,增加了对ES6的支持。 javascript-obfuscator https://github.com/javascript-obfuscator/javascript-obfuscator 该项目可以用来实现几乎所有的混淆效果,比如字符串的混淆,属性的加密,平坦化控制流等等。 Jsfuck https://github.com/aemkeijsfuck 将JS里面的一些变量或者定义替换成比如说{}的表示,体积会变得非常大。 AAEncode https://github.com/bprayudha/jquery.aaencode 把JS代码换成一些颜文字的形式。 JJEncode https://github.com/ay86/jEncrypt 把JS代码替换成一些$,加号,中括号等等,降低可读性。 Javascript混淆的在线工具 https://obfuscator.io https://www.sojson.com/jscodeconfusion.html https://www.jshaman.com/protect.html https://freejsobfuscator.com https://www.daftlogic.com/protects-online-javascript-obfuscator.html https://beautifytools.com/javascript-obfuscator.php Javascript混淆的商业服务 https://javascriptobfuscator.com https://jscrambler.com https://stunnix.com Javascript混淆实现基于javascript-obfuscator开源项目,依赖Node.js,创建好工作空间,并安装好javascript-obfuscator包。 123mkdir workspace && cd workspacenpm initnpm install --save-dev javascript-obfuscator 看下javascript-obfuscator的基本使用: 123456789101112const code = `let x = '1' + 1console.log('x', x)`const options = { compact: false, controlFlowFlattening: true}const obfuscator = require("javascript-obfuscator")console.log(obfuscator.obfuscate(code, options).getObfuscatedCode()) 首先定义一个变量code保存要混淆的代码,然后options是混淆时设置的一些参数,compact表示是否压缩成一行的形式,controlFlowFlattening表示控制流平坦化。执行结果如下: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546function _0x3636() { const _0x1b28ef = [ '10gXHWCI', 'log', '1186QQlqFf', '387777QhzDIE', '4QPhAWc', '7dmdYNq', '3383712sHKpqN', '3268150BmflmK', '338QyRTUY', '3280416bapPFy', '2348556uvulWF', '22yhcCAQ', '2778200CXJHeV' ]; _0x3636 = function () { return _0x1b28ef; }; return _0x3636();}const _0x5f084d = _0x2680;(function (_0x416f08, _0x4aca4c) { const _0x3bfda0 = _0x2680, _0x3eb696 = _0x416f08(); while (!![]) { try { const _0x2d197a = parseInt(_0x3bfda0(0x1d1)) / 0x1 * (-parseInt(_0x3bfda0(0x1ca)) / 0x2) + parseInt(_0x3bfda0(0x1d2)) / 0x3 * (parseInt(_0x3bfda0(0x1d3)) / 0x4) + parseInt(_0x3bfda0(0x1c9)) / 0x5 + -parseInt(_0x3bfda0(0x1cc)) / 0x6 * (parseInt(_0x3bfda0(0x1d4)) / 0x7) + parseInt(_0x3bfda0(0x1ce)) / 0x8 + parseInt(_0x3bfda0(0x1d5)) / 0x9 * (parseInt(_0x3bfda0(0x1cf)) / 0xa) + -parseInt(_0x3bfda0(0x1cd)) / 0xb * (parseInt(_0x3bfda0(0x1cb)) / 0xc); if (_0x2d197a === _0x4aca4c) break; else _0x3eb696['push'](_0x3eb696['shift']()); } catch (_0x4e2954) { _0x3eb696['push'](_0x3eb696['shift']()); } }}(_0x3636, 0x59bb0));let x = '1' + 0x1;function _0x2680(_0x3f1f90, _0x4fb200) { const _0x36367e = _0x3636(); return _0x2680 = function (_0x2680ac, _0x269526) { _0x2680ac = _0x2680ac - 0x1c9; let _0x72d9ee = _0x36367e[_0x2680ac]; return _0x72d9ee; }, _0x2680(_0x3f1f90, _0x4fb200);}console[_0x5f084d(0x1d0)]('x', x); 关于一些混淆参数的说明: 参数名 类型 含义 compact Boolean 表示是否压缩,true为压缩,false为不压缩,压缩后会变为一行 identifierNamesGenerator String 变量名混淆的方式,默认为16进制混淆,设置为mangled为普通混淆 stringArray Boolean 字符串混淆,是否把字符串变量拼成一个数组的形式 rotateStringArray Boolean 字符串混淆,是否对上边提到的数组进行反转 stringArrayEncoding Boolean或者String 字符串混淆,是否对字符串编码,默认为base64,设置为false为不编码,设置为base64表示使用base64编码,设置为rc4表示使用rc4编码 unicodeEscapeSequence Boolean 字符串混淆,是否使用unicode编码 selfDefending Boolean 代码的自我保护,如果格式化后运行,会直接将浏览器卡死 controlFlowFlattening Boolean 控制流平坦化 transformObjectKeys Boolean 对象键名替换 disableConsoleOutput Boolean 禁用控制台输出,会把一些控制台方法置为空 debugProtection Boolean 调试的保护,无限debug,定时debug,debugger关键字 domainLock Array 域名锁定,只允许在特定的域名下执行,降低被模拟风险","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"}]},{"title":"JS逆向之Toggle Javascript工具篇","date":"2022-02-16T07:05:12.000Z","path":"JS逆向之Toggle-Javascript工具篇/","text":"Javascript,它是网站的一部分,我们的网站大部分情况下都会有Javascript的执行。有了它,我们整个网站的功能会变得更加的强大。但是对于我们爬虫来说,比如说我们用requests来请求某一个网站的时候,我们得到的结果实际上可能与浏览器得到的真实看到的结果或者说在浏览器开发者工具Eelements窗口看到的结果是不一样的。这个就是由于Javascript页面的渲染导致的结果不同,这样就会对爬虫产生一个较大的干扰。如果我们能禁用后期的Javascript渲染,就可以在浏览器中看到真实的结果与我们requests请求得到的结果一致了,这样就非常方便了,那么如何做到这一个功能呢?这篇文章就会介绍Toggle Javascript的Chrome插件。 安装打开Chrome 网上商店,搜索Toggle Javascript,然后点击添加至Chrome即可。 添加完之后,点击浏览器右上角的扩展程序小图标,然后点击Toggle Javascript的固定按钮,将Toggle Javascript固定到菜单栏,方便以后使用。 image-20220216151324812 使用通过点击Toggle Javascript按钮来暂停或恢复JS的执行。以一个小说网站红薯网为例: 我们随便点开一个小说的某一章: image-20220216152044654 这个页面实际上有部分内容是用Javascript渲染生成的,如果我们用requests请求的话,得到的页面的效果,就是我们禁用掉Javascript之后的效果,我们点击Toggle Javascript小图标: image-20220216152307901 我们可以看到标点符号没了,整个语句也变得不通顺了。我们如果用正常的requests请求的话就会得到这样一个效果。 打开开发者工具,切换到Network面板,刷新页面,我们查看原生的请求,Preview的结果与我们通过Toggle Javascript禁用JS的结果是一摸一样的。 image-20220216152557041 我们再看一下源码,有一些文字有一些span的占位符,后期通过JS渲染替换这些占位符,就可以渲染出真正的页面内容: image-20220216152850619 总结Toggle Javascript是一款Chrome浏览器插件,其主要功能是禁用或者执行页面的JS,在爬虫开发的过程中,通过该工具可以迅速的得到requests请求之后的页面结果,非常方便。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"工具","slug":"工具","permalink":"http://example.com/tags/%E5%B7%A5%E5%85%B7/"}]},{"title":"JS逆向之EditThisCookie工具篇","date":"2022-02-16T02:26:34.000Z","path":"JS逆向之EditThisCookie工具篇/","text":"在做爬虫开发的时候经常会遇到查看或者修改Cookies的时候,比如说我们可能会有这么一些需求,比如编辑Cookies,编辑它的内容或者有效期;或者是说删除Cookies,实现某个页面的退出,或者说测试某个Cookie是否有效等;或者说添加某个Cookie,比如说在未登录状态下添加某个Cookie然后绕过登录;或者说导入导出某些Cookie,有时候我们需要把Cookie持久化存储在另一台电脑上,那么我们可能需要一些导入导出机制。这个时候我们可以借助一款浏览器插件叫做EditThisCookie来帮助我们轻松的完成上面提到的需求,本文将介绍这个插件的使用方法。 安装打开Chrome 网上商店,搜索EditThisCookie,然后点击添加至Chrome即可。 添加完之后,点击浏览器右上角的扩展程序小图标,然后点击EditThisCookie的固定按钮,将EditThisCookie固定到菜单栏,方便以后使用。 image-20220216104323796 功能介绍管理Cookies我们以editthiscookie.com为例,浏览器地址输入该网址,然后打开Chrome的开发者工具,可以看到多了一个EditThisCookie的窗格: image-20220216113706345 可以看到当前网站的Cookie以一个表格的形式呈现,我们可以在这个表格里对某些Cookie进行修改,删除等操作。但是没法添加。 我们也可以用开发者工具自带的Cookie管理窗口: image-20220216114048001 可以对Cookie进行CURD操作,也比较方便。这里显示的Cookie不仅仅是editthiscookie.com这个domain下的Cookie,不过我们可以用上面的Filter进行过滤,如下: image-20220216114257547 除了上面2种方式,我们还可以点击EditThisCookie这个图标: image-20220216114525960 可以看到这里除了能对Cookie进行CURD操作外,还可以对Cookie进行批量操作,比如删除所有Cookie。还可以进行Cookie的导入导出等。 导出Cookie点击浏览器右上角EditThisCookie小图标,然后点击设置 image-20220216134602208 点击选项,选择Cookie的导出格式,这里我们选择JSON: image-20220216134645420 然后回到前一个页面,点击导出Cookie按钮: image-20220216135001882 此时Cookie并以JSON的形式复制到了剪切板,我们找一个文本文件粘贴,可以得到Cookie的内容: image-20220216135141980 这种JSON格式的Cookie就可以直接放到程序中使用了。 Cookie导入导出小实验首先我们登录github.com,登录之后主页可以看到个人信息: image-20220216135949035 接着我们用EditThisCookie导出Cookie并保存到一个文本文件: image-20220216140120593 然后打开开发者工具,选择Application,选择Cookie,然后右键Clear,清除该站点所有的Cookie: image-20220216140235247 这时我们刷新该页面,发现我们成功退出了github: image-20220216140324742 然后我们点击EditThisCookie,导入刚才保存的Cookie: image-20220216140419364 然后刷新页面,发现我们又重新登录了github: image-20220216140504490","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"工具","slug":"工具","permalink":"http://example.com/tags/%E5%B7%A5%E5%85%B7/"}]},{"title":"JS逆向之Charles工具篇","date":"2022-02-15T05:51:15.000Z","path":"JS逆向之Charles工具篇/","text":"CharlseCharlse是一个HTTP/HTTPS抓包工具,支持Windows/Linux/Mac平台。功能包含截获请求,过滤请求,重发请求,设置断点,模拟网速,反向代理等。 安装证书抓取PC端HTTPS的请求时,如果没有配置Charles证书,会出现如下报错: image-20220215152744261 此时需要安装Charles证书,步骤如下(以Mac环境为例,其他类似): image-20220215153015985 打开Charlse,选择Help->SSL Proxying->Install Charles Root Certicate 然后弹出一个证书选项: image-20220215153449694 更改钥匙串的保存位置为登录,然后点击添加。之后打开钥匙串访问应用: image-20220215153741168 找到登录选项,然后证书这一栏,如上。可以看到刚才添加的Charles证书前面有一个叉叉,双击这个证书。点击信任: image-20220215153936079 接着把使用此证书时改为始终信任,如下图。然后关闭并且保存修改。 image-20220215154022123 添加完证书后,需要修改SSL Proxying设置,将我们具体要抓的请求地址与端口添加进来。具体步骤: 点击Proxy->SSL Proxying Settings image-20220215154322329 然后在Include这一栏点击Add按钮,Host和Port都填通配符*,表示抓包时不限制ip以及端口。 image-20220215154509908 以上两个重要设置都修改好后,重新刷新我们刚才抓包时报握手错误的页面,发现可以正常抓包了。 截获请求安装好证书,配置好SSL Proxing之后,就可以正常的截获请求了。这里介绍一个网站——httpbin.org。httpbin.org是一个可以进行模拟请求的网站。 image-20220215160208105 HTTP Methods 可以模拟发送请求方法为DELETE/GET/PATCH/POST/PUT的请求。 Auth 可以模拟发送包含各种需要验证的请求,比如Bearer token。 Status codes可以模拟发送状态码为401, 403, 500等请求。 其它功能还有包括请求头检测,返回头检测,动态数据,图片,Cookie,重定向等,不一一列举了。 这里我们用httpbin.org发送一个GET请求:点击HTTP Methods,点击GET,点击Try it out,点击Execute,就成功发送了一个HTTP请求。 image-20220215160836694 我们可以在Charles上成功截获到这个请求: image-20220215161001335 过滤请求当charles抓到的请求非常多时,我们需要迅速定位到我们想要截获的请求,这个时候就需要用到过滤请求了。有2种方法: 在filter栏填写需要过滤出来的域名 固定某一个域名 image-20220215161421619 找到我们需要的那一个域名,然后右键选择Focus,这个时候当前域名就会置顶,并且其它所有域名都变为Other Hosts。 重发请求重发请求可以通过点击刷新按钮,也可以右键Repeat按钮,重发请求之前我们还可以编辑当前的请求,比如修改User-Agent,Cookie等,此外还有一个Repeat Advanced功能,可以设置重复某一个请求多少次以及每次请求之间延迟多长时间。下面一一介绍这些功能: 重发请求 第一种方式,选中某一个请求,然后点击刷新按钮: image-20220215164451184 第二种方式,选中某一个请求,然后右键,点击Repeat: image-20220215164611051 编辑请求 编辑请求后重发,可以方便我们不写一行代码去调研某一个请求的关键点,非常方便我们进行调试,选中某一个请求,点击修改按钮: image-20220215164921556 可以修改请求的URL,请求的参数,请求的Header等等: image-20220215165128661 Repeat Advanced 选中某一个请求,右键点击Repeat Advanced: image-20220215165303797 iterations填入重复的次数,Concurrency填入请求的并发数,Repeat delay表示每个请求之间的延迟。勾选Use ranges还可以设置延迟在一个范围内,即保证延迟的随机性。 image-20220215165446565 这个功能可以帮助我们在开发爬虫的时候不用编写代码就可以调试爬虫的是否有访问频率上的限制,应做出来的延时策略是怎样的等等。 设置断点先看下断点的使用方法,然后再介绍这样做的意义。 选中某一个请求,然后右键点击Breakpoints: image-20220215170854304 也可以直接通过点击Proxy->Breakpoint Settings,然后添加一个断点。 image-20220215170941208 添加好断点之后,我们可以再次发起请求,可以看到我们的请求一直处于等待状态。 image-20220215171458730 我们可以编辑该请求从而达到断点调试的功能。 断点调试的意义在哪?有时我们想让服务器返回一些指定的内容,方便我们调试一些特殊情况。例如列表页面为空的情况,数据异常的情况,部分耗时的网络请求超时的情况等,这个时候使用断点调试就可以模拟出这些情况。使用断点调试将网络请求截获并修改过程中,整个网络请求的计时并不会暂停,所以长时间的暂停可能导致客户端的请求超时。 模拟网速点击Proxy->Throttle Settings,然后勾选Enable Throttling开启模拟网速: image-20220215172549357 反向代理反向代理相当于我们在发起一次请求的时候,请求会经过我们配置的代理拿到响应后,然后我们再把响应转发回我们的客户端。 在介绍两种通过Charles配置反向代理的方式之前,先要清除我们上边设置断点时留下的断点。 下面介绍第一种方式:点击Proxy->Reverse Proxies Settings image-20220215174742827 勾选Enable Reverse Proxies,然后点击Add添加规则。Local Port表示本地的端口,Local address表示本地地址,Remote Host表示远程地址,Remote Port表示远程端口,上图的含义表示将httpbin.org反向代理到本地的localhost:8080地址上。所以此时在浏览器地址栏中访问localhost:8080实际上访问的是httpbin.org: image-20220215175118759 下面介绍第二种方式:相当于如果我们请求一个URL的话,我们可以把这个URL它的一个Response转发到一个Remote地址或者说我们用本地的一个地址。 我们以网址https://quotes.toscrape.com/js/为例。 重新抓包,看下网页源代码: image-20220215183821967 内容比较简单,我们看到有一块JSON的数据,我们修改把Response映射到本地的一个文本文件,这样我们就可以完成Response的修改了。我们先保存Response到本地的一个文件,然后修改这个文件,主要修改2个地方,第一个是把JSON里面的第一项Text值修改为Hello, world。第二个是在渲染的地方加上debugger关键词,然后保存文件。 image-20220215184356182 image-20220215184835283 然后我们在Charles中选中刚才的那个请求,右键选择Map Local,然后更改Local Path选择我们刚才保存的那个文件: image-20220215184655514 保存,然后刷新页面,可以看到正常显示了我们更改的内容,并且进入了debug模式: image-20220215185015800 第二种方式类似于在JS逆向之Chrome浏览器工具你知多少?中介绍的文件导航窗格Overrides面板的功能。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"},{"name":"工具","slug":"工具","permalink":"http://example.com/tags/%E5%B7%A5%E5%85%B7/"}]},{"title":"JS逆向之Chrome浏览器工具你知多少?","date":"2022-02-11T06:46:51.000Z","path":"Chrome浏览器工具你知多少/","text":"Network面板Network面板介绍网络面板分为5个部分:控制器面板,过滤器面板,概览部分,请求列表以及概要部分。 Controls(控制器):使用这些选项可以控制Network面板的外观和功能。 Filter(过滤器):使用这些选项可以控制在Requests Table中显示哪些资源。按住Command/Ctrl键可以点选多个过滤器。 Overview(概览):此图表显示了资源检索时间的时间线。如果看到多条竖线堆叠在一起,说明这些资源同时被检索。 Requests Table(请求列表):此表格列出了检索的每一个资源。默认情况下,此表格按照时间顺序排序,最早的资源在顶部。点击资源的名称可以显示更多信息,右键点击Timeline以外的任何一个表格标题可以添加或者移除信息列。 Summary(概要):此窗格可以一目了然地知道请求总数,传输的数据量和加载时间。 控制器面板 image-20220211145738994 第一个按钮控制浏览器是否抓包,如果为红色表示正在抓包,点击之后变灰色表示停止抓包。 第二个按钮表示清除按钮,点击之后会清除请求列表。 第三个按钮表示过滤器的打开与关闭按钮,点击这个按钮控制下方过滤器打开还是关闭。 第四个按钮表示打开一个检索,用得不多。 第五个按钮表示是否要跨页面保存请求列表。☑️上表示保留跨页面请求信息,什么意思呢?就是我打开一个A页面,得到了一个请求列表,我再次访问B页面,C页面…无论是什么页面,只要是同一个窗口,所有的请求列表都会保存。 第六个按钮表示是否禁用浏览器缓存。一般来说☑️上,然后每一个请求都会去请求新的资源。 第七个按钮表示慢速网络模拟。可以使用slow 3G或offline,可以看到浏览器页面在不同网络环境下的样子。 过滤器 image-20220211152552978 通过点选不同类型比如Ajax,JS,CSS等来过滤不同的请求,同时支持点选多个类型的按钮来在请求列表展示多个类型的请求。 前面Filter过滤框还提供了定制化筛选的功能,可以通过输入一些路由的关键词去搜索,也可以通过类似于正则的方式去搜索。比如: domain:*.nightteam.cn:表示domain为*.nightteam.cn的请求。 status-code:301:表示状态码为301的请求。 set-cookie-domain:bbs.nightteam.cn:表示进行set-cookie的操作的domain。 请求列表 image-20220211153842703 标题栏里面比较关键的是Initiator这一栏,表示请求是由哪里发起的。Other表示发起者通过动作发起,而不是某一个具体的JS发起。bbs.nightteam.cn是我们通过浏览器地址栏输入地址后回车发起的,所以这里显示other,而其它的请求都是可以找到具体调用的JS文件。通过定位到某一个请求的发起文件,我们可以在该文件中增加断点进行调试,所以这一栏比较重要。 点击某一个具体的请求,可以看到它的请求头信息,响应头信息,预览页面,耗时,Cookie信息等。 image-20220211154903286 选中某一个请求,然后右键选择Copy->Copy as cURL得到该请求的cURL命令。 image-20220211155142085 12345678910111213141516curl 'https://bbs.nightteam.cn/' \\ -H 'authority: bbs.nightteam.cn' \\ -H 'cache-control: max-age=0' \\ -H 'sec-ch-ua: " Not A;Brand";v="99", "Chromium";v="98", "Google Chrome";v="98"' \\ -H 'sec-ch-ua-mobile: ?0' \\ -H 'sec-ch-ua-platform: "macOS"' \\ -H 'upgrade-insecure-requests: 1' \\ -H 'user-agent: Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/98.0.4758.80 Safari/537.36' \\ -H 'accept: text/html,application/xhtml+xml,application/xml;q=0.9,image/avif,image/webp,image/apng,*/*;q=0.8,application/signed-exchange;v=b3;q=0.9' \\ -H 'sec-fetch-site: none' \\ -H 'sec-fetch-mode: navigate' \\ -H 'sec-fetch-user: ?1' \\ -H 'sec-fetch-dest: document' \\ -H 'accept-language: zh-CN,zh;q=0.9,en;q=0.8' \\ -H 'cookie: __yjs_duid=1_4110af544673b8e7f7ad03205cd2f8dc1644394949170; WoQu_2132_saltkey=YrDr0Zud; WoQu_2132_lastvisit=1644391349; popnotice=ss10; __8qcehdE7ZaRq2q6M__=5df749990f6140450200d728b907dcbe500b; WoQu_2132_sid=uh3oVX; WoQu_2132_st_p=0%7C1644476217%7C06bfc6a3958129a26420eb3777962f3c; WoQu_2132_visitedfid=39D38D37; WoQu_2132_viewid=tid_2275; WoQu_2132_lastact=1644476217%09home.php%09misc' \\ --compressed 可以直接在类Unix系统的控制台中去执行这个cURL命令,也可以把这个命令导入到PostMan中去执行。具体步骤是: image-20220211155646608 点击Shift键,然后鼠标悬停在某一个请求上,如果展示绿色背景,表示绿色背景的请求依赖你当前悬停的请求。如果展示红色背景,表示当前悬停的请求依赖红色背景的请求。 image-20220211160819614 image-20220211160845852 Source面板source面板分为三个部分:文件导航窗口,代码编辑器窗格, 调试窗格。 文件导航窗口:可以对文件目录进行浏览; 编辑器窗格:代码编辑,设置断点; 调试窗格:包含调试所用的常用选项。 image-20220211165847739 文件导航窗格 image-20220214103607974 page面板 以文件目录形式展示当前页面。包含当前网站的页面,静态资源等。 FileSystem面板 可以让开发者工具加载本地的文件系统,并且能在编辑框口修改编辑,由此化身IDE。这里做一个示范: 第一步:点击Add folder to workspace image-20220214104511894 第二步:选择要上传的文件夹 image-20220214104624359 第三步:点击允许,让开发者工具具备读写该文件夹的权限 image-20220214104720311 然后就可以看到我们上传的文件夹以及相关的JS文件了: image-20220214105309274 我们可对其进行编辑修改,打上断点调试等。 Overrides面板 Overrides面板可以很容易的将远程资源下载一份在本地,然后可以在开发者工具下进行编辑,并且开发者工具会展示我们编辑后的文件。换句话说,就是直接将一些请求代理到本地的文件中。这里同样做一个演示: 前三步跟FileSystem类似:点击Select folder for overrides,然后选择本地文件夹,之后点击允许赋予开发者工具读写该文件夹的权限。接着要点击选中Enable Local Overrides: image-20220214112558071 然后回到Network面板,点击浏览器刷新按钮刷新当前页面,选中一个我们需要代理到本地的请求,右键选择Save for overrides: image-20220214113204817 这时,我们刚才添加的文件夹里面就有了保存的代理请求: image-20220214113325024 然后我们修改本地的这个文件,比如将这个页面的招聘求职改为MLGJ求职: image-20220214113851477 然后,保存刷新这个页面,就成功的将该请求代理到本地文件: image-20220214113954313 Content scripts chrome插件加载的一些脚本,如果有过chrome浏览器插件开发的经验的话,对这个概念应该会很熟悉。在JS逆向中这个用的不多,后面会有专门的文章介绍chrome浏览器插件开发。 Snippets 我们都知道,有时候需要调试一行JS代码,会在浏览器的Console控制台上去调试,如果需要在浏览器中调试一个代码片段呢?在Console中一行行的输入执行就显得很麻烦了。这个Snippets就可以帮我们解决这个问题,可以通过点击New snippet按钮,添加一些代码片段,然后选中某一个代码片段文件,右键Run去执行该代码片段: image-20220214115844902 代码编辑器窗格可以在编辑器中打开文件,断点调试或者格式化等。下面演示一下各个功能。 代码格式化 打开Page面板下网站的一个JS文件,如果JS是展示在一行,可以点击下面的一对{}进行格式化: image-20220214140646923 添加简单断点 在我们上边格式化之后的JS文件,选中某一行,单击该行的行号,即可添加断点: image-20220214140844097 添加条件断点 在前面介绍的Snippets代码片段中添加一个片段文件,内容如下: 12345678console.log("Script snippet #1 start");var i = 1;while (5 > i) { console.log(i) debugger; i++;}console.log("Script snippet #1 end"); 这段代码比较简单,当i小于5的时候进入循环,停在断点处。下面开始条件断点的设置: 先单击要添加条件断点的那一行的行号,右键,选择Add conditional breakpoint image-20220214150928211 然后在这个条件输入框中,输入一个条件,比如这里我们是i = 10: image-20220214145305065 设置好条件断点之后,断点会变成黄色,区别于普通断点: image-20220214145448231 为了便于观察i的值,可以在最右边的调试窗格变量监控一栏添加观察i的值。 image-20220214145615800 最后右键运行该文件,如果条件断点正确的打上的话,变量监控值会看到i的值为10: image-20220214151148079 调试窗格 XHR/fetch Breakpoints XHR断点触发需要满足2个条件:一个是基于XHR的生命周期发生改变,一个是XHR的URL与我们设置的字符串相匹配。常用的是第二种。我们现在以百度作为一个演示。 打开百度网站首页,点击登录,然后打开Network面板,选择Fetch/XHR,我们进行一个抓包,只抓XHR请求的包,结果如下: image-20220214154210085 我们选择其中任意一个,比如下面一个请求,回到调试窗格,然后点击新增一个XHR断点,填入该请求的URL或者一部分: image-20220214154422755 然后我们退出登录后重新点击登录百度,可以看到设置的断点生效了: image-20220214154605187 DOM Breakpoint 还是以百度为例,切换到元素面板,然后随便选择一个DOM元素,右键Break on: image-20220214155229706 有三种类型的断点,子元素改变/属性改变/节点删除。选择其中一个,即添加成功: image-20220214155406742 由于DOM Breakpoint在逆向中不是很常用,所以做一个了解即可。 Event Listener Breakpoint 基于事件监听的断点。同样还是以百度为例,点击登录到登录页面,可以看到页面上是一个登录按钮的,这个登录按钮就是触发的submit事件。我们在调试窗格上找到Event Listener Breakpoints,然后打开Control,选中Submit: image-20220214160042544 然后我们点击登录页面的登录按钮,如无意外,断点成功打上: image-20220214160209183 断点控制按钮 image-20220214160335416 第一个按钮:执行到下一个断点,如果没有下一个断点,则按照正常执行顺序执行到下一行代码。 第二个按钮:执行到下一行代码。 第三个按钮:点击进入下一个待执行的函数调用中去。 第四个按钮:跳出当前的函数调用。 第六个按钮:去除所有断点,恢复正常执行的方法。 Call Stack 断点的调用栈列表。下面用一张图说一下调用栈顺序。 image-20220303111929970 为了表述方便,把上边的几个方法分别重新命名,如上图。在栈顶的最后被调用,在栈底的最新被调用。调用顺序如下: 1234567891011function C() { B()}function B() { A() }function A() { XMLHttpRequest.send() } 至于XMLHttpRequest.send接下来会调用哪一个方法,我们无从得知。此外,注意到那个蓝色箭头,表示当前调试执行的代码所在的方法,如果我们点开了很多文件,想快速回到我们正在调试的那一行代码可以通过鼠标点击调用栈栈顶的方法即可返回到调用处。 Scope 断点所在作用域内容。 Console面板在逆向中,console这个交互环境,经常用来输出一些变量的值。有一个方法console.count提一下,经常用来统计某一个方法的调用次数。用法比较简单,如下: image-20220214162607942 console面板还有一些骚操作,如下: console.table 我们可以在console中使用console.table,以表格形式展示数据。这里做一个示例: 打开console控制台,定义一个变量,内容如下: 1var data = [{"name": "zhangsan", "age": 21}, {"name": "lisi", "age": 22}] 然后我们调用console.table输出这个变量: image-20220214163035142 copy 可以在console中调用copy方法,将某一个变量的值复制到剪切板,比如上边的data,我们调用copy(data)就可以将data的内容复制到剪切板了。 $ $_ 表达的是上一次表达式计算的值。 $可以当作document.querySelect使用。 $$可以当作document.querySelectAll来使用。 $x可以当作xpath选择器来使用。 image-20220214163953998 总结本文介绍了浏览器开发者工具的使用技巧,主要介绍了三个面板:Console面板,Network面板和Source面板,是JS逆向必须掌握的知识。 开发者工具的使用介绍和技巧 参考Chrome Tools花式玩法(一) Chrome Tools花式玩法(二)","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"}]},{"title":"JS逆向之调用JS的两种方式","date":"2022-02-10T07:17:03.000Z","path":"JS逆向之调用JS的两种方式/","text":"调用JS代码的2种方式使用Python调用JS PyV8 V8是Google开源的一个JavaScript引擎,被使用在了Chrome中。PyV8是V8引擎的一个Python层包装,可以用来调用V8引擎来执行JS代码。最新版本2010,已经年久失修,并且存在内存泄漏的问题,不推荐使用。 Js2Py Js2Py是一个纯Python实现的JavaScript解释器和翻译器。虽然2019年依然有更新,但是也是6月份的事情,而且它的issues里有很多的bug未修复(https://github.com/PiotrDabkowski/Js2Py/issues)。 解释器部分:性能不高且存在一些bug。翻译器部分:对于高度混淆的大型JS会转换失败,而且转换出来的代码可读性差,性能不高。不推荐使用。 PyMiniRacer 同样是V8引擎的包装,和PyV8效果一样。一个继任PyExecJS和PyV8的库。而且是一个较新的库,不知道有什么坑。可以尝试。 PyExecJS 一个最开始诞生于Ruby中的库,后来被移植到了Python上。有多个引擎可选,但一般我们会选择使用Node作为引擎执行代码。 缺点:执行大型JS时会有点慢,特殊编码的输入或输出参数会出现报错的情况(可以把输入或输出的参数使用Base64编码一下)。总体而言推荐使用 Selenium 一个web自动化测试框架,可以驱动各种浏览器进行模拟人工操作。用于渲染页面以方便提取数据或过验证码。也可以用来执行JS代码。 Pyppeteer Puppeteer的Python版本,是第三方开发的,是一个Web自动化测试框架。原生支持以协程的方式调用,同时性能比Selenium更好。对于使用Asyncio+Aiohttp写爬虫的人而言可以直接使用。可以直接驱动浏览器来执行JS代码。 Playwright 微软开发的Web自动化测试框架,有多种语言的版本,支持同步与异步两种方式,也可以直接驱动浏览器来执行JS代码。 总结:如果执行的JS不是特别复杂,且不依赖浏览器环境(比如需要读取浏览器相关属性)推荐使用PyExecJS,如果执行的JS是一个比较大的工程,或者使用过程中需要读取浏览器相关属性,这时候PyExecJS已经不能满足要求,推荐使用Playwright和Playwright。 PyExecJS使用环境准备:推荐安装Nodejs,安装方便且执行效率高。然后通过pip install pyexecjs来安装PyExecJS。 然后打开终端,执行下面2行代码: 12import execjsexecjs.get().name 如果结果如下,证明PyExecJS使用的引擎是NodeJS: image-20220210160058945 如果不是,则需要手动配置一下使用的引擎,编辑系统环境变量设置如下变量即可: 1export EXECJS_RUNTIME="Node" 配置好PyExecJS后,看一下使用代码实例: 1234567891011import execjsjs_text = """function hello(str) { return str; }"""ctx = execjs.compile(js_text)res = ctx.call("hello", "hello, world!")print(res) 首先通过execjs的compile方法将js代码编译好之后保存在一个context中,然后调用context的call方法去执行js代码中的某一个function。 使用NodeJS调用JS简单来说就是,提供一个可以执行JS的HTTP API,然后通过调用这个API来执行JS并获取想要的结果。 首先将要执行的JS单独封装成一个或者多个文件 1234567var add = function(a, b) { return a + b;}module.exports = { add} 这里只是一个演示,实际可能是一段很复杂的代码逻辑。 然后使用Node搭建一个Express服务 12345678910111213141516var express = require('express')var app = express()var sum = require("./sum")var bodyParser = require('body-parser')app.use(bodyParser.json())app.use(bodyParser.urlencoded({extended: false}))app.get("/sum", function(req, res) { let params = req.query let a = parseInt(params.a) let b = parseInt(params.b) res.send(sum.add(a, b).toString())})app.listen(8081, () => {}) 最后Python客户端去调用这个服务,拿到JS代码执行之后的结果 12345678910import requestsdata = { "a": 1, "b": 2}resp = requests.get("http://127.0.0.1:8081/sum", params=data)print(resp.text) 这种方式存在的问题以及解决方案: Window对象 NodeJS没有window对象,如果要使用window对象,需要自己创建一个或者指向global 使用jsdom之类的库去替代 看下面代码示例: 123456789// 1. 这些对象存在于js,而不存在于nodejs,比如window,document, screen// 2. 这些对象的属性 是一个值。var window = {}// window.btoa = function() ...var document = {}document = {"location": {"href": "https://bbs.nightteam.cn/member.php?mod=register"}}var screen = {"width": 900, "height": 1200} Base64 window.btoa在NodeJS中不存在,可以使用Buffer.from(“Python3”).toString(“base64”)来代替。","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"JS","slug":"JS","permalink":"http://example.com/tags/JS/"}]},{"title":"一文了解Dex文件格式","date":"2022-01-17T09:27:25.000Z","path":"一文了解Dex文件格式/","text":"\u0010\u0010\u0010\u0010\u0010\u0010\u0010\u0010\u0010\u0010\u0010\u0010\u0010\u0010 Dex文件是什么在明白什么是 Dex 文件之前,要先了解一下 JVM,Dalvik 和 ART。JVM 是 JAVA 虚拟机,用来运行 JAVA 字节码程序。Dalvik 是 Google 设计的用于 Android平台的运行时环境,适合移动环境下内存和处理器速度有限的系统。ART 即 Android Runtime,是 Google 为了替换 Dalvik 设计的新 Android 运行时环境,在Android 4.4推出。ART 比 Dalvik 的性能更好。Android 程序一般使用 Java 语言开发,但是 Dalvik 虚拟机并不支持直接执行 JAVA 字节码,所以会对编译生成的 .class 文件进行翻译、重构、解释、压缩等处理,这个处理过程是由 dx 进行处理,处理完成后生成的产物会以 .dex 结尾,称为 Dex 文件。Dex 文件格式是专为 Dalvik 设计的一种压缩格式。所以可以简单的理解为:Dex 文件是很多 .class 文件处理后的产物,最终可以在 Android 运行时环境执行。 构造Dex文件Java代码转化为dex文件的流程如下: img 可以形象理解为Java源代码编译成.class文件,然后通过dx工具生成dex文件。 从.java到.class先建一个文件Hello.java,只是简单的打印一下Hello, world: 12345public class Hello { public static void main(String[] args) { System.out.println("Hello, world!"); }} 进入该文件所在的目录,使用javac编译这个java文件。 1javac Hello.java javac 命令执行后会在当前目录生成 Hello.class 文件。 从.class到.dex上面生成的 .class 文件虽然已经可以在 JVM 环境中运行,但是如果要在 Android 运行时环境中执行还需要特殊的处理,那就是 dx 处理,它会对 .class 文件进行翻译、重构、解释、压缩等操作。 dx 处理会使用到一个工具 dx.jar,这个文件位于 SDK 中,具体的目录大致为 你的SDK根目录/build-tools/任意版本 里面。使用 dx 工具处理上面生成的Hello.class 文件,在 Hello.class 的目录下使用下面的命令: 1dx --dex --output=Hello.dex Hello.class 执行完成后,会在当前目录下生成一个 Hello.dex 文件。这个 .dex 文件就可以直接在 Android 运行时环境执行。 Dex格式详解先看下dex文件的整体布局: image-20220120110317295 然后用xxd命令打开上边生成的dex文件。 1xxd Hello.dex 因为数据不长,这里直接贴出来整个Hello.dex的内容: 1234567891011121314151617181920212223242526272829303132333435363738394041424344454600000000: 6465 780a 3033 3500 555d 340e 86e9 521c dex.035.U]4...R.00000010: b10d 5708 b448 3fb8 feba cd1c 22d3 0f65 ..W..H?....."..e00000020: e002 0000 7000 0000 7856 3412 0000 0000 ....p...xV4.....00000030: 0000 0000 4002 0000 0e00 0000 7000 0000 [email protected]: 0700 0000 a800 0000 0300 0000 c400 0000 ................00000050: 0100 0000 e800 0000 0400 0000 f000 0000 ................00000060: 0100 0000 1001 0000 b001 0000 3001 0000 ............0...00000070: 7601 0000 7e01 0000 8d01 0000 9901 0000 v...~...........00000080: a201 0000 b901 0000 cd01 0000 e101 0000 ................00000090: f501 0000 f801 0000 fc01 0000 1102 0000 ................000000a0: 1702 0000 1c02 0000 0300 0000 0400 0000 ................000000b0: 0500 0000 0600 0000 0700 0000 0800 0000 ................000000c0: 0a00 0000 0800 0000 0500 0000 0000 0000 ................000000d0: 0900 0000 0500 0000 6801 0000 0900 0000 ........h.......000000e0: 0500 0000 7001 0000 0400 0100 0c00 0000 ....p...........000000f0: 0000 0000 0000 0000 0000 0200 0b00 0000 ................00000100: 0100 0100 0d00 0000 0200 0000 0000 0000 ................00000110: 0000 0000 0100 0000 0200 0000 0000 0000 ................00000120: 0200 0000 0000 0000 3102 0000 0000 0000 ........1.......00000130: 0100 0100 0100 0000 2502 0000 0400 0000 ........%.......00000140: 7010 0300 0000 0e00 0300 0100 0200 0000 p...............00000150: 2a02 0000 0800 0000 6200 0000 1a01 0100 *.......b.......00000160: 6e20 0200 1000 0e00 0100 0000 0300 0000 n ..............00000170: 0100 0000 0600 063c 696e 6974 3e00 0d48 .......<init>..H00000180: 656c 6c6f 2c20 776f 726c 6421 000a 4865 ello, world!..He00000190: 6c6c 6f2e 6a61 7661 0007 4c48 656c 6c6f llo.java..LHello000001a0: 3b00 154c 6a61 7661 2f69 6f2f 5072 696e ;..Ljava/io/Prin000001b0: 7453 7472 6561 6d3b 0012 4c6a 6176 612f tStream;..Ljava/000001c0: 6c61 6e67 2f4f 626a 6563 743b 0012 4c6a lang/Object;..Lj000001d0: 6176 612f 6c61 6e67 2f53 7472 696e 673b ava/lang/String;000001e0: 0012 4c6a 6176 612f 6c61 6e67 2f53 7973 ..Ljava/lang/Sys000001f0: 7465 6d3b 0001 5600 0256 4c00 135b 4c6a tem;..V..VL..[Lj00000200: 6176 612f 6c61 6e67 2f53 7472 696e 673b ava/lang/String;00000210: 0004 6d61 696e 0003 6f75 7400 0770 7269 ..main..out..pri00000220: 6e74 6c6e 0001 0007 0e00 0301 0007 0e78 ntln...........x00000230: 0000 0002 0000 8180 04b0 0201 09c8 0200 ................00000240: 0d00 0000 0000 0000 0100 0000 0000 0000 ................00000250: 0100 0000 0e00 0000 7000 0000 0200 0000 ........p.......00000260: 0700 0000 a800 0000 0300 0000 0300 0000 ................00000270: c400 0000 0400 0000 0100 0000 e800 0000 ................00000280: 0500 0000 0400 0000 f000 0000 0600 0000 ................00000290: 0100 0000 1001 0000 0120 0000 0200 0000 ......... ......000002a0: 3001 0000 0110 0000 0200 0000 6801 0000 0...........h...000002b0: 0220 0000 0e00 0000 7601 0000 0320 0000 . ......v.... ..000002c0: 0200 0000 2502 0000 0020 0000 0100 0000 ....%.... ......000002d0: 3102 0000 0010 0000 0100 0000 4002 0000 1...........@... 接下来对照这个dex文件的内容一步步解析整个dex文件的格式。 header在android源码中对dex文件的格式,有了详细的定义: 12345678910111213141516171819202122232425struct DexHeader { u1 magic[8]; // 魔数 u4 checksum; // adler 校验值 u1 signature[kSHA1DigestLen]; // sha1 校验值 u4 fileSize; // DEX 文件大小 u4 headerSize; // DEX 文件头大小 u4 endianTag; // 字节序 u4 linkSize; // 链接段大小 u4 linkOff; // 链接段的偏移量 u4 mapOff; // DexMapList 偏移量 u4 stringIdsSize; // DexStringId 个数 u4 stringIdsOff; // DexStringId 偏移量 u4 typeIdsSize; // DexTypeId 个数 u4 typeIdsOff; // DexTypeId 偏移量 u4 protoIdsSize; // DexProtoId 个数 u4 protoIdsOff; // DexProtoId 偏移量 u4 fieldIdsSize; // DexFieldId 个数 u4 fieldIdsOff; // DexFieldId 偏移量 u4 methodIdsSize; // DexMethodId 个数 u4 methodIdsOff; // DexMethodId 偏移量 u4 classDefsSize; // DexCLassDef 个数 u4 classDefsOff; // DexClassDef 偏移量 u4 dataSize; // 数据段大小 u4 dataOff; // 数据段偏移量}; 其中,u标识无符号整数,u1表示8位无符号整数即一个字节,u4表示32位无符号整数即四个字节。 下面用一个表格对照Hello.dex对每一个成员含义做一个简单的说明,后边会针对某些字段有一个详细的说明。 成员名称 成员长度(字节) 含义 magic 8 魔数,必须出现在文件开头,标识其文件格式,用8个1字节的无符号数来表示,它可以分解为:文件标识 dex + 换行符 + DEX 版本 + 0, 这里是64 65 78 0a 30 33 35 00,表示dex的版本是35 checksum 4 checksum 是对去除 magic 、 checksum 以外的文件部分作 adler32 算法得到的校验值,用于判断 DEX 文件是否被篡改。这里的值是0e34 5d55(为啥不是555d 340e?因为是小端存储,关于大小端参见 大端还是小端) signature 20 SHA1签名,除magic,checksum和它本身,作为文件的唯一标识。这里的值是86 e9 52 1c b1 0d 57 08 b4 48 3f b8 fe ba cd 1c 22 d3 0f 65 fileSize 4 dex文件的大小,包括头文件,这里是0000 02e0 headerSize 4 dex头文件的大小,这里是0000 0070 endianTag 4 端存储标记,主要是用来判断大端存储还是小端存储。默认值是1234 5678,即小端存储,这里是1234 5678 linkSize 4 文件链接段大小,为0则表示静态链接,这里是0000 0000 linkOff 4 文件链接段的偏移位置,如果链接段大小为0,则偏移位置也为0,这里是0000 0000 mapOff 4 DexMapList的文件偏移,这里是0000 0240,也即DexMapList的基址是0000 0240 stringIdsSize 4 dex文件包含的字符串数量,这里是0000 000e stringIdsOff 4 dex文件字符串偏移位置,这里是0000 0070 typeIdsSize 4 dex文件类型信息的数量,这里是0000 0007 typeIdsOff 4 dex文件类型信息的偏移位置,这里是0000 00a8 protoIdsSize 4 dex文件方法声明的数量,这里是0000 0003 protoIdsOff 4 dex文件方法声明偏移位置,这里是0000 00c4 fieldIdsSize 4 dex文件字段信息的数量,这里是0000 0001 fieldIdsOff 4 dex文件字段信息的偏移位置,这里是0000 00e8 methodIdsSize 4 dex文件方法的数量,这里是0000 0004 methodIdsOff 4 dex文件方法的偏移位置,这里是0000 00f0 classDefsSize 4 dex文件类的数量,这里是0000 0001 classDefsOff 4 dex文件类信息的偏移位置,这里是0000 0110 dataSize 4 dex文件数据区的大小,这里是0000 01b0 dataOff 4 dex文件数据区的偏移位置,这里是0000 0130 下面针对部分字段,进一步理解: 1. 验证checksum通过前面表格,了解到checksum 是对去除 magic 、 checksum 以外的文件部分作 alder32 算法得到的校验值,这里我们先备份一下Hello.dex文件,然后用UE(UltraEdit)打开Hello.dex,删除magic,checksum信息,如下: image-20220120113433764 保存之后,执行如下Python代码: 1234import zlibwith open("Hello.dex", "rb") as f: print(zlib.adler32(f.read())) 输出结果是238312789,转为十六进制为0e34 5d55,正好是该文件的checksum。 2. 验证signature类似于在上面验证checksum文件,进一步删除signature,如图: image-20220120143546894 保存之后,执行如下Python代码: 1234import hashlibwith open("Hello.dex", "rb") as f: print(hashlib.sha1(f.read()).hexdigest()) 输出结果是86e9521cb10d5708b4483fb8febacd1c22d30f65,正好是删除的signature。 3. 验证fileSize从备份的Hello.dex还原dex文件,然后前面表格得出整个dex文件的大小是2e0即736个字节,我们用ll命令验证下: image-20220120150229254 4. 验证headerSizeheaderSize占0000 0070也即112个字节。我们通过DexHeader这个结构体可以算出来: magic(8个字节)+checksum(4个字节)+signature(20个字节)+fileSize(4个字节)+ … + dataOff(4个字节)=112字节。 string_ids字符串id区域,这个区域是一个偏移量列表,每个偏移量对应一个真正的字符串资源,每个偏移量占32位,即4个字节。我们可以通过偏移量找到对应的实际字符串数据。 从DexFile.h中我们可以找到DexStringId的定义: 123struct DexStringId { u4 stringDataOff; /* file offset to string_data_item */}; 通过注释可以看到,这个区域存的并不是真正的字符串,只是存储了真正字符串的偏移位置,stringIdsSize为0000 000e即14,stringIdsOff为0000 0070。我们找到地址0000 0070h,然后取出后边的4*14个字节: 123400000070: 7601 0000 7e01 0000 8d01 0000 9901 0000 v...~...........00000080: a201 0000 b901 0000 cd01 0000 e101 0000 ................00000090: f501 0000 f801 0000 fc01 0000 1102 0000 ................000000a0: 1702 0000 1c02 0000 这里以第一个偏移为例,解释具体每个字符串偏移背后代表的真正字符串的,取出前4个字节,即0000 0176,然后找到0000 0176h这个地址: image-20220120164703702 dex中的字符串采用了一种叫做MUTF-8这样的编码,它是经过传统的UTF-8编码修改的。在MTUF-8中,它的头部存放的是由uleb128编码的字符的个数。所以第一个字节06表示的含义是字符串的字节数是6个,然后我们往后推6个字节,即3C 69 6E 69 74 3E,对照ASCII码表含义如下: 字符 3C 69 6E 69 74 3E 对应ASCII码 < i n i t > 即<init>。 其余的13个字符串可以按照这个步骤,依次分析出来,这里就不展开了,给出一个表格如下: 索引 偏移值 字符个数(十六进制) 字符串十六进制内容 对应ASCII内容 0 0000 0176 06 3C 69 6E 69 74 3E <init> 1 0000 017e 0D 48 65 6C 6C 6F 2C 20 77 6F 72 6C 64 21 Hello, world! 2 0000 018d 0A 48 65 6C 6C 6F 2E 6A 61 76 61 Hello.java 3 0000 0199 07 4C 48 65 6C 6C 6F 3B LHello; 4 0000 01a2 15 4C 6A 61 76 61 2F 69 6F 2F 50 72 69 6E 74 53 74 72 65 61 6D 3B Ljava/io/PrintStream; 5 0000 01b9 12 4C 6A 61 76 61 2F 6C 61 6E 67 2F 4F 62 6A 65 63 74 3B Ljava/lang/Object; 6 0000 01cd 12 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 7E 67 3B Ljava/lang/String; 7 0000 01e1 12 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 79 73 74 65 6D 3B Ljava/lang/System; 8 0000 01f5 01 56 V 9 0000 01f8 02 56 4C VL a 0000 01fc 13 5B 4C 6A 61 76 61 2F 6C 61 6E 67 2F 53 74 72 69 7E 67 3B [Ljava/lang/String; b 0000 0211 04 6D 61 69 6E main c 0000 0217 03 6F 75 74 out d 0000 021c 07 70 72 69 6E 74 6C 6E println type_ids类型id区域,索引的值对应字符串id区域偏移量列表中的某一项,每一个人偏移也是占4个字节。 从DexFile.h中我们可以找到DexTypeId的定义: 123456/* * Direct-mapped "type_id_item". */struct DexTypeId { u4 descriptorIdx; /* index into stringIds list for type descriptor */}; 从注释可以看到如果我们要找到某个类型的值,需要先根据类型id列表中的索引值去字符串id列表中找到对应的项,这一项存储的偏移量对应的字符串资源就是这个类型的字符串描述。 typeIdsSize为0000 0007,typeIdsOff为0000 00a8,我们找到地址为0000 00a8然后取出后面7*4个字节。 10300 0000 0400 0000 0500 0000 0600 0000 0700 0000 0800 0000 0a00 0000 以第一个偏移0000 0003为例,即索引为3,查上面的string_ids得到的字符串列表,即为LHello;。类似地,可以分析出来其余6个: 索引 对应string_idx的索引 类型 0 0000 0003 LHello; 1 0000 0004 Ljava/io/PrintStream; 2 0000 0005 Ljava/lang/Object; 3 0000 0006 Ljava/lang/String; 4 0000 0007 Ljava/lang/System; 5 0000 0008 V 6 0000 000a [Ljava/lang/String; proto_ids方法原型id区,这个区块是一个方法原型 id 列表。 从DexFile.h中可以找到其定义: 12345678/* * Direct-mapped "proto_id_item". */struct DexProtoId { u4 shortyIdx; /* index into stringIds for shorty descriptor */ u4 returnTypeIdx; /* index into typeIds list for return type */ u4 parametersOff; /* file offset to type_list for parameter types */}; 各个字段的解释如下: img 可以看到,这个数据结构由三个变量组成。第一个shortyIdx它指向的是我们上面分析的DexStringId列表的索引,代表的是方法声明字符串。第二个returnTypeIdx它指向的是 我们上边分析的DexTypeId列表的索引,代表的是方法返回类型字符串。第三个parametersOff指向的是DexTypeList的位置索引,这又是一个新的数据结构了,先说一下这里面 存储的是方法的参数列表。可以看到这三个参数,有方法声明字符串,有返回类型,有方法的参数列表,这基本上就确定了我们一个方法的大体内容。 parametersOff指向DexTypeList,我们看下DexTypeList的数据结构: 1234struct DexTypeList { u4 size; /* #of entries in list */ DexTypeItem list[1]; /* entries */}; 包含2个字段,第一个是大小说的是DexTypeItem的个数。 那DexTypeItem又是什么呢?我们再看下其数据结构: 123struct DexTypeItem { u2 typeIdx; /* index into typeIds */}; 包含一个指向DexTypeId列表的索引,也就是代表参数列表中某一个具体的参数的位置。 protoIdsSize为0000 0003,protoIdsOff为0000 00c4,找到地址为0000 00c4然后取出后边3*12个字节(为啥是12,因为每一个proto_id数据结构占12个字节): 1230800 0000 0500 0000 0000 00000900 0000 0500 0000 6801 00000900 0000 0500 0000 7001 0000 先对第一个方法原型进行分析,前四个字节0000 0008为shorty_ids,对应string_ids的索引,查上面string_ids字符串的表格知其为V,即方法描述短格式为void()。中间四个字节0000 0005为return_type_idx,对应type_ids的索引,查上面的type_ids类型区域表格知其为V,即返回值类型为void。最后四个字节为0000 0000,即代表方法无参数。 在看第二个方法原型。前四个字节0000 0009为short_ids,对应string_ids的索引,查上面string_ids字符串的表格知其为VL。中间四个字节为0000 0005为return_type_idx,对应type_idx的索引,查上面的type_ids类型区域表格知其为V,即返回值类型为void。最后四个字节为0000 0168,找到168h这个地址如下: image-20220121150146530 这里DexTypeList数据结构,我们先看前4个字节,代表DexTypeItem的个数,0000 0001也就是1,说明只有一个DexTypeItem,每一个占2个字节,就是00 03,查看type_ids表格,找到索引为3的,即Ljava/lang/String;,说明有一个String类型的参数。第三个方法原型类似,就不展开了。整理三个方法原型如下表格: 索引 方法描述短格式 返回值类型 参数类型 方法原型 0 V V 无参数 void() 1 VL V Ljava/lang/String; void(java.lang.String) 2 VL V [Ljava/lang/String; void(java.lang.String[]) field_ids成员id区。这个区域是一个类成员id区域列表。定义如下: 12345678/* * Direct-mapped "field_id_item". */struct DexFieldId { u2 classIdx; /* index into typeIds list for defining class */ u2 typeIdx; /* index into typeIds for field type */ u4 nameIdx; /* index into stringIds for field name */}; 各字段的解释如下: img 这里我们Hello.dex的fieldIdsSize为0000 0001,说明只存在一个DexField,fieldIdOff为0000 00e8,找到地址为e8h然后取出后边8个字节,即04 00 01 00 0c 00 00 00,其中前2个字节0004,表示class_idx,表示成员所在的类在类型区域的索引,查表得Ljava/lang/System;。中间2个字节0001,表示type_idx,表示该成员自身的类型在类型区域的索引,查表得Ljava/io/PrintStream;。最后4个字节0000 000c,表示name_idx,表示该成员的名字在字符串区域的索引,查表得out。所以Hello.dex中仅包含一个成员为java.io.PrintStream java.lang.System.out。 method_ids方法id区,这个方法是存储方法id的列表。数据格式为: 12345678/* * Direct-mapped "method_id_item". */struct DexMethodId { u2 classIdx; /* index into typeIds list for defining class */ u2 protoIdx; /* index into protoIds for method prototype */ u4 nameIdx; /* index into stringIds for method name */}; 解释如下: img methodIdsSize和methodIdsOff分表为0000 0004和0000 00f0,即该dex文件一共包含4个方法,方法id区的偏移地址为f0h,我们找到这个地址,然后取出后边4*(2+2+4)个字节,如下: 12340000 0000 0000 0000 0000 0200 0b00 00000100 0100 0d00 0000 0200 0000 0000 0000 然后根据DexMethodId数据结构,查上边的type_ids表格,proto_ids表格以及string_ids表格,这里就不一一展开了,结果整理如下: 序号 class_idx proto_idx name_idx 方法 0 LHello; void() <init> void Hello.<init>() 1 LHello; void(java.lang.String[]) main void Hello.main(java.lang.String[]) 2 Ljava/io/PrintStream; void(java.lang.String) println void java.io.PrintStream.println(java.lang.String) 3 Ljava/lang/Object; void() <init> void java.lang.Object.<init>() class_def类定义区。这个区域存储的是类定义的列表,具体的数据结构如下: 12345678910111213/* * Direct-mapped "class_def_item". */struct DexClassDef { u4 classIdx; /* index into typeIds for this class */ u4 accessFlags; u4 superclassIdx; /* index into typeIds for superclass */ u4 interfacesOff; /* file offset to DexTypeList */ u4 sourceFileIdx; /* index into stringIds for source file name */ u4 annotationsOff; /* file offset to annotations_directory_item */ u4 classDataOff; /* file offset to class_data_item */ u4 staticValuesOff; /* file offset to DexEncodedArray */}; 各字段的含义如下: img 在开始分析这个类的结构之前,先看DexFile.h中定义的一组枚举值: 1234567891011121314151617181920212223enum { ACC_PUBLIC = 0x00000001, // class, field, method, ic ACC_PRIVATE = 0x00000002, // field, method, ic ACC_PROTECTED = 0x00000004, // field, method, ic ACC_STATIC = 0x00000008, // field, method, ic ACC_FINAL = 0x00000010, // class, field, method, ic ACC_SYNCHRONIZED = 0x00000020, // method (only allowed on natives) ACC_SUPER = 0x00000020, // class (not used in Dalvik) ACC_VOLATILE = 0x00000040, // field ACC_BRIDGE = 0x00000040, // method (1.5) ACC_TRANSIENT = 0x00000080, // field ACC_VARARGS = 0x00000080, // method (1.5) ACC_NATIVE = 0x00000100, // method ACC_INTERFACE = 0x00000200, // class, ic ACC_ABSTRACT = 0x00000400, // class, method, ic ACC_STRICT = 0x00000800, // method ACC_SYNTHETIC = 0x00001000, // field, method, ic ACC_ANNOTATION = 0x00002000, // class, ic (1.5) ACC_ENUM = 0x00004000, // class, field, ic (1.5) ACC_CONSTRUCTOR = 0x00010000, // method (Dalvik only) ACC_DECLARED_SYNCHRONIZED = // ...}; classDefsSize和classDefsOff分别为0000 0001和0000 0110。即只有一个类定义,其偏移地址为110h,我们找到该地址,并取出32个字节: 10000 0000 0100 0000 0200 0000 0000 0000 0200 0000 0000 0000 3102 0000 0000 0000 前4个字节代表类的类型,为0000 0000查表知为LHello;。接下来4个字节为类的访问权限,为0000 0001,记得我们上边刚提到的那个枚举值定义吗,1代表ACC_PUBLIC即Public访问权限。接下来4个字节0000 0002为父类对应的类型,查表知为Ljava/lang/Object;。然后四个字节0000 0000为这个类实现的接口在dex文件中的偏移,因为我们这个类没有实现接口,所以这里为0000 0000。紧接着的四个字节0000 0002查表知为Hello.java为该类类源码所在的文件。然后4个字节为该类的注解在文件中的偏移,很显然这个类没有注解,所以为0000 0000。接下来的4个字节则是表示该类的具体数据在文件中的偏移,这里先不讨论,后边会针对类数据区专门讨论。最后4个字节表示静态成员初始值列表在文件中的偏移,很显然我们这个类没有静态成员。整理一下如下: 类的类型 类的权限 父类的类型 实现的接口 类定义所在的文件 类注解 类具体数据 静态成员初始值列表 Hello ACC_PUBLIC java.lang.Object 无 Hello.java 无 偏移值0000 0231 无 总结本文对dex文件结构进行了一个简单的剖析,让我们对dex文件结构有了一个基本的认识。最后附上一个dex文件结构图以及思维图帮助我们记忆dex文件结构。 dex文件层次结构图: img dex文件结构思维导图: img 参考资料Android逆向笔记 —— DEX 文件格式解析 浅谈 Android Dex 文件 一文读懂 DEX 文件格式解析 一篇文章带你搞懂DEX文件的结构 DexFile.h Android软件安全与逆向分析-非虫","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"android","slug":"android","permalink":"http://example.com/tags/android/"}]},{"title":"Android逆向之动态加载","date":"2022-01-15T15:40:22.000Z","path":"Android逆向之动态加载/","text":"动态加载开始正题之前,在这里可以先给动态加载技术做一个简单的定义。真正的动态加载应该是 应用在运行的时候通过加载一些本地不存在的可执行文件实现一些特定的功能。 这些可执行文件是可以替换的。 更换静态资源(比如换启动图、换主题、或者用服务器参数开关控制广告的隐藏现实等)不属于动态加载。 Android中动态加载的核心思想是动态调用外部的 dex文件,极端的情况下,Android APK自身带有的Dex文件只是一个程序的入口(或者说空壳),所有的功能都通过从服务器下载最新的Dex文件完成。 两个疑问🤔提出两个问题,第一,如何在Android程序中加载外部dex的class;第二,对于有生命周期的组件(比如Activity这种类)该如何加载?本文的目的就是通过解决这2个问题从而对Android的动态加载技术有一定的了解。 类加载器与双亲委派要解决这俩问题,首先要了解几个概念。 类加载器类加载器顾名思义是用来进行类的加载。分别看下JVM的类加载器和Android的类加载器。 JVM的类加载器JVM的类加载器包括3种: Bootstrap ClassLoader(引导类加载器) C/C++代码实现的加载器,用于加载指定的JDK的核心类库,比如java.lang,java.util等这些系统类。Java虚拟机的启动就是通过Bootstrap,该Classloader在java里无法获取,负责加载/lib下的类。 Extensions ClassLoader(拓展类加载器) Java中的实现类为ExtClassLoader,提供了除了系统类之外的额外功能,可以在Java里获取,负责加载/lib/ext下的类。 Application ClassLoader(应用程序类加载器) Java中的实现类为AppClassLoader,是与我们接触最多的类加载器,开发人员写的代码默认就是由它来加载,ClassLoader.getSystemCLassLoader返回的就是它。 同时,我们也可以自定义类加载器,只需要通过继承java.lang.ClassLoader类的方式来实现自己的类加载器即可。 Android中的类加载器首先通过一张图,了解各个加载器之间的继承关系。 image 详细看下各个类加载器的作用: ClassLoader为抽象类; BootClassLoader预加载常用类,单例模式。与Java中的BootClassLoader不同,它并不是由C/C++代码实现,而是由Java实现的; BaseDexClassLoader是PathClassLoader, DexClassLoader, InMemoryDexClassLoader的父类,类加载的主要逻辑都是在BaseDexClassLoader完成的; SecureClassLoader继承了抽象类ClassLoader,拓展了ClassLoader类加入了权限方面的功能,加强了安全性,其子类URLClassLoader是用URL路径从jar文件中加载类和资源。 PathClassLoader是Android默认使用的类加载器,一个apk中的Activity等类便是在其中加载。 DexClassLoader可以加载任意目录下的dex/jar/apk/zip文件,比PathClassLoader更灵活,是实现插件化,热修复以及dex加壳的重点。 InMemoryDexClassLoader是8.0引入的,是用于直接从内存中加载dex。 其中重点关注的是:PathClassLoader和DexClassLoader,因为这2个类加载器是我们解决上面2个问题的关键,也是这个动态加载中非常重要的的类加载器。 类加载的时机 隐式加载。 创建类的实例。 访问类的静态变量,或者为静态变量赋值。 调用类的静态方法。 使用反射方式来强制创建某个类或者接口对应的java.lang.Class对象。 显式加载。 使用LoadClass()加载。 使用forName()加载。 类加载的步骤 装载。查找和导入Class文件。 链接。其中解析步骤是可以选择的。 检查:检查载入的class文件数据的正确性。 准备:给类的静态变量分配存储空间。 解析:将符号引用转换成直接引用。 初始化:即调用函数,对静态变量,静态代码块执行初始化工作。 image 编写代码测试Android ClassLoader的继承关系动手之前先通过Android源码阅读网站,看下ClassLoader的源码: 打开AndroidXRef,Definition填写ClassLoader,搜索包位置libcore,如果不确定位置,可以选中全部,只不过搜索出来的结果不比较多,筛选起来麻烦一些。搜索出来一个ClassLoader.java文件,点击进入: 123456789101112131415public abstract class ClassLoader { // 省略 // The parent class loader for delegation // Note: VM hardcoded the offset of this field, thus all new fields // must be added *after* it. private final ClassLoader parent; @CallerSensitive public final ClassLoader getParent() { return parent; } // 省略 } 这里关注一个属性和一个成员方法,通过组合关系parent来标识每一个ClassLoader的父亲,这个parent是实现双亲委派的关键,调用getParent成员方法则可以获取到parent属性。 看了这么多理论,没有动手coding去实践,有一点枯燥,接下来编写一个demo去测试一下Android的ClassLoader之间的继承关系。 新建一个项目ClassLoaderTest,然后编码如下: 1234567891011121314151617181920212223242526272829303132333435363738package com.example.classloadertest;import androidx.appcompat.app.AppCompatActivity;import android.content.Context;import android.os.Bundle;import android.util.Log;public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); testClassLoader(); } public void testClassLoader() { // 获取当前的ClassLoader Context context = getApplicationContext(); ClassLoader thisClassLoader = context.getClassLoader(); // 或者使用下面这种方式: // ClassLoader thisClassLoader = MainActivity.class.getClassLoader(); Log.i("kanxue", "thisClassLoader:" + thisClassLoader); ClassLoader tmpClassLoader = null; ClassLoader parentClassLoader = thisClassLoader.getParent(); // 向上遍历classLoader while (parentClassLoader != null) { Log.i("kanxue", "this:" + thisClassLoader + ", parent:" + parentClassLoader); tmpClassLoader = parentClassLoader.getParent(); thisClassLoader = parentClassLoader; parentClassLoader = tmpClassLoader; } Log.i("kanxue", "root:" + thisClassLoader); }} 代码比较简单,这里也是直接用了上面源码分析的getParent方法,通过getParent方法拿到parent,然后一层层的向上遍历,从而测试出各个ClassLoader的继承关系。 执行结果如下: image 双亲委派双亲委派的工作原理如果一个类加载器收到了类加载请求,它并不会自己先去加载,而是把这个请求委派给父类的加载器去执行,如果父类加载器还存在其父类加载器,则进一步向上委托,依次递归,请求最终将到达顶层的启动类加载器,如果父类加载器可以完成类加载任务,就成功返回,倘若父类加载器无法完成此加载任务,子加载器才会尝试自己去加载,这就是双亲委派模式,即每个儿子都不愿意干活,每次有活就丢给父亲去干,直到父亲说这件事也干不了时,儿子自己想办法去完成,这就是双亲委派。 image 为什么会有双亲委派 避免重复加载,如果已经加载过一次Class,可以直接读取已经加载的Class 更加完全,无法自定义类来代替系统的类,可以防止核心API库被随意篡改。 解决第一个问题通过前面的理论知识,我们知道了DexClassLoader可以加载任意目录下的dex/jar/apk/zip文件,所以我们先来解决第一个问题。 生成一个用来测试的dex文件。 创建项目DexLoaderTest,然后新建Class:TestCLass。 123456789package com.example.dexloadertest;import android.util.Log;public class TestClass { public void testFunc() { Log.i("kanxue", "call from DexLoaderTest.TestClass.testFunc"); }} 代码比较简单,只是简单的打印了一条日志。 接着,打包该项目,将生成的apk解压,得到dex文件(如果解压得到多个classes.dex文件,查看下我们编写的TestClass类在哪个classes.dex文件),然后将这个classes.dex放到手机的内存卡: 1adb push classes.dex /sdcard/ 加载sdcard上的dex 修改DexLoaderTest,修改其MainActivity的代码如下: 123456789101112131415161718192021222324252627282930313233343536373839404142package com.example.dexloadertest;import androidx.appcompat.app.AppCompatActivity;import android.content.Context;import android.os.Bundle;import android.os.Environment;import java.io.File;import java.lang.reflect.InvocationTargetException;import java.lang.reflect.Method;import dalvik.system.DexClassLoader;public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); String sdcardPath = Environment.getExternalStorageDirectory().getAbsolutePath(); dexClassLoaderTest(getApplicationContext(), sdcardPath + File.separator + "classes.dex"); } public void dexClassLoaderTest(Context context, String dexFilePath) { Class<?> clazz; try { DexClassLoader dexClassLoader = new DexClassLoader(dexFilePath, null, null, MainActivity.class.getClassLoader()); clazz = dexClassLoader.loadClass("com.example.dexclass.TestClass"); if (clazz != null) { Method testFuncMethod = clazz.getDeclaredMethod("testFunc"); Object obj = clazz.newInstance(); testFuncMethod.invoke(obj); } } catch (ClassNotFoundException | NoSuchMethodException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } catch (InstantiationException | InvocationTargetException e) { e.printStackTrace(); } }} 代码也不复杂,首先实例化一个DexClassLoader对象,然后通过这个对象加载TestClass类,然后拿到testFunc方法,最后通过反射调用这个方法。这里主要关注的是DexClassLoader这个类,我们查看下源码: 1234567891011121314151617181920212223public class DexClassLoader extends BaseDexClassLoader {36 /**37 * Creates a {@code DexClassLoader} that finds interpreted and native38 * code. Interpreted classes are found in a set of DEX files contained39 * in Jar or APK files.40 *41 * <p>The path lists are separated using the character specified by the42 * {@code path.separator} system property, which defaults to {@code :}.43 *44 * @param dexPath the list of jar/apk files containing classes and45 * resources, delimited by {@code File.pathSeparator}, which46 * defaults to {@code ":"} on Android47 * @param optimizedDirectory this parameter is deprecated and has no effect since API level 26.48 * @param librarySearchPath the list of directories containing native49 * libraries, delimited by {@code File.pathSeparator}; may be50 * {@code null}51 * @param parent the parent class loader52 */53 public DexClassLoader(String dexPath, String optimizedDirectory,54 String librarySearchPath, ClassLoader parent) {55 super(dexPath, null, librarySearchPath, parent);56 }57} 其构造函数需要四个参数,第一个参数是包含资源文件的jar/apk/dex等文件的路径,如果有多个路径,通过:分隔。第二个参数已经废弃。第三个参数为native库的路径,这里我们也是置为null。最后一个是parent类加载器,我们设置为当前类加载器即可。 因为用到对sdcard的读写,所以需要在AndroidManifest.xml中添加相应的权限: 12<uses-permission android:name="android.permission.READ_EXTERNAL_STORAGE" /><uses-permission android:name="android.permission.WRITE_EXTERNAL_STORAGE" android:maxSdkVersion="28" /> 运行程序,结果如下: image 解决第二个问题如果不考虑双亲委派以及Activity生命周期的问题,我们是不是可以用类似于第一个问题的解决方案,采用DexClassLoader加载Activity类,然后使用Intent直接访问这个Activity呢?说干就干。 生成一个用来测试的dex 新建一个TestActivity文件,然后让TestActivity继承AppCompatActivty,并重写onCreate方法。 1234567public class TestActivity extends AppCompatActivity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.d("TestActivity", "i am from TestActivity.onCreate"); }} 这个方法比较简单,仅仅是打印一条日志。 同样地,打包该项目,将生成的apk解压,得到dex文件,然后将这个classes.dex放到手机的内存卡。 1adb push classes3.dex /sdcard/ 加载并启动Activity 编辑DexClassLoader,修改MainActivity代码如下: 123456789101112131415161718192021public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); String sdcardPath = Environment.getExternalStorageDirectory().getAbsolutePath(); startActivityTest(this, sdcardPath + File.separator + "classes3.dex"); } public void startActivityTest(Context context, String dexFilePath) { Class<?> clazz = null; DexClassLoader dexClassLoader = new DexClassLoader(dexFilePath, null, null, MainActivity.class.getClassLoader()); try { clazz = dexClassLoader.loadClass("com.example.dexclass.TestActivity"); } catch (ClassNotFoundException e) { e.printStackTrace(); } context.startActivity(new Intent(context, clazz)); }} 因为有用到TestActivity,所以需要在AndroidManifest.xml中声明: 1<activity android:name="com.example.dexclass.TestActivity"/> 运行项目,在手机设置中给应用开启sdcard读写权限,然后重新执行,结果报错: 执行结果 想想也不可能成功,要是能跟问题一一样解决,那还叫两个问题吗🤪 那该如何解决第二个问题呢?此时就得从Activity的启动流程说起了,但是这篇文章的目的还是以动态加载为主,Activity以及App的启动流程会有专门的文章去介绍,本文最后给出的参考链接,也会有包含Activity启动流程的介绍。这里挑几个关键链路上的函数简单说下原理: 12345678910111213141516171819202122232425262728293031323334/** Core implementation of activity launch. */private Activity performLaunchActivity(ActivityClientRecord r, Intent customIntent) { ActivityInfo aInfo = r.activityInfo; if (r.packageInfo == null) { r.packageInfo = getPackageInfo(aInfo.applicationInfo, r.compatInfo, Context.CONTEXT_INCLUDE_CODE); } // ... Activity activity = null; try { java.lang.ClassLoader cl = appContext.getClassLoader(); activity = mInstrumentation.newActivity( cl, component.getClassName(), r.intent); StrictMode.incrementExpectedActivityCount(activity.getClass()); r.intent.setExtrasClassLoader(cl); r.intent.prepareToEnterProcess(); if (r.state != null) { r.state.setClassLoader(cl); } } catch (Exception e) { if (!mInstrumentation.onException(activity, e)) { throw new RuntimeException( "Unable to instantiate activity " + component + ": " + e.toString(), e); } } // ... return activity;} 看注释就知道,这个方法是启动Activity的核心方法,在启动Activity之前会通过调用getPackageInfo方法来先获取并解析Activity信息,我们进到这个方法中。 123456public final LoadedApk getPackageInfo(ApplicationInfo ai, CompatibilityInfo compatInfo, int flags) { // ... return getPackageInfo(ai, compatInfo, null, securityViolation, includeCode, registerPackage);} 这个三个参数的getPackageInfo调用了五个参数的getPackageInfo方法,注意第三个参数传的是null,我们继续进入五个参数的getPackageInfo方法。 1234567891011121314151617181920212223242526272829303132333435363738private LoadedApk getPackageInfo(ApplicationInfo aInfo, CompatibilityInfo compatInfo, ClassLoader baseLoader, boolean securityViolation, boolean includeCode, boolean registerPackage) { final boolean differentUser = (UserHandle.myUserId() != UserHandle.getUserId(aInfo.uid)); synchronized (mResourcesManager) { WeakReference<LoadedApk> ref; if (differentUser) { // Caching not supported across users ref = null; } else if (includeCode) { ref = mPackages.get(aInfo.packageName); } else { ref = mResourcePackages.get(aInfo.packageName); } LoadedApk packageInfo = ref != null ? ref.get() : null; if (packageInfo == null || (packageInfo.mResources != null && !packageInfo.mResources.getAssets().isUpToDate())) { if (localLOGV) Slog.v(TAG, (includeCode ? "Loading code package " : "Loading resource-only package ") + aInfo.packageName + " (in " + (mBoundApplication != null ? mBoundApplication.processName : null) + ")"); packageInfo = new LoadedApk(this, aInfo, compatInfo, baseLoader, securityViolation, includeCode && (aInfo.flags&ApplicationInfo.FLAG_HAS_CODE) != 0, registerPackage); if (mSystemThread && "android".equals(aInfo.packageName)) { packageInfo.installSystemApplicationInfo(aInfo, getSystemContext().mPackageInfo.getClassLoader()); } // ... } return packageInfo; }} 有一个HashMap即mPackages维护包名和LoadedApk的对应关系,即每一个应用有一个键值对对应,如果为null,就新创建一个LoadedApk对象,并将其添加到Map中。第一次执行Activity的时候,很显然是没有这个LoadedApk对象的,所以会生成一个新的LoadedApk对象,然后注意到传入了一个baseLoader,正是上面传的null。 我们再回头看下performLaunchActivity,当调用完getPackageInfo之后,会调用java.lang.ClassLoader cl = appContext.getClassLoader();去获取classLoader,我们进到ContextImpl.getClassLoader方法: 1234@Override public ClassLoader getClassLoader() { return mClassLoader != null ? mClassLoader : (mPackageInfo != null ? mPackageInfo.getClassLoader() : ClassLoader.getSystemClassLoader()); } 这里的mClassLoader正是上面传入的null,而mPackageInfo是上边生成的LoadedApk对象不为空,所以会调用LoadedApk的getClassLoader方法。这里就不在一层一层的剥开了,因为想节省点笔迹🤪,总之,翻源码到最后,你会发现最终是通过调用ClassLoader.getSystemClassLoader来获取一个classLoader,而这个classLoader正好是PathClassLoader。 到这里一切真相大白了吧?前面**我们虽然用DexClassLoader通过对APK的动态加载成功加载了TestActivity到虚拟机,但是当系统启动该Activity的时候,依然会出现加载类失败的异常,因为Activity在启动时用到的是PathClassLoader**。前面在介绍Android的ClassLoader的时候提到过,PathClassLoader是Android默认使用的类加载器,一个APK中的Activity等类便是在其中加载,但是我们的TestActivity不存在于当前的APK,而是在外部的dex文件上,自然而然的就会出现上边找不到Activity的异常了。 那我们是不是可以替换掉这个PathClassLoader为DexClassLoader不就好了吗?答案是肯定的。除了这个方案之外,我们还可以利用双亲委派的原理,给出另一种方案。两种解决方案如下: 替换系统组件类加载器为我们的DexClassLoader,同时设置DexClassLoader的parent为系统组件的类加载器。 打破原有的双亲关系,在系统组件类加载器和BootClassLoader中插入我们自己的DexClassLoader即可。 方案一:替换mClassLoader为DexClassLoader image-20220115213655318 修改`MainActivity`的代码如下: 12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455public class MainActivity extends AppCompatActivity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); String sdcardPath = Environment.getExternalStorageDirectory().getAbsolutePath(); startActivityTest(this, sdcardPath + File.separator + "classes3.dex"); } private void replaceClassLoader(ClassLoader classLoader) { try { // 加载ActivityThread类 Class<?> ActivityThreadClazz = classLoader.loadClass("android.app.ActivityThread"); // 获取currentActivityThread方法,从而获取ActivityThread实例 Method currentActivityThreadMethod = ActivityThreadClazz.getDeclaredMethod("currentActivityThread"); currentActivityThreadMethod.setAccessible(true); Object activityThreadObj = currentActivityThreadMethod.invoke(null); // 获取ActivityThread的mPackage属性 Field mPackageField = ActivityThreadClazz.getDeclaredField("mPackages"); mPackageField.setAccessible(true); // 获取loadedApk对象 ArrayMap mPackageObj = (ArrayMap) mPackageField.get(activityThreadObj); WeakReference wr = (WeakReference) mPackageObj.get(this.getPackageName()); Object loadedApkObj = wr.get(); // 替换mClassLoader Class loadedApkClazz = classLoader.loadClass("android.app.LoadedApk"); Field mClassLoader = loadedApkClazz.getDeclaredField("mClassLoader"); mClassLoader.setAccessible(true); mClassLoader.set(loadedApkObj, classLoader); } catch (ClassNotFoundException | NoSuchMethodException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } catch (InvocationTargetException e) { e.printStackTrace(); } catch (NoSuchFieldException e) { e.printStackTrace(); } } public void startActivityTest(Context context, String dexFilePath) { Class<?> clazz = null; DexClassLoader dexClassLoader = new DexClassLoader(dexFilePath, null, null, MainActivity.class.getClassLoader()); try { replaceClassLoader(dexClassLoader); clazz = dexClassLoader.loadClass("com.example.dexclass.TestActivity"); } catch (ClassNotFoundException e) { e.printStackTrace(); } context.startActivity(new Intent(context, clazz)); }} 运行项目,结果如预期: image-20220115213932649 方案二:在mClassLoader和BootClassLoader之间插入DexClassLoader image-20220115214708638 修改TestActivity代码如下: 1234567public class TestActivity extends Activity { @Override protected void onCreate(@Nullable Bundle savedInstanceState) { super.onCreate(savedInstanceState); Log.d("TestActivity", "i am from TestActivity.onCreate"); }} 把AppCompatActivity改为了Activity,防止有一些类重复加载。 再次修改MainActivity的代码如下: 12345678910111213141516171819202122232425262728293031public class MainActivity extends Activity { @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); setContentView(R.layout.activity_main); String sdcardPath = Environment.getExternalStorageDirectory().getAbsolutePath(); startActivityTest(this, sdcardPath + File.separator + "classes3.dex"); } public void startActivityTest(Context context, String dexFilePath) { Class<?> clazz = null; ClassLoader pathClassLoader = MainActivity.class.getClassLoader(); ClassLoader bootClassLoader = MainActivity.class.getClassLoader().getParent(); // dexClassLoader的parent为BootClassLoader DexClassLoader dexClassLoader = new DexClassLoader(dexFilePath, null, null, bootClassLoader); try { Field parentField = ClassLoader.class.getDeclaredField("parent"); parentField.setAccessible(true); // 当前组件的ClassLoader的parent为DexClassLoader parentField.set(pathClassLoader, dexClassLoader); clazz = dexClassLoader.loadClass("com.example.dexclass.TestActivity"); } catch (ClassNotFoundException | NoSuchFieldException e) { e.printStackTrace(); } catch (IllegalAccessException e) { e.printStackTrace(); } context.startActivity(new Intent(context, clazz)); }} 运行项目,结果也如预期: image-20220115231522507 总结动态加载就是用到的时候再去加载,也叫懒加载,也就意味着用不到的时候是不会去加载的。动态加载是dex加壳,插件化,热更新的基础。动态加载的dex不具有生命周期特征,App中的Activity, Service等组件无法正常工作,只能完成一般函数的调用;需要对ClassLoader进行修正,App才能正常运行,两种修正方案: 替换系统组件类加载器为我们的DexClassLoader,同时设置DexClassLoader的parent为系统组件的类加载器。 打破原有的双亲关系,在系统组件类加载器和BootClassLoader中插入我们自己的DexClassLoader即可。 参考链接Android动态加载Activity原理 FART:ART环境下基于主动调用的自动化脱壳方案 ActivityThread源码 Activity的启动流程探究 Android动态加载基础 ClassLoader工作机制","tags":[{"name":"逆向","slug":"逆向","permalink":"http://example.com/tags/%E9%80%86%E5%90%91/"},{"name":"android","slug":"android","permalink":"http://example.com/tags/android/"}]},{"title":"从零开始学深度学习——泰勒公式","date":"2021-01-15T12:18:44.000Z","path":"从零开始学深度学习——泰勒公式/","text":"泰勒公式(也叫 泰勒展开式、泰勒多项式)泰勒级数 它是微积分学下的一个重要概念,与之有关联的有:如泰勒定理,多元泰勒公式,以拉格朗日型余项为代表的各类余项,审敛法,牛顿差值公式(牛顿级数)(列出为了进行树状知识整合和梳理) 什么是泰勒公式基本定义泰勒公式为:$$f(x){Taylor}=\\sum{n=0}^{\\infty}{\\frac{f^{(n)}(a)}{n!}} \\times (x-a)^n=f(a)+\\frac{f^\\prime(a)}{1!}(x-a)+\\frac{f^{(2)}(a)}{2!}(x-a)^2+…+\\frac{f^{(n)}(a)}{n!}(x-a)^n+R_n(x)$$分析下每部分表示的含义: $f^{(n)}(a)$ 表示 $f(x)$ 在的第 $n$ 阶导数的表达式带入一个 $a$ 计算后的结果,所以它是一个值 $\\frac{1}{n!}$ 是一个系数,每一项都不同,第一项:$\\frac{1}{1!}$,第二项:$\\frac{1}{2!}$,第三项:$\\frac{1}{3!}$ … 以此类推 $(x-a)^n$ 是一个以 $x$ 为自变量的表达式 $R_n(x)$ 是泰勒公式的余项,是 $(x-a)^{n}$ 的高阶无穷小 在记住公式之前,认真梳理下公式的各个部分含义,会清晰很多,更加容易帮助我们理解记忆公式。 联想链条 所有的 <内容>➜ 符号都表达【由<内容>联想到】(一种牢固记忆的技巧) 联想链条是为了给你一把一个长期记忆的钥匙,很久不用之后,估计只能记住【泰勒公式】四个字了,如何利用这仅有的信息回忆起具体的理解和内容,除了理解透彻,直观,利用图像外,弄一个联想链条也是不错的方法 首先拆字 【公式】 <什么公式?>➜ 【多项式】(Polynomials),把多项式的一般形式写出来,这应该是非常容易理解的概念,即指数不仅仅为2的抛物线的组合。$$P_n(x)=\\sum_{i=0}^{n}{c_ix^i}=c_0+c_1x+c_2x^2+…+c_nx^n$$【泰勒】<谐音“太乐” ≈ 如果所有小数都能近似成整数那不是太快乐了?> ➜ 近似,获得一个直观理解 泰勒公式通过把【任意函数表达式】转换(重写)为【多项式】形式,是一种极其强大的函数近似工具 为什么说它强大呢? 多项式非常【友好】,三易,易计算,易求导,易积分 几何感觉和计算感觉都很直观,如抛物线和几次方就是底数自己乘自己乘几次 泰勒公式干的事情就是:使用多项式表达式估计(近似) $f(x)$ 在 $x=a$ 附近的值 那么如何近似呢?使用一个例子来加深理解 怎样理解泰勒公式我们要干的事情,就是改变多项式函数 $P(x)=c_0+c_1x+c_2x^2$ 中 $c_0,c_1,c_2$ 的值 (只有三项是为图个方便)去近似余弦函数 $f(x)=cos(x)$,【近似过程】参考下面的动图 我们需要做的事情(目的)即寻找一条绿色的曲线(多项式的系数 $c_0,c_1,c_2$),在 $x=0$ 附近(0为上面提到的 $a$)尽可能的与 $f(x)=cos(x)$ 的图像相似(重合) 函数式角度那如何才能找到这三个参数呢?最为显而易见的做法就是希望在 $x=0$ 的位置,两个表达式的切线尽量相等,切线即斜率,也就是求导,比较抽象,一步一步来可视化一下。 近似过程 【确定 $c_0$】$x=0$ 带入公式,令 $cos(x)=1$ ,同理对 $p(x)$ 可以得到 $c_0=1$ 【确定 $c_1$ 】容易观察到,如果对 $p(x)$可以把 $c_1$ 前的自变量去掉。并且,$x=0$ 处 $p(x)$ 已经固定为1,为了更进一步的相似,如果我们让 $x=0$ 处的 $f(x)$ 和 $p(x)$ 的切线斜率也相同不就更近似了?(两种思考模式我觉得都可以) 求导之后可以的到 $c_1=0$ 【确定 $c_2$ 】现在我们已经确定两个值,那么绿色曲线就只能如下图一样移动(固定了 $x=0$ 的函数值和 $x=0$ 处的斜率 ),为了更接近相似的目标,我们希望斜率在变化的过程中,速度也是近似的(滑动的白色和黄色直线)。求二次导数,斜率的变化率相等,确定 $c_2=−\\frac{1}{2}$ 此时得到表达式 $p(x)=1-\\frac{1}{2}x^2$ ,检测一下近似度如何?$cos(0.1)≈1−\\frac{1}{2}(0.1)^2=0.995$ 同时计算器计算得到 $cos(0.1)=0.9950042$ ,其实只取前几项的多项式已经在 $x=a$ 附近的近似这一要求上有很好的效果了 为什么这个【近似过程】写的这么详细,是为了在过程中体会两个关键点。 为什么使用多项式来近似因为多项式的求导法则可以控制变量,消去低次项,使得 $x=a$ 未知的 $c_n$ 容易确定,在之前的例子里,如下图所示 $c_0$ 确保了 $x=0$ 时相等,$c_1$ 确保了 $x=0$ 时的斜率相等,$c_2$ 确保了 $x=0$ 时斜率的变化率相等,或者说,随着多项式幂次变高,这种近似就越精确 为什么有个系数 $\\frac{1}{n!}$阶层系数是由一次一次的求导产生的。我们再把项数加两个,参看下图,直观的感受一个 $n!$ 的诞生 首先,低次项会变为0,这样可以很方便的通过计算 $f(x)$ 的 $n$ 次求导的表达式,带入 $x=a$ 即可得到 $c_n$ 的值,阶层其实是多次求导的系数 函数角度总结其实,某一点处的导数值信息 ⟺ 那一点附近的函数值信息 这个直观感觉,是很重要的 首先,对于 $cos(x)$ 这个具体例子,把 $x=0$ 位置的多阶导数求出,再使用多项式进行近似,使用的项越多,得到的近似就越准确,参看下面动图 推广到一般函数 $f(x)$ ,下列动图描述了随着项的增加,$x=0$ 附近的越来越准确 最后,推广到 $x=a$ 的情形,完全推导出泰勒展开式的一般形式,如下列动图所示 几何角度首先定义一个函数表示求下列图像中函数图像中填满部分的面积,函数为 $f(x)$,面积函数为 $f_{area}(x)$ ,而围成面积区域的曲线即为面积函数的导数 $\\frac{df_{area}}{dx}(x)$(至于为什么是这样,有一个牛逼的名字叫做,微积分基本定理: $∫^b_af(t)dt=F(b)−F(a) $),如下图所示 定义一个这样的场景是为了计算这样一件事(如下图所示):假设我们知道了 $f(a)$ 点的面积,往右扩展很小的距离 $dx$ 要算出新部分的面积(左边绿色已知 + 黄色矩形 + 红色三角形),公式会是什么样的呢? 设 $dx$ 开始点为 $a$,终点为 $x$ ,则可以得到 【黄色矩形】底边为 $x-a$ ;高为 $\\frac{df_{area}}{dx}(a)$ ; 【红色三角形】底边为 $x-a$;高的计算稍微麻烦,首先,斜边的斜率是 $\\frac{df_{area}}{dx}(x)$, 函数的导数在 $x=a$ 时的函数值(算斜率,求导数即可),而斜率 $k=\\frac{y}{x}$ ,所以得到高为 $\\frac{d^2f_{area}}{dx^2}(a)\\times (x-a)$(前部分是斜率,后半部分是 $x$ ,需要求的是 $y$ 也是高) 【计算总面积】如下图和公式所示 $$f_{area}(x) \\approx f(a) + \\frac{df_{area}}{dx}(a)(x-a)+\\frac{1}{2}\\frac{d^2f_{area}}{dx^2}(a)(x-a)^2$$ 这个公式为啥这么眼熟呢?其实明显就是泰勒展开式的前3项,如果你还要打破沙锅问到底,第4项呢?你可以放大红色三角形,把函数曲线和面积之间的空白部分再次用多个更小的三角形填补,在积分工具的帮助下,可以得到三次项 从几何角度来看,再一次验证了,泰勒公式是近似的 $x=a$ 附近的函数值这一直观理解 余项我们知道,对泰勒公式来说,并没有办法完全逼近待求函数,所以无论如何到最后都会留一点东西,这剩下的东西不好表达,就全都丢到余项中 可以暂时如此理解,不在此迷惑,如果是专业学生,需要深究,建议参看专业教材深入理解其中玄妙 泰勒级数完成对【泰勒公式】的理解后,需要对【级数 Series】这个概念进行一个推广,什么是【级数】呢? 在数学中,【级数】就是无限多项的和 在把泰勒展开式,扩展到无限项之后,就会出现【收敛 Converge】和【发散 diverge】的概念 收敛收敛,即在泰勒展开式被推广到无限项之后,整体式子的值会越来越趋近于一个定值,比如下图的 $12$ 和 $e$ 发散与收敛相对应的,即发散,式子无法趋近于一个定值,比如 $\\ln(x)$ 在 $x=1$ 附近,如下图所示,虚线即为能够让多项式的和收敛的最大取之范围,称为【泰勒级数的收敛半径】 总结 泰勒公式干了一件什么事? 使用多项式表达式估计(近似) $f(x)$ 在 $x=a$ 附近的值 泰勒公式的导数项如何推倒出来的? 某一点处的导数值信息 ⟺ 那一点附近的函数值信息 泰勒公式如何记永远不会忘? 参照第一条总结,是 $x=a$ 附近,公式 ➜ 多项式,很多项,要用求和写在一起;参照第二条总结,近似信息用的求导;系数就是对 $x=a$ 处求导一次一次放下来。$$f(x)=\\sum_{n=0}^{\\infty}{\\frac{f^{(n)}a}{n!}}(x-a)^n$$","tags":[{"name":"数学","slug":"数学","permalink":"http://example.com/tags/%E6%95%B0%E5%AD%A6/"},{"name":"深度学习","slug":"深度学习","permalink":"http://example.com/tags/%E6%B7%B1%E5%BA%A6%E5%AD%A6%E4%B9%A0/"}]},{"title":"从零开始学深度学习——方向导数与梯度","date":"2021-01-14T13:34:57.000Z","path":"从零开始学深度学习——方向导数与梯度/","text":"偏导数回顾导数定义在看偏导数的定义之前,回顾下导数的定义:$y=f(x), x_0\\in D, f^\\prime(x_0)=\\lim_{\\Delta x -> 0}\\frac{\\Delta y}{\\Delta x}或者f^\\prime(x_0) = \\lim_{x->x_0}\\frac{f(x) - f(x_0)}{x - x_0}$ 然后还有以下性质: $f(x)在x=x_0可导\\Rightarrow f(x)在x=x_0连续,反之不成立$ $f(x)在x=x_0可导 \\Leftrightarrow f(x)在x=x_0可微$ 偏导数定义$\\zeta=f(x, y), ((x, y)\\in D), \\forall M_0(x_0, y_0)\\in D$ 称 $\\Delta \\zeta_x = f(x_0 + \\Delta x, y_0) - f(x_0, y_0)$ 为 $f(x, y)$ 在 $M_0$ 处关于x的偏增量 称 $\\Delta \\zeta_y = f(x_0, y_0 + y_0 + \\Delta y) - f(x_0, y_0)$ 为 $f(x, y)$ 在 $M_0$ 处关于y的偏增量 称 $\\Delta \\zeta = f(x_0 + \\Delta x, y_0 + \\Delta y) - f(x_0, y_0) 或 \\Delta \\zeta = f(x, y) - f(x_0, y_0)$ 为 $f(x, y)$ 在 $M_0$ 处的全增量 若 $\\lim_{\\Delta x-> 0} \\frac{\\Delta \\zeta x}{\\Delta x} \\exists 或 \\lim_{x->x_0} \\frac{f(x, y_0)-f(x_0, y_0)}{x-x_0} \\exists$ 称 $f(x, y)$ 在 $M_0$ 处关于x可偏导,极限值称为 $f(x, y)$ 在 $M_0$ 处关于x的偏导数,记为$f^\\prime_{x}(x_0, y_0)$ 或者 $\\frac{\\partial \\zeta}{\\partial x}|_{(x_0, y_0)}$ 若 $\\lim_{\\Delta y-> 0} \\frac{\\Delta \\zeta y}{\\Delta y} \\exists 或 \\lim_{y->y_0} \\frac{f(x_0, y)-f(x_0, y_0)}{y-y_0} \\exists$ 称 $f(x, y)$ 在 $M_0$ 处关于y可偏导,极限值称为 $f(x, y)$ 在 $M_0$ 处关于y的偏导数,记为$f^\\prime_{y}(x_0, y_0)$ 或者 $\\frac{\\partial \\zeta}{\\partial y}|_{(x_0, y_0)}$ 若 $\\forall (x, y) \\in D, f(x, y)$ 在 $(x, y)$ 处对x, y皆可以偏导,称 $f^\\prime_x(x, y), f^\\prime_y(x, y)$ 为 $f(x, y)$ 对 x, y的偏导函数,简称偏导数。 偏导数的计算例. $\\zeta = f(x, y) = x^2y + \\ln(x+y^2),求\\frac{\\partial \\zeta}{\\partial x}, \\frac{\\partial \\zeta}{\\partial y}$ 解:$\\frac{\\partial \\zeta}{\\partial x} = 2xy + \\frac{1}{x+y^2} $ $\\frac{\\partial \\zeta}{\\partial y} = x^2 + \\frac{1}{x+y^2} \\cdot 2y$ 偏导数的几何意义$\\zeta=f(x, y), ((x, y)\\in D), \\forall M_0(x_0, y_0)\\in D$ $\\frac{\\partial \\zeta}{\\partial x}$的几何意义是:通过该点(x, y)且与ZOX平面平行的平面与 $\\zeta$ 的交线上,该点(x, y)的效率,如下图: 之所以取与ZOX平行的平面,是为了保持y方向的增量为0。 $\\frac{\\partial \\zeta}{\\partial y}$ 的几何意义类似。 高阶偏导数设函数 $z=f(x, y)$ 在区域 $D$ 内具有偏导函数 $\\frac{\\partial z}{\\partial x} = f_x(x, y), \\ \\frac{\\partial z}{\\partial y} = f_y(x, y)$ ,如果关于偏导函数 $f_x(x, y), \\ f_y(x, y)$ 的偏导数也存在,那么称他们是函数 $z=f(x, y)$ 的二阶偏导数,四个二阶偏导数如下: $\\frac{\\partial}{\\partial x}(\\frac{\\partial z}{\\partial x}) = f_{xx}(x, y), \\ \\frac{\\partial}{\\partial y}(\\frac{\\partial z}{\\partial y}) = f_{yy}(x, y), \\ \\frac{\\partial}{\\partial x}(\\frac{\\partial z}{\\partial y}) = f_{yx}(x, y), \\ \\frac{\\partial}{\\partial y}(\\frac{\\partial z}{\\partial x}) = f_{xy}(x, y)$ 其中,后边两个偏导数叫做混合偏导数。同样可以得三阶,四阶…以及n阶偏导数,二阶及其以上的偏导数统称为高阶偏导数。 高阶偏导数的计算例. $z=x^3y^2 - 3xy^3 - xy + 1,\\ \\frac{\\partial^2 z}{\\partial x^2}? \\ \\frac{\\partial^2 z}{\\partial x \\partial y}? \\ \\frac{\\partial^2 z}{\\partial y \\partial x}? \\ \\frac{\\partial^2 z}{\\partial y^2}? \\ \\frac{\\partial^3z}{\\partial x^3}? $ 解:$\\frac{\\partial z}{\\partial x} = 3x^2y^2 - 3y^3 - y, \\ \\ \\frac{\\partial z}{\\partial y} = 2x^3y - 9xy^2 - x$ $\\frac{\\partial^2 z}{\\partial x^2}=6xy^2, \\ \\ \\frac{\\partial^2 z}{\\partial x \\partial y} = 6x^2y - 9y^2 - 1, \\ \\ \\frac{\\partial^2 z}{\\partial y \\partial x} = 6x^2y - 9y^2 - 1$ $\\frac{\\partial^2 z}{\\partial y^2} = 2x^3 - 18xy, \\ \\ \\frac{\\partial^3z}{\\partial x^3} = 6y^2$ 高阶偏导数的性质如果函数 $z=f(x, y)$ 的两个二阶混合偏导数 $z=\\frac{\\partial^2 z}{\\partial x \\partial y}$ 及 $\\frac{\\partial^2 z }{\\partial y \\partial x}$ 在定义域D内连续,那么该区域内该二阶偏导必然相等。 方向导数定义 假设有$\\eta=f(x, y) ((x, y) \\in D)$, 其图像在如下图所示,$\\eta$的图像是有一个不规则的圆柱体组成,圆柱体的顶面是一个不规则的曲面。 在圆柱体的底面任取一点$M_0$,过这个点$M_0$随机作一条射线L,取$M_1(x_0+\\Delta x, y_0 + \\Delta y) \\in L$,如上图所示,则$M_0$到$M_1$的距离$\\rho$为$\\rho=\\sqrt{(\\Delta x)^2 + (\\Delta y)^2}$。 过这条射线L作一个垂直于圆柱体底面的平面,这个平面与圆柱体的顶面即不规则的曲面相交产生一条弧线,$M_{0}$和$M_{1}$分别对应弧线上的M和$M^\\prime$ 。$M_{0}$M和$M_{1}M^\\prime$的高度差记为$\\Delta \\eta$,则$\\Delta \\eta=f(x_{0}+\\Delta x, y_{0}+\\Delta y) - f(x_0, y_0)$ 我们来看看这个$\\Delta \\eta$,$\\Delta \\eta>0$相当于沿着这个曲面由地势低的地方往地势高的地方走,就像图中的沿着曲面从M点走到$M^\\prime$这个点。反过来,若$\\Delta \\eta<0$相当于沿着这个曲面从地势高的地方往地势低的地方走,就好比从$M^\\prime$走向M。 我们再看看$\\lim_{\\rho->0}{\\frac{\\Delta \\eta}{\\rho}}$,这个指标反映从地势高往地势低或地势低往地势高方向上移动的效率,如果这个值越大,表示这个地方(曲面上这极小块区域)越陡峭。如果$\\lim_{\\rho->0}{\\frac{\\Delta \\eta}{\\rho}}$存在,称此极限为$\\eta=f(x, y)$在$M_{0}$处沿射线L的方向导数。记为$\\frac{\\partial \\eta}{\\partial L}|{M{0}}$ 即$\\frac{\\partial \\eta}{\\partial L}|{M{0}}=\\lim_{\\rho->0}{\\frac{\\Delta \\eta}{\\rho}} 或 \\frac{\\partial \\eta}{\\partial L}|{M{0}}=\\lim_{\\rho->0}{\\frac{f(x_{0}+\\Delta x, y_{0}+\\Delta y) - f(x_0, y_0)}{\\rho}}$ 计算公式2-dims $\\zeta = f(x, y),((x, y)\\in D),M_{0}\\in D$ 过 $M_{0}$作射线L,设L的方向角为$\\alpha 和 \\beta$(方向角的定义如下图),则 $\\frac{\\partial \\zeta}{\\partial L}|{M{0}} = \\frac{\\partial \\zeta}{\\partial x} \\cos{\\alpha} + \\frac{\\partial \\zeta}{\\partial y} \\cos{\\beta}$,其中$\\frac{\\partial \\zeta}{\\partial x}$表示$\\zeta$对x的偏导,$\\frac{\\partial \\zeta}{\\partial y}$表示$\\zeta$对y的偏导。 3-dims$u(x, y, z)$ 在 $M_0(x_0, y_0, z_0)$ 可微,过 $M_0$ 作射线L,L的方向角为 $\\alpha \\beta \\gamma,$ 则 $\\frac{\\partial u}{\\partial L}|{M_0}=\\frac{\\partial u}{\\partial x}|{M_0}\\cos \\alpha + \\frac{\\partial u}{\\partial y}|{M_0}\\cos \\beta + \\frac{\\partial u}{\\partial z}|{M_0}\\cos \\gamma$ 例. 求 $\\zeta = xe^{2y}在M_0(1, 0)沿从M_0(1, 0)到M(2, -1)的方向导数$ 解:$\\frac{\\partial \\zeta}{\\partial x}|{M_0} = 1, \\frac{\\partial \\zeta}{\\partial y}|{M_0} = 2 $ $\\vec{M_0M} = {1, -1}, \\cos\\alpha = \\frac{1}{\\sqrt{2}}, \\cos\\beta = \\frac{-1}{\\sqrt{2}} $ $\\frac{\\partial \\zeta}{\\partial L}|M_0 = 1 \\cdot \\frac{1}{\\sqrt{2}} + 2 \\cdot (-\\frac{1}{\\sqrt{2}})$ 梯度梯度的定义$u=f(x, y, z),M_0(x_0, y_0, z_0)\\in \\Omega $ 过 $M_0$ 作射线L,产生方向角 $\\alpha$ $\\beta$ $\\gamma$ 。则: $\\frac{\\partial u}{\\partial L}|M_0 = \\frac{\\partial u}{\\partial x}|M_0\\cos \\alpha + \\frac{\\partial u}{\\partial y}|M_0\\cos \\beta + \\frac{\\partial u}{\\partial z}|M_0\\cos \\gamma$ = ${\\frac{\\partial u}{\\partial x}, \\frac{\\partial u}{\\partial y}, \\frac{\\partial u}{\\partial z}} \\cdot {\\cos\\alpha, \\cos\\beta, \\cos\\gamma}$ 向量 ${\\frac{\\partial u}{\\partial x}, \\frac{\\partial u}{\\partial y}, \\frac{\\partial u}{\\partial z}}$ 是一个常向量(固定位置,给定了M_0的坐标,这个向量就是一个确定向量) 向量 ${\\cos\\alpha, \\cos\\beta, \\cos\\gamma}$ 是一个单位向量(与L同向,任何一个向量,它的方向余弦构成的向量,就是该向量的单位向量,方向与该向量相同,单位是1) $\\frac{\\partial u}{\\partial L}|M_0 = {\\frac{\\partial u}{\\partial x}, \\frac{\\partial u}{\\partial y}, \\frac{\\partial u}{\\partial z}} \\vec{e} = \\sqrt{(\\frac{\\partial u}{\\partial x})^2 + (\\frac{\\partial u}{\\partial y})^2 + (\\frac{\\partial u}{\\partial z})^2} \\cos\\theta$ $\\sqrt{(\\frac{\\partial u}{\\partial x})^2 + (\\frac{\\partial u}{\\partial y})^2 + (\\frac{\\partial u}{\\partial z})^2}$是一个固定的数,当 $\\cos\\theta = 1$ 即 $\\theta=0$时,$\\frac{\\partial u}{\\partial L}|M_0$取最大值 那么,$u=f(x, y)$ 在 $M_0$ 的梯度即 $grad \\ u|M_0$ = ${\\frac{\\partial u}{\\partial x}, \\frac{\\partial u}{\\partial y}, \\frac{\\partial u}{\\partial z}}|M_0$ 。一般地,$grad \\ u = {\\frac{\\partial u}{\\partial x}, \\frac{\\partial u}{\\partial y}, \\frac{\\partial u}{\\partial z}}$ 梯度的方向,即函数增长速度最快的方向,或者方向导数取最大值的方向。 与方向导数的关系函数 $\\zeta$ 在 $M_0$ 沿着不同的射线可以得到不同的方向导数,梯度则是过 $M_0$ 点所有这些方向导数取最大值时那个射线的方向。 用一个动画展示二者的关系: 梯度的计算设 $u=xyz+z^2+5$, 求 $grad \\ u$,并求在点M(0, 1, -1)处的方向导数的最大(小)值。 解:$\\because \\frac{\\partial u}{\\partial x} = yz, \\frac{\\partial u}{\\partial y} = xz, \\frac{\\partial u}{\\partial z} = xy + 2z $ $\\therefore grad \\ u|{(0, 1, -1)} = (yz, xz, xy+2z)|{(0, 1, -1)} = (-1, 0, -2) $ 从而 $max{\\frac{\\partial u}{\\partial L}|_M} = ||grad \\ u|| = \\sqrt{5}, min{\\frac{\\partial u}{\\partial L}|_M} = -||grad \\ u|| = -\\sqrt{5}$","tags":[{"name":"数学","slug":"数学","permalink":"http://example.com/tags/%E6%95%B0%E5%AD%A6/"},{"name":"深度学习","slug":"深度学习","permalink":"http://example.com/tags/%E6%B7%B1%E5%BA%A6%E5%AD%A6%E4%B9%A0/"}]}]