Skip to content

ARM64: fix asymmetric hoost_ontop_fcontext epilog (0xc0 dealloc vs 0xd0 alloc) — deterministic /GS FAST_FAIL (0xc0000409) on Windows ARM64#345

Draft
FaithfulAudio wants to merge 1 commit into
microsoft:mainfrom
FacilitronWorks:fix/arm64-ontop-fcontext-stack-skew
Draft

ARM64: fix asymmetric hoost_ontop_fcontext epilog (0xc0 dealloc vs 0xd0 alloc) — deterministic /GS FAST_FAIL (0xc0000409) on Windows ARM64#345
FaithfulAudio wants to merge 1 commit into
microsoft:mainfrom
FacilitronWorks:fix/arm64-ontop-fcontext-stack-skew

Conversation

@FaithfulAudio

@FaithfulAudio FaithfulAudio commented Jul 12, 2026

Copy link
Copy Markdown

Summary

On Windows ARM64, hoost_ontop_fcontext's prolog allocates 0xd0 (208) bytes of stack
(sub sp, sp, #0xd0) but its epilog deallocates only 0xc0 (192) bytes
(add sp, sp, #0xc0) before tail-jumping into the "ontop" callback. That 16-byte (0x10)
shortfall is never reclaimed, so every control-flow path that takes the ontop continuation
(fiber teardown / forced_unwind, and any boost::context::fiber::resume_with-style
call) resumes its target context with sp sitting 16 bytes below the value the target
expects. On builds compiled with /GS (MSVC, on by default) or -fstack-protector-all
(Clang), the next __security_check_cookie in the resumed frame reads the wrong stack
slot and fails fast with STATUS_STACK_BUFFER_OVERRUN (0xc0000409) — indistinguishable,
from a crash-reporting standpoint, from a real stack-smash.

This is not a heap-corruption or use-after-free bug. It is a static, structural
off-by-0x10 in hand-written assembly: the skew is identical in magnitude and in the
exact bytes it exposes on every single occurrence, across independent ASLR bases and
across both engine builds we have crash dumps for.

This fixes both PE-target assembly variants that implement ontop_fcontext for ARM64 —
the Clang/GAS-syntax file that is the one actually linked into the shipping
Microsoft.JavaScript.Hermes binary, and the MSVC ARMASM64-syntax file used by the
alternate toolchain — making both symmetric with hoost_jump_fcontext (already 0xd0/0xd0
on the same target) and with the ELF/GAS ontop_fcontext variant (already 0xb0/0xb0,
smaller frame, no TIB save/restore, but symmetric).

Root cause

File: external/boost/boost_1_86_0/libs/context/src/asm/ontop_arm64_aapcs_pe_armasm.asm
(MSVC ARMASM64 syntax), as it stands today on main (f9abcf6a):

; line 65 — prolog
    sub  sp, sp, #0xd0
    ...
; line 127 — epilog (BUG: should be #0xd0)
    add  sp, sp, #0xc0

    ; jump to ontop-function
    ret x2

File: external/boost/boost_1_86_0/libs/context/src/asm/ontop_arm64_aapcs_pe_clang_gas.S
(Clang/GAS syntax — this is the variant actually compiled into the shipping
hermes.dll
, confirmed by disassembling the crashing binary):

/* line 68 — prolog */
    sub  sp, sp, #0xd0
    ...
/* line 130 — epilog (BUG: should be #0xd0) */
    add  sp, sp, #0xc0

    /* jump to ontop-function */
    ret  x2

For comparison, the sibling function on the identical target,
external/boost/boost_1_86_0/libs/context/src/asm/jump_arm64_aapcs_pe_armasm.asm, is
symmetric:

; line 65 — prolog
    sub  sp, sp, #0xd0
    ...
; line 129 — epilog (correct)
    add  sp, sp, #0xd0

0xd0 is the correct frame size for this target: 0x40 for d8-d15, 0x60 for
x19-x30, 0x10 for the saved PC + alignment pad, and 0x20 for the four
TEB/TIB fields (TeStackBase, TeStackLimit, TeDeallocationStack, TeFiberData)
that the Windows-ARM64 port saves/restores so C++ exception unwinding sees a
consistent TIB across the fiber switch. hoost_ontop_fcontext's own prolog agrees
(0xd0) — only its epilog under-deallocates by exactly the last 0x10 of that.

Mechanism: why the 16-byte skew corrupts the resumed frame

ontop_fcontext doesn't return to its caller in the normal sense — it's reached as
part of a context switch and tail-jumps into the "ontop" function via ret x2
(X2 holds the ontop-function's address, not a real return address). Per
boost::context::fiber's actual usage (~fiber()'s hoost_ontop_fcontext(...) call
for teardown, and resume_with() for the general case), that ontop function itself
later returns normally, via the target context's own restored LR, back into the
target's original suspended call site inside jump_fcontext/ontop_fcontext — a site
that expects sp to be exactly where it was when that context originally suspended.

hoost_jump_fcontext's symmetric epilog (0xd0/0xd0) leaves sp there. The buggy
ontop epilog leaves sp 16 bytes below it, permanently, for the remaining lifetime
of that resumed call frame.

Hermes' interpreter wrapper around the switch stores its /GS cookie at a fixed
[sp, #offset] computed by its own (compiler-generated, correct) prolog, then invokes
the fcontext switch, then runs __security_check_cookie on its own way out. Because
the switch returned control 16 bytes lower than the wrapper's prolog assumed, the
cookie check reads the wrong stack slot. In the corresponding crash dumps, that slot's
contents are exactly an fcontext register-save pair — the wrapper's own live resume PC
and x24 — planted 0x10 from where the save area should have ended; the true,
un-tampered cookie sits at [sp, #0x18] from that same base. Since the read value
doesn't XOR-match __security_cookie, __security_check_cookie calls
__report_gsfailure, which raises STATUS_STACK_BUFFER_OVERRUN via __fastfail — an
unmaskable fail-fast, not a catchable SEH/C++ exception, which is why it terminates the
process rather than propagating as a normal error.

A related, rarer signature (ucrtbase!abort via _CxxFrameHandler3) is consistent with
the same skew hitting the C++ exception-unwind path instead: forced_unwind-driven fiber
teardown also takes the ontop route, and _CxxFrameHandler3 walks frames using the
compiler's static .pdata/.xdata unwind metadata, which encodes each frame's expected
SP delta. A frame that's silently 16 bytes off from that expectation can cause the unwinder
to misidentify frame boundaries mid-walk; the CRT's response to that inconsistency is to
call abort() rather than continue unwinding into unknown state.

Frame bytes at the crash site are module-relative-identical across independent ASLR'd
runs and across both engine builds we have dumps for (which vendor the same assembly) —
i.e., this is a deterministic structural skew, not heap corruption. All app / native-module
threads were provably quiescent in every dump we inspected (KV worker parked in
SleepConditionVariableSRW; no WebView2 / MediaCapture activity), which rules out
app-code involvement.

The fix

Make hoost_ontop_fcontext's ARM64 epilog symmetric with its own prolog (add sp, sp, #0xd0), in both PE-target assembly variants, matching hoost_jump_fcontext on the
same target and the ELF/GAS ontop_fcontext variant (which is 0xb0/0xb0 — smaller
frame, no TIB fields, but symmetric). No other register logic changes; this is a pure
epilog stack-deallocation fix, 1 byte-width immediate changed in each of two files.

--- a/external/boost/boost_1_86_0/libs/context/src/asm/ontop_arm64_aapcs_pe_armasm.asm
+++ b/external/boost/boost_1_86_0/libs/context/src/asm/ontop_arm64_aapcs_pe_armasm.asm
@@ -122,9 +122,16 @@ hoost_ontop_fcontext proc BOOST_CONTEXT_EXPORT
     ; X0 == FCTX, X1 == DATA
     mov x0, x4

-    ; skip pc
-    ; restore stack from GP + FPU
-    add  sp, sp, #0xc0
+    ; skip pc (not read into a register -- ret targets X2 instead), but still
+    ; deallocate the full 0xd0 saved-context block (matching the prolog and
+    ; hoost_jump_fcontext's epilog). The ontop-function (X2) is entered via a
+    ; tail-jump, not a call, and its own eventual `ret` returns to the target
+    ; context's original LR expecting SP restored to the target's pre-suspend
+    ; value. Deallocating only 0xc0 here leaves SP 0x10 bytes below that value,
+    ; corrupting the target context's stack frame (observed as a /GS
+    ; __security_check_cookie FAST_FAIL, STATUS_STACK_BUFFER_OVERRUN /
+    ; 0xc0000409, on resume; see also related _CxxFrameHandler3 unwind aborts).
+    add  sp, sp, #0xd0

     ; jump to ontop-function
     ret x2
--- a/external/boost/boost_1_86_0/libs/context/src/asm/ontop_arm64_aapcs_pe_clang_gas.S
+++ b/external/boost/boost_1_86_0/libs/context/src/asm/ontop_arm64_aapcs_pe_clang_gas.S
@@ -125,9 +125,17 @@ hoost_ontop_fcontext:
     /* X0 == FCTX, X1 == DATA */
     mov  x0, x4

-    /* skip pc */
-    /* restore stack from GP + FPU */
-    add  sp, sp, #0xc0
+    /* skip pc (not read into a register -- ret targets X2 instead), but still
+       deallocate the full 0xd0 saved-context block (matching the prolog and
+       hoost_jump_fcontext's epilog). The ontop-function (X2) is entered via a
+       tail-jump, not a call, and its own eventual `ret` returns to the target
+       context's original LR expecting SP restored to the target's pre-suspend
+       value. Deallocating only 0xc0 here leaves SP 0x10 bytes below that
+       value, corrupting the target context's stack frame (observed as a /GS
+       __security_check_cookie FAST_FAIL, STATUS_STACK_BUFFER_OVERRUN /
+       0xc0000409, on resume; see also related _CxxFrameHandler3 unwind
+       aborts). */
+    add  sp, sp, #0xd0

     /* jump to ontop-function */
     ret  x2

Inherited from upstream boost.context — this is not a hermes-windows-specific defect

This exact asymmetry (0xd0 alloc / 0xc0 dealloc, both PE syntax variants) is present
verbatim in boostorg/context on develop today. We traced it to the PR that
originally added Windows ARM64 support
(boostorg/context#201, merged
2022-07-05) — specifically to commit abf8e04e23 ("Spport Windows arm64 cpp exception",
2022-06-26), which extended the frame from 0xb0 to 0xd0 to save/restore four TEB/TIB
fields for exception-unwind correctness, correctly rewired every other offset in the
function, but only bumped the epilog's deallocation from 0xb0 to 0xc0 instead of
0xd0. A second commit, e878e8edb2 ("Convert ARM64 armasm to armclang for Windows
clang", 2024-06-19), mechanically transcribed the buggy ARMASM64 file into the
Clang/GAS-syntax variant, carrying the same off-by-0x10 forward verbatim into a second
file. Neither hermes-windows nor RNW introduced this bug; it was vendored wholesale from
upstream boost 1.86.0.

We've filed a companion report against boostorg/context with the same root cause and
fix, framed for their file layout:
boostorg/context#336.
Fixing it here does not depend on that landing upstream first — hermes-windows vendors
its own copy of this file and can carry the fix independently, the same way it vendors
the rest of external/boost/boost_1_86_0/.

Relationship to Hermes V1 / RNW 0.84 (please read before suggesting an upgrade)

We're aware RNW 0.84 ships Hermes V1 (0.0.0-2605.6002). "Upgrade to 0.84" is not a
substitute for this fix, for two reasons:

  1. 0.83.x is a current, supported release line, and the engine builds that carry
    this defect (2604/2607) ship in it today. main as of this PR (f9abcf6a, which
    is also the exact git-sha suffix of the shipping 2607.6001-f9abcf6a engine build we
    have crash dumps for) still has the unfixed asm — this is a live defect on main, not
    a historical one.
  2. Not every app can move to 0.84 yet. In our case, react-native-reanimated has no
    0.84 build. Apps in that position are exposed to this crash through no configuration
    of their own, on a still-supported line.

We have not independently confirmed whether V1 carries the same defect or a corrected
epilog (V1's ontop_fcontext codegen path may simply be exercised less, which would
mask rather than fix it). Happy to check if useful, but either way this fix should land
on the current line.

Environment

  • Windows 11 ARM64 (Windows-on-ARM; observed under Parallels on Apple silicon during
    investigation, and on native Windows-on-ARM64 hardware in production).
  • react-native-windows 0.83.2, new architecture (Fabric/Hermes).
  • Engine builds where the defect reproduces:
    • Microsoft.JavaScript.Hermes 0.0.0-2604.21001-94aa5e1d (April) — crash at
      hermes+0x4a209c (6 dumps).
    • Microsoft.JavaScript.Hermes 0.0.0-2607.6001-f9abcf6a (July) — crash at
      hermes+0x4a1ffc (2 dumps). Note the f9abcf6a build suffix matches
      microsoft/hermes-windows main's current HEAD short SHA.
  • x86 (emulated) builds use different fcontext assembly and do not show this signature
    — useful as an A/B control.

Validation

The fix was built and its shipped form verified on Windows ARM64. Building the fork
branch with the shipping CI configuration (build.js --platform arm64 --configuration release --no-uwp; Clang, /GS + -fstack-protector-all) succeeds in ~11 min. Both the
ninja build log and compile_commands.json confirm that the assembler variant actually
compiled into arm64/hermes.dll is ontop_arm64_aapcs_pe_clang_gas.S
(clang.exe -target aarch64-pc-windows-msvc; CMake selects "assembler clang_gas …
implementation fcontext"); the ARMASM64 .asm variant is not compiled on this toolchain
(it is fixed identically for correctness/symmetry but is dead on Clang builds).

The pre-fix crash is well characterised: archived minidumps show the
deterministic-when-hit STATUS_STACK_BUFFER_OVERRUN (0xc0000409,
FAST_FAIL_STACK_COOKIE_CHECK_FAILURE) on the fcontext coroutine-resume path in stock
hermes.dll 0.0.2607.6001. The patched engine was then swapped into the deployed
production app and confirmed loaded via a debugger attach (loaded-module FileVersion
1.0.0.0 / distinct ImageSize, not the stock 0.0.2607.6001), and it ran the app's
heaviest JS↔native workload — a full bulk sync of 1,328 inspections plus the SVG-heavy
dashboard — cleanly.

Honest scope: the crash is probabilistic and scales with JS↔native volume; on the
current perf-optimized app it did not reproduce on demand within a bounded stock-repro
window, so a same-run "stock-crashes / patched-survives" differential was not captured.
The fix is otherwise correct by inspection: it makes the ontop_fcontext epilog
symmetric with its own prolog (0xd0/0xd0), matching hoost_jump_fcontext and the
symmetric ELF ontop variant. Happy to share the 8 crash dumps and cdb disassembly, or
coordinate a maintainer repro on ARM64 hardware, before merge.

Test Plan

  • Clean ARM64 build of hermes-windows with this change (both Debug and
    Release, if maintainers' CI distinguishes).
  • Confirm ontop_arm64_aapcs_pe_armasm.asm and ontop_arm64_aapcs_pe_clang_gas.S
    both now show add sp, sp, #0xd0 in the hoost_ontop_fcontext epilog.
  • (see Validation above for the empirical-repro vs. inspection-only status of the
    production crash-trigger test.)
  • No changes to hoost_jump_fcontext, hoost_make_fcontext, the ELF/GAS
    ontop_fcontext variant, or any non-ARM64/non-PE target — diff is scoped to the
    two files above.

Related

Microsoft Reviewers: Open in CodeFlow

hoost_ontop_fcontext's ARM64 Windows-PE epilog (both the armasm and
clang-gas syntax variants) deallocated only 0xc0 of the 0xd0 bytes its
own prolog allocated, an asymmetry not present in hoost_jump_fcontext
(0xd0/0xd0, symmetric) nor in the ELF/GAS ontop_fcontext variant (0xb0/
0xb0, symmetric -- smaller struct, no TIB fields).

The "ontop" function (X2) is reached via `ret x2`, a tail-jump, not a
call. Per boost::context::fiber's actual usage (fiber_fcontext.hpp:
~fiber()'s hoost_ontop_fcontext(...) call, and resume_with()), that
function itself later returns normally via the target context's
restored LR, back into the target's own suspended call site -- which
expects SP restored to its pre-suspend value, exactly as
hoost_jump_fcontext's symmetric epilog would leave it. Deallocating
only 0xc0 leaves SP 0x10 bytes below that value once the ontop
function returns, corrupting the resumed context's stack frame.

On Windows ARM64 with /GS (MSVC) or -fstack-protector-all (Clang)
enabled, this manifests as a deterministic __security_check_cookie
FAST_FAIL (STATUS_STACK_BUFFER_OVERRUN, 0xc0000409) at a fixed
module-relative offset, and related _CxxFrameHandler3 unwind aborts
when the ontop function raises forced_unwind (fiber teardown/
exception-unwind path).

Fix: make the epilog symmetric with the prolog (0xd0/0xd0), matching
hoost_jump_fcontext. This exact asymmetry is also present verbatim
upstream in boostorg/context (introduced with the original Windows
ARM64 port, PR "Add windows arm64 fcontext support", 2022); it appears
to predate hermes-windows' vendoring rather than being a divergence
introduced here.

Co-Authored-By: Claude Opus 4.8 (1M context) <[email protected]>
@azure-pipelines

Copy link
Copy Markdown
Azure Pipelines:
There may be pipelines that require an authorized user to comment /azp run to run.

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.

1 participant