Skip to content

Undead Survivor

We are provided with a Unity game:

image-20250525151047594

Unfortunately, reversing this game won't be easy, as the original C# code has been compiled down to native C++ code using Il2CPP. The game logic is contained within the GameAssembly.dll file.

Fortunately, the symbols are still available in the il2cpp_data/Metadata/global-metadata.dat file.

We can use Il2cppdumper to parse the global-metadata.dat file and extract the debug information into a format that can be imported into IDA.

After running the ida_py3.py and ida_with_struct_py3.py scripts (and 1GB of .i64 later), we are presented with a decent looking decompilation:

image-20250525152126816

After a few (like 5) hours of poking around and trying various things, I decided that the GameManager$$ColorToHash was kinda sus, as it didn't seem to be used in the main game logic.

Examining its xrefs, we find that it is only used in the GameManager$$Update function:

image-20250525152431522

This code basically grabs the RGBA of the pixel at (0,0) on the screen and sends it to ColorToHash. Unfortunately, the return value of ColorToHash doesn't seem to be displayed anywhere.

Now, let's have a look at ColorToHash:

image-20250525152718699

Hmmm, looks very sus! An AI summary of the function tell us that it takes in a color and returns a 20 byte string.

Additionally, the computation for the second and third characters of the output are identical, especially suspicious since we know the flag starts with CDDC.

I then patched the binary using IDA to add a breakpoint after the ColorToHash function. I could then run the game in a debugger, and inspect its return value.

A string kinda resembling the flag can be seen:

image-20250525153920865

Unfortunately, even after setting conditional breakpoints to monitor the return value of ColorToHash, I wasn't able to get it to produce a fully printable string, even after moving around the game map.

With the help of AI and some manual editing, I converted the ColorToHash function to Python and used z3 to constrain the first 9 characters of the flag to the flag format:

python
from z3 import *
r = BitVec("r", 8)
g = BitVec("g", 8)
b = BitVec("b", 8)
a = BitVec("a", 8)
s = Solver()

v7 = r
v11 = g
v14 = b
v17 = a

expected = [x for x in b"CDDC2025{"]
hash_chars = []
    
hash_chars.append((v17 % 94))

val = (v11 + v7) % 94 - 5
hash_chars.append((val))
hash_chars.append((val))

hash_chars.append(((v17 + v7) % 94))

hash_chars.append(((v11 + v17 + v14) % 94)+32)

hash_chars.append(((v11 + v17 + v14) % 94 + 30))
hash_chars.append((v7 + v14 + v11) % 94 + 5)
hash_chars.append((v11 + v17 + v14) % 94 + 35)
hash_chars.append((v17 + v7) % 94 + 56)
hash_chars.append(v14 % 94 + 33)
hash_chars.append(v11 % 94 + 33)
hash_chars.append(v14 % 94 + 33)

hash_chars.extend([
    v17 % 94 + 33,
    (v11 + v7) % 94 + 33,
    (v14 + v11) % 94 + 33,
    (v17 + v14) % 94 + 33,
    (v17 + v7) % 94 + 33,
    (v7 + v14 + v11) % 94 + 33,
    (v11 + v17 + v14) % 94 + 66,
    (v11 + v17 + v14) % 94 + 107
])

for a,_b in zip(hash_chars, expected):
    s.add(a==_b)

def exclude_solution(solver, inputs, outputs):
    from z3 import And, Not
    cond = And(True, True)
    for i, o in zip(inputs, outputs):
        cond = And(cond, i == o)
    solver.add(Not(cond))

def eval_all_solutions(solver, inputs, limit=-1):
    sols = []
    while solver.check() == z3.sat and len(sols) != limit:
        m = solver.model()
        sol = [m.eval(x).as_long().real for x in inputs]
        print(sol)
        sols.append(sol)
        exclude_solution(solver, inputs, sol)
        yield [m.eval(x).as_long().real for x in hash_chars]
    if len(sols) == 0:
        print("No solution")


for x in eval_all_solutions(s, [r,g,b,a]):
    print(bytes(x))

This produces lots of possible flags, so I submitted one at random and it turned out to be correct: CDDC2025{cjcdjNHdNT}.

As it turns out, 9 of the 15 possible RGBA combinations produce that flag. They are:

image-20250525154933075