Repository navigation
Fix mprotect address for the W^X recent-allocation flip - #1062
Merged
mflatt merged 2 commits intoSep 22, 2026
Merged
Conversation
Under WRITE_XOR_EXECUTE_CODE, a write to compiled code has to be bracketed: one enable_code_write call to mprotect the target memory writable, and another afterwards to restore it to executable. Code is allocated out of a region that a thread fills as it goes, and the thread knows where the region begins (base_loc) and how far it has filled it (next_loc), so flipping the current region is a single mprotect. What complicates things is that the thread can exhaust the region while a bracket is still open. S_find_more_gc_room then closes the old region off and takes a fresh one, and only the fresh one is current from that point on. The old region was made writable and still has to be restored, so close_off_segment records it on the sweep_next chain, and a loop at the end of enable_code_write walks the chain and issues one mprotect per entry. An entry is a seginfo carrying two fields. sweep_bytes is the length, measured as the distance from base_loc to the end of the last object placed in the region. The address is sweep_start, and that is where this goes wrong: close_off_segment takes it from sweep_loc, which is not base_loc but the garbage collector's cursor, the point it should resume sweeping this region from. Allocation and sweeping advance independently. S_find_more_gc_room sets both to the base of the new region, so they start out equal and the ordinary case has always been correct, but a collection whose target generation is 0, which (collect 0 0) asks for, sweeps generation 0 in place and raises sweep_loc to the allocation pointer. Nothing lowers it again while the region is in use, so from then on it sits above base_loc. Once it does, the mprotect is wrong twice over. Its address is an object boundary rather than a page boundary, and mprotect requires page alignment, so the call fails with EINVAL and the process aborts reporting that it failed to protect recent allocation segments. That is the usual symptom, and it hides the second problem: on the occasions when the object boundary happens to be page aligned, the call succeeds instead, and a length measured from base_loc but applied at sweep_loc ends that same distance beyond the region, in whatever lies above it. The address wanted is base_loc, and the fix names it rather than arriving at it by coincidence. A region always begins where a segment begins, since S_find_more_gc_room sets base_loc to build_ptr(seg, 0), and the seginfo the record hangs off is the one for the segment containing base_loc, so build_ptr(sip->number, 0) is base_loc by construction. It is also how the rest of enable_code_write forms its addresses. The comment on sweep_bytes in types.h described the count as starting at sweep_start, which it never did, and now says the segment's base. Reaching the divergence takes a collection with a target generation of 0 and then enough code allocated afterwards to exhaust a region while a bracket is open, which a fasl read will do. It is reachable only under WRITE_XOR_EXECUTE_CODE, defined in tree for iOS and otherwise only by builds that define it themselves. The code-write-flip mat added to mats/7.ms drives that sequence: live generation-0 code, a (collect 0 0), enough further code to close a region off, and then a fasl read to drain the chain. It can only fail where WRITE_XOR_EXECUTE_CODE is defined; elsewhere it runs the same Scheme and passes, for about a quarter of a second.
Contributor
|
This looks like right to me, and I'm planning to merge it. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
Under
WRITE_XOR_EXECUTE_CODE, when a code allocation region is closed off while a code-write bracket is open,close_off_segmentrecords it so that the end of the bracket can restorePROT_EXEC. It records the length measured from the region's base and the address taken from the garbage collector's sweep cursor. Those are the same value until a collection with target generation 0 moves the cursor, and different afterwards.Once they differ, the
mprotectatc/segment.c:687is made at an object boundary.mprotectrequires a page-aligned address, so the call fails withEINVALand the process aborts inS_error_abort("failed to protect recent allocation segments"). On the runs where that object boundary happens to be page aligned the call succeeds instead, and protects a length measured from one place starting at another, running past the end of the region.Where
c/segment.c:682-690, thesweep_nextdrain loop at the tail ofenable_code_write:The two fields are set together in
close_off_segment,c/alloc.c:227-247:Mechanism
S_find_more_gc_roomtakes a fresh region and sets both pointers to its base (c/alloc.c:271-276):So a region always begins where a segment begins. While the two pointers are equal,
sweep_startisbase_loc, the window is exactly the region, and the code is correct. That is why this has gone unnoticed: in the ordinary case the wrong expression and the right one evaluate to the same address.Allocation and sweeping then advance independently. A collection whose target generation is 0 sweeps generation 0 in place and raises the cursor to the allocation pointer (
c/gc.c:971):Nothing lowers it again while the region stays in use, so from that point
sweep_locsits abovebase_locby however much had been allocated. The next region closed off inside a bracket — which happens as soon as a code allocation exhausts the current one, and a fasl read will do it — is recorded at that raised cursor.An allocation cursor stops at object boundaries. It has no reason to be page aligned, and generally is not.
Why this has never fired upstream
WRITE_XOR_EXECUTE_CODEis defined at exactly one place in the tree,c/version.h:346, inside theTARGET_OS_IPHONEarm. No other supported configuration compilesenable_code_writeat all, so no other configuration reaches the drain loop.That is worth stating explicitly, because it defeats the obvious shortcut: an argument of the form "Chez would fail on Linux too if the address were unaligned" proves nothing here, since a default Linux build does not compile this function. Define the macro explicitly on an ordinary host and the path compiles and the failure reproduces.
Reaching the divergence needs, in addition, a collection with target generation 0 —
(collect 0 0)requests one — followed by enough code allocation to close a region off while a bracket is open.How we hit it
We hit it porting Chez to an operating system of our own, with a kernel, a libc and a userland all written from scratch. Chez runs there natively as a threaded x86-64 build with 4K pages. Our kernel refuses to map any page both writable and executable, so the default x86-64
S_PROT_CODEofPROT_READ|PROT_WRITE|PROT_EXECis not available to us; we defineWRITE_XOR_EXECUTE_CODEfor that target so that code is writtenRWand then flipped toRX. That is why we compile this function at all. The arithmetic reported here has nothing to do with our kernel — it is the same expression on every platform that compiles this loop.Chez aborted at startup on
failed to protect recent allocation segments, which is what sent us into this function. Two separate faults were waiting there. Our own kernel had a conformance bug of its own in the same call — it rejected anmprotectlength that was not a page multiple, where POSIX permits one, andsweep_bytesis an allocation distance that is essentially never a page multiple — and fixing that cleared the abort we had captured. We have not established which of the two produced that particular capture, and I would not want the report to rest on it.What the report rests on instead is the code and the reproduction below, which needs none of our platform.
The abort message is a useful discriminator when reading captures.
c/segment.c:676is "failed to protect current allocation segments" — the whole-region flip, whose address isbase_locand which is therefore always correct. This is:688, "recent". The two messages differ by one word and by which branch they prove.Reproduction on stock x86-64 Linux
Built from
mainat659c263e, Chez 10.5.0-pre-release.1, machine typeta6le,./configure --threads --disable-x11 --disable-curses CFLAGS+=-DWRITE_XOR_EXECUTE_CODE, GCC 16.1.0, 4K pages. The two arms differ by exactly one line ofc/segment.c;mats/7.ms,c/types.hand everything else are identical between them, andschemewas confirmed relinked (its sha256 changed) before each run.c/segment.c:685zuo . 7.mo o=3Finished loading matfailed to protect recent allocation segmentsaddr = TO_VOIDP(sip->sweep_start)(current)addr = TO_VOIDP(build_ptr(sip->number, 0))(fix)The pre-fix run stops inside
code-write-flip.7.outends:Instrumenting the drain loop to print both candidate addresses gives one line per arm, and they differ only in which one reaches
mprotect:The cursor sits 59520 bytes above the region base in both arms, so the passing run is not a case of the path going unexercised — the divergence happens either way. The only difference is that
sweep_startis 0x880 into a page and the segment base is not.bytesis identical in both, which is the point: it was always measured from the base.The fix
Take the address from the segment rather than from the cursor:
This is
base_locby construction, and therefore the address the length was measured from. The argument is in two steps, both from the code quoted above:S_find_more_gc_roomsetsbase_loctobuild_ptr(seg, 0).seginfocarrying the record is the one for the segment containingbase_loc—close_off_segmentobtains it asSegInfo(addr_get_segment(base_loc)).So
build_ptr(sip->number, 0)is the base of the segment the region starts in, which is the region's base. It is also how the rest ofenable_code_writeforms addresses: the hint path at:658isbuild_ptr(seg, 0), and the current-region flip at:669usesbase_locdirectly.The patch also corrects the field's comment in
c/types.h:162, which describedsweep_bytesas "total number of bytes starting atsweep_start". It never was; it is measured from the region's base. The comment is arguably the root of the defect, since it documents the pairing the code then implements.Why the ordinary case was always correct
Where no target-generation-0 collection has intervened,
sweep_loc == base_loc == build_ptr(sip->number, 0), and the old and new expressions are the same address. The change is a no-op on any run that could not reach the bug.The repair that does not work
Making the length agree with the cursor —
sweep_bytes = old - sweep_loc— is the natural-looking alternative and it fixes nothing. The address is still an object boundary, andmprotectrejects an unaligned address whatever length accompanies it. Recorded because it is the first thing one tries.Testing
mats/7.msgains(mat code-write-flip ...), which drives the sequence from Scheme: generation-0 code kept live, a(collect 0 0)to raise the cursor off the region base, enough further code allocation to close a region off, and then a fasl read to drain thesweep_nextchain. It checks that the live procedures still run afterwards and that the loaded definition works.It can only fail where
WRITE_XOR_EXECUTE_CODEis defined; on every other configuration it runs the same Scheme and passes, in about a quarter of a second. That is the most a mat can do here, since the defect is in a function those configurations do not compile, but it does mean the sequence is exercised on every platform rather than bit-rotting.Measured both ways in the reproduction above: it aborts the run on the current code and passes on the fix, and
7.msotherwise reports 49 mats with noBug, no unexpectedErrorand noinvalid memory.Release notes
release_notes/release_notes.stexgains aBug Fixessubsection, "Code-write protection after a generation-0 collection (10.5.0)".Scope
bytes_per_segment; this one is thesweep_nextdrain and needs a target-generation-0 collection. They touch different lines, either can be taken without the other, and they merge cleanly together.WRITE_XOR_EXECUTE_CODE. Those do fail, but the comment aboveS_thread_start_code_writealready documents the mechanism as best-effort and notes that a process-wide W^X disposition "seems incompatible" with foreign-thread callbacks. That is stated behaviour, not a defect. These two address bugs are a different category: arithmetic that is wrong on its own terms.