Discord 1.0.9245 Activity-to-Native Calculator RCE

14 Jul 2026 · 15 min read · back to posts

Summary

Origin: github.com/bikini/exploitarium

This chain starts from a Discord Activity (a web app that Discord loads in an iframe), and ends with the Discord desktop client launching calc.exe on the user’s Windows machine.

The starting point is a memory-corruption bug in V8 (the JavaScript engine inside Discord’s Chromium renderer). This gives the attacker read/write access to the renderer’s memory, and from there the chain works around each boundary Discord and the browser put in its way until it can call a Windows API directly.

PlatformBuild
Discord1.0.9245, Stable channel
Windows 11 x6410.0.26200
Electron37.6.0
Chromium138.0.7204.251
V813.8.258.32-electron.0

Background

There are three layers involved:

Electron - The framework that wraps a Chromium renderer and a Node-capable process into a desktop app.

V8 - The JavaScript engine that turns page script into machine code.

Discord - Activities, IPC channels, content-security setup.

Electron

A framework that packages a Chromium browser and a Node.js runtime into single distribution. It splits execution into two kinds of processes:

  1. The main process
  • Runs Node.js and has full access to the operating system. It can spawn child processes, read and write files and call native APIs directly.
  1. The renderer process
  • Runs web content inside Chromium. When configured correctly, it runs in a sandbox which means it doesn’t have the same privilege level as the main process; it’s confined the way a browser tab is confined. Renderers are separate processes and an app can have several of them.

A renderer and the main process are linked by inter-process communication, or IPC. This means a renderer cannot call certain APIs itself, it must send a message over a defined IPC channel, and code in the main process decides whether to act on it and how.

IPC is the central trust boundary of any Electron app. The renderer is treated as potentially hostile, and the main process is supposed to expose only a certain set of IPC channels with validated arguments. Anything a renderer can reach through IPC is, in effect, part of its attack surface.

 V8, Just-In-Time Compilation and Optimization

Inside each renderer, JavaScript runs on V8 (Google’s JavaScript and WebAssembly engine), whose job is to take JavaScript and make it run fast.

Plain interpretation is slow because every operation has to re-check what it is working with, so V8 watches how a program behaves as it runs and uses that information to generate faster code.

Specifically, it records:

  • The shape of the object seen by each operation (called Maps, or hidden classes),
  • The types that flow through each call site.

This runtime record is called type feedback and it’s what the optimizing compilers use to produce fast, speculative machine code (where work is done before it’s known whether it’s needed or not, to prevent having to find out and then do it).

Tiers of execution

There are four tiers of execution. Not every function walks these steps in order; a function starts in the interpreter and is promoted to a higher tier as it gets “hot”, meaning that it runs often enough to be worth compiling. Functions can skip tiers and a violation of an assumption means that a function will fall back to a lower tier.

  1. Ignition, the interpreter
    • All JavaScript code is first compiled to a compact bytecode, and executed by Ignition interpreting it.
    • Interpreting is flexible but slow, because every bytecode operation re-checks its operands every time it runs.
    • Collects type feedback via inline caching. As Ignition interprets, it writes what it observes into per-function feedback vectors (which is a record of the object’s Map along with a fast handler for objects of that shape), and every tier above it reads from those vectors instead of gathering its own.
  2. Sparkplug, the baseline compiler
    • Makes a single linear pass over the bytecode and emits matching machine code without any intermediate representation or optimizing assumptions.
    • Runs faster than the Ignition because it has no interpreter overhead but isn’t specialized to the observed types.
    • Keeps updating the same feedback vectors.
  3. Maglev, the mid-tier optimizing compiler
    • Consumes the feedback gathered by Ignition and uses it to specialize common operations, building an intermediate representation.
  4. Turbofan, the top-tier optimizing compiler
    • Receives the hottest code.
    • Consumes the feedback to specialize aggressively and fold repeated runtime checks into a small number of guards.
    • A guard is a cheap runtime test that asserts an assumption the compiler relies on (for example that a particular object retains the shape the feedback said it had)
    • If a guard assumption is violated at runtime, the execution falls back to a lower tier.

Type confusion

The optimizers are therefore only as correct as the assumption the inline cache reported, and the guard is what is supposed to hold that assumption true at runtime. It is possible that a system’s assumption about an object’s type to diverge from the object’s actual type without a guard catching this mismatch. This is a type confusion. This flaw could lie in an incorrect assumption the compiler made about when a Map could change, a callback that mutated an object at a moment the optimizer assumed was safe, or a representation change that the optimizer tracked incorrectly.

The specific bug this chain relies on is that last kind: a WebAssembly reference type crossing into optimized JavaScript. WebAssembly and JavaScript run in the same engine, and when a value passes between them the optimizer has to track how it is represented. This chain confuses that boundary, which is what makes the primitive direct (the details are in the Vulnerability section below).

A type confusion is exploitable because of how V8 lays out array contents in memory. V8 chooses a backing-store format (which is the underlying format used for persistent storage) based on what an array holds, a property called the array’s elements kind. An array that only holds floating-point numbers is stored as a double array, where the raw 64-bit values sit inline in memory with no indirection. An array that holds objects is stored as tagged pointers, where each slot is a reference to an object elsewhere on the heap. The same 64-bit slot therefore means completely different things under the two formats. In a double array those bits are a number; in a tagged array those same bits are a pointer.

If V8 can be made to treat a slot that holds a pointer as though it holds a double, then reading back that slot would return the pointer’s bits as a number, disclosing an address. This is the addrof primitive. The inverse, fakeobj, works by writing a number into a slot the engine treats as a pointer. This makes it accept an attacker-chosen address as though it were a real object and operate on the memory there as a valid structure.

These two primitives compose into full memory access within the heap. Every V8 object begins with a pointer to its Map (the structure that tells the engine what the object is and where its fields, including its backing-store pointer and length, are laid out).

Using fakeobj, an attacker can hand the engine a reference to memory they have arranged to look like a valid array header, with a Map that says “double array” and a backing-store pointer of their choosing. Indexing element zero of that fake array will read from the address the attacker put in the backing-store pointer, and writing element zero will write to it.

At this point the type confusion has become an arbitrary read and write.

Mitigations between the primitive and the operating system

Currently, the attacker has an in-cage arbitrary read and write, built by an array whose backing-store pointer they control. But there are still two mitigations that remain between the primitive and the ability to run native code.

  1. Pointer compression

To save memory, V8 stores most heap pointers (including the backing-store pointer) as 32-bit offsets from a single base address rather than full 64-bit pointers. All of these compressed pointers refer to objects within one contiguous region of memory (~4GB) called the cage, with one base against which those offsets are resolved.

Getting outside the cage requires finding a full 64-bit pointer that V8 stores. Some V8 structures hold uncompressed native pointers because they refer to things that live outside the cage, and a prime example is compiled machine code. When V8 JIT-compiles a function, the generated code sits in executable memory allocated separately from the heap, so any structure that lets the engine call into that code, such as a dispatch entry, has to store a real 64-bit address. The attacker uses the cage-bound read to inspect those structures and read out full native pointers, which reveals where things actually live in the 64-bit address space and lets them translate between compressed offsets and real addresses.

  1. W^X protection

Knowing where native code lives is not the same as running your own instructions, because of the second mitigation. On modern systems a page of memory is generally either writable or executable but not both at once, so an attacker that can write memory can’t write new code into a page and run it. This is known as W^X protection, short for write-xor-execute.

JIT engines have to produce memory that is executable and that they can regenerate, which puts them in constant tension with W^X. Rather than injecting new executable memory, the technique in this chain locates existing JIT-compiled code (specifically the region holding a compiled routine that enforces a security check), temporarily makes that page writable, overwrites a few specific instructions, and restores the original protection.

