【前端逆向】崔庆才前端逆向教程-python执行js目前的解决方案:自己实现微型pyexecjs

1,325 阅读6分钟

环境

  1. Windows10(默认编码未修改)。
  2. js文件保存编码均为utf-8。
  3. 本文要求先安装node,这也是pyexecjs的要求。本机node版本:v16.13.2

初遇问题

文章链接:juejin.cn/post/708640…

作者:hans774882968以及hans774882968

参考链接3提出了python执行js的一个解决方案:pyexecjs。这个库已经停止维护了,不建议使用。pyv8、js2py也弃用。

本文的目标:跑参考链接3的demo。遗憾的是,在此过程中我遇到了编码问题。查阅百度知:系统默认编码和js文件保存的编码不一致,而pyexecjs生成temp文件用的是系统默认编码。

我一不想改js文件保存编码,二不想改系统默认编码(似乎会有些小麻烦)。于是试着去改了改pyexecjs源码,失败。

一筹莫展之时,我看到了参考链接2。顺着他的思路,我们为什么不用node命令去运行js文件,然后拿输出呢?于是我写了如下代码,试图直接从输出中拿结果(用到subprocess模块):

def demo1(goals):
    crypto_js = 'crypto.js'
    p = subprocess.Popen(
        ['node',crypto_js],
        shell=True,
        stdout=subprocess.PIPE,
        stderr=subprocess.STDOUT,
        encoding='utf-8'
    )
    res = p.communicate()[0]
    res = '\n'.join(res.split('\n')[1:])
    print(res)
    o = json.loads('{"res": %s}' % res.replace("'",'"'))
    want = o["res"]
    assert len(want) == len(goals)
    for i in range(len(want)):
        assert want[i] == goals[i]

这个代码虽然能跑通参考链接3的demo,但实在太丑。我又看了看pyexecjs源码,觉得其实现思路并不复杂。于是我有了这样的想法:自己实现一个微型的pyexecjs,只适配自己的开发环境,从而大大减少工作量。

pyexecjs源码初步阅读笔记

让我们首先看看pyexecjs的用法(代码来自参考链接3):

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)
  • node.compile读入上下文的代码。
  • ctx是一个Context对象,Context_external_runtime.py定义。

pyexecjs处理了多种JS引擎,但我们只查看与node有关的。

首先自然是看__init__.py

get = execjs._runtimes.get

def compile(source, cwd=None):
    return get().compile(source, cwd)
compile.__doc__ = AbstractRuntime.compile.__doc__

故阅读_runtimes.py。发现get()返回的是一个ExternalRuntime对象。故阅读_external_runtime.py,不难发现它最终是通过运行node命令,进入交互模式(与python类似),进而执行_runner_sources.Node(在_runner_sources.py)的代码

Node = r"""(function(program, execJS) { execJS(program) })(function() { #{source}
}, function(program) {
  var output;
  var print = function(string) {
    process.stdout.write('' + string + '\n');
  };
  try {
    result = program();
    print('')
    if (typeof result == 'undefined' && result !== null) {
      print('["ok"]');
    } else {
      try {
        print(JSON.stringify(['ok', result]));
      } catch (err) {
        print('["err"]');
      }
    }
  } catch (err) {
    print(JSON.stringify(['err', '' + err]));
  }
});"""

其中#{source}被替换为包含了上下文(ctx = execjs.get().compile(open(file).read()),对应Contextself._source)和单行表达式(ctx.eval的输入参数source)的代码。

#{source}生成代码的大致方式是self._source + '\n' + return eval('(' + json.dumps(source, ensure_ascii=True) + ')'),仅支持单行表达式。

执行完毕后,通过_external_runtime.pyContext._extract_result提取输出。

Context._exec_不难看出,执行ctx = node.compile(open(file).read())时并未执行上下文的代码,而每次执行ctx.eval都要重新执行一遍上下文的代码。对此我们暂时没有优化的办法。

实现

已开源至GitHub

myexecjs.py就是微型的pyexecjs,main.py用来完成参考链接3的demo。使用方式可参考test.pymain.py,与pyexecjs类似。

  • myexecjs.pynode_wrap_runner_sources.Node(在上文)改动而来,新增了对BigInt的支持。
  • pyexecjs用了js的eval函数,且仅支持单行代码。我认为只支持单行表达式时不需要用到js的eval函数,详见GitHub链接(若理解有误,还请佬佬们指出~)。
  • 增加了多行支持(感觉是没啥用的功能?),用use_js_eval选项开启。
  • 原本想支持Promise,但感觉到这个任务十分困难,遂作罢。

已知的问题:

  • eval传入多行代码的情况,最后一行为对象字面量时不能正确返回对象。如let a = 1;\n{a: a}不能返回对象。但一行代码的情况,用了人工添加括号的方式修复,可以正确返回,如:{a: 1}可正确返回对象。建议使用eval且需要执行多行代码时,自行为最后一行的表达式添加括号,如let a = 1;\n({a: a})
  • 为了实现的简洁,不再强行兼容es5及以下版本。
  • 效率问题挺严重的。能用就行

旧版本实现多行支持时用到反引号,详见GitHub链接的历史提交。这种实现会带来不少问题。新版本则改用如下方式实现:源代码在python层编码为utf-8,在运行时解码获得待执行的源代码。这种实现比旧版本好得多,但也有如下问题:

  • use_js_eval选项开启后,某些表达式执行结果不正确。如:ctx.eval('let a = 1\n{}',use_js_eval = True)应返回空字典,却返回None。这是因为js使用eval('{}')返回undefined。我用人工添加括号的方式,修复了一行代码的情况。但由于多行代码的情况下,判定表达式的开头和结尾,并人工添加括号,是极其困难的工作,故我选择了放弃。
  • 虽然本机使用没遇到,但仍可能存在未知的编码问题。

tips:

  • 建议使用eval且需要执行多行代码时,自行为最后一行的表达式添加括号,如let a = 1;\n({a: a})
  • main.py是demo1,用到crypto.jstest.py是demo2,用到utf8_code_demo.js

myexecjs.py

import subprocess
import json

class ProcessExitedWithNonZeroStatus(Exception):
    def __init__(self, status, stdout, stderr):
        self.status = status
        self.stdout = stdout
        self.stderr = stderr

    def __str__(self):
        return f'Process exited with status {self.status}\n{self.stdout}\n{self.stderr}'

class ProgramError(Exception):
    def __init__(self,v):
        self.v = v

    def __str__(self):
        return f'Program error: {self.v}'

node_wrap = lambda original,cur: """
(function (expression) {
  try {
    let result = expression();
    console.log('');
    if (typeof result == 'undefined' && result !== null) {
      console.log('["ok"]');
    } else {
      try {
        if (typeof result === 'bigint') result = '' + result;
        console.log(JSON.stringify(['ok', result]));
      } catch (err) {
        console.log('["err"]');
      }
    }
  } catch (err) {
    console.log(JSON.stringify(['err', '' + err]));
  }
})(function () {
%s
  ;return %s
})
""" % (original,cur)

def js_encodeURIComponent(b):
    ans = ''
    for c in b:
        ans += '%%%s' % (('00' if not c else ('0' if c < 16 else '')) + hex(c)[2:])
    return ans

class JsContext():
    def __init__(self,source = ''):
        self._source = source

    def get_inp(self,source,use_js_eval):
        if not source.strip():
            if use_js_eval: source = "eval('')"
            else: source = 'undefined'
        elif use_js_eval:
            # 给单行且使用eval的情况加上括号,如eval('({})'),多行情况弃疗
            if '\n' not in source:
                source = f'({source})'
            source_utf8 = js_encodeURIComponent(source.encode(encoding = 'utf-8'))
            # print('?????????',source_utf8)#
            source = 'eval(decodeURIComponent("%s"))' % (source_utf8)
        return node_wrap(self._source,source)

    def eval(self,source,use_js_eval = False):
        try:
            p = subprocess.Popen(
                ['node'],
                stdin = subprocess.PIPE,
                stdout = subprocess.PIPE,
                stderr = subprocess.PIPE,
                encoding = 'utf-8'
            )
            inp = self.get_inp(source,use_js_eval)
            res,stderrdata = p.communicate(input = inp)
            status = p.wait()
        finally:
            del p
        if status != 0:
            raise ProcessExitedWithNonZeroStatus(status = status, stdout = res, stderr = stderrdata)
        return self._extract_result(res)

    def _extract_result(self, output):
        output = output.replace("\r\n", "\n").replace("\r", "\n")
        output_last_line = output.split("\n")[-2]
        ret = json.loads(output_last_line)
        if len(ret) == 1:
            ret = [ret[0], None]
        status, value = ret
        if status == "ok":
            return value
        else:
            raise ProgramError(value)

test.py

from myexecjs import JsContext,js_encodeURIComponent

