PowerShell / PowerShell/Win32-OpenSSH

User-defined environment variables referenced in the user PATH are not expanded in SSH sessions

Open
#2,456 0 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
No language data
Stars
8.3k
Forks
819
PR merge metrics
No merged PRs in 30d

Description

Summary

When HKCU\Environment\Path (a REG_EXPAND_SZ value) contains a reference to a user-defined variable such as %PNPM_HOME%\bin, that entry reaches the SSH session unexpanded, and every executable in that directory becomes unreachable.

The same value expands correctly on a local interactive logon, so this is specific to sshd.

The failure is narrow and easy to miss:

  • References to profile-derived variables (%LOCALAPPDATA%, %USERPROFILE%, …) inside PATH do expand.
  • References to user-defined variables from HKCU\Environment inside PATH do not expand.
  • The very same user-defined variable, read on its own ($env:PNPM_HOME), is present and correct in the session.

This bites anyone whose installer writes the variable form into PATH. pnpm setup / npx get-pnpm does exactly that (%PNPM_HOME%\bin), so pnpm and every globally installed CLI silently vanish over SSH while working fine at the console.

Environment

  • OpenSSH_for_Windows_10.0p2 Win32-OpenSSH-GitHub, LibreSSL 4.2.0
  • Windows 11 Pro 26200 (x64)
  • Login shell: pwsh (also reproduces with the default shell)
  • Auth: public key

Repro

On the Windows host, add one user-defined variable and two PATH entries, keeping the value type REG_EXPAND_SZ:

$k = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
$k.SetValue('MYTOOL_HOME', 'C:\mytool', [Microsoft.Win32.RegistryValueKind]::ExpandString)
$raw = $k.GetValue('Path', '', 'DoNotExpandEnvironmentNames')
$k.SetValue('Path', $raw + ';%MYTOOL_HOME%\bin;%LOCALAPPDATA%\probe',
            [Microsoft.Win32.RegistryValueKind]::ExpandString)
$k.Close()

SSH into the host and inspect:

$env:Path -split ';' | Select-Object -Last 2
"MYTOOL_HOME = [$env:MYTOOL_HOME]"
Expected
C:\mytool\bin
C:\Users\<user>\AppData\Local\probe
MYTOOL_HOME = [C:\mytool]
Actual
%MYTOOL_HOME%\bin                      <-- NOT expanded
C:\Users\<user>\AppData\Local\probe    <-- expanded
MYTOOL_HOME = [C:\mytool]              <-- the variable itself is set correctly

A local interactive logon expands both entries.

Root cause

setup_session_user_vars() in contrib/win32/win32compat/w32-doexec.c builds PATH by reading the HKLM and HKCU values and concatenating them:

/* PATH is a special case. The System Path value is prepended to the User Path value */
wchar_t* hklm_path = get_registry_key_value(HKEY_LOCAL_MACHINE, L"SYSTEM\\CurrentControlSet\\Control\\Session Manager\\Environment", L"PATH", &hklm_path_sz);
wchar_t* hkcu_path = get_registry_key_value(HKEY_CURRENT_USER, L"Environment", L"PATH", &hkcu_path_sz);
...
SetEnvironmentVariableW(L"PATH", user_path);

get_registry_key_value() uses RegGetValueW(..., RRF_RT_REG_SZ, ...). RRF_NOEXPAND is not passed, so expansion is attempted — but RegGetValue expands against the environment of the calling process, which here is sshd-session.exe.

The ordering in setup_session_env() is the problem:

setup_session_user_vars(pw_dir_w);          /* PATH is assembled and expanded HERE */

env = do_setup_env_proxy(ssh, s, s->pw->pw_shell);
while (env_name = env[i]) {
        ...
        /* SKIP, if not applicable on WINDOWS
           PATH is already set. */
        if ((0 == strncmp(env_name, "PATH=", strlen("PATH="))) || ...)
                continue;
        ...
        SetEnvironmentVariableW(env_name_w, env_value_w);   /* user vars land HERE, too late */
}

At the moment PATH is expanded, the process environment holds the profile-derived variables (via LoadUserProfileW and the user token) but not the user-defined values from HKCU\Environment — those are only injected by the loop that runs afterwards. ExpandEnvironmentStrings leaves unresolved references untouched, so %MYTOOL_HOME%\bin survives verbatim. And because the loop explicitly skips PATH=, nothing ever revisits it.

Consistent with this, sshd-session.exe imports only LoadUserProfileW from USERENV.dll; it never calls CreateEnvironmentBlock, which is what a local logon uses and which resolves these references correctly.

Suggested fix

Any of:

  1. Assemble PATH after the user-defined variables have been applied to the process environment, and drop the PATH= skip.
  2. Read the HKCU PATH with RRF_NOEXPAND and expand it explicitly once the user variables are in place.
  3. Use ExpandEnvironmentStringsForUserW() with the user token instead of relying on the service process environment.

Workaround

Write absolute paths into HKCU\Environment\Path instead of variable references, preserving the REG_EXPAND_SZ type:

$k = [Microsoft.Win32.Registry]::CurrentUser.OpenSubKey('Environment', $true)
$raw = $k.GetValue('Path', '', 'DoNotExpandEnvironmentNames')
$k.SetValue('Path', $raw.Replace('%PNPM_HOME%\bin', "$env:LOCALAPPDATA\pnpm\bin"),
            [Microsoft.Win32.RegistryValueKind]::ExpandString)
$k.Close()

Note that setx truncates PATH at 1024 characters and [Environment]::SetEnvironmentVariable(..., 'User') rewrites the value as REG_SZ, so neither is safe here.

Contributor guide

Open the contributing guide

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start in contrib/win32/win32compat/w32-doexec.c, reading setup_session_user_vars(), setup_session_env(), and get_registry_key_value(). Reproduce the SSH PowerShell session with a REG_EXPAND_SZ user PATH containing a user-defined variable, then verify the change makes that reference expand while preserving profile-derived expansion and the user variable itself.

Written by the indexing model from the issue text.

Assessment

Tech stack
c
Domain
operating-systems
Issue type
Bug
Difficulty
4/5
Estimated time
3-5 days
Activity status
Active
Clarity
Clearly specified
Newbie friendliness
68/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.