Catching Use-After-Free Without Valgrind: A Practical Look at ARM64 MTE
ARM64's Memory Tagging Extension catches out-of-bounds and use-after-free bugs in hardware. Here's how it works, how to verify it's actually enabled, and how to choose between sync and async modes.
11 Nov 2025, 06:37 UTC

The bug that only crashes on Tuesdays
Every C or C++ codebase of any age has one: a heap corruption bug that passes review, passes tests, and detonates in production weeks later. Tools like AddressSanitizer catch these in CI, but ASan's roughly 2x slowdown and large memory overhead make it impractical to run on production traffic or on constrained devices. Valgrind is worse — often 20x or more.
ARM's Memory Tagging Extension (MTE), introduced with the ARMv8.5-A architecture, offers a different trade: let the hardware do the checking. The thesis of this post is simple — if your target hardware supports it, MTE is the cheapest way to get continuous spatial and temporal memory-error detection in builds you actually ship, not just in test harnesses.
How MTE works: tags on pointers, tags on memory
MTE assigns a 4-bit tag to every 16-byte chunk of memory, called a granule. Separately, it uses the otherwise-unused top byte of a 64-bit pointer (Top Byte Ignore, or TBI, makes this safe) to carry a matching 4-bit "lock" value in the pointer itself. On every load and store, the hardware compares the pointer's tag against the granule's tag. Mismatch means fault.
Two classic bug classes fall out of this naturally:
- Spatial errors (out-of-bounds). Adjacent allocations get different tags. A pointer that walks past the end of a buffer lands in a granule with a different tag and faults on access.
- Temporal errors (use-after-free). When memory is freed, the allocator re-tags those granules. Any stale pointer still carrying the old tag now mismatches and faults on dereference.
Because the check happens in the memory pipeline, the cost is far below software instrumentation — though not zero, as we'll see.
Checking whether you can use it
MTE is optional in ARMv8.5-A and later, so "aarch64" does not imply MTE. Before building anything, verify support on the target machine. From a shell on the device:
grep -o 'mte' /proc/cpuinfoProgrammatically, in a small C program run on the target:
#include <sys/auxv.h>
#include <asm/hwcap.h>
if (getauxval(AT_HWCAP2) & HWCAP2_MTE) {
/* hardware + kernel expose MTE */
}You need three layers aligned: silicon with MTE, a kernel that enables tag storage and fault delivery, and a toolchain (recent LLVM/Clang or GCC) plus an allocator that actually tags memory — for example Android's Scudo allocator or glibc with MTE support enabled. Compiler flags such as -march=armv8.5-a+memtag enable codegen, but the allocator is what assigns tags at runtime. A mismatch anywhere in that chain silently gives you no protection, so verify with a deliberate bug rather than assuming.
A worked example: forcing a detectable use-after-free
The most reliable way to confirm MTE is live is to write the bug on purpose. Compile this on (or for) the MTE-capable target:
#include <stdlib.h>
#include <string.h>
int main(void) {
char *p = malloc(32);
strcpy(p, "hello");
free(p); /* granules re-tagged here */
p[0] = 'X'; /* stale tag: should fault */
return 0;
}Build and run it on the target device (commands need only normal user permissions):
clang -march=armv8.5-a+memtag -fsanitize=memtag -g uaf.c -o uaf
./uafOn a correctly configured system, the process dies with a signal (typically SIGSEGV with a tag-check fault code visible via dmesg or a debugger) at the write through p. If it exits cleanly, MTE is not actually active — check the allocator configuration and kernel boot parameters before trusting any further results. Do not treat a clean exit as "the bug didn't trigger"; treat it as "the check isn't on." Running the same binary under gdb lets you inspect the fault address and confirm it was a tag mismatch rather than an ordinary page fault.
Synchronous vs asynchronous: the real engineering decision
MTE offers two reporting modes, and choosing between them is the main design call:
| Mode | Behavior | Cost | Best for |
|---|---|---|---|
| Synchronous | Precise exception at the faulting instruction | Higher; checks stall the pipeline | Debug and canary builds, fuzzing infrastructure |
| Asynchronous | Mismatch recorded, reported later (imprecise) | Lowest overhead | Production telemetry: you learn a bug exists, roughly where |
A pragmatic rollout is sync mode in CI and internal dogfood builds (where a precise crash report is gold), async mode for a sampled percentage of production users. Async won't pinpoint the instruction, but it converts silent corruption into an actionable signal at near-production performance.
Limitations worth knowing before you commit
- 4-bit tags mean 16 values. A wild pointer has roughly a 1-in-16 chance of carrying a tag that matches its target granule by luck. MTE is probabilistic detection, not a proof of safety.
- 16-byte granularity. Overflows smaller than a granule that stay within the same tag region are invisible. Small intra-object overflows in particular can slip through.
- Memory overhead. Tags consume extra memory (on the order of a few percent) for tag storage.
- Hardware availability. As of this writing, MTE is present in only some shipping cores (certain Pixel devices and server parts, for example). Verify per-device; this is the item most likely to have changed, so check current vendor documentation before planning around a specific chip.
Where to start this week
Pick one MTE-capable device, confirm HWCAP2_MTE, and run the deliberate use-after-free above. If it faults, enable async mode for one internal build of your real application and let it run for a week. The first tag-mismatch report from code you thought was clean is usually all the convincing a team needs — and if nothing fires, you've at least established a verified detection path for the day something does.
0 replies
A thoughtful contribution can make all the difference. Be the first to share one.