Loading

【JS逆向百例】某眼查风控参数 if-match 逆向分析

NmOVD7.png

声明

本文章中所有内容仅供学习交流使用,不用于其他任何目的,不提供完整代码,抓包内容、敏感网址、数据接口等均已做脱敏处理,严禁用于商业用途和非法用途,否则由此产生的一切后果均与作者无关!

本文章未经许可禁止转载,禁止任何修改后二次传播,擅自使用本文讲解的技术而导致的任何意外,作者均不负责,若有侵权,请在公众号【K哥爬虫】联系作者立即删除!

前言

最近浏览私信时,发现有小伙伴在跑某眼查相关业务时,遇到一点问题,说自己被风控参数按住了,但是自己的解决办法又不是特别效率,特此来向 K 哥求助,K 哥会尽力满足粉丝需求,本文就对该参数进行逆向分析,仅作为学习研究:

Nmu6tf.png

逆向目标

  • 目标:某眼查 if-match 参数
  • 网址:aHR0cHM6Ly93d3cudGlhbnlhbmNoYS5jb20vc2VhcmNoP2tleT3ku4rml6XlrpzkvJE=

抓包分析

通过调试可知,在触发风控以后的响应状态码为 429,同时返回一段经过混淆的 JS 代码,如下图所示:

NmuFoc.png

此后,会调用 sorry/verifyCaptcha4.json 接口进行验证:

NmgdHY.png

同时协议头里新增了 X-If-Match 参数:

Nmge1H.png

校验通过的话则可以继续请求搜索接口,后续频繁请求可能会出现账号相关的若干问题。

逆向分析

综上所述,频繁请求搜索接口触发风控后,会给我们返回一段经过混淆的 JS,我们通过下 XHR 断点成功断下,进入一段 ts 代码中:

NmGsLb.png

向上跟栈发现就是接口返回的混淆的 js,然后通过 setRequestHeader 方法将加密生成的 X-If-Match 置入请求头中:

NmG2Ge.png

发现是调用 _0x2ccea7 方法传入了 4 个参数,如下:

_0x3398f3 = _0x27abae[_0x5af2ca(0x1be, 0x2c7, 0x3f4, '!Ggl', 0x16f)](this, _0x42da8e[_0x314dad(0x78, 0x221, 0x279, 'xh$h', 0x4e3) + 'q'], _0x42da8e[_0x2724bb('d0we', 0x60e, 0x3c2, 0x86c, 0x874) + 'H'](_0x2ccea7, _0x17c050, _0x78dd78, _0x1f4a50, _0x2a3d89))
//_0x17c050 undefined
//_0x78dd78 lot_number
// _0x1f4a50 加密值,跟栈
// _0x2a3d89  固定参数 0

分析函数可知,最终他其实就是将传入的第三个参数返回:

function _0x18f1c6(_0x552ef8, _0x2e63db, _0x395a43, _0x35c1ad) {
  {
    var _0x50a86a = new Array(2);
    _0x50a86a.splice(1, 0, _0x552ef8, _0x2e63db, _0x395a43, _0x35c1ad);
    _0x50a86a.splice(1, 2);
    _0x50a86a.splice(2, 1);
    return _0x50a86a.join('');
  }
}

_0x1f4a50 加密值为 X-If-Match,继续向上跟,发现 match 由以下生成:

_0x1f4a50 = _0x42da8e[_0x3f7ed9(0x5f4, 'wgRL', 0x589, 0x60c, 0x3c2) + 'H'](_0x1f223f, _0x17c050, _0x78dd78, _0x2a3d89, _0x3398f3);
// _0x1f223f函数传入 _0x17c050  _0x78dd78(lot_number) 生成

接着向上跟栈,发现 _0x17c050 生成位置如下:

NmG6zP.png

这是本文第一个难点,通过在混淆的 JS 里拿到里面的活值 _0x17c050

方法一:可以用 AST 解混淆的办法将这个代码解下,然后将 32 位的字符串提取出来即可拿到这个活值:

NmaSWL.png

