❌

Normal view

Received — 28 October 2026 ⏭ Imperva Cyber Security Blog

Node2Shell: From Brotli Data to Remote Code Execution

22 October 2026 at 14:22

Imperva Red Team demonstrated remote code execution against the official vulnerable Node.js build, then confirmed that the fixed release rejects the same Brotli input.

Executive summary

Node2Shell is Imperva Red Team’s name for our analysis of CVE-2026-111497, a High severity flaw in Node.js’s bundled Brotli decoder. Node.js says the out-of-bounds write can permit remote code execution and has released fixes for the 22.x, 24.x, and 26.x lines.

Our standalone proof of concept ran code carried in a Brotli response or request body inside applications using the official Node.js 24.22.0 Linux arm64 image. It needed no debugger or instrumented binary. The same inputs were rejected by 24.22.1. We tested Express 5.0.0–5.3.0 and body-parser 2.0.0–2.3.0, including the latest releases available at testing; execution took multiple attempts in our setup.

Imperva has added WAF rules for CVE-2026-111497. Customers should update affected Node.js deployments to a fixed release.

Overview

A Node.js service can encounter Brotli on either side of an HTTP exchange. It may consume a compressed response from another server, or use middleware to decode an incoming request body. Both operations can send untrusted data into the native decoder bundled with Node.js. That shared path is where CVE-2026-111497 lives.

We built a standalone proof of concept to see whether the flaw could lead to code execution in a Node.js application. In fresh containers running the official vulnerable Node.js image, the application ran code supplied with the Brotli data. We used a memory sanitizer separately to investigate the underlying bug and compared the results with the fixed release.

The Node.js project disclosed the issue on October 21, 2026. Its advisory credits Matteo Collina with reporting it and Antoine du Hamel with the fix. Our work is an independent validation of the impact and the released patch.

Impact: From a decoder error to code execution

In the working proof of concept, the test application had no code to create our marker file. After it received the Brotli data, the file appeared. Code carried in that data had run inside the target process. We reproduced this with the official Node.js 24.22.0 image in fresh Linux arm64 containers. The same inputs on Node.js 24.22.1 were rejected and created no marker.

We also investigated the cause. A source comparison showed that the vulnerable decoder could continue past an invalid stream condition. In a separate sanitizer test, decoding that condition caused an out-of-bounds write. The sanitizer helped explain the flaw; it was not needed for the code-execution proof.

We tested both a compressed response consumed by Node.js’s built-in fetch() and incoming Brotli bodies handled by configured request parsers. The table shows the Brotli-capable package ranges represented by our pinned endpoint tests:

Application path Package range and tested configurations Node.js 24.22.0 Node.js 24.22.1
Consumed HTTP response Node.js built-in fetch() Supplied code ran; marker created Input rejected; no marker
Incoming request body Express 5.0.0–5.3.0; tested 5.0.0 with body-parser 2.0.1, and 5.3.0 with body-parser 2.3.0 Supplied code ran with raw() and json() in both tested setups Input rejected; no marker; server stayed running
Incoming request body body-parser 2.0.0–2.3.0; tested 2.0.0 standalone and 2.0.1/2.3.0 through Express Supplied code ran with raw() and json() in the tested setups Input rejected; no marker; server stayed running

Express 5 documents Brotli decoding in its request-body parsers, and the body-parser release history records support beginning with 2.0.0. Express 5.3.0 and body-parser 2.3.0 were the latest releases when we tested on October 22, 2026. We reproduced execution at the listed versions; we did not individually test every intermediate release. The vulnerable code is in Node.js: these middleware versions identify paths that can invoke it. A bare Node.js HTTP server does not decode incoming Brotli bodies simply by receiving them.

The proof did not succeed on every attempt. Our results establish code execution for the tested Linux arm64 build and application setups, and show how the fixed build handled the same inputs. We did not test every Node.js build or application.

Technical deep dive: When invalid input looks incomplete

