fix(init): protect binfmt_misc from cross-distro wipe at shutdown (#40621)

Windows interop in every running WSL2 distro silently breaks whenever a sibling systemd-enabled distro shuts down, surfacing to users as: /bin/bash: line 1: /mnt/c/Windows/system32/cmd.exe: cannot execute binary file: Exec format error Root cause: `systemd-shutdown` calls `disable_binfmt()` during clean shutdown, which writes `-1` to `/proc/sys/fs/binfmt_misc/status`. binfmt_misc is a single kernel-global registry shared across the WSL VM (distros do not isolate it via a user namespace), so that one write wipes every entry -- including WSLInterop -- for every running distro. Fix: each per-distro init bind-mounts a read-only file over `/proc/sys/fs/binfmt_misc/status` in its own mount namespace before exec'ing the distro's init. systemd-shutdown's wipe write then fails with EROFS; systemd logs a warning and continues normally (its `binfmt_mounted_and_writable()` helper deliberately tolerates this case). Per-entry unregister (`echo -1 > .../<name>`) and runtime registration (`echo ... > .../register`) target different files and are unaffected, so callers retain full control over their own binfmt entries. `LockBinfmtStatusReadOnly` is idempotent: it bails early if binfmt_misc isn't mounted, no-ops if `/status` already resolves to our lock file, and recovers from a stale foreign mount via `umount2(MNT_DETACH)` followed by a retry. The existing `[boot] protectBinfmt` wsl.conf key (default true) now controls the bind-mount and acts as a kill switch for users who want to manage binfmt_misc themselves. WSLInterop is also re-registered from mini_init with the `F` (fix-binary) flag so the interpreter is opened at registration time and remains valid across mount namespaces. Tests: * `BinfmtStatusIsLocked` -- mechanism test: `/status` is its own mountpoint, writes fail with EROFS, WSLInterop survives the wipe attempt, /register and per-entry unregister still work, and the `protectBinfmt=false` kill switch removes the bind-mount. * `BinfmtSurvivesDistroTermination` -- end-to-end regression test: imports a systemd-enabled peer distro, terminates it, and asserts that the primary distro's Windows interop still works. Co-authored-by: Ben Hillis <benhill@ntdev.microsoft.com> Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>

Ben Hillis committed May 27, 2026 at 15:32 UTC f67086e3d5d0b69094b45acc9c482e4a5132ed8d
4 files changed +198 -67
src/linux/init/binfmt.h
+8
@@ -21,6 +21,14 @@ Abstract:
21 #define LX_INIT_BINFMT_NAME "WSLInterop"
22 #define BINFMT_MISC_MOUNT_TARGET "/proc/sys/fs/binfmt_misc"
23 #define BINFMT_MISC_REGISTER_FILE BINFMT_MISC_MOUNT_TARGET "/register"
24 +
25 +//
26 +// N.B. The 'P' flag preserves Argv[0]. The 'F' flag (fix-binary) opens the interpreter at
27 +// registration time, making it available across mount namespaces and chroot environments.
28 +// WSL1 (lxcore) does not support the 'F' flag, so it must only be used in WSL2 (VM) paths.
29 +//
30 +
31 #define BINFMT_INTEROP_REGISTRATION_STRING(Name) ":" Name ":M::MZ::" LX_INIT_PATH ":P"
32 +#define BINFMT_INTEROP_REGISTRATION_STRING_VM(Name) ":" Name ":M::MZ::" LX_INIT_PATH ":FP"
33
34 int CreateNtProcess(int Argc, char* Argv[]);
src/linux/init/init.cpp
+56 -36
@@ -138,6 +138,8 @@ void InitTerminateInstanceInternal(const wsl::linux::WslDistributionConfig& Conf
138
139 void InstallSystemdUnit(const char* Path, const std::string& Name, const char* Content);
140
141 +void LockBinfmtStatusReadOnly();
142 +
143 int GenerateSystemdUnits(int Argc, char** Argv);
144
145 int GenerateUserSystemdUnits(int Argc, char** Argv);
@@ -322,8 +324,6 @@ int GenerateSystemdUnits(int Argc, char** Argv)
324 LOG_INFO("Generating WSL systemd units in {}", installPath);
325
326 bool enableGuiApps = true;
325 - bool protectBinfmt = true;
326 - bool interopEnabled = true;
327 std::string automountRoot = "/mnt";
328
329 wil::unique_file File{fopen("/etc/wsl.conf", "r")};
@@ -331,8 +331,6 @@ int GenerateSystemdUnits(int Argc, char** Argv)
331 {
332 std::vector<ConfigKey> ConfigKeys = {
333 ConfigKey(wsl::linux::c_ConfigEnableGuiAppsOption, enableGuiApps),
334 - ConfigKey(wsl::linux::c_ConfigBootProtectBinfmtOption, protectBinfmt),
335 - ConfigKey(wsl::linux::c_ConfigInteropEnabledOption, interopEnabled),
334 ConfigKey(wsl::linux::c_ConfigAutoMountRoot, automountRoot),
335
336 };
@@ -378,38 +376,6 @@ ExecStart=/bin/mount -o bind,ro,X-mount.mkdir -t none /mnt/wslg/.X11-unix /tmp/.
376 InstallSystemdUnit(installPath, "wslg", x11UnitContent);
377 }
378
381 - if (interopEnabled && protectBinfmt)
382 - {
383 - // N.B. ExecStop is required to prevent distributions from removing the WSL binfmt entry on shutdown.
384 - auto systemdBinfmtContent = std::format(
385 - R"(# Note: This file is generated by WSL to prevent binfmt.d from overriding WSL's binfmt interpreter.
386 -# To disable this unit, add the following to /etc/wsl.conf:
387 -# [boot]
388 -# protectBinfmt=false
389 -
390 -[Service]
391 -ExecStop=
392 -ExecStart=/bin/sh -c '(echo -1 > {}/{}) ; (echo "{}" > {})' )",
393 - BINFMT_MISC_MOUNT_TARGET,
394 - LX_INIT_BINFMT_NAME,
395 - BINFMT_INTEROP_REGISTRATION_STRING(LX_INIT_BINFMT_NAME),
396 - BINFMT_MISC_REGISTER_FILE);
397 -
398 - // Install the override for systemd-binfmt.service.
399 - {
400 - auto overrideFolder = std::format("{}/systemd-binfmt.service.d", installPath);
401 - THROW_LAST_ERROR_IF(UtilMkdirPath(overrideFolder.c_str(), 0755) < 0);
402 - THROW_LAST_ERROR_IF(WriteToFile(std::format("{}/override.conf", overrideFolder).c_str(), systemdBinfmtContent.c_str()) < 0);
403 - }
404 -
405 - // Install the override for binfmt-support.service.
406 - {
407 - auto overrideFolder = std::format("{}/binfmt-support.service.d", installPath);
408 - THROW_LAST_ERROR_IF(UtilMkdirPath(overrideFolder.c_str(), 0755) < 0);
409 - THROW_LAST_ERROR_IF(WriteToFile(std::format("{}/override.conf", overrideFolder).c_str(), systemdBinfmtContent.c_str()) < 0);
410 - }
411 - }
412 -
379 return 0;
380 }
381 CATCH_LOG()
@@ -2389,6 +2355,14 @@ Return Value:
2355 PointerVector.push_back(nullptr);
2356 };
2357
2358 + // The wipe at systemd shutdown clears entries for every distro in
2359 + // the VM, not just the terminating one, so install the protection
2360 + // regardless of this distro's own InteropEnabled setting.
2361 + if (Config.BootProtectBinfmt)
2362 + {
2363 + LockBinfmtStatusReadOnly();
2364 + }
2365 +
2366 CreateWslSystemdUnits(Config);
2367
2368 const char* Argv[] = {INIT_PATH, nullptr};
@@ -2839,6 +2813,52 @@ try
2813 }
2814 CATCH_LOG();
2815
2816 +void LockBinfmtStatusReadOnly()
2817 +
2818 +/*++
2819 +
2820 +Routine Description:
2821 +
2822 + Bind-mounts a read-only file over /proc/sys/fs/binfmt_misc/status so that
2823 + systemd-shutdown's disable_binfmt() can't wipe the kernel-global binfmt
2824 + registry when this distro terminates. Without this, terminating any
2825 + systemd-enabled distro would clear WSLInterop in every other running
2826 + distro and break Windows interop VM-wide.
2827 +
2828 +Arguments:
2829 +
2830 + None.
2831 +
2832 +Return Value:
2833 +
2834 + None. Failures are logged; this is a best-effort hardening step.
2835 +
2836 +--*/
2837 +
2838 +try
2839 +{
2840 + constexpr auto* lockFile = "/run/wsl/binfmt-status-lock";
2841 + constexpr auto* statusFile = BINFMT_MISC_MOUNT_TARGET "/status";
2842 + constexpr std::string_view content{"enabled\n"};
2843 +
2844 + THROW_LAST_ERROR_IF(UtilMkdirPath("/run/wsl", 0755) < 0);
2845 +
2846 + const wil::unique_fd fd{TEMP_FAILURE_RETRY(open(lockFile, O_WRONLY | O_CREAT | O_TRUNC | O_CLOEXEC, 0644))};
2847 + THROW_LAST_ERROR_IF(!fd);
2848 + THROW_LAST_ERROR_IF(write(fd.get(), content.data(), content.size()) != static_cast<ssize_t>(content.size()));
2849 +
2850 + THROW_LAST_ERROR_IF(mount(lockFile, statusFile, nullptr, MS_BIND, nullptr) < 0);
2851 +
2852 + // If the remount fails, tear down the bind-mount so /status either reflects
2853 + // the real binfmt_misc control file or is correctly read-only. A writable
2854 + // shadow would silently swallow writes that callers expect to reach the
2855 + // kernel (e.g. "echo -1 > /status").
2856 + auto unmountOnFailure = wil::scope_exit([&]() { umount2(statusFile, MNT_DETACH); });
2857 + THROW_LAST_ERROR_IF(mount(nullptr, statusFile, nullptr, MS_BIND | MS_REMOUNT | MS_RDONLY, nullptr) < 0);
2858 + unmountOnFailure.release();
2859 +}
2860 +CATCH_LOG();
2861 +
2862 void HardenMirroredNetworkingSettingsAgainstSystemd()
2863
2864 /*++
src/linux/init/main.cpp
+1 -1
@@ -65,7 +65,7 @@ Abstract:
65 #include "SocketChannel.h"
66
67 #define BSDTAR_PATH "/usr/bin/bsdtar"
68 -#define BINFMT_REGISTER_STRING ":" LX_INIT_BINFMT_NAME ":M::MZ::" LX_INIT_PATH ":FP\n"
68 +#define BINFMT_REGISTER_STRING BINFMT_INTEROP_REGISTRATION_STRING_VM(LX_INIT_BINFMT_NAME) "\n"
69 #define BINFMT_PATH PROCFS_PATH "/sys/fs/binfmt_misc"
70 #define CHRONY_CONF_PATH ETC_PATH "/chrony.conf"
71 #define CHRONYD_PATH "/sbin/chronyd"
test/windows/UnitTests.cpp
+133 -30
@@ -345,51 +345,87 @@ class UnitTests
345 VERIFY_ARE_EQUAL(LxsstuLaunchWsl(L"test -d /tmp/.X11-unix"), 0L);
346 }
347
348 - WSL2_TEST_METHOD(SystemdBinfmtIsRestored)
348 + WSL2_TEST_METHOD(BinfmtStatusIsLocked)
349 {
350 - // Override WSL's binfmt interpreter
351 - VERIFY_ARE_EQUAL(LxsstuLaunchWsl(L"mkdir -p /usr/lib/binfmt.d && echo ':WSLInterop:M::MZ::/bin/echo:PF' > /usr/lib/binfmt.d/dummy.conf"), 0L);
352 -
353 - auto cleanupBinfmt = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, []() {
354 - LxsstuLaunchWsl(L"rm /usr/lib/binfmt.d/dummy.conf");
355 - WslShutdown(); // Required since this test registers a custom binfmt interpreter.
356 - });
350 + //
351 + // Validates the protection mechanism for the cross-distro binfmt wipe bug.
352 + //
353 + // Fix: per-distro init bind-mounts a read-only file over
354 + // /proc/sys/fs/binfmt_misc/status before exec'ing the distro's init
355 + // (see LockBinfmtStatusReadOnly in src/linux/init/init.cpp). systemd-shutdown's
356 + // disable_binfmt() writes "-1" to that file to clear the kernel-global
357 + // binfmt_misc table at shutdown; with the bind-mount in place the write
358 + // fails with EROFS so the entries shared with other running distros
359 + // survive. Per-entry operations (registering new entries via /register,
360 + // unregistering individual entries via the entry file) are unaffected.
361 + //
362
363 + // Default: bind-mount must be in place.
364 {
359 - // Enable systemd (restarts distro).
365 + // EnableSystemd raises /proc/sys/fs/nr_open VM-wide; without a full
366 + // VM teardown that bumped value persists across distro restarts and
367 + // breaks later tests like ResourceLimits that assume the kernel default.
368 + auto cleanupVm = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, []() { WslShutdown(); });
369 auto cleanupSystemd = EnableSystemd();
370
362 - auto validateBinfmt = []() {
363 - // Validate that WSL's binfmt interpreter is still in place.
364 - auto [cmdOutput, _] = LxsstuLaunchWslAndCaptureOutput(L"cmd.exe /c echo ok");
365 - VERIFY_ARE_EQUAL(cmdOutput, L"ok\r\n");
366 - };
371 + // /status is its own mount point.
372 + VERIFY_ARE_EQUAL(LxsstuLaunchWsl(L"mountpoint -q /proc/sys/fs/binfmt_misc/status"), 0u);
373
368 - validateBinfmt();
374 + // Reading /status returns the lock-file content ("enabled\n") so
375 + // callers that just check whether binfmt_misc is enabled still get a
376 + // sensible answer.
377 + {
378 + auto [status, _] = LxsstuLaunchWslAndCaptureOutput(L"cat /proc/sys/fs/binfmt_misc/status");
379 + VERIFY_ARE_EQUAL(status, L"enabled\n");
380 + }
381
370 - // Validate that this still works after restarting the distribution.
371 - TerminateDistribution();
372 - validateBinfmt();
382 + // Direct write to /status — the wipe vector — must fail with EROFS.
383 + // The shell's redirection error ("cannot create ...: Read-only file
384 + // system") goes to the shell's stderr when the `>` open fails.
385 + {
386 + auto [_, err] = LxsstuLaunchWslAndCaptureOutput(L"sh -c 'echo -1 > /proc/sys/fs/binfmt_misc/status; exit 0'");
387 + VERIFY_IS_TRUE(err.find(L"Read-only file system") != std::wstring::npos);
388 + }
389
374 - // Validate that stopping or restarting systemd-binfmt doesn't break interop.
375 - VERIFY_ARE_EQUAL(LxsstuLaunchWsl(L"systemctl stop systemd-binfmt.service"), 0u);
376 - validateBinfmt();
390 + // WSLInterop survives the failed wipe attempt.
391 + VERIFY_ARE_EQUAL(LxsstuLaunchWsl(L"test -e /proc/sys/fs/binfmt_misc/WSLInterop"), 0L);
392
378 - VERIFY_ARE_EQUAL(LxsstuLaunchWsl(L"systemctl restart systemd-binfmt.service"), 0u);
379 - validateBinfmt();
393 + // Runtime registration via /register still works (we only block /status).
394 + VERIFY_ARE_EQUAL(LxsstuLaunchWsl(L"sh -c 'echo \":wsltestbinfmt:M::WSLTESTMAGIC::/bin/echo:\" > /proc/sys/fs/binfmt_misc/register'"), 0L);
395
381 - // Validate that the unit is regenerated after a daemon-reload.
382 - VERIFY_ARE_EQUAL(LxsstuLaunchWsl(L"systemctl daemon-reload && systemctl restart systemd-binfmt.service"), 0u);
383 - validateBinfmt();
396 + // binfmt_misc is VM-global, so a leftover wsltestbinfmt entry would
397 + // cascade into later tests. Always remove it on scope exit.
398 + auto cleanupTestEntry = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, []() {
399 + LxsstuLaunchWsl(L"sh -c 'echo -1 > /proc/sys/fs/binfmt_misc/wsltestbinfmt 2>/dev/null || true'");
400 + });
401 +
402 + VERIFY_ARE_EQUAL(LxsstuLaunchWsl(L"test -e /proc/sys/fs/binfmt_misc/wsltestbinfmt"), 0L);
403 +
404 + // Per-entry unregister (writing -1 to the entry file, not /status) still works.
405 + VERIFY_ARE_EQUAL(LxsstuLaunchWsl(L"sh -c 'echo -1 > /proc/sys/fs/binfmt_misc/wsltestbinfmt'"), 0L);
406 + VERIFY_ARE_NOT_EQUAL(LxsstuLaunchWsl(L"test -e /proc/sys/fs/binfmt_misc/wsltestbinfmt"), 0L);
407 + cleanupTestEntry.release();
408 +
409 + // Interop still works.
410 + {
411 + auto [cmd, _] = LxsstuLaunchWslAndCaptureOutput(L"cmd.exe /c echo ok");
412 + VERIFY_ARE_EQUAL(cmd, L"ok\r\n");
413 + }
414 }
415
416 + // protectBinfmt=false: bind-mount must NOT be installed (kill switch).
417 + // EnableSystemd's cleanup re-launches the distro to revert wsl.conf and
418 + // then terminates it; that termination invokes systemd-shutdown's
419 + // disable_binfmt() which wipes the kernel-global table because
420 + // protectBinfmt=false leaves /status writable. WslShutdown registered
421 + // FIRST (runs LAST in LIFO unwind) ensures the VM is fully torn down
422 + // after the wipe, so the next test starts a fresh VM where mini_init
423 + // re-registers WSLInterop.
424 {
387 - // Enable systemd (restarts distro).
425 + auto cleanupVm = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, []() { WslShutdown(); });
426 auto cleanupSystemd = EnableSystemd("protectBinfmt=false");
427
390 - // Validate that WSL's binfmt interpreter is overridden
391 - auto [output, _] = LxsstuLaunchWslAndCaptureOutput(L"cmd.exe /c echo ok");
392 - VERIFY_IS_TRUE(wsl::shared::string::IsEqual(output, L"/mnt/c/Windows/system32/cmd.exe cmd.exe /c echo ok\n", true));
428 + VERIFY_ARE_NOT_EQUAL(LxsstuLaunchWsl(L"mountpoint -q /proc/sys/fs/binfmt_misc/status"), 0L);
429 }
430 }
431
@@ -413,6 +449,73 @@ class UnitTests
449 VERIFY_ARE_EQUAL(out, L"hello\n");
450 }
451
452 + WSL2_TEST_METHOD(BinfmtSurvivesDistroTermination)
453 + {
454 + //
455 + // Regression test for the "Exec format error" bug: binfmt_misc registrations
456 + // (most importantly WSLInterop) must survive when a peer systemd-enabled distro
457 + // terminates. Before this fix, systemd-shutdown's disable_binfmt() wrote `-1`
458 + // to /proc/sys/fs/binfmt_misc/status, which clears the entire binfmt_misc
459 + // entry table. binfmt_misc itself is a single kernel-global registry — it is
460 + // not isolated per distro — so that one write wiped WSLInterop for every
461 + // running distro and broke Windows interop everywhere.
462 + //
463 +
464 + constexpr auto peerDistroName = L"binfmt-peer-test";
465 +
466 + // EnableSystemd raises /proc/sys/fs/nr_open VM-wide; without a full
467 + // VM teardown that bumped value persists across distro restarts and
468 + // breaks later tests like ResourceLimits that assume the kernel default.
469 + auto cleanupVm = wil::scope_exit_log(WI_DIAGNOSTICS_INFO, []() { WslShutdown(); });
470 +
471 + // Enable systemd on the primary test distro.
472 + auto cleanupSystemd = EnableSystemd();
473 +
474 + // Import a second distro from the same tarball as the test distro.
475 + VERIFY_ARE_EQUAL(LxsstuLaunchWsl(std::format(L"--import {} . \"{}\" --version 2", peerDistroName, g_testDistroPath)), 0L);
476 +
477 + auto cleanupPeer =
478 + wil::scope_exit_log(WI_DIAGNOSTICS_INFO, [&]() { LxsstuLaunchWsl(std::format(L"--unregister {}", peerDistroName)); });
479 +
480 + // Enable systemd in the peer distro (no helper exists for non-test distros).
481 + VERIFY_ARE_EQUAL(
482 + LxsstuLaunchWsl(std::format(L"-d {} -- sh -c \"mkdir -p /etc && printf '[boot]\\nsystemd=true\\n' > /etc/wsl.conf\"", peerDistroName)),
483 + 0L);
484 +
485 + // Terminate so the config takes effect on next start.
486 + TerminateDistribution(peerDistroName);
487 +
488 + // Verify interop works in both distros (this also starts the peer with systemd).
489 + {
490 + auto [out, _] = LxsstuLaunchWslAndCaptureOutput(L"cmd.exe /c echo alive");
491 + VERIFY_ARE_EQUAL(out, L"alive\r\n");
492 + }
493 +
494 + {
495 + auto [out, _] = LxsstuLaunchWslAndCaptureOutput(std::format(L"-d {} -- cmd.exe /c echo alive", peerDistroName));
496 + VERIFY_ARE_EQUAL(out, L"alive\r\n");
497 + }
498 +
499 + // Terminate the peer distro — this triggers systemd shutdown. Without
500 + // the fix, systemd-shutdown's disable_binfmt() would clear the kernel-
501 + // global binfmt_misc table for every running distro.
502 + TerminateDistribution(peerDistroName);
503 +
504 + // Verify interop still works in the primary distro.
505 + {
506 + auto [out, _] = LxsstuLaunchWslAndCaptureOutput(L"cmd.exe /c echo survived");
507 + VERIFY_ARE_EQUAL(out, L"survived\r\n");
508 + }
509 +
510 + // Verify the binfmt entry still exists and carries the F (fix-binary) flag.
511 + // The F flag is required so the kernel resolves the interpreter at
512 + // registration time, making the entry independent of mount-namespace state.
513 + {
514 + auto [flags, _] = LxsstuLaunchWslAndCaptureOutput(L"grep ^flags /proc/sys/fs/binfmt_misc/WSLInterop");
515 + VERIFY_IS_TRUE(flags.find(L"F") != std::wstring::npos);
516 + }
517 + }
518 +
519 TEST_METHOD(Dup)
520 {
521 VERIFY_NO_THROW(LxsstuRunTest(L"/data/test/wsl_unit_tests dup", L"Dup"));