HTB Uni CTF 2020 - Rigged Lottery
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.
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 tolucky_number
, up to the length ofusr_length
- Opens
/dev/urandom
and writes random bytes in tolucky_number
in two ways - first readingusr_length
bytes all at once, then loopingusr_length
times and writing one byte at a time - Uses
strcpy
to copy the locallucky_num
value to the globallucky_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 therigged_number
global variable - Uses
strcmp
to check iflucky_number
andrigged_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 andcosy_coins
is below 100. If true, returns from the function - Otherwise, opens the
flag.txt
file and reads the content in to theflag_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:

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:

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