Post

[Linux] CVE-2023-4911: Looney Tunables

해당 취약점은 glibc의 동적 로더인 ld.so에서 발생한 취약점입니다. GLIBC_TUNABLES 환경 변수를 처리하는 과정에서 BOF 취약점이 발생하여, LPE까지 연계가 되는 취약점이였습니다.

💡 GLIBC_TUNABLES 환경 변수는 사용자에게 런타임에 라이브러리의 동작을 수정하는 기능을 제공하여 애플리케이션이나 라이브러리를 다시 컴파일할 필요가 없도록 glibc에 도입되었습니다. GLIBC_TUNABLES를 설정하면 사용자는 다양한 성능 및 동작 매개변수를 조정할 수 있으며, 이는 애플리케이션 시작 시 적용됩니다.

Tunables?

우선 설명된 문서를 살펴보면 아래와 같이 정의하고 있습니다.

💡 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. These are implemented as a set of switches that may be modified in different ways. The current default method to do this is via the GLIBC_TUNABLES environment variable by setting it to a string of colon-separated name=value pairs. For example, the following example enables malloc checking and sets the malloc trim threshold to 128 bytes:
GLIBC_TUNABLES=glibc.malloc.trim_threshold=128:glibc.malloc.check=3 export GLIBC_TUNABLES

Tunables are not part of the GNU C Library stable ABI, and they are subject to change or removal across releases. Additionally, the method to modify tunable values may change between releases and across distributions. It is possible to implement multiple ‘frontends’ for the tunables allowing distributions to choose their preferred method at build time.

Finally, the set of tunables available may vary between distributions as the tunables feature allows distributions to add their own tunables under their own namespace.

Passing –list-tunables to the dynamic loader to print all tunables with minimum and maximum values:

1
2
3
4
5
6
7
8
9
10
11
`GLIBC_TUNABLES=glibc.malloc.trim_threshold=128:glibc.malloc.check=3
export GLIBC_TUNABLES`


Tunables are not part of the GNU C Library stable ABI, and they are subject to change or removal across releases. Additionally, the method to modify tunable values may change between releases and across distributions. It is possible to implement multiple ‘frontends’ for the tunables allowing distributions to choose their preferred method at build time.


Finally, the set of tunables available may vary between distributions as the tunables feature allows distributions to add their own tunables under their own namespace.


Passing --list-tunables to the dynamic loader to print all tunables with minimum and maximum values:

GLIBC_TUNABLES는 환경 변수로 위의 예시와 같이 다음과 같이 NAME1=VALUE1:NAME2=VALUE2 처럼 값을 지정해서 사용하고 있는 glibc에서 사용하는 환경변수입니다.

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
➜  ~ /lib64/ld-linux-x86-64.so.2 --list-tunables                                                                                                       (UTM : ubuntu22.04)
glibc.rtld.nns: 0x4 (min: 0x1, max: 0x10)
glibc.elision.skip_lock_after_retries: 3 (min: 0, max: 2147483647)
glibc.malloc.trim_threshold: 0x0 (min: 0x0, max: 0xffffffffffffffff)
glibc.malloc.perturb: 0 (min: 0, max: 255)
glibc.cpu.x86_shared_cache_size: 0x180000 (min: 0x0, max: 0xffffffffffffffff)
glibc.pthread.rseq: 1 (min: 0, max: 1)
glibc.mem.tagging: 0 (min: 0, max: 255)
glibc.elision.tries: 3 (min: 0, max: 2147483647)
glibc.elision.enable: 0 (min: 0, max: 1)
glibc.malloc.hugetlb: 0x0 (min: 0x0, max: 0xffffffffffffffff)
glibc.cpu.x86_rep_movsb_threshold: 0x800 (min: 0x80, max: 0xffffffffffffffff)
glibc.malloc.mxfast: 0x0 (min: 0x0, max: 0xffffffffffffffff)
glibc.rtld.dynamic_sort: 2 (min: 1, max: 2)
glibc.elision.skip_lock_busy: 3 (min: 0, max: 2147483647)
glibc.malloc.top_pad: 0x0 (min: 0x0, max: 0xffffffffffffffff)
glibc.cpu.x86_rep_stosb_threshold: 0x800 (min: 0x1, max: 0xffffffffffffffff)
glibc.cpu.x86_non_temporal_threshold: 0x120000 (min: 0x4040, max: 0xfffffffffffffff)
glibc.cpu.x86_shstk:
glibc.pthread.stack_cache_size: 0x2800000 (min: 0x0, max: 0xffffffffffffffff)
glibc.cpu.hwcap_mask: 0x6 (min: 0x0, max: 0xffffffffffffffff)
glibc.malloc.mmap_max: 0 (min: 0, max: 2147483647)
glibc.elision.skip_trylock_internal_abort: 3 (min: 0, max: 2147483647)
glibc.malloc.tcache_unsorted_limit: 0x0 (min: 0x0, max: 0xffffffffffffffff)
glibc.cpu.x86_ibt:
glibc.cpu.hwcaps:
glibc.elision.skip_lock_internal_abort: 3 (min: 0, max: 2147483647)
glibc.malloc.arena_max: 0x0 (min: 0x1, max: 0xffffffffffffffff)
glibc.malloc.mmap_threshold: 0x0 (min: 0x0, max: 0xffffffffffffffff)
glibc.cpu.x86_data_cache_size: 0x10000 (min: 0x0, max: 0xffffffffffffffff)
glibc.malloc.tcache_count: 0x0 (min: 0x0, max: 0xffffffffffffffff)
glibc.malloc.arena_test: 0x0 (min: 0x1, max: 0xffffffffffffffff)
glibc.pthread.mutex_spin_count: 100 (min: 0, max: 32767)
glibc.rtld.optional_static_tls: 0x200 (min: 0x0, max: 0xffffffffffffffff)
glibc.malloc.tcache_max: 0x0 (min: 0x0, max: 0xffffffffffffffff)
glibc.malloc.check: 0 (min: 0, max: 3)

