Skip to content

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 — but sftp user@host connects, authenticates, then hangs forever (or drops with Connection closed right after auth).
  • scp also fails — modern OpenSSH scp uses the SFTP protocol by default.
  • Get-WinEvent -LogName OpenSSH/Operational shows a clean Accepted publickey, then nothing useful.
  • sshd_config looks innocent: the stock Subsystem sftp sftp-server.exe line, no ForceCommand.

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:

Subsystem   sftp    C:/Windows/System32/OpenSSH/sftp-server.exe

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 → %1 empty → wsl.exe ~ → real WSL login shell in the Linux home.
  • Remote command (ssh host "cmd") — sshd runs wrapper -c "cmd" → falls through to wsl.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, /I case-insensitive) → sftp-server.exe runs natively on Windows, no WSL in the data path.

Validate the config and restart:

C:\Windows\System32\OpenSSH\sshd.exe -t   # silence = valid
Restart-Service sshd

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:

  1. User text (%~2) may only appear alone on a simple command line, in execution position (wsl.exe -e bash -c "%~2"). Never inside if, set, or parentheses.
  2. if comparisons 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 in sshd_config is part of the design).
  3. goto-structured flow instead of if/else blocks — single-line, no parenthesized groups for line endings or quotes to break.
  4. 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.