上周帮朋友看一台跑了好几年的业务机,现象挺怪:同一个 rpm 包,在 A 机器上装完一切正常,在 B 机器上装完服务能起来,但某个模块一被调用就崩。两边rpm -q的输出一字不差,包名、版本、架构全对得上。最后是rpm -V把问题揪出来的——B 机器上那个 so 文件的大小和时间戳都跟 rpm 数据库里的记录对不上,说明它被人动过。这就是典型的"包装对了,文件不对",也正好是 RPM 校验这件事里最容易被忽略的那一层。
很多人对 RPM 校验的理解停留在"rpm -ivh没报错就等于没问题",实际上这套机制一共有三层:包文件自身的摘要校验(内容有没有在传输中损坏)、包来源的签名校验(这个包是不是厂商签的、有没有被换过)、以及装完之后文件与数据库的一致性校验(系统里的文件有没有被事后改动)。三层用的命令不同、信任模型不同、踩的坑也完全不同。这篇就把这三层从头到尾拆开讲,包括rpm -K的输出怎么读、公钥导入到底动了什么、rpm -V那九位字符每一位什么意思、/etc下的"假阳性"怎么过滤、以及怎么把校验做成能定期跑的巡检脚本。不管你是刚接触 rpm 的新手,还是已经能背出参数的老运维,应该都能捞到点东西。
1. 从"包没坏但机器不对"说起:RPM 校验的三个层次
1.1 摘要对不上和签名对不上,是两类完全不同的故障
先把概念理清楚,不然后面看输出一定会晕。**摘要(digest)**是一段内容的"指纹",是对文件字节做哈希运算得到的一串定长值;**签名(signature)**则是对摘要(严格说是对包头)做非对称加密得到的密文,只有持有对应公钥的人才能验证它确实出自某个私钥持有者。
这个区别带来的后果是:摘要能证明"内容没被改动过",但它证明不了"这个包是谁做的"。任何人改完包重新算一遍摘要,摘要依然是对得上的。签名不一样,私钥不在你手上,你改了包就没办法重新签出合法的签名。所以在信任链上,签名是根,摘要是枝叶——rpm 的签名实际上覆盖了包头,而包头里记录了负载(payload)的摘要,一层套一层。
我见过不止一次这样的场景:同事从某个第三方站点下了一个 rpm,sha256sum算出来的值和页面标的值一模一样,就放心装了。问题是页面上那个值本身可能就是这个站点自己算的,页面被改摘要值也跟着改,这种校验只防传输损坏,不防人为投毒。要判断来源,只有签名这一条路。
1.2 一只手上数得过来的几种校验算法,各用在哪
校验和这件事在计算机里用得极广,从串口协议帧到压缩包目录,到处都有它的影子。搞 RPM 校验之前,最好把几类算法的定位分清,不然很容易问出"为什么这里不用 CRC32"这种问题。
| 算法 | 输出长度 | 典型用途 | 能否对抗人为篡改 |
|---|---|---|---|
| CRC32 | 32 位 | 传输误码检测、压缩包目录、串口/工业总线帧尾校验、文件系统元数据 | 不能,线性运算,改一个字节可以算出对应修正量 |
| MD5 | 128 位 | 老版本 rpm 的文件摘要、部分下载站公布的校验值 | 已经不建议用于对抗性场景 |
| SHA1 | 160 位 | 老版本的 rpm 包头摘要 | 逐步淘汰中 |
| SHA256 | 256 位 | 现代 rpm 的包头与负载摘要、仓库元数据 | 可以 |
差别在哪?CRC32 的设计目标是"发现随机误码",它对偶发位翻转极其敏感,但对刻意构造的修改几乎没有抵抗力,攻击者完全可以在保持 CRC 不变的前提下改内容。MD5 和 SHA1 都属于密码学哈希,理论上应该有抗碰撞和抗原像的能力,只是随着研究推进,MD5 和 SHA1 的抗碰撞性都已经被打破了。SHA256 目前还是可靠的。
在 RPM 生态里,老包用的是 MD5 文件摘要,这也是rpm -V输出里那个5字符的来源;现代发行版普遍换到了 SHA256,但那个位置还是习惯性叫5,这个历史包袱后面会再提一次。
顺手说一句工具层面的东西,因为经常有人在群里问"sha 校验命令是什么"。Linux 上最顺手的是 coreutils 提供的这几个:
sha256sum package.rpm # 算 SHA256 md5sum package.rpm # 算 MD5 cksum package.rpm # 默认就是 POSIX CRC32 cksum -a sha256 package.rpm # coreutils 8.32 之后支持 -a 指定算法如果手上只有 Windows 或者临时想算个 CRC32 对照一下,用 Python 一行就够:
import zlib data = open("package.rpm", "rb").read() print(f"crc32 = {zlib.crc32(data):08x}")不过要提醒一句,CRC32 用来做文件完整性对照是很不靠谱的选择,它的碰撞门槛低到令人发指,随便塞几个字节就能把 CRC32 拉回原值。文件完整性这件事,老老实实用 SHA256。
1.3 一个 rpm 文件里,到底有几个地方会被校验
打开一个 rpm 文件,它的物理结构大致是"引导段(lead)+ 签名头(signature header)+ 包头(header)+ 负载(cpio 压缩包)"。校验覆盖的位置主要有四个:
- 签名头里记录的包头摘要:签名的对象,用来确认包头没被改。
- 包头里记录的负载摘要和负载大小:体现在
rpm -K输出里的Payload SHA256 digest。 - 包头里记录的文件列表和每个文件的摘要:这是
rpm -V的依据,安装时会写进本地的 rpm 数据库。 - 整个包文件本身的摘要:仓库元数据里会记录,dnf 下载完之后会算一遍做比对。
这四层是递进的:签名验过 → 包头可信 → 包头里的负载摘要可信 → 负载内容可信 → 解包出来的每个文件可信。理解了这条链,就能理解为啥rpm -K通过了基本就可以放心——它验证的是整条链的根。
2. 装机之前:rpm -K 与公钥导入这一条完整链路
2.1 简洁输出和详细输出,分别看什么
rpm -K(等价于--checksig)是装机前的第一道闸门。默认输出很简洁:
rpm -K vsftpd-3.0.5-1.el8.x86_64.rpm # vsftpd-3.0.5-1.el8.x86_64.rpm: digests signatures OK结尾的OK就代表摘要和签名都通过了。一旦有任何一个环节不对,这里会变成NOT OK或者NOKEY,后面会分开讲怎么处理。
想看细节就加-v,输出会展开成逐项判定,这个才是有诊断价值的部分:
rpm -K -v vsftpd-3.0.5-1.el8.x86_64.rpm # vsftpd-3.0.5-1.el8.x86_64.rpm: # Header V3 RSA/SHA256 Signature, key ID 8483c65d: OK # Header SHA256 digest: OK # Header SHA1 digest: OK # Payload SHA256 digest: OK # MD5 digest: OK逐行看:第一行是签名,说明这个包头是用哪个 key ID 对应的私钥签的、验签结果如何;第二三行是包头自身的摘要,现代包一般会有 SHA256 和 SHA1 两行,老包可能只有一种;第四行是负载摘要,这一项过了意味着压缩包内容没被动过;最后一行MD5 digest是包裹在整个包上的旧式摘要,主要是为了兼容老客户端,新版本 rpm 也可能不输出这行。
我一般只看两处:签名那行的 key ID 是不是我认得的发行版签名密钥,以及最终的判定结果。中间那几行摘要只要不是BAD,基本不用操心。
2.2 rpmkeys --import 与 rpm --import:公钥到底进了哪里
验签失败最常见的原因不是包有问题,而是本地没导入对应的公钥。发行版会把自己的 GPG 公钥放在 ISO 或者官网,同时也会随基础系统预置在/etc/pki/rpm-gpg/目录里。
导入动作是这样的:
# 老写法,现在依然可用 rpm --import /etc/pki/rpm-gpg/RPM-GPG-KEY-<发行版名> # 新写法,语义更清晰,等价 rpmkeys --import /etc/pki/rpm-gpg/RPM-GPG-KEY-<发行版名>导入之后公钥去了哪里?很多人以为是写进了/etc/pki/rpm-gpg某个文件,其实不是。rpm 把公钥当成一个特殊的"已安装包"写进了自己的数据库,包名形如gpg-pubkey-<keyid>-<时间戳>。你可以这样查:
rpm -qa 'gpg-pubkey*' # gpg-pubkey-8483c65d-5ccc5b19 rpm -qi gpg-pubkey-8483c65d-5ccc5b19 # Name : gpg-pubkey # Version : 8483c65d # Release : 5ccc5b19 # ... # Description : # -----BEGIN PGP PUBLIC KEY BLOCK----- # ...这一点非常关键,因为它意味着两件事:第一,公钥可以用rpm -e删掉(rpm -e gpg-pubkey-8483c65d-5ccc5b19),删掉之后依赖这个 key 的包就再也验不过了;第二,公钥的增删会影响 rpm 数据库,所以在做系统基线快照的时候,gpg-pubkey*这些条目要单独放行,否则巡检脚本会一直报一堆莫名其妙的差异。
注意:导入公钥之前一定要核对指纹。从非官方渠道拿到的"公钥",导进去等于把一个不认识的私钥持有者加进了信任名单,所有用它签的包都会被判定为合法。指纹核对建议走官方发布的渠道,用
gpg --show-keys或者gpg --with-fingerprint看一眼完整指纹再导。
2.3 NOKEY、NOT OK、BAD 三条处理路径
rpm -K的失败结果其实分成好几类,处理方式完全不同,混在一起处理只会浪费时间。
| 输出关键字 | 实际含义 | 该怎么处理 |
|---|---|---|
NOKEY | 签名算法认出来了,key ID 也读出来了,但本地没这个公钥 | 核对 key ID 与官方公布的一致后,导入对应公钥再验 |
NOT OK/BAD | 摘要或签名对不上,包内容确实被改过或下载损坏 | 不要装,重新下载并核对来源 |
(none)/ 无签名段 | 这个包压根没签名 | 只能靠下载来源和公布的摘要值判断,风险自担 |
Header V4 RSA/SHA256 Signature, key ID xxx: OK但digests BAD | 签名没问题,但负载摘要失败 | 包在签完之后被改动过,或传输中损坏,重下 |
NOKEY是最容易被误判成"这是坏包"的一类。我以前就干过这事:从内网自建仓库下包,rpm -K报 NOKEY,第一反应是仓库同步出了问题,折腾半小时才发现只是新换的签名密钥没导进本地。
另外补一个实操细节:rpm -i安装时对这两类失败的处理强度是不一样的。摘要对不上通常直接报错中断安装;而签名对不上(尤其是 NOKEY)在某些版本和配置下只给一行 warning 就继续装了。所以不要指望rpm -i帮你把关,养成习惯:装之前先手动跑一遍rpm -K。
2.4 校验等级宏与 --nodigest/--nosignature 的应急边界
rpm 4.14 之后引入了一个%_pkgverify_level宏,用来控制默认做几层校验,取值一般是all、digest、signature、none。它决定了 rpm 在读写包文件时的严格程度。我个人的建议是保持默认(通常是all或digest),除非有明确理由,否则不要动它。
配套的还有两个开关:
rpm -i --nodigest --nosignature some.rpm这两个参数的作用是跳过摘要和签名检查。注意这里是包文件层面的跳过,不是rpm -V里检查已安装文件内容的那个开关(那个叫--nofiledigest,后面会讲)。这两个东西经常被搞混,我在排查现场见过有人为了关掉文件内容比对,敲了--nodigest,结果什么都没变,还以为是 rpm 有 bug。
--nodigest --nosignature的正经用途只有一个:应急恢复。比如系统崩了要从救援介质里装一个包,而这个救援介质的签名链跟当前系统对不上,这时候用它先把环境拉起来。用完一定要记得后面重新走一遍正规校验,因为跳过之后这个包在后续的rpm -V里是没有摘要记录可比的。
3. 装机之后:rpm -V 那九个字符逐位拆解
3.1 SM5DLUGTP 每一位在说什么
rpm -V <包名>是装机之后最常用的校验手段,它拿本地文件的实际状态跟 rpm 数据库里安装时写下的记录做逐项比对。输出格式长这样:
rpm -V openssh-server # S.5....T. c /etc/ssh/sshd_config # .M....... /usr/libexec/openssh/sftp-server # missing /usr/share/man/man8/sshd.8.gz第一列是九个字符(不足补点),每一位对应一种属性差异:
| 位置 | 字符 | 比对的属性 | 常见触发原因 |
|---|---|---|---|
| 1 | S | 文件大小 | 被替换、被追加内容、被截断 |
| 2 | M | 权限位或文件类型 | chmod、文件被换成软链接或目录 |
| 3 | 5 | 文件内容摘要 | 内容被编辑或替换 |
| 4 | D | 主次设备号 | 设备节点被重建 |
| 5 | L | 软链接指向 | ln -sf改了目标 |
| 6 | U | 属主 | chown |
| 7 | G | 属组 | chgrp |
| 8 | T | 修改时间 | 被编辑、被 touch、日志轮转 |
| 9 | P | capabilities | setcap / getcap 变更 |
第二位是空的还是显示成M之外的值,取决于 rpm 版本,早期版本这一列只报权限和文件类型差异。第九位的P是后来加的,用来覆盖 capabilities 这条现代 Linux 上越来越重要的属性。
重点说说第三位5。这个名字来自 MD5,因为老版本 rpm 用的就是 MD5 做文件摘要。新版本换成了 SHA256,但字符位置没变,还是那个5。所以看到5不要理解成"MD5 校验失败",它现在代表的是"文件内容摘要与安装时记录不符",算法是什么取决于构建这个包时用的默认值。
判断优先级上,我的经验是:5和S同时出现,基本可以确定文件被换过;只有T出现,多半是正常的配置修改或日志写入;只有M或U/G出现,通常是有人手工调过权限。
3.2 把 rpm -Va 的输出压成"真正要看的一小撮"
单包校验好办,麻烦的是全量扫描:
rpm -Va在一台装了几年、跑了几十上百个包的机器上,这条命令输出几千行是常态,直接看等于没看。需要做的是把它压下来。
一个常见的错误做法是用 grep 粗暴过滤:
rpm -Va | grep -v ' c /' # 不推荐为什么不行?因为rpm -V的输出字段是"九位标志 + 属性串 + 路径",中间用空格分隔。当属性串为空的时候,格式是标志 + 空格 + 路径;当属性串是c的时候,格式是标志 + 空格 +c+ 空格 + 路径。这个grep -v ' c /'不但会把合法的配置变更过滤掉,还会误伤路径里恰好包含c的行——虽然概率不高,但线上做巡检脚本,误报和漏报一样致命。
正确做法是按字段解析。rpm 输出的字段分隔用\s+就够稳,写个正则:
import re, subprocess VERIFY_LINE = re.compile(r'^(?P<flags>[SMDLUGTP5.]{9})\s+(?P<attr>\S*)\s+(?P<path>/\S+)$') def collect(): out = subprocess.run(["rpm", "-Va"], capture_output=True, text=True, check=False) rows = [] for line in out.stdout.splitlines(): m = VERIFY_LINE.match(line) if m: rows.append(m.groupdict()) elif line.startswith("missing"): rows.append({"flags": "MISSING", "attr": "", "path": line.split()[-1]}) return rows拿到结构化的数据之后,就可以按"标志组合"分级了:
- 高危:
flags里同时含S和5,说明大小和内容都变了,文件被整体替换的概率极高。 - 中危:只含
5,内容变了但大小没变,可能是原地编辑或二进制补丁。 - 低危:只含
T,时间戳变化,内容没动过。 - 权限类:只含
U、G、M、P,属性层面的调整。 - 缺失:
missing行,包里的文件不在了,可能是被人删了,也可能是发行版升级后遗留。
按这个分级跑一遍,一台常规业务机的待确认条目通常能从几千行压到几十行,人工审起来才现实。
3.3 为什么 /etc 下的文件永远在报 T 和 5
刚上手的人几乎都会问同一个问题:我什么都没动,为什么rpm -V报了一堆/etc下的文件?
答案有三层。
第一层,配置文件本来就会变。装完之后改个端口、调个日志级别、调个连接数上限,都是正常的运维动作,rpm -V当然会报。这不是故障,这是设计如此——rpm 记录了安装时的原始状态,任何改动都会被如实报告出来。
第二层,有些包在安装脚本里自己就在改文件。比如某个服务的%post脚本会往/etc下追加内容、生成机器相关的标识符,装完那一刻就已经和包里的原始文件不一样了。
第三层,日志和运行时会话文件也会被标进去。有些包的日志文件、pid 文件是包的一部分(用%ghost声明的占位文件),运行起来就会写内容,T必然飘。
处理方式有几种,按我的偏好排序:解析输出后按attr == 'c'过滤掉配置项,这是最稳的;rpm 本身在较新版本里提供了--noconfig开关,可以直接让-V忽略配置文件,用之前先在你的发行版上测一下是否支持;再就是维护一份显式的白名单,把已知会变动的路径列进去。
3.4 missing、not installed 和"文件不属于任何包"
rpm -V输出里有一条容易和别的概念混起来的:missing。
它的含义是"这个包在其文件列表里声明了这个文件,但文件系统上找不到"。和它容易混的是两种完全不同的情况:
package xxx is not installed:这个包根本没装,rpm -V自然没法比对。file /xxx is not owned by any package:用rpm -qf /path查某个文件属于哪个包时,回答是"不属于任何包"。
后两种情况要分开对待。missing才是需要关注的——文件被删了。但也不是所有missing都有问题,比如包升级之后旧版本留下的某些文件被清理了,或者管理员手工删了某个确认无用的文件,都会留下missing记录。判断方法是结合文件路径的重要性,/usr/bin、/usr/lib64下的missing要重点看,/usr/share/doc、/usr/share/man下的基本可以忽略。
顺带提一句反向定位的命令组合,排查时用得极多:
rpm -qf /usr/sbin/sshd # 这个文件属于哪个包 rpm -ql openssh-server # 这个包装了哪些文件 rpm -Vf /usr/sbin/sshd # 只校验这一个文件所在的包 rpm -qc openssh-server # 只看这个包的配置文件rpm -Vf在只怀疑个别文件的时候很省事,不用扫全量。
4. 从仓库到本地:一条完整的下载校验链
4.1 repomd.xml 是整条链的信任根
用 dnf/yum 从仓库装包,很多人以为"rpm 帮我验过签名了就行",其实在到达 rpm 之前,软件仓库这一层已经做了好几道校验。把这条链走一遍很有价值,因为出问题的时候你就知道该在哪一环查。
链路的起点是仓库根目录下的repomd.xml。这个文件记录了仓库里各个元数据文件的位置、大小和摘要,比如primary.xml.gz的 SHA256 和字节数。它的地位相当于"元数据的元数据",是整条链的信任根。因此正规的仓库会再给repomd.xml配一个 GPG 签名(通常叫repomd.xml.asc)。
拉包时的顺序是这样的:
- 下载
repomd.xml,如果配置了repo_gpgcheck=1,同时下载repomd.xml.asc并用本地公钥验签。 - 从
repomd.xml里读出primary.xml.gz的 SHA256 和 size,下载后先算摘要、再比大小,两项都对才继续。 - 从解压出来的
primary.xml里找到目标包,读出它的 checksum 和 checksum type(通常是 SHA256)。 - 下载 rpm 文件,本地算一遍摘要与元数据里的值比对,不一致直接丢弃重下。
- 交给 rpm 去做签名和摘要校验,也就是
rpm -K那一套。
四道关,任何一道不过都会中断。这也是为什么从仓库里装的包比从某个论坛链接下的包要放心得多——不是包本身有多大差别,是校验链的完整度差了一整个数量级。
4.2 gpgcheck、repo_gpgcheck、localpkg_gpgcheck 分别管什么
这三个开关名字很像,管的事情完全不一样,配置错了就会出现"看着配了但没生效"的情况。
| 配置项 | 位置 | 实际作用 |
|---|---|---|
gpgcheck | 仓库文件(/etc/yum.repos.d/*.repo)或/etc/dnf/dnf.conf | 安装从仓库下载的包时校验包签名 |
repo_gpgcheck | 仓库文件 | 校验仓库元数据(repomd.xml)的签名,不是包签名 |
localpkg_gpgcheck | /etc/dnf/dnf.conf | 用dnf install ./xxx.rpm装本地 rpm 文件时是否校验签名 |
典型的仓库文件配置长这样:
[internal-base] name=Internal Base baseurl=https://repo.internal.example.com/baseos/$basearch/ enabled=1 gpgcheck=1 repo_gpgcheck=1 gpgkey=https://repo.internal.example.com/keys/RPM-GPG-KEY-internal metadata_expire=3600这里最容易踩的坑是:给baseurl换成https之后,以为传输加密了就万事大吉,把gpgcheck关掉了。加密只保护传输过程,不保护源头的可信度——如果仓库服务器本身被攻陷,https 一点用都没有。签名校验才是这条链上唯一能对抗源端投毒的手段。
另一个坑是repo_gpgcheck=1打开后仓库突然全部报错。最常见原因是本地没有仓库元数据签名用的那把公钥,而gpgkey=里通常只写了包签名的那把。遇到这种情况,先把repo_gpgcheck临时关掉看是否能恢复,确认是公钥问题之后再去补公钥。别一上来就怀疑网络。
4.3 手动核对:和官网公布的摘要比对
有些场景不走仓库,比如从官网直接下资源包、从内网文件服务器拷包。这时候唯一的凭据就是发布方公布的摘要值。流程很简单,但有几个细节值得说。
sha256sum vsftpd-3.0.5-el8.x86_64.rpm拿到输出之后和发布页面上公布的值逐字符比对。这里有两个常见失误:一是把十六进制的大小写搞错,比对的时候先统一转换成小写;二是下载时不小心多下了个.asc或者.sig文件,比例的对象搞错了,白忙一场。
# 统一成小写再比,避免大小写造成的误判 sha256sum vsftpd-3.0.5-el8.x86_64.rpm | awk '{print tolower($1)}'比较正式的发布方还会同时提供 SHA 和 MD5 两套值,这主要是历史习惯,验证时优先用 SHA256 那套。MD5 那套留着当交叉参考就行,别单独依赖它做安全判断。
4.4 rpm2cpio 拆包复核:当 -K 都通过但你还是不放心
确实会遇到这种情况:rpm -K全部 OK,但你需要确认包里到底装了什么,或者要跟另一个版本的文件做逐字节比对。这时候用rpm2cpio把它拆开看。
# 只列内容,不解压 rpm2cpio package.rpm | cpio -t # 解到当前目录下的临时文件夹 mkdir -p /tmp/rpmx && cd /tmp/rpmx rpm2cpio /path/to/package.rpm | cpio -idmv # 只看安装脚本,不落盘 rpm -qp --scripts package.rpm # 只看包头信息 rpm -qpi package.rpm rpm -qp --qf '%{NAME} %{VERSION} %{RELEASE} %{PAYLOADDIGEST} %{PAYLOADDIGESTALGO}\n' package.rpmrpm -qp --qf这条值得多看一眼,它能直接把包里的负载摘要打出来,用来和另一个来源的同名包做比对非常方便。不过要注意%{PAYLOADDIGEST}这类标签在较新的 rpm 上才有,老的发行版上可能输出空值,用之前先试一下。
拆包之后能做的事情就多了:拿某个二进制和线上的版本做cmp,看某个配置文件里到底写了什么默认值,检查%post脚本里是不是做了什么意料之外的事。这一步在排查第三方包、做合规审查的时候几乎是必做动作。
5. 把校验做成例行动作:脚本、白名单和 rpmdb 修复
5.1 rpm -V 没有"一切正常"这种明确信号,得自己判断
rpm -Va在一台完全正常的机器上也可能输出很多东西,因为配置文件本来就被改过。它不会像很多工具那样给你一句"all good",也没有一个稳定的退出码可以直接拿来判断。
关于退出码,不同发行版、不同 rpm 版本在遇到差异时返回什么都不完全一致,有的版本返回 0,有的返回非 0。所以我在脚本里从来不依赖rpm -Va的返回码,一律解析标准输出。这个习惯来自一次教训:早期的巡检脚本用if [ $? -eq 0 ]判断,结果连续几个月一个告警都没出,后来手工跑了一次才发现输出里全是差异,只是退出码恰好是 0。
脚本结构大致分三步:采集、过滤、分级输出。
#!/usr/bin/env python3 """定期巡检:解析 rpm -Va 输出,按白名单过滤并分级""" import re import subprocess import json from pathlib import Path WHITELIST_FILE = Path("/etc/rpmverify/whitelist.json") VERIFY_LINE = re.compile(r'^(?P<flags>[SMDLUGTP5.]{9})\s+(?P<attr>\S*)\s+(?P<path>/\S+)$') def load_whitelist(): if not WHITELIST_FILE.exists(): return {"prefix": [], "exact": [], "pkg_attr_config": True} return json.loads(WHITELIST_FILE.read_text()) def scan(): proc = subprocess.run(["rpm", "-Va"], capture_output=True, text=True) result = [] for line in proc.stdout.splitlines(): if line.startswith("missing"): result.append({"flags": "MISSING", "attr": "", "path": line.split()[-1]}) continue m = VERIFY_LINE.match(line) if m: result.append(m.groupdict()) return result def severity(flags): if flags == "MISSING": return "high" if "S" in flags and "5" in flags: return "high" if "5" in flags: return "medium" if set(flags) <= set(".T"): return "low" return "low" def main(): wl = load_whitelist() rows = [] for item in scan(): path = item["path"] if any(path.startswith(p) for p in wl.get("prefix", [])): continue if path in set(wl.get("exact", [])): continue if wl.get("pkg_attr_config") and item["attr"] == "c": continue item["severity"] = severity(item["flags"]) rows.append(item) rows.sort(key=lambda r: {"high": 0, "medium": 1, "low": 2}[r["severity"]]) for r in rows: print(f"{r['severity']:6} {r['flags']:9} {r['path']}") print(f"\n合计 {len(rows)} 条待确认") if __name__ == "__main__": main()白名单文件长这样:
{ "prefix": ["/var/log/", "/var/lib/", "/var/spool/", "/run/"], "exact": ["/etc/machine-id", "/etc/resolv.conf"], "pkg_attr_config": true }这个脚本我基本没怎么改过,跑在不同发行版上都能用,因为只依赖rpm -Va的输出格式。
5.2 白名单怎么维护才不会变成摆设
白名单是这类巡检脚本的生命线,维护不好就两个下场:要么太松,重要告警被吞掉;要么太严,天天报一堆没用的,最后没人看。
我的原则是只加确定会动的路径,不加"看着像是会动"的路径。具体分三类:
- 确定会变的目录:
/var/log、/var/lib、/var/spool、/run、/tmp。这些地方本来就不该指望文件内容和安装时一致。 - 配置文件:默认通过
attr == 'c'这个条件统一放行,不单独加路径。因为配置文件太多太杂,逐个加根本加不完。 - 机器相关的标识:
/etc/machine-id、/etc/resolv.conf、/etc/hosts这类,按精确路径单列。
有个反直觉的点值得强调:/etc下的白名单要慎用。配置文件的变更虽然正常,但很多攻击手法恰恰就是在/etc下动手脚,比如往ld.so.preload里塞东西、改crontab、改sudoers。如果图省事把整个/etc加进白名单,等于把最需要盯的地方给放行了。我的做法是配置文件只放行T和5这类内容差异的告警,一旦出现U、G、M这种权限属性变化,即使路径在/etc下也要报出来——因为正常的运维改配置不会同时把属主和权限也改了。
5.3 rpmdb 出问题时先分级,再决定要不要 rebuilddb
还有一类校验失败跟文件没关系,是本地的 rpm 数据库自己出了问题。典型现象是:
rpm -qa # error: rpmdb: BDB0113 Thread/process ... failed: BDB1507 ... # error: cannot open Packages database in /var/lib/rpm或者更隐蔽一点,查询某个包时莫名其妙地报段错误、返回不完整结果。这时候要先判断是数据库损坏还是别的问题。
ls -l /var/lib/rpm/ # RHEL8 之后是 rpmdb.sqlite,之前是 Packages、Name、Requires 等一堆文件 rpm -qa --qf '%{NAME}\n' | wc -l # 看能不能正常读如果确认是数据库损坏,处理方式是重建索引:
# 先备份,万一失败还能回退 cp -a /var/lib/rpm /var/lib/rpm.bak.$(date +%s) # 新版本 rpmdb --rebuilddb # 老版本 rpm --rebuilddb # 重建完检查包数量是否合理 rpm -qa | wc -l两点提醒。第一,重建索引不影响已安装的文件,只重建数据库——这一点很多新手会误解,以为要重装系统。第二,重建之前一定要留备份。我见过一次因为磁盘满了导致重建过程中途失败,数据库直接不可读的案例,最后是靠 rpmdb 的备份目录恢复的,折腾了快两个小时。跑rebuilddb之前先确认/var/lib所在分区有足够空间,这一步花不到十秒,能省掉很多麻烦。
6. 给别人发包:私有 rpm 的签名与内部校验闭环
6.1 rpmsign --addsign 的准备工作
如果你需要把内部构建的 rpm 分发给别的机器,签名这件事必须做。不签名的包在装了gpgcheck=1的机器上根本装不上,硬关校验又等于把整条信任链拆了。
给包签名的命令是:
rpmsign --addsign package.rpm但这个命令背后有一堆准备工作。它默认会去找 GPG 的私钥,所以得先有一对能用的密钥:
# 生成密钥对(按提示填信息,注意密钥用途要支持签名) gpg --full-generate-key # 查看本地私钥列表,拿到 key ID gpg --list-secret-keys --keyid-format LONG然后要让 rpm 知道用哪把密钥、以及怎么调用 gpg。传统做法是配置~/.rpmmacros:
%_signature gpg %_gpg_name <你的 key ID> %_gpg_path ~/.gnupg%_gpg_name这一项填错的话,rpmsign会报找不到密钥,或者更糟——静默地签成了另一个身份。签完之后立刻用rpm -K -v验一遍,确认签的是预期的那把密钥,这个动作我每次都做:
rpmsign --addsign package.rpm rpm -K -v package.rpm # 检查输出的 key ID 是不是你准备的那把顺带说一个不太为人知但很有用的参数:--delsign可以移除已有签名。在重新签名之前用它清一下,避免签名层层叠加。
6.2 自建仓库时元数据也要签
只给单个 rpm 签名还不够。如果内部走的是自建仓库,repomd.xml也得签,否则客户端开了repo_gpgcheck就用不了。
createrepo_c生成元数据之后,用 gpg 对repomd.xml做分离签名(detached signature),生成repomd.xml.asc:
createrepo_c /var/www/repo/baseos cd /var/www/repo/baseos/repodata gpg --detach-sign --armor repomd.xml # 生成 repomd.xml.asc这里有两个容易忽略的细节。第一,每次createrepo_c重新生成元数据之后都要重签,因为repomd.xml的内容变了,旧签名自然失效。如果这一步忘了,客户端会报"元数据签名无效",但错误信息不会直接告诉你"你忘了重签",得自己反应过来。我的做法是把这两条命令写进同一个发布脚本,中间不给人手工介入的机会。
第二,repomd.xml.asc的位置和命名要严格对应。有些客户端找的是repomd.xml.asc,有些找的是repomd.xml.key,具体取决于版本和配置。部署完之后用客户端实测一遍最保险。
6.3 内部校验的最小闭环
把前面所有东西串起来,一个内部发包的最小闭环大概是这样的。
构建侧:编译产出 rpm →rpmsign --addsign签名 →rpm -K -v自检 → 放进仓库 →createrepo_c生成元数据 → 对repomd.xml分离签名 → 发布。
客户端侧:导入内部公钥 → 仓库配置打开gpgcheck和repo_gpgcheck→ 装包 → 装完跑一次rpm -V存基线。
再加上定期巡检:每天凌晨跑一遍全量rpm -Va,按第 5 节那套脚本过滤分级,有高危条目就告警。
这个闭环看起来步骤不少,但每一环都有明确目的,拆掉任何一环都会留下缺口。真要说哪一步最容易被省略,大概是最后的定期巡检——因为签名和下载校验都是"装的时候"做的,装完之后文件会不会被改,只有巡检能发现。
最后分享一个我自己一直在用的小习惯:给关键业务机做资产基线时,除了记录包清单,顺手把rpm -Va的输出存一份到与业务机隔离的位置。日常巡检只对比"今天和昨天"的差异,而这份离线基线可以在怀疑系统被改动过的时候,用来比对"现在和部署时"的差异。因为rpm -V比的是本地数据库,如果攻击者连数据库一起改了,rpm -V是发现不了的。多一份离线的、装机时刻的原始记录,成本几乎为零,真到要用的时候价值很大。这个思路和 AIDE、tripwire 那类工具是同一个道理,只不过用 rpm 自带的输出做基线,部署成本更低,也更容易解释清楚。