屏幕锁定失效的我,把 PAM 看了一遍(systemd 你丫!)
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
改成:
shadow: files [UNAVAIL=return] systemd
发生什么事了?
在前段时间的一次例行检查中,突然发现我的设备上 /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 太麻烦了所以当时还不想修,而且好像其它的地方(sudo
、PolicyKit
等)都没有问题,所以就先不管了。
如果是查 /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.so
有debug
和audit
两个输出调试日志的选项- 手动测试 PAM
- 用
strace
查看pamtester
的 syscall 调用细节 - 直接改源代码,在库里加调试输出
调试开始
pam_unix.so
有debug
和audit
两个输出调试日志的选项
几乎相当于不存在。下一个。
- 手动测试 PAM
调用 PAM 的 API 很麻烦,不过 pamtester
是很好用的工具。它大概是这么用的:
pamtester system-auth kotono authenticate
其中第一个参数(这里的 system-auth
)是模块名,第二个参数是用户名(这里的 kotono
),而第三个参数是操作(例如 authenticate
)。
然后 PAM 确实失败了,看来不是 kscreenlocker
的问题。
- 用
strace
查看pamtester
的 syscall 调用细节
strace
有一些不错的参数可用,例如:
-k
/--stack-traces
可以显示每次系统调用完成后的 stack trace-t
/--absolute-timestamps
可以显示每次调用发生的时间
(strace 输出略)
虽然 syscall 的内容不太好理解,不过还是看出了点端倪:在 PAM 出了问题的本机上, PAM 用 systemd 查了些什么东西,而正常的机器上没有这些关于 systemd 的描述。
(这时候开始感受到一丝来自 systemd 的凉气。)
- 开始暴改 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.c
的 get_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;
}
跑一下:
$ gcc test.cpp && ./a.out kotono
!*
# 在正常的设备上呢?
$ gcc test.cpp && ./a.out testuser
x
看来我们找到原因了。/etc/passwd
里,密码栏位是 x
的意义是密码保存在 /etc/shadow
,而以 !
字母开头的意思是账户禁止密码登录。难怪 PAM 拒绝验证。只要让它返回正常的输出,锁屏问题大概就能解决了。但它的行为是由什么影响的呢?
难道真的是 systemd ?
因为之前我们在 strace 里看到 systemd,于是拿着 systemd、pam_unix 和 getpwnam 搜索了一下,发现了这么一条 issue:
它给出的 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 也有将近一年的时间没有更新了,恐怕这个问题短时间之内没有办法在上游解决。