Introduction
I know, very late on posting this, but I forgot about it and just started to finish it now.
Hungry Hungry Hippos was a challenge at BearcatCTF 2025 that involved reverse engineering a Go program with an algorithm that used SHA-256 and XOR to hide the flag. After reversing the algorithm, the final state had to be brute forced character by character in order to obtain the flag.
Running the Program
Before doing anything, let’s run the program with random input to get an idea of the behavior:
1 | $ ./hungry |
Static Analysis
The first interesting bit of code we can see is:
1 | LEA RAX, [gos_df7e37fae9835f8ce5a74cfca4b21f846274d ; = "df7e37fae9835f8ce5a74cfca4b21..." |
The calling convention for Go applications on AMD64 passes RAX as the first integer argument and RBX as the second. So, we know this string, of length 0xc4 (196), is being decoded from hex to bytes.
1 | MOV RBX ,qword ptr [os.Stdin ] |
This is effectively a converted Go call to fmt.Scan()
, a bit unrolled into the internal Go library function. So we’re reading a string from the user, probably the flag input we saw earlier.
Here’s some raw Ghidra C decompiler output, cleaned up a bit:
1 | if (*(ulong *)((long)register0x00000020 + -0x88) == uVar5) { |
Woah, that’s interesting! This is basically the logic to make sure the byte array at register0x00000020 + -0x100
is equal to whatever is in register0x00000020 + -0xf0
, Roughly equal to this Go code:
1 | if bytes.Equal(decoded_hex, encoded_input) { |
We can completely disregard the writes to register0x00000020 + -0x108
- they’re code addresses, probably for error handling or something.
So, we know that whatever our flag gets encoded to, has to be equal to the bytes in that hex string so far.
Then came the actual encryption:
1 | // SHA256 hash |
We have some really weird stuff going on here. Keep in mind that this is in a loop. But, what we can glimpse from this, is that we hash something with SHA256, then XOR it with something, seemingly in 2-byte increments of some kind. The XOR length is 0x20 (32), the same length as a SHA-256 hash, so maybe we’re XORing with another SHA256 hash?
I think we’ve glimpsed all we can without running the actual program, at least for my standards - let’s move to dynamic analysis.
Dynamic Analysis
I figured I would breakpoint at the SHA256 for a start, because that’s the first operation. Let’s feed it BCCTF
, the start of the flag format:
1 | RAX: 0xc000092000 --> 0x42 ('B') |
Our first attempted hash is B! And, remember how the state started with df7e
?
1 | >>> sha256(b"B").hexdigest() |
The hash for the letter B also starts with df7e
! Let’s step forward again:
1 | RAX: 0xc000096000 --> 0xf4441502e5707edf |
Oh. That’s interesting. But, that does look like our hash that’s being pointed to. Let’s check:
1 | gdb-peda$ x/64b 0xc000096000 |
It’s our hash, alright, but there’s also the hex for the letter C in there. C was our next letter! Let’s step forward again:
1 | 0xc000096080: 0xdf 0x7e 0x37 0xfa 0xc1 0x2c 0xa4 0x9e |
Oh. Our df7e
at the start stayed (as we probably ought to have expected, considering the state we’re comparing to.) But the rest of our hash got completely mangled! But, the 3rd and 4th bytes of the state are 37fa, just like they are in the flag state!
We know XOR is involved, right? Let’s XOR 37fa with 70e5:
1 | >>> hex(0x37fa ^ 0x70e5) |
471f is nothing like anything we have seen. But what if it was a SHA256 hash like we suspected during static analysis? So, let’s generate the last state manually real quick:
1 | >>> sha256(sha256(b"B").digest() + b"C").hexdigest() |
It starts with 471f
! We can also see it ends with 9a5a
, which are the last 2 bytes before the appended C
. We’re definitely on the right track.
Let’s step forward again and test our theory:
1 | 0xc000096080: 0xdf 0x7e 0x37 0xfa 0xe9 0x83 0x57 0x7a |
e983 has also stepped into place from the state! Let’s XOR that with c12c
:
1 | >>> hex(0xe983 ^ 0xc12c) |
Then take our last state, and hash it with SHA-256…
1 | >>> last_state = b"\xdf\x7e\x37\xfa[...]\xa1\x9a\x5a\x43" |
It starts with 28af
, which is the result of our XOR, and it ends with d559
, which is the end of the data before the appended T
.
From here, we can assume this is what happens:
- The first character, in this case
B
is hashed and copied into Array B in binary form:df7e70e50215...
- Then, the hash is copied into Array B and the second character is appended to that hash:
...0a5c43
- Afterwards, the data at index
2
and onwards in Array A is XORed with the hash of the state in Array B - Then, that state is copied back into Array B, the next character appended, then XORed with the data in Array A at offset
n*2
(n being the zero-based character index), over and over again until all characters are exhausted.
Knowing how the algorithm works, we can now implement it in Python and try to get the flag.
The Solve Script
Well, let’s start with at least having some wrappers for the basic implementations:
1 | import hashlib |
We define the brute-force alphabet and our data (hex and raw), then also define some functions that will allow us to more easily perform XOR and SHA-256 operations.
Next, we want to allocate the two arrays so we can perform the XOR operations, as well as a space for the final characters. The best bet for this is to just create an empty string, plus two byte strings filled with nullbytes to the length of the raw data. (This is most certainly not what I did during the CTF, but I think it’s better this way for learning purposes.)
1 | current_state = b"\x00" * len(raw_data) |
Now, it’s time to implement the actual algorithm:
1 | for i in range(0, len(raw_data) // 2): |
This is a little complex, so let’s explain. (At least it’s cleaner than the original solve code from during the CTF!)
First, we iterate over each two-byte chunk and store it. Then, for each letter of the alphabet, we brute-force the state for that character by performing the SHA-256 and XOR until the chunk matches. (Note, because we are padding with null bytes to have breathing room for the XOR, we have to strip the remaining null bytes with rstrip so that the hash works correctly.)
Finally, we check to make sure the chunk is correct. If it is, we set up our new state by appending the new hash to the existing chunks, add that character to our flag output, and print the flag so far, then go to the next iteration. (To make sure we can continue to do the XORs correctly, we append ljust.)
Now, let’s run it…
1 | B |
There’s our flag: BCCTF{N0m_n0M_NoM_Nom_N0W_1m_fu11}
!
Conclusion
The primary vulnerability with this challenge is that each iteration of the encryption effectively only adds one byte of entropy - that is, once you’ve guessed the first character correctly, you can guess the second, and the third, and so on. While with CTFs we typically have a flag format and will already know the first few characters, the information was there to guess the first character, so it wouldn’t have mattered too much.
The most difficult part of this challenge was reverse engineering the algorithm itself. Go can be quite a rough language to reverse engineer at times, and it definitely was to me who had practically never touched a reverse engineering challenge in Go before this. Dynamic analysis definitely helped when the code itself was hard to understand, and using my intuition I was able to finally crack this challenge after a few hours.