Tagging WebKit · Part 1 of 4

MIE arrives in WebKit

On 9 September 2025, Apple announced Memory Integrity Enforcement (MIE), a hardware and software defence against memory corruption in its new A19 iPhones. One of its foundations is WebKit’s own memory allocator, libpas, which sits behind Safari’s engine.

That is almost all Apple has said about WebKit and MIE. But WebKit is open source, and fifteen days after the announcement the first memory-tagging code landed in its public repository. Over the following year, more than a hundred commits changed how WebKit allocates, tags, frees and protects memory.

Bar chart of WebKit MTE commits per month on mainline from September 2025 to September 2026, peaking at 16 in October 2025, 112 commits in total.

Commits directly about MTE on WebKit main, per month: 112 between September 2025 and September 2026.

This series follows that history. Part 1 covers how memory tagging works, the first landing and its reverts, and the two kinds of memory libpas can’t tag. Later parts go deeper into the tagging rules, which memory gets tagged, which processes get protected, the gaps WebKit fills by hand, and the bugs tagging has caught.

Source references in this series are to WebKit 814bf42b6770 (29 September 2026), unless a link says otherwise. Dates are when code landed in WebKit, not when it shipped in Safari.

Memory tagging in five minutes

The idea

Most memory-safety bugs come down to a pointer being used where it shouldn’t be. It might run past the end of its object (an out-of-bounds access), or point at memory that was freed and handed to someone else (a use-after-free). The hardware has no idea either happened: an address is an address.

Memory tagging gives the hardware a way to tell. Picture every small block of memory carrying a colour, and every pointer carrying a colour too. On every load and store, the CPU checks that the two match. Allocate an object and paint it and its pointer the same colour. Run off its end, and you reach memory painted another colour. Free it and repaint it, and the old pointer no longer matches.

Three scenes of the same bug. Without tags, a pointer p walks off the end of object A into object B and nothing notices. With tags, A and p share a colour; each step inside A passes, and the step into B, painted differently, faults. After free, A's memory is repainted for a new object C, and the old pointer, still carrying A's colour, faults.

The same bug three ways: without tags, with tags, and after a free.

That’s the whole idea. The rest is details. Let’s dig in.

Tags in memory, tags in pointers

Arm’s version is called the Memory Tagging Extension (MTE). Memory is divided into granules of 16 bytes, and each granule has a 4-bit allocation tag stored in separate tag memory. Four bits give 16 possible tags. Every pointer carries a 4-bit logical tag of its own. A load or store whose pointer tag doesn’t match the granule’s allocation tag is a tag check fault (Linux kernel MTE documentation, LLVM MemTagSanitizer).

Where does a pointer keep its tag? On 64-bit Arm, user-space addresses fit in the low 48 bits, and libpas treats those 48 bits as the address. Top Byte Ignore tells the CPU to ignore bits 63 to 56 when it translates an address, so that byte is free for metadata. MTE uses four of those bits, 59 to 56.

Diagram of a 64-bit arm64 pointer: bits 63 to 60 unused, bits 59 to 56 hold the tag, bits 55 to 48 are zero, bits 47 to 0 are the virtual address. Example 0x0300000102a34560 carries tag 3.

A tagged pointer. The CPU ignores the top byte during address translation, so bits 59–56 can carry the tag.

libpas defines that layout in four constants:

pas_mte.h, lines 56–59

#define PAS_MTE_TAG_SHIFT 56
#define PAS_MTE_TAG_MASK (0x0full << PAS_MTE_TAG_SHIFT)
#define PAS_MTE_CANONICAL_MASK ((0x1ull << 48) - 1)
#define PAS_MTE_GRANULE_SIZE 16

The tag starts at bit 56, the mask covers four bits there, the address is the low 48 bits, and memory is tagged in 16-byte steps.

Here is what a check looks like in practice. Object A is 48 bytes (three granules) tagged 3, and object B next to it is tagged 7:

Diagram of eight 16-byte granules: object A spans three granules tagged 3, object B two granules tagged 7, and three free granules tagged 0. Pointer p with tag 3 reads object A; p plus 48 lands in object B and faults.

Every access compares the pointer’s tag with the tag of the granule it touches.

A pointer to A carries tag 3 and can read all 48 bytes. Add 48 and it lands in B, whose granules are tagged 7. The tags differ and the access faults: the overflow is caught at the moment it happens, not when something later goes wrong.

The instructions

MTE adds a handful of instructions. An allocator needs only a few of them (Arm A64 instruction reference):

