使用 Python 模拟执行 JavaScript
前面我们了解了一些 JavaScript 逆向的调试技巧,通过这些方法,我们可以找到一些突破口,进而找到关键的方法定义。
比如说,通过一些调试,我们发现加密参数 token 是由 encrypt 方法产生的。如果里面的逻辑相对简单的话,那么我们可以用 Python 完全重写一遍。但是现实情况往往不是这样的,一般来说,一些加密相关的方法通常会引用一些相关标准库,比如说 JavaScript 就有一个广泛使用的库,叫作 crypto-js,这个库实现了很多主流的加密算法,包括对称加密、非对称加密、字符编码等。比如对于 AES 加密,通常我们需要输入待加密文本和加密密钥,实现如下:
const ciphertext = CryptoJS.AES.encrypt(message, key).toString();
对于这样的情况,我们其实没法很轻易地完全重写一遍,因为 Python 中并不一定有和 JavaScript 完全一样的类库。
那么,有什么解决方法吗?有的,既然 JavaScript 已经实现好了,那么我们 Python 直接模拟执行这些 JavaScript 得到结果不就好了吗?
本节中,我们就来了解使用 Python 模拟执行 JavaScript 的解决方案。
案例引入
这里我们先看一个和上文描述的情形非常相似的案例,链接是 https://spa7.scrape.center/ ,如图 11-69 所示。
这是一个 NBA 球星网站,用卡片的形式展示了一些球星的基本信息。另外,每张卡片上其实都有一个加密字符串,这个加密字符串其实和球星的信息是有关联的,并且每个球星的加密字符串也是不同的。
所以,这里我们要做的就是找出这个加密字符串的加密算法并用程序把加密字符串的生成过程模拟出来。
准备工作
本节中,我们需要使用 Python 模拟执行 JavaScript,这里我们使用的库叫作 PyExecJS。我们使用 pip3 命令安装它,具体如下:
pip3 install pyexecjs
PyExecJS 是用于执行 JavaScript 的,但执行 JavaScript 的功能需要依赖 JavaScript 运行环境,所以除了安装好这个库之外,我们还需要安装一个 JavaScript 运行环境,个人比较推荐的是 Node.js。更加详细的安装和配置过程可以参考: https://setup.scrape.center/pyexecjs 。
PyExecJS 库在运行时会检测本地 JavaScript 运行环境来实现 JavaScript 执行,做好如上准备工作之后,接着我们运行代码检查一下运行环境:
import execjs
print(execjs.get().name)
运行结果类似如下:
Node.js (V8)
如果你成功安装好 PyExecJS 库和 Nodejs 的话,其结果就是 Node.js(V8)。当然,如果你安装的是其他的 JavaScript 运行环境,结果也会有所不同。
分析
接下来,我们就对这个网站稍作分析。打开 Sources 面板,我们可以非常轻易地找到加密字符串的生成逻辑,如图 11-70 所示。
图 11-70 Sources 面板
首先,声明一个球员相关的列表,如:
const players = [
{
name: '凯文-杜兰特',
image: 'durant.png',
birthday: '1988-09-29',
height: '208cm',
weight: '108.9KG'
}
...
]
然后对于每一个球员,我们调用加密算法对其信息进行加密。我们可以添加断点看看,如图 11-71 所示。
图11-71 添加断点
可以看到,getToken 方法的输入就是单个球员的信息,就是上述列表的一个元素对象,然后 this.key 就是一个固定的字符串。整个加密逻辑就是提取球员的名字、生日、身高、体重,接着先进行 Base64 编码,然后进行 DES 加密,最后返回结果。
加密算法是怎么实现的呢?其实就是依赖了 crypto-js 库,使用 CryptoJS 对象来实现的。
那么,CryptoJS 这个对象是哪里来的呢?总不能凭空产生吧?其实这个网站就是直接引用了 crypto-js 库,如图11-72所示。
图11-72 crypto-js 库对应的网络请求
执行 crypto-js 库对应的这个 JavaScript 文件之后,CryptoJS 就被注入浏览器全局环境下,因此我们门就可以在别的方法里直接使用 CryptoJS 对象里的方法了。
模拟调用
既然这样,我们要怎么模拟呢?下面我们来实现一下。
首先,我们要模拟的其实就是这个 getToken 方法,输入球员相关信息,得到最终的加密字符串。这里我们直接把 key 替换掉,把 getToken 方法稍微改写一下,具体如下:
function getToken(player){
let key = CryptoJS.enc.Utf8.parse("fipffVsZsTda94hJNKJfIloaqygMZFFImwlt");
const { name, birthday, height, weight } = player;
let base64Name = CryptoJS.enc.Base64.stringify(CryptoJS.enc.Utf8.parse(name));
let encrypted = CryptoJS.DES.encrypt(
`${base64Name}${birthday}${height}${weight}`,
key,
{
mode: CryptoJS.mode.ECB,
padding: CryptoJS.pad.Pkcs7,
}
);
return encrypted.toString();
}
因为这个方法的模拟执行需要 CryptoJS 这个对象,如果我们直接调用这个方法,肯定会报 CryptoJS 未定义的错误。
怎么办呢?我们只需要再模拟执行一下刚才看到的 crypto-js.min.js 不就好了吗?
因此,我们需要模拟执行的内容就是以下两部分。
-
模拟运行
crypto-js.min.js里面的 JavaScript,用于声明 CryptoJS 对象。 -
模拟运行
getToken方法的定义,用于声明getToken方法。
接下来,我们就把 crypto-js.min.js 里面的代码和上面 getToken 方法的代码复制一下,都粘贴到一个 JavaScript 文件里面,比如就叫做 crypto.js。
接下来,我们就用 PyExecJS 模拟执行一下,代码如下:
import execjs
import json
item = {
'name': '凯文-杜兰特',
'image': 'durant.png',
'birthday': '1988-09-29',
'height': '208cm',
'weight': '108.9KG'
}
file = 'crypto.js'
node = execjs.get()
ctx = node.compile(open(file).read())
js = f"getToken({json.dumps(item, ensure_ascii=False)})"
print(js)
result = ctx.eval(js)
print(result)
这里我们单独定义了一位球员的信息,并将其赋为 item 变量。然后使用 execjs 的 get 方法获取 JavaScript 执行环境,赋值为 node。
接着,我们调用 node 的 compile 方法,这里给它传入刚才定义的 crypto.js 文件的文本内容。compile 方法会返回一个 JavaScript 的上下文对象,我们将其赋给 ctx。执行到这里,其实就可以理解为,ctx 对象里面就执行过了 crypto-js.min.js,CryptoJS 就声明好了,然后紧接着 getToken 方法的声明代码也执行了,所以 getToken 方法也定义好了,相当于完成了一些初始化工作。
接着,我们只需要定义我们想要执行的 JavaScript 代码。我们定义了一个 js 变量,其实就是模拟调用了 getToken 方法并传入了球员信息。打印 js 变量的值,内容如下:
getToken({"name": "凯文-杜兰特", "image": "durant.png", "birthday": "1988-09-29", "height": "208cm", "weight": "108.9KG"})
其实这就是一个标准的 JavaScript 方法调用的写法而已。
接着,调用 ctx 对象的 eval 方法并传入 js 变量,其实就是模拟执行这句 JavaScript 代码,照理来说最终返回的就是加密字符串了。
然而,运行之后,我们可能看到这个报错:
execjs._exceptions.ProgramError: ReferenceError: CryptoJS is not defined
很奇怪,CryptoJS 未定义?我们明明执行过 crypto-js.min.js 里面的内容了呀?
问题其实出在 crypto-js.min.js,可以看到其中声明了一个 JavaScript 的自执行方法,如图 11-73 所示。
图11-73 JavaScript 的自执行方法
什么是自执行方法呢?就是声明了一个方法,然后紧接着调用执行。我们可以看下这个例子:
!(function(a,b){console.log('result', a, b)})(1,2)
我们先声明了一个 function, 它接收 a 和 b 两个参数, 然后把内容输出出来,接着我们把这个 function 用小括号括起来。这其实就是一个方法, 它可以被直接调用。怎么调用呢? 后面再跟上对应 的参数就好了。比如传入 1 和 2, 执行结果如下:
result 1 2
可以看到, 这个自执行方法就被执行了。
同理, crypto-js.min.js 也符合这个格式, 它接收 t 和 e 两个参数, t 就是 this, 其实就是浏览器 中的 window 对象, e 就是一个 function (用于定义 CryptoJS 的核心内容)。
我们再来观察下 crypto-js.min.js 开头的定义:
"object" == typeof exports ? (module.exports = exports = e()) : "function" == typeof define && define.amd ? define([], e) : (t.CryptoJS = e());
在 Node.js 中, 其实 exports 用来将一些对象的定义导出, 这里 "object" == typeof exports 的结果其实是 true, 所以就执行了 module.exports = exports = e() 这段代码, 这相当于把 e() 作为整 体导出, 而这个 e() 其实就对应后面的整个 function。function 里面又定义了加密相关的各个实现, 其实就指代整个加密算法库。
但是在浏览器中, 其结果就不一样了, 浏览器环境中并没有 exports 和 define 这两个对象。所以, 上述代码在浏览器中最后执行的就是 t.CryptoJS = e() 这段代码, 其实这里就是把 CryptoJS 对象挂载 到 this 对象上面, 而 this 就是浏览器中的全局 window 对象, 后面就可以直接用了。如果我们把代码 放在浏览器中运行, 那没有任何问题。
然而, 我们使用的 PyExecJS 是依赖于一个 Node.js 执行环境的, 所以上述代码其实执行的是 module.exports = exports = e(), 这里面并没有声明 CryptoJS 对象, 也没有把 CryptoJS 挂载到全局 对象里面, 所以我们在调用 CryptoJS 就自然而然出现了未定义的错误了。
怎么办呢? 其实很简单, 直接声明一个 CryptoJS 变量, 然后手动声明一下它的初始化不就好了 吗? 所以我们可以把代码稍作修改, 改成如下内容:
var CryptoJS;
! (function (t, e) {
CryptoJS = e();
"object" == typeof exports
? (module.exports = exports = e())
: "function" == typeof define && define.amd
? define([], e)
: (t.CryptoJS = e());
}(this,
function () {
//...
}));
这里我们首先声明了一个 CryptoJS 变量, 然后直接给 CryptoJS 变量赋值 e(), 这样就完成了 CryptoJS 的初始化。
这样我们再重新运行刚才的 Python 脚本, 就可以得到执行结果:
gQ5feqLDQjKAZHHyTzRX/exviwbOj73b2cjXvy6Pez3rQw6sQsL2w==
这样我们就成功得到了加密字符串了, 和示例网站上显示的一模一样, 这样我们就成功模拟 JavaScript 的调用完成了某个加密算法的运行过程。