Undead Survivor
We are provided with a Unity game:

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:
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:
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
:
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:
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:
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: