屏幕锁定失效的我,把 PAM 看了一遍(systemd 你丫!)

创建于 / 更新于 4410 / 约需 21 分钟

Those who trust systemd... will eventually have their underpants sold to them.

  • Icenowy Zheng, 2016,记录于 aosc.io

一年之后的结论:好吧,其实幕后黑手(大概)是我……

(原文如下)

tl;dr:

如果你的 kscreenlocker 无法使用密码解锁,但是 sudo 正常运行,试着把 /etc/nsswitch.conf 的:

shadow: files systemd
conf

改成:

shadow: files [UNAVAIL=return] systemd
conf

发生什么事了?

在前段时间的一次例行检查中,突然发现我的设备上 /etc/shadow 的权限竟然是 644。/etc/shadow 是存储用户密码(一般都有做哈希)的文件,开放给所有人可读感觉不太对,于是在 Arch Linux CN 的群组问了一下,也翻了一下 Arch Linux 自己的光盘和 Docker 映像,发现这玩意的权限应该是 400 (或者 000,总之就是只有 root 可读就对了),就修正了一下。

修正之后,其它的地方倒没什么问题,但是 kscreenlocker 的屏幕锁定突然没法解锁了,提示 PAM 密码验证失败。想着可能是偶然密码输入错误,但是 sudo 却一直没有问题,把 sudo 通过的相同密码输入给 kscreenlocker 又会失败,这个问题就有点奇怪了。

顺着这个契机,算是大概地了解了一下 shadow 和 PAM。

背景:什么是 /etc/shadow

passwd(5) 是这么说的:

/etc/passwd 存储了系统的用户基本信息,因此它必须对所有人可读。在过去,散列后的密码都是直接存在 /etc/passwd 的第二列的,所以所有人都可以知道其它人密码的散列值。那时候,由于人类的心灵依然纯真,以及设备还不够高性能,大家不用担心其它人从散列反向推出密码。再晚些时候,大家发现人类也不是那么信得过,而机器的性能水平又逐渐提高,就把密码散列从 /etc/passwd 拆出来,放到一个只有 root 可读的文件里了, /etc/passwd 的第二列则变成一个 x 的占位符。

背景:什么是 PAM?

项目站点是这么说的:

PAM (Pluggable Authentication Modules,可插认证模块) 是一种弹性很强的用户认证 (Authentication aka. AuthN) 系统。

至于为什么说它「弹性很强」呢?因为它能搞出的花样很多。除了普通的密码之外,它也可以用来做一些其它方式的验证,例如做人脸识别的 Howdy可以在手机端确认 SSH 登录的 Krypton(现在叫 Akamai MFA 了),还有用类似 Yubikey 这样的硬件设备登录的模块魔法。除了验证方式之外,它也可以用来完成一些验证之外的工作,例如锁定多次验证失败账户的 pam_faillock自动创建用户家目录的 pam_mkhomedir还有干脆禁止登录的 pam_deny 等。

那么背景内容结束,该开始修东西了。

初次尝试修复

kscreenlocker 在过去会调用 kcheckpass,而后者在编译时被配置从两种密码验证方式中选择一种使用:

  • 一种是经典的 PAM
  • 另一种是直接查 /etc/shadow

PAM 太麻烦了所以当时还不想修,而且好像其它的地方(sudoPolicyKit 等)都没有问题,所以就先不管了。

如果是查 /etc/shadow 的话,在修正了 /etc/shadow 的权限之后,它就会需要 kcheckpass 有 root 权限(也就是要给它上 SUID)来正常运行。不过当时的软件包中 kcheckpass 并没有 SUID,所以感觉这种方法大概会失败。去发了个 ticket 问这事,不过看起来这并不是普遍问题。

确实修 PAM 很重要(因为很多时候都会用到),但是因为实在不想管 PAM,就 chmod u+s /usr/lib/kcheckpass 给它上 SUID 了。

当然还有后续否则就不会存在这篇文章了

本月早些时候的 5.25 版本移除了 kcheckpass

怎么办呢?那就去硬着头皮修 PAM 吧。

调试 PAM,从简单到复杂分别有这么几种方法:

  • pam_unix.sodebugaudit 两个输出调试日志的选项
  • 手动测试 PAM
  • strace 查看 pamtester 的 syscall 调用细节
  • 直接改源代码,在库里加调试输出

调试开始

  1. pam_unix.sodebugaudit 两个输出调试日志的选项

几乎相当于不存在。下一个。

  1. 手动测试 PAM