A Brotli stream describes the block types available as decoding progresses. Our test input reached a block transition that the stream’s own structure made impossible. In the vulnerable decoder, that condition could look like a request for more input. Processing continued with an invalid internal state.

Our separate sanitizer run showed that continuing from that state could write beyond an intended buffer. The upstream Brotli fix makes the decoder return a format error for the invalid transition and pass it to the caller. In our application tests, patched Node.js rejected the same inputs before code could run.

node2shell vulnerable recorder

Two tested routes into the native decoder. The patched runtime stops at the invalid-format check.

The distinction matters for exposure. Express 5 and body-parser 2 can decompress incoming Brotli request bodies when their parsers are configured; they are routes into the vulnerable Node.js decoder, not the source of the flaw. On the response side, our test service consumed an external Brotli response with Node.js’s built-in fetch(). Both paths reach the same decoder.

What defenders should do

The priority is to replace affected Node.js runtimes. The project released fixes in 22.23.4, 24.22.1, and 26.12.1. Deploy these or newer fixed releases in each line, including in container images and other artifacts that bundle a Node binary. Move applications on unsupported Node.js lines to a supported release.

Then map where untrusted Brotli data reaches that runtime. Review compressed responses consumed by services and incoming request bodies inflated by configured middleware. Check the Node.js version inside each deployed image or host; a package inventory can miss an older runtime bundled in a container.

Where practical, temporarily limit untrusted Brotli traffic on affected paths while updates roll out. Test changes against the application’s normal traffic. Complete remediation requires updating Node.js.

Unexpected process exits during decompression and decoder errors may help prioritize investigation. Neither is a reliable stand-alone sign of exploitation; correlate them with the deployed runtime, application logs, and the source of the compressed data.

Imperva’s response and customer protection 

Imperva has added dedicated WAF rules in response to CVE-2026-111497 and validated them to detect and block the exploitation attempts we were able to reproduce. Customers should update affected Node.js deployments to a fixed release. The runtime update remediates the decoder flaw. 

Our tests covered both an incoming request parser and a consumed HTTP response. In both cases the affected code is the Node.js decoder, so the runtime update addresses the shared cause. 

Closing Thoughts 

Node2Shell and the recent PHP2Shell research highlight the risk hidden in the runtimes beneath everyday web applications. In PHP2Shell, researchers used AI to help uncover and exploit longstanding memory-safety flaws. Our Node2Shell tests demonstrated how untrusted compressed data could lead to code execution in an official Node.js build.  

AI can accelerate both vulnerability discovery and exploit development, helping researchers investigate flaws while lowering the time and effort attackers need to turn those flaws into working attacks. For defenders, that means less room to delay between disclosure and remediation. 

In our tests, the fixed Node.js release rejected the same inputs that achieved code execution on the vulnerable build. The immediate priority is to deploy that fix wherever applications decode untrusted Brotli data. The broader lesson is that patching speed must keep pace with the tools making exploitation faster. Web Application Firewalls can help bridge that gap through virtual patching, buying defenders critical time to deploy permanent fixes.

The post Node2Shell: From Brotli Data to Remote Code Execution appeared first on Blog.

Received — 13 September 2026 ⏭ Imperva Cyber Security Blog

CopyEscape: Taking Over Docker Hosts with docker cp

11 August 2026 at 05:45

Imperva Red Team uncovered CVE-2026-17106, a container-to-host arbitrary file-write vulnerability in Docker’s docker cp command. Docker later confirmed that the same CVE also affected sbx cp when copying files out of Docker Sandboxes. A malicious container or sandbox could exploit the copy process to create or overwrite files outside the destination selected by the user, potentially enabling code execution on the machine running the Docker CLI.

Through CVE-2026-17106, an attacker-controlled container could turn a routine file copy into two powerful outcomes:

Host file overwrite: creating or replacing any file writable by the user running docker cp, including shell configuration, executables, SSH configuration, source code, and persistence mechanisms.

