【投稿】JS逆向系列-无感绕过一类运行时间差反调试

【投稿】JS逆向系列-无感绕过一类运行时间差反调试

0x00 前言

在开始正文之前,我想先和大家讲一件我去年下半年专注过的一件事。经常看我文章的读者朋友应该都知道,我有一段时间特别专注于绕过location的一些属性和方法,例如location.href、location.replace()等等,经过我尝试各种方案,最终以失败而告终,后来我向大家提供的解决方案有两个:一个是抓包替换,一个是使用CC11001100师傅写的hook脚本进行hook定位替换:https://github.com/JSREI/page-redirect-code-location-hook,前者我其实是不太推荐的,这种可对直接调用location的属性或方法的一些网站有效,但是如果是混淆调用的那种就没办法了,而后者效果会明显好很多,但是还是需要用户自己手动进行override,无感还是做不到。那么这篇文章又和location跳转反调试有什么关系,请看下文。

另外我再和大家讲一件事,不久前我往b站传了一个视频,在视频中我详细演示了如何配置我Hook_JS库里的Hook脚本,各位如有兴趣可以看一看: https://www.bilibili.com/video/BV1rG9oYzEUH

注:本文提到的解决方案只针对本文提到的这一类运行时间差反调试,并不代表本文给出的解决方案能解决掉所有运行时间差检测。

0x01 正文

在我以前提到绕过location跳转反调试的文章中,我用到过抓包替换,hook替换的方法,尝试过重写location.href或location.replace()的方案(失败),这些措施都是针对location本身做的绕过,但是我从来没有从网站是如何检测用户打开了devtools入手。

众所周知,location.href属性或location.replace()方法一旦被设置或调用就会被跳转到指定地址,例如:

location.href = "https://www.bing.com"

或:

location.replace('https://www.bing.com')

也就是说网站进行跳转反调试时绝对会先检测用户是否打开了devtools,然后再去决定是否跳转防止用户反调试,所以它一定会有一个检测的操作,那么在本期文章中我就要提到一种检测用户是否打开devtools的检测手段:运行时间差(TimeDiff)。

首先我需要向读者解释一下,我为什么要提到这种检测手段:因为目前的一些主流网站用的就是这种方法去检测用户是否打开了devtools,从而引起反调试,其中就包括设置location.href属性造成跳转。我之前写过一篇JS逆向系列10-反调试与反反调试,在这篇文章中我提到过一个案例:

d2b5ca33bd20250319014217

从上图中可以看到我打开了devtools,并且读者可以清楚地看到代码停在了CC11001100师傅的脚本中,也就是说目前网站进行了跳转反调试的操作。我在这里直接说结论:该网站就是通过检测运行时间差造成的反调试,更具体些就是通过console.table造成的反调试,那么这个方法到底是干什么用的,我这里给大家看一下该网站的控制台(注:该网站使用了console.clear清除控制台内容,所以我提前进行了hook,各位如有需求可去我的Hook_JS库中找相关脚本):

d2b5ca33bd20250319014227

可见该网站控制台打印了这些内容,没错,这些以表格呈现出来的内容就是console.table打印出来的效果,那么此时读者可能会疑惑,为什么网站打印这些内容就能检测到用户是否打开了devtools,不慌,现在我给大家展示一段代码:

const testData = Array(1000).fill().map((_, index) => ({
         id: index,
         name: `测试数据 ${index}`,
         value: Math.random()
     }));
     console.table(testData);

这段代码是Dexter提供给我的,我们先来看一看效果:

d2b5ca33bd20250319014251

可见它打印了一堆内容,我们先不管它到底打印了多少内容以及console.table到底该怎么用,就看它打印完这段内容花费了多长时间:

const start = performance.now();

     const testData = Array(1000).fill().map((_, index) => ({
         id: index,
         name: `测试数据 ${index}`,
         value: Math.random()
     }));
     console.table(testData);

     const end = performance.now();
     const timeSpent = end - start;

     console.log(timeSpent);

效果:

d2b5ca33bd20250319014313

我先讲一下这段代码中用到的performance.now()是什么东西:

d2b5ca33bd20250319014322

d2b5ca33bd20250319014341

d2b5ca33bd20250319014349

