Fast web
In TJCTF 2022, 480 points
I'm sick of all these JavaScript libraries and bloated Python web frameworks!
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:
__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
:
__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:
// 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:
// 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:
auth_hash[0:2] == user_hash[6:8]
:0x5597==0x5597
(OK)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:
hash[6:8] == 0xe8b8
(seeauth.txt
)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:
#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}