How to prevent script injection attacks
Asked Answered
O

1

6

Intro

This topic has been the bane of many questions and answers on StackOverflow -and in many other tech-forums; however, most of them are specific to exact conditions and even worse: "over-all" security in script-injection prevention via dev-tools-console, or dev-tools-elements or even address-bar is said to be "impossible" to protect. This question is to address these issues and serve as current and historical reference as technology improves -or new/better methods are discovered to address browser security issues -specifically related to script-injection attacks.

Concerns

There are many ways to either extract -or manipulate information "on the fly"; specifically, it's very easy to intercept information gathered from input -to be transmitted to the server - regardless of SSL/TLS.

intercept example

Have a look here Regardless of how "crude" it is, one can easily use the principle to fabricate a template to just copy+paste into an eval() in the browser console to do all kinds of nasty things such as:

  • console.log() intercepted information in transit via XHR
  • manipulate POST-data, changing user-references such as UUIDs
  • feed the target-server alternative GET (& post) request information to either relay (or gain) info by inspecting the JS-code, cookies and headers

This kind of attack "seems" trivial to the untrained eye, but when highly dynamic interfaces are in concern, then this quickly becomes a nightmare -waiting to be exploited.

We all know "you can't trust the front-end" and the server should be responsible for security; however - what about the privacy/security of our beloved visitors? Many people create "some quick app" in JavaScript and either do not know (or care) about the back-end security.

Securing the front-end as well as the back-end would prove formidable against an average attacker, and also lighten the server-load (in many cases).

Efforts

Both Google and Facebook have implemented some ways of mitigating these issues, and they work; so it is NOT "impossible", however, they are very specific to their respective platforms and to implement requires the use of entire frameworks plus a lot of work -only to cover the basics.

Regardless of how "ugly" some of these protection mechanisms may appear; the goal is to help (mitigate/prevent) security issues to some degree, making it difficult for an attacker. As everybody knows by now: "you cannot keep a hacker out, you can only discourage their efforts".

Tools & Requirements

The goal is to have a simple set of tools (functions):

  • these MUST be in plain (vanilla) javascript
  • together they should NOT exceed a few lines of code (at most 200)
  • they have to be immutable, preventing "re-capture" by an attacker
  • these MUST NOT clash with any (popular) JS frameworks, such as React, Angular, etc
  • does NOT have to be "pretty", but readable at least, "one-liners" welcome
  • cross-browser compatible, at least to a good percentile
Outmarch answered 28/10, 2019 at 21:15 Comment(0)
O
3

Runtime Reflection / Introspection

This is a way to address some of these concerns, and I don't claim it's "the best" way (at all), it's an attempt. If one could intercept some "exploitable" functions and methods and see if "the call" (per call) was made from the server that spawned it, or not, then this could prove useful as then we can see if the call came "from thin air" (dev-tools).

If this approach is to be taken, then first we need a function that grabs the call-stack and discard that which is not FUBU (for us by us). If the result of this function is empty, hazaa! - we did not make the call and we can proceed accordingly.

a word or two

In order to make this as short & simple as possible, the following code examples follow DRYKIS principles, which are:

  • don't repeat yourself, keep it simple
  • "less code" welcomes the adept
  • "too much code & comments" scare away everybody
  • if you can read code - go ahead and make it pretty

With that said, pardon my "short-hand", explanation will follow

first we need some constants and our stack-getter

    const MAIN = window;
    const VOID = (function(){}()); // paranoid
    const HOST = `https://${location.host}`; // if not `https` then ... ?

    const stak = function(x,a, e,s,r,h,o)
    {
        a=(a||''); e=(new Error('.')); s=e.stack.split('\n'); s.shift();  r=[]; h=HOSTPURL; o=['_fake_']; s.forEach((i)=>
        {
            if(i.indexOf(h)<0){return}; let p,c,f,l,q; q=1; p=i.trim().split(h); c=p[0].split('@').join('').split('at ').join('').trim();
            c=c.split(' ')[0];if(!c){c='anon'}; o.forEach((y)=>{if(((c.indexOf(y)==0)||(c.indexOf('.'+y)>0))&&(a.indexOf(y)<0)){q=0}}); if(!q){return};
            p=p[1].split(' '); f=p[0]; if(f.indexOf(':')>0){p=f.split(':'); f=p[0]}else{p=p.pop().split(':')}; if(f=='/'){return};
            l=p[1]; r[r.length]=([c,f,l]).join(' ');
        });
        if(!isNaN(x*1)){return r[x]}; return r;
    };

After cringing, bare in mind this was written "on the fly" as "proof of concept", yet tested and it works. Edit as you whish.

stak() - short explanation
  • the only 2 relevant arguments are the 1st 2, the rest is because .. laziness (short answer)
  • both arguments are optional
  • if the 1st arg x is a number then e.g. stack(0) returns the 1st item in the log, or undefined
  • if the 2nd arg a is either a string -or an array then e.g. stack(undefined, "anonymous") allows "anonymous" even though it was "omitted" in o
  • the rest of the code just parses the stack quickly, this should work in both webkit & gecko -based browsers (chrome & firefox)
  • the result is an array of strings, each string is a log-entry separated by a single space as function file line
  • if the domain-name is not found in a log-entry (part of filename before parsing) then it won't be in the result
  • by default it ignores filename / (exactly) so if you test this code, putting in a separate .js file will yield better results than in index.html (typically) -or whichever web-root mechanism is used
  • don't worry about _fake_ for now, it's in the jack function below