解混淆代码如下:

const fs = require('fs');
const parser = require("@babel/parser");
const traverse = require("@babel/traverse").default;
const types = require("@babel/types");
const generator = require("@babel/generator").default;

jsname = "./temp"
const jscode = fs.readFileSync(jsname + ".js", {
    encoding: "utf-8"
});
let ast = parser.parse(jscode);xq

function deepCopy(obj) {
    if (typeof obj !== 'object' || obj === null) {
        return obj;
    }
    let copy = Array.isArray(obj) ? [] : {};
    for (let key in obj) {
        if (Object.prototype.hasOwnProperty.call(obj, key)) {
            copy[key] = deepCopy(obj[key]);
        }
    }
    return copy;
}
unieconsole = deepCopy(console)
unieconsole.time("处理完毕,耗时");

// 获取全局所有函数(字符串解析函数)
function getDecryptFuncNames(decryptCode) {
    let funcNames = [];
    let decryptAst = parser.parse(decryptCode);

    traverse(decryptAst, {
        FunctionDeclaration(path) {
            let { parentPath, node } = path;
            if (!parentPath.isProgram()) {
                return;
            }
            let { id, params, body } = node;
            let length = body.body.length;
            if (params.length == 0 || length == 0 || !types.isReturnStatement(body.body[length - 1]) || params.length != 2) {
                return;
            }
            funcNames.push(id.name);
        }
    });
    return funcNames;
}
let globalparsefun = getDecryptFuncNames(jscode)[0];
unieconsole.log("全局解析函数", globalparsefun)

// 使用 Set
let strob = new Set();
let foundInCurrentPass = true;

// 循环 ob 替换,直到没有 ob
while (foundInCurrentPass) {
    foundInCurrentPass = false;
    let currentPassNames = new Set();

    traverse(ast, {
        VariableDeclarator(path) {
            const node = path.node;
            if (types.isIdentifier(node.init) &&
                (node.init.name == globalparsefun || strob.has(node.init.name))) {

                const varName = node.id.name;
                currentPassNames.add(varName);
                foundInCurrentPass = true;

                let binding = path.scope.getOwnBinding(varName);
                if (binding) {
                    for (let refPath of binding.referencePaths) {
                        if (refPath.parentPath.isCallExpression({ callee: refPath.node })) {
                            const callPath = refPath.parentPath;
                            callPath.node.callee = types.Identifier(globalparsefun);
                        }
                    }
                }
                path.remove();
            }
        }
    });

    // 合并到全局 Set
    currentPassNames.forEach(name => strob.add(name));
    if (foundInCurrentPass) {
        unieconsole.log("根据全局解析函数删除混淆的字符串解析函数数量", currentPassNames.size);
    }
}

let de_hexnum = 0, de_binnum = 0, de_membernum = 0;

// 构建成员变量缓存
const memberCache = new Map();

