TamperDetectJS icon indicating copy to clipboard operation
TamperDetectJS copied to clipboard

Detecting tamper via strict comparison to native function pulled from iframe

Open asger-finding opened this issue 3 years ago • 55 comments

It's possible to create a new iframe (about:blank) and pull functions from its Document, then comparing it to the tampered function. This is, for example, being done in Krunker to detect function tamper. CanvasBlocker has some good methods to address this.

asger-finding avatar Jul 04 '22 12:07 asger-finding

Yes, if you are hooking functions you should also hook all iframes.

Fun fact: Krunker started doing this after I introduced the use of it in above repo.

hrt avatar Jul 04 '22 12:07 hrt

Yes, if you are hooking functions you should also hook all iframes.

I feel it should also be addressed in this repository, as it's certainly another way to detect tamper.

asger-finding avatar Jul 12 '22 18:07 asger-finding

This is hackable by Object.defineProperty + Proxy. I hacked the krunker after reading this repo) I simply defined Function, and checked the stack trace to return proxy, only on game file evaluation. And it's been working for a year now. After all of that I came up with conclusion that everthing is hackable. I worked hard for the whole 3 days, and just don't see any solutons.

doctor8296 avatar Jan 18 '23 19:01 doctor8296

This is hackable by Object.defineProperty + Proxy.

@redscheme I would like to see an example user script that hooks Array.prototype.join in anyway and gets the pass on https://hrt.github.io/TamperDetectJS/ with chrome.

From what I've understood from your explanation, it wouldn't get the pass.

hrt avatar Feb 15 '23 21:02 hrt

This is hackable by Object.defineProperty + Proxy. I hacked the krunker after reading this repo) I simply defined Function, and checked the stack trace to return proxy, only on game file evaluation. And it's been working for a year now. After all of that I came up with conclusion that everthing is hackable. I worked hard for the whole 3 days, and just don't see any solutons.

I'm going to have to agree with hrt here, were you using any other browsers? I've tried to recreate your explanation and It does not work.

oxygen-x avatar Apr 18 '23 09:04 oxygen-x

@hrt @Kepler-11 Hi, Despite the fact that each check can be passed pretty easy, the fastest way to hack current scheme is this:

const join = Array.prototype.join;
Object.defineProperty(Array.prototype, 'join', {
    get() {
       // check "this" or stack to replace the function only when its needed or replace function with original only on checks
        if ( ... ) return new Proxy(join, { ... })
        return join;
    }
});

doctor8296 avatar Apr 19 '23 13:04 doctor8296

@redscheme this means that it wouldn't be hooking join.

