案发现场#
前几天在 pn51 上通过 Tailscale SSH 连 p14s(Arch Linux),碰到一个让我困惑了好一阵的问题。
非交互式命令完全正常:
1$ ssh atang@p14s id
2uid=1000(atang) gid=1000(atang) groups=...
3$ ssh atang@p14s ls
4Desktop Documents Downloads ...瞬间返回,没有任何问题。
但只要进交互式终端——直接 ssh atang@p14s,终端立刻闪退,打印一行:
1Permission denied退出码 1。连 shell prompt 都没来得及出现。
第一反应是去查 Tailscale ACL 配置、SSH 密钥权限、authorized_keys、faillock 锁定状态、/etc/nologin 是否存在——全部正常。这就很诡异了:同一个用户、同一条 SSH 连接,非交互式命令畅通无阻,交互式登录却被拒绝。
从日志里找到真正的执行边界#
既然常规排查路径走不通,就去翻 tailscaled 的日志。
非交互式命令的日志很简单——Tailscale 作为已鉴权的守护进程,直接派生了一个非 pty 的子进程执行命令(starting non-pty command),跑完就退出。这条路径根本不经过系统的登录流程。
交互式终端就不一样了。Tailscale 鉴权成功后,为了建立完整的登录环境(设置 HOME、SHELL、PATH 等环境变量,加载 profile),它实际执行的是:
1tailscaled be-child ssh ... --has-tty=true --shell底层调用的系统命令是:
1/usr/bin/login -f atang -p -h <client_ip>login -f 的 -f 表示跳过密码认证(因为 Tailscale 已经通过自己的身份验证体系确认了用户),但 login 本身还要走 PAM 的其他阶段。
journalctl 里有一条关键记录:
1login[12345]: Permission denied来源是 login 进程,不是 tailscaled。
到这里我做了一个关键验证——脱离网络,在 p14s 本地直接跑:
1$ sudo /usr/bin/login -f atang -p
2Permission denied同样的结果。
问题和 Tailscale 没有任何关系。 根因出在本地的登录系统和 PAM 上。
用 ctypes 把 PAM 黑盒拆成可调试的切片#
login -f 虽然跳过了密码认证阶段(pam_authenticate),但它依然要走完 PAM 的其他三个阶段:
pam_acct_mgmt— 账号状态检查(过期、锁定等)pam_setcred— 建立用户凭据(这一步会复用 auth 栈的模块)pam_open_session— 打开会话
login 的报错只给了一个笼统的 Permission denied,没有告诉我到底是哪个阶段挂的。PAM 的调试一直比较痛苦——pam_debug 输出量巨大且不直观,strace 能看到系统调用但看不到 PAM 内部的逻辑判断。
后来我想到可以用 Python 的 ctypes 直接加载 libpam.so.0,手动对每个阶段逐一调用,相当于把 PAM 这个黑盒拆成可以单步调试的切片:
1import ctypes
2
3pam = ctypes.CDLL("libpam.so.0")
4pam.pam_strerror.restype = ctypes.c_char_p
5
6class PamConv(ctypes.Structure):
7 _fields_ = [("conv", ctypes.c_void_p), ("appdata_ptr", ctypes.c_void_p)]
8
9pamh = ctypes.c_void_p()
10conv = PamConv(None, None)
11pam.pam_start(b"login", b"atang", ctypes.byref(conv), ctypes.byref(pamh))
12
13for name, func, args in [
14 ("pam_acct_mgmt", pam.pam_acct_mgmt, (pamh, 0)),
15 ("pam_setcred", pam.pam_setcred, (pamh, 1)), # PAM_ESTABLISH_CRED
16 ("pam_open_session", pam.pam_open_session, (pamh, 0)),
17]:
18 rc = func(*args)
19 msg = pam.pam_strerror(pamh, rc).decode()
20 print(f"{name:20s} -> {msg} ({rc})")用 sudo python3 跑一下,结果非常清楚:
1pam_acct_mgmt -> Success (0)
2pam_setcred -> Permission denied (6)
3pam_open_session -> Success (0)pam_setcred 返回了 PAM_PERM_DENIED(错误码 6)。
其他两个阶段都是成功的。罪魁祸首锁死在 pam_setcred。
这里有一个关键的 PAM 机制需要说明:pam_setcred 在执行时会复用 auth 栈(/etc/pam.d/system-auth 里 auth 类型的那些行)的模块列表,依次调用每个模块的 setcred 方法,并按照 auth 栈定义的控制流规则(required、sufficient、[success=N ...] 等)决定整体结果。也就是说,auth 栈的配置不仅影响密码认证,还直接影响凭据建立。
success=2 的落点偏了一行#
知道是 auth 栈出了问题,我去对比了 /etc/pam.d/system-auth 和之前的备份 system-auth.bak。
背景是前段时间为了配置指纹登录(fprintd),我在 system-auth 里加了一行。当时的 diff 大概是这样:
1 auth [success=2 default=ignore] pam_systemd_home.so
2+auth sufficient pam_fprintd.so
3 auth [success=1 default=bad] pam_unix.so try_first_pass nullok
4 auth [default=die] pam_faillock.so authfail
5 auth optional pam_permit.so
6 auth required pam_env.so
7 auth required pam_faillock.so authsucc
看起来很无辜——就是在 pam_systemd_home.so 后面插了一行 pam_fprintd.so。但正是这一行,引爆了整个问题。
PAM 的现代控制语法支持 [success=N default=...] 这种形式,其中 N 是相对跳转行数——成功时跳过后续 N 个模块。
在原本的 Arch 默认配置中,auth 栈长这样:
1[0] auth [success=2 default=ignore] pam_systemd_home.so
2[1] auth [success=1 default=bad] pam_unix.so
3[2] auth [default=die] pam_faillock.so authfail
4[3] auth optional pam_permit.so ← 正确落点pam_systemd_home.so 成功后,success=2 跳过 2 个模块([1] pam_unix.so 和 [2] pam_faillock.so authfail),安全着陆在 [3] pam_permit.so。逻辑完全正确。
但我在 [0] 和 [1] 之间插入了 pam_fprintd.so,整个栈变成了:
1[0] auth [success=2 default=ignore] pam_systemd_home.so
2[1] auth sufficient pam_fprintd.so ← 新插入
3[2] auth [success=1 default=bad] pam_unix.so
4[3] auth [default=die] pam_faillock.so authfail ← 实际落点!
5[4] auth optional pam_permit.sosuccess=2 的跳转计数没变,还是跳 2 个模块——但现在跳过的是 [1] pam_fprintd.so 和 [2] pam_unix.so,精准命中了 [3] pam_faillock.so authfail。
pam_faillock.so authfail 的作用是在认证失败时记录失败次数并拒绝访问。它的控制标记是 [default=die]——PAM 遇到 die 会立即终止整个 auth 栈并返回失败。
在 pam_setcred 执行期间,pam_systemd_home.so 返回成功,跳 2 步命中 authfail,authfail 模块在 setcred 语境下返回非成功值,触发 [default=die],PAM 立即暴毙并返回 PAM_PERM_DENIED。这个错误码经过 login 转换后,就变成了终端里那行 Permission denied。
一行看似无害的新增配置,因为破坏了相对跳转的落点,把整条 auth 链路引向了一个致命的陷阱。
正确姿势与验证#
回头查 Arch Wiki 的 fprintd 配置指引,正确做法是把 auth sufficient pam_fprintd.so 放在 auth 栈的最顶端,在 pam_faillock.so preauth 之前:
1auth sufficient pam_fprintd.so ← 栈顶,跳转块之外
2
3auth required pam_faillock.so preauth
4auth [success=2 default=ignore] pam_systemd_home.so
5auth [success=1 default=bad] pam_unix.so try_first_pass nullok
6auth [default=die] pam_faillock.so authfail
7auth optional pam_permit.so
8auth required pam_env.so
9auth required pam_faillock.so authsucc这样 pam_fprintd.so 在跳转块之外独立运行:指纹认证成功就直接通过(sufficient),失败则继续走后续的密码认证流程。不会干扰 success=2 的相对计数。
修改配置后,重新跑 Python 脚本验证:
1pam_acct_mgmt -> Success (0)
2pam_setcred -> Success (0)
3pam_open_session -> Success (0)三项全部通过。本地 sudo /usr/bin/login -f atang -p 也能正常进入 shell。Tailscale SSH 交互式终端秒进,问题彻底解决。
几点体会#
非交互式 SSH 命令能绕过这个问题,是因为 Tailscale 对两种模式走了完全不同的路径:非交互式直接 fork 子进程执行命令,不经过 login,自然也不走 PAM;交互式需要建立完整的登录会话,必须走 login -f,于是撞上了 PAM auth 栈的配置错误。这个执行边界的差异,是排查初期最容易被误导的地方。
PAM 配置中带数字的跳转语法(success=N)本质上是一种基于行号偏移的 goto——脆弱、隐晦,且没有任何编译期校验。新增或删除模块时,所有上游的跳转计数都可能需要同步调整,但 PAM 不会给你任何警告。这种设计在安全关键的认证链路中相当危险,修改 system-auth 时必须逐行核对每个 success=N 的实际落点。
最后,几行 Python + ctypes 调用 libpam.so 就能把 PAM 这个系统级黑盒拆成逐阶段可测试的切片,比翻日志和猜测高效得多。这个技巧以后排查 PAM 相关问题应该都用得上。