






















声明:本文所有内容仅适用于自己拥有或已获得授权的网站/项目的安全测试与加固。禁止用于未授权的第三方系统。
无论什么场景,逆向与安全测试的主线流程是固定的:
目标接口 → 抓包确认参数 → 定位JS来源 → 断点调试 → 还原逻辑 → 本地复现 → 修复加固
每个环节都有对应的工具和方法,后文逐一展开。
| 工具 | 适用场景 |
|---|---|
# 启动 mitmproxy(系统代理模式)
mitmproxy -p 8080
# 安装证书:浏览器访问 http://mitm.it
# 选择对应平台证书下载并安装为受信任的根证书
Charles / Fiddler 类似,均需安装根证书后才能解密 HTTPS 流量。
找到生成加密参数的 JS 代码,是逆向的第一步,也是最关键的一步。
DevTools → Sources → XHR/fetch Breakpoints
→ 点击 + 添加目标接口路径关键词(如 /api/sign)
→ 触发页面请求,自动断在 send() 前的调用栈顶
→ 在 Call Stack 面板逐层追溯,找到参数拼装位置
DevTools → Sources → Ctrl+Shift+F(全局搜索)
搜索:sign= / _signature / x-bogus / token / encrypt
找到赋值语句后,在该行打断点,触发请求即可捕获。
DevTools → Elements → 右键目标元素
→ Break on → subtree modifications / attribute modifications
适用于:点击按钮后触发加密逻辑的场景
在 Console 中注入 Hook 代码,拦截所有网络请求,并打印调用栈:
// Hook XMLHttpRequest
const originalOpen = XMLHttpRequest.prototype.open;
XMLHttpRequest.prototype.open = function(method, url) {
console.log('[XHR Hook]', method, url);
console.trace();
return originalOpen.apply(this, arguments);
};
// Hook fetch
const originalFetch = window.fetch;
window.fetch = function(...args) {
console.log('[Fetch Hook]', args[0]);
console.trace();
return originalFetch.apply(this, args);
};
// 监控 navigator.webdriver 何时被读取(常见反爬检测点)
Object.defineProperty(navigator, 'webdriver', {
get: function() {
console.trace('[检测点] navigator.webdriver 被读取');
return false;
}
});
同理可 Hook navigator.plugins、screen.width 等任意属性。
| 代码特征 | 混淆类型 | 还原方式 |
|---|---|---|
// 方法一:Hook eval,输出原始代码后再执行
const _eval = eval;
eval = function(code) {
console.log('[eval 内容]:', code);
return _eval(code);
};
// 方法二:在 Sources 面板中 Ctrl+H 全文替换
// 将 eval( 替换为 console.log(
// 刷新页面,Console 即输出原始代码
ob 混淆的核心是字符串数组加密 + 控制流平坦化,AST 方式可以系统性还原。
npm install @babel/core @babel/traverse @babel/generator @babel/types
// ast-deobfuscate.js
const fs = require('fs');
const parser = require('@babel/parser');
const traverse = require('@babel/traverse').default;
const generator = require('@babel/generator').default;
const t = require('@babel/types');
const code = fs.readFileSync('input.js', 'utf-8');
const ast = parser.parse(code);
traverse(ast, {
// 还原字符串数组引用:将 _0xabc(0x1) 替换为实际字符串值
CallExpression(path) {
// 根据具体混淆逻辑,识别并内联字符串解密调用
// 通常:找到字符串数组初始化位置 → 在 Node 中执行解密函数 → 替换所有调用
},
// 常量折叠:简化无意义运算
BinaryExpression(path) {
if (t.isNumericLiteral(path.node.left) && t.isNumericLiteral(path.node.right)) {
const result = eval(
`${path.node.left.value} ${path.node.operator} ${path.node.right.value}`
);
path.replaceWith(t.numericLiteral(result));
}
}
});
fs.writeFileSync('output.js', generator(ast).code);
console.log('还原完成 → output.js');
推荐配合 astexplorer.net 可视化分析节点结构。
| 代码特征 | 算法 | 备注 |
|---|---|---|
import hashlib, hmac, base64
from Crypto.Cipher import AES
from Crypto.Util.Padding import pad, unpad
# MD5
def md5(text: str) -> str:
return hashlib.md5(text.encode()).hexdigest()
# HMAC-SHA256
def hmac_sha256(key: str, message: str) -> str:
return hmac.new(key.encode(), message.encode(), hashlib.sha256).hexdigest()
# AES-CBC 加密
def aes_cbc_encrypt(key: str, iv: str, plaintext: str) -> str:
cipher = AES.new(key.encode(), AES.MODE_CBC, iv.encode())
ct = cipher.encrypt(pad(plaintext.encode(), AES.block_size))
return base64.b64encode(ct).decode()
# AES-CBC 解密
def aes_cbc_decrypt(key: str, iv: str, ciphertext: str) -> str:
ct = base64.b64decode(ciphertext)
cipher = AES.new(key.encode(), AES.MODE_CBC, iv.encode())
return unpad(cipher.decrypt(ct), AES.block_size).decode()
JSVMP(JS Virtual Machine Protection)是目前最难逆向的混淆方式。代码被编译成自定义字节码,由一个内置的 JS 解释器执行,无法直接阅读业务逻辑。常见于瑞数、数美、部分商业 SDK。
✓ 存在超大(数千行)的 switch-case 解释器主循环
✓ 大量位运算:>> << & | ^ >>>
✓ 一个核心调度函数反复跳转(pc 指针 / opcode 分发)
✓ 字节码以 ArrayBuffer / Uint8Array 形式存储
✓ 函数名极度混淆,逻辑完全无法直视
典型骨架特征(识别用):
while (true) {
var opcode = bytecode[pc++];
switch (opcode) {
case 0x01: stack.push(constant_pool[bytecode[pc++]]); break;
case 0x02: var b = stack.pop(), a = stack.pop(); stack.push(a + b); break;
case 0x03: pc = stack.pop(); break; // 跳转指令
// ... 几十到几百个 case
}
}
策略一:插桩追踪(最推荐)
不改源码,用条件断点输出每条指令的执行情况:
// DevTools → Sources → switch 行右键 → Add conditional breakpoint
// 条件填入(永远为 false,但会执行 console.log):
(console.log('op:', opcode, 'stack:', JSON.stringify(stack.slice(-3))), false)
// 或者直接修改本地副本,在 while(true) 第一行插入:
console.log('opcode:', opcode, 'stack:', JSON.stringify(stack.slice(-3)));
策略二:Hook 最终出口
JSVMP 无论内部多复杂,最终都要调用原生 API 输出结果。直接 Hook 出口:
// Hook 请求头,捕获最终加密值
const _setHeader = XMLHttpRequest.prototype.setRequestHeader;
XMLHttpRequest.prototype.setRequestHeader = function(name, value) {
if (/sign|token|auth/i.test(name)) {
console.log(`[Header 出口] ${name}: ${value}`);
console.trace();
}
return _setHeader.apply(this, arguments);
};
策略三:算法层面猜测(已知明文攻击)
不硬啃字节码,从输出结果反推:
1. 抓到最终生成的加密值(如 32 位 hex)
2. 猜测算法类型(MD5 / SHA256 / 自定义)
3. 枚举输入参数组合,对比输出是否匹配
4. 验证成功后用 Python 直接复现,无需理解 JSVMP 内部
策略四:Proxy 内存追踪
用 Proxy 劫持对象,追踪 JSVMP 读写了哪些关键数据:
function createTracer(name, target) {
return new Proxy(target, {
get(obj, prop) {
console.log(`[GET] ${name}.${String(prop)}`);
const val = obj[prop];
return typeof val === 'object' && val !== null
? createTracer(`${name}.${String(prop)}`, val)
: val;
},
set(obj, prop, val) {
console.log(`[SET] ${name}.${String(prop)} =`, val);
obj[prop] = val;
return true;
}
});
}
// 在解释器初始化前注入:
window = createTracer('window', window);
① 缩小范围:二分法注释代码,定位生成加密值的最小代码段
② 时间切入:Network 面板观察请求时机,在对应事件处打断点
③ 逆向出口:不分析过程,只 Hook 所有可能携带加密值的 API 出口
④ 整段复用:将 JSVMP 代码整体复制到 Node.js,补好环境后直接调用
// Webpack 4 特征:IIFE 结构,模块以对象形式传入
(function(modules) {
var installedModules = {};
function __webpack_require__(moduleId) { /* ... */ }
__webpack_require__.m = modules;
return __webpack_require__(__webpack_require__.s = 0);
})({ 0: function(module, exports, __webpack_require__) { /* ... */ } });
// Webpack 5 特征:搜索这些关键词
// __webpack_modules__ __webpack_require__ __webpack_exports__
var __webpack_modules__ = {
"./src/utils/sign.js": (module, exports, require) => { /* ... */ }
};
拿到 __webpack_require__ 后,可以直接加载任意内部模块:
// Webpack 4:在 __webpack_require__ 定义处打断点,断住后执行:
window.req = __webpack_require__;
// 枚举所有模块路径,找到目标
Object.keys(window.req.m).forEach(id => console.log(id));
// 直接加载并调用目标模块
const signModule = window.req('./src/utils/sign.js');
console.log(signModule.getSign({ page: 1 }));
// Webpack 5:在 bundle 最后一行打断点,断住后执行:
window._req = __webpack_require__;
// 枚举模块
for (let id in __webpack_modules__) console.log(id);
现代 SPA 大量使用动态 import(),关键模块可能在操作触发时才加载:
// Hook 懒加载函数,追踪 chunk 加载顺序
const _e = __webpack_require__.e;
__webpack_require__.e = function(chunkId) {
console.log('[懒加载 chunk]', chunkId);
return _e.apply(this, arguments);
};
// 实战技巧:触发目标功能(如登录、提交),观察 Network 中新加载的 chunk.js
// 将对应 chunk 保存到本地,结合主 bundle 分析
检查方法:在 bundle.js 末尾查找:
//# sourceMappingURL=bundle.js.map
如果存在,DevTools 会自动加载,Sources 面板直接显示原始源码!
也可手动挂载:Sources → 右键 js 文件 → Add source map → 填入 .map 文件 URL
// extract-modules.js:将 bundle 中所有模块提取为独立文件
const fs = require('fs');
const code = fs.readFileSync('bundle.js', 'utf-8');
const moduleRegex = /"?([\w/.-]+)"?\s*:\s*function\s*\([^)]*\)\s*\{([\s\S]*?)(?=,?\s*"[\w/.-]+":\s*function|\}\s*\))/g;
let match, count = 0;
fs.mkdirSync('modules', { recursive: true });
while ((match = moduleRegex.exec(code)) !== null) {
const [, id, content] = match;
const filename = id.replace(/\//g, '_').replace(/[^a-zA-Z0-9_.]/g, '') + '.js';
fs.writeFileSync(`modules/${filename}`, content);
count++;
}
console.log(`提取了 ${count} 个模块 → ./modules/`);
在 Node.js 中执行浏览器 JS 时,缺少 window/document/navigator 等浏览器对象,需要手动"补环境"。
核心思路:先跑起来,再按报错逐个补。
比手写快 10 倍,先用 Proxy 万能代理运行目标代码,从日志中找出所有被访问的属性:
// auto-env.js
function createAutoProxy(name = 'window', depth = 0) {
if (depth > 5) return undefined;
const cache = {};
return new Proxy(function(){}, {
get(target, prop) {
if (prop === Symbol.toPrimitive) return () => 0;
if (prop === 'toString') return () => `[AutoProxy:${name}]`;
if (prop === Symbol.toStringTag) return 'Object';
if (!(prop in cache)) {
console.log(`[访问] ${name}.${String(prop)}`);
cache[prop] = createAutoProxy(`${name}.${String(prop)}`, depth + 1);
}
return cache[prop];
},
set(target, prop, value) {
console.log(`[设置] ${name}.${String(prop)} =`, typeof value);
cache[prop] = value;
return true;
},
apply(target, thisArg, args) {
console.log(`[调用] ${name}(`, args.map(a => typeof a).join(', '), ')');
return createAutoProxy(`${name}()`, depth + 1);
},
construct(target, args) {
console.log(`[new] ${name}(${args.length} args)`);
return createAutoProxy(`new ${name}`, depth + 1);
}
});
}
// 挂载万能代理
global.window = createAutoProxy('window');
['document','navigator','location','screen','history','localStorage','sessionStorage']
.forEach(k => global[k] = createAutoProxy(k));
// 加载目标 JS,观察日志,记录所有被访问的属性
require('./target.js');
根据上一步的日志,精准补充真实值:
// precise-env.js
'use strict';
// ── 基础全局 ──────────────────────────────────────────────
global.window = global;
global.self = global;
global.globalThis = global;
// ── Navigator ─────────────────────────────────────────────
global.navigator = {
userAgent: 'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36',
appVersion: '5.0 (Windows NT 10.0; Win64; x64)',
platform: 'Win32',
language: 'zh-CN',
languages: ['zh-CN', 'zh', 'en'],
cookieEnabled: true,
onLine: true,
hardwareConcurrency: 8,
deviceMemory: 8,
maxTouchPoints: 0,
webdriver: false, // 关键:必须为 false
plugins: { length: 3 },
mimeTypes: { length: 4 },
vendor: 'Google Inc.',
product: 'Gecko',
};
// ── Location ──────────────────────────────────────────────
global.location = {
href: 'https://www.yoursite.com/page',
origin: 'https://www.yoursite.com',
protocol: 'https:',
host: 'www.yoursite.com',
hostname: 'www.yoursite.com',
port: '',
pathname: '/page',
search: '',
hash: '',
reload: () => {},
replace: (url) => console.log('[location.replace]', url),
};
// ── Screen & 窗口尺寸 ─────────────────────────────────────
global.screen = { width: 1920, height: 1080, availWidth: 1920, availHeight: 1040, colorDepth: 24, pixelDepth: 24 };
global.innerWidth = 1920; global.innerHeight = 937;
global.outerWidth = 1920; global.outerHeight = 1080;
global.devicePixelRatio = 1;
global.scrollX = 0; global.scrollY = 0;
// ── Document ──────────────────────────────────────────────
global.document = {
title: 'Your Site',
referrer: '',
cookie: '',
domain: 'www.yoursite.com',
URL: 'https://www.yoursite.com/page',
readyState: 'complete',
characterSet: 'UTF-8',
hidden: false,
visibilityState: 'visible',
createElement: (tag) => {
const el = {
tagName: tag.toUpperCase(), style: {}, className: '',
setAttribute: () => {}, getAttribute: () => null,
appendChild: () => {}, removeChild: () => {},
};
if (tag === 'canvas') {
el.width = 300; el.height = 150;
el.getContext = (type) => {
if (type === '2d') return {
fillStyle: '', strokeStyle: '', font: '', textBaseline: '',
fillText: () => {}, strokeText: () => {},
fillRect: () => {}, clearRect: () => {},
beginPath: () => {}, closePath: () => {}, arc: () => {},
fill: () => {}, stroke: () => {},
measureText: (text) => ({ width: text.length * 8 }),
getImageData: (x, y, w, h) => ({ data: new Uint8ClampedArray(w * h * 4).fill(128) }),
putImageData: () => {},
createLinearGradient: () => ({ addColorStop: () => {} }),
save: () => {}, restore: () => {},
translate: () => {}, rotate: () => {}, scale: () => {},
};
return null; // webgl 等返回 null
};
el.toDataURL = () =>
'data:image/png;base64,iVBORw0KGgoAAAANSUhEUgAAAAEAAAABCAYAAAAfFcSJAAAADUlEQVR42mNk+M9QDwADhgGAWjR9awAAAABJRU5ErkJggg==';
}
return el;
},
getElementById: () => null,
querySelector: () => null,
querySelectorAll: () => [],
getElementsByTagName: () => [],
addEventListener: () => {},
removeEventListener: () => {},
createEvent: () => ({ initEvent: () => {} }),
dispatchEvent: () => true,
};
// ── 定时器(同步执行,避免异步阻塞)─────────────────────────
global.setTimeout = (fn, _d, ...args) => { try { fn(...args); } catch(e){} return 1; };
global.clearTimeout = () => {};
global.setInterval = (fn, _d, ...args) => { try { fn(...args); } catch(e){} return 1; };
global.clearInterval = () => {};
global.requestAnimationFrame = (fn) => { fn(Date.now()); return 1; };
// ── Performance API ───────────────────────────────────────
global.performance = {
now: () => Date.now() - 1700000000000,
timing: { navigationStart: Date.now() - 5000 },
getEntriesByType: () => [],
mark: () => {}, measure: () => {},
};
// ── Crypto API ────────────────────────────────────────────
const nodeCrypto = require('crypto');
global.crypto = {
getRandomValues: (arr) => {
const bytes = nodeCrypto.randomBytes(arr.byteLength);
arr.set(new arr.constructor(bytes.buffer));
return arr;
},
subtle: nodeCrypto.webcrypto?.subtle,
};
// ── 其他常用 ──────────────────────────────────────────────
global.atob = (str) => Buffer.from(str, 'base64').toString('binary');
global.btoa = (str) => Buffer.from(str, 'binary').toString('base64');
global.XMLHttpRequest = function() {
return { open: () => {}, send: () => {}, setRequestHeader: () => {}, addEventListener: () => {}, readyState: 4, status: 200 };
};
console.log('[env] 环境初始化完成');
| 报错信息 | 原因 | 解决方案 |
|---|---|---|
用 Node.js 内置 vm 模块运行目标 JS,避免污染当前进程全局变量:
const vm = require('vm');
const fs = require('fs');
const sandbox = {
console,
setTimeout: (fn) => fn(),
// ... 将上面的补环境对象全部放进来
};
const code = fs.readFileSync('target.js', 'utf-8');
const script = new vm.Script(code, { filename: 'target.js' });
const ctx = vm.createContext(sandbox);
script.runInContext(ctx);
// 调用目标函数
const result = vm.runInContext('getSign({ page: 1 })', ctx);
console.log('签名结果:', result);
# 方案一:PyExecJS(轻量,适合简单场景)
import execjs
with open('target_with_env.js') as f:
ctx = execjs.compile(f.read())
result = ctx.call('getSign', {'page': 1, 'size': 20})
print('签名:', result)
# 方案二:py-mini-racer(V8 引擎,性能更好)
from py_mini_racer import MiniRacer
ctx = MiniRacer()
ctx.eval(open('target_with_env.js').read())
print('签名:', ctx.call('getSign', {'page': 1}))
# 方案三:subprocess 调用 Node.js(最稳定,推荐生产使用)
import subprocess, json
def call_js(func_name, *args):
script = f"""
require('./env.js');
require('./target.js');
const result = {func_name}(...{json.dumps(list(args))});
process.stdout.write(JSON.stringify(result));
"""
r = subprocess.run(['node', '-e', script], capture_output=True, text=True, timeout=10)
return json.loads(r.stdout)
print(call_js('getSign', {'page': 1}))
自测自己的网站,看看以下弱检测点是否存在,在 DevTools Console 中逐一执行:
// 1. webdriver 检测(正常浏览器应为 undefined 或 false)
console.log('webdriver:', navigator.webdriver);
// 2. plugins 检测(headless 下通常为 0)
console.log('plugins length:', navigator.plugins.length);
// 3. languages 检测(headless 可能为空数组)
console.log('languages:', navigator.languages);
// 4. Chrome 对象检测(headless 下为 undefined)
console.log('window.chrome:', window.chrome);
// 5. 窗口尺寸一致性(headless 下 outerWidth 常为 0)
console.log('outer:', window.outerWidth, window.outerHeight);
console.log('inner:', window.innerWidth, window.innerHeight);
// 6. Canvas 指纹(不同机器/浏览器值不同)
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
ctx.font = '14px Arial';
ctx.fillText('fingerprint_test', 10, 30);
console.log('canvas:', canvas.toDataURL().substring(0, 80));
// 7. AudioContext 支持(headless 通常返回 null)
try {
const ac = new AudioContext();
console.log('AudioContext state:', ac.state);
} catch(e) {
console.log('AudioContext: not supported');
}
防御者核心思路:知道攻击者用哪些方法,就把对应漏洞堵死。
本章参考瑞数、加速乐、五秒盾、Akamai 等商业风控系统的防御体系,
整理为自有系统可直接落地的加固方案。
商业风控的核心壁垒。单一维度极易伪造,组合维度让攻击成本指数级上升。
原理:同一段绘图代码在不同 GPU / 驱动 / 操作系统下,渲染结果存在微小差异。
攻击者弱点:Node.js / Headless 环境无法真实渲染,toDataURL() 返回空值或固定值,与真实浏览器截然不同。
// 前端指纹采集(多操作混合,增加伪造难度)
function getCanvasFingerprint() {
const canvas = document.createElement('canvas');
canvas.width = 240; canvas.height = 60;
const ctx = canvas.getContext('2d');
ctx.textBaseline = 'alphabetic';
ctx.fillStyle = '#f60';
ctx.fillRect(125, 1, 62, 20);
ctx.fillStyle = '#069';
ctx.font = '11pt "Times New Roman"';
ctx.fillText('Cwm fjordbank glyphs vext quiz 😀', 2, 15);
ctx.fillStyle = 'rgba(102, 204, 0, 0.7)';
ctx.font = '18pt Arial';
ctx.fillText('Cwm fjordbank', 4, 45);
// WebGL 指纹(GPU 型号信息,伪造成本极高)
const gl = document.createElement('canvas').getContext('webgl');
const glInfo = gl ? {
renderer: gl.getParameter(gl.RENDERER),
vendor: gl.getParameter(gl.VENDOR),
version: gl.getParameter(gl.VERSION),
} : null;
return { canvas: canvas.toDataURL(), webgl: glInfo };
}
加固建议:
SwiftShader 或空字符串原理:音频处理的浮点运算因硬件而异,产生微小精度差异。
攻击者弱点:Headless 下 AudioContext 输出全零,极易识别。
function getAudioFingerprint() {
return new Promise((resolve) => {
try {
const ctx = new (window.AudioContext || window.webkitAudioContext)();
const analyser = ctx.createAnalyser();
const oscillator = ctx.createOscillator();
const compressor = ctx.createDynamicsCompressor();
oscillator.connect(compressor);
compressor.connect(analyser);
analyser.connect(ctx.destination);
oscillator.start(0);
setTimeout(() => {
const arr = new Float32Array(analyser.frequencyBinCount);
analyser.getFloatFrequencyData(arr);
oscillator.stop();
ctx.close();
// 取前 30 个浮点值拼接即为指纹
resolve(arr.slice(0, 30).join(','));
}, 100);
} catch(e) {
resolve('unsupported'); // headless 常见
}
});
}
原理:每台机器安装的字体集合各不相同,可通过 measureText 差异检测。
攻击者弱点:虚拟机 / Headless 环境只有系统默认字体,字体集极小,特征明显。
function getInstalledFonts() {
const testFonts = [
'Arial', 'Times New Roman', 'Courier New', 'Georgia',
'Microsoft YaHei', 'SimSun', 'PingFang SC', // 中文字体
'Helvetica Neue', 'Futura', 'Gill Sans', // Mac 字体
'Segoe UI', 'Calibri', 'Consolas', // Windows 字体
];
const canvas = document.createElement('canvas');
const ctx = canvas.getContext('2d');
const result = {};
testFonts.forEach(font => {
ctx.font = '16px monospace';
const base = ctx.measureText('mmmmmmmmmmlli').width;
ctx.font = `16px "${font}", monospace`;
const test = ctx.measureText('mmmmmmmmmmlli').width;
result[font] = test !== base; // true = 字体存在
});
return result;
}
机器行为与人类行为存在本质差异,这是商业风控最难被复制的部分。
攻击者弱点:
// 前端行为数据采集
class BehaviorCollector {
constructor() {
this.mouseTrail = [];
this.clickEvents = [];
this.keyEvents = [];
this.startTime = Date.now();
this._init();
}
_init() {
// 鼠标轨迹(50ms 降采样)
let lastMouseTime = 0;
document.addEventListener('mousemove', (e) => {
const now = Date.now();
if (now - lastMouseTime < 50) return;
lastMouseTime = now;
this.mouseTrail.push({ x: e.clientX, y: e.clientY, t: now - this.startTime });
if (this.mouseTrail.length > 200) this.mouseTrail.shift();
});
// 点击偏移(真实用户不总点中心)
document.addEventListener('click', (e) => {
const rect = e.target.getBoundingClientRect();
this.clickEvents.push({
offsetX: e.clientX - rect.left,
offsetY: e.clientY - rect.top,
elementWidth: rect.width,
elementHeight: rect.height,
t: Date.now() - this.startTime,
});
});
// 键盘节奏(按键间隔)
let lastKeyTime = 0;
document.addEventListener('keydown', () => {
const now = Date.now();
this.keyEvents.push({ interval: now - lastKeyTime, t: now - this.startTime });
lastKeyTime = now;
});
}
getFeatures() {
const trail = this.mouseTrail;
if (trail.length < 2) return { suspicious: true, reason: 'no_mouse_movement' };
const speeds = [];
for (let i = 1; i < trail.length; i++) {
const dx = trail[i].x - trail[i-1].x;
const dy = trail[i].y - trail[i-1].y;
const dt = trail[i].t - trail[i-1].t || 1;
speeds.push(Math.sqrt(dx*dx + dy*dy) / dt);
}
const mean = arr => arr.reduce((a,b) => a+b, 0) / arr.length;
const variance = arr => {
const m = mean(arr);
return arr.reduce((a,b) => a + (b-m)**2, 0) / arr.length;
};
return {
mousePoints: trail.length,
avgSpeed: mean(speeds),
speedVariance: variance(speeds), // 太低 → 机器
clickCount: this.clickEvents.length,
keyIntervalVariance: variance(this.keyEvents.map(e => e.interval)), // 太低 → 机器
};
}
}
# 服务端行为风险评分
def calculate_risk_score(data: dict) -> dict:
score, reasons = 0, []
# 无鼠标移动
if data.get('mousePoints', 0) == 0:
score += 40; reasons.append('no_mouse_movement')
# 速度过于均匀(机器特征)
if data.get('speedVariance', 0) < 0.01:
score += 30; reasons.append('unnatural_mouse_speed')
# 点击太精准(总落在元素中心)
clicks = data.get('clickEvents', [])
if clicks:
precise = sum(1 for c in clicks
if abs(c['offsetX'] - c['elementWidth']/2) < 2
and abs(c['offsetY'] - c['elementHeight']/2) < 2)
if precise / len(clicks) > 0.8:
score += 25; reasons.append('too_precise_clicks')
# 键盘节奏太均匀
if data.get('keyIntervalVariance', 0) < 5:
score += 20; reasons.append('robotic_keyboard_rhythm')
level = 'high' if score >= 60 else 'medium' if score >= 30 else 'low'
return { 'risk_score': min(score, 100), 'risk_level': level, 'reasons': reasons }
这是瑞数、Akamai 等系统前端 JS 的核心检测逻辑,自有系统可直接复用:
const EnvDetector = {
// 检测 Headless 浏览器(得分越高越可疑,满分 6)
headlessScore() {
return [
!window.chrome, // 无 chrome 对象
navigator.plugins.length === 0, // 无插件
!navigator.languages || navigator.languages.length === 0, // 无语言列表
navigator.webdriver === true, // webdriver 标记
screen.width === 0 || screen.height === 0, // 屏幕尺寸异常
window.outerWidth === 0 && window.outerHeight === 0, // outer 尺寸为 0
].filter(Boolean).length;
},
// 检测自动化框架遗留变量
isAutomationTool() {
return !!(
window._phantom || window.callPhantom || // PhantomJS
window.__nightmare || // Nightmare.js
window._selenium || window.domAutomation || // Selenium
document.$cdc_asdjflasutopfhvcZLmcfl_ || // ChromeDriver 遗留变量
window.__webdriver_evaluate || window.__selenium_evaluate
);
},
// 检测虚拟机(通过 CPU 密集运算时间误差)
isVM() {
const start = performance.now();
let c = 0; for (let i = 0; i < 1000000; i++) c++;
const elapsed = performance.now() - start;
return elapsed < 1 || elapsed > 100; // 正常机器约 5-15ms
},
// UA 与环境一致性交叉验证
consistencyCheck() {
return {
uaChromeMismatch: navigator.userAgent.includes('Chrome') && !window.chrome,
platformMismatch: navigator.userAgent.includes('Windows') && !navigator.platform.includes('Win'),
langMismatch: navigator.language !== navigator.languages?.[0],
};
},
report() {
return {
headlessScore: this.headlessScore(),
isAutomation: this.isAutomationTool(),
isVM: this.isVM(),
consistency: this.consistencyCheck(),
};
}
};
每种客户端(Chrome / Python requests / curl / Node.js)的 TLS 握手参数不同,包括 TLS 版本、加密套件顺序、扩展列表、椭圆曲线等,服务端可以直接识别。
加固方案:
nginx-module-ja3 模块)import ipaddress
# 已知数据中心 IP 段(需持续维护更新)
DATACENTER_RANGES = [
'13.0.0.0/8', # AWS
'34.0.0.0/8', # GCP
'40.0.0.0/8', # Azure
'47.0.0.0/8', # 阿里云
'119.29.0.0/16', # 腾讯云
]
def assess_ip_risk(ip: str, headers: dict) -> dict:
addr = ipaddress.ip_address(ip)
risk = {}
# 数据中心 IP
risk['is_datacenter'] = any(
addr in ipaddress.ip_network(r) for r in DATACENTER_RANGES
)
# UA 与 Accept-Language 不一致
ua = headers.get('User-Agent', '')
lang = headers.get('Accept-Language', '')
risk['lang_ua_mismatch'] = ('Windows' in ua and 'zh' not in lang and 'en' not in lang)
# 缺少 br 压缩(正常 Chrome 必带 gzip, deflate, br)
risk['missing_br'] = 'br' not in headers.get('Accept-Encoding', '')
return risk
核心流程:
① 用户首次访问 → 服务端下发带随机 challengeKey 的 JS 挑战代码
② 浏览器执行 JS → 采集设备指纹 → 用 SubtleCrypto 计算 Token
③ 带 Token 再次请求 → 服务端验证合法性
④ Token 绑定:IP + UA + 设备指纹 + 时间窗口(5 分钟有效)
攻击者面临的困境:
✗ 必须真实执行 JS(不能跳过)
✗ 必须有完整浏览器环境(补环境成本极高)
✗ Token 有时效,无法复用
✗ 每次 challengeKey 不同,无法固化破解脚本
// 前端:生成 Challenge Token
async function generateChallengeToken(challengeKey) {
const fp = {
canvas: getCanvasFingerprint().canvas.substring(100, 150),
ua: navigator.userAgent,
lang: navigator.language,
tz: Intl.DateTimeFormat().resolvedOptions().timeZone,
screen: `${screen.width}x${screen.height}x${screen.colorDepth}`,
plugins: navigator.plugins.length,
ts: Date.now(),
};
const payload = JSON.stringify(fp) + challengeKey;
const data = new TextEncoder().encode(payload);
const hash = await crypto.subtle.digest('SHA-256', data);
const token = Array.from(new Uint8Array(hash))
.map(b => b.toString(16).padStart(2,'0')).join('');
return { token, fingerprint: fp };
}
# 服务端:验证 Token
import hashlib, json, time
def verify_token(token: str, fingerprint: dict, challenge_key: str) -> tuple:
payload = json.dumps(fingerprint, separators=(',',':')) + challenge_key
expected = hashlib.sha256(payload.encode()).hexdigest()
if token != expected:
return False, 'token_mismatch'
ts = fingerprint.get('ts', 0) / 1000
if abs(time.time() - ts) > 300: # 5 分钟有效期
return False, 'token_expired'
return True, 'ok'
<!-- 页面中插入对普通用户不可见的链接,爬虫可能会访问 -->
<a href="/honeypot/trap" style="display:none;position:absolute;left:-9999px;opacity:0">
hidden
</a>
# 服务端:蜜罐路径访问直接标记为高风险
@app.route('/honeypot/<path:path>')
def honeypot(path):
ip = request.remote_addr
mark_as_suspicious(ip, reason='honeypot_triggered', path=path)
return '', 404 # 正常返回 404,不暴露蜜罐存在
攻击者在分析自有系统时,常常会 Hook XMLHttpRequest、fetch 等原生函数。可以在前端埋入探针,检测到 Hook 立即上报:
function detectHook() {
const suspicious = [];
const nativeFns = [
[XMLHttpRequest.prototype.open, 'XHR.open'],
[XMLHttpRequest.prototype.send, 'XHR.send'],
[fetch, 'fetch'],
[JSON.stringify, 'JSON.stringify'],
[JSON.parse, 'JSON.parse'],
[Object.defineProperty, 'Object.defineProperty'],
];
nativeFns.forEach(([fn, name]) => {
const str = Function.prototype.toString.call(fn);
if (!str.includes('[native code]')) {
suspicious.push(name);
}
});
// 检测 Function.prototype.toString 自身是否被篡改
const toStr = Object.getOwnPropertyDescriptor(Function.prototype, 'toString');
if (toStr && toStr.value !== Function.prototype.toString) {
suspicious.push('Function.prototype.toString');
}
if (suspicious.length > 0) {
// 上报到服务端,标记该用户/IP 为可疑
navigator.sendBeacon('/api/risk-report', JSON.stringify({
type: 'hook_detected',
targets: suspicious,
ts: Date.now(),
}));
}
return suspicious;
}
策略:每次响应中,关键参数名由服务端动态生成(非固定字符串)
效果:爬虫硬编码参数名后,下次更新即失效,维护成本极高
实现:服务端渲染时注入当次参数名映射,前端 JS 从中读取
参考商业系统的验证码降级体系:
| 风险等级 | 触发条件 | 响应策略 |
|---|---|---|
| 优先级 | 措施 | 攻击者对抗成本 | 实现难度 |
|---|---|---|---|
核心结论:没有任何单一措施能防住所有攻击。商业系统的真正壁垒在于多维度组合 + 持续动态更新。自有系统应优先落地服务端鉴权和行为分析,这两项性价比最高,且攻击者无法通过客户端手段绕过。
| 用途 | 工具 / 地址 |
|---|---|
本文整理自实战安全测试经验,所有代码示例均以防御加固为目的。如发现自有系统存在以上风险点,建议优先修复服务端鉴权逻辑,再逐步完善前端防护体系。
此内容由惯性聚合(RSS阅读器)自动聚合整理,仅供阅读参考。 原文来自 — 版权归原作者所有。