Code execution: Executing attacker-controlled code as the logged-in user on macOS or, when docker cp runs with elevated privileges on Linux, achieving root code execution immediately after the command completes by overwriting the runc binary.

The exploit chains two weaknesses in Docker’s archive pipeline: a filesystem race that lets a running container produce an inconsistent tar archive, and an extraction flaw that allows the Docker CLI to follow a planted symlink outside the intended destination.

Together, these weaknesses turn the user’s own Docker CLI into a write primitive. The attacker controls the container and its files, the CLI supplies access to the host filesystem.

The docker cp command is routinely used to retrieve build artifacts, test results, logs, and forensic evidence from containers. This makes the vulnerability particularly relevant to CI systems, developer workstations, privileged automation, and incident-response workflows. In the last case, simply collecting evidence from a compromised container could activate the payload waiting inside it.

The impact also reaches AI-agent workflows. Docker Sandboxes uses sbx cp to move files between an isolated agent environment and the host. Docker Sandboxes 0.38.0, released August 6, identifies and fixes a “destination-escape flaw in sbx cp copy-out” under CVE-2026-17106. This means retrieving an artifact produced inside an untrusted or compromised coding-agent sandbox could expose the host to the same class of destination escape.

From a Simple Copy to a Host Write

The command that started this research could hardly look more ordinary:

docker cp container:/path/to/file.txt ./file.txt

The source is inside the container. The destination is chosen by the user. The expected security boundary seems obvious: Docker should write to ./file.txt and nowhere else.

But docker cp is not a direct filesystem-to-filesystem copy.

When copying from a container, the Docker daemon walks the container’s live filesystem and turns the selected path into a tar archive. The Docker CLI receives that archive and extracts it on the client machine.

image3

This architecture creates a powerful connection between two security domains. The container controls the archive contents, while the local CLI performs the resulting filesystem operations with the permissions of the person or process that invoked docker cp.

For this design to remain safe, Docker needs to preserve two properties:

  1. The daemon must produce a coherent archive from the container’s filesystem.
  2. The CLI must confine every extracted object to the destination selected by the user.

We found a way to break both properties in the same copy operation.

Impact

The vulnerability provides an arbitrary file create or overwrite primitive within the permissions of the Docker CLI process.

The malicious container does not automatically inherit root privileges from dockerd, and exploitation requires a user or automated system to invoke docker cp against the attacker-controlled path.

But the final write occurs on the machine running the CLI, with the authority of that user-not with the permissions of the process inside the container.

We validated the exploit on Linux with Docker Engine 29.6.1 and on macOS with Docker Desktop 4.81.0 (232925).

Developer Compromise on macOS

Docker Desktop runs the daemon inside a Linux virtual machine, but a container-to-local docker cp operation is extracted by the CLI running on macOS.

This means the archive crosses the VM boundary before the vulnerable operation occurs. The symlink is followed by a process on the Mac, and its target is interpreted in the Mac’s filesystem namespace.

A malicious container could therefore target files such as shell startup scripts, user-level executables, SSH configuration, source trees, cloud configuration, or persistence mechanisms under ~/Library/LaunchAgents. Overwriting a shell startup file could execute the attacker’s code the next time the user opens a terminal.

The container does not need to escape the Docker Desktop VM directly. It convinces the local CLI to perform the write on its behalf.

Root Code Execution on Linux

On Linux, the daemon and CLI commonly run on the same host. The write still carries the CLI user’s permissions.

For an ordinary user, the attacker can reach files writable by that account. If an administrator, CI worker, or maintenance process invokes sudo docker cp, the operation can reach root-owned system paths.

In our proof of concept, we replaced /usr/bin/runc with a shell script. A later Docker lifecycle operation executed the replaced binary and created a marker file as root.

The exploit did not obtain root from the Docker daemon. The copy already had enough authority to replace runc, and the later execution converted that file write into root code execution.

The Archive That Described Two Filesystems

The first part of the chain was in the archive producer.

