Skip to content

Fast web

In TJCTF 2022, 480 points

I'm sick of all these JavaScript libraries and bloated Python web frameworks!

https://fast-web.tjc.tf

Challenge files: fast-web.zip

Introduction

This challenge consists of a website and zip file containing the executable responsible for serving the website as well as some other associated files such as index.html. There are also some interesting files like auth.txt and route.txt.

The binary

As with most rev challenges, the first course of action is to open the binary file in ghidra or IDA. Fortunately, the server seems to be written in plain C and not some weird language like C++ or Go or Rust. Unfortunately, the program is pretty large and we see a bunch of functions like websSetIndex, websReadFile and so on. After some poking around, we realized that some functions were way too well written and way too complex to be written just for this rev challenge. With some googling, we found that this challenge server was a slightly modified version of an open source web server with some amount of documentation.

The website

The website is quite simple, consisting of index.html and a flag.txt protected by HTTP basic authentication. From the website and elsewhere, we know that the username is sicer but we don't know the password. However, the auth.txt file (produced below) contains an entry for sicer, including what appears to be a password hash:

user name=sicer password=e8b8[...]47a1 roles=flagger

Analysis (hashes and endianness)

We identified a verifyPassword function that seems to be used to authenticate the user:

c
__int64 __fastcall verifyPassword(_QWORD *a1)
{
  _QWORD *v1; // rax

  if ( a1[98] )
    return verifyPwd2((const char *)a1[70], *(const char **)(a1[98] + 8LL));
  v1 = websLookupUser((const char *)a1[80]);
  a1[98] = v1;
  if ( v1 )
    return verifyPwd2((const char *)a1[70], *(const char **)(a1[98] + 8LL));
  else
    return 0LL;
}

verifyPassword calls another function verifyPwd2, but IDA wasn't able to give a very nice decompilation of verifyPwd2:

c
__int64 __fastcall verifyPwd2(const char *password, const char *hash)
{
  // variable declarations

  v19 = 0LL;
  v2 = strlen(password) + 1;
  v3 = &password[v2 + 1 + ~v2];
  v4 = v2 - 1;
  sha512_hash((__int64)v3, v2 - 1, v18);
  v5 = hash;
  v6 = 0LL;
  do
  {
       // garbage
  }
  while ( v14[1] );
  return 1LL;
}

So the password is hashed with SHA-512, then (probably) compared against the hash stored in the auth.txt file. Let's look at the disassembly instead:

c
// A pointer to the SHA512 hash of the password is stored in r9
// A pointer to the (hex encoded) SHA512 hash in auth.txt is stored in r8
mov     dl, [r8] 
// This takes an ASCII char (0-9 and A-F) in dl and converts it to the corresponding number (0-15)
call    convert_hex
mov     ax, dx
inc     r8
mov     dl, [r8]
call    convert_hex
shl     ax, 4
or      ax, dx
inc     r8
// This repeats a couple more times
// In total, 4 ASCII char are read, representing 2 bytes of actual data

Ok so the first part of the code just reads the password hash from auth.txt. For example, if the hash started with 5597, the value of $ax would be 0x5597. Now comes the cmp we have been waiting for:

cmp    WORD PTR [r9], ax

Well, since we know [r9] refers to the hash of the password we provided, this is a simple check that checks the bytes of the two hashes in batches of 2 bytes right? It's a bit more complicated than that. For some reason the SHA512 code returns 8 8 byte longs instead of a continuous 64 byte string. Let's illustrate with an example.

The SHA512 hash of the string 82298 is b37bf4c100005597e3b0c49a.... If this hash were used in auth.txt, the first 2 bytes compared would be 0xb37b. But if it were specified as the password in the HTTP basic authentication, the first 2 bytes compared would be 0x5597. Why? The first 8 bytes of the hash is 0xb37bf4c100005597 and as longs are represented in little endian, the lower 16 bits are used, thus 0x5597.

C string moment

So now we know there's a 'bug' in the hash checking. But it doesn't really help us crack the password. Let's dive deeper:

c
// the cmp from earlier
cmp     [r9], ax
// the 2 bytes are the same
jz      short loc_14633


loc_14633:
// let's compare the next 2 bytes
add     r9, 2
// hmm...
cmp     word ptr [r9], 0
// here we go again
jnz     short loc_145E4
// we finished checking the hash, let's get the flag
mov     eax, 1
jmp     short allow_access

Remember that the hash of the password submitted by the user is in [r9]. You might already know what the problem here is.

The program checks the hash until it encounters two null bytes. However we (sort of) control the hash, so if we find a hash that contains 2 null bytes, we can get the check to exit early without checking the rest of the hash.

Let's look at the example again. If the hash in auth.txt(auth_hash) was 5597aafd7ad9... and the user supplied password was 82298 which hashes to b37bf4c100005597e3b0c49a... (user_hash), the following comparisons will be performed:

  1. auth_hash[0:2] == user_hash[6:8]: 0x5597==0x5597 (OK)
  2. user_hash[4:6] == 0x0 (goes backwards because little endian): OK

Since two null bytes are encountered, the checker just stops checking and lets us access the flag.

The attack

To summarize, we need to find a hash that meets the following conditions:

  1. hash[6:8] == 0xe8b8 (see auth.txt)
  2. hash[4:6] == 0x0

So we only need 4 bytes to match, instead of 64, which is a huge improvement. However, pow(2,32) SHA512s is still a pretty large number of hashes to bruteforce (more than 4 billion). So I wrote some C code to parallelize this operation:

c
#include <openssl/sha.h>
#include <stdio.h>
#include <string.h>
#include <stdlib.h>
#include <time.h>
#include <unistd.h>

// 10**6
#define MILLION 1000000UL
// 10**8
#define HUNDRED_MILLION 100000000UL
#define NUM_PROC 16
#define START_OFFSET 17UL*HUNDRED_MILLION

int main() {

    if(sizeof(unsigned long) != 8){
        printf("size of long is %lu not 8\n", sizeof(unsigned long));
        exit(-1);
    }
  char data[21];
  unsigned char hash[SHA512_DIGEST_LENGTH];
    int proc_num = 0;
    for(; proc_num < NUM_PROC; proc_num++){
        if(fork() == 0){
            // Child
            break;
        }
    }
    clock_t t;
    t = clock();
    unsigned long i = START_OFFSET + proc_num * HUNDRED_MILLION;
    unsigned long end = i + HUNDRED_MILLION;
    printf("Process %d start %luM end %luM\n", proc_num, i/MILLION, end/MILLION);
    for(;i<end;i++){
        sprintf(data, "%lu", i);
        SHA512(data, strlen((char *)data), hash);
        if(
            hash[4] == 0 &&
            hash[5] == 0 &&
            hash[6] == 0xe8 &&
            hash[7] == 0xb8
        ){
            printf("Process %d found %lu\n", proc_num, i);
            break;
        }
    }
    t = clock() - t;
    double time_taken = ((double)t)/CLOCKS_PER_SEC;
    printf("Process %d done in %lf seconds\n", proc_num, time_taken);
  
}

On my laptop, this took two runs (the first with START_OFFSET = 0) of about 2 minutes to produce a string that hashes to something that fulfills all the conditions: 2377455722 with hashes to 57cc19e20000e8b8....

Entering this as the password with the username sicer grants us the flag: tjctf{g0_ah3ad_and_us3_a_n0rm4l_w3b_serv3r_pls_4b470205e474e398}