Pin disk-attach target by handle to close junction-swap race#40782
Pin disk-attach target by handle to close junction-swap race#40782benhillis wants to merge 4 commits into
Conversation
The disk-attach path operated on the user-supplied path string, re-resolving it independently at the impersonated access check (GrantVmAccess / GrantVmWorkerProcessAccessToDisk + online-state) and again at the privileged attach performed as SYSTEM (AddVhd / AddPassThroughDisk). A standard user could point the path at an NTFS junction/reparse point they control and swap its target between check and use, reaching disks or VHDs they have no rights to. The persisted-restore path (_LoadDiskMount) re-resolved the stored string as SYSTEM, exposing the same surface. Add WslCoreVm::ResolveDiskPath, which opens the target under the caller's impersonation with FILE_FLAG_OPEN_REPARSE_POINT, rejects a final component that is a reparse point, and canonicalizes via GetFinalPathNameByHandle so no reparse point remains in any component. For a VHD it returns a held read handle (no FILE_SHARE_DELETE) that is kept open across GrantVmAccess and AddVhd, pinning the file (and its parent directories) so the target cannot be swapped during the attach window. The canonical path is stored in DiskState and used for every privileged operation and for detach/teardown/eject revocation, so check and use always refer to the same object. Add regression tests: MountVhdThroughSymlinkIsRejected (a raw reparse path sent straight to the AttachDisk RPC is refused) and MountVhdThroughDirectoryJunctionSucceeds (a legitimate junction is canonicalized and still mounts). Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
There was a problem hiding this comment.
Pull request overview
This PR hardens WSL’s disk attach path against a TOCTOU junction/symlink swap by resolving user-supplied disk paths under impersonation, rejecting final-component reparse points, canonicalizing via handle-based resolution, and (for VHDs) pinning the target file with an open handle during the attach window.
Changes:
- Added
WslCoreVm::ResolveDiskPath()to open + validate + canonicalize disk paths and (for VHDs) keep a non-FILE_SHARE_DELETEhandle open to pin the attach target during privileged operations. - Updated attach/detach/eject/destructor paths to perform privileged operations (grant/revoke/online/restore) against the stored canonical path (
DiskState::ResolvedPath). - Added TAEF tests covering symlink rejection and directory-junction canonicalization for VHD mounts.
Reviewed changes
Copilot reviewed 3 out of 3 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/windows/service/exe/WslCoreVm.h |
Introduces DiskState::ResolvedPath and the ResolveDiskPath/ResolvedDisk API to support canonicalized, pinned disk operations. |
src/windows/service/exe/WslCoreVm.cpp |
Wires canonical path usage into attach/detach/eject/teardown and implements handle-based resolution/pinning logic. |
test/windows/MountTests.cpp |
Adds mount tests for rejecting symlink final components and allowing directory junctions via canonicalization. |
| auto resolved = ResolveDiskPath(Disk, Type, UserToken); | ||
| resolvedDisk = std::move(resolved.Path); | ||
| const wil::unique_hfile pinHandle = std::move(resolved.PinHandle); | ||
|
|
| } | ||
|
|
||
| m_attachedDisks.emplace(AttachedDisk{Type, Disk, IsUserDisk}, DiskState{Lun.value(), {}, diskFlags}); | ||
| m_attachedDisks.emplace(AttachedDisk{Type, Disk, IsUserDisk}, DiskState{Lun.value(), {}, diskFlags, resolvedDisk}); | ||
| cleanup.release(); |
| const auto disk = GetBlockDeviceInWsl(); | ||
| VERIFY_IS_TRUE(IsBlockDevicePresent(disk)); | ||
|
|
||
| // Unmount using the canonical path of the VHD. |
Address review feedback: SaveDiskState wrote the original user-supplied path string to the registry, so the SYSTEM-side restore in _LoadDiskMount re-resolved a user-controlled string. Persist DiskState::ResolvedPath (the canonicalized, reparse-free path) instead, so the restore operates on an already-canonical path as defense in depth. Also correct the MountVhdThroughDirectoryJunctionSucceeds comment: the unmount passes the junction path and the service canonicalizes it internally to match the attached-disk entry. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Thanks for the review. Addressed in 627b2b9:
|
Persisting the canonical resolved path broke the persisted-path contract asserted by the mount-state tests (registry 'Disk' value must equal the mount path). Restore is already re-validated by ResolveDiskPath in _LoadDiskMount, so persist the original map key instead. Resolving the disk by handle now surfaces a missing file/path from the open in ResolveDiskPath rather than from the HCS layer, so update the AttachDisk/CreateInstance error-code expectations to drop the /HCS segment. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Fixed the wsl2 CI failures (commit 7a60b63): Persisted path / comment 2 follow-up: Persisting the canonical resolved path broke the persisted-path contract that the mount-state tests assert (the registry Error-code expectations: Resolving the disk by handle now surfaces a missing file/path from the Verified locally (Version=2): |
| auto resolved = ResolveDiskPath(Disk, Type, UserToken); | ||
| resolvedDisk = std::move(resolved.Path); | ||
| const wil::unique_hfile pinHandle = std::move(resolved.PinHandle); | ||
|
|
| // Resolves and pins the user-supplied disk path under the caller's identity, rejecting | ||
| // reparse points (junctions/symlinks) and holding a handle so that the path cannot be | ||
| // swapped between the access check and the privileged attach (TOCTOU). |
| ValidateErrorMessage( | ||
| L"--mount DoesNotExist", | ||
| L"Failed to attach disk 'DoesNotExist' to WSL2: The system cannot find the file specified. ", | ||
| L"Wsl/Service/AttachDisk/MountDisk/HCS/ERROR_FILE_NOT_FOUND"); | ||
| L"Wsl/Service/AttachDisk/MountDisk/ERROR_FILE_NOT_FOUND"); |
- PolicyTest::MountPolicyAllowed asserts the same --mount DoesNotExist scenario, so drop its /HCS error-code segment too (verified locally). - CorruptedVhd keeps /HCS: the pin-handle open succeeds there and the sharing violation still surfaces from HCS (verified locally). - Annotate the VHD pin handle [[maybe_unused]] to document its lifetime-only purpose. - Clarify ResolveDiskPath header comment: only the final path component is reparse-rejected; intermediate junctions are followed and canonicalized. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com>
|
Addressed the latest review (commit a91041f):
|
|
/azp run wsl-github-pr |
|
Azure Pipelines successfully started running 1 pipeline(s). |
Summary
The disk-attach path operated on the user-supplied path string, re-resolving it independently at the impersonated access check (
GrantVmAccess/GrantVmWorkerProcessAccessToDisk+ online-state) and again at the privileged attach performed as SYSTEM (AddVhd/AddPassThroughDisk). A standard user could point the path at an NTFS junction/reparse point they control and swap its target between check and use, reaching disks or VHDs they have no rights to. The persisted-restore path (_LoadDiskMount) re-resolved the stored string as SYSTEM, exposing the same surface.Fix
Add
WslCoreVm::ResolveDiskPath, which:FILE_FLAG_OPEN_REPARSE_POINT(proves the caller can reach this exact object).GetFinalPathNameByHandleso no reparse point remains in any component.GENERIC_READ, noFILE_SHARE_DELETE) kept open acrossGrantVmAccess+AddVhd, pinning the file (and its parent directories) so the target cannot be swapped during the attach window.The canonical path is stored in
DiskStateand used for every privileged operation and for detach/teardown/eject revocation, so check and use always refer to the same object.Tests
MountVhdThroughSymlinkIsRejected— a raw reparse path sent straight to theAttachDiskRPC is refused.MountVhdThroughDirectoryJunctionSucceeds— a legitimate junction is canonicalized and still mounts.Both pass against a full local build deployed to the host (Version=2 TAEF run).