BearcatCTF 2025 - Hungry Hungry Hippos

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
2
3
4
5
6
7
$ ./hungry
,-'''''-.:-^-._ _,-^-;,-'''''-.
/ ' ( ` _\ /_ ` ) ` \
\ \ _ .,-' `-., _, ; /
)_\-._-._((_( )_))_,-_,-/_(
I'm Hungry! Feed me a flag: BCCTF{lol}
EW! That's not a flag!

Static Analysis

The first interesting bit of code we can see is:

1
2
3
LEA  RAX, [gos_df7e37fae9835f8ce5a74cfca4b21f846274d  ; = "df7e37fae9835f8ce5a74cfca4b21..."
MOV EBX, 0xc4
CALL encoding/hex.DecodeString

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
2
3
4
5
6
MOV  RBX ,qword ptr [os.Stdin ]
LEA RCX =>local_48 ,[RSP + 0xb8 ]
MOV EDI ,0x1
MOV RSI ,RDI
LEA &input ,[go:itab.*os.File,io.Reader ]
CALL fmt.Fscanln

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
if (*(ulong *)((long)register0x00000020 + -0x88) == uVar5) {
*(undefined8 *)((long)register0x00000020 + -0x108) = 0x4a1e0b;
runtime.memequal(*(undefined8 *)((long)register0x00000020 + -0x100),
*(undefined8 *)((long)register0x00000020 + -0xf8),
*(undefined8 *)((long)register0x00000020 + -0xf0),
*(undefined1 *)((long)register0x00000020 + -0xe8));
cVar1 = extraout_AL;
}
else {
cVar1 = '\0';
}
if (cVar1 == '\0') {
*(runtime._type **)((long)register0x00000020 + -0x68) = &datatype.String.string;
*(undefined ***)((long)register0x00000020 + -0x60) = &goss_EW_Thats_not_a_flag_4e9f90 ;
*(undefined8 *)((long)register0x00000020 + -0x108) = 0x4a1e93;
fmt.Fprintln(go:itab.*os.File,io.Writer,os.Stdout,
(undefined1 *)((long)register0x00000020 + -0x68),1,1);
}
else {
*(runtime._type **)((long)register0x00000020 + -0x58) = &datatype.String.string;
*(undefined ***)((long)register0x00000020 + -0x50) = &goss_YUM_4e9f80;
*(undefined8 *)((long)register0x00000020 + -0x108) = 0x4a1e50;
fmt.Fprintln(go:itab.*os.File,io.Writer,os.Stdout,
(undefined1 *)((long)register0x00000020 + -0x58),1,1);
}
return;

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
2
3
4
5
6
if bytes.Equal(decoded_hex, encoded_input) {
fmt.Print("YUM!")
}
else {
fmt.Print("EW! That's not a flag!")
}

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
// SHA256 hash
crypto/sha256.Sum256(puVar2,uVar12,uVar3,puVar11,puVar2,uVar12,in_R9)
...
if (lVar7 < (long)uVar5) {
uVar3 = lVar7 * 2;
if (*(ulong *)((long)register0x00000020 + -0x98) < uVar3) goto LAB_004a1ea5;
if (uVar5 < uVar3) goto LAB_004a1e9c;
lVar4 = *(ulong *)((long)register0x00000020 + -0x98) + lVar7 * -2;
lVar8 = uVar5 + lVar7 * -2;
main.xorBytes((uVar3 & -lVar4 >> 0x3f) + *(long *)((long)register0x00000020 + -0x78),lVar8 ,
lVar4,(undefined1 *)((long)register0x00000020 + -0xc1),0x20,0x20);
lVar7 = *(long *)((long)register0x00000020 + -0x90);
in_R9 = lVar8 + lVar7 * 2;
uVar5 = *(ulong *)((long)register0x00000020 + -0x98);
if (uVar5 < in_R9) {
*(long *)((long)register0x00000020 + -0x80) = lVar8;
*(undefined8 *)((long)register0x00000020 + -0x18) = extraout_RAX_02;
runtime.growslice(*(undefined8 *)((long)register0x00000020 + -0x78),in_R9,uVar5,lVar8,
&datatype.Uint8.uint8);
lVar7 = *(long *)((long)register0x00000020 + -0x90);
lVar4 = extraout_RAX_03;
}
else {
lVar4 = *(long *)((long)register0x00000020 + -0x78);
}
*(ulong *)((long)register0x00000020 + -0x98) = uVar5;
*(long *)((long)register0x00000020 + -0x78) = lVar4;
*(ulong *)((long)register0x00000020 + -0xa0) = in_R9;
puVar11 = (undefined1 *)(lVar4 + lVar7 * 2);
runtime.memmove(*(undefined8 *)((long)register0x00000020 + -0x100),
*(undefined8 *)((long)register0x00000020 + -0xf8),
*(undefined8 *)((long)register0x00000020 + -0xf0));
uVar5 = *(ulong *)((long)register0x00000020 + -0xa0);
uVar3 = *(ulong *)((long)register0x00000020 + -0x98);
puVar2 = *(undefined1 **)((long)register0x00000020 + -0x78);
}
else {
uVar3 = *(ulong *)((long)register0x00000020 + -0x98);
puVar11 = (undefined1 *)((long)register0x00000020 + -0xc1);
main.xorBytes(*(undefined8 *)((long)register0x00000020 + -0x78),uVar5,uVar3,puVar11,0x20,
0x20);
puVar2 = extraout_RAX_04;
}

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
2
3
4
5
6
7
8
RAX: 0xc000092000 --> 0x42 ('B')
RBX: 0x1
RCX: 0x8
RDX: 0x42 ('B')
RSI: 0xc000092000 --> 0x42 ('B')
RDI: 0xc000092000 --> 0x42 ('B')
...
Thread 6 "hungry" hit Breakpoint 1, crypto/sha256.Sum256 (data=..., ~r0=...) at /usr/local/go/src/crypto/sha256/sha256.go:248

Our first attempted hash is B! And, remember how the state started with df7e?

1
2
>>> sha256(b"B").hexdigest()
'df7e70e5021544f4834bbee64a9e3789febc4be81470df629cad6ddb03320a5c'

The hash for the letter B also starts with df7e! Let’s step forward again:

1
2
3
4
5
6
RAX: 0xc000096000 --> 0xf4441502e5707edf 
RBX: 0x21 ('!')
RCX: 0x40 ('@')
RDX: 0x43 ('C')
RSI: 0xc000096000 --> 0xf4441502e5707edf
RDI: 0xc000096000 --> 0xf4441502e5707edf

Oh. That’s interesting. But, that does look like our hash that’s being pointed to. Let’s check:

1
2
3
4
5
6
gdb-peda$ x/64b 0xc000096000
0xc000096000: 0xdf 0x7e 0x70 0xe5 0x02 0x15 0x44 0xf4
0xc000096008: 0x83 0x4b 0xbe 0xe6 0x4a 0x9e 0x37 0x89
0xc000096010: 0xfe 0xbc 0x4b 0xe8 0x14 0x70 0xdf 0x62
0xc000096018: 0x9c 0xad 0x6d 0xdb 0x03 0x32 0x0a 0x5c
0xc000096020: 0x43 0x00 0x00 0x00 0x00 0x00 0x00 0x00

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
2
3
4
5
0xc000096080:   0xdf    0x7e    0x37    0xfa    0xc1    0x2c    0xa4    0x9e
0xc000096088: 0x67 0xfc 0xd1 0x7d 0x96 0xe7 0xef 0x64
0xc000096090: 0xb1 0xa4 0x79 0xd2 0xc9 0x85 0x26 0x9e
0xc000096098: 0xc7 0x54 0x0d 0xb1 0xc4 0x1c 0x33 0xa1
0xc0000960a0: 0x9a 0x5a 0x43 0x00 0x00 0x00 0x00 0x00

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
2
>>> hex(0x37fa ^ 0x70e5)
'0x471f'

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
2
>>> sha256(sha256(b"B").digest() + b"C").hexdigest()
'471fc339e06ae4b76f9bdc79d8ed4f18323addf5f9fc5bf9606ac72e39fd9a5a'

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
2
3
4
5
0xc000096080:   0xdf    0x7e    0x37    0xfa    0xe9    0x83    0x57    0x7a
0xc000096088: 0x3a 0x04 0x91 0x50 0xd9 0xd9 0x52 0xa9
0xc000096090: 0x08 0x04 0x0b 0x58 0x54 0xc0 0xc2 0xc9
0xc000096098: 0xe5 0x62 0x73 0x29 0xc4 0x0d 0x10 0x85
0xc0000960a0: 0x2f 0x4e 0xd5 0x59 0x54 0x00 0x00 0x00

e983 has also stepped into place from the state! Let’s XOR that with c12c:

1
2
>>> hex(0xe983 ^ 0xc12c)
'0x28af'

Then take our last state, and hash it with SHA-256…

1
2
3
>>> last_state = b"\xdf\x7e\x37\xfa[...]\xa1\x9a\x5a\x43"
>>> sha256(last_state).hexdigest()
'28aff3e45df8402d4f3ebdcdb9a0728a9d45e45722367e9800112324b514d559'

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
import hashlib
import binascii
import string
# alphabet for bruteforce
alphabet = string.ascii_letters + "01234567890{}_-"
# xor
def xor(bytes1, bytes2):
return bytes(a ^ b for a, b in zip(bytes1, bytes2))
# sha256 as bytes
def sha256_hex(data):
if isinstance(data, str):
data = data.encode('utf-8')
sha256_hash = hashlib.sha256(data)
return sha256_hash.digest()
# hex and raw data
hex_data = "df7e37fa...afa84d1a2"
raw_data = binascii.unhexlify(hex_data)

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
2
3
current_state = b"\x00" * len(raw_data)
hash_state = b"\x00" * len(raw_data)
final_string = ""

Now, it’s time to implement the actual algorithm:

1
2
3
4
5
6
7
8
9
10
for i in range(0, len(raw_data) // 2):
chunk = raw_data[i*2:i*2+2]
for j in alphabet:
hash_state = (current_state.rstrip(b"\x00") + j.encode())
hash = xor(current_state[i*2:], sha256(hash_state))
if hash[:2] == chunk:
current_state = (current_state[:i*2] + hash).ljust(len(raw_data), b"\x00")
final_string += j
print(final_string)
break

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
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
B
BC
BCC
BCCT
BCCTF
BCCTF{
BCCTF{N
BCCTF{N0
....
BCCTF{N0m_n0M_NoM_Nom_N0W_1m
BCCTF{N0m_n0M_NoM_Nom_N0W_1m_
BCCTF{N0m_n0M_NoM_Nom_N0W_1m_f
BCCTF{N0m_n0M_NoM_Nom_N0W_1m_fu
BCCTF{N0m_n0M_NoM_Nom_N0W_1m_fu1
BCCTF{N0m_n0M_NoM_Nom_N0W_1m_fu11
BCCTF{N0m_n0M_NoM_Nom_N0W_1m_fu11}

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.