const firstPassVisitor = {
    // 十六进制字符串和数字字面量
    'StringLiteral|NumericLiteral'(path) {
        path.replaceWith(types.valueToNode(path.node.value));
        path.skip();
        de_hexnum += 1;
    },

    // 一元表达式
    UnaryExpression(path) {
        const evalResult = path.evaluate();
        if (evalResult.confident) {
            path.replaceWith(types.valueToNode(evalResult.value));
            path.skip();
            de_hexnum += 1;
        }
    },

    // 二项式解混淆
    BinaryExpression(path) {
        if (path.node.operator == "/" && path.node.right.value == 0) {
            return;
        }
        const evalResult = path.evaluate();
        if (evalResult.confident) {
            path.replaceWith(types.valueToNode(evalResult.value));
            de_binnum += 1;
        }
    },

    // 成员变量混淆还原
    MemberExpression(path) {
        if (types.isUpdateExpression(path.parent) ||
            (types.isAssignmentExpression(path.parent) && path.parent.left == path.node)) {
            return;
        }

        // 字符串属性转标识符
        if (types.isStringLiteral(path.node.property)) {
            let membervalue = path.node.property.value;
            if (membervalue.includes("-") || membervalue.includes("@") ||
                membervalue.includes("#") || membervalue.includes(" ") ||
                membervalue === "" || membervalue == "toString") {
                return;
            }
            path.node.property = types.Identifier(membervalue);
            path.node.computed = false;
        }

        if (types.isIdentifier(path.node.property) && types.isIdentifier(path.node.object)) {
            const objName = path.node.object.name;
            const propName = path.node.property.name;

            const cacheKey = `${objName}.${propName}`;
            if (memberCache.has(cacheKey)) {
                const value = memberCache.get(cacheKey);
                if (value !== null) {
                    try {
                        path.replaceWith(types.valueToNode(value));
                        de_membernum += 1;
                        return;
                    } catch(e) {}
                }
                return;
            }

            // 查找变量定义
            let binding = path.scope.getBinding(objName);
            if (binding && binding.path.isVariableDeclarator()) {
                const init = binding.path.node.init;
                if (init && init.properties) {
                    for (let i = 0; i < init.properties.length; i++) {
                        const prop = init.properties[i];
                        let proname;

                        if (types.isIdentifier(prop.key)) {
                            proname = prop.key.name;
                        } else if (types.isStringLiteral(prop.key)) {
                            proname = prop.key.value;
                        } else if (types.isNumericLiteral(prop.key)) {
                            proname = prop.key.value;
                        } else {
                            continue;
                        }

                        if ((types.isNumericLiteral(prop.value) || types.isStringLiteral(prop.value)) &&
                            proname == propName) {
                            try {
                                const value = prop.value.value;
                                memberCache.set(cacheKey, value);
                                path.replaceWith(types.valueToNode(value));
                                de_membernum += 1;
                                return;
                            } catch(e) {
                                memberCache.set(cacheKey, null);
                            }
                        }
                    }
                }
            }
            memberCache.set(cacheKey, null);
        }
    }
};

traverse(ast, firstPassVisitor);
unieconsole.log("第一次还原十六进制字符串解混淆数量", de_hexnum);
unieconsole.log("第一次还原二项式解混淆数量", de_binnum);
unieconsole.log("第一次还原成员变量混淆数量", de_membernum);

// 函数花指令混淆
let de_callnum = 0;
const simpleFuncMap = new Map();

traverse(ast, {
    FunctionDeclaration(path) {
        const { id, body, params } = path.node;
        const bodyStatements = body.body;

        if (bodyStatements.length != 1 || !types.isReturnStatement(bodyStatements[0])) {
            return;
        }

        // 检查是否包含this
        let hasthis = false;
        path.traverse({
            ThisExpression() {
                hasthis = true;
            }
        });

        if (!hasthis) {
            simpleFuncMap.set(id.name, {
                params: params.map(p => p.name),
                body: bodyStatements[0].argument
            });
        }
    }
});

// 现在使用预先收集的信息处理调用
traverse(ast, {
    CallExpression(path) {
        const calleename = path.node.callee.name;

        if (!calleename || types.isMemberExpression(path.node.callee)) {
            return;
        }

        const funcInfo = simpleFuncMap.get(calleename);
        if (!funcInfo) {
            return;
        }

        const callparams = path.node.arguments;
        const funparams = funcInfo.params;
        const funbody = types.cloneNode(funcInfo.body, true);

        try {
            path.replaceWith(funbody);
        } catch (e) {
            return;
        }

        // 替换参数
        path.traverse({
            Identifier(path3) {
                const idx = funparams.indexOf(path3.node.name);
                if (idx != -1) {
                    path3.replaceWith(types.cloneNode(callparams[idx], true));
                    path3.skip();
                }
            }
        });

        de_callnum += 1;
    }
});

unieconsole.log("还原第二种函数花指令混淆数量", de_callnum);