调用 PAM 的 API 很麻烦,不过 pamtester 是很好用的工具。它大概是这么用的:

pamtester system-auth kotono authenticate
sh

其中第一个参数(这里的 system-auth)是模块名,第二个参数是用户名(这里的 kotono),而第三个参数是操作(例如 authenticate)。

然后 PAM 确实失败了,看来不是 kscreenlocker 的问题。

  1. strace 查看 pamtester 的 syscall 调用细节

strace 有一些不错的参数可用,例如:

  • -k/--stack-traces 可以显示每次系统调用完成后的 stack trace
  • -t/--absolute-timestamps 可以显示每次调用发生的时间

(strace 输出略)

虽然 syscall 的内容不太好理解,不过还是看出了点端倪:在 PAM 出了问题的本机上, PAM 用 systemd 查了些什么东西,而正常的机器上没有这些关于 systemd 的描述。

(这时候开始感受到一丝来自 systemd 的凉气。)

  1. 开始暴改 PAM 源码加调试输出

好,开干!

暴改源码

源代码在这里。代码量不算很复杂,构建也很方便

PAM 其实在源码里已经预置了很多像是 D(...) 出现的日志行方便调试。这些日志行默认不会生效,需要在编译时通过 PAM_DEBUG 开启,之后就会将日志输出到 /var/run/pam-debug.log

在对比了我自己设备上和一个 archlinux 的 Docker 容器的输出后,发现二者在 support.c_unix_verify_password() 这个函数上表现不同。正常的系统会进入 PAM_UNIX_RUN_HELPER 的分支,调用 SUID 的 /sbin/unix_chkpwd 来咨询 /etc/shadow (因为 PAM 自己并没有提权,如果程序读不到 shadow,那它调用的 PAM 也读不到,需要这个 SUID 的程序来读),而我的设备上会直接进入 "user's record unavailable" 的分支,最后返回验证失败。这个分支的参数 retval 又来自 passverify.cget_pwd_hash(),后者会用到 pam_modutil_getpwnam(),也就是 getpwnam() 这个 libc 函数。

然后 getpwnam() 的行为不太对劲。

getpwnam() 是一个获取密码信息的 libc 函数。它调用起来很方便,我们用一个简单的 demo 来试试:

#include <pwd.h>
#include <stdio.h>
#include <sys/types.h>

int main(int argc, char **argv) {
  if (argc < 2) {
    printf("Usage: ./a.out [username]\n");
    return 0;
  }
  char *username = argv[1];
  struct passwd *pwd = getpwnam(username);
  if (pwd == NULL) {
    printf("User does not exist.\n");
    return 0;
  }
  printf("Password string: %s\n", pwd->pw_passwd);
  return 0;
}
c

跑一下:

$ gcc test.cpp && ./a.out kotono
!*

# 在正常的设备上呢?
$ gcc test.cpp && ./a.out testuser
x
sh

看来我们找到原因了。/etc/passwd 里,密码栏位是 x 的意义是密码保存在 /etc/shadow,而以 ! 字母开头的意思是账户禁止密码登录。难怪 PAM 拒绝验证。只要让它返回正常的输出,锁屏问题大概就能解决了。但它的行为是由什么影响的呢?

难道真的是 systemd ?

因为之前我们在 strace 里看到 systemd,于是拿着 systemd、pam_unix 和 getpwnam 搜索了一下,发现了这么一条 issue:

nss-systemd: conflict with recent pam_unix #20299

它给出的 workaround 也很实在:把 /etc/nsswitch.conf 里的 shadow 一行上, files 和 systemd 中间加一个指示,不要让失败的 shadow 尝试回滚到 systemd:

shadow: files [UNAVAIL=return] systemd

之后我们重新试一下之前的 getpwnam() demo:

gcc test.cpp && ./a.out kotono
x

看起来好了!/usr/lib/kscreenlocker_greet --testing 也一切正常!

结论:看来果然是 systemd

开头遇到的问题,最后通过修改 /etc/nsswitch.conf 绕过 nss-systemd 解决了。从上面提到的 #20299 中看到,PAM 维护者认为 nss-systemd 的实现违背了 getspnam(3) 中的约定,因此这是个 systemd 的问题,不愿意为了这个修改 PAM;而 systemd 的这条 issue 也有将近一年的时间没有更新了,恐怕这个问题短时间之内没有办法在上游解决。


LIKE 本文

Webmention 回应

本文暂无回应。