5

Background

I've run into a website which has advertisement banners on it. When an adblock or equivalent removes the banner elements, the website falls back to spamming alerts about being unable to load advertisements, making the viewing experience worse than with the ads.

I like to tinker around and try to break "protections" like these on websites, however, at this time I am out of ideas.

The website in question seems to employ extensive protections against tampering in general, so while this question is "simply" about doing something with the object freezing done in the head, there are multiple limitations as to how this can be done.

The problem

First of all, I don't want to solve this by doing something special to the banners, such as making them visible instead of removing them. I'd like to attack the alert.

The problem is that when the website loads its index.html, it freezes the window.alert object in the <head> section like so:

<script>
    Object.defineProperty(window, 'alert', {
        configurable: false,
        writable: false
    })
</script>

After which it is impossible to do, e.g.:

window.alert = null;

Given that we can't touch the alert itself, one might think to try to find and remove the call to the alert alternatively...

...Unfortunately this does not seem to be possible either, because the website will not work without JavaScript and the website loads the rest of its scripts with a CSRF-token. All of the loaded scripts also utilize the CSRF-token in every request.

The website loads one "loader-script" initially in the head and it is protected by origin policies, so this script cannot be served from anywhere else. This loader-script itself uses a CSRF-token to load rest of the scripts with XMLHttpRequests.

There are also content-security-policy -headers in place.

This essentially means that it is not possible to, e.g. have a web server acting as a proxy, which would load the web page, strip the freezing script in the head and pass the modified web page over. At least to my knowledge that is - as far as I know, this is exactly what CSRF-token prevents.

Where I'm at

I am using a chrome extension called Resource Override, which lets me, for example:

  • Set regex-based rules and actions for websites
  • Further set regex-based URL-rules and actions for when website request some specific URL
  • "respond" to any request a website makes and respond with, e.g. a locally served file, with the limitation that this this locally served file will obviously fail with everything that requires a CSRF-token
  • Inject JavaScript anywhere on the website, so long as the website is loaded
  • Modify request and response headers

Injecting js in the head

The obvious first move for me was to simply inject a window.alert = null in to the <head> of the website, but this did not work as when the page and the html of the page loads, the object freezing already exists in the head.

Furthermore, this method can never work, because one can only inject scripts into the page when there is a page to inject the script in, but in this case when such page exists, the object freeze code is already there as well.

Unfreezing / thawing the window.alert object

There are many Stackoverflow questions and answers regarding unfreezing / thawing frozen objects in JavaScript. In short, it is not possible. At least not in a way that would help here.

Prototype pollution

To be honest, I am not too familiar with the applications of prototype pollution attacks, but I did look a bit into them because I felt that fiddling around with the __proto__ could somehow allow me to make the window.alert configurable or writable. It seems like this is not the way either though.

One thing I tried out with the knowledge I have was to simply extend the Object.prototype, which is sort of a "root prototype" for all objects, with a property named alert, so:

Object.prototype.alert = 999;

What this does is that when you now do something like this:

const arr = [1, 2, 3];
const str = "abc";

for(prop in arr) { 
    console.log(prop) // will log alert at some point
}

console.log(str.alert) // will log 999

On an interesting side note, the only way to properly get ALL of the enumerable property names of an object, including the ones in the prototype chain (the alert), seems to be to use a for...in-loop as shown. All other methods, such as the conveniently named Object.getOwnPropertyNames, will only return the properties directly on the object, but not it its prototype chain. Documentation for this can be found here.

Anyway the idea with this was to:

A) Get lucky and somehow overwrite window.alert as well, since window is also an object and therefore should get extended by Object.prototype.x
B) Get lucky and hope that the website calls window.alert in some ridiculous manner, so that having the alert-property on everything would mess this up

Unsurprisingly, neither of these happened.

Proxy objects

I also played around with Proxies with the initial understanding that it would let me sort of "hook" into function calls, so that I could intercept the call to the alert-function itself. This is not how Proxies work though, so lesson learned.

It did raise the question though, if it's possible to intercept / listen to function calls in JavaScript some other way. I can obviously wrap any function with a function of my own, but then everything would need to call the wrapping function instead of the original one, which doesn't help here.