// 还原字符串解密函数混淆
let de_strnum = 0;
if (globalparsefun != null) {
    try {
        // 直接eval原始代码以获取解密函数
        eval(jscode);

        // 替换所有对解密函数的调用
        traverse(ast, {
            CallExpression(path) {
                const callee = path.node.callee;
                if (types.isIdentifier(callee) && callee.name == globalparsefun) {
                    try {
                        // 直接eval整个调用表达式
                        const value = eval(path.toString());
                        if (typeof value === "string") {
                            path.replaceWith(types.valueToNode(value));
                            de_strnum += 1;
                        }
                    } catch(e) {
                        // 解密失败,保持原样
                    }
                }
            }
        });
        unieconsole.log("还原字符串解密函数混淆数量", de_strnum);
    } catch(e) {
        unieconsole.log("字符串解密失败:", e.message);
    }
}

// 合并字符串拼接(需要循环直到没有新的合并)
let de_concatnum = 0;
let concatChanged = true;
while (concatChanged) {
    concatChanged = false;
    traverse(ast, {
        BinaryExpression(path) {
            if (path.node.operator === '+') {
                const { left, right } = path.node;

                // 收集所有连续的字符串字面量
                const parts = [];
                let current = path.node;
                let allStrings = true;

                // 递归展开左侧
                function collectLeft(node) {
                    if (types.isBinaryExpression(node) && node.operator === '+') {
                        collectLeft(node.left);
                        collectRight(node.right);
                    } else if (types.isStringLiteral(node)) {
                        parts.push(node.value);
                    } else {
                        allStrings = false;
                    }
                }

                function collectRight(node) {
                    if (types.isBinaryExpression(node) && node.operator === '+') {
                        collectLeft(node.left);
                        collectRight(node.right);
                    } else if (types.isStringLiteral(node)) {
                        parts.push(node.value);
                    } else {
                        allStrings = false;
                    }
                }

                collectLeft(left);
                collectRight(right);

                if (allStrings && parts.length > 1) {
                    const result = parts.join('');
                    path.replaceWith(types.stringLiteral(result));
                    de_concatnum += parts.length - 1;
                    concatChanged = true;
                }
            }
        }
    });
}
unieconsole.log("合并字符串拼接数量", de_concatnum);

// 第三次成员变量还原(字符串合并后,对象属性键已经是完整字符串)
de_membernum = 0;
memberCache.clear();
traverse(ast, {
    MemberExpression: firstPassVisitor.MemberExpression
});
unieconsole.log("第三次还原成员变量混淆数量", de_membernum);

unieconsole.timeEnd("处理完毕,耗时");
code = generator(ast, {
    jsescOption: { quotes: 'single' }
}).code;
fs.writeFile(jsname + "_deob2.js", code, (err) => {
    encoding: "utf-8"
});

// 第二轮合并优化
de_hexnum = 0;
de_binnum = 0;
de_membernum = 0;

traverse(ast, firstPassVisitor);
unieconsole.log("第二次还原十六进制字符串解混淆数量", de_hexnum);
unieconsole.log("第二次还原二项式解混淆数量", de_binnum);
unieconsole.log("第二次还原成员变量混淆数量", de_membernum);

temp 里把基本环境补齐,然后直接运行即可拿到含有 32 位字符串解混淆后的代码,耗时基本几百 ms。