def simple_multilines(ctx):
    print('simple_multilines::')
    # 简单的多行文本1
    res = ctx.eval('''
    let a = new A(`w${String.fromCharCode(97 + 18) + `w`}`,22);
    [a.name,a.age] = ['_' + a.name,a.age + 1];
    a
    ''',use_js_eval = True)
    print(res,type(res))
    print(res['name'],res['age'])
    assert type(res) == dict and res['name'] == '_wsw' and res['age'] == 23
    # 简单的多行文本2
    res = ctx.eval('''
    let v = add + '\\n'
    let w = add1 + '\\n' + A
    v + w
    ''',use_js_eval = True)
    print(res,type(res))

def combine_test():
    print('combine_test::')
    ctx = JsContext()
    res = ctx.eval('''
    const N = %s
    let C = Array.from({length: N},() => Array(N).fill(0))
    for(let i = 0;i < N;++i){
        C[i][0] = 1
        for(let j = 1;j <= i;++j){
            C[i][j] = C[i-1][j] + C[i-1][j-1]
        }
    }
    C
    ''' % (11),use_js_eval = True)
    for i in range(len(res)): print(res[i][:i+1])

def use_js_eval_test():
    print('use_js_eval_test::')
    ctx = JsContext()
    res = ctx.eval('1 + "\\n" + 2',use_js_eval = True)
    print(res,type(res))
    assert res == '1\n2'
    res = ctx.eval('1 + "\\n" + 2',use_js_eval = False)
    print(res,type(res))
    assert res == '1\n2'
    res = ctx.eval('1 + "\\t" + 2',use_js_eval = True)
    print(res,type(res))
    assert res == '1\t2'
    res = ctx.eval('1 + "\\t" + 2',use_js_eval = False)
    print(res,type(res))
    assert res == '1\t2'

def empty_test():
    print('empty_test::')
    ctx = JsContext()
    for fl in [False,True]:
        print(f'use_js_eval = {fl}::')
        res = ctx.eval('',use_js_eval = fl)
        print(res,type(res))
        assert res is None
        res = ctx.eval('{}',use_js_eval = fl)
        print(res,type(res))
        assert type(res) == dict
        res = ctx.eval("{a: %s + '4'}" % 123,use_js_eval = fl)
        print(res,type(res))
        assert type(res) == dict and res['a'] == '1234'
        res = ctx.eval('""',use_js_eval = fl)
        assert res == ''
        res = ctx.eval('+0',use_js_eval = fl)
        assert res == 0
        res = ctx.eval('-0',use_js_eval = fl)
        assert res == 0
        res = ctx.eval('null',use_js_eval = fl)
        assert res is None
        res = ctx.eval('undefined',use_js_eval = fl)
        assert res is None
        res = ctx.eval('Date()',use_js_eval = fl)
        print(res,type(res))

def utf8_code_demo():
    print('utf8_code_demo::')
    code = open('utf8_code_demo.js',encoding = 'utf-8').read()
    ctx = JsContext('let 变量 = 34')
    res = ctx.eval(code,use_js_eval = True)
    print(res)
    assert type(res) == float and abs(res - 200 - 2 / 3) < 1e-10

def test_js_encodeURIComponent():
    w = '''let v = add + '\\\\n'中文
    let w = add1 + '\\\\n' + A'''
    js_encodeURIComponent(w.encode(encoding = 'utf-8'))
    w = '未然形 打た 打とう 強かろう 勇敢だろう 连用形 打(う)ち 打って 強(つよ)く 勇敢(ゆうかん)に 终止形 打つ 強い 勇敢だ 连体形 打つ 強い 勇敢な 假定形 打てば 強ければ 勇敢なら(ば) 命令形 打て'
    js_encodeURIComponent(w.encode(encoding = 'utf-8'))

def main():
    test_js_encodeURIComponent()

    ctx = JsContext('''
    function add1(a,b){return `wsw${a+b+10}`}
    function add(...args){return args.reduce((s,v) => s + v,0)}
    class A{
        constructor(name,age){
            this.name = name
            this.age = age
        }
    }
    ''')
    for fl in [False,True]:
        print(f'use_js_eval = {fl}::')
        res = ctx.eval('add1(10,10)',use_js_eval = fl)
        print(res,type(res))
        assert res == 'wsw30'
        res = ctx.eval('BigInt(99 + 1) ** 9n + BigInt(100 * 2) ** 9n',use_js_eval = fl)
        print(res,type(res))
        assert res == '513000000000000000000'
        res = ctx.eval('add() + add(10,20,30,40)',use_js_eval = fl)
        print(res,type(res))
        assert res == 100
        # backquote
        print('backquote::')
        res = ctx.eval('`ha${`n` + String.fromCharCode(97 + 18)}`',use_js_eval = fl)
        print(res,type(res))
        assert res == 'hans'
        res = ctx.eval('new A(`w${String.fromCharCode(97 + 18)}w`,22)',use_js_eval = fl)
        print(res,type(res))
        print(res['name'],res['age'])
        assert res['name'] == 'wsw' and res['age'] == 22

    simple_multilines(ctx)
    # 不支持Promise
    # res = ctx.eval('new Promise((res) => {res(10)}).then((v) => v++)')

    combine_test()
    use_js_eval_test()
    empty_test()
    utf8_code_demo()