Root Cause Analysis

해당 취약점의 RCA를 GLIBC_TUNABLES 환경변수를 어떻게 parsing하는지 분석하면서 설명드리겠습니다.

LIBC_START_MAIN

glibc가 시작되면 LIBC_START_MAIN 함수에서 호출이 되게 됩니다. 해당 과정에서 __tunables_init 함수가 호출되게 되는데, 해당 함수가 GLIBC_TUNABLES 환경 변수를 parsing하게 됩니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
STATIC int
LIBC_START_MAIN (int (*main) (int, char **, char ** MAIN_AUXVEC_DECL),
		 int argc, char **argv,
#ifdef LIBC_START_MAIN_AUXVEC_ARG
		 ElfW(auxv_t) *auxvec,
#endif
		 __typeof (main) init,
		 void (*fini) (void),
		 void (*rtld_fini) (void), void *stack_end)
{
#ifndef SHARED
  char **ev = &argv[argc + 1];

  __environ = ev;

  /* Store the lowest stack address.  This is done in ld.so if this is
     the code for the DSO.  */
  __libc_stack_end = stack_end;

	...

	__tunables_init (__environ);

	...

__tunables_init

__tunables_init 함수의 구현은 링크에 있고 함수의 일부분은 다음과 같습니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
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;

...

해당 함수에서 환경변수 중 GLIBC_TUNABLES를 가져와서 parse_tunables 함수에 전달하는 것을 확인할 수 있습니다.

parse_tunables

해당 함수의 구현은 링크에 있습니다. 인자로 넘어오는 tunestr를 파싱하는 함수이며 전체 코드는 다음과 같습니다. 이후, 파싱 순서를 순서대로 분석하겠습니다.

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
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;
  }
}

tunestr parsing 과정?

우선, tunestr 변수에서 =, :, \0이라는 문자를 만날때까지 len을 증가시키면서 반복합니다.

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

만약 len이 가르키는 offset이 \0과 같다면 종료합니다. 여기서 __libc_enable_secure 이 True일 경우 off index에 NULL을 넣고 종료하게 됩니다. 그리고 : 과 같다면 len을 1 증가시킨뒤 반복문을 다시 시작합니다.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
/* 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;
}

if (p[len]== ':')
{
  p += len + 1;
  continue;
}

남은 문자는 =이기 때문에 해당 문자를 만날 경우 len을 1 증가시켜 value의 시작점으로 위치시킨뒤 해당 주소를 value 변수에 넣어줍니다. 그리고 다시 : 문자와 \0 문자를 만날때까지 len을 증가시킵니다.

1
2
3
4
5
6
7
8
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++;

이후, tunable이 존재하면 아래의 반복문을 실행하는 것을 확인할 수 있습니다. 여기서 입력한 환경변수의 tunable 값이 tunable_list에 존재한다면 조건문이 실행됩니다.

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
/* 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;
  }
}

위의 반복문이 종료하게 되면, p[len]이 NULL인지 비교한 뒤, 아니라면 1 증가하게 됩니다.

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

취약점

링크처럼 공격자가 tunable1=tunable2=aaa를 입력했다고 가정합니다.

첫번째 반복문

시작할때 중요한 변수의 값은 다음과 같습니다.

💡 중요 변수
len = 0 → “tunable1=tunable2=aaa”
offset = 0 → “tunable1=tunable2=aaa”
*turnable = “tunable1=tunable2=aaa”
*p = “tunable1=tunable2=aaa”

  1. 첫번째 while(1) 문에서 offset은 0이고 lenturnable의 끝인 NULL을 가리킨상태로 for문이 실행되게 됩니다.
  2. for문에서 offset은 tunable2=aaa를 가리키게 되고, 해당 값 전체가 tunestr에 복사되게 됩니다.
  3. 이 때, tunable 변수의 값은 tunable1=tunable2=aaa이 되고 p 포인터는 변경되지 않기 때문에 여전히 NULL을 가리키고 있습니다.
  4. 이후 while(1) 문의 마지막인 p[len] != '\0'을 만나게 되는데 이때 p[len]‘\0’이기 때문에 len+1의 값이 p포인터에 저장됩니다. 따라서 NULL이 아닌 다른값을 가리키게 됩니다.
  5. 하지만, len은 여전히 tunable2=aaa를 가리키고 있게 되기 때문에 해당 부분부터 2번째 반복문이 시작되게 됩니다.

두번째 반복문

두번째 반복문을 시작할때 중요한 변수의 값은 다음과 같습니다.

💡 중요 변수
len = 0 → “tunable2=aaa”
offset = 21 → NULL
*turnable = “tunable1=tunable2=aaa”
*p = “tunable2=aaa”

  1. 여기서 똑같이 파싱을 진행하게 되는데 tunable2가 NAME이라고 생각하게되고 파싱이 진행됩니다.
  2. 하지만, 이미 turnable에는 tunable1=tunable2=aaa 문자열이 다 처리되었기 때문에 offset이 NULL을 가리키고 있습니다.
  3. 이 과정에서 for문에 진입하게 된다면 인자로 받은 tunestr 변수보다 더 큰 영역의 값에 값을 write하기 때문에 BOF가 발생하게됩니다.

PoC

PoC는 작성하는대로 추가할 예정입니다!

참고사이트

This post is licensed under CC BY 4.0 by the author.