Skip to content

Crafted LLEXT relocation entries can trigger kernel out-of-bounds writes during load via unchecked r_offset

Moderate
d3zd3z published GHSA-xv9q-6mrf-8j49 Aug 12, 2026

Software

zephyr

Affected versions

>= 3.7.0, < 4.4.2

Patched versions

4.4.2

Description

Summary

Zephyr's Linkable Loadable Extensions (LLEXT) subsystem computes relocation write targets as section_base + r_offset and dispatches them to architecture-specific relocation backends without validating that each relocation offset still falls within the referenced target section. A crafted LLEXT ELF can therefore trigger a kernel out-of-bounds write during extension load/link time, before any extension entrypoint is executed.

Because the write happens inside the privileged loader, the likely consequences are kernel memory corruption, hard faults, loader-state corruption, and possible control-flow hijack depending on allocator/layout and target architecture.

Details

The root cause is the missing per-relocation bounds validation for r_offset.

In current Zephyr, the relocation write target is derived by:

  • include/zephyr/llext/llext_internal.h
  • llext_get_reloc_instruction_location()

which is currently equivalent to:

return (uintptr_t)llext_loaded_sect_ptr(ldr, ext, shndx) + rela->r_offset;

There is no check here that rela->r_offset is still within the target section's sh_size.

The generic relocation dispatcher in:

  • subsys/llext/llext_link.c
  • llext_link()

does perform some section-level checks before dispatching relocations:

  • relocation section type (SHT_REL / SHT_RELA)
  • sh_entsize
  • sh_size % sh_entsize == 0
  • sh_info < ext->sect_cnt

However, it does not validate that each individual relocation record stays within the section it claims to patch.

After those section-level checks, llext_link() reads each relocation and hands it to arch_elf_relocate():

ret = llext_read(ldr, &rel, shdr->sh_entsize);
...
ret = arch_elf_relocate(ldr, ext, &rel, shdr);

At that point, the computed loc is already attacker-controlled through r_offset.

This is not just a theoretical concern. The architecture backends perform direct writes to the computed relocation address:

  • arch/arm/core/elf.c
  • arch/arm64/core/elf.c
  • arch/riscv/core/elf.c
  • arch/x86/core/elf.c
  • arch/arc/core/elf.c

Typical patterns include:

  • *(uint32_t *)loc = ...
  • *(uint64_t *)loc = ...
  • UNALIGNED_PUT(..., (uint32_t *)loc)

So once loc points outside the target section, the loader has a real kernel write primitive.

I also checked the history to confirm version scope. This is not a recent regression from the file split. The same root cause already existed in the original monolithic LLEXT implementation shipped in v3.5.0, where the relocation location was computed as:

op_loc = loc + rel.r_offset;

without a per-relocation bounds check.

History references:

  • initial LLEXT introduction:
    • 41e0a4a37141bad1c95cdd81653463032e4f05d7
    • llext: Linkable loadable extensions
  • later refactor moving load/link code:
    • 1a2f6ae381421cf3fdd079187d8d60dfec7f41fe
    • explicitly states "No functional changes are introduced"

Therefore the vulnerable logic should be considered present since the first release containing LLEXT, i.e. v3.5.0.

Affected configurations:

  • systems with CONFIG_LLEXT=y
  • impact is strongest when the system loads attacker-controlled or untrusted LLEXT ELF files
  • source inspection confirms the generic dispatcher issue is relevant to the currently supported LLEXT relocation backends on:
    • ARM
    • ARM64
    • RISC-V
    • x86
    • ARC

PoC

I reproduced this with a two-stage PoC:

  1. a generator that creates a minimal ELF64 file with:
    • a .text section of 16 bytes
    • a .rela.text relocation section targeting .text
    • a single relocation record whose r_offset = 0x40, i.e. 64 bytes, clearly outside the 16-byte target section
  2. a host-side ASan harness that models the current Zephyr LLEXT relocation logic:
    • section_base + r_offset
    • no per-relocation bounds check
    • then performs the backend-style write

The input generator (gen_bad_reloc.py) writes bad_reloc.elf with:

  • .text size = 16
  • relocation target section index = 1 (.text)
  • r_offset = 0x40

The host-side reproducer (repro_llext_reloc_oob.c) then:

  • parses the crafted ELF
  • performs the same section-level sanity checks Zephyr currently does
  • allocates a heap buffer exactly equal to the target section size
  • computes loc = section_base + r_offset
  • performs a 4-byte write at loc

This produces a deterministic ASan heap-buffer-overflow.

Representative output from the reproducer:

Zephyr HEAD: 4f2e63556a0 2026-04-15 15:39:01 +0200
Wrote bad_reloc.elf (.text size=16, r_offset=0x40)
ELF ok: shnum=4 shoff=136
Found relocation section idx=2 type=4 info=1 entsize=24 size=24
Target section idx=1 size=16 flags=0x6
Relocation count: 1
  rel[0]: r_offset=64 => loc=section_base+0x40
=================================================================
==455068==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x502000000050
WRITE of size 4 at 0x502000000050
...
0x502000000050 is located 48 bytes to the right of 16-byte region [0x502000000010,0x502000000020)
...
SUMMARY: AddressSanitizer: heap-buffer-overflow repro_llext_reloc_oob.c:164 in main

This directly demonstrates the missing invariant:

  • Zephyr checks relocation section structure,
  • but does not check relocation entry target bounds.

A practical on-target PoC should also be possible by loading a crafted extension through the normal LLEXT load path (for example via filesystem or shell loader), but the host-side ASan model already demonstrates the exact unsafe primitive in a deterministic and architecture-independent way.

Impact

This is a kernel-memory-corruption vulnerability in the LLEXT loader.

Impact includes:

  • kernel out-of-bounds write during extension load
  • hard fault / crash / denial of service
  • corruption of adjacent extension memory regions or loader metadata
  • corruption of exported symbol tables, init/fini arrays, or other nearby structures
  • possible control-flow hijack depending on target architecture and layout

Who is impacted:

  • Zephyr systems that enable CONFIG_LLEXT
  • especially systems that support loading extensions from attacker-influenced storage, shell input, or any other untrusted source

This is stronger than a simple "extension can execute arbitrary code after loading" argument, because the corruption happens inside the privileged loader before normal extension execution begins.

Fix suggestion

The minimum safe fix is to reject any relocation whose r_offset points outside the target section before calling arch_elf_relocate().

At a minimum, the generic dispatcher should enforce:

  • rela->r_offset < ext->sect_hdrs[shdr->sh_info].sh_size

before deriving loc.

A stronger hardening fix should also ensure that the mapped section still fits within its region and optionally add per-backend write-width checks for fixed-width relocation types.

Concretely, I suggest:

  1. add a checked helper in include/zephyr/llext/llext_internal.h
  2. call it from subsys/llext/llext_link.c before arch_elf_relocate()
  3. apply analogous bounds validation to the Xtensa PLT-style path in llext_link_plt() as hardening

Suggested helper:

static inline int llext_validate_reloc_instruction_location(const struct llext_loader *ldr,
                                                            const struct llext *ext,
                                                            unsigned int shndx,
                                                            const elf_rela_t *rela)
{
        enum llext_mem mem_idx;
        size_t sect_off;
        size_t sect_size;
        size_t region_size;
        const void *base;

        if (shndx >= ext->sect_cnt || ext->sect_hdrs == NULL || ldr->sect_map == NULL) {
                return -ENOEXEC;
        }

        mem_idx = ldr->sect_map[shndx].mem_idx;
        if (mem_idx >= LLEXT_MEM_COUNT) {
                return -ENOEXEC;
        }

        base = llext_loaded_sect_ptr((struct llext_loader *)ldr, (struct llext *)ext, shndx);
        if (base == NULL) {
                return -ENOEXEC;
        }

        sect_off = ldr->sect_map[shndx].offset;
        sect_size = ext->sect_hdrs[shndx].sh_size;
        region_size = ext->mem_size[mem_idx];

        if (sect_off > region_size) {
                return -ENOEXEC;
        }
        if (sect_size > (region_size - sect_off)) {
                return -ENOEXEC;
        }
        if ((size_t)rela->r_offset >= sect_size) {
                return -ENOEXEC;
        }

        return 0;
}

And then in subsys/llext/llext_link.c, before debug logging and before arch_elf_relocate():

ret = llext_validate_reloc_instruction_location(ldr, ext, shdr->sh_info, &rel);
if (ret != 0) {
        LOG_ERR("Relocation %d:%d offset %#zx escapes target section %d (size %zu)",
                i, j, (size_t)rel.r_offset, shdr->sh_info,
                (size_t)ext->sect_hdrs[shdr->sh_info].sh_size);
        return ret;
}

This generic check closes the primary OOB write primitive described in this report.

Kindly let me know if you intend to request a CVE ID upon confirmation of the vulnerability.

