返回博客

ssh-copy-id 说成功了但还是密码登录:Windows OpenSSH 的三层陷阱

搭一台 Windows LAN 构建机,需要从 macOS 推公钥实现免密 SSH。ssh-copy-id 运行一切正常,显示 Number of key(s) added: 1,但随后 ssh 仍然弹密码,-o BatchMode=yes 下直接 Permission denied (publickey,password,keyboard-interactive)。反复试了半小时 flag,毫无进展。

排查下来是三个坑同时叠加,缺一不可。

现象

$ ssh-copy-id -i ~/.ssh/id_ed25519.pub user@windows-host
user@windows-host's password:
'exec' 不是内部或外部命令          # Windows cmd.exe 报错
Number of key(s) added:        1   # 谎报成功
 
$ ssh -o BatchMode=yes user@windows-host 'echo ok'
Permission denied (publickey,password,keyboard-interactive).

根因

坑 1:ssh-copy-id 在 Windows OpenSSH 上静默失败

ssh-copy-id 的实现方式是登进目标机,注入一段 bash one-linerumask 077; exec sh -c 'cd; cat >> ~/.ssh/authorized_keys')。Windows OpenSSH Server 默认 shell 是 cmd.exe,不认识 exec,整段脚本部分失败或什么都没写进去。但工具的"成功计数"来自另一个验证步骤,返回了假阳性。

坑:ssh-copy-id 是 Linux-only 工具

它向目标机注入 bash 脚本,Windows cmd.exe 会让 exec 命令直接报错。Number of key(s) added: 1 是假成功,不要继续换 flag 重试——换工具。

坑 2:Admin 用户的公钥文件路径不是你以为的那个

Windows OpenSSH 自 7.7 起,对本地 Administrators 组成员,sshd 完全忽略 %USERPROFILE%\.ssh\authorized_keys,只读 C:\ProgramData\ssh\administrators_authorized_keys(全局一个文件,所有 admin 共享)。写错路径,sshd 视而不见。

检测用户组时不要用 net localgroup administrators——中文 locale 下组名变成 管理员,命令会报"系统找不到指定的路径"。改用 SID(与 locale 无关):

ssh user@host 'whoami /groups | findstr S-1-5-32-544'

S-1-5-32-544BUILTIN\Administrators 的固定 SID,有输出 = 是管理员,写全局文件;无输出 = 普通用户,写 per-user 路径。

坑 3:icacls 括号参数在 Shell 调用栈里被肢解

sshd 对 authorized_keys 的 ACL 有严格要求:必须断继承、只保留文件所有者和 SYSTEM。稍微宽松一点(比如继承来的 Authenticated Users 权限残留),sshd 静默拒绝公钥,Event Log 才有记录,stderr 什么都不说。

设置 ACL 的自然写法会出错:

icacls $authFile /inheritance:r /grant:r "${env:USERNAME}:(F)"
# 错误: 无效参数 "(F)"

原因是调用链 bash → ssh → cmd.exe → powershell → icacls.exe,括号 (F) 在某一层被拆成独立参数,icacls 收到时已经不合法。

坑:icacls 括号权限标记穿越多层 Shell 会被肢解

(F) 是 icacls 文档里的写法,但通过 SSH 跨 cmd + PowerShell 调用时括号会被拆碎。直接用裸字母 F 即可,icacls 同样接受。

正解

从 macOS/Linux 客户端驱动,用 scp 传 PowerShell 脚本再执行,彻底绕开行内 quoting 地狱。

1. 探测用户组

ssh user@host 'whoami /groups | findstr S-1-5-32-544'
# 有输出 → admin,写 C:\ProgramData\ssh\administrators_authorized_keys
# 无输出 → 普通用户,写 %USERPROFILE%\.ssh\authorized_keys

2. 生成安装脚本(以普通用户为例)

PUB_KEY=$(cat ~/.ssh/id_ed25519.pub)
TMP_PS1=$(mktemp -t install-key.XXXXXX.ps1)
cat > "$TMP_PS1" <<PSEOF
\$pubKey  = '${PUB_KEY}'
\$sshDir  = "\$env:USERPROFILE\.ssh"
\$authFile = "\$sshDir\authorized_keys"
\$me = \$env:USERNAME
if (!(Test-Path \$sshDir)) { New-Item -ItemType Directory -Path \$sshDir -Force | Out-Null }
Set-Content -Path \$authFile -Value \$pubKey -Encoding ASCII -NoNewline
& icacls \$authFile /inheritance:r | Out-Null
& icacls \$authFile /grant:r "\${me}:F" | Out-Null
& icacls \$authFile /grant:r "SYSTEM:F" | Out-Null
& icacls \$sshDir   /inheritance:r | Out-Null
& icacls \$sshDir   /grant:r "\${me}:F" | Out-Null
& icacls \$sshDir   /grant:r "SYSTEM:F" | Out-Null
Write-Host "DONE"; & icacls \$authFile
PSEOF

3. 推送并执行

SSHPASS="$PW" sshpass -e scp -o StrictHostKeyChecking=accept-new \
  "$TMP_PS1" user@host:install-key.ps1
 
SSHPASS="$PW" sshpass -e ssh -o StrictHostKeyChecking=accept-new user@host \
  "powershell -NoProfile -ExecutionPolicy Bypass -File install-key.ps1 ; del install-key.ps1"
 
rm -f "$TMP_PS1"

4. 验证

ssh -o BatchMode=yes -o ConnectTimeout=5 user@host 'hostname'
# 直接打印 hostname,不弹密码 = 成功

BatchMode=yes 禁用所有交互提示,公钥失败立即报 Permission denied,是最干净的验证信号。

正解:scp 传脚本 + 裸字母 icacls 权限标记

不要用 ssh-copy-id;用 scp 传一个 .ps1 然后 powershell -File 执行;icacls 权限标记去掉括号写 F 而非 (F);Admin 用户写 C:\ProgramData\ssh\administrators_authorized_keys

排查 ACL 残留问题

如果验证仍失败,检查 ACL:

ssh user@host 'icacls "%USERPROFILE%\.ssh\authorized_keys"'

看到 (I)(继承 ACE)、BUILTIN\UsersAuthenticated Users 中任意一项 = sshd 会静默拒绝。sshd 的详细拒绝原因在 Windows Event Log,不在 stderr:

ssh user@host 'powershell -Command "Get-WinEvent -LogName OpenSSH/Operational -MaxEvents 10 | Format-List TimeCreated,Message"'

一句话外卖

ssh-copy-id 在 Windows OpenSSH 上只是谎报成功的 bash 脚本注入器;真正正确的路是 scp 一个 PowerShell 文件过去执行,Admin 用户写全局文件,icacls 权限标记去掉括号。