在浏览器中,我们可以通过调用performance.now()获取到页面从加载开始所经过的时间(单位为毫秒),例如:

for (var i = 0; i < 100; i++) {
         console.log(performance.now())
     }

d2b5ca33bd20250319014406

这个时间是一直递增的,并不会递减,所以我们可以通过这个方法去检测某段代码执行完花费了多长时间。现在我们回过头来再看看刚刚那段测试代码执行完所花费的时间:

d2b5ca33bd20250319014416

19.5毫秒,但是这是在我打开devtools的情况下所耗费的时间,如果我现在关闭了又是什么样呢,请看下图:

d2b5ca33bd20250319014423

这是我关闭devtools后重新打开显示的运行时间:0.8000000044703484,现在反观来看我们刚刚打开devtools下的执行时间,是不是差异巨大?我相信有经验的读者朋友一定都知道,我们开着devtools访问网站一定是要比往常关闭devtools访问网站慢一些的,因为在devtools中会加载很多内容,就拿打印内容来说,在这个过程中一定是会耗费一些时间的,这就是为什么网站会通过打印内容去判断用户是否打开了devtools。

接下来我就要开始讲一下在正文开始时我提到的console.table

d2b5ca33bd20250319014432

我们只看MDN介绍中的前三行:

将数据以表格的形式显示。

这个方法需要一个必须参数 data,data 必须是一个数组或者是一个对象;还可以使用一个可选参数 columns。

它会把数据 data 以表格的形式打印出来。数组中的每一个元素(或对象中可枚举的属性)将会以行的形式显示在表格中。

 

MDN里介绍的很清楚,简单来说就是我们在调用该方法时所传进去的内容将会以表格的形式显示出来,以下是MDN给的一段测试代码:

// 打印一个由字符串组成的数组

console.table(["apples", "oranges", "bananas"]);

d2b5ca33bd20250319014512

至此,通过上面的演示读者朋友们应该能很容易的猜到这一类检测手段该怎么绕过了,那就是重写console.table

d2b5ca33bd20250319014527

那么真的只有这么简单吗,实践是检验真理的唯一标准,现在我hook一下本文提到的测试案例看下效果:

d2b5ca33bd20250319014535

成功绕过,再换一个案例:

d2b5ca33bd20250319014545

成功绕过,而上述两个测试案例正常打开devtools的效果是这样的:

d2b5ca33bd20250319014552

d2b5ca33bd20250319014559

也就是说我们仅仅是通过重写了console.table方法,就绕过了以下这些反调试常用的方法或属性:

d2b5ca33bd20250319014607

0x02 检测代码解析

为了进一步验证是否像我上文中说的那样,接下来我们分析一下具体代码,现在我给hook脚本多写一个debugger以便定位是哪里调用了console.table:

d2b5ca33bd20250319014615

hook测试案例:

d2b5ca33bd20250319014624

跟堆栈:

d2b5ca33bd20250319014637

e.largeObjectArray:

d2b5ca33bd20250319014645

S:

d2b5ca33bd20250319014652

可见,S是console.table方法,e.largeObjectArray里存放的就是一堆数组,我们回过头来再看一下这段代码:

d2b5ca33bd20250319014700

上述代码被包裹在了一个未执行的函数中,然后这个函数被传给了g,我们接着看一下g函数内部做了什么:

d2b5ca33bd20250319014708

g函数内部先通过m获取了当前时间戳并赋给了t,然后执行了刚刚传进来的函数,也就是通过console.table打印了刚刚的那堆数组,接着又调用了m获取了当前时间戳,并减去了t,将最后得到的结果return了回去,这恰恰是我在正文中用performance.now()做的操作,这个函数最后return的结果其实就是执行完console.table后所花费的时间,只不过它这段代码用的是getTime方法,效果其实都差不多,我们接着向下看:

d2b5ca33bd20250319014722

e.largeObjectArray:

d2b5ca33bd20250319014730

x:

d2b5ca33bd20250319014738

可见x是log方法(这里可能有读者会疑惑:既然网站也会通过log方法打印一堆内容来判断用户是否打开了devtools,那我这里为什么不再继续重写log方法,而是重写console.table进行绕过,这点我后文还会提到),然后依然是将函数传给了g,内部操作我这里就不继续阐述了,接着我们向下看:

d2b5ca33bd20250319014744

这段代码可能看起来会很绕,我扒到本地美化一下让大家看的更清晰一些:

d2b5ca33bd20250319014752

在if语句中,代码首先将this.maxPrintTime和n两者中的最大值赋给了this.maxPrintTime,我看了一下这个参数的初始值:

d2b5ca33bd20250319014759

赋完值后就调用了k:

d2b5ca33bd20250319020210

k这里主要是做了清除控制台的作用:

d2b5ca33bd20250319020219

接着代码判断了t是否等于0,如果等于0就直接return !1,如果为假就继续判断后面的条件:

d2b5ca33bd20250319020227

如果都为假就执行下面的语句:

d2b5ca33bd20250319020252

这里的t就是代码执行完console.table后的运行时间差,如果t大于10*Math.max(this.maxPrintTime, n),结果就为真,然后就会执行this.onDevToolOpen(),我们从这个方法名就能看出来这个方法是干什么用的:

d2b5ca33bd20250319020302

不言而喻,以上证明了我的猜想是正确的,本文提到的这一类运行时间差反调试会先通过console.table或console.log打印内容,再通过执行这两个方法的运行时间判断用户是否打开了devtools。解决方案即是重写console.table。

0x03 为什么不用重写log方法?

这个问题其实很好解答,我演示一下大家就懂了,以下是console.table打印上文案例中的那堆数组后的效果:

function w() {
         for (var e = function() {
             for (var e = {}, t = 0; t < 500; t++)
                 e["".concat(t)] = "".concat(t);
             return e
         }(), t = [], n = 0; n < 50; n++)
             t.push(e);
         return t
     }
     const start = performance.now();
     var largeObjectArray = w();
     console.table(largeObjectArray);
     const end = performance.now();
     const timeSpent = end - start;

     console.log(timeSpent);

d2b5ca33bd20250319020325

花费了36.5毫秒,现在我换成console.log:

function w() {
         for (var e = function() {
             for (var e = {}, t = 0; t < 500; t++)
                 e["".concat(t)] = "".concat(t);
             return e
         }(), t = [], n = 0; n < 50; n++)
             t.push(e);
         return t
     }
     const start = performance.now();
     var largeObjectArray = w();
     console.log(largeObjectArray);
     const end = performance.now();
     const timeSpent = end - start;

     console.log(timeSpent);

d2b5ca33bd20250319020345

0.5999999940395355,很明显console.table打印完所花费的时间要比console.log多得多,那么这是为什么?大家对比一下两张图就能知道答案了:console.table打印时会把所有内容都以表格的形式展开,而console.log不会,它会将这些内容收缩起来,所以console.table消耗的内存是更多的,这就是为什么我们不需要再去将console.log重写的原因。

0x04 Code

脚本我已经上传到了Hook_JS库中,地址:https://github.com/0xsdeo/Hook_JS/blob/main/hook_console/hook_table.js

hook_table.js:

// ==UserScript==
// @name         hook_table
// @namespace    https://github.com/0xsdeo/Hook_JS
// @version      2025-03-14
// @description  Bypass console.table -> TimeDiff -> anti-debug
// @author       Dexter,0xsdeo
// @match        http://*/*
// @icon         data:image/gif;base64,R0lGODlhAQABAAAAACH5BAEKAAEALAAAAAABAAEAAAICTAEAOw==
// @grant        none
// ==/UserScript==

(function() {
    'use strict';

    // let temp_toString = Function.prototype.toString;
    //
    // Function.prototype.toString = function () {
    //     if (this === Function.prototype.toString) {
    //         return 'function toString() { [native code] }';
    //     } else if (this === xxx) { // 将xxx修改为要hook的方法
    //         return ''; // 在控制台执行xxx.toString(),将输出的内容替换掉空字符串
    //     }
    //     return temp_toString.apply(this, arguments);
    // }

    console.table = function () {
        // // 在这里写你想让hook后的方法执行的代码
    }
})();
© 版权声明
THE END
喜欢就支持一下吧
点赞35赞赏 分享
评论 抢沙发

请登录后发表评论

    请登录后查看评论内容