Both the Moby and Docker CLI source trees we reviewed used github.com/moby/go-archive v0.2.0. For a container-to-local copy, Moby’s containerArchivePath creates a tarballer and runs it inside the container filesystem:

image13

Source: Moby containerArchivePath, Docker Engine 29.6.1, lines 76–82.

Docker locks the container object while the archive is read, but that lock protects Docker’s internal state. It does not freeze processes running inside the container.

Those processes can continue renaming files, replacing directories, and creating symlinks while the daemon walks the source tree.

The tarballer performs that walk with filepath.WalkDir:

image8

Source: moby/go-archive v0.2.0 archive walk, lines 691–802.

WalkDir reads a directory entry and records its type. If the entry is a directory, the walker plans to descend into it.

The callback then passes the pathname to addTarFile, which looks it up again with Lstat to create the tar header:

image12

Source: moby/go-archive v0.2.0 addTarFile, lines 295–317.

The same pathname now answers two different questions at two different times:

Traversal decision: is this entry a directory that WalkDir should enter?

Archive metadata: is this entry a file, directory, or symlink that should be written to the tar stream?

If the path changes between those observations, the two answers no longer need to agree.

Turning the Race into a Reliable Primitive

In our proof of concept, /watched/file.txt is intentionally deceptive. An LD_PRELOAD interposer makes it appear to be a regular file to processes inside the container by redirecting exact accesses to a hidden backing file, /watched/.file.txt.regular. The Docker daemon, however, sees the underlying file.txt path as a directory and can walk the attacker-controlled tree beneath it.

The walker initially sees the following directory:

image14

After WalkDir records escape as a directory, the attacker moves it aside and replaces it with a staged symlink:

image7

The switch requires only two rename operations, winning a filesystem race blindly would be unreliable, so we gave the container a clock.

Our proof of concept places a large file immediately before the pivot directory and monitors it with filesystem notifications. When the daemon opens that file, the attacker knows the walk has reached the right location. The large file widens the timing window, and the notification signals when to perform the two renames.

What begins as a race becomes a repeatable sequence.

After the switch, addTarFile sees a symlink and emits a symlink header for file.txt/escape. Control then returns to WalkDir, whose earlier directory entry still says that escape is a directory. The walker descends through the same pathname and adds a child beneath the symlink’s archive name.

The resulting tar stream contains two entries:

image5

Each entry is understandable on its own. Together, they describe two different moments in the source filesystem: escape is represented as a symlink, while its child is represented as though escape were still a directory.

image11

At this point, the container has produced an inconsistent archive. That alone should not be enough to write outside the destination. The client still controls extraction and can reject the dangerous sequence.

It did not.

The Extractor Checked One Path and Created Another

For a copy from a container, the Docker CLI eventually reaches archive.CopyTo:

image6

Source: moby/go-archive v0.2.0 CopyTo, lines 418–437.

Neither option confines traversal through an intermediate symlink. The vulnerable symlink handling attempted to perform its own containment check:

image1

Source: moby/go-archive v0.2.0 symlink extraction, lines 480–492.

The validation and the filesystem operation use different values.

The containment check validates targetPath, a path constructed with filepath.Join. The subsequent os.Symlink call receives the original hdr.Linkname from the archive.

For an extraction path of /safe/output/file.txt/escape and a link target of /usr/bin, the validation evaluates:

image2

That constructed path appears to remain under /safe/output, so the check approves it. Docker then gives the original absolute target to the kernel:

image4

The check approves one path. The filesystem creates another.

The same condition has an additional weakness: string prefixes do not represent path containment. A path such as /safe/output-elsewhere also begins with /safe/output. Our chain already had what it needed from the checked-value-versus-used-value mismatch.

The extractor then processes the child entry, file.txt/escape/runc.

Docker cleans the archive name, joins it to the destination, and checks for a lexical ../ escape, but the child entry contains no ../. As text, it remains beneath the destination.

The previous tar entry has changed what the pathname means on disk. Before creating the child, the vulnerable extractor does not re-establish confinement against the resolved target:

