最新不是很忙,摸鱼整理一份 h5st 的签名算法 jsvmp 插桩法的纯算还原流程,焚诀爆出来了

5.2.2 补环境应该已经不行了,env不对会校验。

只做逆向经验分享,请勿用于非法用途,不提供完整成品
只做逆向经验分享,请勿用于非法用途,不提供完整成品
只做逆向经验分享,请勿用于非法用途,不提供完整成品
只做逆向经验分享,请勿用于非法用途,不提供完整成品
只做逆向经验分享,请勿用于非法用途,不提供完整成品

可以先在网上搜一搜原来老版本的h5st的代码,核心逻辑没有发生变化,只是后面加了两段,并且从ob混淆切换到ob混淆+jsvmp混淆。
如果想跟做,请先自备一份老版本的h5st解密代码。

能直接搜的到的成品应该大部分都是之前从我手里流出去的,之前csdn还有人直接拿我在tg群发的算法卖钱,图片都没改

前言

之前在bilibili直播过,然后发了一个多小时的视频,但是收到了京东安全实验室的邮件,都删除了,巅峰的时候提供的api服务日调用量几千万。
去年十一月份删除主仓库,解散频道。但是陆续还有不少人问我相关的东西(本论坛、tg、某火免流论坛),其实我早就放弃了京东的羊毛,只是默默维护,这里统一发一下思路和经验。
安全性的本文章只做经验分享,不提供任何成品。

1762148373303.png

这里只是补充一下jsvmp的处理方式,现在不需要处理ob混淆了,个人认为不需要解释的太详细,因为主逻辑没变,我的代码网上飘的到处都是,找到关键点进行插桩就好了。

然后还需要熟悉一下基本的加密算法特征与调用方式,保持敏感性。

逆向是一门非常吃经验的活,保持敏感再加上自己的经验有时候可以很轻松的判断出来关键地方。

最后这个jsvmp入手可能有点麻烦,其实代码特征特别明显,熟悉了小版本更新可以在十分钟之内搞定。大版本包括tk版本换了,大概也就半小时多

初识

目标:aHR0cHM6Ly9qZC5jb20=
作用:对于接口请求加签,应付反爬,这里还有一些细节,后续再说

1762148747618.png

该签名目前最新已经包含10部分:

  1. 时间戳转换yyyyMMddhhmmssSSS格式字符串,5.1.8开始会添加一个时间偏移量
  2. fp指纹
  3. appId,和同为请求体里面的appid不是同一个东西
  4. token,分为动态tk和localTk
    分为动态tk和本地tk,涉及MD5、SHA256、HmacSHA256
    • 动态tk是通过本地env环境信息、fp、h5st小版本请求request_algo,交换到的token,并且有携带的对应的加密key的对应算法
    • localTk是为加载到缓存时候的一种兜底策略,对应生成参与sign的key是将token、fp、时间戳、appId、一个扩展字符串(每个版本不一样)解析出一个算法调用逻辑,循环调用MD5、SHA256、HmacSHA256生成key。
  5. 业务请求参数和key的签名
  6. 当前h5st大版本
  7. 原始时间戳,和第一部分是关联的
  8. env web环境信息加密,这里有坑,注意
  9. 4.7.4新增 第二个子签名,也有key的参与,这个不太清楚逻辑,前几个版本刚加的时候和业务请求参数还有关系,目前为固定字符串签名,不清楚是不是京东程序员手滑了
  10. 5.1.3 新增 stk(参与签名参数key)的base64
    京东的虎符文档: https://yd-doc.jdcloud.com/docs/5-hufu/5-2-guanfang/waap.html

这里可以看看京东的文档,大概了解一下

拆解

这里先说一下需要逆向什么东西才能还原,这是事后总结了,放在前面可以节省时间,清晰思路。

  1. fp指纹:核心
  2. env环境信息和加密:核心
  3. localTk:原先版本是可选的,可以跳过直接使用动态tk,但是现在看交换动态tk也需要localTk了
  4. 魔改算法还原:从4.7.1开始进行魔改,似乎也是从这个时候开始赚到jsvmp,之前是纯ob混淆的。

算法入口