All krunker or whoever would do is storejoin in a variable, do whatever checks (which you haven't hooked to get passed the checks) and continue using the stored join that passed the checks.

Your get logic only applies to when the page first stores to a variable.

I may adjust the repo to specifically request the user to hook join to return a unique value that can be checked to ensure the user is actually hooking join. This will let the user be sure that they're hooking join rather than not hooking at all.

hrt avatar Apr 20 '23 08:04 hrt

@hrt yes you right, you can easy avoid this just by calling getter once before checks. But it is pretty difficult to store each class and method separately somewhere and force the new global scope. Difficult but not impossible.

I still believe that it is quite possible to bypass these checks, and sooner or later I will do it, since I have seriously dealt with this topic.

doctor8296 avatar Apr 20 '23 10:04 doctor8296

That was easy. It took me 15 minutes.

const Reflect = {
    apply: window.Reflect.apply
};

const console = {log: window.console.log};
window.console.log = () => {};

const originalJoin = Array.prototype.join;
const joinProxy = Array.prototype.join = new Proxy(Array.prototype.join, {});

const originalDateToDateString = Date.prototype.toDateString;
const originalDatetoString = Date.prototype.toString;
const dateToStringProxy = Date.prototype.toString = new Proxy(Date.prototype.toString, {
    apply(target, thisArg, args) {
        switch (thisArg) {
            case joinProxy: {
                throw {stack: '' + originalJoin};
                break;
            }
            case toStringProxy: {
                throw {stack: '' + originalToString};
                break;
            }
            case originalDateToDateString: {
                throw {stack: '' + originalDateToDateString};
                break;
            }
        }
        return Reflect.apply(...arguments);
    }
});

const originalToString = Function.prototype.toString;
const toStringProxy = Function.prototype.toString = new Proxy(Function.prototype.toString, {
    apply(target, thisArg, args) {
        switch (thisArg) {
            case joinProxy: {
                arguments[1] = originalJoin;
                break;
            }
            case toStringProxy: {
                arguments[1] = originalToString;
                break;
            }
            case dateToStringProxy: {
                arguments[1] = originalDatetoString;
                break;
            }
        }
        return Reflect.apply(...arguments);
    }
});

Object.create = new Proxy(Object.create, {
    apply(target, thisArg, args) {
        if (args[0] === joinProxy) throw {stack: ''};
        return Reflect.apply(...arguments);
    }
});

I got back to this theme a week ago. I spent a lot of time on iframe / html render / fight for the event loop etc, but nothing.. and after I losing all hope I got back here just to solve this puzzle for fun and maybe to see something interesting. Seems I figure something out. The stack of error CANNOT be redefined. I thought it could before, but it seems, if you not calling error through other redefinable functions you cannot reach it. I was really surprized by this fact. Anyway, a week of researches gave me very little bit that I cannot actually use in my goal to create client sided anticheat / checker for redefinitions. Sad. But I'll look into the stack thing.

doctor8296 avatar Sep 21 '23 19:09 doctor8296

@doctor8296 Good one.

You've beaten it. If I were to make it harder, I'd first make the checks on the stack traces a little stronger (Proxy is seen everywhere) 😂

(index):152 check_stack_5_toString
----
TypeError: Method Date.prototype.toDateString called on incompatible receiver function toString() { [native code] }
    at Proxy.toDateString (<anonymous>)
    at Array.check_stack_5_toString (https://hrt.github.io/TamperDetectJS/:149:33)
    at https://hrt.github.io/TamperDetectJS/:487:33

hrt avatar Sep 21 '23 20:09 hrt

Well, I mean yeah, you can add extra checks, but I think it just will took me more time to hack it. I am currently looking into the stack thing. I think with this I have a chance to implement some iframe + document.write logic.

doctor8296 avatar Sep 22 '23 04:09 doctor8296

@hrt WE CAN CHECK EVERYTHING WITH CLEAR toString!

doctor8296 avatar Oct 10 '23 18:10 doctor8296

@doctor8296

Not sure I understood that.


I likely read your comment wrong but I should also add delete Function.prototype.something where something can be a lot of prototype functions for example toString which would restore the native function or at least let you compare against the native function.

hrt avatar Oct 10 '23 18:10 hrt

@hrt Yes that what I meant. Did you know about such behavior? I just figure out this today, playing with primitive prototype redefinition, and figured out that it restores itself after deleting, as well as prototype.toString. So does that mean that the topic completely closed? From my point of view having clear toString solves every our problem, without huge mass of different checks.

doctor8296 avatar Oct 10 '23 18:10 doctor8296

@doctor8296 not necessarily: image

hrt avatar Oct 10 '23 18:10 hrt

@hrt but delete returns false in this case, and this how we can check :P

doctor8296 avatar Oct 10 '23 18:10 doctor8296

@hrt never mind 😢 image

doctor8296 avatar Oct 10 '23 19:10 doctor8296

Right so v8 is likely replacing toString with some other toString function.

Anyway if someone really wanted to get around it, they could try to mimic the entirety of Function (incomplete example incoming): image

hrt avatar Oct 10 '23 19:10 hrt

@hrt Yes, I foresaw replacing Function with the fake one.

const f = _=>0;
if (!(delete f.constructor)) {
    throw "Function constructor was predefined";
}
const _Function = f.constructor;
if (!(f instanceof _Function)) {
    throw "Function has illegal redefined constructor";
}

this check should avoid such actions

doctor8296 avatar Oct 10 '23 19:10 doctor8296

But yeah, the fact that Function.prototype.toString getting replaced with not working one is heart breaking. :( Anyway I have very hard but 100% solution for chromium browsers, but it still hackable on FF. Clear toString could solve every our problem ... 😭😭😭

I'll come back if will find some other actually working solutions...

By the way such deleting logic based on this:

class Parent {
    method() {}
}

class Child extends Parent {
    method() {
        super.method();
    }
}

Parent.prototype.method === Child.prototype.method; // false
delete Child.prototype.method
Parent.prototype.method === Child.prototype.method; // true

And the thing with constructor that I showed doesn't work as well... (but checking through instanceof works)

doctor8296 avatar Oct 10 '23 19:10 doctor8296

Btw you can checkout anticheat on cryzen.io that I made. It seems, that scripts with default inject mode, and with document-start run only after scripts in

Basically you can just freeze all object and functions and that's it. But if somehow (I know how >:3 ) script will run before that, this will not help you

doctor8296 avatar May 28 '24 08:05 doctor8296

that doesnt actually work if you try to do

let iframe = document.createElement("iframe");
iframe.style.display = "none";
document.body.appendChild(iframe);
console.log(iframe.contentWindow.Function.prototype.bind==Function.prototype.bind)

it will always be false maybe im doing something wrong idk

nostopgmaming17 avatar Jul 17 '24 10:07 nostopgmaming17

anyway this is my full bypass to the anticheat you made

bypass.js (in chrome extension since tampermonkey doesnt instantly inject for some reason)

(function() {
    'use strict';

    const spoof = new WeakMap;
    spoof.set = spoof.set;
    spoof.get = spoof.get;
    spoof.has = spoof.has;
    spoof.delete = spoof.delete;

    const reflect = {};
    for (let i of Object.getOwnPropertyNames(Reflect)) {
        reflect[i] = Reflect[i];
    }
    const proxy = Proxy;

    const hook = (o,n,h) => {
        const hooked = new proxy(o[n],h);
        spoof.set(hooked,o[n]);
        o[n] = hooked;
    }

    hook(Function.prototype,"toString",{
        apply(f,th,args){
            try{
                return reflect.apply(f,spoof.get(th)||th,args);
            }catch(err) {
                err.stack = err.stack.replace(/^.+bypass\.js.+\n/gm,"");
                err.stack = err.stack.replace(/Object/m,"Function");
                throw err;
            }
        }
    });
    hook(Function.prototype,"apply",{
        apply(f,th,args){
            try{
                return reflect.apply(f,th,args);
            } catch(err) {
                err.stack = err.stack.replace(/^.+bypass\.js.+\n/gm,"");
                if (spoof.has(args[0])) {
                    err.stack = err.stack.replace(/Proxy/gm,"Function");
                    throw err;
                } else {
                    throw err;
                }
            }
        }
    });

    hook(Array.prototype,"join",{
        apply(f,th,args) {
            try{
                if (th instanceof Array) {
                    for(let i of th) {
                        if (typeof i == "object" && typeof i.toString != "undefined" && i.toString.toString().includes("try")) {
                            i.toString = ()=>"";
                        }
                    }
                }
                return reflect.apply(f,th,args);
            } catch(err) {
                err.stack = err.stack.replace(/^.+bypass\.js.+\n/gm,"");
                throw err;
            }
        }
    });
})();

keep in mind that there are other elegant solutions but which allow for easy false positive checks which could flag you also you would need to change /^.+bypass.js.+\n/gm to match for your file name

also heads up none of that fancy stuff like Uint8Array.prototype.sort is needed you can simply do

void function() {
 try{
  ""();
  detected();
 } catch(error) {
  if (error.stack.includes("Proxy")) {detected()}
 }
}.call(Array.prototype.join);

NOTE: using Error.prepareStackTrace is not only irrational and stupid but it is a massive insecurity risk which the anticheat devs could use to implement false positives and ultimately banning the client this is because Error.prepareStackTrace only gets the error message and cant get further info about the error youre handlnig

using a call/apply hook is much better because you can check if its a hooked function

nostopgmaming17 avatar Jul 17 '24 10:07 nostopgmaming17

that doesnt actually work if you try to do

let iframe = document.createElement("iframe");
iframe.style.display = "none";
document.body.appendChild(iframe);
console.log(iframe.contentWindow.Function.prototype.bind==Function.prototype.bind)

it will always be false maybe im doing something wrong idk

@nostopgmaming17 Hi! Well it doesn't work like that, since "Function" itself is instance, and as you know {} === {} will give you false every time. The way we are trying to compare those functions with iframe is by using "clear" function for example for comparing their signatures that we could achive by:

const clearWindow = iframe.contentWindow;
const clearToString = clearWindow.Function.prototype.toString;
const clearJoinSignature = clearToString.call(clearWindow.Array.prototype.join);
const globalJoinSignature = clearToString.call(Array.prototype.join);

if (clearJoinSignature !== globalJoinSignature) {
    alert("Array.prototype.join was redefined!")
}

doctor8296 avatar Jul 17 '24 11:07 doctor8296

void function() {
 try{
  ""();
  detected();
 } catch(error) {
  if (error.stack.includes("Proxy")) {detected()}
 }
}.call(Array.prototype.join);

@nostopgmaming17 can I ask why did you place detected call after ""() is there any case where ""() will not throw an error? Also, I prefer to use 0() since there less symbols.

doctor8296 avatar Jul 17 '24 14:07 doctor8296

void function() {
 try{
  ""();
  detected();
 } catch(error) {
  if (error.stack.includes("Proxy")) {detected()}
 }
}.call(Array.prototype.join);

@nostopgmaming17 can I ask why did you place detected call after ""() is there any case where ""() will not throw an error? Also, I prefer to use 0() since there less symbols.

looking back at it I dont know i guess its just incase, also you would probably have it after the function call like that

void function() {
 try{
  ""();
 } catch(error) {
  if (error.stack.includes("Proxy")) {detected()}
 }
}.call(Array.prototype.join);
detected();

this is because Function.prototype.call can be hooked

nostopgmaming17 avatar Jul 17 '24 22:07 nostopgmaming17

@nostopgmaming17 yes everything can be hooked. Except few cases.

doctor8296 avatar Jul 17 '24 22:07 doctor8296

@nostopgmaming17 yes everything can be hooked. Except few cases.

new bypass with HTMLIFrameElement contentWindow getter

(function() {
    'use strict';

    const spoof = new WeakMap;
    spoof.set = spoof.set;
    spoof.get = spoof.get;
    spoof.has = spoof.has;
    spoof.delete = spoof.delete;


    const reflect = {};
    for (let i of Object.getOwnPropertyNames(Reflect)) {
        reflect[i] = Reflect[i];
    }
    const proxy = Proxy;

    const hook = (o,n,h) => {
        const hooked = new proxy(o[n],h);
        spoof.set(hooked,o[n]);
        o[n] = hooked;
    }

    hook(Function.prototype,"toString",{
        apply(f,th,args){
            try{
                return reflect.apply(f,spoof.get(th)||th,args);
            }catch(err) {
                err.stack = err.stack.replace(/\n.+bypass\.js.+/gm,"");
                err.stack = err.stack.replace(/Object/m,"Function");
                throw err;
            }
        }
    });
    const descriptor = reflect.getOwnPropertyDescriptor(HTMLIFrameElement.prototype,"contentWindow");
    hook(descriptor,"get",{
        apply(f,th,args) {
            const ret = reflect.apply(f,th,args);
            if (!spoof.has(ret.Function.prototype.toString)) {
                hook(ret.Function.prototype,"toString",{
                    apply(f,th,args){
                        try{
                            return reflect.apply(f,spoof.get(th)||th,args);
                        }catch(err) {
                            err.stack = err.stack.replace(/\n.+bypass\.js.+/gm,"");
                            err.stack = err.stack.replace(/Object/m,"Function");
                            throw err;
                        }
                    }
                });
            }
            return ret;
        }
    })
    reflect.defineProperty(HTMLIFrameElement.prototype,"contentWindow",descriptor);

    hook(Function.prototype,"apply",{
        apply(f,th,args){
            try{
                return reflect.apply(f,th,args);
            } catch(err) {
                err.stack = err.stack.replace(/\n.+bypass\.js.+/gm,"");
                if (spoof.has(args[0])) {
                    err.stack = err.stack.replace(/Proxy/gm,"Function");
                    throw err;
                } else {
                    throw err;
                }
            }
        }
    });

    hook(Array.prototype,"join",{
        apply(f,th,args) {
            try{
                if (th instanceof Array) {
                    for(let i of th) {
                        if (typeof i == "object" && typeof i.toString != "undefined" && i.toString.toString().includes("try")) {
                            i.toString = ()=>"";
                        }
                    }
                }
                return reflect.apply(f,th,args);
            } catch(err) {
                err.stack = err.stack.replace(/\n.+bypass\.js.+/gm,"");
                throw err;
            }
        }
    });
})();

nostopgmaming17 avatar Jul 17 '24 22:07 nostopgmaming17

@nostopgmaming17 contentWindow will not get called if I will take it from ’window[n]’

doctor8296 avatar Jul 17 '24 22:07 doctor8296

@nostopgmaming17 contentWindow will not get called if I will take it from ’window[n]’

wdym?

nostopgmaming17 avatar Jul 17 '24 22:07 nostopgmaming17