image10

Source: moby/go-archive v0.2.0 addTarFile, lines 295–317.

When the file is created, the kernel follows escape -> /usr/bin. The attacker’s bytes land at /usr/bin/runc on the Docker client rather than under /safe/output.

The full chain is:

  1. WalkDir records escape as a directory.
  2. The container replaces it with an absolute symlink.
  3. addTarFile emits the symlink into the tar stream.
  4. WalkDir descends using its earlier directory type and emits a child entry.
  5. The CLI validates a constructed target but creates the original absolute symlink.
  6. The CLI extracts the child through that symlink and writes outside the destination.

One stale directory entry and one ineffective containment check were enough to turn docker cp into a host write.

From Host Write to Host Compromise

At this point, the copy operation is no longer confined to the destination chosen by the user. The attacker-controlled container has gained a write primitive on the Docker client, with the permissions of the process running docker cp.

What happens next depends on those permissions. A copy launched by a developer could target user-writable startup files, shell configuration, source code, or persistence mechanisms. If the command runs as root, as is common in automation and some Linux environments, the same primitive could reach system executables and privileged configuration, turning the file write into root code execution.

The vulnerability simply prepares the malicious filesystem and waits. The trigger may be a developer retrieving a build artifact, a CI job collecting test results, or an incident responder copying evidence from a container they already suspect is compromised.

In each case, the operation intended to take data safely out of the container becomes the step that carries the attack back onto the host.

Mitigations

Users should upgrade to Docker Engine and CLI 29.7.2 or later, and Docker Desktop 4.86.0 or later.

Where upgrading is not immediately possible:

  • Avoid copying from running, untrusted, or compromised containers onto a sensitive client.
  • Stop the container before copying. Docker supports copying from a stopped container, preventing a live process from performing the race used in this chain.
  • Avoid sudo docker cp and root-run copy automation.
  • Run the Docker CLI with the least privilege required for the workflow.
  • Retrieve data from suspicious containers using a disposable account, VM, or similarly isolated environment.

Stopping the container blocks the producer race demonstrated here. It does not replace safe extraction: any archive originating from an untrusted source should still be treated as hostile input.

Conclusion

Container boundaries are not defined only by namespaces, virtual machines, or the Docker daemon. They also depend on the client-side tools that move data across those boundaries.

docker cp connects a live, attacker-controlled filesystem to an extractor running with the user’s authority. That design requires both sides of the pipeline to preserve a strict contract: the archive must be coherent, and every extracted object must remain inside the destination.

CVE-2026-17106 broke that contract. The daemon observed one pathname as two different objects, producing a symlink followed by its apparent child. The CLI then validated one path, created another, and allowed the child entry to cross onto the client filesystem.

The most important lesson is simple: archive extraction is a security boundary. Path normalization and string-prefix checks cannot enforce that boundary once symlinks and concurrent filesystem changes are involved. Filesystem operations must be rooted so that the kernel, not a string comparison, guarantees where the write can land.

We responsibly disclosed the vulnerability to Docker and delayed publication several times to align disclosure with a usable patch. We would like to thank Docker’s security and engineering teams for investigating the report, coordinating the CVE, and working through the regressions introduced by the initial fix.

Responsible Disclosure Timeline

April 11, 2026 – We reported the vulnerability to Docker with technical details and reproduction information.

April 14, 2026 – Docker acknowledged receipt and began reviewing the report.

April 15, 2026 – Docker confirmed the behavior as a valid finding and explained its assessment of the client-side privilege boundary.

July 10, 2026 – The initial 90-day disclosure period elapsed without a public patch. We requested a concrete remediation, advisory, and publication schedule.

July 14, 2026 – Docker described two remediation tracks: extraction-library hardening as the primary security fix and source-walk hardening as follow-up work. We agreed to delay publication while the fixes were prepared.

July 24, 2026 – Docker provided CVE-2026-17106 / GHSA-hfg8-hc9c-6c3h and targeted an August 3 coordinated release.

