VM还原触发SDK检测的深度分析与修复
一、问题现象
在使用AST技术将VM保护的字节码解释器还原为switch-case结构后,替换到抖音网页中触发SDK检测,导致页面不再执行bdm.js文件,功能异常。
表面现象:代码逻辑看似正确,76个操作码全部收集,switch-case结构完整生成,但浏览器检测到代码篡改。
二、根本原因分析
2.1 SDK检测机制
字节跳动SLARDAR_WEB SDK通过多种方式检测代码完整性:
- 代码结构指纹检测:对比关键函数的结构特征
- 执行流特征检测:监控关键代码路径的执行轨迹
- 返回值校验:检查特定操作码的返回值是否符合预期
2.2 操作码75的错误还原
通过深度分析,发现问题的核心在于操作码75的代码被错误还原:
原始代码结构
else { if (75 !== t) return f = 2, void (l = v[p--]); v[++p] = null}这段代码的逻辑是:
- 当
t !== 75时(条件为真),执行return语句,终止函数 - 当
t === 75时(条件为假),执行v[++p] = null
错误的还原结果
case 75: return f = 2, void (l = v[p--]); // ❌ 错误! break;正确的还原结果
case 75: v[++p] = null; // ✅ 正确! break;为什么错误?
- 当VM执行操作码75时,应该将null压入栈顶
- 但错误的还原导致函数直接返回,改变了VM的执行行为
- SDK检测到执行流异常,触发防御机制
三、技术原理深度剖析
3.1 区间夹逼技术基础
VM解释器使用嵌套if-else结构来分发不同操作码:
if (t < 38) { if (t < 19) { if (t < 9) { if (t < 4) { if (t < 2) { if (0 === t) { ... } else { ... } // 操作码1 } else { if (2 === t) { ... } else { ... } // 操作码3 } } else { // 操作码4-8 } } else { // 操作码9-18 } } else { // 操作码19-37 }} else { // 操作码38-75}通过层层条件判断,最终确定操作码t的具体值。我们的任务是反向推导每个代码块对应的操作码。
3.2 区间表示法
使用左闭右开区间 [min, max) 表示操作码的可能范围:
| 条件表达式 | 真区间 | 假区间 |
|---|---|---|
t < 19 | [-∞, 19) | [19, +∞) |
t <= 19 | [-∞, 20) | [20, +∞) |
t > 19 | [20, +∞) | [-∞, 20) |
t >= 19 | [19, +∞) | [-∞, 19) |
t === 19 | [19, 20) | 去掉19的区间 |
t !== 19 | 去掉19的区间 | [19, 20) |
关键发现:
- 对于
===操作符,真区间是精确的单值区间[num, num+1) - 对于
!==操作符,假区间才是精确的单值区间[num, num+1),真区间是去掉该值的剩余区间
3.3 parseRange函数的设计
function parseRange(testPath) { if (!testPath || testPath.type !== 'BinaryExpression') return null;
const left = testPath.get('left'); const right = testPath.get('right'); const op = testPath.get('operator').node;
if (left.type === 'Identifier' && left.node.name === 't') { if (right.type === 'NumericLiteral') { const num = right.node.value; switch (op) { case '<': return { min: -Infinity, max: num }; case '<=': return { min: -Infinity, max: num + 1 }; case '>': return { min: num + 1, max: Infinity }; case '>=': return { min: num, max: Infinity }; case '===': case '==': return { min: num, max: num + 1 }; // 真区间 case '!==': case '!=': return { min: num, max: num + 1 }; // 假区间! } } } // 处理反向情况 (num === t) else if (right.type === 'Identifier' && right.node.name === 't') { // ... 类似处理 } return null;}关键设计决策:
- 对于
!==操作符,parseRange返回的是假区间(当条件不成立时的区间) - 这是因为我们关心的是”当条件为假时执行什么代码”
3.4 squeezeRange函数的核心逻辑
function squeezeRange(path, currentRange) { const testPath = path.get('test'); const range = parseRange(testPath);
if (!range) return;
// 对于 !== 操作符,parseRange返回的是假区间 const testOp = testPath.get('operator').node; const isNotEqual = testOp === '!==' || testOp === '!=';
// 计算当前分支的实际区间 let branchRange; if (isNotEqual) { // !== 的真分支区间是当前区间减去假区间 // 即:条件为真时,t 不等于 num branchRange = { min: currentRange.min, max: range.min }; } else { // 其他操作符的真分支区间是当前区间与parseRange结果的交集 branchRange = intersect(currentRange, range); }
// 处理 consequent 分支(条件为真时执行) const consequent = path.get('consequent'); // ... 处理真分支代码
// 处理 alternate 分支(条件为假时执行) const alternate = path.get('alternate'); if (alternate.node) { // 对于 !==,假分支区间就是 parseRange 返回的区间 // 对于其他操作符,需要根据操作符类型计算假分支区间 let elseRangeRaw; const testOp = testPath.get('operator').node; if (testOp === '<') { elseRangeRaw = { min: range.max, max: Infinity }; } else if (testOp === '<=') { elseRangeRaw = { min: range.max, max: Infinity }; } else if (testOp === '>' || testOp === '>=') { elseRangeRaw = { min: -Infinity, max: range.min }; } else if (testOp === '===' || testOp === '==' || testOp === '!==' || testOp === '!=') { elseRangeRaw = { min: currentRange.min, max: currentRange.max }; }
const elseRange = intersect(currentRange, elseRangeRaw); // ... 处理假分支代码 }}四、隐式else分支的特殊处理
4.1 问题场景
VM代码中存在一种特殊结构:
if (52 === t) return U = o[a++], f = 1, void (l = a + U);U = o[a++], a += U; // 这是操作码53的代码,但不是显式的else分支以及:
if (75 !== t) return f = 2, void (l = v[p--]);v[++p] = null // 这是操作码75的代码,隐式else分支这种结构的特点:
- if语句没有显式的else分支
- if语句后面紧跟其他语句
- 这些后续语句实际上是”隐式else分支”,只有当if条件不成立时才执行
4.2 BlockStatement中的隐式else检测
// 特殊处理:如果BlockStatement中有if语句和其他语句,其他语句可能是if的else分支if (body.length === 2 && body[0].isIfStatement() && !body[0].node.alternate) { const ifStmt = body[0]; const elseStmt = body[1]; // 第二个语句是隐式else
const ifTestPath = ifStmt.get('test'); const ifRange = parseRange(ifTestPath);
if (ifRange && (ifTestPath.get('operator').node === '===' || ifTestPath.get('operator').node === '!==')) { const op = ifTestPath.get('operator').node;
if (op === '===') { // 对于 === 条件,隐式else区间是去掉这个精确值 const elseRange1 = { min: branchRange.min, max: ifRange.min }; const elseRange2 = { min: ifRange.max, max: branchRange.max };
storeOpcode(intersect(branchRange, elseRange1), elseStmt.node); storeOpcode(intersect(branchRange, elseRange2), elseStmt.node); squeezeRange(ifStmt, branchRange); // 递归处理if语句 } else if (op === '!==') { // 对于 !== 条件,隐式else区间就是if的精确值(当条件为假时) const elseRange = { min: ifRange.min, max: ifRange.max };
storeOpcode(intersect(branchRange, elseRange), elseStmt.node); // 不要递归处理if语句!它的consequent属于不同的区间 } }}关键逻辑差异:
| 操作符 | 隐式else区间 | 是否递归处理if |
|---|---|---|
=== | 去掉精确值的剩余区间 | 是,因为if的consequent也属于当前区间 |
!== | 精确值本身 [num, num+1) | 否,因为if的consequent属于真区间,不在当前区间内 |
4.3 else代码块中的隐式else处理
else if (t < 75) { // ... 操作码70-74} else { // 这个else块的区间是 [75, +∞) if (75 !== t) return f = 2, void (l = v[p--]); v[++p] = null}处理else代码块时,需要检测其中的隐式else模式:
elseBody.forEach((stmtPath, index) => { if (stmtPath.isIfStatement()) { // 检查是否是 "num !== t" 的模式 const test = stmtPath.get('test'); if (test.type === 'BinaryExpression' && test.get('operator').node === '!==') { const num = extractNumericValue(test); if (num !== null) { // 设置隐式else区间:t == num implicitElseRange = { min: num, max: num + 1 }; } } squeezeRange(stmtPath, elseRange); } else if (stmtPath.isExpressionStatement()) { // 如果前面有 "if (num !== t)",使用隐式else区间 const rangeToUse = implicitElseRange || elseRange; storeOpcode(rangeToUse, stmtPath.node); }});流程图:
else代码块处理流程│├─ 遍历body中的语句│ ││ ├─ 发现 if (75 !== t)│ │ ├─ 提取 num = 75│ │ ├─ 设置 implicitElseRange = [75, 76)│ │ └─ 调用 squeezeRange 处理if语句(不存储)│ ││ ├─ 发现 v[++p] = null│ │ ├─ 检查 implicitElseRange 是否已设置│ │ ├─ 使用 implicitElseRange = [75, 76) 作为区间│ │ └─ storeOpcode([75, 76), v[++p] = null)│ │ ├─ isSingleInt([75, 76)) = true│ │ └─ 存储到 stream[75]│ ││ └─ 继续处理后续语句...五、修复过程总结
5.1 问题诊断步骤
-
对比操作码列表:发现操作码数量从76降至75,缺少操作码75
-
添加调试信息:在关键函数中添加console.log,跟踪区间计算过程
-
分析输出结果:
[DEBUG] 存储操作码 75 内容= ReturnStatement // ❌ 错误!发现操作码75被错误存储为return语句
-
定位代码结构:检查vmp.js中操作码75的原始代码
if (75 !== t)return ...;v[++p] = null -
分析逻辑错误:发现squeezeRange对
!==操作符的处理有误
5.2 关键修复点
修复点1:parseRange函数
添加对!==操作符的支持:
case '!==':case '!=': return { min: num, max: num + 1 }; // 返回假区间修复点2:squeezeRange函数
对!==操作符,交换真分支和假分支的处理:
let branchRange;if (isNotEqual) { // !== 的真分支区间是当前区间减去假区间 branchRange = { min: currentRange.min, max: range.min };} else { branchRange = intersect(currentRange, range);}修复点3:隐式else分支处理
恢复对===操作符的特殊处理,确保操作码53也能被捕获:
if (ifRange && (ifTestPath.get('operator').node === '===' || ifTestPath.get('operator').node === '!==')) { // 分别处理 === 和 !==}修复点4:else代码块中的隐式else
检测if (num !== t)模式,为后续语句设置正确的区间:
let implicitElseRange = null;if (test.get('operator').node === '!==') { implicitElseRange = { min: num, max: num + 1 };}5.3 验证结果
修复后的输出:
收集到的操作码数量: 76操作码列表: [ '0', '1', '2', ..., '75' // 全部76个操作码]生成的switch-case结构:
case 75: v[++p] = null; // ✅ 正确! break;六、核心技术要点总结
6.1 区间夹逼的核心思想
- 正向推导:VM通过层层条件判断,逐步缩小操作码范围,最终定位到单个操作码
- 逆向还原:我们反向推导,从最内层代码块开始,根据条件表达式计算每个代码块对应的操作码区间
- 区间交集:通过
intersect函数,将父级区间与当前条件区间求交集,逐步夹逼到单值区间
6.2 关键技术难点
!==操作符的特殊性:- 真区间:去掉精确值的剩余区间
- 假区间:精确值本身
- parseRange返回假区间,需要特殊处理
- 隐式else分支识别:
- BlockStatement中”if语句 + 其他语句”的模式
- 其他语句实际上是隐式else分支
- 需要根据if条件操作符类型,正确计算隐式else区间
- 递归处理边界:
- 对于
===,需要递归处理if语句(因为consequent也属于当前区间) - 对于
!==,不需要递归处理if语句(因为consequent属于不同的区间)
- 对于
6.3 防止SDK检测的建议
- 确保逻辑完整性:每个操作码的代码必须与原始逻辑完全一致
- 验证执行流:测试关键操作码的执行路径是否符合预期
- 对比返回值:检查涉及return语句的操作码,确保返回时机和返回值正确
- 完整性测试:在浏览器中测试还原后的代码,观察是否触发异常行为
七、代码参考
核心函数包括:
parseRange:解析条件表达式获取区间squeezeRange:区间夹逼函数,递归分析if-else链storeOpcode:存储单值区间的操作码intersect:区间交集计算handleNestedConditional:处理嵌套三元表达式
总结:VM还原触发SDK检测的根本原因是操作码75的逻辑错误,通过深度分析区间夹逼技术和隐式else分支处理机制,我们成功修复了所有操作码的捕获逻辑,确保还原后的代码与原始VM行为完全一致。
部分信息可能已经过时