Reflect API

There is also a JavaScript equivalent of a "Reflection API", but by the looks of it and some tests I did, it also doesn't offer anything with regards to hooking / listening / intercepting / redirecting function calls, though the documentation somewhy states that:

Reflect is a built-in object that provides methods for interceptable JavaScript operations.

Local overrides with Chrome

Scripts modified by Chrome local overrides do not pass some kind of integrity check and therefore they will be blocked.

Other ideas

As per usual with these things, I fiddled around with various different miscellaneous ideas such as:

  • Tried to replace the whole window object in the hopes of "unlocking" window.alert
  • Naively tried to just set window.alert configurable and writable again
  • Tried Object.freeze = null, though it would have already been called by the time it could be set null or modified anyway

Now what

Anything is possible in my opinion, though it could be that the solution in this case might be complicated. I think I have exhausted most of the quick and easy ways, but I'm hopeful that I've missed something.

One idea that is still in-progress for me is to first of all try to intercept and strip the content-security-policy headers to better allow intercepting and modifying requests which fetch scripts. I'm not sure if this will work in the end either, because it seems like there is also some kind of integrity validation going on.

Interestingly enough, there does not seem to be a policy in place for inline scripts, so those do work. It is because of this why I am interested in JavaScript-based solutions the most, but I'll take anything.

I'd also be very interested to know if there is a way to run JavaScript or kind of setup a "modified JavaScript context" before even loading a website? I guess one could go tinker with V8 and delete the whole alert-functionality at its core, but I am looking for something little less than that.

Recap

  • Website is spamming window.alert
  • Website freezes the alert object, making it unmodifiable
  • Website requires JavaScript to work
  • Website uses a CSRF-token extensively, even for loading scripts in the first place, so simply swapping files is not possible
  • Website loads all the important scripts with XMLHttpRequests and does so from within a CSRF-token protected script, which itself is protected by origin policies
  • Website utilizes content-security-policy -headers
  • Script files have integrity checks in place, so e.g. chrome local overrides modifications cannot be used
  • The goal is to "simply" disable such website from making alerts

Final words

I wouldn't be surprised for this to just get nuked due to the broadness of it for one, but at least I had fun writing it. I find it really interesting how seemingly well a website, as well as specific functionality of it, such as the alert-function, can be secured against tampering these days - investigating this has been a eye opener for me.

Even so, It is quite a bit ridiculous how many different measures need to be used; origin checks, integrity checks, hashed inline scripts, content-security-policies, CSRF-tokens, scripts loaded in very specific ways and in very specific places, ... All to prevent me from setting one little alert function to null in a sense!

If there is one takeaway from all this, that would be the ENORMOUS QUESTION of why in the everliving f- is it not possible to simply block websites from sending alerts? There are permissions controls for all kinds of features, so how come alerts and prompts are not one of them?

Edit

Like I said in my own answer, it turned out that the website had even more extensive anti-tampering measures than I expected and therefore I am including here what I dug up after the fact. Even if one manages to make the alert object writable, it still can't be replaced so easily, as that would trigger additional explicit anti-tampering checks.

In my answer, I will go through bypassing all of these checks, but here's what we're dealing with:

