Go 主机安全面试:Linux polkit 授权规则持久化与提权检测
Linux 桌面的软件安装、磁盘挂载、服务管理,以及不少需要管理员权限的系统操作,都会经过 polkit 做授权决策。与应用自身写死“是否允许”不同,polkit 把“动作是什么”和“谁可以执行”拆开:动作由 .policy 文件声明,本机策略可以由 .rules 文件覆盖。
这种可扩展性很方便,也形成了主机安全的持久化面。攻击者如果已经取得可写规则目录的权限,可以不再反复利用漏洞,而是留下一条“某个用户或某个条件永远返回 YES”的规则,等待后续 pkexec、服务管理或磁盘操作触发。检测的重点不是看到一个 .rules 文件就报恶意,而是回答:谁改了规则、改了哪个动作、实际是否授予了权限、之后有没有发生提权行为。
岗位场景
Web 服务或低权限账号被利用
-> 攻击者获得写入 polkit 规则目录的错误权限
-> 新增或修改 .rules,提前返回 YES / AUTH_SELF
-> polkitd 检测到目录变化并重新加载规则
-> 攻击者通过 pkexec 或受 polkit 保护的服务执行管理员操作
-> Go Agent 关联文件写入、规则内容、授权检查与提权进程树
-> 输出“谁改了哪条授权边界、何时生效、之后做了什么”的证据链如果攻击者已经拥有 unrestricted root,单独讨论规则文件权限意义不大;真实的高价值场景是错误目录权限、脆弱的管理面、供应链写入、容器错误挂载,以及已经获得局部权限后利用 polkit 做持久化和防御规避。
高频面试题
1. polkit 在 Linux 权限体系中解决什么问题?核心对象有哪些?
简洁答案: polkit 是一个授权管理器。特权程序把“某个主体能否对某个动作执行操作”的决策交给 polkit authority;用户可能需要通过认证代理证明身份。核心对象是 mechanism、subject、action、authority 和 authentication agent。
关键知识点:
- mechanism 是提供特权操作的程序,subject 是发起请求的非特权客户端。
- action 用命名空间中的 ID 表示,例如
org.freedesktop.policykit.exec。 - authority 由系统总线上的
polkitd服务实现,启动后通常会降到非特权polkitd用户运行。[1][2] - 认证代理负责弹出密码框或使用文本界面完成认证;没有代理时,部分工具会注册自己的文本代理。[3]
- polkit 不是 Linux capability、sudo 或 SELinux 的简单替代品。它主要解决“这次请求是否被授权”,执行主体的真实权限仍由进程凭据和 LSM 决定。
Go 落地思路: 事件模型不要把 pkexec 当成唯一对象。至少保留 subject、action_id、mechanism、result、认证方式和进程实例。后续规则、告警和攻击链都依赖这几个维度的稳定关联。
2. .policy 和 .rules 分别是什么?为什么检测重点通常落在 .rules?
简洁答案: .policy 用于声明动作、描述文字和默认授权;.rules 是 JavaScript 授权规则,可以根据 action、subject 和运行时细节动态返回授权结果。.rules 能直接改变本机决策,因此是本机持久化和权限边界变更的高价值证据。
关键知识点:
.policy通常由软件包安装到/usr/share/polkit-1/actions,定义 action ID、默认值、提示文字等。[1].rules由polkitd从/etc/polkit-1/rules.d和/usr/share/polkit-1/rules.d读取,使用 JavaScript 调用polkit.addRule()或polkit.addAdminRule()。[1]/etc/polkit-1/rules.d通常是管理员本地覆盖区,安全关注度高于软件包管理的/usr/share/polkit-1/rules.d;但两者都必须纳入监控。- 旧版
.pkla兼容层或发行版自有目录可能存在,不能只看一个固定路径;应以当前发行版实际加载目录为准。 - 规则文件是代码,不是普通配置。Agent 可以静态提取特征,但不应在检测进程里执行规则或加载第三方 JavaScript 引擎。
Go 落地思路: 把 .policy 变化和 .rules 变化分成两个事件类型。前者通常意味着软件包或动作定义变化,后者可能直接改变授权结果;将二者合并会丢失风险级别和处置语义。
3. polkit 规则如何排序和生效?为什么“第一条返回值”很关键?
简洁答案: polkitd 按文件 basename 做字典序加载两个规则目录;同名时先处理 /etc,再处理 /usr。规则函数按注册顺序调用,直到某个函数返回非 NOT_HANDLED 的结果。因此,一个很早加载、条件过宽并直接返回 YES 的规则,可以抢先决定授权结果。
关键知识点:
- 示例顺序是
/etc/.../10-auth.rules、/usr/.../10-auth.rules、/etc/.../15-auth.rules。[1] polkit.Result.NOT_HANDLED、null、undefined或不返回值,才会继续尝试后续规则。YES、AUTH_SELF、AUTH_ADMIN等结果会结束本次规则链。AUTH_SELF_KEEP和AUTH_ADMIN_KEEP会在短时间内缓存授权,而且缓存键是 action 与 subject;规则依赖的细节参数变化时仍可能命中缓存。[1]- 任一规则文件增加、删除或修改后,
polkitd会清空现有规则并重新读取两个目录。[1]
Go 落地思路: 报告规则变化时同时输出加载顺序键,例如 source_rank=/etc before /usr、basename、file_mtime 和 content_hash。只输出绝对路径,无法解释为什么某条规则可能先命中。
4. 哪些规则内容值得提高风险级别?能否用正则直接判定恶意?
简洁答案: return polkit.Result.YES、把 AUTH_ADMIN 降为 AUTH_SELF、宽泛匹配 action ID、给非预期用户或组增加管理员身份、以及规则中调用 spawn(),都值得重点复核。但 JavaScript 语义复杂,正则只能提供证据,不能单独定性。
关键知识点:
- 高风险特征包括无条件
YES、对任意 action 返回成功、对高价值 action 放宽管理员认证,以及addAdminRule()返回意外的unix-user:或unix-group:。 - 条件可能经过变量、函数、字符串拼接或 minify 处理,文本中不一定出现直白的
YES。 spawn()会运行外部 helper,并等待其结束;helper 以polkitd用户运行,且运行期间可能影响授权检查吞吐。[1]- 规则脚本超时会被终止,但这不代表 Agent 的静态解析可以忽略大小和复杂度限制。
- 单行、压缩、注释分隔和 Unicode 字符串都可能绕过简单正则。发现可疑片段后,应保存哈希和有限上下文,交给规则引擎或人工复核。
Go 落地思路: 规则内容特征输出 evidence 和 reason_code,例如 rule_returns_yes、admin_rule_added、rule_calls_spawn,不要把 regex_match=true 直接等同恶意。事件保留文件 hash,原始内容按最小必要原则采样并脱敏。
5. 哪些 polkit action 更值得关注?如何避免写死一组 ID 后很快过时?
简洁答案: 优先关注能执行任意程序、安装软件、修改服务、管理网络或挂载设备、创建账号的 action。不要只维护一张固定 ID 表,应从当前主机 .policy 文件生成动作清单,再按动作能力、调用方和默认授权做风险分级。
关键知识点:
org.freedesktop.policykit.exec是pkexec默认检查的 action;执行程序、用户和命令行详情会影响实际风险。[3]- PackageKit、systemd、NetworkManager、udisks、accounts-service、logind 等组件各自提供 action,ID 随发行版和版本变化。
- 一个 action 的默认授权可能是
no、auth_admin、auth_self或yes;风险要结合默认值和规则改动前后差异。 - “允许挂载一个受限设备”和“允许执行任意程序”不能使用同一风险权重。
- action 名称看起来像系统组件,不代表调用进程一定正常;仍要关联 subject 和后续进程行为。
Go 落地思路: 建立 action_catalog:从 .policy 解析 action ID、vendor 和默认值,并为每个 action 标注能力类别。规则变更事件引用 action 类别,服务端就能在新组件出现时自动给出基础风险,而不是等待人工补充 ID。
6. Go Agent 应如何采集规则目录变化?inotify、auditd、fanotify 和 eBPF 各自缺什么?
简洁答案: inotify 适合低开销发现目录内新增、完成写入、重命名和属性变化,但通常不告诉你写入进程;auditd 能补 PID、comm 和路径,但受规则配置、队列和平台差异影响;fanotify 能观察更多文件操作和进程信息;eBPF 适合获取 VFS open/write 事件和进程归因,但受内核、权限和版本影响。生产上应采用事件加周期快照对账,而不是依赖单一来源。
关键知识点:
- inotify 常见事件包括
IN_CREATE、IN_CLOSE_WRITE、IN_MOVED_TO、IN_ATTRIB、IN_DELETE;只监听IN_MODIFY容易在原子重命名和短文件中丢上下文。 - inotify 队列会溢出;必须处理
IN_Q_OVERFLOW并触发全量对账。 - 递归目录、mount namespace、容器 bind mount 和 watcher 重建都会影响覆盖范围。
- audit 目录 watch 可能产生多条记录,需要按 audit event ID 聚合
SYSCALL与PATH记录,不能只看一行。 - 文件事件只证明写入,不证明
polkitd已成功加载。还要采集规则解析错误或 reload 后的授权检查。 - 不要把任意大小的
.rules全量写入日志。应设置读入上限、文件数量上限和队列上限。
Go 落地思路: 采用三层证据:目录事件提供时间点,周期 hash 快照确认最终内容,授权检查和进程行为证明影响。事件丢失时明确上报 collector_degraded,不能把“没有事件”解释成“没有修改”。
7. 如何用 Go 对 polkit 规则目录做可靠快照?
简洁答案: 只接受目录内普通 .rules 文件,限制单文件大小,打开后再次校验文件身份与大小,计算 SHA-256,并保留路径、mode、mtime 等元数据。周期比较快照即可发现新增、删除和内容变化;对于可疑内容再做有界静态提取。
关键知识点:
filepath.WalkDir不跟随符号链接;遇到非普通文件应跳过或上报,而不是盲目读取目标。- 先
Lstat、后open、再Fstat并比较,可以降低文件在检查与读取之间被替换的风险。 - 生产事件还应保存 device、inode、
nlink、UID、GID 和 Linux ctime,用于识别硬链接、替换和 owner 变化。 - 哈希只证明当前内容,不证明规则语法有效或已被
polkitd接受。 - 快照用于对账,inotify/audit/eBPF 用于及时性,两者不能互相替代。
- 两次
Fstat只能降低普通原子替换的竞态;同一 inode 内并发改写仍可能绕过检查,强一致场景要结合 eBPF/LSM 写入事件、缩短快照窗口或受保护的文件系统策略。
Go 落地思路: 把目录扫描写成无状态纯函数,返回相对路径到元数据的映射;上层负责任务节流、diff、缓存和告警。这样既方便单元测试,也能在 inotify 溢出或 Agent 重启后直接做全量对账。
type RuleFile struct {
Path string
Mode fs.FileMode
Size int64
ModTime time.Time
SHA256 string
}
const maxRuleSize = 512 << 10
func snapshotRules(root string) (map[string]RuleFile, error) {
out := make(map[string]RuleFile)
err := filepath.WalkDir(root, func(path string, d fs.DirEntry, walkErr error) error {
if walkErr != nil {
return walkErr
}
if d.IsDir() || !strings.HasSuffix(d.Name(), ".rules") {
return nil
}
before, err := d.Info()
if err != nil {
return err
}
if before.Mode()&os.ModeSymlink != 0 {
return nil
}
if !before.Mode().IsRegular() || before.Size() > maxRuleSize {
return fmt.Errorf("invalid rule file %q", path)
}
f, err := os.Open(path)
if err != nil {
return err
}
defer f.Close()
afterOpen, err := f.Stat()
if err != nil {
return err
}
if !os.SameFile(before, afterOpen) || afterOpen.Size() > maxRuleSize {
return fmt.Errorf("rule changed before read: %q", path)
}
h := sha256.New()
if _, err := io.Copy(h, io.LimitReader(f, maxRuleSize+1)); err != nil {
return err
}
afterRead, err := f.Stat()
if err != nil {
return err
}
if afterOpen.Size() != afterRead.Size() || !afterOpen.ModTime().Equal(afterRead.ModTime()) {
return fmt.Errorf("rule changed during read: %q", path)
}
rel, err := filepath.Rel(root, path)
if err != nil {
return err
}
out[filepath.ToSlash(rel)] = RuleFile{
Path: path,
Mode: afterRead.Mode(),
Size: afterRead.Size(),
ModTime: afterRead.ModTime(),
SHA256: hex.EncodeToString(h.Sum(nil)),
}
return nil
})
return out, err
}这段代码只读元数据和内容哈希,不执行规则。生产实现还应限制递归深度、文件总数和总字节数,并捕获单个文件错误后继续扫描其余文件。
8. 规则变化误报很多时,怎样做基线而不是简单按文件名放行?
简洁答案: 用文件来源、包管理归属、owner/mode、路径层级、内容 hash、变更进程和变更窗口共同建立基线。/usr/share 中由合法软件包升级带来的变化,与 /etc 中无工单的临时写入,不应使用同一条抑制规则。
关键知识点:
- 可以把
/usr/share/polkit-1/rules.d与包管理数据库比对,确认文件属于哪个包、包版本是否刚升级。 /etc/polkit-1/rules.d是本地策略区,新增文件通常需要责任人、变更单或配置管理来源。- 文件 owner、group、mode、ACL 和 SELinux label 都是信任边界;内容没变但权限放宽也可能降低安全性。
- 配置管理工具会批量写文件,适合按任务、父进程和短时间窗口聚合,而不是永久放行某个路径。
- 正常管理员也可能临时添加宽松规则排障;这类变化应降低优先级并提示复核,不应静默删除。
Go 落地思路: 先用 host_role + path_scope + package_owner + writer_process + change_source 做上下文,再交给策略层判断。进程名为空时不自动白名单;unknown writer 应作为证据缺口,而不是正常来源。
9. 如何把规则变更关联成攻击链,并避免把“文件变了”直接说成“已经提权”?
简洁答案: 规则变更只是“权限边界可能改变”的前置证据;其后还要观察到授权检查结果、pkexec 或受保护机制启动、管理员权限进程以及命令、文件、网络行为。按时间窗口和主体关联,输出阶段化结论。
关键知识点:
- 规则写入后,
polkitd会重新加载;解析失败可能使规则未生效,成功加载也不代表马上有人使用。 pkexec、systemd、PackageKit 等调用会形成新的进程与授权事件,适合与规则变更做短窗口关联。- 需要区分“规则内容允许”和“本次授权实际返回 YES”。日志字段随版本和发行版变化,不能硬编码单一文本格式。
- 攻击链证据应保留规则动作 ID、调用程序路径、subject UID/进程实例、最终执行用户和后续子进程。
- 高价值结论是“规则变更后出现了非预期提权执行”,而不是“文件 hash 变化,所以主机已失陷”。
Go 落地思路: 使用阶段化事件:
polkit.rule.added | modified | removed | permission_changed
polkit.rule.load_failed | loaded
polkit.auth.checked
polkit.privileged_process.started
polkit.post_exec_behavior同一主机、同一 action_id 和相近时间窗口内的事件可以组成候选链;关联失败时仍保留独立事件,供人工排查。
10. 客户反馈“规则被改了但没有告警”,怎么排查?
简洁答案: 先确认改的是哪个实际加载目录,再检查 watcher 是否覆盖该 mount namespace、事件是否丢失、Agent 是否有读取权限、快照是否把首次基线当成正常状态,以及规则是否被策略抑制。最后确认规则是否成功加载并被实际调用。
关键知识点:
- 发行版可能使用不同目录或兼容层;不能只检查
/etc/polkit-1/rules.d。 - 容器通过 bind mount 写入时,主机路径、容器路径和 inotify 视图可能不一致。
- 首次安装或首次启动时常把现状直接当基线;如果恶意规则已经存在,后续只做增量事件就可能漏报。
- 文件名不以
.rules结尾、符号链接、超大文件、JSON/文本规则或旧版.pkla,可能被当前采集器过滤。 - 事件可能被正确采集,但规则因路径、UID、主机角色或维护窗口被抑制;要区分“没采到”和“采到但被降噪”。
polkitd日志或授权检查没有出现,只能证明暂时没观察到使用,不能证明规则没有影响系统其他动作。
Go 落地思路: 排障接口不要只返回 alert_found=false,而应返回 file_observed、writer_attributed、watcher_healthy、snapshot_compared、rule_loaded、suppressed_by 和 coverage_gaps。这样可以明确区分漏采、漏关联、主动抑制和暂未使用。
排查顺序:
- 在授权范围内确认实际规则目录、文件属主、mode、hash 和变更时间。
- 对照 Agent 的 watcher、audit/eBPF 能力状态、队列丢事件指标和全量快照任务。
- 查看规则加载错误与最近一次授权检查,确认规则是否被
polkitd接受。 - 核对降噪策略和基线来源,判断是漏采、漏关联还是主动抑制。
- 保留规则样本、写入进程、重启/重载时间和后续特权进程证据,避免仅凭文件名下结论。
学习要点
- 先理解 polkit 的授权链,再记路径和文件名:mechanism -> action -> rules -> authorization result -> privileged action。
.rules是代码,检测端要“读而不执行”,并设置大小、数量和超时边界。- 规则加载顺序与第一条有效返回值的语义,决定了
00-*.rules或本地覆盖规则的特殊风险。 - 文件变化、规则加载、授权检查、实际提权是四个不同阶段,不能混成同一个结论。
- Go 采集要把事件及时性、快照对账、进程归因和覆盖缺口组合起来,任何单点都不可靠。
- 误报治理应基于包来源、权限、变更过程和业务角色,而不是脆弱的文件名白名单。
小练习 / 复盘题
- 画出
pkexec从用户进程到 root 执行的授权链,标出.policy、.rules、polkitd和认证代理的位置。 - 解释为什么
/etc/polkit-1/rules.d/00-a.rules和/usr/share/polkit-1/rules.d/10-b.rules的加载顺序不是简单的“先 etc 后 usr”。 - 写一个只返回
NOT_HANDLED的测试规则,在隔离虚拟机中验证文件变化、重新加载和pkcheck检查流程;不要在生产机放宽权限。 - 为
snapshotRules增加“新增、删除、内容变化、权限变化”四类 diff,并为原子重命名设计测试。 - 设计一个告警事件,要求同时包含 writer process、file hash、action ID、规则证据片段和采集模式。
- 分别列出 inotify、auditd、fanotify、eBPF 能提供的关键字段和典型缺失字段。
- 给
/usr/share包升级和/etc手工变更设计不同的降噪条件,并说明如何避免永久放行。 - 模拟“规则已修改但未实际调用”的场景,说明为什么不能直接判定主机已经发生提权。