July 27, 2026 – Docker confirmed that the source-walk TOCTOU would not receive a separate CVE and would instead be addressed as defense-in-depth hardening.

July 30, 2026 – moby/go-archive v0.3.0 and Docker Engine/CLI 29.7.0 shipped the extraction fix.

July 31, 2026 – Docker reported significant functional regressions caused by the initial hardening and requested a final extension to August 10. Follow-up go-archive and Engine releases addressed the regressions.

August 1, 2026 – We agreed to extend coordinated disclosure to August 10.

August 6, 2026 – Docker Sandboxes 0.38.0 shipped a fix for a destination-escape flaw in sbx cp copy-out under CVE-2026-17106. Docker also confirmed that the Docker Desktop update remained scheduled for August 10.

August 10, 2026 – Docker released Docker Desktop 4.86.0, bundling Docker Engine 29.7.2 and delivering the corresponding Docker Desktop fix.

The post CopyEscape: Taking Over Docker Hosts with docker cp appeared first on Blog.

Received — 23 April 2026 ⏭ Imperva Cyber Security Blog

Hacking Safari with GPT 5.4 

23 April 2026 at 20:58

When Anthropic unveiled Mythos and Project Glasswing, the reaction was immediate and polarized. Some dismissed it as fear-driven marketing, while others treated it as a credible shift in the threat landscape.

Like with many things, the truth is probably somewhere in the middle. I wanted to test that for myself, and since I recently got access to OpenAI’s Trusted Access for Cyber program, I decided to take it for a spin.

GPT-5.4 identified the bugs and helped assemble a working exploit chain, but it wasn’t a simple “build me an exploit” prompt. Guiding it required domain knowledge, iterative probing, and knowing which paths were actually exploitable.

On modern browsers like Safari, exploitation is less about finding bugs and more about finding bugs that still matter after multiple layers of defense.

The bug I’m going to talk about today sits in a more interesting category. The bug itself looked contained, and in many ways it was. It did not provide a path to RCE or a sandbox escape. What it did instead was cross a different boundary entirely: it broke the Same-Origin Policy.

If you visited a malicious page from any Apple device, it could read authenticated cross-origin data from other sites you use, including access tokens and other sensitive data, making account takeover trivial.

The video below shows the PoC we sent Apple, demonstrating leakage of sensitive data from both Apple Connect and iCloud / Apple ID endpoints. Although this demo focuses on Apple services, the issue affects all websites. This means that by visiting a malicious website, sensitive data from other domains is at risk of being leaked.


The Sandbox Russian Doll

Browser exploitation in 2026 is a lot like being trapped in a Russian doll.

You start in the smallest doll, and every time you escape one layer you discover you are still trapped inside another one.

Finding a low-level memory bug is not the same thing as finding an exploit. Most of these bugs die in the gap between “memory corruption happened” and “something meaningful crossed a security boundary.”

On the outside you have the browser process model. Even if renderer code goes wrong, the browser is trying very hard to keep that damage inside the web content process.

infographic

Inside that you have the web security model: Same-Origin Policy, CORS, opaque responses, cookie scoping, and credential modes. Even if a page can trigger a cross-origin request, the renderer, and especially the Gigacage, should not be able to access the response bytes. Right?…

The Bug

The original bug lives in the refresh logic for non-shared resizable WebAssembly memory.

When a non-shared WebAssembly.Memory grows in BoundsChecking mode, JavaScriptCore can replace the underlying memory handle. That part is not the bug. The bug is what happens after that to the JS-visible resizable buffer returned by memory.toResizableBuffer().

diagram

The bug is simple enough that once I saw it, it was hard to unsee it. Safari’s grow path effectively does this:

code1

And the refresh step effectively does this:

code2

After memory.grow(), WebKit updates the buffer metadata, but leaves m_data pointing at the old freed allocation.

So after a grow, JavaScript can hold a buffer whose reported size is new, whose handle is new, but whose actual data pointer still references the old freed Primitive Gigacage allocation.

That turns into a stale typed-array window over freed memory.

On its own, this is already a real bug. But we’re still stuck inside the JavaScriptCore gigacage, effectively sandboxed. Without a second bug to break out into the renderer, it doesn’t chain into anything meaningful. What we have is a solid first-stage primitive, but no real security impact on its own.

Why it did not look exploitable at first

The stale window is confined to the Primitive Gigacage, which immediately limits what you can do with it. Many typical targets either never land there, lack useful structure, or fail to produce any cross-boundary effect.

So early on, it had all the hallmarks of a bug that looks promising but rarely goes the distance:

  • easy source-level root cause
  • visible stale memory behavior
  • real reclaim
  • no clean escape path

This is where a lot of low-level browser bugs die.

What changed the problem was a very different framing: maybe I did not need to escape the cage at all.

Maybe I just needed the browser to place something valuable inside it.

The Pivot

Instead of asking “how do I get from my stale WASM view to some protected browser state?” I started asking a better question:

“What browser code takes data that JavaScript is not allowed to read, but still copies that data into normal renderer memory?”

Because that is all I need.

I don’t need to break the abstraction.

I just need the browser to break it for me.

That naturally narrows the search space to subsystems that:

  • handle sensitive cross-origin data, and
  • still allocate ArrayBuffer-backed memory as part of their internal pipeline

That points straight at Fetch. The Fetch API clearly indicates that the response is opaque, meaning that its headers and body are not available to JavaScript.

Opaque Responses Are Supposed to Be Opaque

At the API level, the Fetch model here is straightforward.

If I do a cross-origin request with:

fetch(url, { mode: “no-cors”, credentials: “include” });

The browser may send the request, including cookies depending on context, but JavaScript receives an opaque response.

That means:

  • I can hold the Response object
  • but I cannot read the body bytes

And WebKit enforces that in the obvious place:

FetchBodyOwner::readableStream() blocks opaque bodies via isBodyNullOrOpaque().

So at first glance, everything looks fine. The body is hidden. The policy is enforced. Same-Origin Policy survives another day.

Except it does not.

The Fetch Behavior that Broke the Modal

The surprising part is Response.clone().

If FetchResponse::clone() is called while the response is still loading, WebKit will internally create a readable stream so it can tee the body between the original response and the clone.

That internal path does not apply the same opaque-body check first.

And once that happens, hidden response bytes start becoming very real renderer objects.

This is the part that made me stop and stare at the source, because the mismatch is right there.

The normal body path blocks opaque responses:

code3

But FetchResponse::clone() does this while the response is still loading:

code4

That is why it works.

The visible accessor path says “opaque bodies do not get a stream.” The clone path says “if it is still loading, create a stream so both clones can tee it.”

That second path is exactly what I needed.

The data flows through normal ArrayBuffer creation paths:

  • buffered chunks go through tryCreateArrayBuffer()
  • later chunks go through takeAsArrayBuffer()
  • shared buffer data gets copied into ordinary ArrayBuffer allocations inside the renderer

So the policy ends up split in two:

  • the public Fetch API says the body is opaque
  • the renderer still materializes the opaque body into readable byte arrays during clone-time streaming

Combined with the stale WASM window, it becomes a SOP break.

The Chain

At a high level, the exploit became:

  1. Force the target WASM memory into the BoundsChecking path.
  2. Call memory.toResizableBuffer().
  3. Grow the memory.
  4. Keep the stale resizable buffer whose pointer still targets freed Primitive Gigacage pages.
  5. Trigger a cross-origin fetch(…, { mode: “no-cors”, credentials: “include” }).
  6. Call response.clone() while the response is still loading.
  7. Let Fetch internals materialize the hidden body bytes into ordinary renderer ArrayBuffers.
  8. Reclaim the stale WASM-covered pages with those allocations.
  9. Read the cross-origin bytes through the stale view.

That is the entire trick.