某东的任意网页,F12打开console,输入 window.ParamsSign,也有可能是window.ParamsSignLite,之前他们有区别,不带Lite是会请求动态tk,使用localTk兜底,带Lite的是纯localTk,但是现在好像都一样了,没什么区别了。

1762148925585.png

单击下面的代码进入算法主逻辑,然后右击override进行随时修改

1762148952148.png

文件内搜索:debug = ,修改成true,进行打印京东自动的调试日志

1762148984683.png

虽然现在jsvmp后,函数名都已经看不出来了,但是可以根据代码特征判断出来初始化函数。刷新页面后看到console打印出来大量日志即为成功。

1762149025899.png

算法流程

这里根据之前ob混淆的方法名称进行说明。

最简请求上下文(正常情况)

参数说明
appid唯一标识
functionId接口标识
body业务请求参数、签名阶段会先做 SHA256 哈希,原始明文不直接参与签名
h5st安全签名结果
  1. __checkParams 按键名字典序排序,并逐项调用 isSafeParamValue 过滤空值、非法字符、过滤非_stk内的参数;若全部被丢弃直接抛错。并且做数据格式转换
  2. __requestDeps 获取token,这里只讨论localTk,动态tk请求接口不讨论。涉及到 token 的种子,随机取数长度异常总长度等信息。
  3. __collect 环境参数加密,提取当前页面的信息和环境信息,有时候会开验证,验证里面的小参,有时候不会
  4. __makeSign 加密流程
    • __genDefaultKey 生成参与签名的key
    • __genSign 通过请求体的参数与key进行签名
    • __genSignDefault 签名2,现在是每个版本不同的字符串与key进行签名
    • signStkStr stk base64,stk是参与签名的参数key , 拼接字符串
    • __genSignParams 拼接完成的h5st

老版本的相关代码可以在github上面搜一下,应该还有不少之前fork的,注释是让当时的gpt3.5写的,应该还挺详细的,配合上面的流程应该可以很清晰的过一遍。

jsvmp插桩实战

以一个的md5算法为例,京东自己魔改了,魔改的地方进行了vmp处理。其他的逻辑都是这样,一通百通.

1762149174135.png
1762149182238.png

这里截图不太好截图,可以对比一下原始的代码

https://github.com/brix/crypto-js/blob/develop/src/core.js#L537
https://github.com/brix/crypto-js/blob/develop/src/core.js#L653
https://github.com/brix/crypto-js/blob/develop/src/md5.js

可以看到md5加了_eData和_seData,其他是一致的,_eData搜一下,能看到是覆盖的BufferedBlockAlgorithm的_eData。_seData覆盖的是Hasher的,这样看几本上能大概猜到思路。

BufferedBlockAlgorithm的append也被vmp化了,这个append大概应该魔改了调用了_eData,但是md5有自己的逻辑,和其他的hash算法魔改的还不一样,他自己又重写了_eData和_seData。可以验证一下。

先写一个snippet,进行稳定的调用MD5算法

var test = new window.ParamsSign({'appId': "b5216", 'debug': false})
console.log(test._algos.MD5('123123').toString())

然后进行插桩,插桩之前得大概了解一下他这个vmp代码,非常简单:

1762149275286.png

执行流程:

┌────────────────────────────────────────────────────────┐
│                  VM执行流程图                           │
└────────────────────────────────────────────────────────┘

初始化阶段:
┌─────────┐
│ 初始化   │
│ q = 129 │  ← 指令指针(PC)
│ m = []  │  ← 操作数栈
└────┬────┘
     │
     ▼
┌─────────────────────────────────────────┐
│        主循环: for(;;)                   │
└─────────────────────────────────────────┘
     │
     ▼
┌─────────────────────────────────────────┐
│  读取指令: m[q++]                        │
│  (从_2a765数组中读取操作码)                │
└─────────────────────────────────────────┘
     │
     ▼
┌─────────────────────────────────────────┐
│      switch(操作码) {                    │
│        case 9: ...  // 操作              │
│        case 16: ...  // 操作             │
│        case 55: ... // 返回              │
│      }                                  │
└─────────────────────────────────────────┘
     │
     ├──► 栈操作 (push/pop)
     ├──► 变量赋值
     ├──► 函数调用
     ├──► 条件跳转
     └──► 返回值
