This is one of those challenges that when you know the trick, is very easy. But if you don't it is very hard. At first, this seems like a nightmare reversing challenge to do manually, and if you're more experienced you might try using an SMT solver for it. But this writeup shows a method where you could complete this challenge in 5 minutes with not even 10 lines of Python.
The Challenge
We get a simple cave
binary executable file that we can open in a decompiler like Ghidra (see this post if you are unfamiliar). It has one "simple" main
function that asks for a password from stdin:
undefined8 main(void)
{
...
printf("What route will you take out of the cave? ");
fgets((char *)&local_88,0x80,stdin);
iVar1 = memcmp(&local_88,&DAT_00102033,4);
if (((((((iVar1 == 0) && ((byte)(local_78._5_1_ * (char)local_58) == '\x14')) &&
((byte)((byte)local_68 - local_68._4_1_) == -6)) &&
(((((((byte)(local_68._5_1_ - local_70._2_1_) == -0x2a &&
((byte)((byte)local_78 - (char)local_58) == '\b')) &&
(((char)(local_58._7_1_ - (char)local_80) == -0x2b &&
(((byte)(local_70._2_1_ * local_88._7_1_) == -0x13 &&
((char)(local_88._4_1_ * (char)local_70) == -0x38)))))) &&
((local_68._2_1_ ^ local_70._4_1_) == 0x55)) &&
(((((byte)(local_70._6_1_ - local_58._7_1_) == '4' &&
((byte)(local_50._3_1_ + local_58._2_1_) == -0x71)) &&
((byte)(local_60._4_1_ + local_70._3_1_) == -0x2a)) &&
(((local_78._1_1_ ^ local_80._6_1_) == 0x31 &&
((byte)((byte)local_50 * local_78._4_1_) == -0x54)))))) &&
...
...
...
(((((byte)((char)local_80 + local_78._4_1_) == -0x2d &&
((byte)(local_80._3_1_ * local_58._5_1_) == -0x28)) &&
((byte)(local_70._3_1_ + local_88._6_1_) == -0x2e)) &&
(((byte)(local_88._5_1_ + local_88._3_1_) == -0x55 &&
((byte)(local_68._3_1_ - local_60._7_1_) == -0x2e)))))))) &&
(((byte)local_78 ^ local_68._1_1_) == 0x10)))))))))) {
puts("Freedom at last!");
}
else {
puts("Lost in the darkness, you\'ll wander for eternity...");
}
return 0;
}
}
Woah, that is a giant if
statement. A lot of conditions, calculations, and variables all depend on each other. Doing this by hand would be a nightmare, but we probably need to find some input that will satisfy the condition to reach "Freedom at last". This input will then be the flag, as there is nothing else in the binary.
Solving with Angr
This is where the trick I was talking about comes in. Angr is a framework for solving conditions in binaries, through symbolic execution. This means it will run the binary, and while doing so record all the instructions it passes. It remembers which input variables cause what branches to take, and can then solve it like a math problem.
One way to use it is to simply give it a binary, give it a target address for it to try and reach, and tell it that it can provide any STDIN data (our input). That is exactly what this simple piece of code does:
import angr
project = angr.Project("./cave", auto_load_libs=False)
@project.hook(0x401aba) # Target address
def print_flag(state):
print("VALID INPUT:", state.posix.dumps(0))
project.terminate_execution()
project.execute()
This hexadecimal target address can be found in Ghidra, if you click on the puts("Freedom at last!");
call:
Ghidra by default starts addresses from 0x100000, while Angr will start it from 0x400000 (as it tells you in the output). We can just translate this address to what Angr will use, and run the script to try and solve the condition.
WARNING | 2023-03-24 11:45:58,024 | cle.loader | The main binary is a position-independent executable. It is being loaded with a base address of 0x400000.
WARNING | 2023-03-24 11:45:58,045 | angr.storage.memory_mixins.default_filler_mixin | The program is accessing memory with an unspecified value. This could indicate unwanted behavior.
WARNING | 2023-03-24 11:45:58,045 | angr.storage.memory_mixins.default_filler_mixin | angr will cope with this by generating an unconstrained symbolic variable and continuing. You can resolve this by:
WARNING | 2023-03-24 11:45:58,045 | angr.storage.memory_mixins.default_filler_mixin | 1) setting a value to the initial state
WARNING | 2023-03-24 11:45:58,045 | angr.storage.memory_mixins.default_filler_mixin | 2) adding the state option ZERO_FILL_UNCONSTRAINED_{MEMORY,REGISTERS}, to make unknown regions hold null
WARNING | 2023-03-24 11:45:58,045 | angr.storage.memory_mixins.default_filler_mixin | 3) adding the state option SYMBOL_FILL_UNCONSTRAINED_{MEMORY,REGISTERS}, to suppress these messages.
WARNING | 2023-03-24 11:45:58,046 | angr.storage.memory_mixins.default_filler_mixin | Filling memory at 0x7fffffffffeff9c with 4 unconstrained bytes referenced from 0x401085 (_start+0x5 in cave (0x1085))
VALID INPUT: b"HTB{H0p3_u_d1dn't_g3t_th15_by_h4nd,1t5_4_pr3tty_l0ng_fl4g!!!}\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00\x00"
There it found a valid input! The null bytes at the end can be ignored, but the valid input is indeed the flag.
HTB{H0p3_u_d1dn't_g3t_th15_by_h4nd,1t5_4_pr3tty_l0ng_fl4g!!!}