一、逆向基础1.1 语法基础1.2 作用域1.3 窗口对象属性1.4 事件二、浏览器控制台2.1 Network2.1.1 Network-Headers2.2 Sources2.3 Application2.4 Console三、加密参数的定位方法3.1 巧用搜索3.2 堆栈调试3.3 控制台调试3.4 监听XHR3.5 事件监听3.6 添加代码片3.7 Hook四、常见的压缩和混淆4.1 JavaScript压缩4.2 JavaScript混淆4.3 javascript-obfuscator示例4.3.1 代码压缩4.3.2 变量名混淆4.3.3 字符串混淆4.3.4 代码自我保护4.3.5 控制流平坦化4.3.6 无用代码注入4.3.7 对象键名替换4.3.8 禁用控制台输出4.3.9 调试保护4.3.10 域名锁定4.3.11特殊编码五、常见的编码和加密5.1 base645.2 MD55.3 SHA15.4 HMAC5.4 DES5.5 AES5.6 RSA六、加密参数还原与模拟6.1 Newrank榜单逆向案例6.3 RSA参数加密逆向案例七、浏览器环境补充7.1 常被检测的环境7.2 手动补充环境7.3 JSDOM环境补充7.4 Selenium环境模拟7.5 puppeteer环境模拟八、加密方法远程调用8.1 RPC 的基本原理8.2 示例代码8.3 示例运行效果九、AST技术简介9.1 AST介绍9.2 实例引入9.3 准备工作9.4 节点类型9.5 @babel/parser的使用9.6 @babel/generator的使用9.7 @babel/traverse的使用9.8 @babel/types的使用十、使用AST技术还原混淆代码10.1 表达式还原10.2 字符串还原十一、WebAssembly案例分析和爬取实战11.1 案例介绍11.2 模拟执行十二、无限debugger的原理与绕过12.1 案例介绍12.2 实现原理12.3 禁用断点12.4 替换文件十三、JavaScript逆向技巧总结13.1 寻找入口13.1.1 查看请求13.1.2 搜索参数13.1.3 分析发起调用13.1.4 断点13.2 调试分析13.2.1 格式化13.2.2 断点调试13.2.3 反混淆13.3 模拟执行13.3.1 python改写或者模拟执行13.3.2 JavaScript模拟执行+API13.3.3 浏览器模拟执行
Web页面已经是大家非常熟悉的事物。在Web开发过程中,后端主要负责程序架构设计和数据管理,而前端则负责页面的展示效果和用户交互。有一种不够严谨但通俗易懂的说法:“前端代码写给浏览器看,后端代码写给服务器看
。”
对有开发经验的朋友来说,前后端交互的机制理解会更加深入。在当前主流的前后端分离开发模式下,前后端通过接口标准进行对接与整合。为了确保接口调用过程中的数据安全性,以及防止请求参数被篡改,大多数接口都会引入一些安全机制
,比如请求签名、身份认证、动态Cookie等。此外,一些网站还会对返回数据进行加密处理,常见的加密方式包括AES、RSA等,或者在传输过程中对数据进行序列化(如Protobuf)。这些技术点我们会在后续章节中详细讨论。
在接口设计中,请求签名是一种非常常见的安全措施,比如URL中的加密参数sign;身份认证也有很多实例,比如通过动态Cookie实现。生成这些参数的逻辑通常由JavaScript代码控制。因此,如果我们想绕过浏览器环境,直接从接口获取数据,就需要深入调试并分析JavaScript的调用逻辑,弄清楚网站中加密参数的生成过程。通过研究和还原加密参数的生成规则,这个过程通常被称为JavaScript逆向
。
目前,我们总结了几种常见的加密参数逆向方法:
1、基于源码还原加密逻辑
:直接分析并实现加密算法;
2、补环境复制代码进行模拟
:搭建完整的运行环境,将原有加密代码移植到新环境中运行;
3、RPC远程调用
:通过远程调用的方式直接复用目标环境的加密逻辑。
在一些实际案例中,关键步骤是将浏览器运行环境移植到Node.js中。由于Node.js采用了V8引擎,与浏览器运行JavaScript的引擎一致,因此可在一定程度上复用浏览器的加密逻辑。但需要注意的是,Node.js没有图形界面支持,因此浏览器中的一些特定API(如window、navigator、DOM操作等)在Node环境中并不存在。因此,掌握Node.js环境的搭建以及补齐浏览器环境,是进行JavaScript逆向的重要技能之一。
此外,Chrome浏览器是JavaScript逆向的核心工具。熟练使用Chrome控制台以及编写相关插件,基本可以满足大多数抓包、调试和Hook的需求。这部分内容也会在后续详细讲解。
一、逆向基础
1.1 语法基础
Js调试相对方便,通常只需要chrome或者一些抓包工具、扩展插件,就能顺利的完成逆向分析。但是Js的弱类型和语法多样,各种闭包,逗号表达式等语法让代码可读性变得不如其他语言顺畅。所以需要学习一下基础语法。
基本数据类型
引用数据类型
语句标识符
算数运算符
比较运算符
在JavaScript中将数字存储为64位浮点数,但所有按位运算都以32位二进制数执行。在执行位运算之前,JavaScript将数字转换位32位有符号整数。执行按位操作后,结果将转换回64位JavaScript数。
Javascript 函数
JavaScript 中的函数是头等公民,不仅可以像变量一样使用它,同时它还具有十分强大的抽象能力
定义函数的 2 种方式
在JavaScript 中,定义函数的方式如下:
function abs(x) {if (x >= 0) {return x;} else {return -x;}
}
上述 abs() 函数的定义如下:
function 指出这是一个函数定义;
abs 是函数的名称;
(x) 括号内列出函数的参数,多个参数以,分隔;
{……} 之间的代码是函数体,可以包含若干语句,甚至可以没有任何语句。
请注意,函数体内部的语句在执行时,一旦执行到 return 时,函数就执行完毕,并将结果返回。因此,函数内部通过条件判断和循环可以实现非常复杂的逻辑。
如果没有 return 语句,函数执行完毕后也会返回结果,只是结果为 undefined。
由于JavaScript的函数也是一个对象,上述定义的 abs()函数实际上是一个函数对象,而函数名 abs可以视为指向该函数的变量。
因此,第二种定义函数
的方式如下:
var abs = function (x) {if (x >= 0) {return x;} else {return -x;}
};
在这种方式下,function(x){……}
是一个匿名函数,它没有函数名。但是,这个匿名函数赋值给了变量 abs,所以,通过变量 abs 就可以调用该函数。
注意
:上述两种定义 完全等价 ,第二种方式按照完整语法需要在函数体末尾加一个 ;,表示赋值语句结束。( 加不加都一样,如果按照语法完整性要求,需要加上)
调用函数时,按顺序传入参数即可:
abs(10); // 返回10
abs(-9); // 返回9
由于JavaScript 允许传入任意个参数而不影响调用,因此传入的参数比定义的参数多也没有问题,虽然函数内部并不需要这些参数:
abs(10, 'blablabla'); // 返回10
abs(-9, 'haha', 'hehe', null); // 返回9
传入的参数比定义的少也没有问题:
此时 abs(s) 函数的参数 x 将收到 undefined,计算结果为NaN。要避免收到undefined,可以对参数进行检查:
function abs(x) {if (typeof x !== 'number') {throw 'Not a number'; // 停止并抛出错误信息}if (x >= 0) {return x;} else {return -x;}
}
1.2 作用域
Js中有一个被称为作用域的特性。作用域是在运行时代码中的某些特定部分中变量、函数和对象的可访问性。换句话说,作用域决定了代码区块中变量和其他资源的可见性。作用域最大的用处就是隔离变量,不同作用域下同名变量不会有冲突。
Js的作用域分为三种:全局作用域、函数作用域、块级作用域。全局作用域可以让用户在任何位置进行调用,需要注意的是最外层函数和在最外层函数外面定义的变量拥有全局作用域,所有未定义直接赋值的变量会自动声明为拥有全局作用域,所有window对象的属性也拥有全局作用域。函数作用域也就是说只有在函数内部可以被访问,当然函数内部是可以访问全局作用域的。块级作用域则是在if和switch的条件语句或for和while的循环语句中,块级作用域可通过新增命令let和const声明,所声明的变量在指定的作用域外无法被访问。
1.3 窗口对象属性
我们总结了浏览器window的常见属性和方法。因为很多环境监测都是基于这些属性和方法的,在补环境前,需要了解window对象的常用属性和方法。
Window:Window对象表示浏览器当前打开的窗口。
Document:载入浏览器的HTML文档。
Navigator:Navigator对象包含的属性描述了当前使用的浏览器,可以使用这些属性进行平台专用的配置。
Location:Location对象包含有关当前URL的信息
Screen:每个window对象的screen属性都引用一个Screen对象。Screen对象中存放着有关显示浏览器屏幕的信息。
History:History对象包含用户在浏览器窗口中访问过的URL。
window中还有很多属性和方法,这里就不再过多的描述,大家可以自行查看。
1.4 事件
HTML 事件是发生在 HTML 元素上的事情。
当在 HTML 页面中使用 JavaScript 时, JavaScript 可以触发这些事件。
HTML 事件可以是浏览器行为,也可以是用户行为。
以下是 HTML 事件的实例:
-
HTML 页面完成加载
-
HTML input 字段改变时
-
HTML 按钮被点击
通常,当事件发生时,你可以做些事情。
在事件触发时 JavaScript 可以执行一些代码。
HTML 元素中可以添加事件属性,使用 JavaScript 代码来添加 HTML 元素。
事件可以用于处理表单验证,用户输入,用户行为及浏览器动作:
-
页面加载时触发事件
-
页面关闭时触发事件
-
用户点击按钮执行动作
-
验证用户输入内容的合法性
-
等等 …
可以使用多种方法来执行 JavaScript 事件代码:
-
HTML 事件属性可以直接执行 JavaScript 代码
-
HTML 事件属性可以调用 JavaScript 函数
-
你可以为 HTML 元素指定自己的事件处理程序
-
你可以阻止事件的发生。
-
等等 ..
HTML事件
接下来说一下HTMl中绑定事件的几种方法,分别是行内绑定、动态绑定、事件监听、bind和on绑定。
行内绑定是指把触发事件直接写到元素的标签中
<li><div onclick="xxx()">点击</div>
</li>
动态绑定是指先获取到dom元素,然后在元素上绑定事件
<script>var xx = document.getElementById("lx");xx.onclick = function(){}
</script>
事件监听主要通过addEventListener()方法来实现
<script>var xx = document.getElementById("lx");xx.addEventListener("click", function(){})
</script>
bind()和on()
绑定都是属于JQuery的事件绑定方法,bind()的事件函数只能针对已经存在的元素进行事件的设置
$("button").bind("click",function(){$("p").slideToggle();
});
on()
支持对将要添加的新元素设置事件
$(document).ready(function(){$("p").on("click", function(){});
});
还有live()
和delegate()
等事件绑定方法,目前并不常用。
二、浏览器控制台
首先介绍一下浏览器控制台的使用,以开发者使用最多的chrome为例。Windows操作系统下的F12键可以打开控制台,mac操作系统下用Fn+F12键打开。我们选择平时使用较多的模块进行介绍
2.1 Network
Network是Js调试的重点,面板上由控制器、过滤器、数据流概览、请求列表、数据统计这五部分组成。
【图】浏览器控制台Network下各项属性的含义
控制器
:Presserve Log是保留请求日志的作用,在跳转页面的时候勾选上可以看到跳转前的请求。Disable cache是禁止缓存的作用,Offline是离线模拟。
过滤器
:根据规则过滤器请求列表的内容,可以选择XHR,JS,CSS,WS等。
数据流概览
:显示HTTP请求、响应的时间轴。
请求列表
:默认是按时间排序,可以看到浏览器所有的请求,主要用于网络请求的查看和分析,可以查看请求头、响应状态和内容、Form表单等。
数据统计
:请求总数、总数据量、总花费时间等。
作用:
-
可以查看调取接口是否正确,后台返回的数据;
-
查看请求状态、请求类型、请求地址
2.1.1 Network-Headers
首先打开控制台,找到Network. 刷新页面可以看到Name
Name对应的是资源的名称及路径, Status Code 是请求服务器返回的状态码,一般情况下当状态码为200时,则表示接口匹配成功。点击任一文件名,右侧会出现Header选项。
Request URL: 资源请求的urlRequest Method: 请求方法(HTTP方法)Status Code: 状态码200(状态码) OK301 - 资源(网页等)被永久转移到其它URL404 - 请求的资源(网页等)不存在500 - 内部服务器错误(后台问题)Remote Address: 远程地址;Referrer Policy: 控制请求头中 refrrer 的内容
包含值的情况:当一个用户点击页面中的一个链接,然后跳转到目标页面时,本变页面会收到一个信息,即用户是从哪个源链接跳转过来的。也就是说当你发起一个HTTP请求,请求头中的 referrer 字段就说明了你是从哪个页面发起该请求的;“”, 空串默认按照浏览器的机制设置referrer的内容,默认情况下是和no-referrer-when-downgrade设置得一样“no-referrer”, 不显示 referrer的任何信息在请求头中“no-referrer-when-downgrade”, 默认值。当从https网站跳转到http网站或者请求其资源时(安全降级HTTPS→HTTP),不显示 referrer的信息,其他情况(安全同级HTTPS→HTTPS,或者HTTP→HTTP)则在 referrer中显示完整的源网站的URL信息“same-origin”, 表示浏览器只会显示 referrer信息给同源网站,并且是完整的URL信息。所谓同源网站,是协议、域名、端口都相同的网站“origin”, 表示浏览器在 referrer字段中只显示源网站的源地址(即协议、域名、端口),而不包括完整的路径“strict-origin”, 该策略更为安全些,和 origin策略相似,只是不允许 referrer信息显示在从https网站到http网站的请求中(安全降级)“origin-when-cross-origin”, 当发请求给同源网站时,浏览器会在 referrer中显示完整的URL信息,发个非同源网站时,则只显示源地址(协议、域名、端口)“strict-origin-when-cross-origin”, 和 origin-when-cross-origin相似,只是不允许 referrer信息显示在从https网站到http网站的请求中(安全降级)“unsafe-url” 浏览器总是会将完整的URL信息显示在 referrer字段中,无论请求发给任何网站补充: 什么是referrer?Access-Control-Allow-Origin: 请求头中允许设置的请求方法Connection: 连接方式content-length: 响应数据的数据长度,单位是bytecontent-type: 客户端发送的类型及采用的编码方式Date: 客户端请求服务端的时间Vary: 用来指示缓存代理(例如squid)根据什么条件去缓存一个请求Last-Modified: 服务端对该资源最后修改的时间Server: 服务端的web服务端名Content-Encoding: gzip 压缩编码类型Transfer-Encoding:chunked: 分块传递数据到客户端Accept: 客户端能接收的资源类型Accept-Encoding: 客户端能接收的压缩数据的类型Accept-Language: 客户端接收的语言类型Cache-Control: no-cache 服务端禁止客户端缓存页面数据Connection: keep-alive 维护客户端和服务端的连接关系Cookie:客户端暂存服务端的信息Host: 连接的目标主机和端口号Pragma: no-cache 服务端禁止客户端缓存页面数据Referer: 来于哪里(即从哪个页面跳转过来的)User-Agent: 客户端版本号的名字
2.2 Sources
Sources按列分为三列,从左至右分别是文件列表区、当前文件区、断点调试区。
文件列表区
中有Page、Snippets、FileSytem等。Page可以看到当前所在的文件位置,在Snippets中单击New Snippets可以添加自定义的Js代码,FileSytem可以把本地的文件系统导入到chrome中。
当前文件区
是需要重点操作的区域,单击下方的{}来格式化代码,就能看到美观的Js代码,然后可以根据指定行数进行断点调试。
断点调试区
也非常重要,每个操作点都需要了解是什么作用。最上方的功能区分别是暂停、跳过、进入、跳出、步骤进入、禁用断点、异常断点。
Watch:变量监听,对加入监听列表的变量进行监听。
Call Stack:断点的调用堆栈列表,完整地显示了导致代码被暂停的执行路径。
Scope:当前断点所在函数执行的作用域内容。
Breakpoints:断点列表,将每个断点所在文件/行数/改成简略内容进行展示。
DOM Breakpoints:DOM断点列表。
XHR/fetch Breakpoints:对达到满足过滤条件的请求进行断点拦截。
Event Listener Breakpoints:打开可监听的事件监听列表,可以在监听事件并且触发该事件时进入断点,调试器会停留在触发事件代码行。
2.3 Application
Application是应用管理部分,主要记录网站加载的所有资源信息。包括存储数据(Local Storage、Session Storage、InDexedDB、Web SQL、Cookies)、缓存数据、字体、图片、脚本、样式表等。Local Storage(本地存储)和 Session Storage中可以查看和管理其存储的键值对。这里使用最多的是对Cookies的管理了,有时候调试需要清除Cookies,可以在Application的Cookies位置单击鼠标右键,选择Clear进行清除,或者根据Cookies中指定的Name和Value来进行清除,便于进一步调试。
注意:我们辨别Cookie来源时,可以看httpOnly这一栏,有√的是来自于服务端,没有√的则是本地生成的。
2.4 Console
谷歌控制台中的Console区域用于审查DOM元素、调试JavaScript代码、查看HTML解析,一般是通过Console.log()来输出调试信息。在Console中也可以输出window、document、location等关键字查看浏览器环境,如果对某函数使用了断点,也可以在Console中调用该函数。
如果你平时只是用console.log()来输出一些变量的值,那你肯定还没有用过console的强大的功能。下面带你用console玩玩花式调试。
来看下主要的调试函数及用法:
console.log(), console.error(), console.warn(), console.info()
最基本也是最常用的用法了,分别表示输出普通信息、错误信息、警示信息和提示性信息,且error和warn方法有特定的图标和颜色标识。
以下是优化和美化后的内容,提升了排版清晰度、语法规范性和易读性:
示例:console.assert(expression, message)
function greaterThan(a, b) {console.assert(a > b, { message: "a is not greater than b", a, b });
}greaterThan(5, 6);
console.count(label)
function login(name) {console.count(name + " logged in");
}login("User1");
login("User2");
login("User1");
console.dir(object)
console.dir(document.body);
console.dirxml(object)
console.dirxml(document.body);
console.group([label]) 和 console.groupEnd([label])
console.log("This is the outer level");console.group("Group 1");
console.log("Level 2");console.group("Group 2");
console.log("Level 3");
console.warn("More of level 3");
console.groupEnd();console.log("Back to level 2");
console.groupEnd();console.log("Back to the outer level");
console.groupCollapsed(label)
console.groupCollapsed("Collapsed Group");
console.log("This group is collapsed by default");
console.groupEnd();
console.time([label]) 和 console.timeEnd([label])
console.time("Process Timer");for (let i = 0; i < 1000000; i++) {// 模拟耗时操作
}console.timeEnd("Process Timer");console.assert(expression, message)
输出
Process Timer: 3.25ms
console.time()开始一个计时器。console.timeEnd()结束计时器并输出总耗时。两个函数的 label 参数需要一致。label:计时器的名称,默认为 "default"(可选)。参数:功能:示例:与 console.group() 相同,但默认分组是折叠状态。可以手动展开查看分组内容。功能:示例:输出This is the outer levelLevel 2Level 3More of level 3 (warning)Back to level 2Back to the outer level在控制台创建一个新的分组。所有输出的内容会添加一个缩进,表示属于当前分组。调用 console.groupEnd() 后,结束当前分组。label:分组的标识符(可选)。参数:功能:示例:打印输出 XML 元素及其子元素。对 HTML 和 XML 元素调用 console.dirxml() 和 console.log() 是等价的。功能:示例:控制台会显示 document.body 的 DOM 表达式及其所有属性和方法。输出:打印出对象的详细属性、函数和表达式信息。如果对象是 HTML 元素,则会打印其 DOM 属性。object: 要打印的对象。参数:功能:示例:输出User1 logged in: 1User2 logged in: 1User1 logged in: 2该函数用于计算并输出以特定标识符为参数的 console.count 函数被调用的次数。label: 计算数量的标识符。参数:功能:示例:如果表达式 a > b 为 false,控制台会输出错误信息。错误信息包含提供的自定义 message 和变量值。说明参数:expression: 条件语句,语句会被解析成 Boolean,且为 false 的时候会触发message语句输出message: 输出语句,可以是任意类型该函数会在 expression 为 false 的时候,在控制台输出一段语句,输出的内容就是传入的第二个参数 message 的内容。当我们在只需要在特定的情况下才输出语句的时候,可以使用 console.assert再举几个例子:
console.time();
var arr = new Array(10000);
for (var i = 0; i < arr.length; i++) {
arr[i] = new Object();
}
console.timeEnd();// default: 3.696044921875ms
对 console.time(label) 设置一个自定义的 label 字段,并使用console.timeEnd(label) 设置相同的 label 字段来结束计时器。
console.time('total');
var arr = new Array(10000);
for (var i = 0; i < arr.length; i++) {
arr[i] = new Object();
}
console.timeEnd('total');// total: 3.696044921875ms
设置多个 label 属性,开启多个计时器同步计时。
console.time('total');
console.time('init arr');
var arr = new Array(10000);
console.timeEnd('init arr');
for (var i = 0; i < arr.length; i++) {
arr[i] = new Object();
}
console.timeEnd('total');
// init arr: 0.0546875ms
// total: 2.5419921875ms
console.trace(object)
三、加密参数的定位方法
想要找到Js加密参数的生成过程,就必须要找到参数的位置,然后通过debug来进行观察调试。我们总结了目前通用的调试方式。每种方法都有其独特的运用之道,大家只有灵活运用这些参数定位方法,才能更好地提高逆向效率。
3.1 巧用搜索
搜索操作比较简单,打开控制台,通过快捷键Ctrl + F打开搜索框。在Network中的不同位置使用Ctrl + F会打开不同的搜索区域,有全局搜索、页面搜索。
另外关于搜索也有一定的技巧,如果加密参数的关键词是signature,可以直接全局搜索signature,搜索不到可以尝试搜索sign或者搜索接口名。如果还没有找到位置,则可以使用下面几种方法。
3.2 堆栈调试
控制台的 Initiator 堆栈调试是我们比较喜欢的调试方式之一,不过新版本的谷歌浏览器才有,如果没有 Initiator 需要更新Chrome版本。Initiator主要是为了监听请求是怎样发起的,通过它可以快速定位到调用栈中。
具体使用方法是先确定请求的接口,然后进入Initiator,单击第一个Request call stack参数,进入Js文件后,在跳转行上打上断点,然后刷新页面等待调试。
3.3 控制台调试
控制台的Console中可以由console.log()方法来执行某些函数,该方法对于开发调试很有帮助,有时通过输出会比找起来更便捷。在断点到某一处时,可以通过console.log()输出此时的参数来查看状态和属性,console.log()方法在后面的参数还原中也很重要。
3.4 监听XHR
XHR是XMLHttpRequest的简称,通过监听XHR的断点,可以匹配URl中params参数的触发点和调用堆栈,另外post请求中From Data的参数也可以用XHR来拦截。
使用方法:打开控制台,单击Sources,右侧有一个XHR/fetch Breakpoints,单击+号即可添加监听事件。像一些URL中的_signature参数就很适合使用XHR断点。
3.5 事件监听
这里其实和监听XHR有些相似,为了方便记忆,我们将其单独放在一个小节中。
有的时候找不到参数位置,但是知道它的触发条件,此时可以使用事件监听器进行断点,在Sources中有
DOM Breakpoints、Global Listeners、Event Listener Breakpoints都可以进行DOM事件监听。
比如需要对Canvas进行断点,就在Event Listener Breakpoints中选择Canvas,勾选Create canvas context时就是对创建canvas时的事件进行了断点。
3.6 添加代码片
在控制台中添加代码片来完成Js代码注入,也是一种不错的方式。
使用方法:打开控制台,单击Sources,然后单击左侧的snippets,新建一个Script Snippet,就可以在空白区域编辑Js代码了。
3.7 Hook
在Js中也需要用到Hook技术,例如当想分析某个cookie是如何生成时,如果想通过直接从代码里搜索该cookie的名称来找到生成逻辑,可能会需要审核非常多的代码。这个时候,如果能够用hook document.cookie的set方法,那么就可以通过打印当时的调用方法堆栈或者直接下断点来定位到该cookie的生成代码位置。
什么是hook?
在 JS 逆向中,我们通常把替换原函数的过程都称为 Hook。一般使用 Object.defineProperty() 来进行hook。
以下先用一段简单的代码理解Hook的过程:
function a() {console.log("I'm a.");
}
a = function b() {console.log("I'm b.");
};
a() // I'm b.
直接覆盖原函数是以最简单的做法,以上代码将a函数进行了重写,再次调用a函数将会输出I’m b.
如果还想执行原来a函数的内容,可以使用中间变量进行存储:
function a() {console.log("I'm a.");}var c = a;a = function b() {console.log("I'm b.");};a() // I'm b.c() // I'm a.
此时,调用 a 函数会输出 I’m b.,调用 c 函数会输出 I’m a.
这种原函数直接覆盖的方法通常只用来进行临时调试,实用性不大,但是它能够帮助我们理解 Hook 的过程,在实际 JS 逆向过程中,我们会用到更加高级一点的方法,比如 Object.defineProperty()。
Object.defineProperty()
Object.defineProperty(obj, prop, descriptor)
obj:需要定义属性的当前对象
prop:当前需要定义的属性名
descriptor:属性描述符,可以取以下值;
属性描述符的取值通常为以下:
通常情况下,对象的定义与赋值是这样的:
var people = {}
people.name = "Bob"
people["age"] = "18"
console.log(people)// { name: 'Bob', age: '18' }
使用 defineProperty() 方法:
var people = {}
Object.defineProperty(people, 'name', { value: 'Bob', writable: true // 是否可以被重写})
console.log(people.name) // 'Bob'people.name = "Tom"console.log(people.name) // 'Tom'
我们一般hook使用的是get和set方法:
var people = { name: 'Bob',};
var count = 18;// 定义一个 age 获取值时返回定义好的变量 count
Object.defineProperty(people, 'age', { get: function () { console.log('获取值!'); return count; },
set: function (val) { console.log('设置值!'); count = val + 1; },});console.log(people.age);
people.age = 20;
console.log(people.age);
输出:
通过这样的方法,我们就可以在设置某个值的时候,添加一些代码,比如 debugger;
让其断下,然后利用调用栈进行调试,找到参数加密、或者参数生成的地方,需要注意的是,网站加载时首先要运行我们的Hook代码,再运行网站自己的代码,才能够成功断下,这个过程我们可以称之为Hook代码的注入。
TamperMonkey 注入
TamperMonkey 俗称油猴插件
,是一款免费的浏览器扩展和最为流行的用户脚本管理器,支持很多主流的浏览器, 包括 Chrome、Microsoft Edge、Safari、Opera、Firefox、UC 浏览器、360 浏览器、QQ 浏览器等等,基本上实现了脚本的一次编写,所有平台都能运行,可以说是基于浏览器的应用算是真正的跨平台了。用户可以在 GreasyFork、OpenUserJS 等平台直接获取别人发布的脚本,功能众多且强大,比如视频解析、去广告等。
来到谋奇异首页,可以看到cookie里面有个__dfp
值:
我们想通过Hook的方式,让在生成__dfp
的地方断下,就可以编写如下函数:
我们以某奇艺的 cookie 为例来演示如何编写 TamperMonkey 脚本,首先去应用商店安装 TamperMonkey,安装过程不再赘述,然后点击图标,添加新脚本,或者点击管理面板,再点击加号新建脚本,写入以下代码:
(function () {'use strict';var cookieTemp = '';Object.defineProperty(document, 'cookie', {set: function (val) {if (val.indexOf('__dfp') !== -1) {debugger; // 调试点}console.log('Hook 捕获到 cookie 设置 ->', val);cookieTemp = val;return val;},get: function () {return cookieTemp;},});
})();
if (val.indexOf('__dfp') != -1) {debugger;}
的意思是检索__dfp
在字符串中首次出现的位置,等于 -1 表示这个字符串值没有出现,反之则出现。如果出现了,那么就 debugger 断下,这里要注意的是不能写成 if (val == '__dfp') {debugger}
,因为 val 传过来的值类似于 __dfp=xxxxxxxxxx
,这样写是无法断下的。
写入后进行保存
主体的JavaScript自执行函数和前面的都是一样的,这里需要注意的是最前面的注释,每个选项都是有意义的,所有的选项参考 TamperMonkey 官方文档,以下列出了比较常用、比较重要的部分选项(其中需要特别注意 @match、@include、@run-at)
清除 cookie,开启 TamperMonkey 插件,再次来到某奇艺首页,可以成功被断下,也可以跟进调用栈来进一步分析 __dfp 值的来源。
四、常见的压缩和混淆
在Web系统发展早期,Js在Web系统中承担的职责并不多,Js文件比较简单,也不需要任何的保护。随着Js文件体积的增大和前后端交互增多,为了加快http传输速度并提高接口的安全性,出现了很多的压缩工具和混淆加密工具。
代码混淆的本质是对于代码标识和结构的调整,从而达到不可读不可调用的目的,常用的混淆有字符串、变量名混淆,比如把字符串转换成_0x
,把变量重命名等,从结构的混淆包括控制流平坦化,虚假控制流和指令替换,代码加密主要有通过veal方法去执行字符串函数,通过escape()等方法编码字符串、通过转义字符加密代码、自定义加解密方法(RSA、Base64、AES、MD5等),或者通过一些开源的工具进行加密。
另外目前市面上比较常见的混淆还有ob混淆(obfuscator),特征是定义数组,数组位移。不仅Js中的变量名混淆,运行逻辑等也高度混淆,应对这种混淆可以使用已有的工具ob-decrypt或者AST解混淆或者使用第三方提供的反混淆接口。大家平时可以多准备一些工具,在遇到无法识别的Js时,可以直接使用工具来反混淆和解密,当然逆向工作本身就很看运气。
【eg】翻看网站的Javascript源代码,可以发现很多压缩或者看不太懂的字符,如javascript文件名被编码,文件的内容被压缩成几行,变量被修改成单个字符或者一些十六进制的字符——这些导致我们无法轻易根据Javascript源代码找出某些接口的加密逻辑。
4.1 JavaScript压缩
这个非常简单,Javascript压缩即去除JavaScript代码中不必要的空格、换行等内容或者把一些可能公用的代码进行处理实现共享,最后输出的结果都压缩为几行内容,代码的可读性变得很差,同时也能提高网站的加载速度。
如果仅仅是去除空格、换行这样的压缩方式,其实几乎是没有任何防护作用的,因为这种压缩方式仅仅是降低了代码的直接可读性。因为我们有一些格式化工具可以轻松将JavaScirpt代码变得易读,比如利用IDE、在线工具或Chrome浏览器都能还原格式化的代码。
这里举一个最简单的JavaScript压缩示例。原来的JavaScript代码是这样的:
function echo(stringA, stringB) {const name = "Germey";alert("hello " + name);
}
压缩之后就变成这样子:
function echo(d, c) {const e = "Germey";alert("hello " + e);
}
可以看到,这里参数的名称都被简化了,代码中的空格也被去掉了,整个代码也被压缩成了一行,代码的整体可读性降低了。
目前主流的前端开发技术大多都会利用webpack、Rollup等工具进行打包。webpack、Rollup会对源代码进行编译和压缩,输出几个打包好的JavaScript文件,其中我们可以看到输出的JavaScript文件名带有一些不规则的字符串,同时文件内容可能只有几行,变量名都用一些简单字母表示。这其中就包含JavaScript压缩技术,比如一些公共的库输出成bundle文件,一些调用逻辑压缩和转义成冗长的几行代码,这些都属于JavaScript压缩,另外,其中也包含了一些很基础的JavaScript混淆技术,比如把变量名、方法名替换成一些简单的字符,降低代码的可读性。
但整体来说,JavaScript压缩技术只能在很小的程度上起到防护作用,想要真正的提高防护效果,还得依靠JavaScript混淆和加密技术。
4.2 JavaScript混淆
JavaScript混淆完全是在JavaScript上面进行的处理,它的目的就是使得JavaScript变得难以阅读和分析,大大降低代码的可读性,是一种很实用的JavaScript保护方案。
JavaScript混淆技术主要有以下几种。
变量名混淆
:将带有含义的变量名、方法名、常量名随机变为无意义的类乱码字符串,降低代码的可读性,如转换成单个字符或十六进制字符串。
字符串混淆
:将字符串阵列化集中并可进行MD5或Base64加密存储,使代码中不出现明文字符串,这样可以避免使用全局搜索字符串的方式定位到入口。
对象键名替换
:针对JavaScript对象的属性进行加密转化,隐藏代码之间的调用关系。
控制流平坦化
:打乱函数原有代码的执行流程及函数调用关系,使代码逻辑变得混乱无序。
无用代码注入
:随机在代码中插入不会被执行到的无用代码,进一步使代码看起来更加混乱。
调试保护
:基于调试器特征,对当前运行环境进行检查,加入一些debugger语句,使其在调试模式下难以顺利执行JavaScript代码。
多态变异
:使JavaScript代码每次被调用时,将代码自身立刻自动发生变异,变为与之前完全不同的代码,即功能完全不变,只是代码形式变异,以此杜绝代码被动态分析和调试。
域名锁定
:使JavaScript代码只能在指定域名下执行。
代码自我保护
:如果对JavaScript代码进行格式化,则无法执行,导致浏览器假死。
特殊编码
:将JavaScript完全编码为人不可读的代码,如表情符号、特殊表示内容、等等。
总之,以上方案都是JavaScript混淆的实现方式,可以在不同程度上保护JavaScript代码。
在前端开发中,现在JavaScript混淆的主流实现使javascript-obfuscator和terser这两个库。它们都能提供一些代码混淆功能,也都有对应的webpack和Rollup打包工具的插件。利用它们,我们可以非常方便地实现页面的混淆,最终输出压缩和混淆后的JavaScript代码,使得JavaScript代码的可读性大大降低。
下面我们以javascript-obfuscator为例来介绍一些代码混淆的实现,了解了实现,那么我们自然就对混淆的机制有了更加深刻的认识。
javascript-obfuscator的官方介绍内容如下:
链接:https://www.javascriptobfuscator.com/
它是支持ES8的免费、高效的JavaScript混淆库,可以使得JavaScript代码经过混淆后难以被复制、盗用、混淆后的代码具有和原来的代码一模一样的功能。
首先我们需要安装好Node.js 12.x及以上版本,确保可以正常使用npm命令。
具体的安装方式如下:
简单的说 Node.js 就是运行在服务端的 JavaScript。
Node.js 是一个基于 Chrome JavaScript 运行时建立的一个平台。
Node.js 是一个事件驱动 I/O 服务端 JavaScript 环境,基于 Google 的 V8 引擎,V8 引擎执行 Javascript 的速度非常快,性能非常好。
如果你是一个前端程序员,你不懂得像 PHP、Python 或 Ruby 等动态编程语言,然后你想创建自己的服务,那么 Node.js 是一个非常好的选择。
Node.js 是运行在服务端的 JavaScript,如果你熟悉 Javascript,那么你将会很容易的学会 Node.js
官网:https://nodejs.org/
文档:https://nodejs.org/en/docs/
中文网:http://nodejs.cn/
基础教程:https://www.runoob.com/nodejs/nodejs-tutorial.html
接着新建一个文件夹,比如js-ob然后进入该文件夹,初始化工作空间:
这里会提示我们输入一些信息,然后创建package.json文件,这就完成了项目的初始化了。
接下来,我们来安装javascript-obfuscator这个库:
npm i -D javascript-obfuscator
稍等片刻,即可看到本地js-ob文件下生成了一个node_modules文件夹,里面就包含了javascript-obfuscator这个库,这就说明安装成功了。
接下来,我们就可以编写代码来实现一个混淆样例了。比如,新建main.js
文件,其内容如下:
const code = `let x = '1' + 1console.log('x', x)`
const options = { compact: false,}
const obfuscator = require('javascript-obfuscator')
function obfuscate(code, options) { return obfuscator.obfuscate(code, options).getObfuscatedCode()}
console.log(obfuscate(code, options))
这里我们定义了两个变量:一个是code,即需要被混淆的代码;另一个是混淆选项options,是一个Object。接下来,我们引入了javascript-obfuscator这个库,然后定义了一个方法,给其传入code和options来获取混淆之后的代码,最后控制台输出混淆后的代码。
代码逻辑比较简单,我们来执行一下代码:
node main.js
输出结果如下:
看到了吧,那么简单的代码,被我们混淆成了这个样子,其实这里我们就是设定了“控制流平坦化”选项。整体看来,代码的可读性大大降低了,JavaScript调试的难度也大大加强了。
4.3 javascript-obfuscator示例
下面的例子代码同上
4.3.1 代码压缩
这里javascript-obfuscator也提供了代码压缩的功能,使用其参数compact即可完成JavaScript代码的压缩,输出为一行内容。参数compact的默认值是true,如果定义为false,则混淆后的代码会分行显示。
如果不设置或者把compact设置为true,结果如下:
可以看到,单行显示的时候,对变量名进行了进一步的混淆,这里变量的命名都变成了十六进制形式的字符串,这是因为启用了一些默认压缩和混淆的方式。总之我们可以看到代码的可读性相比之前大大降低。
4.3.2 变量名混淆
变量名混淆可以通过在javascript-obfuscator中配置identifierNamesGenerator参数来实现。我们通过这个参数可以控制变量名混淆的方式,如将其设置为hexadecimal,则会将变量名替换为十六进制形式的字符串。该参数的取值如下。
hexadecimal
:将变量名替换为十六进制形式的字符串,如0xabc123。
mangled
:将变量名替换为普通的简写字符,如a,b,c等。
该参数的默认值为:hexadecimal
我们将该参数改为:mangled 来试一下:
const code = `let x = '1' + 1;console.log('x', x);
`;const options = {compact: true,identifierNamesGenerator: 'mangled'
};const obfuscator = require('javascript-obfuscator');function obfuscate(code, options) {return obfuscator.obfuscate(code, options).getObfuscatedCode();
}console.log(obfuscate(code, options));
运行结果如下:
另外,我们还可以通过设置identifiersPrefix参数来控制混淆后的变量前缀,示例如下:
const code = `let x = '1' + 1;console.log('x', x);
`;const options = {identifiersPrefix: 'kk'
};const obfuscator = require('javascript-obfuscator');function obfuscate(code, options) {return obfuscator.obfuscate(code, options).getObfuscatedCode();
}console.log(obfuscate(code, options));
运行结果如下:
可以看到,混淆后的变量前缀加上了我们自定义的字符串kk。
另外,renameGlobals这个参数还可以指定是否混淆全局变量和函数名称,默认值为false。示例如下:
const code = `var $ = function(id) {return document.getElementById(id);};
`;const options = {renameGlobals: true
};const obfuscator = require('javascript-obfuscator');function obfuscate(code, options) {return obfuscator.obfuscate(code, options).getObfuscatedCode();
}console.log(obfuscate(code, options));
运行结果如下:
可以看到,这里我们声明了一个全局变量$,在renameGlobals设置为true之后,这个变量也被替换了。如果后文用到了这个变量,可能就会有找不到定义的错误,因此这个参数可能导致代码执行不通。
如果我们不设置 renameGlobals 或者将其设置为false,结果如下:
可以看到,最后还是有$的声明,其全局名称没有被改变。
4.3.3 字符串混淆
字符串混淆,就是将一个字符串声明放到一个数组里面,使之无法被直接搜索到。这可以通过stringArray参数来控制,默认为true。
此外,我们还可以通过rotateStringArray参数来控制数组化后结果的元素顺序,默认为true。
示例如下:
const code = `var a = 'helloworld';
`;const options = {stringArray: true,rotateStringArray: true
};const obfuscator = require('javascript-obfuscator');function obfuscate(code, options) {return obfuscator.obfuscate(code, options).getObfuscatedCode();
}console.log(obfuscate(code, options));
运行结果如下:
另外,我们还可以使用unicodeEscapeSequence这个参数对字符串进行Unicode转码,使之更加难以辨认,示例如下:
const code = `var a = 'hello world';
`;const options = {compact: false,unicodeEscapeSequence: true
};const obfuscator = require('javascript-obfuscator');function obfuscate(code, options) {return obfuscator.obfuscate(code, options).getObfuscatedCode();
}console.log(obfuscate(code, options));
可以看到,这里字符串被数字化和Unicode化,非常难以辨认。
在很多JavaScript逆向过程中,一些关键的字符串可能会作为切入点来查找加密入口,用了这种混淆之后,如果有人想通过全局搜索的方式搜索hello这样的字符串找加密入口,也就没法搜到了。
4.3.4 代码自我保护
我们可以通过设置selfDefending参数来开启代码自我保护功能。开启之后混淆后的JavaScript会强制以一行显示。如果我们将混淆后的代码进行格式化或者重命名,该段代码将无法执行。
【eg】如下:
const code = `console.log('hello world');
`;const options = {selfDefending: true
};const obfuscator = require('javascript-obfuscator');function obfuscate(code, options) {return obfuscator.obfuscate(code, options).getObfuscatedCode();
}console.log(obfuscate(code, options));
如果我们将上述代码放到控制台,它的执行结果和之前是一模一样的,没有任何问题。
如果我们将其进行格式化,然后粘贴到浏览器控制台里面,浏览器会直接卡死无法运行。这样如果有人对代码进行了格式化,就无法正常对代码进行运行和调试,从而起到了保护作用。
4.3.5 控制流平坦化
控制流平坦化其实就是将代码的执行逻辑混淆,使其变得复杂、难度。其基本的思想是将一些逻辑处理块都统一加上一个前驱逻辑块,每个逻辑块都由前驱逻辑块进行条件判断个分发,构成一个个闭环逻辑,这导致整个执行逻辑十分复杂、难度。
比如说这里有一段示例代码:
console.log(c);
console.log(a);
console.log(b);
代码逻辑一目了然,一次在控制台输出了c, a, b三个变量的值。但是如果把这段代码进行控制流平坦化处理,代码就会变成这样:
const code = `console.log(c);console.log(a);console.log(b);
`;const options = {compact: false,controlFlowFlattening: true
};const obfuscator = require('javascript-obfuscator');function obfuscate(code, options) {return obfuscator.obfuscate(code, options).getObfuscatedCode();
}console.log(obfuscate(code, options));
使用控制流平坦化可以使得执行逻辑更加复杂、难读,目前非常多的前端混淆都会加上这个选项。但启用控制流平坦化之后,代码的执行时间会变长。
4.3.6 无用代码注入
无用代码即不会被执行的代码或对上下文没有任何影响的代码,注入之后可以对现有的JavaScript代码的阅读形成干扰。我们可以使用deadCodeInjection参数开启这个选项,其默认值为false。
示例:
const code = `console.log(c);console.log(a);console.log(b);
`;const options = {compact: false,deadCodeInjection: true
};const obfuscator = require('javascript-obfuscator');function obfuscate(code, options) {return obfuscator.obfuscate(code, options).getObfuscatedCode();
}console.log(obfuscate(code, options));
这种混淆方式通过混入一些特殊的判断条件并加入一些不会被执行的代码,可以对代码起到一定的干扰作用。
4.3.7 对象键名替换
如果是一个对象,可以使用transformObjectKeys来对对象的键值进行替换,示例如下:
const code = `(function() {var object = {foo: 'test1',bar: {baz: 'test2'}};})();
`;const options = {compact: false,transformObjectKeys: true
};const obfuscator = require('javascript-obfuscator');function obfuscate(code, options) {return obfuscator.obfuscate(code, options).getObfuscatedCode();
}
可以看到,Object的变量名被替换为了特殊的变量,代码的可读性变差,这样我们就不好直接通过变量名进行搜寻了,这也可以起到一定的防护作用。
4.3.8 禁用控制台输出
我们可以使用disableConsoleOutput来禁用掉console.log输出功能,加大调试难度,示例如下:
const code = `console.log('hello world');
`;const options = {disableConsoleOutput: true
};const obfuscator = require('javascript-obfuscator');function obfuscate(code, options) {return obfuscator.obfuscate(code, options).getObfuscatedCode();
}console.log(obfuscate(code, options));
此时,我们如果执行这段代码,发现是没有任何输出的,这里实际上就是将console的一些功能禁用了。
4.3.9 调试保护
我们知道,如果Javascript代码中加入关键字debugger关键字,那么执行到该位置的时候,就会进入断点调试模式。如果在代码多个位置都加入debugger关键字,或者定义某个逻辑来反复执行debugger,就会不断进入断点调试模式,原本的代码就无法顺畅执行了。这个过程可以称为调试保护,即通过反复执行debugger来使得原来的代码无法顺畅执行。
其效果类似于执行了如下代码:
setInterval(() => {debugger;
}, 3000);
如果我们把这段代码粘贴到控制台,它就会反复执行debugger语句,进入断点调试模式,从而干扰正常的调试流程。
在javascript-obfuscator中,我们可以使用debugProtection来启用调试保护机制,还可以使用debugProtectionInterval来启用无限调试(debug),使得代码在调试过程中不断进入断点模式,无法顺畅执行。配置如下:
const options = {debugProtection: true,
};
混淆后的代码会跳到debugger代码的位置,使得整个代码无法顺畅执行,对JavaScript代码的调试形成干扰。
4.3.10 域名锁定
我们还可以通过控制domainLock来控制JavaScript代码只能在特定域名下运行,这样就可以降低代码被模拟或者盗用的风险。
示例如下:
const code = `console.log('hello world');
`;const options = {domainLock: ['kk.com']
};const obfuscator = require('javascript-obfuscator');function obfuscate(code, options) {return obfuscator.obfuscate(code, options).getObfuscatedCode();
}console.log(obfuscate(code, options));
这里我们使用domainLock指定了一个域名kk.com,也就是设置了一个域名白名单,混淆后的代码结果如下:
这段代码就只能在指定的域名kk.com下运行,不能在其他网站运行。这样的话,如果一些相关JavaScript代码被单独剥离出来,想在其他网站运行或者使用程序模拟运行的话,运行结果只有失败,这样就可以有效降低代码被模拟或盗用的风险。
4.3.11特殊编码
另外,还有一些特殊的工具包(jjencode,aaencode等)他们可以对代码进行混淆和编码。
链接:https://www.sojson.com
示例如下:
使用jjencode工具的结果:
使用aaencode工具的结果:
可以看到,通过这些工具,原本非常简单的代码被转化为一些几乎完全不可读的代码,但实际上运行效果还是相同的。这些混淆方式比较另类,看起来虽然没有什么头绪,但实际上找到规律是非常好还原的,并没有真正达到强力混淆的效果。
关于这种混淆代码的解码方法,一般直接复制到控制台运行或者用解码工具进行转换,如果运行失败,就需要按分号分割语句,逐行调试分析源码。
以上便是对JavaScript混淆方式的介绍和总结。总的来说经过混淆的JavaScript代码的可读性大大降低,同时其防护效果也大大增强。
五、常见的编码和加密
我们在爬取网站的时候,会遇到一些需要分析接口或URL信息的情况,这时会有各种各样的类似加密的情形。
-
某个网站的URL带有一些看不太懂的长串加密参数,要抓取就得必须懂得这些参数是怎么构造的,否则我们连完整的URL都构造不出来,更不用说爬取了。
-
在分析某个网站的Ajax接口时,可以看到接口的一些参数也是加密的,Request Headers 里面也可能带有一些加密参数,如果不知道这些参数的具体构造逻辑,就没法直接用程序来模拟这些Ajax请求。
常见的编码有base64、unicode、urlencode编码,加密有MD5、SHA1、HMAC、DES、RSA等。
本节简单介绍一下常见的编码加密,同时附上Python实现加密的方法。
5.1 base64
base64是一种基于64个可打印ASCLL字符对任意字节数据进行编码的算法,其在编码后具有一定意义的加密作用。在逆向过程中经常会碰到base64编码(不论是Js逆向还是安卓逆向)。
浏览器提供了原生的base64编码、解码方法,方法名就是btoa和atob如下图所示:
在python中使用base64:
import base64
print(base64.b64encode('msb'.encode()))
print(base64.b64decode('bXNi'.encode()))
var str1 = "msb";
unicode和urlencode比较简单,unicode是计算机中字符集、编码的一项业界标准,被称为统一码、万国码,表现形式一般以“\u
” 或者 “&#
”开头。urlencode是URL编码,也称作百分号编码用于把URL中的符号进行转换。
5.2 MD5
MD5消息摘要算法(英文:MD5 Message-Digest Algorithm),一种被广泛使用的密码散列函数,可以产出一个128位(16字节)的散列值(hash value),用于确保信息传输完整一致。MD5加密算法是不可逆的,所以解密一般都是通过暴力穷举方法,以及网站的接口实现解密。
python代码实现加密:
import hashlib
pwd = "123"
# 生成MD5对象
m = hashlib.md5()# 对数据进行加密
m.update(pwd.encode('utf-8'))# 获取密文
pwd = m.hexdigest()
print(pwd)
解密工具:https://www.cmd5.com/
5.3 SHA1
SHA1(Secure Hash Algorithm)安全哈希算法主要适用于数字签名标准里面定义的数字签名算法,SHA1比MD5的安全性更强。对于长度小于2^64位的消息,SHA1会产生一个160位的消息摘要。
一般在没有高度混淆的Js代码中,SHA1加密的关键词就是sha1。
Python实现代码:
import hashlib
sha1 = hashlib.sha1()
data1 = "msb"
data2 = "kkk"
sha1.update(data1.encode("utf-8"))
sha1_data1 = sha1.hexdigest()
print(sha1_data1)
sha1.update(data2.encode("utf-8"))
sha1_data2 = sha1.hexdigest()
print(sha1_data2)
运行结果:
解密工具:http://tool.geekapp.cn/index.php
5.4 HMAC
HMAC
全称:散列消息鉴别码。HMAC加密算法是一种安全的基于加密hash函数和共享密钥的消息认证协议。实现原理是用公开函数和密钥产生一个固定长度的值作为认证标识,用这个标识鉴别消息的完整性。
python实现代码:
new(key,msg=None,digestmod)方法
-
创建哈希对象
-
key和digestmod参数必须指定,key和msg(需要加密的内容)均为bytes类型,digestmod指定加密算法,比如‘md5’,’sha1’等
对象digest()方法:返回bytes类型哈希值
对象hexdigest()方法:返回十六进制哈希值
import hmac
import hashlib
key = "key".encode()
text = "msb".encode()
m = hmac.new(key, text, hashlib.sha256)
print(m.digest())
print(m.hexdigest())
5.5 DES
DES
全称:数据加密标准(Data Encryption Standard),属于对称加密算法。DES是一个分组加密算法,典型的DES以64位为分组对数据加密,加密和解密用的是同一个算法。它的密钥长度是56位(因为每个第8位都用作奇偶校验),密钥可以是任意的56位数,而且可以任意时候改变。
Js逆向时,DES加密的搜索关键词有DES、mode、padding等。
python实现代码:
# pyDes需要安装
from pyDes import des, CBC, PAD_PKCS5
import binascii
# 秘钥
KEY = 'dsj2020q'
def des_encrypt(s):""" DES 加密 :param s: 原始字符串 :return: 加密后字符串,16进制 """secret_key = KEY iv = secret_key k = des(secret_key, CBC, iv, pad=None, padmode=PAD_PKCS5) en = k.encrypt(s, padmode=PAD_PKCS5) return binascii.b2a_hex(en).decode()def des_decrypt(s): """ DES 解密 :param s: 加密后的字符串,16进制 :return: 解密后的字符串 """ secret_key = KEY iv = secret_key k = des(secret_key, CBC, iv, pad=None, padmode=PAD_PKCS5) de = k.decrypt(binascii.a2b_hex(s), padmode=PAD_PKCS5) return de.decode()text = 'msb'secret_str = des_encrypt(text)print(secret_str)clear_str = des_decrypt(secret_str)print(clear_str)
5.6 AES
AES
全程:高级加密标准,在密码学中又称Rijndael加密法,是美国联邦政府采用的一种区块加密标准。
AES也是对称加密算法,如果能够获取到密钥,那么就能对密文解密。
Js逆向时,AES加密的搜索关键词有AES、mode、padding等。
python代码实现之前
pip install pycryptodome
python实现代码:
import base64
from Crypto.Cipher import AES# AES 加密需要补位,字符串长度需要是 16 的倍数,不足时补足为 16 的倍数
def add_to_16(value):while len(value) % 16 != 0:value += '\0'return str.encode(value) # 返回 bytes 类型# 加密方法
def encrypt(key, text):aes = AES.new(add_to_16(key), AES.MODE_ECB) # 初始化加密器encrypt_aes = aes.encrypt(add_to_16(text)) # 进行 AES 加密encrypted_text = str(base64.encodebytes(encrypt_aes), encoding='utf-8') # Base64 编码return encrypted_text# 解密方法
def decrypt(key, text):aes = AES.new(add_to_16(key), AES.MODE_ECB) # 初始化加密器base64_decrypted = base64.decodebytes(text.encode(encoding='utf-8')) # Base64 解码decrypted_text = str(aes.decrypt(base64_decrypted), encoding='utf-8').replace('\0', '') # AES 解密并去除补位return decrypted_text
5.6 RSA
RSA
全称:Rivest-Shamir-Adleman, RSA加密算法是一种非对称加密算法,在公开密钥加密和电子商业中RSA被广泛使用,它被普遍认为是目前最优秀的公钥方案之一。RSA是第一个能同时用于加密和数字签名的算法,它能够抵抗目前为止已知的所有密码攻击。
注意Js代码中的RSA常见标志setPublickey。
算法原理参考:https://www.yht7.com/news/184380
实现代码之前先安装 :
pip install pycryptodome
python代码实现:
import base64
from Crypto.Cipher import PKCS1_v1_5
from Crypto import Random
from Crypto.PublicKey import RSA# ------------------------生成密钥对------------------------
def create_rsa_pair(is_save=False):"""创建RSA公钥私钥对:param is_save: 是否保存到文件,默认False:return: public_key, private_key"""f = RSA.generate(2048)private_key = f.exportKey("PEM") # 生成私钥public_key = f.publickey().exportKey() # 生成公钥if is_save:with open("crypto_private_key.pem", "wb") as f:f.write(private_key)with open("crypto_public_key.pem", "wb") as f:f.write(public_key)return public_key, private_keydef read_public_key(file_path="crypto_public_key.pem") -> bytes:"""读取公钥文件:param file_path: 公钥文件路径:return: 公钥内容(bytes)"""with open(file_path, "rb") as x:return x.read()def read_private_key(file_path="crypto_private_key.pem") -> bytes:"""读取私钥文件:param file_path: 私钥文件路径:return: 私钥内容(bytes)"""with open(file_path, "rb") as x:return x.read()# ------------------------加密------------------------
def encryption(text: str, public_key: bytes) -> str:"""使用公钥加密文本:param text: 明文字符串:param public_key: 公钥:return: 加密后的Base64字符串"""text = text.encode("utf-8") # 转为bytescipher_public = PKCS1_v1_5.new(RSA.importKey(public_key)) # 构建公钥对象text_encrypted = cipher_public.encrypt(text) # 加密return base64.b64encode(text_encrypted).decode() # Base64编码并返回字符串# ------------------------解密------------------------
def decryption(text_encrypted_base64: str, private_key: bytes) -> str:"""使用私钥解密密文:param text_encrypted_base64: Base64加密密文:param private_key: 私钥:return: 解密后的明文字符串"""text_encrypted = base64.b64decode(text_encrypted_base64.encode("utf-8")) # Base64解码cipher_private = PKCS1_v1_5.new(RSA.importKey(private_key)) # 构建私钥对象text_decrypted = cipher_private.decrypt(text_encrypted, Random.new().read) # 解密return text_decrypted.decode() # 转为字符串if __name__ == "__main__":# 生成密钥对public_key, private_key = create_rsa_pair(is_save=False)# 加密text = "msb"text_encrypted_base64 = encryption(text, public_key)print("密文:", text_encrypted_base64)# 解密text_decrypted = decryption(text_encrypted_base64, private_key)print("明文:", text_decrypted)
六、加密参数还原与模拟
加密参数还原的逻辑很简单,找到代码中加密参数的生成过程,然后模拟出相同的方法。很多时候会卡到加密参数定位上,然后遇到混淆加密过的复杂Js,导致还原的过程无比艰辛。本章我们准备了由易到难的逆向案例,带大家体验精彩的逆向过程。
6.1 Newrank榜单逆向案例
本节内容是分析新榜榜单接口的加密参数,网页上的微信榜、微博榜、抖音榜、快手榜、bilibili榜、资讯等都使用了相同的参数。
网站链接:https://www.newrank.cn/public/info/list.html
首先通过控制台抓包,以微信榜单的文化日榜为例查看接口,如图所示:
已知post请求,再看From Data,发现两个加密参数nonce和xyz,如图所示:
通过Ctrl+F快捷键全局搜索nonce和xyz关键字,如图所示:
可以在控制台输入j
查看该方法的内容:
可用鼠标双击函数跳转查看:
回到刚才的位置打上断点,刷新一下往下走一步:
可以发现h是当前的Api
加一些params
。
接着通过d(h)
点进去查看,如图所示:
到这里就结束了,b中的具体运算这里不做深究,整体流程分析完毕,此时将b(a)复制出来即可。
Js模拟示例:
function generateNonce() {var chars = ["0", "1", "2", "3", "4", "5", "6", "7", "8", "9", "a", "b", "c", "d", "e", "f"];var nonce = "";for (var i = 0; i < 500; i++) {var randomString = "";for (var j = 0; j < 9; j++) {var randomIndex = Math.floor(16 * Math.random());randomString += chars[randomIndex];}nonce += randomString;}return nonce;
}var nonce = generateNonce();
var url = "/xdnphb/common/account/getFull?AppKey=joker&nonce=" + nonce;function hashFunction(input) {function encode(input) {return convertToHexString(utf8Encode(input));}function utf8Encode(input) {var encoded = "";for (var i = 0; i < input.length; i++) {var charCode = input.charCodeAt(i);if (charCode <= 0x7F) {encoded += String.fromCharCode(charCode);} else if (charCode <= 0x7FF) {encoded += String.fromCharCode(0xC0 | (charCode >> 6), 0x80 | (charCode & 0x3F));} else if (charCode <= 0xFFFF) {encoded += String.fromCharCode(0xE0 | (charCode >> 12), 0x80 | ((charCode >> 6) & 0x3F), 0x80 | (charCode & 0x3F));} else {charCode -= 0x10000;encoded += String.fromCharCode(0xF0 | (charCode >> 18), 0x80 | ((charCode >> 12) & 0x3F), 0x80 | ((charCode >> 6) & 0x3F), 0x80 | (charCode & 0x3F));}}return encoded;}function convertToHexString(input) {var hexChars = "0123456789abcdef";var hexString = "";for (var i = 0; i < input.length; i++) {var byte = input.charCodeAt(i);hexString += hexChars.charAt((byte >>> 4) & 0x0F) + hexChars.charAt(byte & 0x0F);}return hexString;}function md5(input) {function md5Cycle(x, k) {var a = 1732584193;var b = -271733879;var c = -1732584194;var d = 271733878;for (var i = 0; i < x.length; i += 16) {var oldA = a;var oldB = b;var oldC = c;var oldD = d;a = ff(a, b, c, d, x[i + 0], 7, -680876936);d = ff(d, a, b, c, x[i + 1], 12, -389564586);c = ff(c, d, a, b, x[i + 2], 17, 606105819);b = ff(b, c, d, a, x[i + 3], 22, -1044525330);a = gg(a, b, c, d, x[i + 1], 5, -165796510);d = gg(d, a, b, c, x[i + 6], 9, -1069501632);c = gg(c, d, a, b, x[i + 11], 14, 643717713);b = gg(b, c, d, a, x[i + 0], 20, -373897302);a = hh(a, b, c, d, x[i + 5], 4, -378558);d = hh(d, a, b, c, x[i + 8], 11, -2022574463);c = hh(c, d, a, b, x[i + 11], 16, 1839030562);b = hh(b, c, d, a, x[i + 14], 23, -35309556);a = ii(a, b, c, d, x[i + 0], 6, -198630844);d = ii(d, a, b, c, x[i + 7], 10, 1126891415);c = ii(c, d, a, b, x[i + 14], 15, -1416354905);b = ii(b, c, d, a, x[i + 5], 21, -57434055);a = add(a, oldA);b = add(b, oldB);c = add(c, oldC);d = add(d, oldD);}return [a, b, c, d];}function add(x, y) {var lsw = (x & 0xFFFF) + (y & 0xFFFF);var msw = (x >> 16) + (y >> 16) + (lsw >> 16);return (msw << 16) | (lsw & 0xFFFF);}function ff(a, b, c, d, x, s, t) {return add(rotateLeft(add(add(a, (b & c) | (~b & d)), add(x, t)), s), b);}function gg(a, b, c, d, x, s, t) {return add(rotateLeft(add(add(a, (b & d) | (c & ~d)), add(x, t)), s), b);}function hh(a, b, c, d, x, s, t) {return add(rotateLeft(add(add(a, b ^ c ^ d), add(x, t)), s), b);}function ii(a, b, c, d, x, s, t) {return add(rotateLeft(add(add(a, c ^ (b | ~d)), add(x, t)), s), b);}function rotateLeft(lValue, iShiftBits) {return (lValue << iShiftBits) | (lValue >>> (32 - iShiftBits));}return md5Cycle(input, input.length * 8);}return encode(input);
}console.log(hashFunction(url));
经过调试发现,每个接口对应的a(“/xdnphd/******”)
是不同的,所以要根据不同接口进行修改。
6.3 RSA参数加密逆向案例
本节是对RSA加密的逆向案例,主要内容是对登录参数的RSA加密分析。
网站链接:https://login.10086.cn/html/login/email_login.html
进入之后选择电脑版:
去到登陆界面:
登录之前我们打开控制台,然后登陆进行抓包。
输入用户名和密码之后点击登录,会抓到一个login的包。
它是一个post的请求,我们观察一下Form Data里面的数据:
我们发现用户和密码都进行了加密,但是不确定是哪种加密方式。
此时我们可以全局搜索一下password关键字,发现能搜索到很多相关的信息:
如果一个一个的找会很麻烦,这里告诉大家一个技巧,我们可以搜索encrypt这个关键字(它其实就是表示加密的操作)
这时我们就能找到加密的地方,我们在加密的位置打上断点,再重新登录,看能不能拦截到。
拦截之前先进入资源面板打上断点。
登录的时候可以被拦截,点进去看看它的方法:
发现有明显的RSA标志setPublicKey,接下来就用代码进行模拟。
from Crypto.PublicKey import RSA
from Crypto.Cipher import PKCS1_v1_5
import base64def encrypt_str(data):key = ("MIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEAsgDq4OqxuEisnk2F0EJFmw4xKa5IrcqEYHvqxPs2CHEg2kolhfWA2SjNuGAHxyDDE5MLtOvzuXjBx/5YJtc9zj2xR/0moesS+Vi/xtG1tkVaTCba+TV+Y5C61iyr3FGqr+KOD4/XECu0Xky1W9ZmmaFADmZi7+6gO9wjgVpU9aLcBcw/loHOeJrCqjp7pA98hRJRY+MML8MK15mnC4ebooOva+mJlstW6t/1lghR8WNV8cocxgcHHuXBxgns2MlACQbSdJ8c6Z3RQeRZBzyjfey6JCCfbEKouVrWIUuPphBL3OANfgp0B+QG31bapvePTfXU48TYK0M5kE+8LgbbWQIDAQAB")rsakey = RSA.import_key(base64.b64decode(key))cipher = PKCS1_v1_5.new(rsakey)cipher_text = base64.b64encode(cipher.encrypt(data.encode(encoding="utf-8")))return cipher_textpassword = encrypt_str("123")
print(password.decode())
Crypto是第三方库,属于对PyCrypto库的扩展,所以需要进行安装,Windows安装命令
pip install pycryptodome
七、浏览器环境补充
现在很多网站的Js都引入了浏览器的特征,就是检测浏览器环境和浏览器使用的一些方法。比如用Node直接去运行复制下来的Js代码,可能会报未定义的错误或者找不到方法,导致无法运行或者得到的结果与浏览器不一致。因为Node环境和浏览器具有一定的区别,比如window对象区别,this指向区别、Js引擎区别,以及一些DOM操作的区别,很多网站也会以此检测来判断是不是真实用户,此时就需要对Js代码进行补充。
又比如对浏览器一些参数的设置,网站设置了window、navigate的某一个属性,在模拟的时候没有进行这些设置,就会被检测到。
通常情况下简单的补充只需要补上window或者document以及定义一些变量,比如直接在开头定义一个window=global;或者根据报错和调试结果缺啥补啥。但是不同网站的检测标准和补充难度参差不齐,先来看看哪些环境是经常被检测的。
7.1 常被检测的环境
在常被检测的环境中,有window、location、navigate、document、native、canvas等。除了这些属性外,还有针对自动化的检测、Node环境的检测,以及浏览器指纹检测、TLS指纹校验等。下面列出了一些经常用来做检测的属性和方法。
window检测:
window是否为方法window对象是否freeze各属性检测
location检测:
hostnameprotocolhosthashorigin
navigator检测:
AppNameAppVersioncookieEnabledlanguageuserAgentproductplatformplugins浏览器插件javaEnabled()方法taintEnabled()方法
document检测:
referrercookiecreateElement()方法
canvas指纹:
不同类型图片的canvas指纹应当不一样,如 .jpg .png不同质量quality的canvas指纹应该不一样不同属性的canvas指纹应该不一样同一个条件的canvas多次绘制时应该保持一致
浏览器指纹信息:
window.screen屏幕分辨率/宽高navigator.useragentlocation.href/hostnavigator.platform平台、语言等信息cancas2D图像指纹navigator.plugin浏览器插件信息webgl3D图像指纹浏览器字体信息本地存储的cookie信息
除了上面列出的之外,还有对自动化痕迹的检测,比如chromedriver属性检测。还有异常堆栈检测,通过检测堆栈来判断所处的执行环境。native方法检测,检测某个方法是否被重写。这里不再多说了,在接下来的小节中来了解一下补充环境时的几种方案。
补充
:浏览器指纹很重要,在数据采集、搜索引擎、埋点分析、网站测试等方面都有体现。指纹通常是指服务端的为了做识别而收集的客户端设备信息。即使没有cookie,指纹也可用于识别个人用户或设备。比如常用于记录的指纹 Header、Cookie、IP、DNS、UserAgent,Font(字体列表),Language,localStorage、Plugin(插件),Canvas(渲染绘图),WebGL(3D渲染图形),Web Vendor,Timezone(时区),WebRTC,ScreenResolution(分辨率),Platform(系统),Audio(音频设置和硬件特征指纹),以及enumerateDevices(其他媒体设备)、CPU、GPU信息等等。
7.2 手动补充环境
一般在手动补充时,根据报错信息缺什么补什么。比如’window’ is not defined,就补上window=global;或者widow={}。
如果报错没有plugins,可以在navigator中写上plugins。同时用Symbol.toStringTag标识一下该对象的类型标签,因为目前有很多toString检测,而toStringTag也能被toString()方法识别并返回。
var navigator = {plugins: {0: {0: { description: "", type: "" },name: "",length: 1,filename: "",description: "",}}
};navigator.plugins[Symbol.toStringTag] = "PluginArry";
navigator.plugins[0][Symbol.toStringTag] = "Plugin";
如果报错没有getElementByTagName,就到document中去定义一个,但是参数和方法中具体实现,以及返回内容都需要根据调试结果来进行补充。
var document = {getElementsByTagName: function(x) {return {};},createElement: function(x) {return {};}
};
如果报错没有canvas,那么可以用createElement去创建一个简单的元素节点,通过此方法可返回一个canvas对象。
none
var document = {getElementsByTagName: function(x) {return {};},createElement: function createElement(x) {if (x == "canvas") {return {toDataURL: function toDataURL() {return "data:image/png";}};}}
};
7.3 JSDOM环境补充
js中的DOM指的是“Document Object Model”,也就是文档对象模型的意思,是HTML和XML文档的编程接口;它提供了对文档的结构化的表述,并定义了一种方式可以使从程序中对该结构进行访问,从而改变文档的结构,样式和内容。
JSDOM对浏览器环境的还原非常到位,它可以被当作无头浏览器使用。一般来说,在node环境下需要补充最多的就是document和window对象。通过下面的代码来看JSDOM是如何模拟成浏览器的。
通过HTML文本来创建一个JSDOM的实例,然后就可以通过实例取得window对象和document对象。JSDOM生成的window对象下还实现了history、location、postMessage、setTimeout、setInterval等熟悉的Api。
JSDOM安装:
npm install jsdom
查看jsdom的位置:
npm root -g
const { JSDOM } = require('jsdom');
const NewjsDom = new JSDOM(`<!DOCTYPE html><html lang="en"><head><meta charset="UTF-8"><title>Title</title></head><body><div><p>web js逆向</p></div></body></html>`
);const window = NewjsDom.window; // window 对象
const document = window.document; // document 对象console.log('123');
另外要提的一点是Python中的execjs库也可以切换成jsdom来执行含有document、window等对象的Js代码。
代码示例:
signature = execjs.compile(js, cwd=jsdom_path).call("sign")
JSDOM能满足大部分测试场景下对浏览器环境的还原,但在一些场景下也会被检测出来。某些时候并不知道网站检测了什么属性,也不知道修补环境时,可以使用真实的浏览器驱动来加载Js代码。比如像抖音web版、头条新闻页面,这两个站点的Js代码都具备深度检测浏览器身份的功能。
7.4 Selenium环境模拟
我们喜欢用的方法是使用selenium来驱动浏览器,先把Js和参数都写到本地的html文件,然后用selenium打开html文件加载Js,生成加密参数。当然也可以使用其他的Web自动化测试工具,比如Pyppeteer、htmlunit等。
具体方法如下:
HTML文件
:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>web js逆向</title>
</head>
<body><p>我们在学习 js 逆向</p><script>function f() {// 此处省略 n 行}</script>
</body>
</html>
python文件
:
使用selenium需要安装selenium库。
还得安装浏览器驱动,这里提供chrome的驱动:
下载地址:http://chromedriver.storage.googleapis.com/index.html
注意需下载和自己本地谷歌浏览器版本一致的驱动。
import os
import time
from selenium import webdriver# 获取当前脚本所在目录
PRO_DIR = os.path.dirname(os.path.abspath(__file__))def driver_sig(html_file):"""使用 Selenium 获取 HTML 文件的标题参数:html_file (str): HTML 文件的文件名返回:str: HTML 文件的标题"""# 初始化 Chrome WebDriverdriver = webdriver.Chrome()try:# 加载本地 HTML 文件file_path = f"file://{PRO_DIR}/{html_file}"driver.get(file_path)# 等待页面加载完成time.sleep(5)# 获取页面标题sig = driver.titlefinally:# 确保退出 WebDriverdriver.quit()return sigif __name__ == '__main__':# 指定 HTML 文件名html_file = 'get_sign.html'# 获取 HTML 文件的标题sig = driver_sig(html_file)# 输出标题print(sig)
7.5 puppeteer环境模拟
puppeteer是一个Node.js的库,支持调用Chrome的Api来操纵Web,相比较Selenium或是PhantomJs,它最大的特点就是操作DOM可以完全在内存中进行模拟(可用代码驱动浏览器操作),既在v8引擎中处理,又不打开浏览器,而且这个是Chrome团队在维护,会拥有更好的兼容性和发展前景。
Puppeteer 是一个Chrome官方团队提供的node库,它可以通过 Puppeteer 的提供的 API 直接控制 Chrome 或 Chromium。
Puppeteer可以做什么?
1) 生成网页截图或者 PDF
2) 爬取SPA应用,并生成预渲染内容(即“SSR” 服务端渲染)
3) 高级爬虫,可以爬取大量异步渲染内容的网页
4) 模拟键盘输入、表单自动提交、登录网页等
5) 创建一个最新的自动化测试环境,实现 UI 自动化测试
6) 捕获站点的时间线,以便追踪网站、帮助分析网站性能问题7) 用于测试 Chrome 扩展程序
Puppeteer安装:
下面是简单的调用代码:
puppeteer.launch 启动浏览器实例browser.newPage() 创建一个新页面page.goto 进入指定网页page.screenshot 截图
const puppeteer = require('puppeteer');(async () => {// 启动浏览器const browser = await puppeteer.launch({headless: false, // 是否在无头模式下运行浏览器(false 显示浏览器,true 不显示浏览器)});// 打开一个新页面const page = await browser.newPage();// 设置页面视窗大小await page.setViewport({width: 1024,height: 800,});// 打开百度首页await page.goto('https://www.baidu.com');// 截图并保存为 'example.png'await page.screenshot({ path: 'example.png' });// 关闭浏览器await browser.close();
})();
puppeteer的中文文档:
https://www.w3cschool.cn/puppeteer/
手动补充环境相对耗时耗力,如果采集频率要求不高时,直接在浏览器中模拟环境调用即可,在接下来的小节中来学习一下对浏览器环境的检测。
八、加密方法远程调用
加密方法的远程调用主要是使用了 RPC(Remote Procedure Call,远程过程调用)协议。RPC是一种通过网络实现不同系统、进程间通信的技术,可以将本地调用抽象成调用远程服务,从而实现跨进程、跨设备的通信。
RPC 的应用场景十分广泛,比如:
分布式系统中的进程间通信
:通过 RPC 实现不同服务之间的交互。
微服务中的节点通信
:服务 A 可以直接调用服务 B 的方法,而不需要了解底层实现逻辑。
前后端协同
:前端应用可以通过 RPC 直接调用后端暴露的接口,获取需要的数据或执行操作。
在 JavaScript 逆向工程 中,RPC 通常用来简化调试和分析。通过在浏览器和本地环境之间建立通信通道,可以直接调用浏览器中的一些加密函数,而无需对函数的具体执行逻辑进行详细逆向。这种方法可以显著提高效率,节省时间。
8.1. RPC 的基本原理
在 RPC 模型中:
客户端
:是发出调用请求的程序(通常是浏览器环境)。
服务端
:是提供方法实现的程序(通常是本地环境)。
在 JavaScript 逆向工程中,可以利用 WebSocket 协议 来建立客户端和服务端之间的通信管道:
浏览器作为客户端
:负责执行加密算法,提供加密服务。
本地作为服务端
:接收客户端传来的加密请求,并返回处理结果。
WebSocket 实现 RPC 的流程:
-
服务端启动 WebSocket 服务器。
-
客户端连接服务端 WebSocket。
-
客户端发送加密请求(包括待加密数据和调用的函数)。
-
服务端接收请求,调用浏览器中相关的加密函数。
-
浏览器返回加密后的结果给服务端。
8.2示例代码
服务端
代码(Node.js):
以下代码实现了一个简单的 WebSocket 服务器,接收加密请求并返回结果。
const WebSocket = require('ws');// 创建 WebSocket 服务器
const server = new WebSocket.Server({ port: 8080 });console.log('WebSocket 服务器已启动,监听端口 8080...');server.on('connection', (socket) => {console.log('客户端已连接');// 接收来自客户端的消息socket.on('message', (message) => {const request = JSON.parse(message); // 假设客户端发送 JSON 格式的数据console.log('接收到请求:', request);// 模拟调用加密函数const encryptedData = simulateEncryption(request.data);// 返回加密结果socket.send(JSON.stringify({ success: true, result: encryptedData }));});// 连接关闭socket.on('close', () => {console.log('客户端已断开连接');});
});// 模拟加密函数
function simulateEncryption(data) {// 这里可以是实际的加密逻辑,或者在实际场景中调用浏览器加密方法return Buffer.from(data).toString('base64'); // 示例:简单的 Base64 编码
}
浏览器端代码(客户端
):
客户端通过 WebSocket 连接服务端,并发送加密请求。
// 创建 WebSocket 连接
const socket = new WebSocket('ws://localhost:8080');// WebSocket 连接打开时触发
socket.onopen = () => {console.log('已连接到服务端');// 构造加密请求数据const request = {method: 'encrypt', // 调用方法data: 'Hello, RPC!' // 待加密数据};// 发送请求socket.send(JSON.stringify(request));console.log('请求已发送:', request);
};// 接收服务端的响应
socket.onmessage = (event) => {const response = JSON.parse(event.data);console.log('接收到服务端响应:', response);if (response.success) {console.log('加密结果:', response.result);} else {console.error('加密失败:', response.error);}
};// WebSocket 连接关闭时触发
socket.onclose = () => {console.log('与服务端的连接已关闭');
};// WebSocket 出现错误时触发
socket.onerror = (error) => {console.error('WebSocket 错误:', error);
};
8.3示例运行效果
启动服务端脚本后,服务端将监听 8080 端口。
浏览器端运行客户端代码后,向服务端发送请求:请求数据为 { method: 'encrypt', data: 'Hello, RPC!' }。
服务端接收到请求后,将调用加密方法(这里模拟为 Base64 编码)并返回结果。
浏览器端接收服务端响应,并输出加密后的数据。服务端输出:
WebSocket 服务器已启动,监听端口 8080...
客户端已连接
接收到请求: { method: 'encrypt', data: 'Hello, RPC!' }客户端输出:
已连接到服务端
请求已发送: { method: 'encrypt', data: 'Hello, RPC!' }
接收到服务端响应: { success: true, result: 'SGVsbG8sIFJQQyE=' }
加密结果: SGVsbG8sIFJQQyE=
九、 AST技术简介
前面我们介绍了一些javascript混淆的基本知识,可以看到混淆方式多种多样,比如字符串混淆、变量名混淆、对象键名换、控制流平坦化等,当然,我们也学习了一些相关的调试技巧,比如Hook、断点调试等。但是这些方法本质上其实还是在已经混淆的代码上进行的操作,所以代码的可读性依然比较差。
有没有什么办法可以直接提高代码的可读性呢?比如说,字符串混淆了,我们想办法把它还原了;对象键名替换了,我们想办法把它们重新组装好了,控制流平坦化之后逻辑不直观了,我们想办法把它还原成一个代码控制流。
到底应该怎么做呢?这就需要用到AST相关的知识了。本节中,我们就来了解AST相关的基础知识,并介绍操作AST的相关方法。
9.1 AST介绍
首先,我们来了解什么是AST。AST的全称叫作Abstract Syntax Tree,中文翻译叫作抽象语法树。
如果你对编译原理有所了解的话,一段代码在执行之前,通常要经历这么三个步骤。
1、词法分析
:一段代码首先会被分解成一段段有意义的词法单元,比如const name = “msb”这段代码,它可以被拆分成四部分:const、name、=、“msb”,每个部分都具备一定的含义。
2、语法分析
:接着编译器会尝试对一个个词法单元进行语法分析,将其转换为能代表程序语法结构的数据结构。比如,const 就被分析为 VariableDeclaration 类型,代表变量声明的具体定义;name就被分析为Identifier类型,代表一个标识符。代码内容多了,这一个个词法就会有依赖、嵌套等关系,因此表示语法结构的数据结构就构成了一个树状的结构,也就成了语法树,即AST。
3、指令生成
:最后将AST转换为实际真正可执行的指令并执行即可。
AST是源代码的抽象语法结构的树状表示,树上的每个节点都表示源代码的一种结构,这种数据结构其实可以类比成一个大的JSON对象。
在前端开发中,AST技术应用十分广泛,有了AST,我们可以方便地对JavaScript代码进行转换和改写,因此还原混淆后的Javascript代码也就不在话下了。
接下来,我们通过一些实例了解AST的一些基本理念和操作。
9.2 实例引入
首先,推荐一个AST在线解析的网站 http://astexplorer.net/,我们先通过一个非常简单的实例来感受一下AST究竟是什么样子的,输入上述的示例代码:
这时候我们就可以看到在右侧就出现了一个树状结构,这就是AST,如图所示:
这就是一个层层嵌套的数据结构,可以看到他把代码的每一个部分都进行了拆分并分析出对应的类型、位置和值。比如说,name被解析成一个type为Identifier的数据结构,start和end分别代表代码的起始和终止位置,name属性代表该Identifier的名称。另外,msb这个字符串被解析成了StringLiteral类型的数据结构,它同样有start、end等属性,同时还有extra属性。我们所看到的这些数据结构就构成了一个层层嵌套的AST。
另外,在右上角,我们还看到一个Parser标识。这是一个目前最流行的JavaScript语法编译器Babel的Node.js包,同时它也是主流前端开发技术中必不可少的一个包。它内置了很多分析JavaScript代码的方法,可以实现JavaScript代码到AST的转换。
接下来,我们使用Babel来实现一下AST的解析、修改。
9.3 准备工作
由于本节内容需要用到babel,而Babel是基于Node.js的,所以这里需要先安装Node.js。
安装好Node.js之后,我们便可以使用npm命令了,接着,我们还需要安装一个Babel的命令行工具@babel/node,安装命令如下:
npm install -g @babel/node
接下来,我们再初始化一个Node.js项目learn-ast,然后在learn-ast目录下运行初始化命令,具体如下:
npm install -D @babel/core @babel/cli @babel/preset-env
运行完毕之后,就会生成一个package.json文件并在devDependencies中列出了刚刚安装的几个Node.js包。
接着,我们需要在learn-ast目录下创建一个,.babelrc
文件,其内容如下:
{"presets": ["@babel/preset-env"]
}
这样我们就算完成了初始化操作。
9.4 节点类型
在刚才的示例中,我们看到不同的代码词法单元被解析成了不同的类型,所以这里先简单列举Babel中所支持的一些类型。
Literal
:中文可以理解为字面量,既简单的文字表示,比如3、”abc”、null这些都是基本的字面表示。
Declarations
:声明,比如声明方法或者变量。
Expressions
:表达式,它本身会返回一个计算结果,通常有两个作用:一个是放在赋值语句的右边进行赋值,另外还可以作为方法的参数。
Statements
:语句。
Identifier
:标识符,指代一些变量的名称,比如上述例子中的name就是Identifier。
Classes
:类,代表一个类的定义。
Functions
:方法声明。
Modules
:模块,可以理解为一个Node.js模块。
Program
:程序,整个代码可以称为Program。
当然除此之外还有很多的类型,具体可以参考:https://babeljs.io/docs/en/babel-types。
9.5 @babel/parser的使用
@babel/parser是Babel中的Javascript解析器,也是一个Node.js包,他提供了一个重要的方法,就是parse和parseExpression方法,前者支持解析一段JavaScript代码,后者则是尝试解析单个JavaScript表达式并考虑了性能问题。一般来说,我们直接使用parse方法就够了。
对于parse方法来说,输入和输出如下。
输入:一段JavaScript代码。
输出:该段JavaScript代码对应的抽象语法树,即AST,它基于ESTree规范。
现在我们来测试一下。
新建一个JavaScript文件,将其保存为codes/code1.js
,其内容如下:
const a = 3; // 定义常量 alet string = "hello"; // 初始化字符串变量// 循环拼接字符串
for (let i = 0; i < a; i++) {string += "world";
}// 打印结果
console.log("string:", string);
下面我们需要使用parse方法将其转化为一个抽象语法树,即AST。
新建一个basic1.js
文件,内容如下:
// 导入所需模块
import { parse } from "@babel/parser";
import fs from "fs";// 读取代码文件
const filePath = "codes/code1.js"; // 文件路径
const code = fs.readFileSync(filePath, "utf-8");// 解析代码为 AST(抽象语法树)
const ast = parse(code);// 打印 AST
console.log(ast);
接着我们可以使用babel-node运行:
babel-node basic1.js
结果如下:
可以看到,整个AST的根节点就是一个Node,其中type是File,代表一个File类型的节点,其中包括type、start、end、loc、program等属性。其中program也是一个Node,但它的type是Program,代表一个程序。同样,Program也包括了一些属性,比如start、end、loc、interpreter、body等。其中,body是最为重要的属性,是一个列表类型,列表中的每个元素也都是一个Node,但这些不同的Node其实也是不同的类型,它们的type多种多样,不过这里控制台并没有把其中的节点内容输出出来。
我们可以增加一行代码,再专门输出一下body的内容:
console.log(ast.program.body);
重新运行,可以发现这里又多输出了一些内容,具体如下:
由于内容过多,这里省略了一些内容。可以看到,我们直接通过ast.program.body即可将body获取到。可以看到,刚才的四个Node的具体结构也被输出出来了。前面两个Node都是VariableDeclaration类型,这正好对应了前面两行代码:
const a = 3;
let string = "hello";
这里我们分别声明了一个数字类型和字符串类型的变量,所以每句都被解析为VariableDeclaration类型。每个VariableDeclaration都包含了一个declarations属性,其内部又是一个Node列表,其中包含了具体的详情信息。
接着,我们再继续观察下一个Node,它是ForStatement类型,代表一个for循环语句,对应的代码如下:
for (let i = 0; i < a; i++){string += "world";}
for循环通常包括四个部分,for初始逻辑、判断逻辑、更新逻辑以及以及for循环区块的主循环执行逻辑,所以对于一个ForStatement,它也自然有几个对应的属性表示这些内容,分别为init、test、update和body。
对于init,即循环的初始逻辑,其代码如:let i = 0;
它相当于声明一个变量声明,所以它又被解释为VariableDeclaration类型,这和上文是一样的。
对于test,即判断逻辑,其代码如下:
i < a
它是一个逻辑表达式,被解析BinaryExpression,代表逻辑运算。
对于update,即更新逻辑,其代码如下:
i++
它就是对i+1,也是一个表达式,被解析为UpdateExpression类型。
对于body,它被一个大括号包围,其内容为:
{string += "world";
}
整个内容算作一个代码块,所以被解析为BlockStatement类型,其body属性又是一个列表。
对于最后一行,代码如下:
console.log(“string”, string);
它被解析为ExpressionStatement类型,expression的属性是CallExpression。CallExpression又包含了callee和arguments属性,对应的就是console对象的log方法的调用逻辑。
到现在为止,我们应该能弄明白这个基本过程了。
parser会将代码根据逻辑区块进行划分,每个逻辑区块根据其作用都会归类成不同的类型,不同的类型拥有不同的属性表示。同时代码和代码之间有嵌套关系,所以最终整个代码就会被解析成一个层层嵌套的表示结果。
9.6 @babel/generator的使用
@babel/generator也是一个Node.js包,它提供了generate方法将AST还原成JavaScript代码,调用如下:
// 导入所需模块
import { parse } from "@babel/parser";
import generate from "@babel/generator";
import fs from "fs";// 读取 JavaScript 源代码文件
const filePath = "codes/code1.js"; // 文件路径
const code = fs.readFileSync(filePath, "utf-8");// 解析源代码为 AST(抽象语法树)
const ast = parse(code);// 根据 AST 生成新的代码
const { code: output } = generate(ast);// 输出生成的代码
console.log(output);
重新运行可以得到如下结果:
这时候我们可以看到,利用generate方法,我们成功地把一个AST对象转化成为Javascript代码。
到这里我们就清楚了,如果要把一段JavaScript解析成AST对象,就用parse方法。如果要把AST对象还原成代码,就用generate方法。
9.7 @babel/traverse的使用
前面我们了解了AST的解析,输入任意一段JavaScript代码,我们便可以分析出其AST。但是只是了解AST,我们并不能实现JavaScript代码的反混淆。下面我们还需要进一步了解另一个强大的功能,就是AST的遍历和修改。
遍历我们使用的是@babel/traverse,它可以接收一个AST利用tracerse方法就可以遍历其中的所有节点。在遍历方法中,我们便可以对每一个节点进行对应的操作了。
我们先来感受一下遍历的基本实现。新建一个JavaScript文件,将其命名为basic2.js
,内容如下:
// 导入所需模块
import traverse from "@babel/traverse";
import { parse } from "@babel/parser";
import generate from "@babel/generator";
import fs from "fs";// 读取 JavaScript 源代码文件
const filePath = "codes/code1.js"; // 文件路径
const code = fs.readFileSync(filePath, "utf-8");// 解析源代码为 AST(抽象语法树)
const ast = parse(code);// 遍历 AST
traverse(ast, {enter(path) {// 打印 AST 中的每个路径节点console.log(path);},
});
这里我们调用了traverse方法,给第一个参数传入AST对象,给第二个参数定义了相关的处理逻辑,这里声明了一个enter方法,它接收path参数。这个enter方法在每个节点被遍历到时都会被调用,其中path里面就包含了当前被遍历到的节点相关信息。这里我们先把path输出出来,看看遍历时能拿到什么信息。
运行如下代码:
babel-node basic2.js
这时我们看到控制台输出了非常多的内容,调用很多次log方法输出了对应的内容。每次输出都代表一个path对象,我们拿其中一次输出结果看下。
可以看到内容比较复杂。首先我们可以看到它的类型是NodePath,拥有parent、container、node、scope、type等多个属性。比如node属性是一个Node类型的对象,他代表当前正在遍历的节点。比如,利用parent也能获得一个Node类型对象,它代表该节点的父节点。
所以我们可以利用path.node拿到当前对应的Node对象,利用path.parent拿到当前Node对象的父节点。
既然如此,我们便可以使用他来对Node进行一些处理。比如,我们可以把值变化一下,原来的代码如下:
const a = 3; // 循环次数
let string = "hello"; // 初始化字符串// 循环拼接字符串
for (let i = 0; i < a; i++) {string += "world";
}// 输出最终字符串
console.log("string:", string);
我们想要通过修改AST的方式对如上代码进行修改,比如修改一下a变量和string变量的值,变成如下代码:
const a = 5; // 循环次数
let string = "hi"; // 初始化字符串// 循环拼接字符串
for (let i = 0; i < a; i++) {string += "world";
}// 输出拼接后的字符串
console.log("Resulting string:", string);
我们可以这样实现逻辑:
// 导入所需模块
import traverse from "@babel/traverse";
import { parse } from "@babel/parser";
import generate from "@babel/generator";
import fs from "fs";// 读取 JavaScript 源代码文件
const filePath = "codes/code1.js"; // 文件路径
const code = fs.readFileSync(filePath, "utf-8");// 解析源代码为 AST(抽象语法树)
let ast = parse(code);// 遍历 AST 并修改节点
traverse(ast, {enter(path) {const { node } = path;// 修改数值字面量if (node.type === "NumericLiteral" && node.value === 3) {node.value = 5; // 将 3 替换为 5}// 修改字符串字面量if (node.type === "StringLiteral" && node.value === "hello") {node.value = "hi"; // 将 "hello" 替换为 "hi"}},
});// 根据修改后的 AST 生成新的代码
const { code: output } = generate(ast, {retainLines: true, // 保留原代码的行号和格式
});// 输出修改后的代码
console.log(output);
这里我们判断了node的类型和值,然后将node的value进行了替换,这样执行完毕traverse方法之后,AST就被更新完毕了。
运行结果如下:
可以看到,原始的JavaScript代码就被成功更改了!
另外,除了定义enter方法外,我们还可以直接定义对应特定类型的解析方法,这样遇到此类型的节点时,该方法就会被自动调用,用法类似如下:
// 导入所需模块
import traverse from "@babel/traverse";
import { parse } from "@babel/parser";
import generate from "@babel/generator";
import fs from "fs";// 读取 JavaScript 源代码文件
const filePath = "codes/code1.js"; // 文件路径
const code = fs.readFileSync(filePath, "utf-8");// 解析源代码为 AST(抽象语法树)
const ast = parse(code);// 遍历 AST 并修改节点
traverse(ast, {NumericLiteral(path) {// 修改数值字面量,将 3 替换为 5if (path.node.value === 3) {path.node.value = 5;}},StringLiteral(path) {// 修改字符串字面量,将 "hello" 替换为 "hi"if (path.node.value === "hello") {path.node.value = "hi";}},
});// 根据修改后的 AST 生成新的代码
const { code: output } = generate(ast, {retainLines: true, // 保留原代码的行号和格式
});// 输出修改后的代码
console.log(output);
运行结果完全是相同的,单独定义特定的类型的解析方法会显得更有条理。
另外,我们可以再看下其他的操作方法,比如,删除某个node,这里可以试着删除最后一行代码对应的节点,此时直接调用remove方法即可,用法如下:
// 导入所需模块
import traverse from "@babel/traverse";
import { parse } from "@babel/parser";
import generate from "@babel/generator";
import fs from "fs";// 读取 JavaScript 源代码文件
const filePath = "codes/code1.js"; // 文件路径
const code = fs.readFileSync(filePath, "utf-8");// 解析源代码为 AST(抽象语法树)
const ast = parse(code);// 遍历 AST 并删除指定节点
traverse(ast, {CallExpression(path) {const { node } = path;// 检查是否为 console.log 调用if (node.callee?.object?.name === "console" &&node.callee?.property?.name === "log") {path.remove(); // 移除 console.log 调用}},
});// 根据修改后的 AST 生成新的代码
const { code: output } = generate(ast, {retainLines: true, // 保留原代码的行号和格式
});// 输出修改后的代码
console.log(output);
这样我们就可以删除所有的console.log语句。
运行结果如下:
上面说了简单的替换和删除,那么如果我们要插入一个节点,该怎么办呢?插入新的节点时,需要先声明一个节点,怎么声明呢?这时候就要用到types了。
9.8 @babel/types的使用
@babel/types是一个Node.js包,里面定义了各种各样的对象,我们可以方便地使用types声明一个新的节点。
比如说,这里有这样一个代码:
我们想增加一行代码,将原始的代码变成:
const a = 1;
const b = a + 1;
该怎么办呢?这时候我们可以借助types实现如下操作:
import traverse from "@babel/traverse";
import { parse } from "@babel/parser";
import generate from "@babel/generator";
import * as types from "@babel/types";
const code = "const a = 1;";
let ast = parse(code);
traverse(ast, { VariableDeclaration(path) { let init = types.binaryExpression( "+", types.identifier("a"), types.numericLiteral(1) ); let declarator = types.variableDeclarator(types.identifier("b"), init); let declaration = types.variableDeclaration("const", [declarator]); path.insertAfter(declaration); path.stop(); },});
const { code: output } = generate(ast, { retainLines: true,});
console.log(output);
运行结果如下:
这里我们成功使用AST完成了节点的插入,增加了一行代码。
但上面的代码看起来似乎不知道怎么实现的,init、declarator、declaration都是怎么来的呢?
别担心我们,接下来我们详细剖析一下。首先我们可以把最终想要变换的代码进行AST解析,结果如图所示:
这时候我们可以看到第二行代码的节点结构了,现在需要做的就是构造这个节点,需要从内而外依次构造。
首先看到整行代码对应的节点是VariableDeclaration。要生成VariableDeclaration,我们可以借助types的variableDeclaration的方法,二者的差别仅仅是后者的开头字母是小写的。
API怎么用呢?这就需要查阅官方文档了。我们查到variableDeclaration的用法如下:
t.variableDeclaration(kind, declarations)
可以看到,构造他需要两个参数,具体如下。
-
kind:必需,可以是“var”|“let”|“const”。
-
declarations:必需,是Array<VariableDeclarator>,即VariableDeclarator组成的列表。
这里kind我们可以确定了,那么declarations怎么构造呢?
要构造declarations,我们需要进一步构造VariableDeclarator,它也可以借助types的variableDeclarator方法,用法如下:
t.variableDeclarator(id, init)
他需要id和init两个参数。
-
id:必需,即Identifier对象
-
init:Epression对象,默认为空。
因此,我们还需要构造 id 和 init 对象。这里 id 其实就是 b 了,我们可以借助于types的identifier方法来构造。而对于init,它是expression,在AST中我们可以观察到它是BinaryExpression类型,所以我们可以借助于types的binaryExpression来构造。binaryExpression的用法如下:
t.binaryExpression(operator, left, right)
他有三个参数,具体如下。
operator
:必需,”+” | “-“ | “/“ | “%” | “*” | “**” | “&” | “|” | “>>” | “>>>” | “<<” | “^” | “==” | “===” | “!=” | “!==” | “in” | “instanceof” | “>” | “<” | “>=” | “<=”
。
left
:必需,Expression,即operator左侧的表达式
rught
:必需,Expression,即operator右侧的表达式。
这里又需要三个参数,operator就是运算符,left就是运算符左侧的内容,right就是运算符右侧的内容。后面两个参数都需要是Expression,根据AST,这里的Expression可以直接声明为Identifier和NumericLiteral,所以又可以分别用types的identifier和numericLiteral创建。
这样梳理清楚后,我们从里到外将代码实现出来,一层一层构造,最后就声明了一个VariableDeclaration类型的节点。
最后,调用path的insertAfter方法便可以成功将节点插入到path对相应的节点。
十、使用AST技术还原混淆代码
在上一节中,我们介绍了AST相关的基本知识和基础的操作方法,本节中我们就来实际应用这些方法来还原JavaScript混淆后的代码,即反混淆的实现。
由于JavaScript混淆方式多种多样,这里就介绍一些常见的反混淆方案,如表达式还原,字符串还原,无用代码剔除,反控制流平坦化等。
10.1 表达式还原
有时候,我们会看到有一些混淆的JavaScript代码其实就是把简单的东西复杂化,比如说一个布尔常量true,被写成 !![] ; 一个数字,被转化为parseInt加一些字符串的拼接。通过这些方式,一些简单又直观的表达式就被复杂化了。
看下面的这几个例子,代码如下:code2.js
const a = !![];
const b = "abc" == "bcd";
const c = (1 << 3) | 2;
const d = parseInt("5" + "0");
对于这种情况,有没有还原的方法呢?当然有,借助于AST,我们可以轻松实现。
首先,在=
的右侧,其实都是一些表达式的类型,比如 “abc” = “bcd” 就是一个BinaryExpression,他代表的是一个布尔类型的结果。
怎么处理呢?我们将上述代码保存为code2.js
,根据上一章节学到的知识,可以编写如下还原代码:
import traverse from "@babel/traverse";
import { parse } from "@babel/parser";
import generate from "@babel/generator";
import * as types from "@babel/types";
import fs from "fs";
const code = fs.readFileSync("codes/code2.js", "utf-8");
let ast = parse(code);
traverse(ast, { "UnaryExpression|BinaryExpression|ConditionalExpression|CallExpression": ( path ) => { const { confident, value } = path.evaluate(); if (value == Infinity || value == -Infinity) return; confident && path.replaceWith(types.valueToNode(value)); },});const { code: output } = generate(ast);
console.log(output);
这里我们使用traverse方法对AST对象进行遍历,,使用
“UnaryExpression|BinaryExpression|ConditionalExpression|CallExpression“作为对象的键名,分别用于处理一元表达式、布尔表达式、条件表达式、调用表达式。如果AST对应的path对象符合这几种表达式,就会执行我们定义的回调方法。在回调方法里面,我们调用了path的evaluate方法,该方法会对path对象进行执行,计算所得到的结果。其内部实现会返回一个confident的value字段表示置信度,如果认定结果是可信的,那么confident就是true,我们可以调用path的replaceWith方法把执行的结果value进行替换,否侧不替换。
运行结果如下:
可以看到,原本看起来不怎么直观的代码现在被还原得非常直观了。
所以,利用这个原理,我们可以实现一些表达式的还原和计算,提高整个代码的可读性。
10.2 字符串还原
之前我们了解到,JavaScript被混淆后,有些字符串会被转化为Unicode或者UTF-8编码的数据,比如说这个样子:
const string = [“\x68\x65\x6c\x6c\x6f”, “\x77\x6f\x72\x6c\x64”];
其实这原本就是一个简单的字符串,被转换成UTF-8编码之后,其可读性大大降低了,如果这样的字符串被隐藏在JavaScript代码里面,我们想通过搜索字符串的方式寻找关键突破口,就搜不到了。
对于这种字符串,我们能用AST还原码?当然可以。
我们先在https://astexplorer.net/里面把这行代码粘贴进去,结果如图所示:
可以看到,两个字符串都被识别成了 StringLiteral类型,它们都有一个extra属性。extra属性里卖有一个raw属性和rawValue属性,二者是不一样的,rawValue的真实值已经被分析出来了。
因此,我们只需要将 StringLiteral 中 extra 属性的 raw 值替换为 rawValue 的值即可,实现如下:
import traverse from "@babel/traverse";
import { parse } from "@babel/parser";
import generate from "@babel/generator";
import fs from "fs";const code = fs.readFileSync("codes/code3.js", "utf-8");
let ast = parse(code);traverse(ast, {StringLiteral({ node }) {if (node.extra && /\\[ux]/gi.test(node.extra.raw)) {node.extra.rawValue = node.extra.raw;}},
});const { code: output } = generate(ast);
console.log(output);
输出结果如下:
这样我们就成功实现了混淆字符串的还原。
如果我们把这个脚本应用于混杂了混淆字符串的JavaScript文件,那么其中的混淆字符串就可以被还原出来。
十一、WebAssembly案例分析和爬取实战
WebAssembly是一种可以使用非JavaScript编程语言编写代码并且能在浏览器上运行的技术方案。
前面我们也简单介绍过了,借助Emscripten编译工具,我们能将C/C++文件转成wasm格式的文件,JavaScript可以直接调用该文件执行其中的方法。
这样做的好处如下。
-
一些核心逻辑(比如API参数的加密逻辑)使用C/C++实现,这样这些逻辑就可以“隐藏”在编译生成的wasm文件中,其逆向难度比JavaScript更大。
-
一些逻辑是基于C/C++编写的,有更高的执行效率,这使得以各种语言编写的代码都可以以接近原生的速度在Web中运行。
对于这种类型的网站,一般我们会看到网站会加载一些wasm后缀的文件,这就是WebAssembly技术常见的呈现形式,即原生代码被编译成了wasm后缀的文件,JavaScript通过调用wasm文件得到对应的计算结果,然后配合其他JavaScript代码实现页面数据的加载和页面的渲染。
本节中,我们就来通过一个集成WebAssembly的案例网站来认识下WebAssembly,并通过简易的模拟技术来实现网站的爬取。
11.1 案例介绍
下面我们来看一个案例,网址是 https://spa14.scrape.center/,这个网站表面上和之前非常类似,但是实际上其API的加密参数是通过WebAssembly实现的。
首先,我们还是像之前一样,加载首页,然后通过Network面版分析Ajax请求,如图所示:
可以看到,这里就找到了第一页数据的Ajax请求。和之前的案例类似,limit、offset参数用来控制分页,sign参数用来做校验,它的值是一个数字。通过观察后面几页的内容,我们发现sign的值一直在变化。
因此,这里的关键就是在于找到sign值的生成逻辑,我们再模拟请求即可。
接下来,我们就进行一下逆向,先看看这个参数生成的逻辑在哪里吧。
这里我们还是设置一个Ajax断点,再Sources面板的 XHR/fetch Breakpoints 这里添加一个断点,内容为/api/movie,就是在请求加载数据的时候进如断点,如图所示:
接下来,重新刷新页面,可以看到页面执行到断点的位置后停下来,如图所示:
这里我们还是通过Call Stack找到构造逻辑。经过简单的查找和推测,可以判断逻辑的入口在onFetchData方法里面,如图所示:
点击onFetchData方法,找到方法所在的JavaScript代码如图所示:
和之前的案例类似,params的参数有三个——limit、offset、sign,这和Ajax请求一致。
这里关键的参数就是sign了,可以看到它的值是用变量e表示的,而e的生成代码就在上面,如下所示:
可以看到,它通过调用this.$wasm.asm对象的encrypt方法传入了n和一个时间戳构造出来了。
接下来,我们进一步在此处调试一下,在2100行添加断点,如图所示:
重新刷新页面,可以发现页面运行到该段点的位置并停了下来,如图:
这相当于JavaScript上下文处于onFetchData方法内部,所以现在我们可以访问方法内部的所有变量,比如this、this.$wasm等。
接下来,我们在Watch面板中添加一个变量this.$wasm,先看看它是什么对象,如图所示:
可以看到这个this.$wasm对象里面又定义了很多对象和方法,其中就包括了asm对象。因为代码中又调用了asm对象的encrypt来生产sign,所以我们进一步看看asm对象、encrypt方法都是什么。将asm对象直接展开即可:
这时候我们可以看到asm对象里面又包含了 几个对象和方法,比较重要的就是encrypt方法了,其中它的 [[FunctionLocation]]指向了另外一个位置,名称是 Wasm.wasm:0xd9。因为我们就是想知道这个方法内部究竟是什么逻辑,所以直接点击进入,如图:
可以看到,我们进入了一个似乎不是JavaScript代码的位置,文件名称叫作Wasm.wasm。
代码跳转的位置可以看到encrypt字样,其代码定义如下
如果你了解汇编语言的话,会发现这有点汇编语言的味道。
这其实就是wasm文件,这里面的逻辑其实原本使用C++编写的,通过Emscripten转化为wasm文件,就成了现在的这个样子。
这时候我们可以找下Network请求,搜索wasm后缀的文件,如图:(把断点先去掉)
可以看到,这里就有一个wasm后缀的文件,其逻辑就是刚才看到的内容。
到了这里,wasm代码已经完全看不懂了,接下来怎么做呢?
有两种办法,一种是直接把wasm文件进行反编译,还原成C++代码,此种方法上手难度大,需要了解WebAssembly和逆向相关的知识,另外一种就是通过模拟执行的方式来直接得到加密结果。
本节我们主要来了解第二种方案。拿到wasm文件,然后通过Python模拟执行的方式调用wasm文件,模拟调用它的encrypt方法,传入对应的参数即可。
11.2 模拟执行
首先,我们把wasm文件下载下来,地址为 https://spa14.scrape.center/js/Wasm.wasm,将其保存为Wasm.wasm文件
要使用Python模拟执行wasm,可以使用两个Python库,一个叫作pywasm,另一个叫作wasmer-python,前者使用更加简单,后者功能更加强大。我们使用任意一个库都可以完成wasm文件的模拟。
这里我们以pywasm为例
这个库比较简单,其主要功能就是加载一个wasm文件,然后用Python执行。
安装命令如下:
pip install pywasm
安装完之后,我们可以用如下代码来加载一个wasm文件:
import pywasm
runtime = pywasm.load("./Wasm.wasm")
print(runtime)
这里我们调用了pywasm的load方法,直接将wasm文件的路径传入,实现了wasm文件的读取,输出结果如下:
可以看到,返回结果就是一个pywasm.Runtime类型的对象。
有了这个Runtime对象之后,我们就可以调用它的exec方法来模拟执行Wasm里面的方法。
比如,在网页中我们可以看到他执行了encrypt方法,并传入了两个参数。我们也来试一下,要模拟调用wasm的方法,只需要调用Runtime对象的exec方法并传入对应的方法名和参数内容即可。
我们可以将代码改写如下:
import pywasm
runtime = pywasm.load("./Wasm.wasm")
result = runtime.exec("encrypt", [1, 2])
print(result)
这里我们调用了exec方法来,第一个参数就是要调用的wasm中的方法名,这里我们传入字符串encrypt,第二个参数是一个列表,代表encrypt方法接收的参数,如果是两个,那么列表长度就是2,参数和列表的元素一一对应即可。
运行结果如下:
调用成功!
成功的输出了结果,但是这似乎并不是我们想要的,因为这里传入的参数其实是我们自定义的,要真正模拟网站的Ajax请求,就要用网站里面的真实参数。
通过分析逻辑,我们知道传入的参数其实一个是offset,一个是时间戳。
其中时间戳的实现是这样的:
parseInt(Math.round((new Date).getTime() / 1e3).toString())
这是JavaScript中的实现,我们将其输出到控制台,可以看到运行结果如图所示:
输出的其实就是一个时间戳,结果是数值类型,位数是10位。使用Python实现同样的结果,可以这样写:
import time
print(int(time.time()))
最终,我们可以将爬虫逻辑实现,具体如下:
import pywasm
import time
import requests
base_url = "https://spa14.scrape.center"
page = 10
runtime = pywasm.load("./Wasm.wasm")
for i in range(page):offset = i * 10 sign = runtime.exec('encrypt', [offset, int(time.time())]) url = f'{base_url}/api/movie/?limit=10&offset={offset}&sign={sign}' response = requests.get(url) print(response.json())
这里我们先定义了page是10,就是10页,然后开始一个for循环遍历,i就是0~9的数字,offset就是0、10、20、30……、90,sign就是利用刚才的实现,将参数转化为offset变量和时间戳,最后构造url请求即可:
运行结果如下:
可以看到,Ajax请求被成功模拟了,成功爬取到了结果。
十二、无限debugger的原理与绕过
debugger是JavaScript中定义的一个专门用于断点调试的关键字,只要遇到他,JavaScritp的执行便会在此处中断,进入调试模式。
有了debugger这个关键字,我们就可以非常方便地对JavaScript代码进行调试,比如使用JavaScript Hook时,我们可以加入debugger关键字,使其在关键的位置停下来,以便查找逆向突破口。
但有时候,debugger会被网站开发者利用,使其成为阻挠我们正常调试的拦路虎。
本节我们介绍一个案例来绕过无限debugger
12.1 案例介绍
我们先看一个案例,网址是 https://antispider8.scrape.center/,打开这个网站,一般操作和之前的网站没有什么不同。但是,一旦我们打开开发者工具,就发现它立即进入了断点模式,如图:
我们既没有设置任何断点,也没有执行任何额外的脚本,它就直接进入了断点模式。这时候我们可以点击 Resume script execution(恢复脚本执行)按钮,尝试跳过这个断点继续执行。如图所示:
然而不管我们点击多少次按钮,它仍然一次次地进入断点模式,无限循环下去,我们称这样的情况为无限debugger。
怎么办呢?似乎无法正常添加断点调试了,有什么解决办法吗?
肯定是有办法的,本节我们就来总结一下无限debugger的应对方案。
12.2 实现原理
我们首先要做的是找到无限debugger的源头。在Sources面板中可以看到,debugger关键字出现在一个JavaScript文件里,这时点击左下角的格式化按钮,如图:
格式化后可以发现这里通过setInterval循环,每秒执行1次debugger语句。
当然,还有很多类似的实现,比如无限for循环、无限while循环、无限递归调用等,它们都可以实现这样的效果,原理大同小异。
了解了原理,下面我们就对症下药吧!
12.3 禁用断点
因为debugger其实就是对应的一个断点,它相当于用代码显式地声明了一个断点,要解除它,我们只需要禁用所有断点就好了。
首先,我们可以禁用所有断点。全局禁用开关位于Sources面板地右上角,叫作 Deactivate breakpoints,如图所示:
这时候我们重新点击一下 Resume script execution 按钮,跳过当前断点,页面就不会进入到无限debugger的状态了。
但是这种全局禁用其实并不是一个好的方案因为禁用之后我们也无法在其他位置增加断点进行调试了,所有的断点都失效了。
这时候我们可以选择禁用局部断点。取消刚才的 Deactivate breakpoints 模式,页面会重新进入无限debugger模式,我们尝试使用另一种方法来跳过这个无限循环debugger。
我们可以在debugger语句所在的行的行号上单击鼠标右键,此时会出现一个快捷菜单如图:
这里会有一个 Never pause here选项,意思是从不在此处暂停。选择这个选项,于是页面变成如图:
当前断点显示为橙色,并且断点前面多了一个?符号,同时Breakpoints也出现了刚才添加的断点位置。这时再次点击 Resume script execution 按钮,就可以发现不会再进入无限debugger模式了。
当然,我们也可以选择另外一个选项 Add conditional breakpoint 如图:
这个模式更加高级,我们可以设置进入断点的条件,比如在调试过程中,期望某个变量的值大于某个具体值的时候才停下来。但在本案例中,由于这里是无限循环,我们没有什么具体的变量可以作为判断依据,因此可以直接写一个简单的表达式来控制。
选择 Add conditional breakpoint 选项,直接填入false 即可。
按下回车键,就跟 Never pause here 选项一样,重新点击 Resume script execution 按钮,也不会进入无限debugger循环了。
12.4 替换文件
之前我们介绍过Overrides面板的用法,利用它可以将远程的JavaScript文件替换成本地的JavaScript文件,这里我们依然可以使用这个方法来对文件进行替换。
很简单,只需要在新的文件里面把debugger这个关键字删除或者是注释。
我们将当前的JavaScript文件复制到文本编辑器中,删除或者直接注释掉debugger这个关键字,修改如下:
替换完成之后,重新刷新网页,这时候发现不会进入无限debugger模式了。
十三、JavaScript逆向技巧总结
前面我们已经学习了不少JavaScript逆向相关的知识,包括浏览器调试、Hook、AST、无限debugger的绕过以及JavaScript的模拟调用等,这些知识点都比较松散,有时大家学完了可能觉得没有形成一个知识体系,或者说没有一个常规“套路”来应对一些JavaScript逆向的处理流程。
本节中,我们就对前面的知识点做一个串联和总结,总结出JavaScript逆向过程中常用的流程,这个流程适用于大多数JavaScript逆向过程。大家熟练运用之后,可以在不同情况下运用不同的技巧来进行JavaScript逆向操作。
总的来说,JavaScript逆向可以分为三大部分:寻找入口、调试分析、模拟执行。下面我们来分别介绍。
寻找入口
:这是非常关键的一步,逆向在大部分情况下就是找一些加密参数到底是怎么来的,比如请求中token、sign等参数到底是在哪里构造的,这个关键逻辑可能写在某个关键的方法里面或者隐藏在某个关键变量里面。一个网站加载了很多JavaScript文件,那么怎么从这么多JavaScript代码里面找到关键的位置,那就是一个关键问题。这就是寻找入口。
调试分析
:找到入口之后,比如说我们可以定位到某个参数可能是在某个方法里面执行的了,那么里面的逻辑究竟是怎样的,里面调用了多少加密算法,经过了多少变量赋值和转换等,这些我们需要先把整体思路搞清楚,以便于我们后面进行模拟调用或者逻辑改写。在这个过程中,我们主要借助于浏览器的调试工具进行调试分析,或者借助于一些反混淆工具进行代码的反混淆等。
模拟执行
:经过调试分析之后,我们差不多已经搞清楚整个逻辑了,但我们的最终目的还是写爬虫,怎么爬到数据才是根本,因此这里就需要对整个加密过程进行逻辑复写或者模拟执行,以把整个加密流程模拟出来,比如输入一些已知变量,调用之后我们就可以拿到一些token内容,再用合格token来进行数据爬取即可。
13.1 寻找入口
首先,我们来看下怎么寻找入口,其中包括查看请求、搜索参数、分析发起调用、断点、Hook等操作,下面我们来分别介绍一下。
13.1.1 查看请求
一般来说,我们都是先分析想要的数据到底是从哪里来的,比如说对于示例网站 https://spa6.scrape.center/,我们可以看到首页有一条条数据,如“霸王别姬”、“这个杀手不太冷”等,这些数据肯定是某个请求返回的,那它究竟是从哪个请求里面来的呢?我们可以先尝试搜索一下。打开浏览器开发者工具,打开Network面板,然后点击搜索按钮,比如这里我们就搜索“霸王别姬”这四个字,如图:
此时可以看到对应的搜索结果,点击搜索到的结果,我们就可以定位到对应的响应结果的位置。
找到对应的响应之后,我们也就可以顺便找到是哪个请求发起的了。
比如这里,我们就顺利找到想要的数据所对应的请求位置了,可以看到这是一个GET请求,同时还有一个token参数,我们可以在后面继续分析。
一般来说,我们可以通过这种方法来尝试寻找最初的突破口。如果这个请求带有加密参数,就顺着继续找下这个参数究竟是在哪里生成的,如果这个请求对应的参数甚至都没有什么加密参数,那么这个请求都可以直接模拟爬取了。
13.1.2 搜索参数
在上一步中,我们找到了最初的突破口,也就是关键请求是怎么发起的,带有什么加密参数,比如,在上面的例子中,我们发现这里有一个关键的加密参数token,那这又是怎么构造出来的呢?
一种简单有效的方法就是直接进行全局搜索。一般来说,参数名大多数情况下就是一个普通的字符串,比如这里就叫作token,那么这个字符串肯定隐藏在某个JavaScript文件里面,我们可以尝试进行搜索,也可以加冒号、空格、引号等来配合搜索。因为一般来说这个参数通常会配合一些符号一起出现,比如我们可以搜索:token、token:、“token”等。
在哪里搜索呢?我们可以直接利用浏览器调试面板的搜索功能,如图:
这是一个资源搜索的入口,比如可以搜索下载下来的JavaScript文件的内容,这里我们输入token来进行搜索,结果如图:
这样我们就可以找到一些关键的位置点了,一共五个结果,结果不多,我们可以进一步点击并定位到对应的JavaScript文件中,然后进一步进行分析。
13.1.3 分析发起调用
上述的搜索是其中一种查找入口的方式,这是从源码级别上直接查找。当然,我们也可以通过其他的思路来查找入口,比如可以查看发起调用的流程,怎么查看呢?
可以直接从Network请求里面的Initiator查看当前请求的相关逻辑,如图:
右侧显示了一步步调用对应的源码的位置,我们可以顺次点进去找到对应的位置,比如这里第8层调用,里面有个 onFetchData 方法。点击右侧的代码位置,就可以找到一些相关的逻辑,如图:
这里可以看到一些 token 相关的逻辑调用过程了。
13.1.4 断点
另外,我们还可以通过断点来进行入口的查找,比如XHR断点,DOM断点,事件断点等。
我们可以在开发者工具Sources面板里面调价设置,比如我们之前添加了XHR断点,如图:
这样网页就可以在发起Ajax请求的时候停下来,进入断点调试模式,也就是说,通过浏览器断点调试功能,我们也可以找到到对应的入口。
13.2 调试分析
找到对应的入口位置之后,接下来我们就需要进行调试分析了。在这个步骤中,我们通常需要进行一些格式化、断点调试、反混淆等操作来辅助整个流程的分析。
13.2.1 格式化
格式化这个流程是非常重要的,它可以大大增加代码的可读性,一般来说很多JavaScript代码都是经过打包和压缩的,多数情况下,我们可以使用Sources面板下的JavaScript窗口左下角的格式化按钮对代码就行格式化,如图:
13.2.2 断点调试
代码格式化之后,我们就可以进入正式的调试流程了,基本操作就是给想要调试的代码添加断点,同时在对应的面板里面观察对应变量的值。
如图所示:我们在190行添加断点,然后逐行运行对应的代码,这时代码页面就会出现对应变量的值,同时我们也可以在Watch面板上监听关注的变量。
通过这样的方式,我们就可以对整个代码流程有一个大致的了解。
13.2.3 反混淆
在某些情况下,我们还有可能遇到一些混淆的方式,此时通过之前章节所讲的反混淆技术还原代码即可。
13.3 模拟执行
经过一系列调试,现在我们已经可以理清其中的逻辑了,接下来就是一些调用执行的过程了。在前面的章节中,我们已经讲过一些案例执行的流程了。
13.3.1 python改写或者模拟执行
由于python简单易用,同时也能够模拟调用执行JavaScript。如果整体逻辑不复杂的话,我们可以尝试使用python来把整个加密流程完整实现一遍。如果整体流程相对复杂,我们可以尝试使用python来模拟调用JavaScript的执行。
13.3.2 JavaScript模拟执行+API
由于整个逻辑是JavaScript实现的,使用python来执行JavaScript难免会有一些不太方便的地方。
而node.js天生就有对javaScript的支持。为了更通用地实现JavaScript的模拟调用。我们可以用express来模拟调用JavaScript,同时将其暴露成一个API,从而实现跨域语言的调用。
13.3.3 浏览器模拟执行
由于整个逻辑是运行在浏览器里面的,我们当然也可以将浏览器当作整个执行环境,比如使用Selenium等工具尝试执行一些JavaScript代码,得到一些返回结果。
调用的方式有很多,不同的情况我们可以根据实现的难以程度来选择不同的方案。
原创 猿榜编程