Instruction What it does
irg Insert a random tag into a pointer, avoiding any tags in an exclusion mask
stg, st2g Store the pointer’s tag as the allocation tag of one or two granules
stzg, stz2g The same, and zero the memory
ldg Load a granule’s allocation tag into a pointer
gmi Add a pointer’s tag to an exclusion mask, to feed to irg

libpas wraps them in short inline-assembly macros. Loading and storing a tag look like this:

pas_mte.h, lines 87–104 (trimmed)

#define PAS_MTE_GET_MTAG(ptr) do { \
        __asm__ volatile( \
            ".arch_extension memtag\n\t" \
            "ldg %0, [%0]" \
            : "+r"(ptr) \
            : \
            : \
        ); \
    } while (0)
#define PAS_MTE_SET_TAG(ptr) do { \
        __asm__ volatile( \
            ".arch_extension memtag\n\t" \
            "stg %0, [%0]" \
            // …

ldg reads the tag of the granule that ptr points at and writes it into ptr’s own tag bits. stg does the reverse: it writes ptr’s tag to that granule.

When a mismatch is caught

Arm lets the operating system choose what happens on a tag check fault (SCTLR_EL1):

Mode What happens
Synchronous The faulting instruction traps immediately; the bad access never happens
Asynchronous The fault is recorded and reported later, without the exact address
Asymmetric Reads are synchronous, writes asynchronous

Asynchronous mode is cheaper, and Arm’s guidance suggests it for production use. Apple rejects it for MIE: a delayed report leaves an attacker a window to act before the process is stopped. MIE uses synchronous checking only (Apple Security Research).

Apple also offers a report-only setting for development: soft mode, in which a tag mismatch produces a simulated crash report instead of terminating the process (Apple developer documentation). Apple tells developers to fix what it reports and then turn it off. WebKit’s commits call the strict setting “hard mode” (for example 8d85f545cea5, “Re-enable MTE hard-mode”). That’s WebKit’s term, not Apple’s; part 2 follows both in WebKit’s processes.

What tagging can’t promise

Memory tagging has limits:

  • Tags can collide. With 16 values, a random wrong tag still matches one time in 16. An allocator can do much better for the cases it controls. If it never gives two neighbouring objects the same tag, a linear overflow into the next object is always caught. Part 2 is about exactly these rules.
  • Tags cover whole objects, in 16-byte steps. The whole object carries one tag, so an overflow that stays inside the object, from one field into the next, is never caught. And because objects are tagged in whole granules, an overflow into the unused bytes at the end of the last granule isn’t caught either. A 20-byte object occupies two granules (32 bytes), and bytes 20–31 share its tag.

    A 20-byte object occupies two 16-byte granules, both tagged 3: field a in bytes 0 to 7, field b in bytes 8 to 19, and unused bytes from 20 to 31. The next object, from byte 32, is tagged 7. Overflowing from a into b is not caught, overflowing from b into the unused bytes is not caught, and reaching byte 32 is caught because tag 7 doesn't match.

    Inside one object, and in the unused bytes at its end, every byte has the same tag.

  • Tags cost memory and time. Four bits for every 16 bytes is about 3% extra memory for tag storage, and every allocation has to be tagged, which takes longer the bigger it is.

Apple’s MIE doesn’t use the original MTE but Enhanced MTE (EMTE), which Apple says it developed with Arm and which Arm lists as MTE4. The difference Apple highlights: under original MTE, accesses to untagged memory, such as global variables, weren’t checked. EMTE closes that gap: to reach untagged memory from a tagged region, a pointer has to carry that region’s tag (Apple Security Research).

Tagging is also only one layer of MIE. Apple’s announcement names three “secure allocators” as its foundation: kalloc_type in the kernel, xzone malloc in user space, and WebKit’s libpas. They sort memory by type first, and tags then separate objects within the same type bucket. Apple names another part too, Tag Confidentiality Enforcement, which keeps the tags themselves secret, including from side-channel and speculative-execution attacks.

A quick tour of libpas

WebKit doesn’t use the system malloc for most of its objects. On Apple platforms, its allocation calls (fastMalloc and friends) go through bmalloc into libpas, WebKit’s own allocator. It serves JavaScriptCore, WebCore and the rest of the engine, and WebKit documents its design in depth in Libpas Internals. A few libpas terms come up throughout this series:

  • Heap: a pool of memory for a family of allocations, with its own rules (heap configurations). WebKit has many.
  • Page: a chunk of memory a heap splits into objects (pages, headers and alloc bits). A heap’s page configuration decides, among other things, whether objects on those pages may be tagged.
  • Size category: libpas handles small, medium and large allocations differently. Its tagging support covers small and medium objects.
  • Compact pointer: a pointer stored in 32 bits to save memory (CompactPtr.h). More about that soon.

The landing

The first commit is d3acbe4f9624, “[libpas] Implement primary support for MTE”, on 24 September 2025. Its message ties the work to the chips announced on 9 September and to MIE. The goal it gives is “to ensure that WebKit is up to par with the new memory safety standards” set by the rest of the system.

It’s a large change: 80 files, about 1,700 added lines, mostly in new files: pas_mte.h alone is 950 lines, alongside pas_mte.c and a configuration layer in pas_mte_config.h and pas_mte_config.c. The rest is small hooks across the allocator: the local allocators, page configurations, the scavenger that returns memory to the system, and the thread-local caches.

Here is how that allocation path looks a year later, in the September 2026 code this series links to. The core is from the first commit; some steps, such as excluding the tag the memory had last time, came later, and part 2 dates each one.

When libpas hands out memory, it first picks a random tag:

pas_mte.h, lines 552–567

static PAS_ALWAYS_INLINE uintptr_t
pas_mte_generate_random_tag(
    uintptr_t begin,
    pas_mte_tag_constraint constraint)
{
    if (PAS_MTE_FEATURE_ENABLED(PAS_MTE_FEATURE_ZERO_TAG_ALL))
        return begin & ~PAS_MTE_TAG_MASK;
    __asm__ volatile( \
        ".arch_extension memtag\n\t"
        "irg %0, %0, %1"
        : "+r"(begin)
        : "r"((uintptr_t)(constraint))
        :
    );
    return begin;
}

irg takes the address and a constraint, a bitmask of tags it must not choose, and returns the address with a new random tag in bits 59–56. (ZERO_TAG_ALL is a runtime switch, listed alongside libpas’s logging and assertion options, that forces tag 0 everywhere.)

Then it tags the whole allocation:

pas_mte.h, lines 569–609 (trimmed)

/*
 * Tagging is what actually applies an PAS_MTE tag to an allocation.
 * We always tag objects with a non-zero tag. It's assumed that this function
 * will be invoked with a size that's a multiple of 16, and it's really
 * important that the size passed be the allocation size of the object, not the
 * actual (i.e. user-requested) size.
 */
static PAS_ALWAYS_INLINE uintptr_t
pas_mte_generate_tag_and_tag_region(uintptr_t begin, size_t size, /* … */)
{
    pas_mte_tag_constraint valid_tags;
    if (PAS_MTE_FEATURE_ENABLED(PAS_MTE_FEATURE_ADJACENT_TAG_EXCLUSION))
        valid_tags = pas_mte_compute_valid_tags_under_adjacent_tag_exclusion(/* … */);
    else
        valid_tags = pas_mte_any_nonzero_tag;
    if (PAS_MTE_FEATURE_ENABLED(PAS_MTE_FEATURE_PREVIOUS_TAG_EXCLUSION)) {
        // … read the slot's previous tag with ldg, and exclude it
    }
    begin = pas_mte_generate_random_tag(begin, valid_tags);
    pas_mte_tag_region_from_pointer(begin, size, is_known_medium);
    // …
    return begin;
}

The function does five things:

  1. Decide which tags are allowed. At minimum that’s any tag but 0: in normal operation (outside debug switches like ZERO_TAG_ALL), libpas never gives an object tag 0.
  2. Optionally rule out the tags of neighbouring objects, and the tag this memory had last time.
  3. Pick a random tag from what’s left (irg).
  4. Write that tag to every granule of the allocation. As the comment says, that’s the allocation size: libpas rounds requests up to its size classes, and every granule it hands out carries the tag, not only the bytes the caller asked for.
  5. Return the tagged pointer to the caller.

Steps 1 and 2 are where most of the security work happens, and they are the subject of part 2.

Four landings, three reverts

The first commit was reverted the same day. It took four tries over two and a half weeks to land, and until 19 November to be built for every platform:

Timeline from 24 September to 19 November 2025: lands and is reverted for breaking Apple internal builds; lands and is reverted for breaking iOS Debug builds; lands and is reverted for a JetStream 3, Speedometer 3 and RAMification regression; lands unused on 10 October; iOS builds switch to the open-source implementation on 13 October and all platforms on 14 October, with a runtime check still deciding; macOS builds compile it from 19 November.

libpas MTE support on WebKit main, from first commit to every platform.

The first two reverts (ad44c1173ed4, c269983fd0a8) fixed broken builds. The third (97a651d3d0e7, 5 October) was for a regression in three benchmarks: JetStream 3, Speedometer 3 and the memory benchmark RAMification. The change cost speed and memory, though the revert doesn’t say whether the cost came from tag checks or from changes to the allocator.

So the fourth attempt, 86ccfc607001 on 10 October, landed the code unused, promising “A later patch will enable it in stages”. The switch is one line in pas_platform.h:

86ccfc607001, pas_platform.h

+#ifndef PAS_USE_OPENSOURCE_MTE
+#define PAS_USE_OPENSOURCE_MTE 0
+#endif // PAS_USE_OPENSOURCE_MTE

The flag picks which MTE implementation gets built. On 13 October, iOS builds switched to this open-source one, and on 14 October every platform did (0c8a20becc15, f8792f90c31a). Both commits are titled “Switch to open source MTE implementation”, and an older MTE_ENABLED_IN_BUILD flag is kept “for legacy consumers”. That suggests Apple’s builds had used a different, private implementation until then, though the public history doesn’t show it.

Neither commit turns tagging on by itself, and until 19 November MTE was compiled only for iOS-family builds. Then e2924d98e06c dropped that condition, so privileged processes on MTE-capable Macs could use it too.

You shall not tag

Building the code doesn’t turn MTE on. The all-platform commit notes that it “does not necessarily enable MTE itself on all devices”; a runtime check still decides. An allocation has to pass three separate gates before it gets a tag:

Diagram of three gates. First, compiled in: PAS_ENABLE_MTE, which needs Apple's internal SDK, arm64e and no ASan or TSan. Second, enabled at runtime: PAS_USE_MTE, decided once at process start from CPU support and which process it is. Third, object taggable: at most 32 KB, not reached through a compact pointer, and in a heap whose pages allow tagging.

Build, process and object: an allocation is tagged only if all three gates pass.

The first gate means a WebKit built from public source doesn’t include MTE by default: it’s compiled only with Apple’s internal SDK, for arm64e, without sanitizers (pas_config.h, lines 91–97). The code is public, but by default only Apple’s builds run it.

The second gate decides which processes tag their memory, covered in the next section. The third decides which objects can be tagged at all; the last section covers what never gets through.

Who got what

Once the gates are open, which processes actually tag their memory? Safari splits its work across several processes. WebContent processes run web pages and their JavaScript, so they are the most exposed. Privileged processes, such as networking and GPU, do work on their behalf. The first commit gave each kind of process different settings:

pas_mte_config.c at d3acbe4f9624, lines 101–153 (trimmed)

    bool isWebContentProcess = !strncmp(name, "com.apple.WebKit.WebContent", 27) || !strncmp(name, "jsc", 3);
    // …
    if (isWebContentProcess) {
        *medium_byte = 0;
#if !PAS_USE_MTE_IN_WEBCONTENT
        // Disable tagging in libpas by default in WebContent process
        *enabled_byte = 0;
#endif
        // …
        if (sysctlbyname("security.mac.lockdown_mode_state", &ldmState, &sysCtlLen, NULL, 0) >= 0 && ldmState == 1) {
            *enabled_byte = 1;
            *medium_byte = 1;
            *lockdown_mode_byte = 1;
        } else {
            *lockdown_mode_byte = 0;

            // FIXME: rdar://159974195
            bmalloc_common_primitive_heap.is_non_compact_heap = false;
        }
        // …
    } else
        *medium_byte = 1; // Tag libpas medium objects in privileged processes.

The comment says WebContent is disabled by default, but the line under it only runs when PAS_USE_MTE_IN_WEBCONTENT is 0, and the header sets it to 1:

pas_mte_config.h at d3acbe4f9624, lines 85–87

#ifndef PAS_USE_MTE_IN_WEBCONTENT
#define PAS_USE_MTE_IN_WEBCONTENT 1
#endif

So the public source allowed WebContent tagging from the first landing, if the runtime gate turned MTE on, with less coverage than privileged processes. Privileged processes tagged small and medium objects. WebContent tagged only small ones, and outside Lockdown Mode it also marked libpas’s common primitive heap as one that compact pointers may reference, which keeps that heap untagged. Lockdown Mode turned medium tagging on and skipped the primitive-heap change.

One caveat: the header includes a private WebKitAdditions file just before that default (lines 35–37), so Apple’s own builds could have set it to 0. The public history can’t tell us. The Lockdown Mode branch has its own catch: it relies on a sysctl that, according to the later fix, “was always returning 0” inside WebContent’s sandbox. Part 2 follows how each process’s tagging changed, including that fix.

What libpas can’t tag

The first commit’s message lists two limits: objects of 32 KB and larger, and objects that may be referenced through compact pointers, cannot be tagged.

Large objects. Tagging costs grow with size. Apple’s MIE design protects large and multi-page allocations at the page level instead, and uses tags for the small ones inside a type bucket. libpas made a similar split in December 2025 (54b588aa233a): large allocations it can’t tag can be handed to the system allocator and its hardening. The same commit defined the limit as a constant:

pas_internal_config.h, lines 101–102

#define PAS_MAX_MTE_TAGGABLE_OBJECT_SIZE_SHIFT 15
#define PAS_MAX_MTE_TAGGABLE_OBJECT_SIZE ((size_t)1 << PAS_MAX_MTE_TAGGABLE_OBJECT_SIZE_SHIFT)

1 << 15 is 32,768 bytes. The first commit’s message says objects of 32 KB and larger can’t be tagged; today’s tryMalloc, shown at the end of this section, routes only sizes above 32,768 bytes to an untagged heap.

Compact pointers. WebKit stores many pointers to some objects, so using eight bytes for each pointer adds up. WebKit’s CompactPtr stores them in 32 bits instead:

CompactPtr.h, lines 95–98

    // The CompactPtr algorithm relies on shifting a 36-bit address
    // right by 4 bits to fit within 32 bits.
    // This operation is lossless only if the address is always
    // 16-byte aligned, meaning the lower 4 bits are always 0.

A compact pointer keeps address bits 35 to 4. The bottom four are always zero anyway, and the top byte, where the tag lives, is dropped:

Diagram of a 64-bit tagged pointer whose bits 35 to 4 are shifted into a 32-bit CompactPtr; the tag in bits 59 to 56 is not stored.

A 32-bit compact pointer has no room for a tag.

A pointer rebuilt from a compact pointer has no tag, so any object reachable through one has to live in untagged memory.

Today, bmalloc checks all of these limits in one function:

bmalloc.h, lines 107–120

BINLINE void* tryMalloc(size_t size, CompactAllocationMode mode, HeapKind kind = HeapKind::Primary)
{
#if BUSE(LIBPAS)

    // The decision tree
    if (isGigacage(kind))
        return bmalloc_try_allocate_auxiliary_inline(&heapForKind(gigacageKind(kind)), size);
    if (mode == CompactAllocationMode::Compact)
        return bmalloc_try_allocate_auxiliary_inline(&bmalloc_compact_primitive_heap_ref, size);
    if (!isMTEEnabled()) [[likely]]
        return bmalloc_try_allocate_inline(size);
    if (size > PAS_MAX_MTE_TAGGABLE_OBJECT_SIZE) [[unlikely]]
        return bmalloc_try_allocate_inline(size);
    return tagged_bmalloc_try_allocate(size);

Each check sends the allocation to an untagged heap: Gigacage memory to its own heap, anything that may be referenced by a compact pointer to the compact heap, everything when MTE is off, and anything over 32 KB. Only the rest reaches tagged_bmalloc_try_allocate, in the tagged heap. This is today’s code: the separate tagged heap was switched on only on 29 September 2026 (c8cb35beacac), and part 2 covers how it got there. The two limits themselves date from the first commit.

Key commits

Date Commit What
2025-09-24 d3acbe4f9624 First libpas MTE support
2025-09-24 ad44c1173ed4 Reverted: broke Apple internal builds
2025-09-26 c269983fd0a8 Reverted: broke iOS Debug builds
2025-10-05 97a651d3d0e7 Reverted: JetStream 3, Speedometer 3 and RAMification regression
2025-10-10 86ccfc607001 Lands unused (PAS_USE_OPENSOURCE_MTE 0)
2025-10-13 0c8a20becc15 iOS builds switch to the open-source implementation
2025-10-14 f8792f90c31a All platforms switch; a runtime check still decides
2025-11-19 e2924d98e06c MTE compiled for macOS too, for privileged processes on MTE-capable Macs
2025-12-05 54b588aa233a 32 KB limit becomes an explicit constant
2026-06-30 8d85f545cea5 Soft-mode entitlements removed: hard mode for all processes
2026-09-29 c8cb35beacac Tagged heap switched on; today’s allocation path

Further reading

Next: part 2, The rules and reach of tagging. Once memory can be tagged, which tag does each allocation get, which memory gets one, and in which processes?