↓ 跳过正文
  1. Posts/

记一次 SSH 交互式登录闪退排查:Tailscale SSH、login -f 与 PAM 跳转计数陷阱

·2500 字·5 分钟

案发现场
#

前几天在 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 的其他三个阶段:

  1. pam_acct_mgmt — 账号状态检查(过期、锁定等)
  2. pam_setcred — 建立用户凭据(这一步会复用 auth 栈的模块)
  3. 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.so

success=2 的跳转计数没变,还是跳 2 个模块——但现在跳过的是 [1] pam_fprintd.so 和 [2] pam_unix.so,精准命中了 [3] pam_faillock.so authfail。

PAM auth 栈跳转偏移对比

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 相关问题应该都用得上。

参考资料
#

相关文章

从进程视角重新理解 Docker

·3809 字·8 分钟
起因 # 第一次接触 Docker 是 2017 年,在深圳网警机房驻场做审核项目。机房完全没有外网,模型、依赖、运行环境全靠 U 盘拷贝 docker image 来传输。后来做模型转换系统,再到量化行业负责机器学习平台,和 Docker、K8s 打了不少交道。但距离上一次系统梳理 namespace、cgroup 这些底层机制,已经过去好几年了。

linux 下C++ 连接mysql 数据库

·243 字·1 分钟
资料推荐这个:MySQL C API programming tutorial 环境为ubuntu 14.04 lts 需要安装mysql 和mysql 开发包 sudo apt-get install libmysqlclient15-dev mysql-server mysql-client 先在mysql 中建立test数据库和test表格 mysql>create database test; mysql>use test; //切换到test数据库中 mysql> create table test(name varchar(255),num int(10) ); //创建一个叫test的表 mysql>show create table test; //显示刚才创建的表信息 mysql> select * from test; //查询test表中数据 mysql>quit 然后用如下cpp代码连接