Windows OpenSSH + WSL: sftp Hangs Because Subsystems Launch Through the DefaultShell¶
TL;DR¶
On Win32-OpenSSH (verified on 9.5p2), the sftp subsystem is launched through the DefaultShell, exactly like exec commands — there is no bypass. If your DefaultShell routes into WSL (any of wsl.exe, bash.exe, or a wrapper script), sftp-server.exe ends up running inside WSL across the interop bridge, and the SFTP binary protocol hangs or dies. Neither an absolute Subsystem path nor internal-sftp avoids the shell — Win32-OpenSSH maps internal-sftp straight back to sftp-server.exe and shells out anyway.
The fix is a DefaultShell wrapper that recognizes the subsystem invocation and runs sftp-server.exe natively on Windows, while still routing everything else into WSL. Complete working wrapper below.
Symptoms¶
ssh user@host "command"works;ssh user@host(interactive) works — butsftp user@hostconnects, authenticates, then hangs forever (or drops withConnection closedright after auth).scpalso fails — modern OpenSSHscpuses the SFTP protocol by default.Get-WinEvent -LogName OpenSSH/Operationalshows a cleanAccepted publickey, then nothing useful.sshd_configlooks innocent: the stockSubsystem sftp sftp-server.exeline, noForceCommand.
Diagnosis: watch what sshd actually spawns¶
Two techniques nailed this, both reusable.
1. Process-tree diff during the hang. Start the hanging sftp in the background, then list processes and parents:
Get-CimInstance Win32_Process |
Where-Object { $_.Name -in @('cmd.exe','wsl.exe','sftp-server.exe') } |
Select-Object Name,ProcessId,ParentProcessId
During the hang the chain was: sshd → cmd.exe (the DefaultShell wrapper) → wsl.exe → bash → sftp-server.exe. The Windows sftp-server.exe was being found via WSL's interop PATH and launched from inside Linux — its stdio then traverses sshd ↔ cmd ↔ wsl ↔ bash ↔ interop, which is not 8-bit-clean/blocking-compatible enough for the SFTP wire protocol.
2. A one-shot debug sshd. Run a second instance in the foreground with full verbosity on a spare port (elevated; add/remove a temporary firewall rule around it):
netsh advfirewall firewall add rule name=SSHDebugTemp dir=in action=allow protocol=TCP localport=2224
C:\Windows\System32\OpenSSH\sshd.exe -d -d -d -f sshd_debug.conf -E sshd_debug.log
netsh advfirewall firewall delete rule name=SSHDebugTemp
-d mode serves exactly one connection and exits. The log contained the smoking gun:
debug2: subsystem request for sftp by user majorlinux
debug3: exec_command: C:/Windows/System32/OpenSSH/sftp-server.exe
debug3: spawning "c:\\wsl-shell.cmd" -c "C:/Windows/System32/OpenSSH/sftp-server.exe " as subprocess
The subsystem is spawned via the DefaultShell with -c "<subsystem command>". Note the trailing space sshd appends to the command — it matters for the wrapper match below.
Dead ends (so you don't repeat them)¶
| Attempt | Result |
|---|---|
Subsystem sftp internal-sftp |
Parses fine (sshd -t passes), but Win32-OpenSSH maps it back to sftp-server.exe and still shells out. Same hang. |
Absolute path in Subsystem |
Still spawned through the shell — but now bash can't resolve C:/... as a Linux path, so it fails fast (Connection closed) instead of hanging. Useful signal, not a fix. |
Hunting ForceCommand / broken Subsystem lines |
The config was stock. The shell-mediated launch is the mechanism. |
The fix: subsystem dispatch in the DefaultShell wrapper¶
Two coupled pieces. They reference the same exact string — change them together or sftp breaks.
sshd_config — use an absolute, forward-slash path so the wrapper has a unique constant to match:
C:\wsl-shell.cmd (the registry HKLM:\SOFTWARE\OpenSSH\DefaultShell value) — CRLF line endings, ASCII, no BOM:
@echo off
if "%~1"=="" goto interactive
if /I "%~2"=="C:/Windows/System32/OpenSSH/sftp-server.exe " goto sftp
if /I "%~2"=="C:/Windows/System32/OpenSSH/sftp-server.exe" goto sftp
wsl.exe -e bash -c "%~2"
goto :eof
:interactive
wsl.exe ~
goto :eof
:sftp
C:\Windows\System32\OpenSSH\sftp-server.exe
How each SSH mode routes:
- Interactive login (
ssh host) — sshd runs the wrapper with no args →%1empty →wsl.exe ~→ real WSL login shell in the Linux home. - Remote command (
ssh host "cmd") — sshd runswrapper -c "cmd"→ falls through towsl.exe -e bash -c "%~2"→ executes in Linux. - sftp/scp — sshd runs
wrapper -c "C:/Windows/System32/OpenSSH/sftp-server.exe "→ matched by the dispatch lines (with and without sshd's trailing space,/Icase-insensitive) →sftp-server.exeruns natively on Windows, no WSL in the data path.
Validate the config and restart:
Note: sftp/scp now serve the Windows filesystem (sessions land in C:\Users\<user>), while ssh commands land in WSL. To move a file into WSL, sftp it to Windows and pick it up from /mnt/c/..., or pipe through the exec channel: cat file | ssh host "cat > /path".
The batch quoting trap (why the wrapper is shaped like this)¶
cmd.exe expands %~2 at parse time, before syntax analysis. Any line that combines control syntax with user-controlled text is a bomb:
REM BROKEN — kills every command containing double quotes (exit 255):
if "%~2"=="something" (...) else (wsl.exe -e bash -c "%~2")
A remote command like echo "quoted" | pipe expands into that if line, the quote parity shifts, and cmd aborts with The syntax of the command is incorrect. — the SSH session dies before bash ever runs. This exact mistake once turned a working SSH setup into a full remote lockout (see history below).
Rules that make the wrapper safe:
- User text (
%~2) may only appear alone on a simple command line, in execution position (wsl.exe -e bash -c "%~2"). Never insideif,set, or parentheses. ifcomparisons may only test%~1(always empty or-c, never user-controlled) or a fixed constant you control (the Subsystem path — which is why an absolute path insshd_configis part of the design).goto-structured flow instead ofif/elseblocks — single-line, no parenthesized groups for line endings or quotes to break.- Known residual limitation (inherent to cmd→wsl re-quoting): commands mixing embedded double quotes and pipes may mangle. Use single quotes — Ansible's generated commands are single-quoted and work.
Guard against drift¶
This DefaultShell mechanism broke four times in four months (registry value clobbered ×3, then the wrapper file itself corrupted by a bad edit). A scheduled task (SYSTEM, at startup + every 15 min) now reasserts both the registry value and the wrapper file's exact content from a $knownGood string in a PowerShell script — a registry-only guard would have missed the file-corruption case. Corollary: edit the wrapper by changing $knownGood in the guard script, never the .cmd directly (the guard rewrites it within 15 minutes).
Keep the guard script somewhere only admins can write. A user-writable script executed as SYSTEM is a privilege-escalation vector — handy in an emergency (an unprivileged sftp session can stage a fix that SYSTEM then applies), but that cuts both ways.
History (MajorRig, 2026)¶
| Date | Event |
|---|---|
| 04-03 → 04-08 | wsl.exe (no -c support) → PowerShell → WindowsApps bash.exe as DefaultShell |
| 07-01 | bash.exe App Execution Alias fails non-console exec (Ansible) → first wsl-shell.cmd wrapper (wsl.exe -e bash -c "%~2", no interactive branch) |
| 07-03 | Wrapper re-verified for exec — interactive logins silently broken (bash -c "" exits instantly) |
| 07-07 | Repair attempt mistypes %~2 as %-2 → full remote lockout (every path returns cmd.exe syntax error); recovered via local session + UAC-elevated copy; guard task built; sftp root-caused and fixed with the v5 wrapper above |
| 08-05 | Confirmed live again, this time via Ansible's ansible service account, not an interactive sftp/scp session. The MajorAnsible fleet's first-ever full site.yml --check --diff run hit fatal: [majorrig-wsl]: UNREACHABLE! with "Failed to connect to the host via ssh: \" goto sftp was unexpected at this time." — the exact cmd.exe syntax-error phrasing this article's wrapper mechanism produces when the dispatch parsing breaks. The specific task that triggered it, tailscale : Deploy tailscaled network-online override, is a copy/template task — Ansible's pipelining=True setting avoids the sftp/scp path entirely for plain command-exec tasks, but file-transfer tasks (copy, template, fetch) still go through the sftp subsystem, so they're the ones that reliably surface this wrapper's fragility. Fix applied at the Ansible layer, not the wrapper layer: rather than debug the Windows-side wrapper state again, majorrig-wsl was excluded from the two site-wide plays that don't make sense against a WSL2 workstation anyway (tailscale.yml, ssh_hardening.yml — see Fleet-Security-Hardening). If a future Ansible role needs to push files to majorrig-wsl specifically, expect to hit this same wrapper issue and come back here rather than treating it as a new bug. |
Related¶
- Windows OpenSSH: WSL as Default Shell Breaks Remote Commands — the original
-cproblem this design descends from - Windows OpenSSH Server (sshd) Stops After Reboot
- Win32-OpenSSH releases