AlphaSix,公众号:顺丰安全应急响应中心高端的二进制0day挖掘,往往只需要从1day的分析开始
我们已经详细分析了 CVE-2021-3156 漏洞的成因,并且展示了PoC来验证这一漏洞。CVE-2021-3156,又被称为“Baron Samedit”,是一个影响广泛的 sudo 堆溢出漏洞,允许未经授权的用户在许多 Unix 和 Linux 系统上获得 root 权限。在本篇文章中,我们将深入探讨如何利用这个漏洞,从而进一步理解漏洞利用的细节和技术。通过这次的分析与实践,你将了解到漏洞利用的过程以及其中涉及的关键技术点。
二、漏洞利用方法
对于这个提权漏洞主流的利用方法有三种
对于 CVE-2021-3156 这个提权漏洞,主流的利用方法主要有三种,每种方法都利用了漏洞在不同内存结构上的影响。
参考Qualys的原文:
https://www.openwall.com/lists/oss-security/2021/01/26/3
1. struct sudo_hook_entry 覆写
2. struct service_user 覆写
这个方法利用漏洞覆写 struct service_user 结构。service_user 是一个在 sudo 中用于管理用户信息的结构体,通过覆写其中的字段,攻击者可以劫持程序流,进而获得 root 权限。
3. def_timestampdir 覆写
在这种方法中,攻击者利用漏洞覆写 def_timestampdir 变量。这个变量控制着 sudo 程序的时间戳目录路径,通过覆写该路径,攻击者可以将其指向受控目录,进而影响权限验证机制,实现提权。这种方法主要利用了对文件系统和 sudo 权限管理的理解。
在接下来的分析中,我们将重点探讨第二种方法——struct service_user 覆写。这种方法不仅利用了 sudo 中的关键结构体,还展示了堆溢出漏洞的典型利用方式。我们将一步步解析如何通过精心设计的输入数据,操控内存布局,最终实现对目标系统的提权。通过这一方法的深入分析,你将更加全面地理解漏洞利用的技术细节和防护措施。
三、漏洞利用流程
对于 struct service_user 覆写,这种方法通过精心设计的堆风水布局,结合 setlocale函数 的堆分配,以及 args 的用户输入,覆盖了与 nssfile 相关联的 struct service_user结构体。通过这个覆写过程,攻击者能够控制程序的执行流,并加载特定的 .so 文件,从而实现提权。当然,以上很多名词会让大家感到陌生,下面我们来一一介绍。
1. 堆风水
首先,来解释一下什么是堆风水。(堆风水(Heap Feng Shui)是一种在漏洞利用中常用的技术,旨在通过精心设计输入数据和操控内存分配,使得堆中的特定内存结构按照攻击者的预期方式排列,从而为后续的攻击步骤创造有利条件。对于这个漏洞,你可以简单地理解为,通过用户输入和环境变量,来实现用户输入在内存中分配的堆块和溢出覆盖的堆块有一个固定的offset,覆盖关键的结构从而达到提权效果。
2. setlocate函数
在 sudo 的 main 函数中,setlocale 函数的调用是用于设置程序的区域设置(locale)。区域设置决定了程序如何处理特定语言、数字格式、货币符号、日期和时间的显示格式等本地化相关的特性。具体来说,setlocale 函数配置了 C 标准库中的一些函数,如字符串比较、字符分类、输入/输出格式化等的行为方式。
setlocale 函数的详细作用
setlocale 函数的主要作用是设置当前的区域设置信息。区域设置通常包括语言、字符编码、时间格式、货币格式、数字格式等。例如,setlocale(LC_ALL, “en_US.UTF-8”); 会将程序的区域设置为美国英语,使用 UTF-8 编码。
在 sudo 中,setlocale 的调用会影响一些与本地化相关的库函数。这些函数在处理特定区域数据时会根据 setlocale 的设置来决定如何表现。例如,字符串比较函数 strcoll 会使用 setlocale 设置的区域信息进行比较,而不仅仅是按照字符的二进制值。
setlocale 函数不仅仅是单纯地设置区域信息,它还会导致底层库分配和释放堆内存。特别是在 sudo 这样的复杂程序中,setlocale 的调用会引发一系列的内存分配操作,包括存储新的区域设置信息、字符映射表、时间格式化模板等。这些分配的内存通常会位于堆中。因此,在一些利用 setlocale 的漏洞攻击中,攻击者通过setlocale 来调整堆的布局,从而影响堆块的分配顺序和位置,这就是堆风水技术的基础。
当 setlocale 被调用时,它可能会从环境变量(如 *LC_ALL*, *LC_CTYPE*, *LC_TIME* 等)中读取区域设置信息。如果这些环境变量被设置,那么 setlocale 会根据这些变量的值来改变程序的行为。这在某些攻击场景中可以被利用,通过设置特定的环境变量来影响 setlocale 的行为,进一步控制程序执行的路径或内存布局。
sudo 中的 setlocale 具体作用
setlocale 函数通常是靠前被调用的,其目的是确保整个 sudo 程序在正确的区域设置下运行。这对于国际化和本地化支持尤为重要,因为 sudo 需要支持多种语言和不同的本地化需求。
然而,在 CVE-2021-3156 的利用中,setlocale 的作用不仅限于设置区域信息。攻击者利用 setlocale 的内存分配行为,配合堆风水,逐步布置堆中的内存块布局,以便在后续的堆溢出攻击中能够精确地覆盖目标内存区域(例如 struct service_user 结构体)。因此,setlocale 在 sudo 的 main 函数中不仅是为了程序的正常运行,还在特定攻击场景中扮演了至关重要的角色,成为了漏洞利用链的一部分。
3. nssfile文件
在 GNU C 库(glibc)中,nsswitch 子系统(Network Service Switch,NSS)负责系统服务的查询机制,例如用户认证、主机名解析等。该机制由 nsswitch.conf 文件配置,定义了不同服务(如 passwd、group、hosts)的查找顺序和方式。
parallels@parallels-Parallels-Virtual-Platform:~/Desktop/CVE-2021-3156$ cat /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: files systemd
group: files systemd
shadow: files
gshadow: files
hosts: files mdns4_minimal [NOTFOUND=return] dns
networks: files
protocols: db files
services: db files
ethers: db files
rpc: db files
netgroup: nis
那么这个文件有什么用呢?
glibc/nss/nsswitch.c 是 GNU C 库(glibc)中的一个实现文件,负责解析 /etc/nsswitch.conf 配置文件,并按照配置的顺序调用相应的 NSS 模块(例如 libnss_files.so, libnss_dns.so 等)。
简单说就是读取配置文件(/etc/nsswitch.conf),把读取的结果存储到service_user机构体中,加载结构体中对应的so文件。
4. 简单流程
接下来,我将通过一个简单的图示来解释这个漏洞利用的流程,帮助大家更直观地理解整个攻击过程的关键步骤和逻辑(当然实际上图里的堆块在内存里并不是连续的。图只是方便大家理解)
-
用户通过输入argv 和 envp(环境变量)启动sudo
-
在sudo通过 setlocale 函数读取envp的LC环境变量并free掉
-
在sudo通过 get_user_info 函数初始化service user结构体
-
在sudo的漏洞触发点(set_cmnd函数)读取argv 和envp并覆盖service user结构体
5. 详细分析
结合上面的图例,接下来我们将按照图里的流程详细的解释漏洞利用的详细过程。
首先argv 是用户输入的参数 ,envp是用户输入的环境变量。
假设启动参数是sudoedit -i AAA….. (40个A),那么argv[0]应该是 sudoedit,argv[1]便是 -i
envp即用户输入的环境变量,这里需要注意的一点是在这个漏洞里,带上envp启动sudo主要是为了用envp触发堆块的分配和释放。接下来我们会跟随sudo的执行流程分析利用过程。
2. 在sudo通过 setlocale 函数读取envp的LC环境变量并free掉
setlocale 函数的调用在sudo的main函数中相对靠前的地方。
关注setlocale函数,主要是因为我们需要利用setlocale函数进行堆布局。正如我之前说的这个函数主要是是用于设置程序的区域设置(locale)。当然我推荐大家可以看下这篇文章加深对这个函数的理解。
https://ssoor.github.io/2020/03/25/c-setlocale/
pwndbg> p _nl_global_locale
$13 = {
__locales = {0x7fbdf769c6c0 <_nl_C_LC_CTYPE>, 0x7fbdf769cc00 <_nl_C_LC_NUMERIC>, 0x7fbdf769cc80 <_nl_C_LC_TIME>, 0x7fbdf769d500 <_nl_C_LC_COLLATE>, 0x7fbdf769ca40 <_nl_C_LC_MONETARY>, 0x7fbdf769c9c0 <_nl_C_LC_MESSAGES>, 0x0, 0x7fbdf769d1c0 <_nl_C_LC_PAPER>, 0x7fbdf769d220 <_nl_C_LC_NAME>, 0x7fbdf769d2a0 <_nl_C_LC_ADDRESS>, 0x7fbdf769d360 <_nl_C_LC_TELEPHONE>, 0x7fbdf769d3e0 <_nl_C_LC_MEASUREMENT>, 0x7fbdf769d440 <_nl_C_LC_IDENTIFICATION>},
__ctype_b = 0x7fbdf764f3c0 <_nl_C_LC_CTYPE_class+256>,
__ctype_tolower = 0x7fbdf764e4c0 <_nl_C_LC_CTYPE_tolower+512>,
__ctype_toupper = 0x7fbdf764eac0 <_nl_C_LC_CTYPE_toupper+512>,
__names = {0x7fbdf7668fd9 <_nl_C_name> "C" <repeats 13 times>}
}
其中__locales 代表一些预定义环境变量,查看 locale.h源码
其次我们需关注的是 _nl_global_locale.__names ,_nl_global_locale.__names实际上是一个大小为13的字符串指针数组,刚好对应13个 LC环境变量的值,此时可以从gdb看到每个环节变量都是初始值_nl_C_name 也就是“C”。locale.h源码中每个 define 都代表一个对应__names字符串指针数组的index。
而__names中字符串的值:为期望设定的locale名称字符串,在Linux/Unix环境下,通常以下面格式表示locale名称:language[_territory][.codeset][@modifier],language 为 ISO 639 中规定的语言代码,territory 为 ISO 3166 中规定的国家/地区代码,codeset 为字符集名称。举个例子 假设__LC_IDENTIFICATION变量的值是 “AAACCC”(当然这不是一个合法的值),那么 __names[__LC_IDENTIFICATION] = “AAACCC”,这都是一一对应的。既然AAACCC不是一个合法的值我们再来说说合法的值应该是怎么样的。
LC_IDENTIFICATION=C.UTF-8@AAAAAAAAAAA
LC_IDENTIFICATION=C.UTF-8@AAAAAAAAAAA 是一个不常见的 locale 字符串,它结合了以下部分:
1). C: 指定为 “C” locale,也称为标准 C 语言环境或 POSIX 环境,这个环境是一种默认的、不依赖特定语言或地区的设置。它提供了一个最小化的、独立于语言的环境,一般用于保证程序的可移植性和一致性。
2). UTF-8: 指定字符编码为 UTF-8,表示使用 Unicode 字符集编码,可以支持全球几乎所有的字符。
3). @AAAAAAAAAAA: 是一个自定义的 modifier(修饰符),用来指定 locale 的某种变体或定制。这里的 AAAAAAAAAAA 是一个任意的修饰符字符串,通常用于表示一些特殊的约定或环境。
接下来需要关注setlocale 函数这部分
while (category-- > 0)
if (category != LC_ALL)
{
newdata[category] = _nl_find_locale(locale_path, locale_path_len,
category,
&newnames[category]);
if (newdata[category] == NULL)
{
if (newnames[category] == _nl_C_name)
/* Null because it's the weak value of _nl_C_LC_FOO. */
continue;
break;
}
......
......
}
/* Create new composite name. */
composite = (category >= 0
? NULL
: new_composite_name(LC_ALL, newnames));
if (composite != NULL)
{
/* Now we have loaded all the new data. Put it in place. */
for (category = 0; category < __LC_LAST; ++category)
if (category != LC_ALL)
{
setdata(category, newdata[category]);
setname(category, newnames[category]);
}
setname(LC_ALL, composite);
/* We successfully loaded a new locale. Let the message catalog
functions know about this. */
++_nl_msg_cat_cntr;
}
else
for (++category; category < __LC_LAST; ++category)
if (category != LC_ALL && newnames[category] != _nl_C_name && newnames[category] != _nl_global_locale.__names[category])
free((char *)newnames[category]);
while循环会从category=12,也就是从 LC_IDENTIFICATION 开始向前遍历到 LC_CTYPE,然后进入_nl_find_locale函数。_nl_find_locale 函数用于在系统中查找、验证和加载指定类别(category)的 locale 数据。它优先尝试从 locale 存档文件中加载,如果失败,则尝试从文件系统中的其他位置加载。该函数确保找到的 locale 数据是有效的,并且字符集与用户指定的相匹配。加载成功后,它返回指向该 locale 数据的指针。_nl_explode_name函数,分解 locale 名称:
mask = _nl_explode_name(loc_name, &language, &modifier, &territory, &codeset, &normalized_codeset);
然后再把拆分的结果传递给 nl_make_l10nflist函数,_nl_make_l10nflist 会生成多个路径一直递归查找 locale 数据文件,并构建 locale 文件列表。可能会生成以下不同的路径
$5 = 0x55a23fe331f0 "/usr/lib/locale/C.UTF-8.utf8@", 'C' <repeats 32 times>, "/LC_TELEPHONE"
$6 = 0x55a23fe33030 "/usr/lib/locale/C.UTF-8@", 'C' <repeats 32 times>, "/LC_TELEPHONE"
$7 = 0x55a23fe33370 "/usr/lib/locale/C@", 'C' <repeats 32 times>, "/LC_TELEPHONE"
$8 = 0x55a23fe33000 "/usr/lib/locale/C/LC_TELEPHONE"
$9 = 0x55a23fe33450 "/usr/lib/locale/C.UTF-8/LC_TELEPHONE"
$12 = 0x55a23fe34360 "/usr/lib/locale/C.utf8@", 'C' <repeats 32 times>, "/LC_TELEPHONE"
composite = (category >= 0
? NULL
: new_composite_name(LC_ALL, newnames));
前面说过while循环会从category=12开始向前遍历慢慢递减,所以循环过程中如果遇到breaks说明有LC变量没有找到对应的locale数据,此时肯定没有遍历完所有LC变量因此 category >= 0 为真,composite 就会赋值为 NULL,然后进入 else 分支
else
for (++category; category < __LC_LAST; ++category)
if (category != LC_ALL && newnames[category] != _nl_C_name && newnames[category] != _nl_global_locale.__names[category])
free((char *)newnames[category]);
这个分支会判断 category 是否是LC_ALL, newnames[category]是否为”C”, newnames[category] 不等于 _nl_global_locale.__names[category]。然后从刚才break的index开始从前到后开始 free 对应的newnames[category]内存。newnames[category]实际上是我们输入的LC环境变量,在GDB里可以看到,这个时候我的 newnames[LC_IDENTIFICATION]是我输入的 “C.UTF-8@” + ‘A’ * 32。
当然,从判断条件可以知道,
-
我们不能用LC_ALL环境变量。
-
且其他LC环境变量的值不能为“C”。
-
同时输入的LC变量的值得无法找到对应的locale数据。
这里free的newnames[category]都会进入tcachebins,且大小由我们输入的环境变量LC的大小控制的。同时满足以上三个条件后,我们就能得到大小和顺序都由我们控制的tcachebins堆块。接下来我们会走到 get_user_info。
int
__nss_database_lookup2 (const char *database, const char *alternate_name,
const char *defconfig, service_user **ni)
{
/* Prevent multiple threads to change the service table. */
__libc_lock_lock (lock);
/* Reconsider database variable in case some other thread called
`__nss_configure_lookup' while we waited for the lock. */
if (*ni != NULL)
{
__libc_lock_unlock (lock);
return 0;
}
/* Are we initialized yet? */
if (service_table == NULL)
/* Read config file. */
service_table = nss_parse_file (_PATH_NSSWITCH_CONF);
/* Test whether configuration data is available. */
if (service_table != NULL) ...
...
/* No configuration data is available, either because nsswitch.conf
doesn't exist or because it doesn't have a line for this database.
DEFCONFIG specifies the default service list for this database,
or null to use the most common default. */
if (*ni == NULL)...
...
__libc_lock_unlock (lock);
return *ni != NULL ? 0 : -1;
}
nss_parse_file 通过逐行解析 /etc/nsswitch.conf 文件,动态创建和链接 name_database_entry、service_user 和 service_library 结构体。
typedef enum
{
NSS_ACTION_CONTINUE,
NSS_ACTION_RETURN,
NSS_ACTION_MERGE
} lookup_actions;
typedef struct service_library
{
/* Name of service (`files', `dns', `nis', ...). */
const char *name;
/* Pointer to the loaded shared library. */
void *lib_handle;
/* And the link to the next entry. */
struct service_library *next;
} service_library;
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;
由源码可以看出通过 nss_parse_file 解析完后结构体之间的关系图应该如下
接着分析nss_parse_file 源码
static name_database *
nss_parse_file (const char *fname)
{
FILE *fp;
name_database *result;
name_database_entry *last;
char *line;
size_t len;
/* Open the configuration file. */
fp = fopen (fname, "rce");
if (fp == NULL)
return NULL;
/* No threads use this stream. */
__fsetlocking (fp, FSETLOCKING_BYCALLER);
result = (name_database *) malloc (sizeof (name_database));
if (result == NULL)
{
fclose (fp);
return NULL;
}
result->entry = NULL;
result->library = NULL;
last = NULL;
line = NULL;
len = 0;
do
{
name_database_entry *this;
ssize_t n;
n = __getline (&line, &len, fp);
if (n < 0)
break;
if (line[n - 1] == 'n')
line[n - 1] = ' ';
/* Because the file format does not know any form of quoting we
can search forward for the next '#' character and if found
make it terminating the line. */
*__strchrnul (line, '#') = ' ';
/* If the line is blank it is ignored. */
if (line[0] == ' ')
continue;
/* Each line completely specifies the actions for a database. */
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);
/* Close configuration file. */
fclose (fp);
return result;
}
-
用 __getline (&line, &len, fp);申请一个0x80的块读入每行。 -
跳过注释行和空行
-
对有效的行 进入nss_getline函数。
接着来看 nss_getline函数
static name_database_entry *
nss_getline (char *line)
{
const char *name;
name_database_entry *result;
size_t len;
/* Ignore leading white spaces. ATTENTION: this is different from
what is implemented in Solaris. The Solaris man page says a line
beginning with a white space character is ignored. We regard
this as just another misfeature in Solaris. */
while (isspace (line[0]))
++line;
/* Recognize `<database> ":"'. */
name = line;
while (line[0] != ' ' && !isspace (line[0]) && line[0] != ':')
++line;
if (line[0] == ' ' || name == line)
/* Syntax error. */
return NULL;
*line++ = ' ';
len = strlen (name) + 1;
result = (name_database_entry *) malloc (sizeof (name_database_entry) + len);
if (result == NULL)
return NULL;
/* Save the database name. */
memcpy (result->name, name, len);
/* Parse the list of services. */
result->service = nss_parse_service_list (line);
result->next = NULL;
return result;
}
-
首先会把:前后分开, 以顺序第一行为例 “passwd: ……”。把passwd赋值给name
-
malloc创建name_database_entry结构体。
-
把name 赋值到name_database_entry -> name
-
然后有效的部分进入 nss_parse_service_list。
static service_user *
nss_parse_service_list (const char *line)
{
service_user *result = NULL, **nextp = &result;
while (1)
{
service_user *new_service;
const char *name;
while (isspace (line[0]))
++line;
if (line[0] == ' ')
/* No source specified. */
return result;
/* Read <source> identifier. */
name = line;
while (line[0] != ' ' && !isspace (line[0]) && line[0] != '[')
++line;
if (name == line)
return result;
new_service = (service_user *) malloc (sizeof (service_user)
+ (line - name + 1));
if (new_service == NULL)
return result;
*((char *) __mempcpy (new_service->name, name, line - name)) = ' ';
......
......
......
-
new_service->name:这是 service_user 结构体的 name 成员,定义为 char name[0];。因为 name 是一个零长度数组,实质上 new_service->name 指向的是分配的结构体后面的内存区域。
-
name:指向需要复制的字符串的开始位置。
-
line – name:计算需要复制的字节数(即字符串长度)。
-
__mempcpy (new_service->name, name, line – name):将从 name 开始的 line – name 个字符复制到 new_service->name 指向的内存区域,并返回该区域最后一个字符的下一个位置的指针。
-
*((char *) __mempcpy (new_service->name, name, line – name)) = ‘