Skip to content

arm64: copy the master page on CoW instead of zeroing clean pages#84

Merged
fwsGonzo merged 2 commits into
masterfrom
fix-arm64-cow-zeroes-initialized-data
Jun 28, 2026
Merged

arm64: copy the master page on CoW instead of zeroing clean pages#84
fwsGonzo merged 2 commits into
masterfrom
fix-arm64-cow-zeroes-initialized-data

Conversation

@perbu

@perbu perbu commented Jun 28, 2026

Copy link
Copy Markdown
Collaborator

Problem

On arm64, cow_page() treated a clear DESC_DIRTY bit as "this page is zero" and
zeroed the freshly-allocated copy-on-write page. But split_l2_block() stamps all
512 split 4KB L3 entries with the parent 2MB block's single flag set, so a block
that holds non-zero .data whose DIRTY bit doesn't reflect that content yields
!dirty 4KB pages. CoW then zeroed those pages on first write after a fork,
destroying initialized data.

Observed as glibc's stdin FILE struct pointers (_IO_read_ptr) reading back NULL,
faulting the guest with an EL0 data abort (ESR 0x92000007, L3 translation fault)
on the first buffered read in a fork. Layout-sensitive: only triggers when non-zero
.data shares a 2MB block with the wrong DIRTY bit (a guest with a large BSS array
shifts it there).

Fix

The master page is always the CoW source of truth, so duplicate its actual bytes;
only honor an explicit zero request (options.zeroes). This drops a fragile
"!dirty ⇒ zero" optimization in favor of correctness — the page is being written
immediately afterward anyway, so set_dirty() follows.

-	if (options.zeroes || (entry & DESC_DIRTY) == 0) {
+	if (options.zeroes) {

Follow-ups (not in this PR)

  • The file-backed RO promotion path in writable_page_at() carries the same
    "!dirty ⇒ zero" shortcut and likely needs the same treatment.
  • amd64 writable_page_at() has a structurally identical guard
    (options.zeroes || (pt[e] & PDE64_DIRTY) == 0); not addressed here.

Regression test

Added tests/unit/arm64_fork.cpp — the first arm64 fork unit test (fork.cpp /
reset.cpp are AMD64-only). The field bug is layout-sensitive, so rather than
chase a fragile .data/.bss layout the test constructs the triggering state
deterministically: a zero-initialized .bss buffer is cloneable with DESC_DIRTY
clear after prepare_copy_on_write(), and the host plants non-zero sentinels
straight into the master backing via unsafe_memory_at() — the same
dirty-bit-bypassing path the ELF loader uses for .data. A fork then writes one
offset of each page (forcing CoW) and reads a sentinel at another offset of the
same page.

  • buggy code: 161/1024 pages survive (the rest are zeroed) → test fails
  • fixed code: 1024/1024 survive → test passes

🤖 Generated with Claude Code

perbu and others added 2 commits June 28, 2026 11:24
cow_page() zeroed the new page whenever DESC_DIRTY was clear, treating
"not dirty" as "is zero". But split_l2_block() stamps all 512 split 4KB
L3 entries with the parent 2MB block's single flag set, so a block that
holds non-zero .data whose DIRTY bit does not reflect that content yields
!dirty 4KB pages. CoW then zeroed those pages, destroying initialized
data: e.g. glibc's stdin FILE struct pointers (_IO_read_ptr) read back
NULL, faulting the guest with an EL0 data abort (ESR 0x92000007, L3
translation fault) on the first buffered read after a fork. Layout-
sensitive -- only triggers when non-zero .data shares a 2MB block with
that wrong DIRTY bit (a guest with a large BSS array shifts it there).

The master page is always the CoW source of truth, so duplicate its
actual bytes; only honor an explicit zero request (options.zeroes). This
drops a fragile "!dirty => zero" optimization in favor of correctness;
the page is being written anyway, so set_dirty() follows immediately.

The sibling shortcut in the file-backed RO promotion path (writable_page_at)
has the same pattern and likely needs the same treatment.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Adds tests/unit/arm64_fork.cpp (the first arm64 fork unit test; fork.cpp
and reset.cpp are AMD64-only) covering the cow_page() fix.

The bug is layout-sensitive in the field, so the test constructs the
triggering state deterministically: a zero-initialized .bss buffer is
cloneable with DESC_DIRTY clear after prepare_copy_on_write(), and the
host plants non-zero sentinels straight into the master backing via
unsafe_memory_at() -- the same dirty-bit-bypassing path the ELF loader
uses for .data. A fork then writes one offset of each page (forcing CoW)
and reads a sentinel at another offset of the same page. With the bug the
CoW zeroes the page and the sentinel is lost (161/1024 survive); with the
fix all 1024 survive.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
@fwsGonzo

Copy link
Copy Markdown
Member

LGTM

@fwsGonzo fwsGonzo merged commit cc075c1 into master Jun 28, 2026
4 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants