What Telegram shipped on November 9, 2026
When Telegram released Bot API 10.4 on November 9, 2026, followed a day later by Pavel Durov’s announcement that any Telegram message can now become an app, the pitch was extreme compression and frictionless execution. Unlike the older Telegram Mini Apps, which open an external WebView and load a remote page, the new interactive HTML widgets live directly inside chat bubbles. A bot sends a raw UTF-8 HTML document of up to 32,768 bytes, Telegram’s servers zlib-compress it at level 9 down to a hard cap of 4,096 bytes, and when the message scrolls into view, the client renders the layout natively, hooks into chat theme variables (--tg-theme-*), and runs embedded JavaScript to handle state updates (TelegramWidget.chatData and TelegramWidget.publicData) alongside TON Connect wallet transactions.
Within seventy-two hours, the feature became a live demonstration of why application engines don’t run untrusted JavaScript inside their main process. By November 12, SlowMist founder Yu Xian (Cosine) had warned publicly about QuickJS sandbox escape risks, and two separate research repositories, davtur19/tg-widget-rce and telejam, had published full, deterministic remote code execution chains. One of them ends with a bot-delivered message widget launching calc.exe out of a signed Telegram Desktop 7.4.2 x64 process the moment the message lays out, with no click required.
To see how a four-kilobyte chat bubble escalates to arbitrary native code execution, it helps to look past the Bot API docs and into the commit history of TelegramMessenger/widget-sandbox (codenamed twidget), TelegramMessenger/quickjs, and the architecture plans committed to TelegramMessenger/Telegram-iOS in mid-October 2026. Those repositories show a basic conflation between restricting what an API is allowed to call and actually isolating memory. Telegram built a policy filter to block scripts from making unauthorized network requests and called it a sandbox. Then it tore down the intermediate validation layer and bound a C++ DOM, an HTML parser, a CSS layout engine, and a compact C JavaScript interpreter directly into one thread inside the host application.
From a validated pipeline to native in-process execution
Widget-sandbox originally followed a fairly standard defense-in-depth design. Untrusted widget code ran inside QuickJS against a JavaScript DOM shim, and instead of mutating native rendering structures directly, the QuickJS context emitted serialized RenderOp messages across a boundary. The host treated everything coming out of QuickJS as hostile and ran every batch through a schema and allowlist validator (validator.ts, allowlist.ts, css-sanitizer.ts, url-policy.ts) before handing validated operations to a downstream renderer, whether that was an isolated WKWebView, an experimental Rust blitz-dom / vello_cpu prototype, or a shadow tree. Network APIs like fetch, XMLHttpRequest, WebSocket, and Worker were stubbed out to throw NetworkDisabledError, and external URLs were stripped so only data: images and #fragment identifiers could reach the renderer.
That separation came apart between October 13 and 18, 2026, during the sprint to wire message widgets (protocol layer 233, messageMediaWidget) into the native clients. According to the engineering design records in the Telegram-iOS repository (docs/superpowers/plans/2026-10-13-message-widgets.md and the native runtime amendments that followed), the team replaced the serialized shim-and-validator pipeline with an in-process native runtime. The design was approved on October 16 with “no validator,” amended on October 17 so the runtime would own litehtml’s container directly and output a layer tree, and finalized on October 18 when the client switched to the new runtime with no feature flag, deleting the old widget-sandbox JavaScript shim and its trusted validator from the app.
In Telegram’s own words from the design record, the production native runtime runs on a worker thread with no JavaScript sandbox, no JavaScript validator, and no HTML in between: a C++ DOM built into their QuickJS fork, parsed by their gumbo fork, rendered by feeding litehtml the DOM directly. Later versions reintroduced widget profile enforcement (profile-v1), but it operates on the renderer’s copy of the document rather than isolating the JavaScript engine itself. When a message carrying a messageMediaWidget payload arrives, the client decompresses it, parses the markup with Google’s gumbo HTML5 parser (a C library Google archived in 2016, forked by Telegram into C++20), builds C++ DOM nodes bound directly as native JSClass objects inside QuickJS, computes styles and layout through litehtml, and runs the widget’s <script> tags as soon as the message lays out.
No click, tap, or permission prompt is involved. Because this whole pipeline runs on a thread (JSThread) inside the primary Telegram process, linked directly against the C++ standard library, pthreads, and OS primitives like _beginthreadex and bcrypt on Windows, there’s no OS-level process boundary, no seccomp-bpf filter, no AppContainer or Job object jail, and no IPC serialization barrier between the JavaScript interpreter and the messenger’s memory. Corrupt memory inside QuickJS and you’re already inside the address space holding the user’s active MTProto session keys, their local chat database, and every OS API Telegram has imported.
Why QuickJS was never built to run hostile code
Choosing Fabrice Bellard and Charlie Gordon’s QuickJS, and its community-maintained successor quickjs-ng, for an embedded widget engine makes sense if binary size and cold-boot latency are all that matter. Telegram’s own benchmarks in packages/desktop/README.md note that booting the sandbox bundle from precompiled .qbc bytecode takes about 20 milliseconds in a release build of quickjs-ng. Unlike V8, JavaScriptCore, or SpiderMonkey, QuickJS has no multi-tiered JIT, no gigabyte-scale virtual address space reservation, and very little memory overhead.
But browser vendors haven’t spent hundreds of engineering-years on systems like the V8 Sandbox, which confines heap pointers to a 32-bit offset table inside a dedicated virtual memory cage, MiraclePtr, and site-isolated renderer processes because JIT compilers happen to be complicated. They built all of that because implementing modern ECMAScript in C or C++ without memory corruption bugs is close to impossible. QuickJS is a compact C interpreter relying on manual reference counting (JS_DupValue and JS_FreeValue), a cycle-collecting mark-and-sweep garbage collector on top of that, manual stack frame management, and raw pointer arithmetic across union-tagged JSValue structures.
Through 2026, before Telegram’s widget launch, both upstream bellard/quickjs and quickjs-ng picked up a steady stream of severe memory-corruption bugs that show how easily untrusted JavaScript can break the engine’s invariants:
📬 Stay Ahead of Cyber Threats
Get the latest cybersecurity news, critical vulnerabilities, threat intelligence, tutorials, and exclusive giveaways delivered straight to your inbox. No spam. Unsubscribe anytime.
- CVE-2026-88378 (CVSS 9.8, published September 24, 2026): a heap out-of-bounds write in
JS_ReadFunctionTag(), affecting upstream commit04be24600(issue #549, patched in PR #553). Insufficient bounds checking on stream inputs when deserializing bytecode function tags let attackers write past the allocated heap buffer, which is a direct primitive for code execution inside the host process. - CVE-2026-1144 (published early 2026): a remotely exploitable use-after-free in quickjs-ng up to v0.11.0 (issues #1301/#1302, patched in commit
ea3e9d77454e8fc9cb3ef3c504e9c16af5a80141), inside the Atomics operations handler inquickjs.c. A malicious callback could free or reallocate the underlying backing store mid-operation during a shared memory atomic operation, letting an attacker read and write freed heap chunks. - CVE-2026-120438 / GHSA-jp52-chwg-2p9p (CVSS 8.5, published November 2, 2026): disclosed just seven days before Bot API 10.4 shipped. The flaw sits in
js_proxy_get_own_property_names()atquickjs.c:51509in quickjs-ng before v0.16.0 (patched in commit252209d99cf112568160548bdcaa98d95e0d0068). Enumerating keys on a JavaScript Proxy lets an attacker return an oversized array-like object from theownKeystrap; on 32-bit architectures, the allocation arithmetic wraps aroundSIZE_MAX, so a tiny heap buffer gets allocated and user-controlled property descriptors get copied straight across the adjacent heap.
These were public warnings that QuickJS assumes its input is trusted code. Once Telegram embedded its own fork (TelegramMessenger/quickjs, branched from quickjs-ng) into client builds for Android, iOS, macOS, and Windows Desktop, researchers started auditing the exact C source tree shipped inside the messenger, and found a target-rich mix of unpatched upstream n-days and novel zero-days across the parser, the bytecode compiler, closure-variable lifecycle handling, and the built-in collection objects.
Exploit chain one: the ARG_SCOPE_END parser zero-day
The first major public writeup, from researcher davtur19 in the tg-widget-rce repository, catalogs eight distinct memory-safety and denial-of-service bugs in production Telegram builds, including Telegram Desktop 7.4.0, Telegram iOS 13.2, Telegram macOS 13.1, and Telegram Android builds 72889, 72929, 73029, and 73039 (version 13.2.0). At the center is BUG-1, a critical zero-day in QuickJS-ng’s parser and bytecode compiler that davtur19 named ARG_SCOPE_END, later patched in TelegramMessenger/quickjs commit ca3594d.
How the compiler’s scope tracking breaks
To understand ARG_SCOPE_END you need to know how QuickJS compiles function arguments and lexical scopes in quickjs.c. When it parses a function definition (JSFunctionDef *s), it tracks local variables and lexical scopes in an array of JSVarDef structures (s->vardefs) and a scope array (s->scopes). Because ECMAScript lets default parameter expressions declare closures or call eval() directly, the compiler creates a dedicated argument scope before parsing those initializers. Inside the scope chain, QuickJS uses sentinel markers (ARG_SCOPE_INDEX = 1, ARG_SCOPE_END = -2) to mark where the argument scope starts and ends relative to the function body’s local variable scope.
Normally, when a function has parameter expressions that might touch the arguments object or run eval(), the parser calls add_arguments_arg(ctx, s), or adjusts the scope links when it transitions from the parameter list into the function body, replacing the ARG_SCOPE_END (-2) sentinel with a real parent scope index or variable index. davtur19 found an edge case where three specific ECMAScript features combine to skip that step:
- The function runs in strict mode (
"use strict"). - The parameter list has an empty destructuring pattern, such as
([])or({}). - A parameter default initializer makes a direct
eval()call, as infunction f([] = eval("...")) { "use strict"; ... }.
Because the function is strict, the parser skips add_arguments_arg(), which in sloppy mode would materialize the special arguments binding and clean up the scope boundary. At the same time, the empty destructuring parameter combined with direct eval() triggers variable-resolution emission for the parameter scope, but leaves the raw ARG_SCOPE_END (-2) marker sitting in the scope chain (first = -2).
When the compiler emits bytecode to close or reference the scope’s variables, it computes the target variable index as first + 1. Since first is still -2, that’s -2 + 1 = -1. The bytecode emitter serializes this as an unsigned 16-bit integer with put_u16, which turns -1 into 0xFFFF (65535).
From 0xFFFF to a 1.31 MB out-of-bounds read and write
When the interpreter runs this compiled function (JSFunctionBytecode *b), the opcode handler reads the 16-bit operand (0xFFFF), subtracts 1 or applies the scope offset mapping, and uses 65534 (0xFFFE) as a direct array index into b->vardefs and the function’s closure variable tables. Nothing in the compiler or the pre-ca3594d interpreter checked that the resolved index was less than b->var_count + b->arg_count.
Each JSVarDef entry in QuickJS is 20 bytes. Indexing b->vardefs[65534] makes the interpreter read metadata at an offset of 65534 × 20 = 1,310,680 bytes, roughly 1.31 MB past the base of the b->vardefs heap allocation. Worse, when the interpreter instantiates closure references (JSVarRef) or writes variable definitions off that corrupted index, it writes 8-byte closure records out of bounds, past a live heap allocation.
By grooming the heap first, specifically by spraying ArrayBuffer headers and JSVarDef-sized backing buffers at predictable offsets 1.31 MB ahead of b->vardefs, an attacker can control the fake JSVarDef metadata the interpreter reads and steer the resulting 8-byte out-of-bounds write into the byteLength field of a live JSArrayBuffer. Once that field holds a massive value, the JavaScript code has a contiguous, bounds-check-free read/write window into the rest of the process heap. From there, overwriting a TypedArray’s data pointer gives arbitrary read/write across the entire 64-bit address space of the Telegram client.
Exploit chain two: the await using use-after-return bug
While tg-widget-rce attacked the compiler’s scope emission, the telejam research repository showed an equally precise zero-click RCE on November 12, 2026, against the official signed Telegram Desktop 7.4.2 x64 binary on Windows. That build shipped widget-sandbox v0.6.3, bundling a TelegramMessenger/quickjs revision that lacked commit ca3594d and related lifecycle fixes (those landed later, in widget-sandbox v0.6.4).
The telejam chain achieves full, crash-free native code execution, spawning calc.exe as soon as a bot message renders, with the Telegram UI staying completely responsive throughout, by exploiting a flaw in how QuickJS implements the ECMAScript Explicit Resource Management spec (using and await using declarations).
A use-after-return on the C stack
In QuickJS, synchronous JavaScript functions run inside the main C interpreter loop, JS_CallInternal(). To avoid heap allocations on every call, local variables (var_buf) and arguments (arg_buf) for a synchronous stack frame (JSStackFrame) live directly on the native C thread stack, via alloca or stack-allocated buffers.
If a nested closure captures a local variable from an outer synchronous function, QuickJS creates a heap-allocated JSVarRef. While the outer function is still running on the C stack, JSVarRef->pvalue points directly at &var_buf[idx], an address on the C stack, and the JSVarRef is linked into the stack frame’s sf->var_ref_list. When the outer function returns normally, JS_CallInternal() runs its cleanup epilogue and calls close_var_refs(rt, sf), which walks sf->var_ref_list, copies the current JSValue from the C stack into JSVarRef->value on the heap, repoints JSVarRef->pvalue to &JSVarRef->value, and detaches the reference from the dying C stack frame.
Async functions and generators work differently, because they can suspend at OP_yield or OP_await, so their stack frames live on the heap inside a JSAsyncFunctionState or JSGeneratorData structure. When JS_CallInternal() hits OP_await, it jumps to the done_generator: label at the end of the function and returns to the caller without calling close_var_refs(rt, sf), because it assumes the frame lives on the heap and will resume later.
The root cause here was a missing parser check: QuickJS allowed for (await using x = ... of ...) to be compiled inside a non-async class static initialization block (static { ... }). A static block compiles and runs as a synchronous function on the C stack through JS_CallInternal(). But when the await using scope exited and triggered asynchronous resource disposal ([Symbol.asyncDispose]()), the compiler emitted an OP_await instruction inside that synchronous static block anyway.
When the interpreter hit that OP_await during disposal, JS_CallInternal() took the done_generator exit path instead of the normal function epilogue, skipping close_var_refs(rt, sf) and returning from the C function. Any closure created inside the static block that had captured a local variable now held a live, heap-allocated JSVarRef whose pvalue pointer still pointed into the returned C stack frame.
Turning stack reuse into forged objects
A dangling JSVarRef->pvalue pointing into the C stack is a powerful primitive, because every later read or write to that captured variable in JavaScript dereferences *pvalue, a 16-byte JSValue struct on 64-bit platforms made of an 8-byte JSValueUnion u payload or pointer and an 8-byte int64_t tag.
Because QuickJS runs deterministically on a single worker thread (JSThread), calling another deeply nested synchronous function immediately reuses the same C stack addresses the exited JS_CallInternal() frame just vacated. By arranging local variables in a following synchronous call, the telejam exploit aligned controlled 64-bit values on the C stack directly underneath the dangling JSVarRef->pvalue.
Using that overlap, the exploit forged a JSVarRef backed by a JSString allocation (JS_TAG_STRING = -7). Corrupting the len field of the JSString header expanded the string’s apparent length well past its real allocation. Reading character codes out of that oversized string leaked heap memory, defeating ASLR by revealing adjacent JSObject and JSVarRef pointers, and combining the leak with the stack overlap let the attacker construct arbitrary JSValue descriptors at known heap addresses, an addrof / fakeobj pair.
Arbitrary memory access and bypassing ASLR
With the ability to forge JSObject structures, the exploit built a fake Uint8Array (JS_CLASS_UINT8_ARRAY) whose u.array.u.uint8_ptr data pointer and u.array.count length were controlled by another writable TypedArray. That turned the stack use-after-return into a clean, repeatable, byte-precise 64-bit read/write primitive.
To defeat ASLR on Windows, the exploit read the internal C pointers of a native QuickJS function object, which holds a function pointer into the compiled Telegram.exe image. Walking backward from that leaked code pointer, computing the known RVA offset, and checking the PE header image markers revealed the exact base address of the signed Telegram.exe 7.4.2 x64 module in memory, and confirmed it matched the right build.
Calling WinExec through a forged closure
Windows enforces DEP and Control Flow Guard, so jumping to shellcode on the heap isn’t necessary when the host binary’s Import Address Table already resolves standard Win32 process creation APIs. By parsing the IAT from the leaked image base, the exploit found the runtime address of kernel32!WinExec.
To call WinExec("calc.exe", SW_SHOW) cleanly from QuickJS without corrupting the C stack, telejam abused QuickJS’s native closure mechanism, JS_CLASS_C_CLOSURE. Objects of that class store a JSCClosureRecord struct holding a C function pointer (c_function), a finalizer callback (void (*finalizer)(void *opaque)), and an arbitrary user pointer (void *opaque). When a JS_CLASS_C_CLOSURE object’s reference count hits zero during JS_FreeValueRT(), QuickJS runs:
c
if (s->finalizer) { s->finalizer(s->opaque);}
On x64 Windows, the first argument (s->opaque) goes into register RCX under the Microsoft calling convention. WinExec(LPCSTR lpCmdLine, UINT uCmdShow) takes its command-line pointer in RCX and a show-state value in RDX, where any non-zero residual register value is enough. Pointing s->finalizer at the resolved WinExec import and s->opaque at a heap buffer containing "calc.exe\0", then triggering garbage collection or dropping the last reference to the forged closure, made QuickJS call WinExec("calc.exe", ...) directly. The exploit even repaired the corrupted heap metadata afterward so the legitimate finalizer freed the genuine donor allocation cleanly, leaving Telegram Desktop running without a frame drop.
The wider bug cluster: iterator UAFs, truncations, and a stale regex pointer
What makes this architecture hard to defend isn’t one bug. A focused audit turned up half a dozen distinct memory-corruption primitives in the same codebase within days.
The Map.prototype.clear() iterator use-after-free (BUG-4, commit 3ad65ad). This is the heap UAF behind the one-liner that circulated with the advisory (CVSS 6.5, live in Telegram Android builds 73029 and 73039):
javascript
let m = new Map([[0, 0], [1, 1]]), i = m.entries();i.next();i.next();m.delete(1);m.set(0, i);i = 0;m.clear();
QuickJS implements Map and Set (JSMapState and JSMapRecord) with a doubly linked list of records to preserve insertion order while allowing safe deletion during for..of iteration. When a MapIterator is active and pointing at an element, or when delete() runs while an iterator is alive (s->iter_count > 0), QuickJS can’t immediately free the deleted JSMapRecord, since the live iterator might still reference it. Instead it marks the record a tombstone (mr->empty = TRUE), frees its key and value, and waits for the iterator to move past it or close.
Walking through the PoC: m starts with two entries, and i = m.entries() pins an iterator. Two calls to i.next() advance it to the second entry without exhausting it. m.delete(1) deletes the entry i is currently pointing at, so that entry becomes a tombstone rather than being freed outright. m.set(0, i) then stores the iterator object itself as the value of key 0 in m, and i = 0 drops the local reference, so the only remaining strong reference to the MapIterator is the one sitting inside m.
When m.clear() runs (js_map_clear), it iterates the map’s entries to free their keys and values. Freeing the value of key 0 drops the MapIterator’s reference count to zero, which synchronously fires the iterator’s finalizer (js_map_iterator_finalizer) in the middle of the clear() loop. That finalizer detaches itself from m and frees the pinned tombstone record (entry 1), mutating the same linked list the outer js_map_clear loop is actively walking. The outer loop then dereferences a pointer that was just freed: a heap use-after-free.
FinalizationRegistry.prototype.unregister() UAF (BUG-5, commit 7ca4ac5) and reset_weak_ref (BUG-6, commit 45c5b9b). The same reentrancy hazard showed up in unregister(unregisterToken) and in weak reference teardown. While unregister() scanned the registry’s list of records comparing unregisterToken, freeing the matched record’s held value could invoke a custom finalizer or release a cycle that recursively mutated or freed the registry’s list mid-iteration, the same shape as the earlier upstream quickjs-ng issue #1318 (fixed in v0.13.0, commit 36df68f), where cyclic strong references between a target, a FinalizationRegistry, and a WeakRef caused JS_FreeValueRT to read freed heap memory during garbage collection.
16-bit integer truncations in closure and variable counts (BUG-2, BUG-3, NDAY-2). QuickJS packs variable counts and closure indices into 16-bit integers inside JSFunctionDef and JSFunctionBytecode, capped by JS_MAX_LOCAL_VARS (typically 65534), but more than one code path skipped the overflow check. In BUG-2 (CVSS 8.1, commit 3e236cb), a function that called direct eval() while capturing more than 65,535 closure variables across nested scopes wrapped the 16-bit closure count during bytecode emission; the runtime allocated a closure pointer array based on the truncated count while the bytecode still referenced the untruncated one, producing a clean heap buffer overflow of JSVarRef * pointers. BUG-3 (commit 8f0d3b6) and NDAY-2 (upstream issue #1865, CVSS 8.8) hit the same var_ref_count truncation, overflowing the counter that tracks captured closure variables and undersizing the b->var_refs heap allocation, leading to out-of-bounds writes during closure instantiation on Telegram Android 13.2.0.4.
A stale RegExp bytecode pointer (NDAY-1, upstream issue #1827, CVSS 8.8). Recompilation or side effects during regular expression execution could leave the regex engine running freed bytecode on the heap, and tg-widget-rce showed this was live and exploitable for RCE across Telegram Android 13.2.0, iOS 13.2, and Desktop 7.4.0.
Renderer denial of service (LH-DOS). Because the native widget pipeline feeds CSS and inline SVG directly into litehtml, crafted SVG and CSS can exhaust resources and crash the C++ layout engine on its own.
Below is a list of bugs in Telegram
The telejam zero-day, rooted in for (await using ...) inside a non-async class static block exiting through done_generator without calling close_var_refs(), was fixed in widget-sandbox v0.6.4 and enabled a zero-click RCE via C stack use-after-return leading to WinExec, confirmed on Telegram Desktop 7.4.2 x64 running widget-sandbox 0.6.3.
BUG-1, the ARG_SCOPE_END flaw, came from a strict-mode function with an empty destructuring parameter and a direct eval call skipping add_arguments_arg() and emitting -1 (0xFFFF) as a variable index. It was fixed in commit ca3594d and rated CVSS 9.6 for a zero-click RCE via a 1.31 MB out-of-bounds read/write, confirmed across Android builds 72889-73039, iOS 13.2, Telegram Desktop 7.4.0, and macOS 13.1.
BUG-2 was a 16-bit truncation of the closure-variable count triggered by direct eval, fixed in commit 3e236cb, rated CVSS 8.1 for a heap out-of-bounds write, confirmed on Telegram Android 73029 and 73039.
BUG-3 and NDAY-2 both trace to upstream issue #1865: a 16-bit truncation of var_ref_count and captured-variable counts, fixed in commit 8f0d3b6, rated CVSS 8.8 and 5.8 for zero-click RCE, confirmed on Telegram Android 13.2.0 (build 73029).
BUG-4 was the Map.prototype.clear() reentrant iterator finalizer freeing an active tombstone record, fixed in commit 3ad65ad, rated CVSS 6.5 for a heap use-after-free, confirmed on Telegram Android 73029 and 73039.
BUG-5 was the same reentrancy pattern in FinalizationRegistry.prototype.unregister(), fixed in commit 7ca4ac5, also CVSS 6.5 for a heap use-after-free, confirmed on Telegram Android 73029 and 73039.
NDAY-1 traces to upstream issue #1827: stale RegExp bytecode executing off freed heap memory, rated CVSS 8.8 for zero-click RCE, confirmed on Telegram Android 13.2.0, iOS 13.2, and Telegram Desktop 7.4.0.
Telegram has done this before: rlottie and the pattern of unsandboxed parsers
This isn’t the first time Telegram has exposed its core process to zero-click RCE by wiring a complex C/C++ parser straight into message rendering. The QuickJS widget architecture repeats the same structural mistake as Telegram’s animated sticker engine, rlottie.
To render .tgs animated stickers (gzip-compressed Lottie JSON) at 60 frames per second, Telegram embedded an unmaintained fork of Samsung’s rlottie C++14 library directly into its client process. In 2021, researchers at Shielder disclosed multiple heap buffer overflows, stack overflows, and type confusions in rlottie’s parsing pipeline. More recently, on March 30, 2026, researcher Dennis Kim (@gameworkerkim) published the analysis for ZDI-CAN-30207 (CVSS 9.8 Critical), a full zero-click RCE in Telegram’s automatic preview generation for incoming animated stickers and media files, one that bypassed auto-download settings entirely.
An unsandboxed static-format parser like rlottie or libwebp is bad enough, but embedding a Turing-complete JavaScript engine in the same process is worse by an order of magnitude. Exploiting a static parser means fighting blind against ASLR and a variable heap state: you have to craft one byte stream that shapes the heap, triggers corruption, and diverts control flow, all without being able to look at memory mid-flight. A JavaScript engine hands an attacker an interactive programming environment inside the target process. Both telejam and tg-widget-rce show what that buys: precise heap grooming with sized allocations and frees (ArrayBuffer, Uint8Array, String), a logic bug to corrupt a specific length field or pointer, and then the ability to read leaked memory back into JavaScript variables, inspect pointers with bitwise arithmetic, locate exact library and binary base addresses to beat ASLR reliably, confirm the exact build in memory, hijack control flow, and clean up the corrupted heap chunk afterward so the process never crashes.
Telegram’s own test suite for widget-sandbox (corepack pnpm test:attacks, checking 99 attack files for zero canary hits) tested whether untrusted HTML, CSS, or JS could smuggle an external URL past the validator or trigger a network request. It tested the firewall rules. It assumed the C interpreter running the untrusted code couldn’t be broken.
What real isolation would take, and what to do right now
Telegram has moved to cherry-pick point fixes, commits ca3594d, 3e236cb, 8f0d3b6, 3ad65ad, 7ca4ac5, and 45c5b9b in TelegramMessenger/quickjs, and shipped widget-sandbox v0.6.4. For now, Telegram’s own documentation says production widgets are restricted to private chats with bots, while broader group, inline, and forwarded message widgets run on the test server during the staged rollout.
Patching individual C memory-safety bugs while keeping QuickJS, gumbo, and litehtml inside the main messenger process is still a losing game. Every new commit to quickjs-ng’s issue tracker is a potential zero-click weapon against hundreds of millions of endpoints the moment group and forwarded widgets go live in production. If Telegram wants to run untrusted JavaScript inside message bubbles without relying on the platform’s native out-of-process WKWebView or WebView, there are really only two sound paths:
- Software fault isolation. Compile the whole QuickJS, gumbo, and litehtml stack to WebAssembly (wasm32) and run it inside a bounds-checked runtime such as Wasmtime, WAMR, or a sandboxed wasm2c module, with linear memory isolated in a dedicated guard-paged arena. An out-of-bounds write like ARG_SCOPE_END or a
Map.clear()UAF inside a Wasm-compiled QuickJS instance can only corrupt that instance’s own guest memory. There are no native code pointers, return addresses, or host IAT pointers in Wasm linear memory, so a QuickJS RCE collapses into harmless widget-level state corruption. - OS process isolation. On desktop and on Android (
android:isolatedProcess="true"), moveJSThreadout of the main Telegram binary into a dedicated, unprivileged utility process stripped of filesystem, network, and token privileges, via Windows AppContainer or restricted tokens, macOS Seatbelt, or Linux seccomp-bpf with user namespaces, talking to the main UI process only through serialized, schema-validatedRenderOpmessages.
Until one of those actually ships, what you should do depends on which build you’re running:
- If you’re on an older client, built before October 18 or November 9, 2026 (pre-Bot API 10.4 widget support): stay on it, or use Telegram Web inside a modern, multi-process Chromium or Firefox browser, where site isolation and the browser’s own JS engine sandbox apply. Older native clients just show a fallback caption for
messageMediaWidget(layer 233) payloads and never invoke QuickJS. - If you’ve already updated to a widget-enabled build (Telegram Desktop 7.4.0-7.4.2, Android builds 72889-73039, iOS 13.2, or macOS 13.1): staying on those specific unpatched point releases leaves you exposed to ARG_SCOPE_END (BUG-1), telejam’s
await usingUAR, and the #1827 RegExp n-day. Update immediately to the latest post-November 12 hotfix build, which includes widget-sandbox v0.6.4 and QuickJS commitca3594d, or downgrade to a pre-widget release, disable automatic updates, and restrict incoming messages from unknown bots and unverified accounts until you do.