if __name__ == '__main__':
    main()

main.py

from myexecjs import JsContext
import json
import subprocess

def main():
    goals = [
        'DG1uMMq1M7OeHhds71HlSMHOoI2tFpWCB4ApP00cVFqptmlFKjFu9RluHo2w3mUw',
        '3oimklW/W/ngYkFCAre5DPS4f/d4s9wsxAx+vxTeWY7Ab9AneJtN7AJr9elx7PLpSpxFUXsd0t0=',
        'sZkeRg+OqU4406ZO5EhpRUqsg9QS9dz4BANdNgmpsFVYJjNWn0k61E3lrj05r5EC',
        'evDiDnPBGwIxe3cjYPXB22oKj049pxLL3RfgIp5P9hT+tOtrsevTx29K9aaBj2Ds',
        'o6vkqk+dSV8jKFxlrQPB6S9GITUH4Fx8Kf4EI49OrTgM6c2ehRvua4osLn6iczH/IDcOr0DuFV9KnEVRex3S3Q==',
        'rR95BGefawHYyhz4VPfk6bkheavfgJcELQbGSxEWhB2Sna9EyCwZXhYyvHpf0X7NW3p0MRuPhYdE3KC6Bu3I5A==',
        'nTinqg2MTz6HPcn5po2mvK2hFB6CtN1VNYo3b7BUgrO7cZU7JmI4KETcoLoG7cjk',
        'SZrUq97bIKMT8A8+5poINKp4LvkMho6D22h7QAQdeaVt7lwUBMrzbKGsn4CWgWISSpxFUXsd0t0=',
        'jM2AMHWEGR7XPzcS9TXbFvWk3egklebrLpy4PGOJ2Dgp6gEMy+K7RC1pQBjov5z5',
        'ZbCyikQGss8IGCV14Utz07yC+ltW38oRRtagvPsNby+W5le06WT0SiAI79ae3rHi',
        '4QkDpTPjrgZdr6Mru2jvaxxik7n7Hs3GqA5xX/nVNMnpMvNi8r7WyLUe5gqPTte0+S7G053mkfc=',
        'LpC7uqNRalkxczZr61omV8D/Czy6GJR/0N56qHW8addmWsnr1LQ0VrtxlTsmYjgoRNygugbtyOQ=',
        'RsRBspJvKRto1876nMhHgh3Z6/9buixxfJZedby46Ydoywsc1boyIK4crMvnVmS2OS/I7URLXdw=',
        'OMHyOG8pDj5TH2yMM3zYJtdXdArhLFhp0ArpmcdGlYm87BsE44L1EOVOWatR++TjSpxFUXsd0t0=',
        '6C03P2Jzlff3i0amcVIMM/TdZLftH7b1xUuVHeeTaFlpMOkB0yHNHBbu7Z8EMDtMUVOJ3gkHn5I=',
        'TtulIkS1gMCQiGTImQtOZbPg/A766KPWnRe4RhZ6YxITyudvx3kTgiR0UGwcAEzr',
        'JWGteWDj2RBfHGC0jr8quHNUoJyrK2yIallL/iGyNwk=',
    ]
    crypto_js = 'crypto.js'
    ctx = JsContext(open(crypto_js,encoding = 'utf-8').read())

    hans = {
        'name': 'hans',
        'image': 'hans.png',
        'birthday': '1995-02-28',
        'height': '183cm',
        'weight': '58.8KG'
    }
    res = ctx.eval(f"getToken({json.dumps(hans,ensure_ascii = False)})")
    print(res,type(res),len(res))

    players = ctx.eval('players')
    players.append(hans)
    want1 = []
    # 效率问题挺严重的
    for p in players:
        want1.append(ctx.eval(f'getToken({json.dumps(p,ensure_ascii = False)})'))
    print(want1)

    want2 = ctx.eval('players.map(p => getToken(p))')
    print(want2)
    want2.append(res)
    assert want1 == goals and want2 == goals

if __name__ == '__main__':
    main()

参考链接

  1. python的subprocess:www.cnblogs.com/lgj8/p/1213…
  2. Python调用nodejs现在建议的方法(弃用pyexecjs、pyv8、js2py):www.codenong.com/cs107102509…
  3. cuiqingcai.com/2022114.htm…