./run.sh 
Zephyr HEAD: 4f2e63556a0 2026-04-15 15:39:01 +0200
Wrote bad_reloc.elf (.text size=16, r_offset=0x40)
ELF ok: shnum=4 shoff=136
Found relocation section idx=2 type=4 info=1 entsize=24 size=24
Target section idx=1 size=16 flags=0x6
Relocation count: 1
  rel[0]: r_offset=64 => loc=section_base+0x40
=================================================================
==455068==ERROR: AddressSanitizer: heap-buffer-overflow on address 0x502000000050 at pc 0x55ac87fc31b7 bp 0x7ffe2ca830f0 sp 0x7ffe2ca830e0
WRITE of size 4 at 0x502000000050 thread T0
    #0 0x55ac87fc31b6 in main repro_llext_reloc_oob.c:164
    #1 0x7f8873d75d8f in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58
    #2 0x7f8873d75e3f in __libc_start_main_impl ../csu/libc-start.c:392
    #3 0x55ac87fc1484 in _start (/home/zyf/zafl/reshow/zephyr-vuln-repro/llext-reloc-oob/repro_llext_reloc_oob+0x4484)

0x502000000050 is located 48 bytes to the right of 16-byte region [0x502000000010,0x502000000020)
allocated by thread T0 here:
    #0 0x7f887465e887 in __interceptor_malloc ../../../../src/libsanitizer/asan/asan_malloc_linux.cpp:145
    #1 0x55ac87fc2b16 in main repro_llext_reloc_oob.c:139
    #2 0x7f8873d75d8f in __libc_start_call_main ../sysdeps/nptl/libc_start_call_main.h:58

SUMMARY: AddressSanitizer: heap-buffer-overflow repro_llext_reloc_oob.c:164 in main
Shadow bytes around the buggy address:
  0x0a047fff7fb0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0a047fff7fc0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0a047fff7fd0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0a047fff7fe0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
  0x0a047fff7ff0: 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00 00
=>0x0a047fff8000: fa fa 00 00 fa fa fa fa fa fa[fa]fa fa fa fa fa
  0x0a047fff8010: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0a047fff8020: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0a047fff8030: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0a047fff8040: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
  0x0a047fff8050: fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa fa
Shadow byte legend (one shadow byte represents 8 application bytes):
  Addressable:           00
  Partially addressable: 01 02 03 04 05 06 07 
  Heap left redzone:       fa
  Freed heap region:       fd
  Stack left redzone:      f1
  Stack mid redzone:       f2
  Stack right redzone:     f3
  Stack after return:      f5
  Stack use after scope:   f8
  Global redzone:          f9
  Global init order:       f6
  Poisoned by user:        f7
  Container overflow:      fc
  Array cookie:            ac
  Intra object redzone:    bb
  ASan internal:           fe
  Left alloca redzone:     ca
  Right alloca redzone:    cb
  Shadow gap:              cc
==455068==ABORTING
exit status: 1

Patches

Branch Pull request Status
main #109875 — 106540afbd22 merged
v4.4-branch #112635 merged
v4.3-branch #112634 merged
v3.7-branch #111541 merged

For more information

If you have any questions or comments about this advisory:

embargo: 2026-08-11

Severity

Moderate

CVSS overall score

This score calculates overall vulnerability severity from 0 to 10 and is based on the Common Vulnerability Scoring System (CVSS).
/ 10

CVSS v3 base metrics

Attack vector
Local
Attack complexity
High
Privileges required
Low
User interaction
None
Scope
Unchanged
Confidentiality
None
Integrity
High
Availability
High

CVSS v3 base metrics

Attack vector: More severe the more the remote (logically and physically) an attacker can be in order to exploit the vulnerability.
Attack complexity: More severe for the least complex attacks.
Privileges required: More severe if no privileges are required.
User interaction: More severe when no user interaction is required.
Scope: More severe when a scope change occurs, e.g. one vulnerable component impacts resources in components beyond its security scope.
Confidentiality: More severe when loss of data confidentiality is highest, measuring the level of data access available to an unauthorized user.
Integrity: More severe when loss of data integrity is the highest, measuring the consequence of data modification possible by an unauthorized user.
Availability: More severe when the loss of impacted component availability is highest.
CVSS:3.1/AV:L/AC:H/PR:L/UI:N/S:U/C:N/I:H/A:H

CVE ID

CVE-2026-12235

Weaknesses

No CWEs

Credits