case 9:  // ADD指令
    a = y.pop();
    y[y.length - 1] += a;
    
    栈变化: [a, b] → [a+b]

case 16:  // PUSH指令 压栈操作
    y.push(_$uu);
    
    栈变化: [] → [_$uu]

case 61:  // POP指令 出栈操作
    y.pop();
    
    栈变化: [a, b, c] → [a, b]

case 46:  // CALL指令
    if (y[y.length - 2] != null) {
        y[y.length - 3] = h.call(y[y.length - 3], y[y.length - 2], y[y.length - 1]);
        y.length -= 2;
    }
    
    栈变化: [obj, method, arg1] → [result]

case 74:  // JUMP指令 跳转到指定位置
    q += m[q];
    
    控制流: q=100 → q=150

case 74:  // BRANCH指令 条件转换
    if (y[y.length - 1])
        ++q;
        --y.length;
    else
        q += m[q];

比较简单的是,我们其实只需要关心 类似于 add、call这种,其他的对于push,pop几本上都是在攒参数,真正的逻辑代码应该都不在这里。那么我可完全可以在方法入口和return出口进行下断,发生值变动的操作进行下日志断点,最后观察入参、执行逻辑、出参进行还原逻辑,这就是vmp的插桩纯算,不过感觉这种操作对于程序员来说应该不是问题吧,会看日志都会反向思考的

右键下在关键地方下日志断点后执行snippet

1762149333214.png

观察日志:

1762149351150.png

棕色的框,发现多调用了一个方法,内容多了j|/3n8,这个也许就是vmp想要隐藏的,但是我们还需要重新确认一下。直接定位到魔改内容,点击调用的日志跳转到js代码。
跳转到MD5的_eData,按照上面流程重新处理:

1762149384009.png

似乎还有判断是否以 envCollect 结尾的逻辑,但是实际上没遇到过,所以我没有进行处理,有兴趣可以修改变量值进行走到相关逻辑进行还原。

这里似乎好像还是不能确定j|/3n8是不是固定字符串还是动态算出来的,上面怎么说的,参数都是push攒出来的,断一下push相关的代码判断可以代码进行调试。

1762149421223.png
1762149427838.png

这说明就是固定字符串了。
这下MD5就魔改的参数就处理好了。

是吗?等等等等,我记得我们的snippet脚本是传入的 1231234,但是append入参是1231234iM4A,所以前面还有一步漏了,重新在append入口下段

还好,调用链很短,基本上就是上一层了。

1762149458991.png
1762149463264.png

这里Hasher.finalize也魔改了,会判断参数是否是string类型,调用_seData进行进一步魔改,进入该方法继续处理。

1762149489941.png

啊嘞,空方法,还是判断是否以envCollect结尾,然后调用了另一个方法,添加的后缀,又进入了一个新方法,继续

1762149512830.png

可以看到就是方法加了后缀,这个日志有点长,截图接不下来,我贴在下面

