JS hook

浏览器端JS Hook技术详解,如何透明劫持所有出站请求实现风控和防爬能力。

前文介绍了通过WAF在页面注入JS,通过劫持从浏览器页面发起的请求,从而实现透明零侵入的提供风控和防爬能力。本文主要讨论如何在浏览器端实现对所有出站请求的透明拦截。

劫持点分析

浏览器中发起网络请求的途径主要有以下几种,我们的目标:全部劫持,一个不漏。

Hook #1: AJAX劫持

原理

AJAX请求主要通过XMLHttpRequest 是浏览器提供的原生对象。劫持的核心思路是:

  1. 备份原生构造函数
  2. 替换为我们的 Proxy 构造函数
  3. 转发所有调用到原生对象,同时注入我们的逻辑
┌─────────────────────────────────────────────────────────┐
│                    Application Code                      │
│                  xhr.open() / xhr.send()                │
└─────────────────────┬───────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────┐
│               Hijacked XMLHttpRequest                    │
│  ┌───────────────────────────────────────────────────┐  │
│  │  • Intercept open() → Inject params to URL        │  │
│  │  • Intercept send() → Inject params to POST body  │  │
│  │  • Intercept response → Check risk_user flag      │  │
│  └───────────────────────────────────────────────────┘  │
└─────────────────────┬───────────────────────────────────┘
                      │
                      ▼
┌─────────────────────────────────────────────────────────┐
│                 Native XMLHttpRequest                    │
│                    (Real Network)                        │
└─────────────────────────────────────────────────────────┘

核心实现

var oldXML = window.XMLHttpRequest;  // 备份原生对象

function hookXMLHttpRequest() {
    var originXHR = new oldXML();     // 创建真实 XHR 实例
    var hookXHR = this;               // 代理对象引用
    
    // Hook open(): 劫持 URL,注入前端采集的风控数据
    hookXHR.open = function(method, url) {
        this._url = url;
        var parsedUrl = parseURL(url);
        if (isConfigUrl(parsedUrl) && method === 'GET') {
            parsedUrl.search += (parsedUrl.search ? '&ua=' : '?ua=') + encodeURIComponent(ua);
            url = combineUrl(parsedUrl);
        }
        originXHR.open.call(originXHR, method, url);
    }
    
    // Hook send(): POST 请求注入数据
    hookXHR.send = function(data) {
        if (this._method === 'POST' && isConfigUrl(this._parsedUrl)) {
            data = addPostData(data, 'ua', ua); 
        }
        originXHR.send.call(originXHR, data);
    }
    
    // Hook readystatechange: 响应拦截
    originXHR.onreadystatechange = function(e) {
        syncStatus(originXHR, hookXHR); 
        if (originXHR.readyState === 4 && originXHR.responseText === 'risk_user') {
            // 风险用户!阻断请求,触发人机验证
            block_list.push({ type: 'ajax', oldXHR: hookXHR, /* ... */ });
            showBlock();
            return;  
        }
        hookXHR.onreadystatechange?.call(hookXHR, e);
    }
}
function syncStatus(xhr, copy) {
        //需要随着state变化同步的属性
        try {       
           var syncList = ['readyState', 'response', 'responseText', 'responseType', 'responseXML', 'status', 'withCredentials', 'upload', 'statusText'];
            for (var i = 0; i < syncList.length; i++) {
                var name = syncList[i];
                copy[name] = xhr[name];
            }       
        } catch(e) {}
                
    }  

// 替换全局构造函数
window.XMLHttpRequest = hookXMLHttpRequest;

TODO List:
因为篇幅精简了具体实现,为了保证Ajax的完整实现,还需要实现如下细节

技术点说明
状态同步readyState, response, status 等属性需实时同步到代理对象
方法代理abort(), getResponseHeader() 等需要透传到原生对象
事件绑定onload, onerror, ontimeout 等事件需正确转发

Hook #2: JSONP 劫持

原理

JSONP 是通过在DOM上动态创建 <script> 标签实现跨域请求。通用的劫持点(部分网站可以基于JSONP的具体实现,需要进行部分自定义):

  • document.head.appendChild()
  • document.head.insertBefore()

核心实现

function hookJSONP() {
    function jsonpFactory(oldFn) {
        return function(ele) {
            if (ele.tagName !== 'SCRIPT') return oldFn.apply(this, arguments);
            
            var url = parseURL(ele.src);
            var callbackName = parseQuery(url.search).callback;
            
            if (isConfigUrl(url)) {
                // 1. 修改 URL 注入参数
                ele.src += '&ua=' + encodeURIComponent(ua);
                
                // 2. 劫持回调函数
                var oldCb = window[callbackName];
                window[callbackName] = function(data) {
                    if (data === 'risk_user') {
                        block_list.push({ type: 'jsonp', url: ele.src, oldCbs: oldCb });
                        showBlock();
                    } else {
                        oldCb?.(data);  // 正常执行业务回调
                    }
                };
            }
            
            return oldFn.apply(this, arguments);  // 执行原生方法
        }
    }
    
    // Hook DOM 操作方法
    hook(document.head, 'appendChild', jsonpFactory);
    hook(document.head, 'insertBefore', jsonpFactory);
}

function hook(obj, key, factory) {
    var oldFn = obj[key];
    obj[key] = factory(oldFn);  // 工厂模式:包装原函数
}

Hook #3: Form 表单劫持

原理

表单提交是比较传统的API请求方式。可以通过监听 submit 事件,在提交前动态注入隐藏字段。这样可以避免在用户浏览过程中导致form表单发生变化的情况。

核心实现

function hookForm() {
    var forms = document.forms;
    for (var i = 0; i < forms.length; i++) {
        var form = forms[i];
        if (isConfigUrl(parseURL(form.action))) {
            // 监听 submit 事件(捕获阶段)
            form.addEventListener('submit', function(e) {
                var input = document.createElement('input');
                input.type = 'hidden';
                input.name = 'ua';
                input.value = ua;  // 注入风控数据
                form.appendChild(input);
            }, true);
        }
    }
}

原理

用户点击 <a> 标签时的页面跳转也需要拦截,因为有可能通过跳转GET的方式实现API请求。通过监听 document 的 click 事件可以实现劫持。

核心实现

function hookLink() {
    eventHandler(document, 'click', function(e) {
        e = e || window.event;
        var target = e.target || e.srcElement;
        do {
            if (target.tagName == 'A') {
                if (isConfigUrl(target)) {
                    target.search += (target.search ? '&ua=' : '?ua=') + encodeURIComponent(ua);
                    target.src = combineUrl(target);
                }
                break;
            }
        } while (target = target.parentNode);
    });     
}