bytecodealliance / bytecodealliance/wasmtime-dotnet

[Bug]: Process fail-fast (0xC0000409) on Windows x64 when guest traps under .NET 9+ (CET shadow stack)

Open
#374 2 comments 0 reactions 0 assignees View on GitHub
Dominant language
C#
Stars
519
Forks
67
Avg merge
1h 7m
Merged PRs (30d)
1

Description

### Summary

On Windows x64, a WebAssembly guest trap that is **hardware-signaled** (`unreachable`, memory out-of-bounds, integer divide-by-zero) terminates the host process with **`0xC0000409` (`STATUS_STACK_BUFFER_OVERRUN`, exit `-1073740791`)** instead of surfacing as a catchable `Wasmtime.TrapException`.

**Root cause (confirmed): Intel CET / hardware shadow stack enforcement.** Since **.NET 9 Preview 6**, the .NET SDK marks the generated Windows `apphost` executable with `/CETCOMPAT` ([documented breaking change](https://learn.microsoft.com/en-us/dotnet/core/compatibility/interop/9.0/cet-support)). This enables the hardware shadow stack for the process. Wasmtime's Windows trap recovery performs a non-local thread-context restore to a landing pad that is neither present on the shadow stack nor registered in an EH continuation allow-list, so Windows terminates the process.

Cooperative metered traps (fuel / epoch) are **not** affected — they return through the normal call path and never perform a non-local context restore.

> [!NOTE]
> This issue was originally filed as a **.NET 10 regression** with an SEH / security-cookie theory, and stated that disabling CET had no effect. **All three of those claims were wrong.** They are struck through in [Retracted claims](#retracted-claims-from-the-original-report) at the bottom rather than deleted, since earlier comments refer to them. Body corrected 2026-08-17.

---

### Environment

| | |
| :--- | :--- |
| OS | Windows 11, build 26200 (x64) |
| CPU | AMD Ryzen 7 7735HS (Zen 3+, shadow-stack capable) |
| SDK | .NET SDK 10.0.302 |
| Runtimes | `Microsoft.NETCore.App` 8.0.30 and 10.0.10 |
| Package | `Wasmtime` 44.0.0 |
| System mitigation policy | `UserShadowStack: NOTSET` (OS defaults, no per-machine override) |
| Configurations | Reproduces in **both Debug and Release**, deterministically (5/5 runs) |

Although the feature is branded *Intel CET*, this reproduces on an **AMD** CPU — it is not vendor-specific.

The original report listed "**.NET SDK**: .NET 10.0.10". That figure is the **runtime** version (`Microsoft.NETCore.App` 10.0.10), not the SDK version; .NET SDK versions use feature bands (`10.0.3xx`), and the SDK here is `10.0.302`. Both are listed above under the correct names.

**Affected .NET versions: 9.0 and later** (anything whose apphost is `/CETCOMPAT`-marked). .NET 8 and earlier are unaffected.

---

### Trap matrix

Each scenario runs in its own process so that a fail-fast in one cannot mask the others.

| Guest trap | .NET 8 | .NET 10 (CET on) | .NET 10 (CET disabled) |
| :--- | :--- | :--- | :--- |
| `unreachable` | ✅ `Unreachable` | ❌ `0xC0000409` | ✅ `Unreachable` |
| memory out-of-bounds load | ✅ `MemoryOutOfBounds` | ❌ `0xC0000409` | ✅ `MemoryOutOfBounds` |
| integer divide-by-zero | ✅ `IntegerDivisionByZero` | ❌ `0xC0000409` | ✅ `IntegerDivisionByZero` |
| fuel exhaustion | ✅ `OutOfFuel` | ✅ `OutOfFuel` | ✅ `OutOfFuel` |
| epoch interruption | ✅ `Interrupt` | ✅ `Interrupt` | ✅ `Interrupt` |

---

### Minimal reproduction

```xml


Exe
net10.0
enable
enable



```

`Program.cs`:
```csharp
using Wasmtime;

Console.WriteLine("Starting Wasmtime probe on Windows x64...");

using var engine = new Engine();
using var module = Module.FromText(engine, "unreachable_test", @"
(module
(func (export ""run"")
unreachable
)
)
");

using var linker = new Linker(engine);
using var store = new Store(engine);
var instance = linker.Instantiate(store, module);
var run = instance.GetFunction("run");

if (run is null)
{
Console.Error.WriteLine("Failed to find exported function 'run'");
return 1;
}

try
{
Console.WriteLine("Invoking guest function that traps...");
run.Invoke();
Console.Error.WriteLine("FAIL: Expected TrapException was not thrown.");
return 1;
}
catch (TrapException ex)
{
Console.WriteLine($"SUCCESS: Caught expected TrapException: {ex.Message}");
return 0;
}
```

**Steps:**
1. Build and run the generated `.exe` on `net8.0` → prints `SUCCESS`, exit `0`.
2. Rebuild on `net10.0` (or `net9.0`) and run the generated `.exe` → process terminates, exit `-1073740791`.

> Run the **generated apphost `.exe`**, not `dotnet run`/`dotnet app.dll`. `dotnet.exe` is not CET-marked, so launching through it hides the bug entirely.

---

### Root-cause evidence

**Isolation controls.** Each row changes exactly one variable against the `net10.0` baseline:

| Experiment | Result | Eliminates |
| :--- | :--- | :--- |
| `net8.0` app on .NET 8 runtime | ✅ exit 0 | — (baseline) |
| `net8.0` app forced onto the **.NET 10.0.10 runtime** (`DOTNET_ROLL_FORWARD=LatestMajor`, resolution confirmed via `COREHOST_TRACE=1`) | ✅ exit 0 | the **runtime version** |
| `net10.0` app with `DOTNET_LegacyExceptionHandling=1` | ❌ `0xC0000409` | the **new managed EH model** added in .NET 9 |
| `net10.0` app with `lib/net8.0/Wasmtime.Dotnet.dll` swapped in | ❌ `0xC0000409` | the **managed assembly / package asset selection** |
| `net10.0` app launched via `dotnet.exe` instead of its apphost | ✅ exit 0 | → isolates the **host executable** |
| `net10.0` apphost with the CET bit patched to `0` | ✅ exit 0 | → **causation, single bit** |
| `net10.0` rebuilt with `false` | ✅ exit 0 | → supported workaround |

Two of these deserve emphasis:

- **The .NET 10 runtime is not implicated.** The same runtime executes the same IL successfully when the process is launched by a non-CET host.
- **Package asset selection is a red herring.** A `net10.0` project resolves `lib/net9.0/Wasmtime.Dotnet.dll` while `net8.0` resolves `lib/net8.0/…`; those files differ in content (identical length, same `44.0.0.0` assembly version). "The net9 managed build is at fault" is a natural first guess and it is wrong — forcing the `net8.0` assembly into the `net10.0` app still fail-fasts.

**PE-level state.** `IMAGE_DLLCHARACTERISTICS_EX_CET_COMPAT` (debug directory type 20) and the load-config EH continuation table:

| Binary | CET compat | `GuardFlags` | EH continuation targets |
| :--- | :--- | :--- | :--- |
| `apphost` (net8.0 build) | absent | `0x10417500` | 12 |
| `apphost` (net10.0 build) | **SET** | `0x10417500` | 12 |
| `dotnet.exe` (10.0.10) | absent | — | — |
| `coreclr.dll` (10.0.10) | — | `0x10417500` | 1017 |
| `wasmtime.dll` (44.0.0, win-x64) | absent | `0x00000100` | **0** |

The apphost carried `/EHCONT` in **both** .NET 8 and .NET 10. What .NET 9 changed is the `CETCOMPAT` bit that makes the constraint *enforced* — not the presence of continuation metadata. `wasmtime.dll` registers **zero** EH continuation targets and is not CFG-instrumented, so no address it continues to can be on an allow-list, by construction.

---

### Workaround

Opt the application out of CET:

```xml

false

```

This restores correct `TrapException` behavior for every trap kind. **Verify the flag actually landed in the emitted binary** — an incremental build will silently reuse an already-generated CET-marked apphost and the property will appear to do nothing:

```
dotnet build -c Release -p:CetCompat=false --no-incremental
```

Trade-off: this disables ROP protection for the whole process, so it is a mitigation rather than a fix.

Alternatively, opt the specific executable out via Windows Security / exploit-protection policy (`UserShadowStack`).

---

### Proposed upstream resolution

`wasmtime-dotnet` (or Wasmtime's Windows trap handling) needs to make its trap-recovery continuation target legal under CET. The correct mechanism depends on where that landing pad lives, which I have **not** established:

- if it is **static code inside `wasmtime.dll`** (a `setjmp`-style recovery point) → building that library with `/EHCONT` (plus CFG) is sufficient;
- if it is **JIT-generated code** → `SetProcessDynamicEHContinuationTargets` must be called at runtime as code regions are published.

At minimum, shipping a `win-x64` `wasmtime.dll` that is CET-aware would let .NET 9+ consumers keep shadow-stack protection enabled.

**What I did not do:** I did not attach a debugger to capture the faulting continuation address or the fail-fast subcode, so the specific `RtlRestoreContext`/`NtContinue` attribution is inference from the documented CET contract, not a direct observation. My measurements stop at "`wasmtime.dll` registers zero continuation targets." Happy to run that debugger session if it would help.

---

### Retracted claims from the original report

Preserved for reference because earlier comments respond to them.

1. ~~"This is a .NET 10 regression; .NET 8 works."~~ → The trigger landed in **.NET 9 Preview 6**. .NET 8 does work, but .NET 9 is equally affected; I simply went 8 → 10 and never tested 9.
2. ~~"Disabling CET (Hardware-enforced Stack Protection) does not alter the behavior."~~ → **False.** Disabling CET fixes it completely, on every trap kind. My original observation was almost certainly an incremental build reusing the CET-marked apphost — the rebuild reported success in 0.39 s and emitted an exe whose CET bit was still set.
3. ~~"Changes to SEH stack unwinding or security cookies in .NET 10 conflict with Wasmtime's native exception handler."~~ → **No evidence.** `DOTNET_LegacyExceptionHandling=1` does not help, which specifically rules out the .NET 9+ managed exception-handling rewrite.

Still standing from the original report: metered fuel/epoch traps behave correctly (re-verified above). The claim that Linux hosts (POSIX signals) are unaffected is carried over from the original report and was **not** re-tested in this round — though it is consistent with the mechanism, since CET shadow-stack enforcement is a Windows process mitigation.

Contributor guide

No contributing guide indexed for this repository

Assessment

This issue has not been assessed yet.

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.