Because the bytes being changes are the compiled security check itself, altering a couple of instructions changes what the browser will enforce. V8 then calls that routine exactly as it normally would, but it behaves the way the attacker wants.

Two hardware details make this work in practice. After writing new bytes into executable memory, the attacker must flush the instruction cache, forcing the processor to fetch the new instructions rather than a cached copy of the old ones. The chain uses this same writable-JIT capability in the other direction as well: rather than only editing an existing routine, it writes its own native code into a JIT page as a trampoline, a small piece of native code whose job is to redirect execution into the attacker’s payload.

Web platform boundaries

The attacker now controls one renderer’s memory and can run native code inside it. There are several boundaries enforced by the browser that limits what any renderer can do regardless of what runs inside it.

The iframe sandbox

An iframe can be given a sandbox attribute that strips its capabilities to almost nothing and then grants them back one flag at a time. These govern whether the framed content may run scripts at all, whether it may open popup windows, and when it does open one, whether that new window inherits the frame’s restrictions or escapes them. The security property is that a frame the embedder chose to restrict cannot grant itself capabilities withheld by the embedder. When a sandboxed frame calls window.open the renderer checks these flags before creating the new window and decides which restrictions carry over to it.

This check runs in the renderer, in compiled code, as part of the routine that handles window creation. A renderer that can patch its own compiled code can change what that check decides.

User activation

Opening a window is not something a page can do at any time. Browsers require user activation, which is a user gesture such as a click, before they will honor a window.open call. This is what stops an arbitrary page from spawning windows on its own. An exploit that relies on the popup path has to arrange for its window creation to occur while a legitimate user activation is still in effect, rather than fabricating one.

Content Security Policy nonces

Content Security Policy, or CSP, is a set of rules a server attaches to a page that tells the browser what the page is allowed to do, including which scripts it may run.

One form of CSP requires that every script carries a nonce, which is a random token the server generates fresh for each response. The browser runs a script only if the nonce matches the one in the policy for that response, and refuses any which are absent or wrong.

The nonce changes on every response, so any content that needs its own scripts to run has to carry that specific response’s nonce. This becomes relevant when a page’s scripts are moved or rewritten between responses.

Discord’s Activity proxy assigns a fresh nonce on every response, so a script that gets moved from one response into another has to be given the new response’s nonce or the browser will refuse it, and this is handled by the chain directly (elaborated in the exploitation chain section).

Discord Activities

Discord Activities are embedded web applications that run inside the Discord client. They’re loaded into sandboxed iframes and served through a Discord-operated proxy rather than talking to their origin servers directly. This means that the Activity is the restricted frame whose flags and script policy the chain has to contend with.

The Electron IPC surface

Discord exposes renderer-faced IPC through Electron, including channels that read and write client settings and a channel that relaunches the application. These are the intended, validated interface between a renderer and the privileged main process.

In this chain, a renderer with native memory control does not have to use them through the intended JavaScript surface. It can locate the underlying IPC objects in memory and invoke the channels directly with arguments of its choosing.


The Vulnerability

The root cause is a type confusion in V8’s optimizing compiler, over the representation of a value returned from a WebAssembly export, confusing an externref (a tagged object reference) with an i64 (a raw 64-bit integer). The attacker builds two property getters out of WebAssembly functions that are identical except for their return type: one returns an object reference, the other returns an integer. A small JavaScript accessor (return o.x) is run hot across two object shapes that use those getters, so the optimizer commits to a single return representation. Each getter also calls back into JavaScript before returning, and that callback mutates the receiver’s prototype at the exact moment the optimizer assumes the shape is stable. No guard re-checks the return representation, so the optimizer’s assumption is incorrect.

