补瑞数4环境

一、摘要

本文借助Proxy将常用浏览器全局对象,一共6个(window,navigator,location,document,localStorage,sessionStorage)全部挂上代理,并hook console.log方法(有关hook的使用,可见document.cookie的hook及引申, 把日志写到文件里去形成一份代理日志。

通过递归代理,由以上6个对象衍生出来的对象也会被自动套上代理,如衍生对象有动作也会被记录到代理日志当中,利用代理日志去补环境,从而过掉瑞数4反爬防护。

二、目标确定

地址:aHR0cDovL3d3dy5mYW5nZGkuY29tLmNuL2luZGV4Lmh0bWw=
爬取

三、前期工作

3.1 定位接口

通过开发者工具定位到了接口:./service/index/getHouseNews.action
定位接口

3.2 精简参数

请求返回400, 经过调试, 每次请求时有2个参数都在动态变化(这两个参数使用一次就不能再用了)
精简参数但请求失败

动态参数:FSSBBIl1UgzbN7N80T(cookie),MmEwMD(params)
静态参数:JSESSIONID1(cookie),FSSBBIl1UgzbN7N80S(cookie)
所以一共有4个参数需要逆向。

四、逆向工程

4.1 FSSBBIl1UgzbN7N80S

思路:先验证是否是response返回, 后判断是否是js生成。
通过抓包工具搜索 response.headers,确认了是由response返回得到,且接口是:./index.html, 且来源请求无加密参数。

4.2 FSSBBIl1UgzbN7N80T

response返回验证
搜索不到结果,说明是js生成。
tip:如在调试过程中遇无限debugger, 右击 永不在此处暂停,或 条件断点false即可。

4.2.1 定位js加密入口

通过hook定位, 通过油猴在 document.start时机注入hook。
hook cookie
成功hook住,并通过堆栈上寻。
上寻
document.cookie赋值语句,继续上寻。
生成cookie值的关键代码
var _$Pz = _$5i(5)是生成cookie值的关键代码。
但并没有这么简单, 放开断点, 可以看到, cookie连续生成了4次, 而只有最后一次长度为324的cookie才是最终发起请求的cookie。
生成了4次cookie
统计这4次cookie的堆栈生成path如下:

第1次 第2次 第3次 第4次
1 index.html中的ret = $cn.call($H2, _$oc); index.html下的ret = $os.call($V4, _$Zd); window.load _$k6:return _$vi.apply(this, _$eS);
2 vm2581下的大自执行函数 VM2556中的大自执行函数 _$vi(768, 2); _$fz();
3 _$Qo(); _$Qo(); $b($ub($cn), _$fy); _$vi(768, 13);
4 _$bT(); _$bT(); $UO[$zo[39]] = _$sx + ‘=’ + _$5k + _$PU() + _$zo[494] + $Rp($CT); $b($ub($cn), _$fy);
5 _$b($dF, _$fy); _$vi(768, 1); $UO[$zo[39]] = _$sx + ‘=’ + _$5k + _$PU() + _$zo[494] + $Rp($CT);
6 $UO[$zo[39]] = _$sx + ‘=’ + _$5k + _$PU() + _$zo[494] + $Rp($CT); $UO[$zo[39]] = _$sx + ‘=’ + _$5k + _$PU() + _$zo[494] + $Rp($CT);

总结一下入口规律:

  1. 第1次与第2次是在同步代码阶段执行。
    第1/2次cookie生成
  2. 第3次是在load异步事件中执行, 第4次是在setTimout异步事件中执行的

接下来的目标是在本地nodejs环境将这4次cookie生成都模拟成功,且值校验一致。

思路:第1/2次cookie是在eval代码中生成的, 那么补充该eval代码执行的上下文环境, 让eval顺利执行完就可以得到1次和2次的cookie代码。紧接着执行异步代码,让第3/4也得以生成。首先要先做一些前置准备,把初始化环境搭建出来。

4.2.2 前置准备

静态文件固定:
在调试中发现每刷新一次代码结果都会产生变化, 说明瑞数是动态加密, 那么需固定住一份静态代码以方便调试, 一切始于 index.html, 那么index.html是一定要在本地替换一份(方法有很多,笔者这里采用的是 proxyman抓包软件的map local功能)。

观察一下index.html内部有无引用一些外链文件,发现一份 dfe1675.js文件, 该文件也是动态变化的,因此也需固定。
index.html的外链文件

随机值与时间戳固定:

performance = {} //浏览器性能相关的api
Performance = function () { }
Object.setPrototypeOf(performance, Performance.prototype) 
Date.now = function () { return 1666666666666 };
Date.parse = function () { return 1666666666666 };
Date.prototype.valueOf = function () { return 1666666666666 };
Date.prototype.getTime = function () { return 1666666666666 };
Date.prototype.toString = function () { return 1666666666666 };
Performance.prototype.now = function () { return 51523 }
Math.random = function () { return 0.5 }; //表示从页面打开到执行该api所经过的毫秒时间数

把eval执行上下文扣到本地nodejs中, 包括一个外链js和3个自执行函数, meta中content内容暂时先不管。
eval代码的执行上下文和外链js
扣到本地nodejs

补充浏览器对象
一共6个对象:window,navigator,location,document,localStorage,sessionStorage。

对于window, 先补一个 window.name=''

对于document, 先补一个 document.cookie=''

对于location和navigator,笔者提供一个copy脚本,在控制台执行可以快速把对象中值为字符串、数字、布尔和列表的属性复制出来。

let obj2copy = {}
function objectCopy(obj) {
    for (key in Object.getOwnPropertyDescriptors(obj)) {
        // console.log(key)
        if (Object.getOwnPropertyDescriptor(obj, key)['value'] && typeof (Object.getOwnPropertyDescriptor(obj, key)['value']) === 'string') {
            console.log(key, Object.getOwnPropertyDescriptor(obj, key)['value'])
            obj2copy[key] = Object.getOwnPropertyDescriptor(obj, key)['value']
        }
        if (Object.getOwnPropertyDescriptor(obj, key)['get']) {
            try {
                result = obj[key]
            } catch (e) {
                continue
            }
            if (['string', 'boolean', 'number'].indexOf(typeof result) !== -1) {
                console.log(key, result)
                obj2copy[key] = result
            }
            if (Object.prototype.toString.call(result) === '[object Array]') {
                console.log(key, result)
                obj2copy[key] = result
            }
        }
    }
    for (key in Object.getOwnPropertyDescriptors(obj.__proto__)) {
        // console.log(key)
        if (Object.getOwnPropertyDescriptor(obj.__proto__, key)['value'] && typeof (Object.getOwnPropertyDescriptor(obj.__proto__, key)['value']) === 'string') {
            console.log(key, Object.getOwnPropertyDescriptor(obj.__proto__, key)['value'])
            obj2copy[key] = Object.getOwnPropertyDescriptor(obj.__proto__, key)['value']
        }
        if (Object.getOwnPropertyDescriptor(obj.__proto__, key)['get']) {
            result = obj[key]
            if (['string', 'boolean', 'number'].indexOf(typeof result) !== -1) {
                console.log(key, result)
                obj2copy[key] = result
            }
            if (Object.prototype.toString.call(result) === '[object Array]') {
                console.log(key, result)
                obj2copy[key] = result
            }
        }
    }
    for (key in Object.getOwnPropertyDescriptors(obj.__proto__.__proto__)) {
        // console.log(key)
        if (Object.getOwnPropertyDescriptor(obj.__proto__.__proto__, key)['value'] && typeof (Object.getOwnPropertyDescriptor(obj.__proto__.__proto__, key)['value']) === 'string') {
            console.log(key, Object.getOwnPropertyDescriptor(obj.__proto__.__proto__, key)['value'])
            obj2copy[key] = Object.getOwnPropertyDescriptor(obj.__proto__.__proto__, key)['value']
        }
        if (Object.getOwnPropertyDescriptor(obj.__proto__.__proto__, key)['get']) {
            result = obj[key]
            if (['string', 'boolean', 'number'].indexOf(typeof result) !== -1) {
                console.log(key, result)
                obj2copy[key] = result
            }
            if (Object.prototype.toString.call(result) === '[object Array]') {
                console.log(key, result)
                obj2copy[key] = result
            }
        }
    }
}
objectCopy(localStorage) //这里写要复制的对象,可复制本身以及最近2层原型链上的属性
copy(obj2copy)

对于localStorage和sessionStorage,把常用的三种方法补上:setItem、getItem和removeItem

为了避免原型链检测, 给每个对象都设置自己的原型链, 其中把window指向globalThis,一是让window成为顶级/全局对象,二是为了方便调用一些内置api, 例如eval,escape等。

环境初始化如下:

//document
document = {
}
HTMLDocument = function () { }
Object.assign(HTMLDocument.prototype, {
    cookie: '',
    [Symbol.toStringTag]: '[object HTMLDocument]', //对象的名字,调用Object.prototype.toString.call()时指向即是该属性值
})
Object.setPrototypeOf(document, HTMLDocument.prototype)
//navigator
navigator = {
}
Navigator = function () { }
Object.assign(Navigator.prototype, {
    "vendorSub": "",
    "productSub": "20030107",
    "vendor": "Google Inc.",
    "maxTouchPoints": 0,
    "pdfViewerEnabled": true,
    "hardwareConcurrency": 8,
    "cookieEnabled": true,
    "appCodeName": "Mozilla",
    "appName": "Netscape",
    "appVersion": "5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36",
    "platform": "MacIntel",
    "product": "Gecko",
    "userAgent": "Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/116.0.0.0 Safari/537.36",
    "language": "zh-CN",
    "languages": [
        "zh-CN"
    ],
    "onLine": true,
    "webdriver": false,
    [Symbol.toStringTag]: '[object Navigator]',
})
Object.setPrototypeOf(navigator, Navigator.prototype)
//location
location = {
    "href": "http://www.fangdi.com.cn/index.html",
    "origin": "http://www.fangdi.com.cn",
    "protocol": "http:",
    "host": "www.fangdi.com.cn",
    "hostname": "www.fangdi.com.cn",
    "port": "",
    "pathname": "/index.html",
    "search": "",
    "hash": "",
};
Location = function () { }
Object.assign(Location.prototype, {
    [Symbol.toStringTag]: '[object Location]'
})
Object.setPrototypeOf(location, Location.prototype)
//localStorage
localStorage = {
}
Storage = function () { }
Object.assign(Storage.prototype, {
    setItem: function (name, value) {
        this[name] = value
    },
    getItem: function (name) {
        return this[name]
    },
    removeItem: function (name) {
        delete this[name]
    },
    [Symbol.toStringTag]: '[object Storage]'
})
Object.setPrototypeOf(localStorage, Storage.prototype)
//sessionStorage
sessionStorage = {
}
Object.setPrototypeOf(sessionStorage, Storage.prototype)
//window
window = globalThis
Object.assign(window, {
    name: '',
})
Window = function () { }
Object.assign(Window.prototype, {
    [Symbol.toStringTag]: '[object Window]',
})
Object.setPrototypeOf(window, Window.prototype)

4.2.3 让代码跑通

准备工作完毕后即可开始执行代码, 这一步的原则是缺啥补啥, 先让代码跑通, 至于跑的结果对不对放在第二步做。(这里先不上代理,用手动补思路,即去浏览器调试为主补充环境,方便对比看出手法的差异)
报错:不能读取未定义的location属性
复制相关代码, 去浏览器中调试。
window.top指向window
发现需要补 window.top=window,补完之后重新执行:
报错:xx is not a function
复制相关代码, 去浏览器中调试。
浏览器调试
通过调试可知:该处逻辑是创建一个div标签, 然后用getElementsByTagName取到div标签里的i元素, 返回结果是一个空列表, 补充这两个方法即可, 注意把这些方法都写到原型对象中, 以防原型链检测。
补充document.createElement
重新运行:
报错:xx is not a function
复制相关代码去浏览器中调试:
浏览器调试
这里有一个 if..else, 要注意把断点打在if里, 原来是缺少 window.addEventListener, 先补一个空方法。
补充window.addEventListener
重新运行报错, 继续去浏览器中调试,发现是document.getElementsByTagName(‘meta’),返回结果:[meta meta],取最后一个meta中content属性的值, 最后调用其父元素删除子元素。
补上相关逻辑
重新执行报错, 浏览器中调试发现缺少 document.write方法, 思路:document.write并无返回值, 创建的标签必然在后面用到, 因此需要把dom节点解析对象然后保存到全局对象中, 笔者这里用的是第三方库(html-dom-parser, 为了能够监控这个对象后续的操作, 我们将其封装代理对象然后推入到全局对象中。

然后跟进_$_T函数内, 里面的逻辑是用getElementByTagName获取所有的script标签, 取最后一个script并调用其父节点删掉此标签, 因此也需要补一下, removeChild给一个空方法即可。
补充document.write环境
补充getElementbyTagName('script')环境
重新运行,报错提示说xx不是一个函数, 调试发现是获取到的script标签缺少attribute属性, 下图代码的逻辑是, 删除当前页面中所有属性r值为m的script的标签, 那么我们需要在script(上一步中补充)内部实现一个getAttribute方法, 该方法置空即可。
补充script标签的getAttribute方法
重新运行,发现代码一直在执行不停下来, 这说明同步代码已经执行完了, 现在在执行异步代码, 这里是遇到了setInterval(与setTimeout都是nodejs内置api), 我们先赋空该函数(包括setTimeout)。
赋空setInterval和setTimout
至此,代码流程已经跑通,第二步是细补环境,出结果并保证结果一致。

4.2.4 调同第1次cookie

em..直接在代码末打印document.cookie。
第1次cookie无需调同,直接一致。

4.2.4 调同第2次cookie

虽然第1次cookie保持了一致,但第2次cookie却并不一致,说明我们本地缺失了某些环境,或者某些环境补的不对。
具体而言,主要原因是nodejs没有bom和dom环境, 当然也有其他的原因, 比如nodejs中有些环境浏览器中没有, 这部分就可能变成浏览器端针对nodejs的检测点, 守方利用try.catch或if.else或三元表达式又switch.case等控制流语句,依据环境的差异改变流程执行分支, 这就造成了结果不一致。

因此,矛盾点在于,作为攻方的我们如何找处哪些环境存在差异?这里顺势引入本文的重头戏,利用Proxy代理嗅探缺失环境。

利用Proxy之前,首先要有一个认知, 浏览器端所有定义在全局作用域的变量、函数都是window的属性或方法, nodejs端这个对象即是globalThis, 那么我们给全局对象挂一层代理, 把这层环境作为第0层的话,第0层缺失的环境就很容易暴露出来了。
紧接着我们给navigator,document,location,localStorage,sessionStorage挂上代理, 这些都是window的属性, 代表第一层环境, 当然还会有第二层, 第三层代理, 我们上递归即可。

关于具体Proxy如何使用, 之前写过一篇文章Proxy与Reflect嗅探缺失环境, 本文就不在赘述了,直接贴代码。

yueqian = {
    symbolProxy: Symbol("proxy"), //用于避免无限递归(类似window.self=window)
    symbolRawFatherObj: Symbol('fatherObj'),//用于更改apply行为内部this指向到原生对象,而非Proxy对象
    symbolRawSelfObj: Symbol('selfObj'),//用于更改construct行为内部this指向到原生对象,而非Proxy对象
    symbolRawName: Symbol('rawName'),//用于表示被创建的元素名称,方便在内存中通过此属性精准定位到
}
//代理部分
yueqian.proxy = function (obj, objName) {
    // 代理前需要注意,一些configuable为false的对象不可被代理,需要滤过
    let propertyDes = obj[yueqian.symbolRawFatherObj] && Object.getOwnPropertyDescriptor(obj[yueqian.symbolRawFatherObj], obj[yueqian.symbolRawName])
    let configurable = propertyDes && propertyDes['configurable']
    if (configurable === false) {
        return obj
    }
    //对于有proxy symbol标记的,返回这个标记中存储的值 1)确保每个对象只被代理一次,2)过window.Top===window类似检测,由于window.Top还是返回window,这句话可以保证不重复代理,直接嫁接到window本身
    if (Object.hasOwnProperty.call(obj, yueqian.symbolProxy)) {
        return obj[yueqian.symbolProxy]
    }
    let handler = {
        get: function (target, prop, receiver) {// 三个参数
            // if(prop==='innerText'){debugger}
            let result;
            result = Reflect.get(target, prop, receiver); //通过反射拿到结果
            //过滤输出一些属性
            if ((yueqian.proxy_filters_init.indexOf(prop) !== -1) || typeof prop === 'symbol' || (yueqian.proxy_filters_custom.indexOf(prop) !== -1)) {
                return result;
            }
            let type = Object.prototype.toString.call(result);
            if (result instanceof Object) {
                //将原生父对象(相对于result)的地址存到result的symbol属性中,方便在apply行为中更改this指向
                Object.defineProperty(result, yueqian.symbolRawFatherObj, {
                    value: target
                })
                //将原生对象地址存到result的symbol属性中,方便在construct行为中更改this指向
                Object.defineProperty(result, yueqian.symbolRawSelfObj, {
                    value: result
                })
                //将对象名字存到result的symbol属性中,在过滤不可配置属性时(Object.hasOwnProperty)作为prop字符串
                Object.defineProperty(result, yueqian.symbolRawName, {
                    value: prop
                })
                console.log(`{get|obj:[${objName}] -> prop:[${prop.toString()}],type:[${type}]}`);
                // 递归代理
                result = yueqian.proxy(result, `${objName}.${prop.toString()}`);
            } else if (typeof result === "symbol") {
                console.log(`{get|obj:[${objName}] -> prop:[${prop.toString()}],ret:[${result.toString()}]}`);
            } else {
                console.log(`{get|obj:[${objName}] -> prop:[${prop.toString()}],ret:[${result}]}`);
            }
            return result;
        },
        set: function (target, prop, value, receiver) {
            let result;
            result = Reflect.set(target, prop, value, receiver);
            //过滤输出一些属性
            if ((yueqian.proxy_filters_init.indexOf(prop) !== -1) || typeof prop === 'symbol' || (yueqian.proxy_filters_custom.indexOf(prop) !== -1)) {
                return result;
            }
            let type = Object.prototype.toString.call(value);
            if (value instanceof Object) {
                console.log(`{set|obj:[${objName}] -> prop:[${prop.toString()}],value_type:[${type}]}`);
            } else if (typeof value === "symbol") {
                console.log(`{set|obj:[${objName}] -> prop:[${prop.toString()}],value:[${value.toString()}]}`);
            } else {
                let value_length = value && value.length
                console.log(`{set|obj:[${objName}] -> prop:[${prop.toString()}],value:[${value}]} length:${value_length}`)
            }
            return result;
        },
        deleteProperty: function (target, propKey) {
            let result = Reflect.deleteProperty(target, propKey);
            //过滤输出一些属性
            if ((yueqian.proxy_filters_init.indexOf(propKey) !== -1) || typeof propKey === 'symbol') {
                return result;
            }
            console.log(`{deleteProperty|obj:[${objName}] -> prop:[${propKey.toString()}], result:[${result}]}`);
            return result;
        },
        apply: function (target, thisArg, argumentsList) {
            // target: 函数对象
            // thisArg: 调用函数的this指针
            // argumentsList: 数组, 函数的入参组成的一个列表
            let arg = Object.prototype.toString.call(argumentsList[0]);
            if (typeof argumentsList[0] === 'string') {
                arg = argumentsList[0].slice(0, 30)
            }
            let result;
            thisArg = target[yueqian.symbolRawFatherObj] //更改apply的this指向为原生对象(内置对象的this指向必须为原生对象,不可以是代理对象)
            result = Reflect.apply(target, thisArg, argumentsList);
            let type = Object.prototype.toString.call(result);
            if (result instanceof Object) {
                let result_objName = `${objName}_obj${yueqian.func_ret_seq++}`
                result = yueqian.proxy(result, result_objName) //让function返回的对象也自动套上代理
                console.log(`{apply|function:[${objName}], arg:${arg}, type:[${type}]}`);
            } else if (typeof result === "symbol") {
                console.log(`{apply|function:[${objName}], arg:${arg}, result:[${result.toString()}]}`);
            } else if (typeof result === "string") {
                console.log(`{apply|function:[${objName}], arg:${arg}, result:[${result.slice(0, 50)}]}`);
            } else {
                console.log(`{apply|function:[${objName}], arg:${arg}, result:[${result}]}`);
            }
            return result;
        },
        construct: function (target, argArray, newTarget) {
            // target: 函数对象
            // argArray: 参数列表
            // newTarget:代理对象
            let result;
            newTarget = target[yueqian.symbolRawSelfObj] //更改this指向为原生对象(内置对象的this指向必须为原生对象,不可以是代理对象)
            result = Reflect.construct(target, argArray, newTarget);
            let type = Object.prototype.toString.call(result);
            result = yueqian.proxy(result, objName)
            console.log(`{construct|function:[${objName}], type:[${type}]}`);
            return result;
        },
    };
    let proxyObj = new Proxy(obj, handler);
    //防止重复代理,导致代理被用嵌套对比的方式检测出来
    Object.defineProperty(obj, yueqian.symbolProxy, {
        configurable: true,
        enumerable: false,
        writable: false,
        value: proxyObj
    })
    return proxyObj
}

关于symbol属性,已在注释中阐明其用途。

挂上代理后再执行,控制台会自动吐出调用过的环境。
控制台自动吐环境
用终端看输出日志有些不太方便(显示效果、无法持久保持等),可以hook console.log方法,将其写入到文件中。

//hook console.log方法,将日志写到文件中
!function () {
    if (yueqian.log === true) { //配置是否将日志写到文件
        let _log = console.log
        fs.writeFileSync(__dirname + '/proxy_log.txt', '')
        console.log = function () {
            let log = ''
            for (let i = 0; i < arguments.length; i++) {
                log += (arguments[i].toString() + ' ')
            }
            log += '\n'
            fs.appendFileSync(__dirname + '/proxy_log.txt', log)
            _log(...arguments)
        }
    }
}()

吐出的环境中可以看见很多是undefined, 这部分去浏览器控制台打印调试查探一下真实返回值什么, 以下是找到的异常环境以及参照浏览器补充的值。
参考以下原则:

  1. 如果是对象, 先以{}补之, 看后几层代理暴露情况,暴露了再补,没暴露就不补
  2. 如果是方法, 先以空方法补之,与对象类似,看后几层代理暴露情况,暴露了再补,没暴露就不补
  3. 如果浏览器端也返回undefined,那么就不管
window.indexedDB={}
window.self=window
window.webkitRequestFileSystem=function(){}
window.openDatabase=function(){}
window.XMLHttpRequest=function(){}
window.chrome={}
document.documentElement.getAttribute=function(){}
navigator.connection={}

补充了以上环境后,再重新执行:
第2次cookie生成一致
可见,在代理日志的帮助下,我们很快补齐了环境。
接下来是调异步代码,将第3/4次cookie也调成一致(先要实现异步逻辑)。

4.2.5 实现异步逻辑

思路:
nodejs本身的setTimeout/setInterval的确实现了异步功能,但addEventListener可是我们自定义的方法,nodejs本身没有提供,因此做一份伪异步代码。怎么实现呢?

  1. 改写addEventListener和setTimeout/setInterval方法, 让其把异步回调函数及调用方、入参等信息包装一个对象保存到全局对象中去,假设叫异步任务池。
  2. 在同步代码执行结束后,排列异步任务池中的任务,生成一份异步执行池,排列逻辑依据3点: push顺序和time参数和异步任务类型
    1. load事件执行时机等同于setTimout事件time为0,
    2. 对于非load事件time赋予20s(使其执行时机尽量靠后)
    3. unload事件删除(当前窗口销毁才会触发)
    4. 超过1s的异步任务全部删除(超过几秒由浏览器调试结果而定, 如果想要的结果是在1s后异步任务产生,那么需更改该参数,笔者调试结果是0s的setTimout事件)
    5. 剩余依排列逻辑推入异步执行池
  3. 从异步执行池中取异步任务, 判断其类型并分发不同的执行逻辑, 最后while循环确保每次异步任务执行完都会重新排列异步任务池生成异步执行池, 直到异步任务池中无任务。
//异步环境
//异步事件全局配置
let async_seq = 0; //记录push的先后次序
asyncEvents = { //放置异步任务
    addEventListener: {},
    setTI: [], //setTimout和setInterval放在一个列表中
}
let init_events_pool = {
    load: {}, //放置异步事件,主要是addlistener事件,回调函数会默认传一个event,到时候就从这里去
    other: {}, //把load事情单独拿出来,是因为事件之间携带的信息不同,要有所区分
}
let async_exec_pool = [] //异步执行池
//异步池排列逻辑,每执行完一个异步事件对异步池重新排列(因为异步任务有可能还会注册异步任务)
function async_pool_get() {
    //每次执行前,排列异步池事件,逻辑:setTimeout/setInterval time 0权重最大,先于load执行,每执行完一个从池中删除
    let setTIEventPool = asyncEvents.setTI;
    async_exec_pool = async_exec_pool.concat(setTIEventPool)
    //清空池
    asyncEvents.setTI = []

    delete asyncEvents.addEventListener['unload'] //删除unload事件

    let otherListeners = asyncEvents.addEventListener
    for (const listenersKey in otherListeners) {
        let other_listenPool = otherListeners[listenersKey]
        async_exec_pool = async_exec_pool.concat(other_listenPool)
        asyncEvents.addEventListener[listenersKey] = []
    }
    let new_async_exec_pool = []
    //pool中time大于1000的全部丢弃,丢弃阈值由浏览器端调试结论决定,这里笔者调试结论为1s
    for (let i = 0; i < async_exec_pool.length; i++) {
        let event = async_exec_pool[i]
        let time = event.time
        if (time <= 1000) {
            new_async_exec_pool.push(event)
        }
    }
    //对pool进行排序 load在监听事件中理应是先的,但不排除非先清空,到时候具体问题具体分析,这里先手动给load time设置为1
    new_async_exec_pool.sort((a, b) => {
        if (a['time'] < b['time']) {
            return -1
        } else if (a['time'] > b['time']) {
            return 1
        }
        if (a['seq'] < b['seq']) {
            return -1
        } else if (a['seq'] > b['seq']) {
            return 1
        }
        return 0
    })

    return new_async_exec_pool
}
function async_pool_exec(async_exec_pool) {
    if (async_exec_pool.length === 0) {
        return //池空则不执行
    }
    //遍历执行pool,用shift取
    let pool_event = async_exec_pool.shift()
    let pool_event_out_type = pool_event['out_type']
    if (pool_event_out_type === 'addEventListener') {
        let inner_type = pool_event['type']
        let self = pool_event['self']
        let func = pool_event['func']
        if (inner_type === 'load') {
            console.log(`${this} ${pool_event_out_type} ${inner_type} 事件正在执行 > ${func.name}`)
            let loadEvent = init_events_pool.load
            func.call(self, loadEvent)
        } else {
            //除load事件外,其余事件均以观察为主,如果调式发现确实用到,则取消注释
            /*             let loadEvent=init_events_pool.other
                        func.call(self, loadEvent)
                        console.log(`${pool_event_out_type} ${inner_type} 正在执行 > ${func.name}`) */
        }
    } else if (['setInterval', 'setTimeout'].indexOf(pool_event_out_type) !== -1) {
        let func = pool_event['func']
        let params = pool_event['params']
        let time = pool_event['time']
        console.log(`${pool_event_out_type}|${time} 事件正在执行 > ${func.name}`)
        func.call(window, ...params)
    } else {
        console.log(`${pool_event_out_type} 未实现`)
    }
    return async_exec_pool
}

4.2.6 调同第3次cookie

重新运行代码:
报错:xx is not a function
调试: document.getElementById获取__anchor__ 返回null。
补环境: 补document.getElementById返回null即可。
重新运行代码:
报错:xx没有width属性
调试:缺少screen对象
补环境:补充screen对象(使用上面提过的一键copy脚本)
从调试过程来看,这里获取screen的各个属性放到一个列表中, 推测这里可能是要拿这部分做加密组件。
重新执行, 没有再产生报错, 那么接下来比对一下第3次cookie是否一致:
比较第3次cookie是否一致
比对结果一致。

补充:
在日志输出中可以看到浏览器对于navigator的原型链进行了检测, 通过hasOwnProperty方法判断一个属性是否是自身的属性:
检测navigator原型链
但事实情况是navigator根本就没有属于自己的属性:
navigator对象无自身属性
而我们早在一开始就补充navigator的原型链, 并将属性放置到了原型链中(是是自身属性还是原型链属性需要去浏览器中实地考察), 因此关于navigator的原型链检测我们无感通过。

4.2.7 调同第4次cookie

思路:
在终端中我们并未见到第4次cookie的生成, 想来应该是某些环境没有补, 导致没有走到正确的分支上(如果是环境值不对则会导致加密结果不一致), 因此这一节的主要打法是借助代理日志补环境。
document.body.firstChid.nextSibling>undefined
异常日志: document.body.firstChid.nextSibling,返回 undefined
调试: 这种DOM对象虽然也属于浏览器环境,但不适合直接控制台调试,因为它会变化,所以此处选择跟栈调试,运行到正确的堆栈,然后在控制台打印输出结果。调查表明,正确返回应该是之前用 document.write生成的 input标签, 此处采用 Proxy.get实现动态拦截并返回事先存在内存中的 input对象, 拦截规则以target和prop为凭。

然后还会出现一些报错, 最终补成如下图所示, 不难看出, 该步逻辑是为了删除页面中的某个 script标签, 但对于本地环境而言, 根本就不存在 DOM树, 也就是说其实啥也不做也没什么影响。
document.body.firstChid.nextSibling 补环境

接下为了加快进程,一些小环境就直接贴结果了,一些重要的环境会停下来,详细分析。
补充:window.clientInformation=navigator
补充:document.createElement('div').style={}

4.2.7.1 字体指纹

innerHTML环境
这里开始进入字体指纹, 通过 innerHTML创造的标签方法参考 document.write ,用 Proxy.set去实现该功能, 拦截规则依据 set-value值判定, 但不同的是, 这次w无需要放到全局对象中, 而是放到这个 innerHTML调用者对象下面。
补充: document.body.appendChild=function(){}
获取字体宽高
上图中日志显示,浏览器正在获取默认字体宽高(当一种 font不被当前浏览器支持时返回的宽高,可以看到这里在设置一种叫 mmllii的字体,这显然是浏览器不支持的字体),这部分环境通过Proxy.get解决。

可以看到接下来会有非常多的设置字体样式操作,以及获取最新字体宽高的过程,如果依据调试结果逐个用 Proxy.get补充的话,会比较辛苦(400+的字体库),因此这里换一种思路:

将浏览器已经支持的字体库拿下来保存到全局变量中,在设置字体时比较 style属性中的字体样式,如果符合则直接返回一个与默认宽高不同的宽高。

实时跟栈调试这部分代码,可以看到支持的字体样式都被push到一个_$RE的变量中,那么把该变量中的值 copy出来:
拿到浏览器支持的字体文件库

将浏览器支持的字体列表放置到全局变量中,并动态修改 span元素对象的 get行为的返回值,由于字体指纹加密用到的是字体样式名称,而与宽高值无关,因此返回值可以任意设置,只要不与默认宽高相同即可:
拦截并修改字体宽高值
自此字体指纹已经通过。

补充:document.body.removeChild=function(){}

4.2.7.2 插件检测(plugins和mimeTypes)

navigator.plugins检测
补充 navigator.plugins={},然后重新运行,看能不能走通某些分支,以暴露出更多的环境。
果然有更多环境暴露出来了
然后就这样一步步往下补,最终补成如下图所示:
插件环境补充
这里看出检测点是:

  1. 获取 navigator.plugins中插件的 name属性。
  2. 获取 navigator.mimeTypes中的 type属性。

4.2.7.3 批量环境布尔检测

批量环境布尔检测
接下来是一系列的环境检测,这个没有什么好说的,见一个补一个,最终笔者取到了38个环境待补,其中有7个环境是 nodejs本身就存在的,不需要补,尚需补31个环境:
31个环境待补
在补充的时候需要注意2点:

  1. 不同的环境给到不同的对象,比如是document下的环境要放到document下,window下的给到window
  2. 与之前补过的环境合并,例如之前补过document.body,这里要进行合并操作,避免覆盖引起之前的环境缺失

4.2.7.4 canvas_2d指纹检测

canvas_2d指纹检测
从日志中可以看到,浏览器创建了一个 canvas对象,看来是要拿 canvas指纹做加密了。
补充:canvas.getContext.fillRect=()=>{}
补充:canvas.getContext.fillText=()=>{}
canvas中其余的环境都还好说,但 toDateUrl这个方法需要特别注意,由于 nodejs环境是无法绘制canvas图形的,因此这个方法的返回值需要从浏览器中取出来,这里hook HTMLCanvasElement.prototype.toDataURL方法拿到返回值,并放到全局变量中方便替换和建立指纹池(因为对于每个浏览器终端而言,canvas指纹具有唯一性,如果想实现高并发,需要建立一个指纹池)。
canvas_2d环境补充
canvas_2d指纹图片

4.2.7.5 canvas_webgl指纹

webgl指纹检测
canvas_2d指纹通过后,接下来就是 3d(webgl)指纹了。
通过hook toDateURL定位到 webgl指纹生成的代码处,然后参考代理日志和浏览器端跟栈调试进行缺啥补啥。
webgl定位源码
最终 webgl环境补充成如下图所示,注意该对象有一个 canvas属性指向创建webgl的 canvas对象,浏览器获取之后会进而调用 toDateURL从而生成 webgl指纹,但这里的 canvas赋予的是个空对象,又如何调用 toDateURL呢?

  1. 把从浏览器端获取的 webgl指纹存在到一个全局变量中
  2. 创建 canvas对象时给 canvas对象设置一个 symbol属性用于记录对象名称,并把这个对象推入到全局对象
  3. 拦截 canvas对象的 get行为,返回内存中的 canvas对象
  4. 拦截 toDateURLapply行为,返回内存中记录的 webgl指纹对象,最终成功模拟了 webgl指纹
    webgl环境补充

webgl3d指纹图片

webgl指纹检测到这里就结束了吗?别着急继续看日志:
webgl指纹后续检测
这里提示获取 webgl对象的 getSupportedExtensions方法,本地返回了 undefined,而浏览器中却是返回一个方法,可见在 toDataURL之后还有2个方法和一个if块需要走通。
在本地把 getSupportedExtensions方法先置空,然后缺啥补啥,见机行事,最终补成如下图所示:
webgl指纹后续getSupportedExtensions环境补充
重新运行后后,通过代理日志发现 webgl对象的 getShaderPrecisionFormat方法开始调用,传入了2个数字(来自2次for循环)产生一个对象,然后获取其 rangeMixrangeMaxprecison属性,2次for循环加起来的次数有12次,这里需要拦截 Proxy.apply动态修改返回结果,但写12次拦截规则还是有些累,笔者这里采取的措施是:

  1. 从浏览器端取出入参的数字组合与返回结果的三次属性调用组合
  2. 以键值对的形式构成一个对象(入参为键,返回为值),并放置到全局对象中(属于webgl指纹)
  3. 写一个 Proxy.apply拦截规则,将入参与上步全局对象中的键做比较,符合条件则返回预存好的值,最终顺利过了这一关,这种写法的优势在于只要写一次拦截规则以及便于维护和扩展 webgl指纹池。
    webgl指纹后续getShaderPrecisionFormat环境补充

4.2.7.6 最后的小环境

补充:window.Audio={}
webgl环境之后有一处是获取之前用 document.write生成的 input元素,补充原理和之前获取 webgltoDateURL调用结果类似,这里不再赘述:
最后的小环境
当补完该环境之后重新运行,出现了报错,不过通过日志我们可以知道是缺少 location.replace方法,该方法作用是替换当前页面url,这里不难猜测网站要带着新生成的 cookie准备重新请求当前网页,我们置空即可。
补充location.replace方法
第4次cookie最终生成且与浏览器保持了一致:
比较第4次cookie是否一致

4.2.7.7 小结

第4次cookie需要补的环境是最多也是最难的。
主要检测点先后次序来说:
1.字体指纹
2.插件信息
3.浏览器环境(布尔检测,多达31个)
4.canvas_2d指纹
5.webgl指纹
最终这些检测点会被提取出来,通过 localStorage缓存起来,最后构成第4次cookie的加密原材料。

如果发现第4次cookie值不一致的话,可以比对本地 localStorage对象和浏览器 localStorage,尽量保证其中每一项都要一致(笔者也不确定是不是所有的键值对都参与了加密,但标红的一定参与了!)
第4次cookie生成时的localStorage对象
第1次cookie能跑通代码就能出,第2次cookie主要检测了window.self===window和一些自动化工具痕迹, 第3次cookie难点在于异步事件的处理,第4次cookie就是上述环境检测,接下来分析 MmEwMD参数(url签名)。

4.3 MmEwMD(url签名)

思路:
通过hook xhr.open方法来跟值,不过浏览器端的文件已经被我们本地映射掉了,因此直接在映射的html中注入 xhr.open()手动触发。
hook xhr.open()
定位到源码后,一个叫 _$ry的函数对传进来的 path做了一些操作,不过由于是在eval层中的代码,所以我们并不需要扣,模拟执行即可。
不管这个 path被加了什么样的签名,最终都会调用 xhr.open(),所以本地环境在初始化阶段就改写 xhr.open方法,在内部把url打印出来,即可取到签名信息。

4.3.1 a标签检测

期待落空
不过结果却并未如愿以偿,看来签名部分也检测了环境,且本地环境并不能通过检测,接下来通过代理日志信息把这些缺失的环境补上。
a标签检测
其实玄机就在于这里创建的 a标签,让我们先来看正常浏览器创建的 a标签,传入一段路径返回的是什么:
正常创建a标签并设置href
可以看到,正常浏览器的 a标签向 href属性设置了 path之后会自动根据 location的信息把url补全(拼接 location.origin),因此本地也需实现此功能。

措施是利用 proxy.set来更改 a标签对于 href属性的set行为,但 a标签的检测不仅仅需要实现 href的自动补全功能,还需要补充一些其他的功能,大部分可以直接返回 location对应的结果,除了 pathname,因此这里分成2部分实现:

  1. 通过 Object.assignlocation的所有可迭代对象复制给 a对象
  2. 重写 hrefpathname部分,最终补成如下图所示,重写运行成功拿到了带有签名的url
    a标签环境补充

比对本地与浏览器端的url签名部分(MmEwMD),结果完全一致,说明我们补的环境已经通过了a标签检测。
比对MmEwMD(url签名)是否一致

4.4 JSESSIONID1

通过在抓包软件中搜索,可以发现该参数来自 ./index.html接口(状态吗200)返回结果(response.headers)中 set-cookie值。
JSESSIONID1来源
自次4个参数全部逆向完毕,接下里开始组装最终的请求。

五、请求测试

请求测试
最终也是顺利拿到了目标数据,其中要注意的一点是 MmEwMD参数的加密材料来源之一是所请求 apipath部分,并不是逆向分析时的测试path。

六、总结

本文以采用rs4作为防护的网站作为逆向目标,一探瑞数那神秘面纱下的面孔,采用的方式是补环境,最终在代理日志的帮助下补全了所有的环境,在整个逆向过程中:

  1. 加深了对浏览器环境检测的了解,尤其是第四次cookie生成中的5个加密组件:字体指纹、插件环境、api环境、canvas_2d指纹和webgl指纹,以及url签名加密过程中对于 a标签检测的理解。
  2. 了解了如何写伪异步(其实还是同步)来模拟浏览器端的异步执行流程。
  3. 熟悉了利用代理日志嗅探缺失环境的思路。

转载请注明来源,欢迎对文章中的引用来源进行考证,欢迎指出任何有错误或不够清晰的表达,如有问题请邮件至2454612285@qq.com。
跃迁主页