tamperCheck()
{
    for (const check of [
        {ob: Function.prototype, fun: 'toString'},
        {ob: window, fun: 'alert'},
        {ob: window, fun: 'confirm'},
        {ob: Object, fun: 'freeze'},
        {ob: Object, fun: 'seal'},
        {ob: Object, fun: 'getPrototypeOf'},
        {ob: Object, fun: 'getOwnPropertyDescriptors'},
    ]) {
        const regex = new RegExp('^function ' + check.fun + '\\(\\)\\s*{\\s*\\[native code]\\s*}$');
        let d;

        try {
            d = delete check.ob[check.fun]['toString'];
        } catch {
            d = false;
        }

        if (
            !d
            || typeof Object.getPrototypeOf(check.ob[check.fun]) !== 'function'
            || typeof check.ob[check.fun] !== 'function'
            || check.ob[check.fun].name !== check.fun
            || check.ob[check.fun].toString().replaceAll('\n', '').match(regex) === null
        ) {
            return true;
        }

        Object.defineProperty(check.ob, check.fun, {value: check.ob[check.fun], configurable: false, writable: false});
    }

    return false;
}
Swiffy
  • 4,401
  • 2
  • 23
  • 49
  • I wonder if an extension could override the alert at document_start before the script runs? – evolutionxbox Oct 03 '22 at 13:09
  • @evolutionxbox "document_start: Scripts are injected after any files from css, but before any other DOM is constructed or any other script is run." That might be worth to look at, if what the documentation says holds any truth. IIRC there might be problems with accessing the window object from an extension in the first place, but I also wonder if window can be worked with so early... I don't see why not. Weird that the extension I am using for injecting and overriding does not include document_start scripting. – Swiffy Oct 03 '22 at 13:22
  • I use tampermonkey myself. – evolutionxbox Oct 03 '22 at 13:29
  • "*This essentially means that it is not possible to have a web server acting as a proxy, [… as] this is exactly what CSRF-token prevents.*" - no. I think you're confusing [CSRF tokens](https://en.wikipedia.org/wiki/CSRF_token) with the [CSP sources](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/Content-Security-Policy/Sources#sources). A proxy should be able to around that by injecting its own domain into the respective headers however. Or if you don't want to deal with that, use a man-in-the-middle proxy that uses the same domain. – Bergi Oct 03 '22 at 15:48
  • "*one can only inject scripts into the page when there is a page to inject the script in, but in this case when such page exists, the object freeze code is already there as well.*" - why do you think you can only inject code at the end of the `` element, not at the start of it? What tool are you using to inject your scripts? – Bergi Oct 03 '22 at 15:49
  • Proxies and the Reflect object don't help with any of this. All techniques, including prototype pollution, hinge on the fact that your code executes first - that's the only way to "win". – Bergi Oct 03 '22 at 15:51
  • @Bergi CSP is present as well. I might have confused CSRF token with [nonce](https://developer.mozilla.org/en-US/docs/Web/HTML/Global_attributes/nonce), though the website seems to use the CSRF-token as nonce as well. I can of course inject scripts anywhere in the DOM or in the head element, but it doesn't make a difference even if I, say, removed the whole head and replaced it with my own after the page has loaded. The original freezing in the head will have always ran before any of mine. So the problem is not so much about the place, but the time of the injecting. – Swiffy Oct 03 '22 at 19:30
  • @evolutionxbox Seems like the extension approach won't work. Extension scripts do not have access to the page's window-object in the same way that the page itself does. There is a workaround for this where the extension script is injected as a script element in to the web page, but such script would be then executed too late, as the would already be there. https://stackoverflow.com/questions/12395722/can-the-window-object-be-modified-from-a-chrome-extension – Swiffy Oct 03 '22 at 21:50
  • @Swiffy If you are using chrome extension `content_scripts`, then [`run_at: "document_start"`](https://developer.chrome.com/docs/extensions/mv3/content_scripts/#run_time) should do what you need: "*injected […] before any other DOM is constructed or any other script is run.*" – Bergi Oct 03 '22 at 21:53
  • @Bergi Indeed it does, but although such script would execute early enough, it does not have access to the correct window object of the web page and therefore no way that I know of to do anything to the alert. I also tried using a MutationObserver as well as a simple interval to catch when the freezing script appears in the head and remove it, but without luck. – Swiffy Oct 03 '22 at 22:37
  • @Swiffy Ah, but [injecting an actual script tag](https://stackoverflow.com/q/10485992/1048572) from the content script should work even with `run_at: "document_start"`. Or at least it did 10 years ago - has that changed? – Bergi Oct 03 '22 at 22:58

1 Answers1

2

Solution

Well, I couldn't sleep until I beat this thing, so I did. This solution turned out to be quite complex and hopefully there are easier and simpler solutions out there, but here is mine.

Overview

This solution requires making a chrome extension. Manifest V2 extension to be specific. Unfortunately Manifest V2 extensions will become unsupported in 2023, as the error in my extension says:

Manifest version 2 is deprecated, and support will be removed in 2023. See https://developer.chrome.com/blog/mv2-transition/ for more details.

But for now this works. I'm not too familiar with chrome extensions, so it might be possible that this could be done with Manifest V3 extensions as well already.

The extension does a multitude of things:

  • It modifies the content-security-policy header of main_frame and sub_frame request response headers so that we can inject inline scripts
  • It utilizes the run_at: "document_start" content script option to execute certain parts of our scripts early enough (thanks to @evolutionxbox for this)
  • It uses MutationObserver to intercept static <script> nodes before they are executed
  • It injects a script into the page which accesses and patches the window.alert
  • It uses a Proxy object to hide the patched window.alert from anti-tampering checks

Bypassing the content-security-policy

This one was pretty simple when I switched to Manifest V2. I couldn't get this to work with Manifest V3 extension. We use the declarativeNetRequest API to have the extension modify the content-security-policy header.

manifest.json needs the following properties (on top of the usual ones):

{
  "permissions": [
    "declarativeNetRequest",
    "declarativeNetRequestWithHostAccess",
    "declarativeNetRequestFeedback",
    "*://<website.domain>/*"
  ],

  "declarative_net_request" : {
    "rule_resources" : [
      {
        "id": "ruleset_1",
        "enabled": true,
        "path": "rules.json"
      }
    ]
  }
}

rules.json looks like this:

[
  {
    "id" : 1,
    "priority": 1,

    "action" : { 
      "type" : "modifyHeaders",

      "responseHeaders": [
        { "header": "content-security-policy", "operation": "set", "value": "default-src * data: blob: filesystem: about: ws: wss: 'unsafe-inline' 'unsafe-eval'; script-src * data: blob: 'unsafe-inline' 'unsafe-eval'; " }
      ]
    },

    "condition" : {
      "urlFilter": "*://*/*",
      "resourceTypes": ["main_frame", "sub_frame", "script", "other"]
    }
  }
]

Such manifest.json combined with the rules.json makes the extension to modify the content-security-policy header in such a way that inline-scripts can be injected into the page. The documentation here explains why we apparently have to use the V2 manifest version.

run_at: "document_start"

To run the extension at document_start, the manifest.json needs to have the following options block:

"content_scripts": [
  {
    "matches": ["<website>"],
    "js": ["inject.js"],
    "run_at": "document_start"
  }
]

This will make the extension execute whatever is inside inject.js at document_start, so when the document hasn't even loaded yet.

Intercepting static <script> node creation

Now that our script can execute early enough, so before the document hasn't loaded yet, we can setup a MutationObserver to intercept statically served <script> node additions to the web page.

This is what the start of the inject.js looks like:

class ScriptInterceptor {
    #observer
    #config = { childList: true, subtree: true };
    #handlerFn

    constructor(handlerFn) {
        this.#handlerFn = handlerFn;
        this.#observer = new MutationObserver(this.#callback.bind(this));
        this.#observer.observe(document, this.#config);
    }

    #isScriptMutation = (node) => { 
        return node.tagName === "SCRIPT";
    }

    #callback(mutationList, observer) {
        for(const mutation of mutationList) {
            for(const node of mutation.addedNodes) {
                if(this.#isScriptMutation(node)) {
                    this.#handlerFn(node);
                }
            }
        }
    }
}

const scriptTrace = "Object.defineProperty(window,'alert',{configurable:false,writable:false})";

const interceptor = new ScriptInterceptor((script) => {
    if(script.textContent.includes(scriptTrace)) {
        script.textContent = "";
        script.remove();
    }
});

So what is happening here? I made a ScriptInterceptor class, which internally creates a MutationObserver instance to watch for any changes in element childlists or subtrees within the whole document.

When MutationObserver notices a DOM mutation, or a bunch of them, we go through each of these mutations and the affected DOM nodes while filtering out everything that isn't a <script> node.

When we do encounter a <script> node, we call a handler function and pass it the found node. In this case, our handler function looks like this:

(script) => {
  if(script.textContent.includes(scriptTrace)) {
    script.textContent = "";
    script.remove();
  }
}

Where the scriptTrace is the code content of the specific <script> element that freezes the window.alert object:

const scriptTrace = "Object.defineProperty(window,'alert',{configurable:false,writable:false})";

This gets rid of the freezing of the object, but we still haven't done anything to the window.alert itself to disable it.

Side-note about MutationObserver and statically served <script> blocks

So I've been using the term static here a lot and there is a reason for it. This MutationObserver method will not work for scripts that are added dynamically to the page, so for example scripts like these:

const dynamicScript = document.createElement('script');
dynamicScript.text = `(() => { console.log("Hello!") })()`;
document.documentElement.appendChild(dynamicScript);

While the MutationObserver will catch them being added to the page, it will only do so after the script has already been executed.

My situation did not require handling intercepting scripts like this, but I did figure out one solution out of interest, if someone would find themselves in such situation:

Element.prototype.appendChild = () => { };
Element.prototype.append = () => { };
Element.prototype.prepend = () => { };
Element.prototype.after = () => { };
Element.prototype.before = () => { };
Element.prototype.insertAdjacentElement = () => { };
Element.prototype.insertAdjacentHTML = () => { };
Element.prototype.insertAdjacentText = () => { };
Element.prototype.replaceChildren = () => { };
Element.prototype.replaceWith = () => { };
Element.prototype.setHTML = () => { };

Object.defineProperty(Element.prototype, "appendChild", { configurable:false, writable:false });
Object.defineProperty(Element.prototype, "append", { configurable:false, writable:false });
Object.defineProperty(Element.prototype, "prepend", { configurable:false, writable:false });
Object.defineProperty(Element.prototype, "after", { configurable:false, writable:false });
Object.defineProperty(Element.prototype, "before", { configurable:false, writable:false });
Object.defineProperty(Element.prototype, "insertAdjacentElement", { configurable:false, writable:false });
Object.defineProperty(Element.prototype, "insertAdjacentHTML", { configurable:false, writable:false });
Object.defineProperty(Element.prototype, "insertAdjacentText", { configurable:false, writable:false });
Object.defineProperty(Element.prototype, "replaceChildren", { configurable:false, writable:false });
Object.defineProperty(Element.prototype, "replaceWith", { configurable:false, writable:false });
Object.defineProperty(Element.prototype, "setHTML", { configurable:false, writable:false });

This is just a boilerplate, but the idea here is that we can actually overwrite all of the methods that could possibly be used to insert <script> tags into the page and them lock them ourselves. The point is not to leave the methods empty, but implement some kind of logic there to catch and cancel the insertion of the dynamic script of interest and have everything else work normally. Off-topic, but might be nice to know.

Patching window.alert and making it undetectable

The rest of the inject.js script looks like this:

const inject = () => {
    const fn = () => {

        // Alert actor
        const alert = () => { }

        alert.toString = () => { return "function alert() { [native code] }" }

        const antiTamperCheckFaker = {
            deleteProperty() {
                return true;
            },

            getPrototypeOf() {
                return () => { };
            }
        }

        const alertProxy = new Proxy(alert, antiTamperCheckFaker);
        window.alert = alertProxy;
        Object.defineProperty(window, 'alert', { configurable: false, writable: false });
    }

    const e = document.createElement('script');
    e.text = `(${fn.toString()})()`;
    document.documentElement.appendChild(e);
}

inject();

What we are doing here is essentially simple - we are turning window.alert into a function () => { } that does nothing.

However, when I got to this point, the website started to brick because my window.alert patch was being detected by some anti-tampering check in the web page. Digging deeper I found the following tampering check function (which I'll be editing to the original question as well):

tamperCheck()
{
    for (const check of [
        {ob: Function.prototype, fun: 'toString'},
        {ob: window, fun: 'alert'},
        {ob: window, fun: 'confirm'},
        {ob: Object, fun: 'freeze'},
        {ob: Object, fun: 'seal'},
        {ob: Object, fun: 'getPrototypeOf'},
        {ob: Object, fun: 'getOwnPropertyDescriptors'},
    ]) {
        const regex = new RegExp('^function ' + check.fun + '\\(\\)\\s*{\\s*\\[native code]\\s*}$');
        let d;

        try {
            d = delete check.ob[check.fun]['toString'];
        } catch {
            d = false;
        }

        if (
            !d
            || typeof Object.getPrototypeOf(check.ob[check.fun]) !== 'function'
            || typeof check.ob[check.fun] !== 'function'
            || check.ob[check.fun].name !== check.fun
            || check.ob[check.fun].toString().replaceAll('\n', '').match(regex) === null
        ) {
            return true;
        }

        Object.defineProperty(check.ob, check.fun, {value: check.ob[check.fun], configurable: false, writable: false});
    }

    return false;
}

It looks a bit messy, but we can make this simpler if we say that:

check.ob = window and check.fun = alert

So first of all, we can replace the check.fun in the RegExp-line with "alert":

const regex = new RegExp('^function alert \\(\\)\\s*{\\s*\\[native code]\\s*}$');

The regex is not actually used at this point yet, but I'll get back to it. Next up we have the try...catch block:

let d;

try {
    d = delete check.ob[check.fun]['toString'];
} catch {
    d = false;
}

This essentially checks if it is possible to perform delete window["alert"]["toString"] successfully. If we look at my code here:

Object.defineProperty(window, 'alert', { configurable: false, writable: false });

I am freezing the alert object myself, so this delete operation will fail, as you cannot delete properties or methods such as the toString from a frozen object.

This is where the Proxy object comes into play for the first time. As you can read from my code, we're not actually setting window.alert = alert, but we're instead doing window.alert = alertProxy. This alertProxy is basically the const alert = () => { } we're defining earlier, but it comes with the added advantage that we can specify certain handlers for it:

const antiTamperCheckFaker = {
    deleteProperty() {
        return true;
    },

    getPrototypeOf() {
        return () => { };
    }
}

To get around this deletion check, we are sort of "hooking into" the deletion function which gets called when delete window["alert"]["toString"] is executed. We're overwriting the default behaviour to not delete anything, but instead to simply say "all good, it's deleted!". We can't have that toString to be deleted because we've also overwritten that and we need it later.

Next up comes typeof Object.getPrototypeOf(check.ob[check.fun]) !== 'function' check, which we can write as:

typeof Object.getPrototypeOf(window["alert"]) !== 'function'

This checks that the prototype of window.alert is a function(*). This isn't necessarily a problem as our "alert actor" should be a function, but I remember from experience that in some cases the usage of Proxy might trigger checks like this. And if we wanted or had to use an object instead of function for our alert actor, that is also when this check would fail. This can be bypassed with the getPrototypeOf handler which we have inside the antiTamperCheckFaker if need be.

(*) While it clearly says !== 'function', the if-clause as whole returns true if any of the checks are true, so if window.alert is not a function, that part of the check would evaluate as true, making the check return true which is bad.

Then there is the check.ob[check.fun] !== 'function' check, which checks if window.alert itself is a function. Pretty self-explanatory, again, our alert that is used to replace window.alert with just needs to be a function.

We could have gotten around these checks a bit easier, if we could have replaced window.alert with an object, but this check essentially serves as a requirement that whatever we are replacing window.alert with must be some kind of function.

Then we have check.ob[check.fun].name !== check.fun check. This one checks if the name of the current alert function within the window is not actually named alert. This would fail if we'd have:

const myPatchedAlert = () => { }

instead of:

const alert = () => { }

The check.ob[check.fun].name would evaluate to "myPatchedAlert" in such case.

Then we have this bigger check:

check.ob[check.fun].toString().replaceAll('\n', '').match(regex) === null

Now this one is interesting. You could see for yourself what happens if you type window.alert.toString() into your dev console, but I'll spoil it for you and say that it will output 'function alert() { [native code] }'. The ugly looking regex we saw before, so this one:

const regex = new RegExp('^function alert \\(\\)\\s*{\\s*\\[native code]\\s*}$');

Is essentially just to do a comparison like:

window.alert.toString() === 'function alert() { [native code] }'

When our alert const alert = () => { } gets .toString() called upon it, it will output () => { } which would fail this check. Luckily we're overwriting the toString() method of our alert to return a similar [native code] thing:

alert.toString = () => { return "function alert() { [native code] }" }

While it is easy to just make our own toString() method and have it return whatever we want, keep in mind that earlier the tampering check literally tried to delete the toString() method from our alert before running this check. If it couldn't delete it, the check would have failed at that.

We protected our toString() method from deletion by freezing our own alert, but we also had to intercept the delete operation so that the check would think that it successfully deleted the undeleteable toString().

On a side-note, we could not have used Function.prototype.toString = ..., which would have protected it from deletion because it would have been a prototype function instead of member of alert, because the check also happens to check if the toString of Function.prototype has been tampered with.

This turned out to not be the case, actually. Bergi replied with a clever Function.prototype -based solution:

Function.prototype.toString = function toString() { 
    return `function ${this.name}() { [native code] }`; 
};

Anyway

I have to apologize. This whole thing got a bit out of hand. SO is probably not the best place for such self-actualization adventures like I've had here.

To my defence, I did come up with decent solutions to many, I guess, unusual problems regarding extensions, script injections, anti-tampering methods, and browser security. Here's to hoping that maybe someone finds themselves here looking for answers.

Full files

manifest.json

{
  "name": "document_start script exector",
  "description": "document_start script exector",
  "version": "1.0",
  "manifest_version": 2,
  "permissions": [
    "declarativeNetRequest",
    "declarativeNetRequestWithHostAccess",
    "declarativeNetRequestFeedback",
    "*://<website>/*"
  ],

  "content_scripts": [
    {
      "matches": ["<website>"],
      "js": ["inject.js"],
      "run_at": "document_start"
    }
  ],

  "declarative_net_request" : {
    "rule_resources" : [
      {
        "id": "ruleset_1",
        "enabled": true,
        "path": "rules.json"
      }
    ]
  }
}

rules.json

[
  {
    "id" : 1,
    "priority": 1,

    "action" : { 
      "type" : "modifyHeaders",

      "responseHeaders": [
        { "header": "content-security-policy", "operation": "set", "value": "default-src * data: blob: filesystem: about: ws: wss: 'unsafe-inline' 'unsafe-eval'; script-src * data: blob: 'unsafe-inline' 'unsafe-eval'; " }
      ]
    },

    "condition" : {
      "urlFilter": "*://*/*",
      "resourceTypes": ["main_frame", "sub_frame", "script", "other"]
    }
  }
]

inject.js

class ScriptInterceptor {
    #observer
    #config = { childList: true, subtree: true };
    #handlerFn

    constructor(handlerFn) {
        this.#handlerFn = handlerFn;
        this.#observer = new MutationObserver(this.#callback.bind(this));
        this.#observer.observe(document, this.#config);
    }

    #isScriptMutation = (node) => { 
        return node.tagName === "SCRIPT";
    }

    #callback(mutationList, observer) {
        for(const mutation of mutationList) {
            for(const node of mutation.addedNodes) {
                if(this.#isScriptMutation(node)) {
                    this.#handlerFn(node);
                }
            }
        }
    }
}