A value produced as an object reference is then handed back as a raw integer, disclosing its pointer (addrof), or an attacker-chosen integer is handed back as an object reference (fakeobj). Those two primitives are then used to forge a fake array whose map says “double array” and whose backing-store pointer the attacker controls, which turns the confusion into an arbitrary in-cage read and write.

  • The renderer’s own compiled code enforces the iframe-sandbox popup check so a renderer that can rewrite its compiled code can change what that check decides.
  • Discord’s privileged IPC channels (settings and relaunch) trust any renderer that calls them, rather than checking whether the calling renderer is one that should be allowed to.
  • Discord has a persistent web-application endpoint in its settings and loads it on the next launch, so whoever can write that setting controls what the main client loads after a relaunch.

Exploitation Chain

  1. Get a memory primitive.

The Activity runs its script in Discord’s sandboxed renderer, triggers the V8 type confusion, and turns it into an arbitrary read/write over the renderer’s memory. At this point the attacker owns the renderer but is still within the boundaries of a browser tab.

  1. Corrupt the popup policy.

The Activity iframe isn’t allowed to open windows. The patch runs in a Web Worker: it rebuilds the memory primitive and rewrites the handful of compiled instructions in Discord’s image that enforce the popup check. Because that compiled code is shared across the whole process, the main Activity thread — which keeps retrying window.open on a timer — starts succeeding once the worker has patched it.

  1. Open a second renderer.

With the popup check defeated (and riding a real click, since the browser a user gesture to open a window), the Activity opens a window at a real Discord origin, stops its navigation, and writes the next-stage document straight into it. Opening at a Discord origin is what makes this an auxiliary Discord window rather than an attacker one. The Discord proxy stamps a fresh CSP nonce on every response, so the moved scripts carry a nonce copied from the Activity’s own current response which means the browser will run them.

  1. Abuse Discord’s IPC directly.

The second renderer rebuilds the memory primitive, finds Discord’s Electron IPC machinery in memory, and calls the privileged channels itself. There are three calls issued: two settings writes (a temporary web-application endpoint pointing back at the attacker, and the path /native-proof), then a relaunch request after a brief pause so the asynchronous writes land.

  1. Persist and relaunch.

Because Discord loads that stored endpoint on startup, the relaunch hands the main Discord renderer to the attacker’s page instead of the real Discord web app.

  1. Run native code.

In that relaunched renderer the attacker runs the memory primitive one more time, this time to look up CreateProcessW in Discord’s own imported Windows functions and call it, launching C:\Windows\System32\calc.exe.

Steps 1–3 are about escaping the iframe; step 4 is about escaping the renderer’s limited API surface; steps 5–6 are about turning that into a native process on the host.


Impact

The end result is arbitrary native code execution on the victim’s machine, at the privilege level of the Discord desktop client. In the proof this is Calculator, but the same CreateProcessW call could launch anything.

However this starts from an attacker-controlled Activity, and the demonstrated flow assumes the attacker can get their Activity in front of the victim and have them interact with it (the popup step needs a user gesture, such as a click). What it does not require is any elevated permission, developer mode on the victim’s side, or the victim approving anything beyond normal Activity use. Every privileged action happens from inside a renderer that Discord treats as untrusted.


Remediation

There is currently no confirmed vendor patch. The defensive directions that would close this chain, from the most fundamental:

  • Fix the engine bug. Ship a V8 build with the type-confusion correction; that removes the memory primitive the whole chain depends on.
  • Authorize IPC by renderer origin. The settings and relaunch channels should verify which renderer is calling and reject calls from Activity and auxiliary renderers, rather than trusting any caller.
  • Treat the stored endpoint as privileged. The persisted web-application endpoint should not be something a renderer can choose; a renderer-supplied origin should be rejected.
  • Keep sandbox decisions out of renderer-writable code. Popup and auxiliary-window sandbox flags should be derived from immutable browser-process policy, so patching renderer code can’t change them.

There’s an assumption that the renderer may be hostile, so any privileged capability it can reach (IPC channels, persisted configuration, or sandbox flags) has to be validated on the trusted side of the boundary, not behind a JavaScript API.