Skip to content

mremap in-place growth overwrites an adjacent coalesced anonymous mapping #389

Description

@Ksld154

Summary

mremap(..., MREMAP_MAYMOVE) can incorrectly grow a mapping in place when the
requested tail is occupied by an adjacent anonymous mapping. The two mappings
must have compatible metadata so the region tracker coalesces them.

The syscall returns the original address as a successful result and zeroes the
occupied tail. This corrupts the adjacent mapping. The same path can also
publish overlapping region records after the byte corruption.

This was found through an Alpine apk --simulate workload, then reduced to a
small static aarch64 guest binary.

Environment

  • Host: macOS 26.7, Darwin 25.6.0, Apple Silicon arm64
  • Apple SDK: 27.0
  • elfuse commit: 0a633684faa5c6de252b378f6da914230adc3da2
  • build/elfuse SHA-256:
    63879eefead430475e411c7c2cf8adde8dacc9a1439a6c793336c6d77d049526
  • Guest compiler: aarch64-linux-gnu-gcc, static binary

Minimal reproducer

Save as /tmp/mremap-adjacent.c:

#define _GNU_SOURCE
#include <errno.h>
#include <stdio.h>
#include <string.h>
#include <sys/mman.h>

int main(void)
{
    const size_t page = 4096;
    unsigned char *base = mmap(NULL, 3 * page, PROT_READ | PROT_WRITE,
                               MAP_PRIVATE | MAP_ANONYMOUS, -1, 0);
    if (base == MAP_FAILED || munmap(base, 3 * page) != 0)
        return 10;

    unsigned char *first = mmap(base, page, PROT_READ | PROT_WRITE,
                                MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED,
                                -1, 0);
    unsigned char *neighbor =
        mmap(base + page, page, PROT_READ | PROT_WRITE,
             MAP_PRIVATE | MAP_ANONYMOUS | MAP_FIXED, -1, 0);
    if (first != base || neighbor != base + page)
        return 11;

    memset(first, 0x5a, page);
    memset(neighbor, 0xa5, page);

    errno = 0;
    unsigned char *grown =
        mremap(first, page, 2 * page, MREMAP_MAYMOVE);
    printf("base=%p grown=%p errno=%d neighbor=%02x/%02x\n", base,
           grown, errno, neighbor[0], neighbor[page - 1]);

    if (grown == MAP_FAILED)
        return 20;
    if (grown == first)
        return 21;
    if (neighbor[0] != 0xa5 || neighbor[page - 1] != 0xa5)
        return 22;
    if (grown[0] != 0x5a || grown[page - 1] != 0x5a)
        return 23;
    return 0;
}

Compile and run:

aarch64-linux-gnu-gcc -static -O2 \
    -o /tmp/mremap-adjacent /tmp/mremap-adjacent.c

build/elfuse --timeout 0 /tmp/mremap-adjacent
printf 'status=%s\n' "$?"

Expected result

The second page is occupied, so the source cannot grow in place.
MREMAP_MAYMOVE permits relocation. The call should return a different
address, preserve the first page, zero the new page at the destination, and
leave the adjacent mapping unchanged.

An equivalent call without MREMAP_MAYMOVE should fail with ENOMEM and
leave both mappings unchanged.

Linux enforces this in vrm_can_expand_in_place(): the bytes from the source
address to the end of its VMA must equal old_len. A VMA suffix beyond the
requested source range prevents in-place expansion.

Reference:
mm/mremap.c

Actual result

base=0x200000000 grown=0x200000000 errno=0 neighbor=00/00
status=21

elfuse reports successful in-place growth and clears the adjacent mapping.
The result is identical in quiet and verbose modes. It is also identical with
ELFUSE_DISABLE_TLBI_RANGE=1.

Root cause

guest_region_add_ex_owned_gpa() assigns compatible adjacent anonymous
mappings the same vma_id, then merges them into one tracker record. This is
intentional and matches the fact that Linux may merge compatible VMAs.

The in-place growth branch in sys_mremap() scans the requested tail for
overlap. It skips any overlapping region whose start equals old_off:

if (g->regions[i].start == old_off)
    continue;

That test assumes the source tracker record ends at old_off + old_size. A
coalesced record can extend beyond that boundary and contain the adjacent
mapping. The branch therefore treats an occupied tail as free.

The successful branch then executes:

memset((uint8_t *) g->host_base + grow_off, 0, grow_len);

This clears live bytes in the adjacent mapping. Removing the old source range
and adding the enlarged region can then overlap the suffix retained from the
coalesced record, violating the region table's non-overlap invariant.

The existing test_grow_no_maymove() case does not cover this shape. Its
blocker uses PROT_READ specifically to prevent region coalescing with the RW
source.

Proposed fix

Treat any tracked overlap with [grow_off, grow_off + grow_len) as occupied.
The source range ends at grow_off, so no source region needs an exception in
the tail check. mem.c already provides region_range_overlaps() for this
query:

bool can_grow =
    !region_range_overlaps(g, grow_off, grow_off + grow_len);

The existing control flow can then relocate when MREMAP_MAYMOVE is present
or return ENOMEM when it is absent. Region coalescing does not need to be
disabled.

Real-world impact

The original reproducer is Alpine 3.21 aarch64 apk 2.14.6 resolving git
from local repository indexes with --no-network --simulate. It consistently
exits with status 139 under elfuse.

The last relevant trace entry is:

mremap(0x204e44000, 0x38000, 0x39000, 0x1) -> 0x204e44000
EL0 data fault at 0x10 PC=0xff0002a604
    (ESR=0x92000007 FSC=0x7) -> SIGSEGV/MAPERR

The requested tail starts at:

0x204e44000 + 0x38000 = 0x204e7c000

A GDB inspection found zeroes at 0x204e7c1f0, inside that page. The later
fault at 0x10 is consistent with a pointer in the adjacent allocation being
cleared before a field at offset 0x10 is read.

The equivalent local-index workload does not reproduce the guest fault in the
QEMU aarch64 reference lane.

Regression coverage

Add a portable test to tests/test-mremap.c that creates two separately
mapped, adjacent RW anonymous pages so the elfuse tracker may coalesce them.
The test should cover both outcomes:

  1. Without MREMAP_MAYMOVE, growth fails with ENOMEM and both mappings keep
    their contents.
  2. With MREMAP_MAYMOVE, the source moves, its contents are preserved, its
    extension is zero, and the adjacent mapping keeps its contents.

Run the test in both the elfuse aarch64 and QEMU aarch64 matrix lanes.

Acceptance criteria

  • The minimal reproducer exits with status 0.
  • The adjacent mapping remains unchanged.
  • Growth without MREMAP_MAYMOVE returns ENOMEM without modifying either
    mapping.
  • The local-index apk --simulate reproducer no longer exits with status 139.
  • Existing mremap tests and the elfuse and QEMU aarch64 matrix lanes pass.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions