{"uuid": "db3bc8af-4fc0-472d-8988-b8e86489cc40", "vulnerability_lookup_origin": "1a89b78e-f703-45f3-bb86-59eb712668bd", "author": "9f56dd64-161d-43a6-b9c3-555944290a09", "vulnerability": "CVE-2023-53254", "type": "seen", "source": "https://gist.github.com/yskzalloc/5acfdef88e6354dc047604c9883faa8b", "content": "# Userspace reproducer: slab-out-of-bounds write in `populate_cache_leaves()`\n\nA `write(2)` to `/sys/devices/system/cpu/cpuN/online` makes the kernel perform\nan 8-byte out-of-bounds write in\n`arch/x86/kernel/cpu/cacheinfo.c:__cache_cpumap_setup()`.\n\nThis directory contains two programs:\n\n| program | needs root | what it does |\n|---|---|---|\n| `cacheinfo-oob-predict` | no | Reads CPUID on every online CPU, replays the kernel's own sibling test, and says whether this machine can hit the out-of-bounds write. Writes nothing. |\n| `cacheinfo-oob-trigger` | yes | Brings a CPU online, so the buggy path runs, and captures the KASAN report from `/dev/kmsg`. Prints the predicted offsets *before* triggering, then compares them with what KASAN reports. |\n\n```\nmake            # or: make static, for dropping into a guest image\n./cacheinfo-oob-predict\nsudo ./cacheinfo-oob-trigger --all\n```\n\nExit codes: `cacheinfo-oob-predict` returns 0 affected, 1 not affected,\n2 indeterminate (some CPU is offline, so its leaf count cannot be read yet).\n`cacheinfo-oob-trigger` returns 0 reproduced, 1 not reproduced, 2 nothing left\nto try, 3 usage error.\n\n## The bug\n\n`__cache_cpumap_setup()` addresses a *sibling's* cacheinfo array with *this*\nCPU's leaf index:\n\n```c\n\tfor_each_online_cpu(i)\n\t\tif (cpu_data(i).topo.apicid &gt;&gt; index_msb == c-&gt;topo.apicid &gt;&gt; index_msb) {\n\t\t\tstruct cpu_cacheinfo *sib_cpu_ci = get_cpu_cacheinfo(i);\n\n\t\t\t/* Skip if itself or no cacheinfo */\n\t\t\tif (i == cpu || !sib_cpu_ci-&gt;info_list)\n\t\t\t\tcontinue;\n\n\t\t\tsibling_ci = sib_cpu_ci-&gt;info_list + index;\t\t/* &lt;-- */\n\t\t\tcpumask_set_cpu(i, &amp;ci-&gt;shared_cpu_map);\n\t\t\tcpumask_set_cpu(cpu, &amp;sibling_ci-&gt;shared_cpu_map);\t/* &lt;-- OOB */\n\t\t}\n```\n\nThe only guard is \"does the sibling have an array at all\". Nothing checks\n`index &lt; sib_cpu_ci-&gt;num_leaves`. Since commit 9677be09e5e4 (\"x86/cacheinfo:\nDelete global num_cache_leaves\") the leaf count is per-CPU, so a CPU selected\nby the APIC-ID test can have a shorter array than the CPU indexing it, and the\n`cpumask_set_cpu()` writes past its end.\n\nThe generic implementation was fixed for exactly this in\n`drivers/base/cacheinfo.c:cache_shared_cpu_map_setup()` (CVE-2023-53254): it\nwalks the *sibling's* own leaf count and matches on level and type. The x86\nimplementation was not updated.\n\n## Why a userspace program can reach it\n\n`populate_cache_leaves()` is only called from `cacheinfo_cpu_online()`, the\n`CPUHP_AP_BASE_CACHEINFO_ONLINE` callback. That callback runs on every CPU\nthat comes online \u2014 during boot, and equally when userspace writes `1` to\n`/sys/devices/system/cpu/cpuN/online`. So the path is a plain syscall away.\n\nTwo properties of the path shape the reproducer:\n\n1. **It is a first-online path.** Offlining and re-onlining a CPU does not\n   re-enter it: `free_cache_attributes()` only clears the shared maps and never\n   frees `info_list`, so `last_level_cache_is_valid()` stays true and\n   `detect_cache_attributes()` skips `populate_cache_leaves()` and goes\n   straight to the generic (fixed) `cache_shared_cpu_map_setup()`. The buggy\n   x86 code therefore runs once per CPU per boot.\n\n2. **Direction matters.** The fault needs the CPU with *more* leaves to come up\n   while a CPU with *fewer* leaves is already online, so that its loop reaches\n   an index the shorter array does not have. The reverse order is harmless.\n\nHold a CPU back from boot with `maxcpus=`, and userspace controls that order.\nThat turns a boot-time race into a deterministic, on-demand reproducer:\n\n```\nboot with maxcpus=1\necho 1 &gt; /sys/devices/system/cpu/cpu1/online     &lt;- the out-of-bounds write\n```\n\n## Reproducing it\n\nThe precondition is two CPUs that enumerate different numbers of CPUID leaf 4\nsubleaves while the APIC-ID test still treats them as cache siblings. Run\n`cacheinfo-oob-predict` to find out whether a given machine has it; it prints\nthe leaf counts, APIC IDs, `num_threads_sharing`, `index_msb` and the resulting\nsibling relation.\n\n### Under crosvm on a hybrid host \u2014 deterministic\n\ncrosvm builds CPUID per vCPU and its leaf-4 arm executes the host `CPUID`\ninstruction inline, and in each vCPU thread `set_vcpu_thread_scheduling()`\n(which applies `--cpu-affinity`) runs *before* `configure_vcpu()` \u2192\n`setup_cpuid()`. So each vCPU samples leaf 4 from the host CPU it is pinned to,\nand `--cpu-affinity` selects the orientation directly \u2014 no waiting for a\nfavourable boot.\n\n`run-crosvm-repro.sh` does this. On the host used here (Intel Core Ultra 7 268V:\nP-cores `cpu0-3` enumerate 4 leaves, E-cores `cpu4-7` enumerate 3):\n\n```sh\n./run-crosvm-repro.sh                  # unpatched kernel: KASAN report\n./run-crosvm-repro.sh -k      # patched kernel: same setup, no report\n./run-qemu-control.sh                  # QEMU: no leaf mismatch arises at all\n```\n\n`-e 4` pins vcpu0 to an E-core (3 leaves) and `-p 0` pins vcpu1 to a P-core\n(4 leaves), so `cpu0` is populated during boot with the shorter array and\n`cpu1` \u2014 onlined later, by the program \u2014 is the one that overruns it.\n\n### Under QEMU or virtme-ng (vng) \u2014 needs the debug patch\n\nNeither reproduces the bug on its own, and the reason is not incidental: QEMU\ncomputes **one** CPUID set and gives it to every vCPU, so the leaf counts can\nnever differ between vCPUs. Measured on this host, same kernel, same pinning\nacross a P-core and an E-core as the crosvm run: `cpu0` 4 leaves, `cpu1` 4\nleaves, no mismatch, no fault. virtme-ng is a front-end to QEMU, so it\ninherits that, and QEMU exposes no per-vCPU CPUID knob for `--qemu-opts` to\nreach.\n\n`debug-fake-short-leaves.patch` closes that gap for testing. It adds a\n`fake_short_leaves=` boot parameter that shortens one CPU's\n`num_leaves`, which is exactly the state hybrid hardware and crosvm produce,\nso the write can then be reproduced on **any** x86 machine:\n\n```sh\n# on an unpatched tree, with CONFIG_KASAN=y\npatch -p1 &lt; debug-fake-short-leaves.patch\n\n# boot-time trigger: cpu0 fakes 3 leaves, cpu1 comes up with 4 and overruns it\nvng -r ./arch/x86/boot/bzImage --cpus 2 -a \"fake_short_leaves=0\"\n\n# userspace trigger: clean boot, then the write(2) does it\nvng -r ./arch/x86/boot/bzImage --cpus 2 -a \"fake_short_leaves=0 maxcpus=1\" \\\n    --rw -e \"./cacheinfo-oob-trigger --all\"\n```\n\nConfirm the option names against `vng --help` on your install; the flags above\nwere not run here. The plain-QEMU equivalent is what `run-qemu-control.sh`\ndoes, with `fake_short_leaves=0` added to `-append`.\n\nA fault reproduced this way exercises the same code with the same input, but\nit is a modified kernel, so it is evidence about the *code path*, not about\nthe platform. The unmodified-kernel evidence is the crosvm run above.\n\n### On bare metal\n\nThe same code is one APIC-ID assignment away from faulting without any VMM. On\nthe Lunar Lake host used here it does not fault, and `cacheinfo-oob-predict`\nshows why:\n\n```\n  cpu0   apicid=0   num_leaves=4\n         index3 L3 Unified  threads_sharing=64  index_msb=6  cache_id=0  shared_cpu_list=0-3\n  cpu4   apicid=64  num_leaves=3\n         index2 L2 Unified  threads_sharing=8   index_msb=3  cache_id=8   shared_cpu_list=4-7\n```\n\nThe P-cores' L3 leaf reports `num_threads_sharing=64`, giving `index_msb=6`, so\nthe sibling window is `apicid &gt;&gt; 6`. P-core APIC IDs are 0, 8, 16, 24 (window\n0) and E-core APIC IDs are 64, 66, 68, 70 (window 1), so the 3-leaf E-cores\nfall outside the 4-leaf P-cores' window and are never indexed. Nothing but that\nnumbering prevents the write: a part whose hybrid cores land in the same\n`apicid &gt;&gt; index_msb` window, or firmware that numbers them more densely, hits\nit on bare metal, in which case the reproducer above works unchanged with\n`maxcpus=1` and `--order` chosen so a 4-leaf CPU comes up after a 3-leaf one.\n\nRun `cacheinfo-oob-predict` on any hybrid part to check that machine.\n\n## Results on this host\n\nHost: Dell Pro 14 Premium PA14250, Intel Core Ultra 7 268V (Lunar Lake).\nGuest kernel: Debian `7.2~rc7-1~exp1` amd64 with `CONFIG_KASAN=y`,\n`CONFIG_NR_CPUS=8192`. Guest booted with `maxcpus=1`, two vCPUs,\n`--cpu-affinity 0=4:1=0`.\n\n| VMM | kernel | cpu0 / cpu1 leaves | boot | after `echo 1 &gt; cpu1/online` | trigger exit |\n|---|---|---|---|---|---|\n| crosvm | unpatched | 3 / 4 | clean | **KASAN slab-out-of-bounds write** (6/6 runs) | 0 |\n| crosvm | patched | 3 / 4 | clean | clean (5/5 runs) | 1 |\n| QEMU | unpatched | 4 / 4 | clean | clean, no mismatch to begin with | 1 |\n\nThe boot itself is clean in every case, so the KASAN report is attributable to\nthe `write(2)` and nothing else. The patched run is the meaningful control: the\nleaf-count mismatch is present and the reproducer confirms it (`cpu0` 3 leaves,\n`cpu1` 4 leaves, out-of-bounds write predicted), and the report does not\nhappen.\n\nThe prediction is computed from CPUID before the trigger runs, and matches the\nreport exactly:\n\n```\n  cpu1 (leaf index 3, 4 leaves) -&gt; cpu0 (3 leaves)\n    sibling test: num_threads_sharing=64 index_msb=6, apicid 1&gt;&gt;6 == 0&gt;&gt;6 == 0\n    write:        8 bytes at info_list(cpu0) + 3*1088 + 32 = +3296,\n                  32 bytes past the end of the 3264-byte allocation\n    KASAN should report: \"Write of size 8\" ... \"located 32 bytes to the right of\n                         allocated 3264-byte region\"\n...\n  prediction vs report:\n    allocated region : predicted 3264, reported 3264  MATCH\n    bytes to the right: predicted 32, reported 32  MATCH\n```\n\nwith the kernel reporting:\n\n```\nBUG: KASAN: slab-out-of-bounds in populate_cache_leaves+0x9d0/0x16d0\nWrite of size 8 at addr ffff8880038f2ce0 by task cpuhp/1/112\n...\n populate_cache_leaves+0x9d0/0x16d0\n detect_cache_attributes+0x323/0x11a0\n cacheinfo_cpu_online+0x29/0xb30\n cpuhp_invoke_callback+0x3f6/0x1530\n...\nThe buggy address is located 32 bytes to the right of\n allocated 3264-byte region [ffff8880038f2000, ffff8880038f2cc0)\n```\n\n3264 = 3 \u00d7 `sizeof(struct cacheinfo)` (1088 at `CONFIG_NR_CPUS=8192`) and 32 =\n`offsetof(struct cacheinfo, shared_cpu_map)`, i.e. the write lands exactly on\n`info_list[3].shared_cpu_map` of a CPU that allocated only three leaves. This\nis the same signature as the original crash found by boot churn under\nsyzkaller.\n\n## Anticipated objections\n\n**\"This needs a VMM that hands vCPUs inconsistent CPUID. Fix the VMM.\"**\nThe VMM inconsistency is worth fixing separately, but it is not what makes this\na kernel bug. `CPUID.4` is platform data, and commit 9677be09e5e4 made the leaf\ncount per-CPU *precisely because* hybrid parts enumerate different counts per\nCPU \u2014 its changelog says \"This is erroneous on systems such as Meteor Lake,\nwhere each CPU has a distinct num_leaves value\". The generic implementation was\nalready fixed for the identical situation in `cache_shared_cpu_map_setup()`\n(CVE-2023-53254, \"cacheinfo: Fix shared_cpu_map to handle shared caches at\ndifferent levels\"), by walking the sibling's own leaf count and matching on\nlevel and type. The x86 copy still assumes index alignment. On the bare-metal\nhost used here, what prevents the write is APIC-ID numbering \u2014 the E-cores\nhappen to fall outside the P-cores' `apicid &gt;&gt; index_msb` window \u2014 not any\nbounds check.\n\n**\"It only happens at boot, so userspace cannot reach it.\"**\nIt is a CPU-hotplug path: `cacheinfo_cpu_online()` is the\n`CPUHP_AP_BASE_CACHEINFO_ONLINE` callback, and `write(2)` to\n`/sys/devices/system/cpu/cpuN/online` runs it. That write needs privilege, so\nthis is not an unprivileged-escalation claim; it is memory corruption reachable\nfrom an ordinary administrative operation, and on an affected configuration it\nalso happens during boot with no userspace involvement at all \u2014 which is how it\nwas originally found.\n\n**\"The reproducer needs `maxcpus=1`, so it is artificial.\"**\n`maxcpus=1` only moves the trigger from boot to a syscall, so that one variable\nchanges at a time and the boot log is clean. Without it, the same write happens\nduring AP bring-up on an affected configuration.\n\n**\"The patched run may just have been lucky.\"**\nThe precondition is re-verified inside every run, patched or not: the program\nprints the measured per-CPU leaf counts, the APIC IDs, the sibling test it\nreplays, and the predicted write offsets. In the patched runs the mismatch is\npresent and the out-of-bounds write is predicted; only the kernel differs.\n\n**\"Something else in the boot could be producing that KASAN report.\"**\nThe boot log contains zero KASAN reports (`dmesg | grep -c` is 0 before the\ntrigger). The report is read from `/dev/kmsg` by the triggering program itself,\nin the window of its own `write(2)`, and the offsets KASAN prints match the\noffsets computed from CPUID before that write.\n\n**\"1088-byte `struct cacheinfo` is a Debian `CONFIG_NR_CPUS=8192` artefact.\"**\n`CONFIG_NR_CPUS` changes only *which* memory is clobbered, not whether the\nwrite goes out of bounds. `--nr-cpus N` recomputes the arithmetic for any\nconfiguration, and the tools flag the cases where the write leaves the kmalloc\nobject entirely and lands in the next one instead of in slack.\n\n## Notes\n\n* Without `CONFIG_KASAN=y` the write still happens; at these sizes it lands in\n  the slack of the 4096-byte kmalloc object, so nothing reports it. The tools\n  print, per prediction, whether the write stays in slack or leaves the object\n  (which depends on `CONFIG_NR_CPUS` and the leaf count) \u2014 when it leaves, it\n  corrupts the next object. `slub_debug=Z` also catches the in-slack case.\n* A KASAN-free footprint is checked too: if sysfs lists a CPU in a cache's\n  `shared_cpu_list` while that CPU has no cache of the same level and type,\n  that cross-link can only come from this write. Both tools report it.\n* Only the Intel/fallback `__cache_cpumap_setup()` path is modelled.\n  `__cache_amd_cpumap_setup()` has the same unbounded `info_list + index`\n  pattern for `index == 3` and for `X86_FEATURE_TOPOEXT`, also with only a NULL\n  check, and is not covered by these tools.\n\nAuthor: Yunseong Kim ", "creation_timestamp": "2026-08-27T23:29:55.012514Z"}