Skip to content
This repository was archived by the owner on Sep 13, 2026. It is now read-only.
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion PLAN.md
Original file line number Diff line number Diff line change
Expand Up @@ -259,7 +259,7 @@ Explicitly **not** commands: distro install/uninstall/run, networking/memory tun
- **WSL access**: `wsl.exe` for everything the registry cannot answer — launch, terminate, mount, unmount, manage — isolated behind `IWslHost` so it can be swapped for the COM `ILxssUserSession` surface from the open-sourced [microsoft/WSL](https://github.com/microsoft/WSL) later. Guest commands run as `wsl.exe -d <distro> -u root --exec /absolute/path`: `--exec` does not search PATH, and `-u root` is what gets uid 0 regardless of `DefaultUid`.
**Not `wslapi.dll`.** Measured: every entry point returns `E_ACCESSDENIED` from an unpackaged process, including for a distribution name that does not exist, so the refusal is about the caller rather than the argument. These APIs exist for MSIX distribution launchers, which is the one shape goal 6 rules out. See [docs/RESEARCH.md](docs/RESEARCH.md).
- **File system**: `CopyFileEx`, `MoveFileEx`, `GetCompressedFileSizeW`, `FSCTL_QUERY_ALLOCATED_RANGES`, `FSCTL_SET_SPARSE`, `GetDiskFreeSpaceEx`, `GetVolumeInformation` (fs type).
- **Elevation**: `ShellExecuteEx` with `runas`, named pipe for IPC; `CheckTokenMembership` to detect admin.
- **Elevation**: `ShellExecuteEx` with `runas`, `CheckTokenMembership` to detect admin, and a **one-way** named pipe (`PIPE_ACCESS_INBOUND`, worker → parent) for progress plus a separate manual-reset event for cancellation. Not a duplex pipe: I/O on a synchronous file object is serialized, so a pending `ReadFile` blocks the concurrent `WriteFile` the other direction needs and both halves hang — measured, see [docs/RESEARCH.md](docs/RESEARCH.md). The pipe name is 128 random bits, created with `FILE_FLAG_FIRST_PIPE_INSTANCE` and a DACL naming only the launching user; the worker verifies the server's pid, image and SID before it streams, and declining the prompt is `ERROR_CANCELLED` → exit 4 (D11).
- **Task Scheduler**: `ITaskService` COM for `schedule`.

### 5.4 Architecture
Expand Down Expand Up @@ -317,6 +317,7 @@ Full design in [docs/CI.md](docs/CI.md): `ci.yml` (lint, MSVC+clang-cl × x64+ar
| D8 | WSL2 only; WSL1 is detect-and-refuse | No VHDX to manage; legacy and shrinking user base; avoids a second code path and test-matrix leg for zero functional gain |
| D9 | `compact` never stops another distribution on its own: it refuses, names the ones holding the VM open, and requires `--shutdown` | Terminating the target is not enough — the utility VM holds every attached disk for as long as any distribution runs (measured: still locked after 300 s). Shutting everything down silently would kill unrelated work, including Docker Desktop containers, so the user opts in |
| D10 | Unattached `CompactVirtualDisk` via `OPEN_VIRTUAL_DISK_VERSION_2` + `VIRTUAL_DISK_ACCESS_NONE` is the default path; attach-read-only is an opt-in | Measured: after `fstrim` the unattached path reclaimed 100% of the freed space in 0.2 s with no administrator rights. V1 + `METAOPS` compacts too, so this is a choice rather than the only option: V2 accepts exactly one mask and rejects the rest at open, whereas V1 accepts masks that open and then fail at the compaction. Corrected 2026-08-30 — the original rationale rested on a spike that measured `ATTACH_RW` while calling it `METAOPS` |
| D11 | The elevated half is one verb, not a re-parsed CLI: it takes a path, re-validates it against the caller's own `Lxss` hive, and reads nothing from the pipe — the pipe is output only | Its command line is readable by any same-user process and UAC is not a boundary against the same user, so the defence cannot be secrecy. Keeping the privileged surface to attach-read-only → compact → detach means a tampered argument can at worst name a different VHDX, which the re-validation refuses. Pipe name is 128 random bits with `FILE_FLAG_FIRST_PIPE_INSTANCE`, and the worker checks the server's pid, image and SID — measured against a squatter in docs/RESEARCH.md |

## 8. Open questions

Expand Down
3 changes: 2 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ Goal: de-risk the unknowns, have a compiling skeleton and CI.
- [x] Guest commands as uid 0; `wslapi.dll` found unusable unpackaged ([#3](https://github.com/wslkit/wsldisk/issues/3))
- [x] Registry layout across WSL inbox 1.x / Store 2.x ([#4](https://github.com/wslkit/wsldisk/issues/4))
- [x] Docker Desktop VHDX lock behaviour when Docker is "stopped" vs quit ([#5](https://github.com/wslkit/wsldisk/issues/5))
- [~] Elevation relaunch + named-pipe result streaming — [moved to M2](https://github.com/wslkit/wsldisk/issues/6). Compaction turned out to need no elevation at all (D10), so this belongs with the attach-read-only and resize work that does
- [~] Elevation relaunch + named-pipe result streaming — [moved to M2](https://github.com/wslkit/wsldisk/issues/6) and answered there. Compaction turned out to need no elevation at all (D10), so this belonged with the attach-read-only and resize work that does

**Exit criteria: met.** CI is green across 16 required checks with the 100% coverage gate passing and no exclusions; every spike is answered in [docs/RESEARCH.md](docs/RESEARCH.md); PLAN.md §8 now separates what was measured from what is still open.

Expand Down Expand Up @@ -130,6 +130,7 @@ owner, not something CI should do on its own.

- [x] `MoveOperation`: preflight (fs type, free space, running), sparse-preserving copy with progress, registry repoint, start test, rollback, source cleanup; same-volume fast path ([#106](https://github.com/wslkit/wsldisk/issues/106)). `--verify` full-hash comparison is still to come
- [x] `wsldisk relink <distro> <path>` — the operation existed behind `orphans --relink`; promoted to a command of its own, and taught to honour `--json` ([#63](https://github.com/wslkit/wsldisk/issues/63))
- [x] Elevation spike: `runas` relaunch, one-way pipe + cancel event, squatting defences ([#6](https://github.com/wslkit/wsldisk/issues/6)) — `spikes/elevation`, results in [docs/RESEARCH.md](docs/RESEARCH.md). A duplex pipe deadlocks, so the plan's IPC shape changed (D11); the `--elevate` code path itself is still to build
- [ ] `GrowOperation`: `ResizeVirtualDisk` + `resize2fs`; detect partitioned disks and refuse
- [ ] Helper-distro mechanism (tiny Alpine rootfs, on-demand import/remove) or `--via <distro>`
- [ ] `ShrinkOperation`: fit check with margin, `e2fsck -f`, `resize2fs <size>`, `ResizeVirtualDisk` (safe flag only), compact, `e2fsck -n` verify
Expand Down
148 changes: 146 additions & 2 deletions docs/RESEARCH.md
Original file line number Diff line number Diff line change
Expand Up @@ -434,6 +434,149 @@ Found by dogfooding: the reported failure was `compact Ubuntu` sitting through
ten "waiting for the disk to be released" lines and then refusing, on a machine
where `wsl --list --running` said nothing was running at all.

### Elevation relaunch and result streaming (issue #6) — answered, and it changes the IPC shape

**The split works, and the control channel cannot share the pipe.** An
unelevated parent can relaunch itself with `runas`, stream progress back from the
elevated half over a named pipe the launching user alone can open, cancel it from
the unelevated console, and exit with the elevated half's own exit code. What the
plan got wrong is the channel: §5.3 says "named pipe for IPC", and a *duplex*
pipe carrying progress one way and cancellation the other deadlocks both
processes.

Measured on Windows 10 Pro 22H2 (build 19045) — a different host from the M0
spikes above, which ran on Windows 11 26200 — with an unsigned x64 binary built
by MSVC from `spikes/elevation/elevate.cpp`, driven by `spikes/elevation/run.ps1`.
The account is a **split-token administrator**: `BUILTIN\Administrators` is
present in the filtered token as "Group used for deny only". UAC policy was
`EnableLUA=1`, `ConsentPromptBehaviorAdmin=5` (consent prompt for non-Windows
binaries), `PromptOnSecureDesktop=1`. SIDs are redacted as
`S-1-5-21-<redacted>-1001`. Nothing was compacted: the elevated worker sleeps and
reports progress.

#### The two halves, measured

| | parent | elevated worker |
|---|---|---|
| `CheckTokenMembership` (Administrators) | no | yes |
| Integrity level | `0x2000` medium | `0x3000` high |
| Token user SID | `S-1-5-21-<redacted>-1001` | `S-1-5-21-<redacted>-1001` — same |
| `TokenIsElevated` seen through the pipe | — | yes |

The child is the same user one integrity level up, which is why a pipe whose
DACL is `D:P(A;;GA;;;<user sid>)` — that user and nobody else, not even SYSTEM,
with inheritance blocked — is openable by the elevated child with no weakening
at all. Mandatory integrity control does not get in the way either: the
restriction is no-write-**up**, and here the high-IL client is writing to a
medium-IL object.

> **Not measured, and it matters.** This holds because a split-token admin's
> filtered and elevated tokens carry the *same* user SID. Over-the-shoulder
> elevation — a standard user typing a different account's administrator
> credentials — gives the worker a different SID, and this DACL would then deny
> it. There is no second account on the test machine, so that path is untested.
> The implementation must either grant the elevated identity explicitly or fail
> with a clear message instead of an unexplained access denial.

#### Declining the prompt (issue question 2)

`ShellExecuteEx` returns `FALSE` with `GetLastError() == ERROR_CANCELLED` (1223).
No crash, no hang, no orphaned child. Mapping that one error to
`ErrorCode::NeedsElevation` gives the exit code 4 the issue asked for, and it is
the only error worth special-casing at that call site.

#### Cancellation (issue question 4)

A real `CTRL_C_EVENT` — delivered by a second process that does
`AttachConsole(parent_pid)` + `GenerateConsoleCtrlEvent`, not simulated — reaches
the parent's handler, which returns `TRUE` so the default handler does not kill
the process before the worker's exit code can be collected. The worker stops
within one 200 ms poll, writes its result record and exits 5; the parent
propagates 5.

**The Ctrl+C does not reach the elevated child.** The worker installs its own
console control handler and reports over the pipe if it ever fires. It never
did — the child is launched through the AppInfo service and does not join the
parent's console process group. So cancellation *must* be explicit; there is no
inherited signal to rely on. That is a safety property, not a limitation: an
elevated worker holding an attached disk should unwind deliberately, never die
where the console happened to be.

#### The deadlock that changes the design

The first shape tried was the obvious one: a duplex message pipe, the worker
writing progress from its main thread while a second thread sat in `ReadFile`
waiting for a cancel record. Both processes hung after the *first* progress
record, indefinitely, and only unwedged when the parent was killed — which
released the worker's pending read and let its write complete.

The cause is not the pipe but the handle. I/O on a synchronous file object is
serialized: a pending `ReadFile` blocks any concurrent `WriteFile` on the same
handle, whichever thread issues it. The parent had the same bug in mirror image
— its Ctrl+C handler tried to write the cancel record while the main thread was
parked in `ReadFile` on that handle.

Three ways out; the third is what the spike settled on:

| Option | Cost |
|---|---|
| `FILE_FLAG_OVERLAPPED` on both ends | Correct, but overlapped I/O in both halves for one bit of state |
| A second pipe instance for control | Another name, another ACL, another connect to verify |
| **A named event for cancellation** | One manual-reset event, same user-only DACL, `Local\` namespace; the worker polls it each tick |

Cancellation is one bit and never needs a reason, so the event wins. The pipe
becomes one-way (`PIPE_ACCESS_INBOUND`, worker → parent), which also removes any
question of the elevated half *reading* instructions from a channel — see below.
`Local\` is correct because elevation keeps the child in the same session.

#### Name squatting and tampered arguments (issue question 3)

The pipe namespace is machine-wide: any process on the box can create
`\\.\pipe\<name>` first, and a medium-IL process can ordinarily do so. Two
defences, both measured:

| Defence | Result |
|---|---|
| Server creates with `FILE_FLAG_FIRST_PIPE_INSTANCE` | A squatter holding the name makes our own `CreateNamedPipe` fail with `ERROR_PIPE_BUSY` (231), so the parent aborts instead of proceeding |
| Worker verifies the server before trusting it | Refused: server PID did not match the launcher PID it was given. It also compares the server's image path and token user SID to its own |

The name is 128 bits from `BCryptGenRandom`, so winning the race means guessing
the name, not merely being early.

The deeper answer to "arguments an unprivileged process could tamper with" is to
make the elevated half not worth tampering with. Its command line is visible to
any same-user process, and UAC is not a security boundary against the same user
anyway — so the rule for the implementation is:

- The elevated worker implements exactly **one verb** (attach read-only, compact,
detach), never a re-parsed copy of the full CLI.
- It takes the target path as an argument and **re-validates it itself**:
canonicalize, confirm it is the `VhdFileName` of a registered distribution in
the caller's own `Lxss` registry hive, refuse anything else.
- It reads no instructions from the pipe. The pipe is output only.

#### What the shape looks like

```text
parent (medium IL) worker (high IL, via runas)
------------------ ---------------------------
CheckTokenMembership -> not admin
128-bit random pipe name
CreateNamedPipe INBOUND + FIRST_PIPE_INSTANCE, DACL = user only
CreateEvent Local\...-cancel, same DACL
ShellExecuteEx "runas" -------------> verify server pid/image/sid, else exit
ConnectNamedPipe <--- I|sid|elevated|integrity
ImpersonateNamedPipeClient <--- P|pct|text
(fails with 1368 until a
message has been read)
Ctrl+C -> SetEvent ----------------> polled each tick, unwinds
exit with the worker's code <--- R|code|text
```

`ImpersonateNamedPipeClient` is worth calling out: it fails with
`ERROR_CANNOT_IMPERSONATE` (1368) until data has been read from the pipe, so the
client check belongs after the first record, not at connect time.

### Incidental

`wsl.exe` prints `Failed to translate '<path>'` to stderr for every Windows PATH
Expand All @@ -443,5 +586,6 @@ consider passing `WSLENV`/a clean environment.

### Still open

- Elevation relaunch and named-pipe IPC (#6) — now lower priority, since the
common compaction path needs no elevation at all.
- Over-the-shoulder elevation (#6): whether the worker can be reached at all
when a standard user elevates with *another* account's credentials, and what
the pipe DACL has to say in that case. Needs a second account to measure.
Loading
Loading