Tagging WebKit · Part 2 of 4

The rules and reach of tagging

In part 1, an allocation had to get through three gates before libpas would tag it: MTE built in, MTE switched on for the process, and an object libpas is able to tag (You shall not tag). The second gate is libpas’s own decision, separate from the operating system turning MTE on.

Turning MTE on for a process doesn’t tell you which tagging rules apply, which allocations get tags, or what happens when a check fails. This part takes those questions in turn, from libpas’s tagging rules to the memory and processes they actually protect.

As part of this research, we developed pas, an LLDB tool for looking inside libpas in a running process. Give it an address and it shows the heap, page and object it falls in, with the pointer’s tag and the memory’s, and on a Mac with MTE it explains why a tag check failed.

Enabled doesn’t mean tagged

We conducted our research on an M6 Mac mini (Mac18,5) running macOS 27.0.1 and Safari 22625.1.29.11.28, and looked at its processes in two ways.

Safari’s processes all had MTE enabled. But WebKit’s allocator regions differed. In the GPU process they had tag storage, and in WebContent, the process that runs web pages, they didn’t. Even within WebContent, the system allocator’s regions had it.

The kernel’s view. For every process, proc_pidinfo returns a word of flags, pbi_flags. Apple’s published kernel source defines what its top bits mean, from PROC_FLAG_SEC_ENABLED (0x04000000, MTE on) to PROC_FLAG_SEC_BYPASS_ENABLED (0x08000000, soft mode) (proc_info_private.h, lines 130–135), so a few lines of Python can read them. This is what we got (process names shortened):

  5770  pbi_flags=0xad804410  Safari       SEC (MTE on), SEC_BYPASS (soft mode), TPRO, GUARD_OBJECTS
  5772  pbi_flags=0xad404030  WebContent   SEC (MTE on), SEC_BYPASS (soft mode), TPRO, GUARD_OBJECTS
  5775  pbi_flags=0xa5004430  Networking   SEC (MTE on), TPRO, GUARD_OBJECTS
  5777  pbi_flags=0xa5004430  GPU          SEC (MTE on), TPRO, GUARD_OBJECTS

Every process runs with MTE on. Safari and WebContent also carry the kernel’s soft-mode bit. We come back to that under process policy. (TPRO is another kernel hardening feature, outside this series. GUARD_OBJECTS means the process is enrolled in guard objects, which come up again below. It doesn’t say any given allocation is guarded.)

The memory. vmmap -attributes lists every memory region in a process and marks the ones that have tag storage with MTE. In the GPU process, one of the regions WebKit’s allocator uses has it:

WebKit Malloc   110800000-112800000  [ 32.0M   160K    80K    0K] rw-/rw- SM=PRV  MTE   WebKit Malloc_0x10640e308

The last column names the malloc zone that owns the region, here libpas’s WebKit Malloc zone. That’s what makes it allocator memory. vmmap’s first column comes from the mapping’s VM tag, which any code can set, so it says “WebKit Malloc” whoever made the mapping. In WebContent, none of WebKit’s allocator regions has tag storage. The system allocator’s regions in the same process do, all 354 of the Malloc Small ones:

WebKit Malloc   110100000-114100000  [ 64.0M  3808K  2784K    0K] rw-/rwx SM=PRV        WebKit Malloc_0x10600e678
Malloc Small    728c000000-728c004000 [   16K    16K    16K    0K] rw-/rwx SM=S/A  MTE   objc-class_rw_t_0x1050ec000

MTE on a region means the memory can hold tags, not that every object in it got a non-zero one. Still, in WebContent the system allocator tags its memory but WebKit’s own allocator doesn’t. That’s libpas’s choice, and it isn’t the public source’s default, which tags WebContent (pas_mte_config.h, lines 71–73). We come back to it under process policy.

A few caveats. This is one Mac on one build, with nothing but a blank page loaded, and we haven’t looked at Lockdown Mode or the EnhancedSecurity variant of WebContent yet.

The rest of this part follows libpas’s side of that choice, in three sections: the tagging rules, which memory gets tags, and how each process is set up. Source references are to WebKit 814bf42b6770 (29 September 2026). Dates are when code landed in WebKit, not when it shipped in Safari.

The rules

Pick each tag at random, independently and evenly from the 15 non-zero values, and any two objects share a tag one time in 15. An allocator can do better for the bugs it can see coming: running off the end of one object into the next, and using a pointer after its object was freed. libpas has three rules for those:

  1. Avoid the neighbours’ tags, so an overflow into the next object meets a different tag.
  2. Retag freed memory, so a pointer kept after free no longer matches.
  3. Avoid the previous tag, so the new tag is actually new.

In Release builds, libpas uses these rules only in processes it treats as hardened. Elsewhere, when it tags at all, any non-zero tag will do. Here, “hardened” is libpas’s own process category, the one that decides which tagging rules apply. Hard and soft mode, from part 1, are something else. They describe what the operating system does when a tag check fails. And Apple’s code-signing setting called hardened-process is a third thing again, which comes up under process policy.

None of the rules makes a miss impossible. Each one closes off a common way for a bad access to get lucky, and how far it reaches depends on where that access lands and when it happens.

Neighbours don’t share a tag

If two neighbouring objects have different tags, an access that runs off the end of one into the start of the other hits a tag mismatch. Whether that stops the process on the spot or just gets reported depends on its mode (part 1). libpas calls this adjacent-tag exclusion, and does it two ways, one for each kind of page it tags.

A segregated page holds objects of a single size, packed in equal slots (Libpas Internals). That regularity allows a trick that has been there since the first commit: give every other slot an odd tag, and the slots in between an even one.

A segregated page with six equal slots. Even slots have non-zero even tags and odd slots have odd tags, so neighbouring slots always have opposite tag parity. Two rows of tags show the irg exclusion masks: 0xaaab leaves the seven even tags 2 to 14, and 0x5555 leaves the eight odd tags 1 to 15.

Neighbouring slots have opposite parity, so their tags can’t be equal.

libpas represents tag constraints as an exclusion mask: 16 bits, one per tag, in the form irg takes, where a set bit means don’t pick this tag.

pas_mte.h, lines 194–207

// Exclusion bit-mask of tags in the range 0-15, indicating
// the set of tags each constraint prevents the IRG instruction from generating.
enum pas_mte_tag_constraint {
  pas_mte_any_nonzero_tag = 0x0001,
  pas_mte_odd_tag = 0x5555,
  pas_mte_nonzero_even_tag = 0xaaab,
};
// …
static PAS_ALWAYS_INLINE pas_mte_tag_constraint pas_mte_exclude_tag(pas_mte_tag_constraint base, uint8_t tag_value_to_exclude)
{
    return (pas_mte_tag_constraint)((unsigned)base | (1u << tag_value_to_exclude));
}

0x0001 rules out tag 0 only. The other two leave only the odd tags or only the even ones, as the figure above shows. pas_mte_exclude_tag rules out one more tag by setting its bit, which bitfit pages rely on below. Which mask a slot gets depends on its position:

pas_mte.h, lines 217–223

    if (PAS_LIKELY(homogeneity == pas_mte_homogeneous_allocator)) {
        // Try to be smart and tag based on our parity within the
        // current segregated page
        if ((((uintptr_t)ptr & PAS_MTE_CANONICAL_MASK) / size) & 0x1)
            valid_tags = pas_mte_odd_tag;
        else
            valid_tags = pas_mte_nonzero_even_tag;

The slot’s address divided by the object size gives a slot number. Neighbours are exactly one object size apart, so their numbers are consecutive. One is odd, and the other even.

A bitfit page holds objects of different sizes, placed wherever they fit (Libpas Internals). There are no fixed slots, so there’s no parity to use. Since December 2025 (e698dda8251b), libpas instead looks at the tags of its neighbours directly, with a parity rule kept for page edges:

pas_mte.h, lines 279–296 (trimmed)

valid_tags = pas_mte_any_nonzero_tag;
if (current_pageno != prior_pageno) {
    valid_tags = pas_mte_nonzero_even_tag;
    // …
    PAS_ASSERT(current_pageno == succeeding_pageno, current_pageno, prior_pageno, succeeding_pageno);
} else if (current_pageno != succeeding_pageno)
    valid_tags = pas_mte_odd_tag;

if (PAS_LIKELY(current_pageno == succeeding_pageno)) {
    PAS_MTE_GET_MTAG(succeeding_granule);
    valid_tags = pas_mte_exclude_tag(valid_tags, succeeding_granule >> PAS_MTE_TAG_SHIFT);
}
if (PAS_LIKELY(current_pageno == prior_pageno)) {
    PAS_MTE_GET_MTAG(prior_granule);
    valid_tags = pas_mte_exclude_tag(valid_tags, prior_granule >> PAS_MTE_TAG_SHIFT);
}

The last two checks each read the tag of a neighbouring granule, the one just after the new object or the one just before it, with the ldg instruction inside PAS_MTE_GET_MTAG, and remove that tag from the choices. Their page-number comparisons keep those reads inside the current page, so a neighbour on another page needs the first rule instead. An object that starts its page gets an even tag, and one that ends it gets an odd tag, so the last object of one page and the first of the next can’t match.

A sketch with example tags of how libpas picks a new object's tag on a bitfit page, where objects of different sizes sit side by side. 1: in the middle of a page, libpas reads the tag of the granule just before the new object and the one just after it with ldg, here 9 and E, and the new object gets any non-zero tag except those. 2: at a page edge, ldg never reads across the edge. The last object on a page gets an odd tag, excluding only the previous object's tag, here 3. The first object on the next page gets an even tag, excluding only the next object's tag, here 6. Odd and even can't match, and any other kind of page also starts untagged or even.

A sketch, with example tags. The second row follows the comment above that code, which works through each kind of page that could come next.

In a running process, neighbours did get different tags. We checked with a small test app of our own, built on the same Mac and linked against the JavaScriptCore that ships with macOS. We’ll publish our test programs and LLDB scripts alongside the series. The app’s signature is what turns MTE on for it:

$ codesign -d --entitlements - <our test app>
…
[Dict]
	[Key] com.apple.security.get-task-allow
	[Value]
		[Bool] true
	[Key] com.apple.security.hardened-process
	[Value]
		[Bool] true
	[Key] com.apple.security.hardened-process.checked-allocations
	[Value]
		[Bool] true
	…

Unlike Safari’s WebContent, GPU and Networking processes, our app isn’t an Apple platform binary, so the kernel goes by its checked-allocations key (process policy covers the difference), and get-task-allow lets the debugger attach. libpas then reports what it decided. Run with LibpasMallocReportConfig=1 set, it prints its configuration at start-up (trimmed, with tabs shown as two spaces):

… malloc: libpas config:
  …
  MTE (Enabled/Medium-Enabled/Lockdown/Hardened/ATE/RoS/ZTA): (1, 1, 0, 1, 0, 0, 0)
  …
  Heap-Config bmalloc:
    …
    Runtime Heap Config Size-Maximums (Segregated, Bitfit, Baseline Dir, No-View-Cache Dir):
      …
      Intrinsic: 0B, 32768B, 0B, 0B
      …
      Primitive: 0B, 32768B, 0B, 4294967295B

Hardened is 1, and the Intrinsic and Primitive lines under bmalloc allow no segregated objects (0B) and bitfit objects up to 32 KB. That’s the forced bitfit described below. The ATE and RoS columns stand for adjacent-tag exclusion and retag-on-scavenge, libpas’s names for the first two rules above, and both print 0. In the public source, though, those columns print runtime override bits (config->mode_bits, pas_mte_config.c, lines 335–336) that only debug builds read. In a Release build, a hardened process gets both rules regardless, as the feature gate shows. libpas’s report also lists only one bmalloc heap configuration, Heap-Config bmalloc. The public source we cite has a second one just for tagged allocations, which WebKit switched on at the end of September 2026 (see A heap of its own). The shipped build is most likely older than that change, so its tagged allocations still come from the one shared heap. That’s why we say the wrappers came back tagged without naming the heap they came from.

The app uses only JavaScriptCore’s public C API. Each string it creates comes with a small wrapper object, which the shipped library allocates with a 24-byte fastMalloc call:

(lldb) disassemble -n JSStringCreateWithCharacters
JavaScriptCore`JSStringCreateWithCharacters:
    …
    0x1b9104d24 <+32>:  mov    w0, #0x18                 ; =24 
    0x1b9104d28 <+36>:  bl     0x1b7f93f88               ; WTF::fastMalloc(unsigned long)

Releasing the string’s only reference frees the wrapper. The program around those two calls:

Our test program (trimmed, comments ours)

// COUNT = 256
for (unsigned i = 0; i < COUNT; ++i) {
    handles[i] = JSStringCreateWithCharacters(input, 128);
    // … check the string, log its pointer
}
// … sort by address; pick three
//   wrappers 32 bytes apart in one page
checkpoint(1);  // all three alive
JSStringRelease(handles[middle]);
handles[middle] = NULL;
checkpoint(2);  // middle released
// MAX_REPLACEMENTS = 256
for (unsigned i = 0; i < MAX_REPLACEMENTS; ++i) {
    JSStringRef string = JSStringCreateWithCharacters(input, 128);
    // … keep it alive; stop once one
    //   lands at the middle's address
}
checkpoint(3);  // address reused

At each checkpoint, our LLDB script runs memory tag read over each wrapper’s 32 bytes, which reads the tags stored on the memory itself, not just the pointers. Here’s the middle wrapper at the first checkpoint, with byte dumps cut. The other two read A and D the same way:

phase=1 index=1 original_pointer=0x060000010e000320
Logical tag: 0x6
Allocation tags:
[0x10e000320, 0x10e000330): 0x6
[0x10e000330, 0x10e000340): 0x6

The logical tag is the pointer’s, and the allocation tags are the memory’s, one per 16-byte granule. Laid out in memory, the three wrappers sit back to back:

Measured in one run of our own test app, before anything is freed. Three 24-byte string wrappers sit back to back at 0x10e000300, 0x10e000320 and 0x10e000340. Each covers two 16-byte granules and uses 24 of their 32 bytes. The last 8 are unused. Their granules carry tags A, 6 and D, the same tags as their pointers, so at each boundary between wrappers the tag changes.

Each wrapper’s two granules carry its pointer’s tag, so the tag changes at every boundary between wrappers.

An access that runs off the end of one wrapper into the next meets a different tag, from A to 6 or from 6 to D. Across all 256, none of the 255 pairs at adjacent addresses had matching pointer tags. We read the memory’s tags for those three only. A build of the same app without the entitlements got tag 0 on every pointer.

The rule covers the next object. An access that skips past a neighbour, or stays inside its own object (part 1’s unused bytes), meets whatever tag is there.

Freed memory gets a new tag

The second rule is about time. When an object is freed, a pointer to it may survive somewhere. That’s a dangling pointer. Until the memory gets a new tag, tag checks won’t reject that pointer. So the question is when libpas retags.

Today, with retagging on, tagged memory gets a tag twice, once when it’s allocated and again when it’s freed. libpas decides the allocation-time write here:

pas_mte.h, lines 712–725 (trimmed)

    if (PAS_MTE_FEATURE_ENABLED(PAS_MTE_FEATURE_RETAG_ON_SCAVENGE)
        && !PAS_WORKAROUND_RDAR_171662605_UNCONDITIONAL_TAG_ON_ALLOC
        && initiality == pas_non_initial_allocation) {
        // …
        PAS_MTE_PURIFY(begin);
    } else {
        // …
        begin = pas_mte_generate_tag_and_tag_region(begin, size, homogeneity, is_known_medium);
    }

The first branch skips the write and takes the pointer’s tag from what’s already in memory (PAS_MTE_PURIFY reads it back). Only allocations from a segregated page’s freed slots are passed in as pas_non_initial_allocation (line 887). Bitfit allocations are always passed as pas_maybe_initial_allocation (line 893), so they always get a fresh tag. Since March 2026 (b754353fb35e), the workaround constant in the second condition is 1 (pas_mte_config.h, lines 148–149), so no allocation takes the first branch. The shortcut assumed a freed slot still held the tag written at free. But libpas sometimes gives an unused page’s memory back to the operating system and takes it back later, and the tags it wrote may not survive that. Until libpas retags a page when it takes it back, it writes a tag on every allocation.

What differs between the two kinds of page is when the free-time write happens. On the logged segregated path, free normally queues the object in a per-thread deallocation log rather than retagging it immediately. Only a page that doesn’t log deallocates straight away:

pas_segregated_page_inlines.h, lines 544–569 (trimmed)

    if (!pas_segregated_deallocation_logging_mode_does_logging(mode)) {
        // …
        pas_segregated_page_deallocate(
            begin, &held_lock, pas_segregated_deallocation_direct_mode, NULL, page_config);
        // …
        return;
    }
    // … size-aware modes append the object with its size
    pas_thread_local_cache_append_deallocation(
        cache, begin, page_config.kind);

The tagged heap’s segregated pages are configured to log:

tagged_bmalloc_heap_config.h, lines 57 and 71 (trimmed)

    .small_exclusive_segregated_logging_mode = pas_segregated_deallocation_size_oblivious_logging_mode, \
    // …
    .medium_exclusive_segregated_logging_mode = pas_segregated_deallocation_size_aware_logging_mode, \

So a free usually just appends the object to its thread’s log:

pas_thread_local_cache.h, lines 396–403

    index = thread_local_cache->deallocation_log_index;
    if (PAS_UNLIKELY(index >= PAS_DEALLOCATION_LOG_SIZE - 1)) {
        pas_thread_local_cache_append_deallocation_slow(thread_local_cache, begin, kind);
        return;
    }

    thread_local_cache->deallocation_log[index++] = pas_thread_local_cache_encode_object(begin, kind);
    thread_local_cache->deallocation_log_index = index;

The retag lives in the page-level deallocation, PAS_MTE_HANDLE(SEGREGATED_PAGE_DEALLOCATION, …) (pas_segregated_page_inlines.h, line 426), which runs when libpas processes the log. That can still happen inside the same free. When the log reaches its entry limit, the slow path above adds the entry and flushes the whole log (pas_thread_local_cache.c, lines 1268–1281), and the size-aware append does the same at its byte limit (pas_thread_local_cache.h, lines 414–416). Our app’s libpas reports those limits as 100 entries and 50,000 bytes (Deallocation Log (Max Entries, Max Bytes): 100, 50000B).

Otherwise the entry waits for the scavenger, libpas’s background thread. The scavenger can flush a log outright, or put it off if the owning thread flushed it recently. Appending another object doesn’t count as recent use, because only a flush sets the log’s deallocation_log_dirty flag (pas_thread_local_cache.h, lines 61–63 and pas_thread_local_cache.c, lines 1143–1153). Our app reports a 100 ms scavenger period, but that isn’t a deadline for retagging any particular object.

While an entry waits in the log, the freed memory keeps its tag, so a stale pointer to it still matches. libpas hasn’t released the slot yet either, so no new object can be placed there. The stale pointer can reach only the freed object itself.

Bitfit pages have no log. A bitfit free retags while it still holds the page’s lock, and only when it’s freeing, not when it’s shrinking an object:

pas_bitfit_page_inlines.h, lines 608–610 and 887–911 (trimmed)

    case pas_bitfit_page_deallocate_with_page_impl_deallocate_mode:
    case pas_bitfit_page_deallocate_with_page_impl_shrink_mode:
        pas_lock_lock(&owner->ownership_lock);
        // …
        switch (mode) {
        case pas_bitfit_page_deallocate_with_page_impl_deallocate_mode:
            // …
            PAS_MTE_HANDLE(BITFIT_PAGE_DEALLOCATION, page_config, begin, (num_bits << page_config.base.min_align_shift));
            break;
        case pas_bitfit_page_deallocate_with_page_impl_shrink_mode:
            break;
        // …
        }
        // …
        pas_lock_unlock(&owner->ownership_lock);
        break;

For a page that can hold tags, under if (PAS_USE_MTE && PAS_MTE_SHOULD_TAG_PAGE(page_config)), that handler calls pas_mte_retag_freed_region_if_tagged (pas_mte.h, lines 944–953):

pas_mte.h, lines 736–750 (trimmed)

    if (!PAS_MTE_FEATURE_ENABLED(PAS_MTE_FEATURE_RETAG_ON_SCAVENGE))
        return begin;
    uintptr_t tag = begin;
    PAS_MTE_GET_MTAG(tag);
    tag &= PAS_MTE_TAG_MASK;
    // …
    if (tag)
        begin = pas_mte_generate_tag_and_tag_region(begin, size, homogeneity, PAS_MTE_IS_KNOWN_MEDIUM_PAGE(page_config));
    return begin;

With retagging on, the freed memory gets a new tag before free returns. The if (tag) skips memory that was never tagged. The comment cut here explains that libpas keeps every allocation on a tagged page non-zero. Side by side, the two kinds of page differ only right after a free that leaves its entry queued in the log.

A sketch with example tags of one free on each kind of page. A pointer to the object carries tag 5 throughout. Three columns show the object alive, right after free() returns, and later. On a segregated page, free() usually queues the object in the thread's free log, so right after it returns the memory still has tag 5 and the pointer still matches. Once the log is flushed, the memory has tag 9. On a bitfit page, free() writes 9 before it returns, so the pointer stops matching at once. The middle column is the only point where the two pages differ. Hardened processes keep tagged bmalloc allocations on bitfit pages, so the second row is what happens there.

A sketch, with example tags. The middle column is the moment free() returns, the only point where the two pages differ.

So in March 2026 (dee9781145cf), WebKit moved bmalloc’s tagged allocations onto bitfit pages whenever retagging is on:

pas_mte_config.c, lines 238–239 and 395–400 (trimmed)

    if (PAS_MTE_FEATURE_ENABLED(PAS_MTE_FEATURE_RETAG_ON_SCAVENGE))
        pas_bmalloc_force_allocations_into_bitfit_heaps_where_available();
// …
void pas_bmalloc_force_allocations_into_bitfit_heaps_where_available(void)
{
#if PAS_ENABLE_BMALLOC
    // Switch to bitfit allocation for anything that isn't isoheaped.
    tagged_bmalloc_intrinsic_runtime_config.base.max_segregated_object_size = 0;
    tagged_bmalloc_primitive_runtime_config.base.max_segregated_object_size = 0;
    // …

A maximum segregated object size of 0 sends every tagged bmalloc allocation to a bitfit page. In a Release build, retagging is on only in a hardened process, as the process policy below shows.

In our app, a free retagged the memory straight away. We released the middle one of those three wrappers, kept its neighbours alive, and then made new wrappers until one landed at the same address. The first new wrapper did:

Measured in one run of our own app. Three 24-byte string wrappers sit back to back at 0x10e000300, 0x10e000320 and 0x10e000340, each covering two 16-byte granules. Alive, their memory tags are A, 6 and D, matching their pointers. After the middle wrapper is released, its granules are retagged 9, so a pointer still carrying 6 no longer matches. The debugger read only the tags. The next wrapper reuses the same address with tag F and pointer 0x0f0000010e000320. The neighbours keep A and D throughout.

One address, three stages. The neighbours stay A and D throughout.

This is LLDB’s read of the middle wrapper at the second checkpoint, after JSStringRelease returned:

phase=2 index=1 original_pointer=0x060000010e000320
Logical tag: 0x6
Allocation tags:
[0x10e000320, 0x10e000330): 0x9 (mismatch)
[0x10e000330, 0x10e000340): 0x9 (mismatch)

The memory now has tag 9, so a pointer still carrying 6 would no longer match. (mismatch) is LLDB comparing the old pointer’s tag with the memory’s, not a fault. The new wrapper at that address got tag F and a new pointer, 0x0f0000010e000320.

Reusing the tag already in memory, instead of writing a new one at allocation, is a cheaper design for segregated slots, which libpas’s source describes in a long comment on when to write tags (pas_mte.h, lines 645–702): tag only the first object in a slot, retag at every free, and let the next object reuse the tag written at free. That’s safe because, until the next allocation, no pointer carries the new tag, but it needs the next object to be the same size as the last, so bitfit objects, which come in any size, are tagged at both allocation and free. The comment also admits that the segregated retag really happens “on scavenge”, when the log is processed, which is why the feature is called RETAG_ON_SCAVENGE. A workaround added in March 2026, which makes every allocation write a fresh tag, has switched the optimisation off for now. The figure below redraws the comment’s policies, plus the free-log delay, for one slot that holds three objects in turn. Unlike the one above, it’s conceptual, with example tags.

A sketch with example tags. One slot holds objects A, B and C in turn, with empty stretches between them. Each row shows the tag in the slot's memory over time under one policy, and shading marks where a stale pointer would still match. Labels above the strips name each stage: A alive, A freed, B alive, B freed, C alive. With tag on alloc, only allocations write tags, so the freed slot keeps A's 5 until B is allocated with 13, and B's 13 after B is freed, and both stretches are shaded. With retag on free, as designed for segregated slots, each free writes a new tag, 9 and then 3, which the next object keeps, so a stale pointer stops matching at the free. With tagging at both allocation and free, as on bitfit pages in hardened processes today, A's free writes 9 but B can be given 5 again, so A's stale pointer, which carries 5, reaches B. On a segregated slot whose free waits in the thread's free log, the slot keeps 5 until the log is flushed and only then gets 9.

Each row is one way of writing tags into the slot that A, B and C use in turn. The numbers are the tag in the slot’s memory over time, and shading marks where a stale pointer would still match. Hardened processes today follow the third row.

The new tag avoids the old one

Retagging only helps if the new tag differs. An even slot in a segregated page has seven tags to choose from, so a uniform random retag lands on the old tag about one time in seven. The commit that closes this gap puts it at “a ~14% chance of collision”. Previous-tag exclusion (569d28c26a9b, April 2026) reads the tag the memory has now and excludes it:

pas_mte.h, lines 588–600 (trimmed)

    if (PAS_MTE_FEATURE_ENABLED(PAS_MTE_FEATURE_PREVIOUS_TAG_EXCLUSION)) {
        // …
        uintptr_t p = begin;
        PAS_MTE_GET_MTAG(p);
        uint8_t previous_tag = (p & PAS_MTE_TAG_MASK) >> PAS_MTE_TAG_SHIFT;
        valid_tags = pas_mte_exclude_tag(valid_tags, previous_tag);
    }

It reads one granule, the first. In a segregated page the whole object shares that tag, so the new tag differs everywhere. In a bitfit page a new object can cover several old ones, each with its own tag, and only the first granule’s is excluded. The commit is upfront about it, saying the new tag differs from the old one “for a limited definition of ‘old one’”. Checking every old tag would take an expensive ldg loop, and couldn’t always work, because fifteen small objects can use up all fifteen tags.

There’s a second limit that the commits don’t mention, but the code shows. Each write excludes only the tag the memory has at that moment. It doesn’t remember an earlier allocation’s tag. The free-time write avoids the tag the dangling pointer carries. The next allocation’s write avoids the free-time tag, but not the one before it, so the new object can get the dangling pointer’s tag back, as B gets A’s 5 in the figure.

To put a number on it, take a bitfit object in the middle of a page, and suppose:

  • The next object covers exactly the same granules.
  • Its neighbours’ tags don’t change in between.
  • The two neighbours’ tags and the free-time tag are three different values.
  • irg picks uniformly among the tags left.

That leaves 12 tags, one of them the dangling pointer’s, so the new object would match it about one time in 12.

A second program of ours, signed like the first, measures it. It repeats the wrapper experiment over and over. Each cycle frees the middle wrapper and allocates until a new one lands at the same address, so the replacement covers the same two granules beside the same neighbours, exactly the case above. Each cycle records three tags:

Our second program (trimmed, comments ours)

static unsigned memory_tag(uintptr_t addr)
{
    uintptr_t p = addr;
    __asm__ volatile(".arch_extension memtag\n\tldg %0, [%0]" : "+r"(p));
    return TAG(p);
}
// …
    unsigned old_tag = TAG(current);        // what a dangling pointer would carry
    JSStringRelease(current);
    unsigned free_tag = mte ? memory_tag(slot) : 0;   // tag written at free; ldg reads only the tag
    // … allocate until a new wrapper lands at slot
    unsigned new_tag = TAG(fresh);

Across five runs of 100,000 cycles each, the new wrapper got the old pointer’s tag back 41,692 times, or 8.34%. One in 12 is 8.33%. The other rules held in every cycle. The tag written at free never matched the old one, and the new tag never matched it or either neighbour’s. Every allowed tag came up about equally often.

Here’s the first time the old tag came back, in cycle 17 of the first run, and how often it happened across that run:

Measured in our test app, run 1 of 5. Part 1, cycle 17 step by step, with neighbours 9 and E throughout. The old wrapper is alive with tag C, it is freed and libpas writes tag 2, and the new wrapper at the same address gets C again. Of the 16 tag values, four are ruled out for the new wrapper: 0, which is never used, 2, written at free, and 9 and E, the neighbours. That leaves 12, including C, so C comes back about one time in 12. Part 2, all 100,000 cycles of run 1 as one bar. 8,379 cycles, 8.4%, got the old tag back, and 91,621 got another allowed tag. A mark at one in 12 shows the prediction, which falls at the same place.

Cycle 17 step by step, then all 100,000 cycles of run 1.

Measured by reading tags alone, on one machine, build and layout, this is how often a stale access would go unnoticed.

So each rule has a limit:

  • Neighbour exclusion catches an access that runs into a neighbouring object, not one that skips past it.
  • When freed memory gets its new tag depends on the page. Bitfit pages retag before free returns, while segregated pages usually wait for the free log.
  • Previous-tag exclusion avoids only the tag the memory holds now, so a new object can get an older tag back, about one time in 12 in our layout.

Getting these rules right took most of a year, and two of them first landed with bugs. That’s a story for part 4.

The coverage

Whether an allocation lands in tagged memory comes down to which heap it goes to. When libpas has MTE on for a process, non-compact fastMalloc and TZone allocations up to 32 KB go to a tagged heap of their own, which in hardened processes uses only bitfit pages. When libpas has it off, the same calls get untagged memory, even if the operating system turned MTE on for the process. That’s what we saw in WebContent.

Where a fastMalloc call goes

fastMalloc asks bmalloc for a non-compact allocation, bmalloc::api::malloc(size, bmalloc::CompactAllocationMode::NonCompact) (FastMalloc.cpp, lines 341–345), and bmalloc decides where it goes:

bmalloc.h, lines 133–144

BINLINE void* malloc(size_t size, CompactAllocationMode mode, HeapKind kind = HeapKind::Primary)
{
#if BUSE(LIBPAS)
    if (isGigacage(kind))
        return bmalloc_allocate_auxiliary_inline(&heapForKind(gigacageKind(kind)), size);
    if (mode == CompactAllocationMode::Compact)
        return bmalloc_allocate_auxiliary_inline(&bmalloc_compact_primitive_heap_ref, size);
    if (!isMTEEnabled()) [[likely]]
        return bmalloc_allocate_inline(size);
    if (size > PAS_MAX_MTE_TAGGABLE_OBJECT_SIZE) [[unlikely]]
        return bmalloc_allocate_inline(size);
    return tagged_bmalloc_allocate(size);

Flowchart of bmalloc's malloc in the order the code checks it. A Gigacage heap kind goes to its Gigacage heap, untagged. A compact allocation goes to the compact heap, untagged. If libpas hasn't enabled MTE for the process, the call goes to the untagged primitive heap, even if the operating system enabled MTE for the process. A size over 32,768 bytes also goes to the primitive heap, and hardened processes may hand it to the system malloc. Everything else goes to tagged_bmalloc_heap and is tagged: in hardened processes on bitfit pages under the tagging rules, otherwise with any non-zero tag.

Every untagged exit comes before the tagged heap, in the order the code checks.

Part 1 showed the same checks in tryMalloc. The one that matters here is the third, whether libpas has enabled MTE for this process. That’s libpas’s own decision, made once at start-up, and it’s separate from whether the operating system runs the process with MTE.

One small object from our app shows two of those exits at once. The app’s string wrappers are ordinary 24-byte fastMalloc calls, while the strings they point to are compact. In the public source, the wrapper’s call is a trip to the tagged heap. The shipped build names its heaps differently, so we can’t say which one served them, only that they came back tagged. Here’s one, alive, as LLDB read it:

One live JSStringRef wrapper at 0x10e000320, measured in our own app. Its pointer, 0x060000010e000320, carries tag 6. The 24-byte request covers two 16-byte granules, both with allocation tag 6. The last 8 bytes of the second granule are unused, outside the request and not read. The live words are a reference count of 1 in the low 32 bits of the first word, then a StringImpl pointer 0x0000000112000230 and a character pointer 0x0000000112000244, both with tag bits 0. The tags sit in separate tag storage, not in these bytes. The two stored pointers lead outside the wrapper, to the StringImpl and the characters.

One wrapper: a 24-byte request, two granules, and the same tag in the pointer and on its memory.

The wrapper is tagged, but what it points to isn’t reached through tagged pointers. Its pointer and both of its granules carry 6. The figure also shows the 8 unused bytes past the 24 requested, part 1’s unused bytes. The two pointer fields inside have tag bits 0. They lead to the string’s StringImpl and its characters, outside the wrapper, and StringImpl asks for compact memory:

StringImpl.h, lines 180–183

DECLARE_COMPACT_ALLOCATOR_WITH_HEAP_IDENTIFIER(StringImpl);
class StringImpl : private StringImplShape, public NoVirtualDestructorBase {
    WTF_MAKE_NONCOPYABLE(StringImpl);
    WTF_DEPRECATED_MAKE_FAST_COMPACT_ALLOCATED_WITH_HEAP_IDENTIFIER(StringImpl, StringImpl);

The first line makes StringImplMalloc, the allocator that string creation uses, an alias of FastCompactMalloc in normal builds (DebugHeap.h, lines 134–135), and the last gives the class a compact operator new. Either way the request takes the compact exit in the code above. Those zeros are the tag the pointers carry. For a pointer in use, the memory it leads to should carry the same tag, or every checked access through it would fail. But a pointer’s bits show only the pointer’s tag, so a third small program of ours reads the memory’s tag too. For a string wrapper, that string’s characters and a 16-byte typed array’s buffer, it reads the pointer’s tag, the memory’s tag (with ldg) and vmmap’s region for the same address, all in one process at one moment:

string wrapper
  pointer 0x080000011300e910: pointer tag 8, memory tag 8
  region: WebKit Malloc               111498000-113498000    [ 32.0M   208K   208K     0K] rw-/rw- SM=PRV  MTE                   WebKit Malloc_0x1094a6288
string characters
  pointer 0x000000011700d4c4: pointer tag 0, memory tag 0
  region: WebKit Malloc               115498000-117498000    [ 32.0M   512K   512K     0K] rw-/rwx SM=COW                        WebKit Malloc_0x1094a6288
typed-array buffer
  pointer 0x0000000113010110: pointer tag 0, memory tag 0
  region: WebKit Malloc               111498000-113498000    [ 32.0M   208K   208K     0K] rw-/rw- SM=PRV  MTE                   WebKit Malloc_0x1094a6288

The wrapper is tagged, 8 in both the pointer and the memory, in a region vmmap marks MTE. The characters have tag 0 on both sides, in one of libpas’s WebKit Malloc regions without the MTE mark, which can’t hold tags at all. The typed-array buffer is the odd one out, with tag 0 on both sides but in the same MTE region as the wrapper. As at the start of this part, a region that can hold tags doesn’t give every object in it a non-zero tag.

Zero is still a tag value, though. A checked access compares the tag in the address with the tag in memory (Arm’s MTE whitepaper), and the architecture’s only way to exempt tag 0 is a “match-all” control the operating system has to switch on, TCMA0 in TCR_EL1, which leaves user pointers with tag 0 unchecked. The published macOS 26 kernel never sets it. Its MTE setup changes other TCR_EL1 bits, but not TCMA0 (proc_reg.h, lines 1028–1033 and 1138–1144). Going by that source, accesses to the buffer are checked like any other and pass, because both tags are 0.

Large objects go elsewhere

Since December 2025 (54b588aa233a), hardened processes can hand large allocations that libpas can’t tag to the system allocator, whose hardening the commit says libpas doesn’t currently match. Part 1 covered the same commit’s 32 KB constant. libpas’s large heap decides per allocation:

pas_large_heap.c, lines 192–205

static bool should_delegate_user_allocation_to_system_malloc(pas_large_heap* heap,
                                                             size_t size,
                                                             const pas_heap_config* heap_config)
{
    if (size < PAS_MAX_MTE_TAGGABLE_OBJECT_SIZE)
        return false;
    if (!heap_config->delegate_large_user_allocations)
        return false;
    /* A heap confined to a client-supplied reservation should not delegate to malloc,
       as that would return memory from outside the reservation. */
    if (pas_heap_for_large_heap(heap)->segregated_heap.runtime_config->is_in_reserved_memory)
        return false;
    return large_object_delegation_is_enabled(heap_config);
}

Only allocations of 32 KB or more, from heaps configured to delegate and not confined to a reserved region, qualify. The last check needs MTE on and the process hardened (pas_mte_config.h, line 77), plus the system heap enabled (pas_large_heap.c, lines 54–62). Elsewhere, large allocations stay in libpas, untagged.

Once they’re in the system allocator, larger allocations can get a different protection, guard objects. The system allocator, xzone malloc, groups them into chunks of equal-sized slots and keeps some of those slots inaccessible, moving the guards around as objects come and go. A bad access that lands in a guarded slot faults. Apple’s design applies this above 32 KB, where its tag-based coverage stops. It’s probabilistic, and a freed slot isn’t always turned into a guard straight away, for example while it’s held in quarantine (xzone_malloc.c, lines 1013–1053).

TZone steps aside

WebKit’s TZone allocator keeps objects of different types apart in separate heaps. In March 2026, 174feea3cd59 began replacing it with fastMalloc wherever libpas enables MTE. The commit argues that, against the attacks TZone defends, MTE’s guarantees are “strictly stronger”, because tags also catch corruption between objects of the same type. It considered keeping both and rejected that, because it would make type separation depend on allocator behaviour that could change without an obvious failure.

The part that makes the change process-wide “was accidentally omitted”, and landed only on 14 September 2026 (01f33be1d1f9):

TZoneHeapManager.cpp, lines 126–128

    if (isMTEEnabled()) {
        tzoneMallocFallback = TZoneMallocFallback::ForceFastMalloc;
        return;

A heap of its own

Today, tagged and untagged allocations come from separate heaps, tagged_bmalloc_heap for the first and the ordinary bmalloc heap for the second. Before, one shared bmalloc heap served both. Memory that can hold tags has to be mapped that way from the start, so that heap needed two kinds of memory, and libpas kept compact and non-compact versions of its internal structures just to keep them apart (c8cb35beacac). The commit that created the new heap (3f04665f12e8) sets the goal that each heap maps only one kind of memory.

The split needed two things. First, each heap configuration got its own tagging switch, allow_mte_tagging, so a heap is tagged or untagged by design rather than through exceptions (133a8c3c2301). Today only the tagged heap sets it (pas_mte.h, lines 861–865). The same commit dropped the old small/medium distinction, pointing to guard objects, the system allocator’s mechanism above, for larger allocations.

libpas’s start-up report shows the difference. The second number on each line says whether that kind of page can hold tags. In the shipped build, the one bmalloc configuration has pages of both kinds (trimmed, with tabs shown as two spaces):

  Heap-Config bmalloc:
    Page Configs (Enabled/MTE Taggable, Static Max Obj Size):
      Small Segregated: 1/1, 1472B
      …
      Medium Bitfit : 1/1, 32768B
      Marge Bitfit : 1/0, 380928B

We also built the public source ourselves. With the public SDK, libpas’s MTE code is switched off altogether, because it needs two headers only Apple’s internal SDK has. pas includes minimal versions of both, so the source can be built with MTE on (how). In that build, bmalloc has no taggable pages and tagged_bmalloc has them all:

  Heap-Config bmalloc:
    Page Configs (Enabled/MTE Taggable, Static Max Obj Size):
      Small Segregated: 1/0, 1472B
      …
      Marge Bitfit : 1/0, 380928B
  Heap-Config tagged_bmalloc:
    Page Configs (Enabled/MTE Taggable, Static Max Obj Size):
      Small Segregated: 1/1, 1472B
      …
      Medium Bitfit : 1/1, 32768B
      Marge Bitfit : 0/0, 0B

Marge pages, libpas’s word for medium-large, never hold tags in either build, and the tagged heap doesn’t have them at all.

Second, checking whether MTE is enabled had to be cheap enough to do on every allocation. The setting became a three-state value that the fast path reads with a single load, instead of calling into a slower initialisation check (86f52ba2f2c1). Today PAS_USE_MTE is just PAS_RUNTIME_CONFIG_PTR->mte_state == pas_mte_state_enabled (pas_mte_config.h, line 70).

The commit that switched the tagged heap on (c8cb35beacac) reports its own costs: about 0.3% on Speedometer 3, from dispatching frees across two heaps, and inline fastMalloc shrinking from 141 to 73 instructions. We haven’t tried to reproduce either number.

The process policy

Whether a process’s allocations end up tagged, and what a failed check does, comes down to four separate decisions, made by the operating system and by libpas. In the WebContent we looked at, the operating system said yes and libpas said no. Here they are for each of Safari’s processes:

Process MTE Tags Rules On fail
Safari on yes yes soft
WebContent on no¹ no soft
WebContent
Lockdown Mode
on yes yes hard
WebContent
EnhancedSecurity
on yes yes ?
GPU on yes yes hard
Networking on yes yes hard

Plain entries are what we observed on the M6 running macOS 27.0.1. Italic entries are what the public source says, not observed. A ? is untested.

¹ Built from the public source, libpas tags ordinary WebContent by default, as a jsc shell built that way showed. The shipped build doesn’t.

  • MTE is the kernel’s SEC flag, set when the process runs with MTE.
  • Tags is libpas’s own per-process decision, visible as MTE on its WebKit Malloc regions.
  • Rules are libpas’s hardened rules: neighbour, retag and previous-tag.
  • On fail is what a failed check does, soft (reported) or hard (fatal), from the SEC_BYPASS flag.

The rest of this section explains where each column comes from.

What libpas decides

libpas starts from the same flag we read at the start of this part. Without it, libpas switches tagging off for the whole process:

pas_mte_config.c, lines 135–149 (trimmed)

    int rc = proc_pidinfo(getpid(), PROC_PIDTBSDINFO, 0, &info, sizeof(info));
    if (rc == sizeof(info) && info.pbi_flags & PAS_MTE_PROC_FLAG_SEC_ENABLED)
        enabled = true;
    // … environment-variable overrides of enabled
    if (!enabled) {
        pas_atomic_store_uint8(&config->mte_state, pas_mte_state_disabled);
        return;
    }

The kernel sets that flag, using the program’s signature as input. The next section comes back to that. With the flag set, libpas then decides, by process name, whether to tag this process’s allocations and whether it’s hardened:

pas_mte_config.c, lines 173–214 (trimmed)

    bool isWebContentProcess = !strncmp(name, "com.apple.WebKit.WebContent", 27) || !strncmp(name, "jsc", 3);

    if (isWebContentProcess) {
        // …
        bool wcp_is_hardened = false;
        bool isEnhancedSecurityWebContentProcess = !strncmp(name, "com.apple.WebKit.WebContent.EnhancedSecurity", 44);
        if (config->is_lockdown_mode || isEnhancedSecurityWebContentProcess)
            wcp_is_hardened = true;

        if (wcp_is_hardened) {
            enabled = true;
            config->is_hardened = true;
            // …
        } else {
            config->is_hardened = false;
#if !PAS_USE_MTE_IN_WEBCONTENT
            // Disable tagging in libpas by default in WebContent process
            enabled = false;
#else
            enabled = true;
#endif
        }
        // … environment-variable overrides of enabled
    } else {
        config->is_hardened = true;
    }

Every process that isn’t WebContent (or a jsc shell) is hardened, which fits what libpas’s diagnostic reported for our test app. Ordinary WebContent never is, and whether it’s tagged at all comes down to PAS_USE_MTE_IN_WEBCONTENT, which the public source sets to 1 only under #ifndef PAS_USE_MTE_IN_WEBCONTENT, that is, unless something has already defined it (pas_mte_config.h, lines 71–73). The shipped WebContent at the start of this part wasn’t tagged. A build-time override to 0 would explain that, and the public header allows one, including through <WebKitAdditions/pas_mte_additions.h>, which it includes before the default if that private header exists (lines 36–43). We haven’t established which setting this shipped build uses. In our build of the public source, a jsc shell, which libpas groups with WebContent by its name, reported tagging on and the process not hardened. Lockdown Mode and EnhancedSecurity WebContent are tagged and hardened.

“Hardened” is what switches the rules on, through the PAS_MTE_FEATURE_ENABLED checks in the excerpts above:

pas_mte_config.h, lines 111–146 (trimmed)

// ATE and PTE are always enabled together
#define PAS_MTE_FEATURE_ADJACENT_TAG_EXCLUSION 5
#define PAS_MTE_FEATURE_PREVIOUS_TAG_EXCLUSION PAS_MTE_FEATURE_ADJACENT_TAG_EXCLUSION
// …
#define PAS_MTE_FEATURE_FORCED(feature) (0)
#define PAS_MTE_FEATURE_HARDENED_FORCED(feature) (feature == PAS_MTE_FEATURE_ADJACENT_TAG_EXCLUSION || feature == PAS_MTE_FEATURE_RETAG_ON_SCAVENGE)
// …
#define PAS_MTE_FEATURE_FORCED_IN_RELEASE_BUILD(feature) \
    (PAS_MTE_FEATURE_FORCED(feature) || \
     (PAS_MTE_FEATURE_HARDENED_FORCED(feature) && PAS_MTE_IS_HARDENED))

#define PAS_MTE_FEATURE_FORCED_IN_DEBUG_BUILD(feature) \
    (PAS_MTE_FEATURE_FORCED_IN_RELEASE_BUILD(feature) || \
     PAS_MTE_FEATURE_DEBUG_FORCED(feature) || \
     PAS_MTE_FEATURE_BIT(feature))

#ifndef NDEBUG
#define PAS_MTE_FEATURE_ENABLED(feature) (PAS_USE_MTE && PAS_MTE_FEATURE_FORCED_IN_DEBUG_BUILD(feature))
#else
#define PAS_MTE_FEATURE_ENABLED(feature) (PAS_USE_MTE && PAS_MTE_FEATURE_FORCED_IN_RELEASE_BUILD(feature))
#endif

In a Release build, a feature is on only if it’s forced, and only two are: adjacent-tag exclusion and retag-on-scavenge, in a hardened process. Only debug builds also consult PAS_MTE_FEATURE_BIT, the runtime override bits in config->mode_bits (lines 118–127). Previous-tag exclusion (PTE in the comment) is defined as the same feature as adjacent-tag exclusion (ATE), so all three rules follow PAS_MTE_IS_HARDENED. The EnhancedSecurity variant has counted as hardened since October 2025 (6390f36dce31), and apps get it through WebKit’s securityRestrictionMode setting.

Lockdown Mode has a catch. From the first commit until 2026, libpas detected it with a sysctl, security.mac.lockdown_mode_state, that according to the fix “was always returning 0 due to a sandbox restriction” inside WebContent (1b1e4d08c01c). The fix also matches the process name com.apple.WebKit.WebContent.CaptivePortal, the variant WebKit uses for Lockdown Mode. Today either one sets config->is_lockdown_mode (pas_mte_config.c, lines 151–158). It landed on a Safari release branch on 23 January 2026 (ed3e2dcf0010) and on main on 23 April. Until the fix, a missed detection sent Lockdown Mode’s WebContent down the ordinary WebContent path. With the public default, that meant tagging without the hardened rules. In a build that turns ordinary WebContent tagging off, it meant no tags at all.

Entitlements

Entitlements are settings carried in a program’s code signature. WebKit’s build script (process-entitlements.sh) gives WebContent, GPU, Networking and webpushd two hardened-process keys. On macOS the second only comes with macOS 26, as the WebContent part shows:

process-entitlements.sh, lines 234–238

        plistbuddy Add :com.apple.developer.hardened-process bool YES
        if (( "${TARGET_MAC_OS_X_VERSION_MAJOR}" >= 260000 ))
        then
            plistbuddy Add :com.apple.security.hardened-process.checked-allocations.no-tagged-receive bool YES
        fi

Despite the names, neither is libpas’s “hardened”.

Apple and WebKit describe no-tagged-receive in different words:

  • Apple’s documentation says it can “prevent receiving tagged memory from other processes”, for processes that run arbitrary code, such as interpreters and JITs.
  • WebKit’s commit (1b4dd17f517a) describes it as marking the process “as never receiving untagged aliases to MTE memory”.

Apple’s published kernel source shows what they’re both describing. When one process’s tagged memory is shared with another, the kernel normally maps it there without MTE. For a process with no-tagged-receive, the fault handler refuses that mapping with a fatal guard exception instead, and its comment names WebContent (“WCP”) among the processes it’s for:

vm_fault.c, lines 3702–3725 (trimmed)

if (vm_should_override_mte_cacheattr(pmap, obj, virtual_address, physical_address)) {
    /*
     * Certain first-party actors (such as WCP and BlastDoor) are modeled untrustworthy, and should never
     * be allowed to receive untagged aliases to tagged memory from other actors.
     * If we make it this far on a pmap that should never receive untagged aliases, throw a fatal guard.
     */
    if (pmap->restrict_receiving_aliases_to_tagged_memory) {
        // …
        EXC_GUARD_ENCODE_FLAVOR(code, kGUARD_EXC_SEC_SHARING_DENIED);
        // …
        return VM_FAULT_MEMORY_ERROR;

The entitlement switches that on when the process starts, through task_set_sec_restrict_receiving_aliases_to_tagged_memory (kern_exec.c, lines 1705–1714). This is the published macOS 26 kernel, as is the rest of the kernel code in this section. Apple hasn’t published the macOS 27 kernel we ran, and we haven’t tested whether it refuses these mappings too.

Apple’s documentation also lists the base checked-allocations entitlement as a requirement, and the public script gives it to none of those four processes. Yet WebContent, GPU and Networking all ran with MTE on at the start of this part. The kernel turns MTE on for Apple’s own programs without it:

kern_exec.c, lines 1353–1367 (trimmed)

if (load_result->platform_binary) {
#if KERN_AMFI_SUPPORTS_MTE >= 2
    if (__improbable(amfi->has_mte_opt_out && amfi->has_mte_opt_out(cs_blob))) {
        // …
        return EXEC_SECURITY_NOT_ENTITLED;
    }
#endif /* KERN_AMFI_SUPPORTS_MTE */

    /*
     * At this stage we are an hardened-process and AMFI hasn't said that we should
     * not enable MTE, therefore we just force enable even if the entitlement is not
     * present (until we can publicly require checked-allocations to be true).
     */
    return EXEC_SECURITY_ENTITLED;
}

WebContent, GPU and Networking are Apple platform binaries with a hardened-process entitlement, so unless AMFI, the system’s code-signing enforcer, has them on its opt-out list, the kernel enables MTE for them. That list isn’t public.

Soft mode is an entitlement too. The script gave most processes checked-allocations.soft-mode from March to June 2026, while retagging went live (bdbe48f72fba, 8d85f545cea5), and webpushd got it in September (57709f26206d). On the M6, Safari’s signature asks for soft mode, and WebContent’s doesn’t. Here is the codesign output, trimmed to the relevant keys. An … marks cut paths and lines, and WebContent’s full output has no soft-mode key:

$ codesign -d --entitlements - …/Safari.app/Contents/MacOS/Safari
	…
	[Key] com.apple.security.hardened-process.checked-allocations
	[Value]
		[Bool] true
	[Key] com.apple.security.hardened-process.checked-allocations.soft-mode
	[Value]
		[Bool] true
	…
$ codesign -d --entitlements - …/com.apple.WebKit.WebContent.xpc/Contents/MacOS/com.apple.WebKit.WebContent
	…
	[Key] com.apple.developer.hardened-process
	[Value]
		[Bool] true
	…
	[Key] com.apple.security.hardened-process.checked-allocations.no-tagged-receive
	[Value]
		[Bool] true
	…

Yet WebContent runs in soft mode. That’s the SEC_BYPASS bit in the flags at the start of this part, and the kernel sets it only for a process in soft mode:

proc_info.c, lines 772–779

#if HAS_MTE || HAS_MTE_EMULATION_SHIMS
if (task_has_sec(task)) {
    pbsd->pbi_flags |= PROC_FLAG_SEC_ENABLED;
    if (task_has_sec_soft_mode(task)) {
        pbsd->pbi_flags |= PROC_FLAG_SEC_BYPASS_ENABLED;
    }
}
#endif /* HAS_MTE || HAS_MTE_EMULATION_SHIMS */

In soft mode, a tag mismatch is reported instead of killing the process. Networking and GPU run in hard mode. The entitlement isn’t the only way in. The kernel also asks AMFI whether a process with MTE should run in soft mode, unless Lockdown Mode is on:

kern_exec.c, lines 4185–4196 (trimmed)

if (task_has_sec(task)) {
    // …
#if KERN_AMFI_SUPPORTS_MTE
    if (get_lockdown_mode_state() == 0 &&
        amfi->has_mte_soft_mode &&
        amfi->has_mte_soft_mode(p)) {
        // …
        task_set_sec_soft_mode(task);
    }
#endif /* KERN_AMFI_SUPPORTS_MTE */
}

AMFI’s policy isn’t public, and this source is for macOS 26, but it’s the likely route for WebContent. So to find a process’s mode, read its flags, as we did at the start of this part, not its entitlements.

Finally, main isn’t what ships. A patch that exists only on Safari release branches (6d6a960dac02, June 2026) turned on retag-on-free and soft mode together, “sans xrOS”. It says it “CANNOT be taken for any actual customer release” unless soft mode is later turned off, and describes soft mode as crashing “once per process, after which MTE would be disabled”.

Putting it together

“MTE is on” covers less than it sounds. Where libpas tags, non-compact allocations up to 32 KB get tags. In hardened processes they live on bitfit pages, where all three rules apply and a free retags at once, though in our measurement a new object still got an old tag back about one time in 12. Larger allocations may go to the system allocator instead. The shipped WebContent we looked at doesn’t tag WebKit’s allocations at all, and compact-pointer allocations stay untagged in every process. Part 3 looks at how WebKit hardens those compact objects.

Key commits

Date Commit What
2025-09-24 d3acbe4f9624 Adjacent-tag exclusion by parity, in the first commit
2025-10-22 6390f36dce31 EnhancedSecurity WebContent treated as hardened
2025-12-05 e698dda8251b Adjacent-tag exclusion for bitfit pages
2025-12-05 54b588aa233a Large allocations can go to the system allocator
2026-03-05 b754353fb35e Tagged allocations tagged on every allocation
2026-03-16 bdbe48f72fba Retagging on in hardened processes, with soft-mode entitlements
2026-03-19 dee9781145cf bmalloc’s tagged allocations moved to bitfit pages
2026-03-24 174feea3cd59 TZone replacement begins where MTE is on
2026-04-07 569d28c26a9b Previous-tag exclusion
2026-04-23 1b1e4d08c01c Lockdown Mode detection fixed on main (branch: January)
2026-06-11 3f04665f12e8 tagged_bmalloc_heap created, unused
2026-06-30 8d85f545cea5 Soft-mode entitlements dropped from the build script
2026-07-17 133a8c3c2301 Tagging switch per heap configuration
2026-09-10 86f52ba2f2c1 MTE check becomes a single load on the fast path
2026-09-14 01f33be1d1f9 TZone replaced process-wide when MTE is on
2026-09-29 c8cb35beacac Tagged heap switched on

Next: part 3, Filling the gaps. What WebKit hardens by hand where tags can’t reach.