入参: 123123
-4243 '+' 1697 '--->' -2546
-2546 '+' 2551 '--->' 5
6370 '+' 5143 '--->' 11513
11513 '+' -11491 '--->' 22
调用: ƒ (_$j, _$L) {
            _$j = _$j - (-0xb94 + -0x5e * -0x55 + -0x1295);
            var _$z = _$G[_$j];
            if (a0d2b23b.FqOFnt === undefined) {
                var _$B = function(_$w) {
   … 462
调用结果: utsrqponmlkjihgfedcbaZYXWVUTSRQPONMLKJIHGFEDCBA-_9876543210zyxwv
6 '/' 5 '--->' 1.2
调用: ƒ floor() { [native code] } Math {abs: ƒ, acos: ƒ, acosh: ƒ, asin: ƒ, asinh: ƒ, …} 1.2
调用结果: 1
-8909 '+' 3838 '--->' -5071
-5071 '+' 5071 '--->' 0
0 '<' 5 '--->' true
-5749 '+' -9900 '--->' -15649
-15649 '+' 15649 '--->' 0
0 '*' 1 '--->' 0
-322 '+' 2199 '--->' 1877
1877 '+' -1876 '--->' 1
5 '-' 1 '--->' 4
0 '===' 4 '--->' false
-3925 '+' -6564 '--->' -10489
-10489 '+' 10489 '--->' 0
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 0 1
调用结果: true
0 '+' 0 '--->' 0
0 '<' 6 '--->' true
调用: ƒ charCodeAt() { [native code] } 123123 0
调用结果: 49
0 '+' 49 '--->' 49
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 1 1
调用结果: false
49 '*' 22 '--->' 1078
1078 '%' 64 '--->' 54
调用: ƒ charAt() { [native code] } utsrqponmlkjihgfedcbaZYXWVUTSRQPONMLKJIHGFEDCBA-_9876543210zyxwv 54
调用结果: 4
调用: ƒ push() { [native code] } [] 4
调用结果: 1
1 '<' 5 '--->' true
-5749 '+' -9900 '--->' -15649
-15649 '+' 15649 '--->' 0
1 '*' 1 '--->' 1
-322 '+' 2199 '--->' 1877
1877 '+' -1876 '--->' 1
5 '-' 1 '--->' 4
1 '===' 4 '--->' false
-3925 '+' -6564 '--->' -10489
-10489 '+' 10489 '--->' 0
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 0 1
调用结果: true
1 '+' 0 '--->' 1
1 '<' 6 '--->' true
调用: ƒ charCodeAt() { [native code] } 123123 1
调用结果: 50
0 '+' 50 '--->' 50
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 1 1
调用结果: false
50 '*' 22 '--->' 1100
1100 '%' 64 '--->' 12
调用: ƒ charAt() { [native code] } utsrqponmlkjihgfedcbaZYXWVUTSRQPONMLKJIHGFEDCBA-_9876543210zyxwv 12
调用结果: i
调用: ƒ push() { [native code] } ['4'] i
调用结果: 2
2 '<' 5 '--->' true
-5749 '+' -9900 '--->' -15649
-15649 '+' 15649 '--->' 0
2 '*' 1 '--->' 2
-322 '+' 2199 '--->' 1877
1877 '+' -1876 '--->' 1
5 '-' 1 '--->' 4
2 '===' 4 '--->' false
-3925 '+' -6564 '--->' -10489
-10489 '+' 10489 '--->' 0
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 0 1
调用结果: true
2 '+' 0 '--->' 2
2 '<' 6 '--->' true
调用: ƒ charCodeAt() { [native code] } 123123 2
调用结果: 51
0 '+' 51 '--->' 51
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 1 1
调用结果: false
51 '*' 22 '--->' 1122
1122 '%' 64 '--->' 34
调用: ƒ charAt() { [native code] } utsrqponmlkjihgfedcbaZYXWVUTSRQPONMLKJIHGFEDCBA-_9876543210zyxwv 34
调用结果: M
调用: ƒ push() { [native code] } (2) ['4', 'i'] M
调用结果: 3
3 '<' 5 '--->' true
-5749 '+' -9900 '--->' -15649
-15649 '+' 15649 '--->' 0
3 '*' 1 '--->' 3
-322 '+' 2199 '--->' 1877
1877 '+' -1876 '--->' 1
5 '-' 1 '--->' 4
3 '===' 4 '--->' false
-3925 '+' -6564 '--->' -10489
-10489 '+' 10489 '--->' 0
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 0 1
调用结果: true
3 '+' 0 '--->' 3
3 '<' 6 '--->' true
调用: ƒ charCodeAt() { [native code] } 123123 3
调用结果: 49
0 '+' 49 '--->' 49
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 1 1
调用结果: false
49 '*' 22 '--->' 1078
1078 '%' 64 '--->' 54
调用: ƒ charAt() { [native code] } utsrqponmlkjihgfedcbaZYXWVUTSRQPONMLKJIHGFEDCBA-_9876543210zyxwv 54
调用结果: 4
调用: ƒ push() { [native code] } (3) ['4', 'i', 'M'] 4
调用结果: 4
4 '<' 5 '--->' true
-5749 '+' -9900 '--->' -15649
-15649 '+' 15649 '--->' 0
4 '*' 1 '--->' 4
-322 '+' 2199 '--->' 1877
1877 '+' -1876 '--->' 1
5 '-' 1 '--->' 4
4 '===' 4 '--->' true
6 '%' 5 '--->' 1
1 '+' 1 '--->' 2
-3925 '+' -6564 '--->' -10489
-10489 '+' 10489 '--->' 0
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 0 2
调用结果: true
4 '+' 0 '--->' 4
4 '<' 6 '--->' true
调用: ƒ charCodeAt() { [native code] } 123123 4
调用结果: 50
0 '+' 50 '--->' 50
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 1 2
调用结果: true
4 '+' 1 '--->' 5
5 '<' 6 '--->' true
调用: ƒ charCodeAt() { [native code] } 123123 5
调用结果: 51
50 '+' 51 '--->' 101
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 2 2
调用结果: false
101 '*' 22 '--->' 2222
2222 '%' 64 '--->' 46
调用: ƒ charAt() { [native code] } utsrqponmlkjihgfedcbaZYXWVUTSRQPONMLKJIHGFEDCBA-_9876543210zyxwv 46
调用结果: A
调用: ƒ push() { [native code] } (4) ['4', 'i', 'M', '4'] A
调用结果: 5
5 '<' 5 '--->' false
调用: ƒ join() { [native code] } (5) ['4', 'i', 'M', '4', 'A'] 
调用结果: 4iM4A
123123 + 4iM4A ---> 1231234iM4A
a[0]
'1231234iM4A'

此处静止一分钟,考考大家能分出来是什么逻辑吗?

相信以大家的编码能力仔细点还是能分辨出来大概逻辑的。

首先看头和结尾都有判断是否 < 5的逻辑判断,最终生成的后缀也是5位,合理判断这是一个循环,搜索一下。

1762149558684.png
1762149564172.png

结合开头的有一个6 / 5也很可以,5大概可以判断也是for循环,这个6是什么,巧了这不是,入参是123123,长度正好是6,不确定,放行重新构造一个别的字符串看看是不是入参字符串的长度(只有字符串才会进入这个方法,如果是 WordArray 类型是不会进入的)

现在可以大概写一个初始代码

function transformMessage(plainText, options) {  
    // segments 是循环次数
    var segments = options.segments;
  
    var segmentLength = Math.floor(plainText.length / segments);

    for (var i = 0; i < segments; i++) {
        
    }
    
    var checkString = transformedSegments.join('');
    return plainText + checkString;
}

然后再看for循环里面是什么,结合5个for循环的日志进行还原。

1762149616322.png

可以看到代码流程相似度主要分为两块:
首先都是 一个数字 * 1,这个数字还是递增的,应该是for循环的当前位置。
5 - 上面*的结果,判断是否等于4,如果不是4是一个逻辑,是4是另外一个逻辑
这里的1合理怀疑是上面 入参长度 / 循环步数 floor的值,可以用控制变量法,多尝试几个入参就判断出来。
这个5-和是否等于4,似乎是判断是最后一次循环,根据前四个的代码可以判断里面似乎还有一层循环,根据 入参长度 / 循环步数 floor的值 进行处理,如果入参不是5的倍数,最后一个循环处理的数据应该长度不够5,应该是在兼容这里

1762149637876.png

棕色的框里面应该也是一个循环
还是那句话,先进行合理的猜测,然后进行控制入参去验证两次,就可以判断出来逻辑了

然后整理这一块逻辑,重新补全代码

function transformMessage(plainText, options) {  
    // segments 是循环次数
    var segments = options.segments;
  
    var segmentLength = Math.floor(plainText.length / segments);

    for (var i = 0; i < segments; i++) {
        // 计算当前段的起始和结束位置
        var startIndex = i * segmentLength;
        var endIndex;
        
        if (i === segments - 1) {
            // TODO
        } else {
            endIndex = startIndex + segmentLength;
            // TODO
        }
    }
    
    var checkString = transformedSegments.join('');
    return plainText + checkString;
}

先看 else 分支的,因为日志稍微短

调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 0 1
调用结果: true
0 '+' 0 '--->' 0
0 '<' 6 '--->' true
调用: ƒ charCodeAt() { [native code] } 123123 0
调用结果: 49
0 '+' 49 '--->' 49
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 1 1
调用结果: false
49 '*' 22 '--->' 1078
1078 '%' 64 '--->' 54
调用: ƒ charAt() { [native code] } utsrqponmlkjihgfedcbaZYXWVUTSRQPONMLKJIHGFEDCBA-_9876543210zyxwv 54
调用结果: 4
调用: ƒ push() { [native code] } [] 4
调用结果: 1

看着逻辑是 循环之前的入参长度 / 5,然后将去到当前入参idx字符的ASCII码,然后 * 22 % 64,然后取自定义映射表utsrqponmlkjihgfedcbaZYXWVUTSRQPONMLKJIHGFEDCBA-_9876543210zyxwv idx上的字符,但是这里面 < 6,以及循环就一次,这里看不太清楚逻辑,重新写snippet,将入参控制长一点看看。

入参: 123123123123123123
-4243 '+' 1697 '--->' -2546
-2546 '+' 2551 '--->' 5
6370 '+' 5143 '--->' 11513
11513 '+' -11491 '--->' 22
调用: ƒ (_$j, _$L) {
            _$j = _$j - (-0xb94 + -0x5e * -0x55 + -0x1295);
            var _$z = _$G[_$j];
            if (a0d2b23b.FqOFnt === undefined) {
                var _$B = function(_$w) {
   … 462
调用结果: utsrqponmlkjihgfedcbaZYXWVUTSRQPONMLKJIHGFEDCBA-_9876543210zyxwv
18 '/' 5 '--->' 3.6
调用: ƒ floor() { [native code] } Math {abs: ƒ, acos: ƒ, acosh: ƒ, asin: ƒ, asinh: ƒ, …} 3.6
调用结果: 3
-8909 '+' 3838 '--->' -5071
-5071 '+' 5071 '--->' 0
0 '<' 5 '--->' true
-5749 '+' -9900 '--->' -15649
-15649 '+' 15649 '--->' 0
0 '*' 3 '--->' 0
-322 '+' 2199 '--->' 1877
1877 '+' -1876 '--->' 1
5 '-' 1 '--->' 4
0 '===' 4 '--->' false
-3925 '+' -6564 '--->' -10489
-10489 '+' 10489 '--->' 0
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 0 3
调用结果: true
0 '+' 0 '--->' 0
0 '<' 18 '--->' true
调用: ƒ charCodeAt() { [native code] } 123123123123123123 0
调用结果: 49
0 '+' 49 '--->' 49
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 1 3
调用结果: true
0 '+' 1 '--->' 1
1 '<' 18 '--->' true
调用: ƒ charCodeAt() { [native code] } 123123123123123123 1
调用结果: 50
49 '+' 50 '--->' 99
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 2 3
调用结果: true
0 '+' 2 '--->' 2
2 '<' 18 '--->' true
调用: ƒ charCodeAt() { [native code] } 123123123123123123 2
调用结果: 51
99 '+' 51 '--->' 150
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 3 3
调用结果: false
150 '*' 22 '--->' 3300
3300 '%' 64 '--->' 36
调用: ƒ charAt() { [native code] } utsrqponmlkjihgfedcbaZYXWVUTSRQPONMLKJIHGFEDCBA-_9876543210zyxwv 36
调用结果: K
调用: ƒ push() { [native code] } [] K
调用结果: 1

入参是123123123123123123后能看清楚一点逻辑了,他是循环之前的入参长度 / 5,然后将每次循环当前入参idx字符的ASCII码相加 * 22 % 64,再去自定义映射表里面取到字符串

但是 这里还是有 < 18(入参长度) 不知道什么含义!!!

联想之前的可能最后一次循环有特殊操作,这个18有可能是给他准备的,前面的都分析完了,直接看最后一次循环的逻辑

4 '<' 5 '--->' true
-5749 '+' -9900 '--->' -15649
-15649 '+' 15649 '--->' 0
4 '*' 3 '--->' 12
-322 '+' 2199 '--->' 1877
1877 '+' -1876 '--->' 1
5 '-' 1 '--->' 4
4 '===' 4 '--->' true
18 '%' 5 '--->' 3
3 '+' 3 '--->' 6
-3925 '+' -6564 '--->' -10489
-10489 '+' 10489 '--->' 0
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 0 6
调用结果: true
12 '+' 0 '--->' 12
12 '<' 18 '--->' true
调用: ƒ charCodeAt() { [native code] } 123123123123123123 12
调用结果: 49
0 '+' 49 '--->' 49
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 1 6
调用结果: true
12 '+' 1 '--->' 13
13 '<' 18 '--->' true
调用: ƒ charCodeAt() { [native code] } 123123123123123123 13
调用结果: 50
49 '+' 50 '--->' 99
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 2 6
调用结果: true
12 '+' 2 '--->' 14
14 '<' 18 '--->' true
调用: ƒ charCodeAt() { [native code] } 123123123123123123 14
调用结果: 51
99 '+' 51 '--->' 150
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 3 6
调用结果: true
12 '+' 3 '--->' 15
15 '<' 18 '--->' true
调用: ƒ charCodeAt() { [native code] } 123123123123123123 15
调用结果: 49
150 '+' 49 '--->' 199
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 4 6
调用结果: true
12 '+' 4 '--->' 16
16 '<' 18 '--->' true
调用: ƒ charCodeAt() { [native code] } 123123123123123123 16
调用结果: 50
199 '+' 50 '--->' 249
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 5 6
调用结果: true
12 '+' 5 '--->' 17
17 '<' 18 '--->' true
调用: ƒ charCodeAt() { [native code] } 123123123123123123 17
调用结果: 51
249 '+' 51 '--->' 300
调用: ƒ (_$uj, _$uL) {
            return _$uj < _$uL;
        } {xPZwm: 'function', SSOed: ƒ, gIscO: ƒ, OXhhk: ƒ, lioaD: ƒ, …} 6 6
调用结果: false
300 '*' 22 '--->' 6600
6600 '%' 64 '--->' 8
调用: ƒ charAt() { [native code] } utsrqponmlkjihgfedcbaZYXWVUTSRQPONMLKJIHGFEDCBA-_9876543210zyxwv 8
调用结果: m
调用: ƒ push() { [native code] } (4) ['K', 'K', 'K', 'K'] m
调用结果: 5

这次大概看清楚什么逻辑了,因为之前我有一个地方分析错了,不知道发现了没

如果入参不是5的倍数,最后一个循环处理的数据应该长度不够5,应该是在兼容这里
实际上因为 / 5进行floor,最后一次循环是大于了5,是保证能全部取到,但是总体逻辑发现还是一样的,循环内当前idx在入参取到对应字符的ASCII相加,最后*22/64在自定义映射表里面取值。

那么最终的还原代码就是

function transformMessage(plainText, options) {  
    var map = options.map;
    var segments = options.segments;
    var multiplier = options.multiplier;
  
    // 计算每段的长度
    var segmentLength = Math.floor(plainText.length / segments);
    
    // 存储转换后的校验字符
    var transformedSegments = [];
    
    // 循环处理每一段
    for (var i = 0; i < segments; i++) {
        // 计算当前段的起始和结束位置
        var startIndex = i * segmentLength;
        var endIndex;
        
        if (i === segments - 1) {
            // 最后一段处理到文本末尾
            endIndex = plainText.length;
        } else {
            endIndex = startIndex + segmentLength;
        }
        
        // 提取当前段的文本
        var segment = plainText.slice(startIndex, endIndex);
        
        // 计算当前段所有字符的 ASCII 码总和
        var segmentSum = 0;
        for (var j = 0; j < segment.length; j++) {
            segmentSum = segmentSum + segment.charCodeAt(j);
        }
        
        // 根据总和生成校验字符
        var index = (segmentSum * multiplier) % 64;
        var checkChar = map.charAt(index);
        
        // 添加到结果数组
        transformedSegments.push(checkChar);
    }
    
    // 将所有校验字符连接成字符串
    var checkString = transformedSegments.join('');
    
    // 返回原文本 + 校验字符串
    return plainText + checkString;
}

var result = transformMessage("Hello World", {
    map: "utsrqponmlkjihgfedcbaZYXWVUTSRQPONMLKJIHGFEDCBA-_9876543210zyxwv",
    segments: 5,
    multiplier: 22
});

console.log(result);

功能是:

  1. 将输入文本分成若干段(segments)
  2. 计算每段中所有字符的 ASCII 码总和
  3. 用这个总和生成一个校验字符
  4. 将所有校验字符附加到原文本后面

最后还原的md5算法如下:

/**
 * File: customAlgorithm.ts
 * Description: 京东加强算法
 * Author: zhx47
 */

import * as CryptoJS from 'crypto-js';
import { Injectable } from '@nestjs/common';
import { ClsService } from 'nestjs-cls';
import { isNullOrUndefined } from '../../utils/baseUtils';
import { TransformMessageOptions } from './type';

interface Format {
  stringify(cipherParams: CryptoJS.lib.CipherParams): string;

  parse(str: string): CryptoJS.lib.CipherParams;
}

interface CipherOption {
  iv?: CryptoJS.lib.WordArray | undefined;
  format?: Format | undefined;

  [key: string]: any;
}

@Injectable()
export class CustomAlgorithm {
  constructor(protected readonly clsService: ClsService) {}

  MD5(message: CryptoJS.lib.WordArray | string): CryptoJS.lib.WordArray {
    return CryptoJS.MD5(this.addSalt(message));
  }

  addSalt(message: CryptoJS.lib.WordArray | string): CryptoJS.lib.WordArray | string {
    if (typeof message === 'string') {
      const transformMessageOptions = this.clsService.get('h5stContext.customAlgorithm')?.transformMessageOptions;
      if (transformMessageOptions) {
        message = this.transformMessage(message, transformMessageOptions);
      }
      const salt = this.clsService.get('h5stContext.customAlgorithm')?.salt ?? '';
      return message + salt;
    }
    return message;
  }

  transformMessage(plainText: string, options: TransformMessageOptions) {
    const { map, segments, multiplier } = options;

    const transformSegments = () => {
      const segmentLength = Math.floor(plainText.length / segments);

      return Array.from({ length: segments }, (_, i) => {
        const startIndex = i * segmentLength;
        const endIndex = i === segments - 1 ? plainText.length : startIndex + segmentLength;

        const segmentSum = plainText
          .slice(startIndex, endIndex)
          .split('')
          .reduce((sum, char) => sum + char.charCodeAt(0), 0);

        return map.charAt((segmentSum * multiplier) % 64);
      });
    };

    const transformedSegments = transformSegments();
    return plainText + transformedSegments.join('');
  }
}

相应的位置已经使用变量进行替换,因为每一个版本都不太一样。

代码就分析到这,因为所有的逻辑都一样,主流程基本上没什么变化,只有localTk升级到05版本了,其余都是这个逻辑

最后贡献一点京东的网页api的验证心得

  1. jstoken:就是请求参数里面的x-api-eid-token参数,不知道什么时候开始校验了,不过这个参数是eval加密,可以直接f12在vm里面看到明文,也是一段设备指纹,加上活动标志请求https://jra.jd.com/jsTk.do动态下发的。
  2. tls验证:不知道什么时候开始越来越多的接口加了tls指纹验证
  3. 自定义请求头:x-referer-page、x-rp-client这种别漏了
  4. cookie验证:有的接口似乎会验证jda、jdb那些东西,反正就是cookie里面有时候也会验证一部分值
  5. python下params对于queryString处理似乎和浏览器、node有一点不同,可能会异常。
  6. h5st里面的env京东有时候会解密进行强校验,应对方法是通过提供官方生成的h5st,脚本生成h5st以官方的解密为准,只替换部分信息。(现在双十一 5.2.2 就开了)

最后最后的提醒

此文章只作为学习交流,京东现在没什么好撸的了,动不动掉线,env不对直接踢下线。
部分接口除了踢下线还要拉黑你。

即使这个签名生成的与官方百分百一致,还是可以很轻松的查到你,毕竟你的cookie、埋点都不对,附上一份官方之前发的调查报告。

https://developer.jdcloud.com/article/3281