I never needed response.text(). I never needed response.arrayBuffer(). I never needed the public API to hand me the body.

The browser copied the body into memory for its own internal bookkeeping, and the stale WASM view read it directly.

That is why this bug stopped being “some weird WASM UAF” and became “this completely breaks the Same-Origin Policy.”

The file:// Detour

One of the weirdest parts of the research was that the request side behaved differently depending on where I launched it from.

In my testing, cross-origin requests were much easier to get moving from file:// than from a normal https attacker page.

That sounds backwards until you look at WebKit’s handling of local origins.

Document.cpp has explicit special-casing around local documents and settings like:

  • allowUniversalAccessFromFileURLs
  • allowFileAccessFromFileURLs

MiniBrowser exposes those knobs too, which made file:// very useful as a research environment. It let me focus on the memory side and confirm the leak path before I had a clean web-facing story.

But I did not want a local-file party trick.

I wanted a real web exploit.

And from a normal https page, the same request pattern was not giving me the reliability I wanted.

That is where about:blank saved me.

Why about:blank saved the final POC

The final PoC opens an about:blank popup and performs the fetches from there:

code5

This ended up mattering a lot.

At first I thought this was just an origin-inheritance trick. That part is real:

code6

So about:blank does inherit the opener’s origin.

But that alone does not explain why the popup path behaved differently.

What actually seems to matter is Safari’s cookie / first-party bookkeeping. Fetch subresource requests copy document->firstPartyForCookies() into the request:

code7

And WebKit’s cookie blocking logic bails out immediately if that first-party domain is empty:

code8

That is a very different path from a normal attacker-controlled https page. From a regular https://attacker.example origin, the first party is the attacker site, so a request to the victim site looks third-party and Safari’s tracking-prevention logic can suppress cookies.

From the about:blank popup path, the security origin still comes from the opener, but the popup’s top-level URL / first-party context is no longer a normal registrable https site in the same way. In practice, that was enough to make credentials: “include” requests behave differently and get me the authenticated traffic pattern I needed.

So the important point is not “about:blank disabled CORS.” It did not. The important point is:

  • the popup kept the opener’s origin
  • the request still went through normal Fetch/CORS code
  • Safari’s first-party cookie logic treated that popup context differently

That was the difference between “cross-origin request happens but is useless” and “cross-origin request comes back with authenticated bytes worth stealing.”

Why this was fun

This is my favorite kind of browser bug.

Not because the root cause was complicated. It was not. The WASM bug was almost embarrassingly direct.

And not because the final chain was huge. It was not.

It was fun because it is exactly the kind of bug modern browser architecture is supposed to suppress.

A stale pointer inside a cage is supposed to stay a stale pointer inside a cage.

An opaque response is supposed to stay opaque.

Those are both reasonable assumptions.

The exploit works because both assumptions were true only locally.

JavaScriptCore gave me a stale view that looked hard to use. WebCore Fetch gave me sensitive bytes that looked impossible to read.

Put them together and Safari’s Same-Origin Policy fell apart.

Disclosure

We reported our findings to Apple. Shortly after, a fix shipped, suggesting the issue was already known internally.

The vulnerability (CVE-2026-20664) is addressed in iOS 26.4 and iPadOS 26.4 (23E6254 and later), and macOS Tahoe 26.4 (25E253 and later). Make sure your systems are up to date.

Closing Thoughts

The biggest thing on my mind after working with these models is the leverage they provide, and what that means for N-days. A security patch in popular software used to hide the underlying exploit behind time, effort, and expertise. Now that you can scale tokens instead of effort, that barrier is mostly gone.

This doesn’t turn exploitation into a trivial task. You still need someone who understands what they are looking at, can filter noise, and can steer the process when it stalls. But AI changes the unit of work. Instead of deep, sequential effort, you get parallel exploration and rapid iteration. The constraint shifts from raw effort to how effectively an operator can guide multiple lines of inquiry at once.
`

The post Hacking Safari with GPT 5.4  appeared first on Blog.

❌