now we need some tools

bore() - get/set/rip some value of an object by string reference
const bore = function(o,k,v)
{
    if(((typeof k)!='string')||(k.trim().length<1)){return}; // invalid
    if(v===VOID){return (new Function("a",`return a.${k}`))(o)}; // get
    if(v===null){(new Function("a",`delete a.${k}`))(o); return true}; // rip
    (new Function("a","z",`a.${k}=z`))(o,v); return true; // set
};
bake() - shorthand to harden existing object properties (or define new ones)
const bake = function(o,k,v)
{
    if(!o||!o.hasOwnProperty){return}; if(v==VOID){v=o[k]};
    let c={enumerable:false,configurable:false,writable:false,value:v};
    let r=true; try{Object.defineProperty(o,k,c);}catch(e){r=false};
    return r;
};

bake & bore - rundown

These are failry self-explanatory, so, some quick examples should suffice

  • using bore to get a property: console.log(bore(window,"XMLHttpRequest.prototype.open"))
  • using bore to set a property: bore(window,"XMLHttpRequest.prototype.open",function(){return "foo"})
  • using bore to rip (destroy carelessly): bore(window,"XMLHttpRequest.prototype.open",null)
  • using bake to harden an existing property: bake(XMLHttpRequest.prototype,'open')
  • using bake to define a new (hard) property: bake(XMLHttpRequest.prototype,'bark',function(){return "woof!"})

intercepting functions and constructions

Now we can use all the above to our advantage as we devise a simple yet effective interceptor, by no means "perfect", but it should suffice; explanation follows:

const jack = function(k,v)
{
    if(((typeof k)!='string')||!k.trim()){return}; // invalid reference
    if(!!v&&((typeof v)!='function')){return}; // invalid callback func
    if(!v){return this[k]}; // return existing definition, or undefined
    if(k in this){this[k].list[(this[k].list.length)]=v; return}; //add
    let h,n; h=k.split('.'); n=h.pop(); h=h.join('.'); // name & holder
    this[k]={func:bore(MAIN,k),list:[v]}; // define new callback object

    bore(MAIN,k,null); let f={[`_fake_${k}`]:function()
    {
        let r,j,a,z,q; j='_fake_'; r=stak(0,j); r=(r||'').split(' ')[0];
        if(!r.startsWith(j)&&(r.indexOf(`.${j}`)<0)){fail(`:(`);return};
        r=jack((r.split(j).pop())); a=([].slice.call(arguments));
        for(let p in r.list)
        {
            if(!r.list.hasOwnProperty(p)||q){continue}; let i,x;
            i=r.list[p].toString(); x=(new Function("y",`return {[y]:${i}}[y];`))(j);
            q=x.apply(r,a); if(q==VOID){return}; if(!Array.isArray(q)){q=[q]};
            z=r.func.apply(this,q);
        };
        return z;
    }}[`_fake_${k}`];

    bake(f,'name',`_fake_${k}`); bake((h?bore(MAIN,h):MAIN),n,f);
    try{bore(MAIN,k).prototype=Object.create(this[k].func.prototype)}
    catch(e){};
}.bind({});
jack() - explanation
  • it takes 2 arguments, the first as string (used to bore), the second is used as interceptor (function)
  • the first few comments explain a bit .. the "add" line simply adds another interceptor to the same reference
  • jack deposes an existing function, stows it away, then use "interceptor-functions" to replay arguments
  • the interceptors can either return undefined or a value, if no value is returned from any, the original function is not called
  • the first value returned by an interceptor is used as argument(s) to call the original and return is result to the caller/invoker
  • that fail(":(") is intentional; an error will be thrown if you don't have that function - only if the jack() failed.

Examples

Let's prevent eval from being used in the console -or address-bar

jack("eval",function(a){if(stak(0)){return a}; alert("having fun?")});

extensibility

If you want a DRY-er way to interface with jack, the following is tested and works well:

const hijack = function(l,f)
{
    if(Array.isArray(l)){l.forEach((i)=>{jack(i,f)});return};
};

Now you can intercept in bulk, like this:

hijack(['eval','XMLHttpRequest.prototype.open'],function()
{if(stak(0)){return ([].slice.call(arguments))}; alert("gotcha!")});

A clever attacker may then use the Elements (dev-tool) to modify an attribute of some element, giving it some onclick event, then our interceptor won't catch that; however, we can use a mutation-observer and with that spy on "attribute changes". Upon attribute-change (or new-node) we can check if changes were made FUBU (or not) with our stak() check:

const watchDog=(new MutationObserver(function(l)
{
   if(!stak(0)){alert("you again! :D");return};
}));

watchDog.observe(document.documentElement,{childList:true,subtree:true,attributes:true});

Conclusion

These were but a few ways of dealing with a bad problem; though I hope someone finds this useful, and please feel free to edit this answer, or post more (or alternative/better) ways of improving front-end security.

Outmarch answered 28/10, 2019 at 21:15 Comment(1)
Fixed the code to work in: Firefox -and Chromium-based browsers (Chrome, Brave, etc). Please comment if you experience any issues and I'll fix it.Outmarch

© 2022 - 2024 — McMap. All rights reserved.