const scriptTrace = "Object.defineProperty(window,'alert',{configurable:false,writable:false})";

const interceptor = new ScriptInterceptor((script) => {
    if(script.textContent.includes(scriptTrace)) {
        script.textContent = "";
        script.remove();
    }
});

const inject = () => {
    const fn = () => {

        // Alert actor
        const alert = () => { }

        alert.toString = () => { return "function alert() { [native code] }" }

        const antiTamperCheckFaker = {
            deleteProperty() {
                return true;
            },

            getPrototypeOf() {
                return () => { };
            }
        }

        const alertProxy = new Proxy(alert, antiTamperCheckFaker);
        window.alert = alertProxy;
        Object.defineProperty(window, 'alert', { configurable: false, writable: false });
    }

    const e = document.createElement('script');
    e.text = `(${fn.toString()})()`;
    document.documentElement.appendChild(e);
}

inject();
Swiffy
  • 4,401
  • 2
  • 23
  • 49
  • 1
    Nice writeup! The proxy shouldn't be necessary though, if you did ``Function.prototype.toString = function toString() { return `function ${this.name}() { [native code] }`; };`` (possibly with `if (this != toString && this != alert) return original.call(this)`) – Bergi Oct 04 '22 at 15:08
  • @Bergi Hmm, that is a nice alternative. I have the tampercheck set up for myself like so that I can see what is and isn't passing it. Your solution also outputs all falses, so it would pass the check. :) – Swiffy Oct 04 '22 at 15:34