方法二:通过十几组动态 JS 的观察,可以发现活值代码是有规律的,存在于第 8 个 window[ 到第 13 个 window[ 之间:NmaUXJ.png

且活值最后一个为字符串常量,格式为 [变量名] = [混淆对象][[混淆函数(参数列表)] + 字符串常量];

所以,我们可以直接通过正则精准定位:

NmaPmG.png

const fs = require('fs');
const path = require('path');
const parser = require("@babel/parser");
const generator = require("@babel/generator").default;

function extractTargetAssignments(jsFilePath) {
    try {
        const jscode = fs.readFileSync(jsFilePath, { encoding: "utf-8" });
        let ast = parser.parse(jscode);
        let code = generator(ast, {
            compact: false,
            minified: false,
            jsescOption: { quotes: 'single' },
        }).code;

        const windowRegex = /window\[/g;
        const windowPositions = [];
        let match;
        while ((match = windowRegex.exec(code)) !== null) {
            windowPositions.push(match.index);
        }

        if (windowPositions.length < 13) {
            console.log(`文件中找到 ${windowPositions.length} 个 "window[",数量不足13个`);
            return;
        }

        const eighthWindowStart = windowPositions[7];
        const thirteenthWindowStart = windowPositions[12];
        const targetCodeRange = code.substring(eighthWindowStart, thirteenthWindowStart);

        const assignRegex = /(_0x[a-f0-9]+)\s*=\s*(_0x[a-f0-9]+)\[.*?\+\s*'([^']+)'/g;
        const matchedAssignments = [];
        let assignMatch;

        while ((assignMatch = assignRegex.exec(targetCodeRange)) !== null) {
            const matchStart = assignMatch.index;
            const stmtStart = targetCodeRange.lastIndexOf('\n', matchStart) || 0;
            let stmtEnd = targetCodeRange.indexOf(',', matchStart);
            if (stmtEnd === -1) stmtEnd = targetCodeRange.indexOf(';', matchStart);
            if (stmtEnd === -1) stmtEnd = matchStart + 300;

            const fullAssignment = targetCodeRange.substring(stmtStart, stmtEnd).trim();

            matchedAssignments.push({
                variable: assignMatch[1],
                baseObject: assignMatch[2],
                stringConstant: assignMatch[3],
                fullAssignment: fullAssignment
            });
        }

        if (matchedAssignments.length === 0) {
            console.log('未找到符合条件的赋值语句');
        } else {
            console.log(`找到 ${matchedAssignments.length} 条符合条件的赋值语句:`);
            matchedAssignments.forEach((item, idx) => {
                console.log(`\n第${idx+1}条:`);
                console.log(`变量: ${item.variable}`);
                console.log(`基础对象: ${item.baseObject}`);
                console.log(`字符串常量: '${item.stringConstant}'`);
                console.log(`完整语句: ${item.fullAssignment}`);
            });
        }
        return matchedAssignments;

    } catch (error) {
        console.error('处理出错:', error.message);
        return null;
    }
}

const TARGET_JS_FILE = './temp.js';
extractTargetAssignments(TARGET_JS_FILE);

活值位置搞定后,直接插入 log 打印取出即可,耗时比 ast 略快一点。

活值搞定后分析 match 参数的主函数,进入一段花指令的函数:

NmaMmf.png

继续单独跟进,发现最终由 _0x91b26[_0x535c98(0x716, 'L)%*', 0x4ec, 0x61b, 0x3fb) + 'F'](_0x3b0086, _0x3184cb, _0x167174) 生成的 match 参数:

Nmaf8c.png

经过分析后,发现这是一种位运算处理逻辑,逻辑如下,初始化 res=[] 循环 8 次,每次截取 4 个字符,作为一个 4 位十六进制段,分别从 s1s2 中截取该段(若超出长度则取空字符串,转换为 0),将截取的十六进制字符串转为十进制整数 n1n2,对 n1n2 执行异或运算,将异或结果转为 4 位十六进制字符串,添加到 res

def a(n):
    s = hex(n)[2:]
    return b(s, 4)


def b(s, l):
    d = l - len(s)
    if d <= 0:
        return s
    elif d == 1:
        return '0' + s
    else:
        return '0' * d + s


def c(s1, s2):
    res = []
    for i in range(8):
        st = i * 4
        ed = (i + 1) * 4

        p1 = s1[st:ed] if st < len(s1) else ''
        n1 = int(p1, 16) if p1 else 0

        p2 = s2[st:ed] if st < len(s2) else ''
        n2 = int(p2, 16) if p2 else 0

        res.append(a(n1 ^ n2))
    return ''.join(res)


print(c("611270d5e7fcb331e84ffb43f5144b74", "08c4cf113fa04c1da4a2a9f64d2d5cef"))

可以将代码部分扣出或者导出到全局即可。

结果验证

Nmait3.png

posted @ 2026-05-11 17:52  K哥爬虫  阅读(54)  评论(0)    收藏  举报