I grabbed a crackme called Aegis Privacy Protocol. It pretends to be a privacy tool. There's a menu of features that promise to scrub Windows telemetry, and half of them sit behind a "PRO" tier. Option 7 is Authenticate Override Key. Type the right key and the locked features open up. That key is the whole game.
It's a .NET binary. Normally that means dnSpy, fifteen minutes, done. Not this one. Someone ran it through a protector that flattens control flow, encrypts every string, hides its own helper types from decompilers, and throws in some anti-debug for good measure. So it ate more of my afternoon than I planned. And the part that actually worked wasn't reading the code. It was making the program decrypt its own secret and print it.
If you want a crack at it yourself, the link above is where to start. The walkthrough below gives away the method but not the answer. The key sits behind a spoiler, so you can read the how without tripping over it.
Here's the route I took. Wrong turns left in.
Triage#
A quick look before the heavy tools come out.
file "Aegis Privacy Protocol.protected.exe"
# PE32 executable ... Intel i386 Mono/.Net assembly, 3 sections32-bit .NET. The filename says .protected, which never bodes well for a quiet
afternoon. I didn't have strings on this box, so I threw a short Python scanner
at it to pull the readable text runs. The UTF-16 side came back nearly empty. The
ASCII side leaked the framework names the protector couldn't touch.
HMACSHA256 FromBase64String ToBase64String Marshal
RegistryKey CreateSubKey FileShare dwDesiredAccess
processHandle UnauthorizedAccessException ConsoleKeyInfo FailFastAlready a decent x-ray. HMAC and Base64 mean crypto in the key check. Marshal and
dwDesiredAccess and processHandle and FailFast mean native anti-debug.
RegistryKey means it pokes the registry somewhere. Now I knew roughly what I
was walking into.
What am I dealing with#
Before decompiling I wanted the metadata straight, so I wrote a tiny CLR parser in Python. Walk the PE, find the table stream, read the string heaps. Two numbers did most of the talking.
#US user strings: 0 non-empty every literal is encrypted
FieldRVA rows: 1 one big blob mapped into .text
TypeDef: 148 MethodDef: 445The #US heap is where .NET keeps string literals. It's empty. Every word this
thing prints gets built at runtime. And .text is 6 MB for 445 methods, with a
single FieldRVA entry. That's a packer. One giant encrypted array, unpacked
when it's needed.
One thing went my way. The type and method names aren't Unicode garbage. The
protector went with plausible but meaningless names instead. CachedTokenTable1021.
StagedSessionBuilder650. RouteGateway492. Fake corporate namespaces layered on
top, Summit.Licensing.Gateway and Northwind.Workflow.Runtime. It reads like
enterprise middleware written by someone with a grudge. But it reads. A decompiler
would hand me something to work with.
An empty #US heap next to one huge FieldRVA blob is a
reliable tell. Strings get decrypted at runtime, and the plaintext lives inside
that one array, encrypted. Hold that thought. It comes back later.
A decompiler, without a decompiler#
This part got tedious. No dnSpy. No .NET SDK, so no dotnet tool install. No
strings. What I had was the runtime and Python.
You don't need the SDK to run a decompiler. Only to install one. So I pulled
ilspycmd off NuGet as a plain .nupkg, which is just a zip, unpacked it, and
ran the DLL on the runtime I already had.
curl -s -o ilspycmd.nupkg \
https://api.nuget.org/v3-flatcontainer/ilspycmd/9.1.0.7988/ilspycmd.9.1.0.7988.nupkg
python -c "import zipfile; zipfile.ZipFile('ilspycmd.nupkg').extractall('ilspycmd')"
dotnet ilspycmd/tools/net8.0/any/ilspycmd.dll "Aegis Privacy Protocol.protected.exe" > all.cs2,600 lines of C# came out. Then I opened it.
The wall#
Every method looks like this. Real code, straight out of the file, untouched.
int num3 = Sourceofs.Pipelinegtr(0x6BDD0324, 0x1319596053) ^ 0x6F5BEA47 ^ num2;
while (true)
{
int num4 = (num3 ^ num2 ^ (Sourceofs.Pipelinegtr(0x38000000, 0x1A6E32E5) ^ 0x575838C3)) - (Sourceofs.Pipelinegtr(0x22EC1FB2, 0x1169005941) ^ 0x576939D2);
if (num4 == (Sourceofs.Pipelinegtr(0x10AB3AD5, 0x210DE07C) ^ 0x668EEA77)) { /* ... */ }
else if (num4 < (Sourceofs.Pipelinegtr(0x6E7A6711, 0x3C5F0620) ^ 0x67BEF640)) { /* ... */ }
// forty more branches, all comparing num4 to decoded constants
}Control-flow flattening. The real logic gets diced into a state machine. A
while(true) loop dispatches on num4, and each block works out the next state
as an obfuscated constant. Pipelinegtr and its sibling Cursorjgi are constant
decoders. Garbage goes in, the real integer comes out. Every literal in the
program runs through one of them.
The framework calls are gone too. Nowhere does it say HMACSHA256.ComputeHash.
You get this instead.
result = (string)Contextnsr.Handleddp(439158902, obj, new object[1] { array }, 0x...);Real calls into the base library get proxied through reflection stubs keyed by encoded tokens. Clean names or not, I can't see what it actually calls.
Then the good part. The types doing the decryption and dispatch are named things
like <PrivateImplementationDetails>Contextivh and
<PrivateImplementationDetails>Workerjld. That prefix is what the C# compiler
uses for its own generated data classes. So ILSpy treats them as noise and hides
them. Ask for those types directly and you get empty files. Dump the raw IL and
Cursorjgi alone runs 1,174 lines of the same flattened soup.
Naming your runtime after <PrivateImplementationDetails> is a
real trick. The decompiler folds those away for you, so the code that matters most
is the code you never see. I lost an hour to that one before the penny dropped.
I could sit down and constant-fold the whole thing by hand. People do. I weighed the effort against the payoff and went a different way.
Stop reading, start running#
The reframe that saved the day. I don't need to understand the obfuscation. I need one boolean. Is this key correct. And the program already knows how to answer that. The protection makes the code hard to read. It does nothing to stop me calling it.
All of it runs at runtime. Strings decrypt. Constants decode. The validator does its job. So I'll load the assembly into my own process and drive it by reflection. Two things to clear first.
Bitness. The target is 32-bit, so my harness has to be 32-bit too or the CLR
refuses to load it. I compiled with the .NET Framework's own csc.exe, which
lives on every Windows box, and passed -platform:x86.
Mark-of-the-Web. The file was downloaded, so Assembly.LoadFrom sandboxed it. One
Unblock-File on a local copy killed that.
The obfuscator sets itself up lazily. Every method opens by calling an init
routine, Adaptermsj. So my harness loads the assembly, calls that once by
reflection, and from there everything behaves like a normal run.
var asm = Assembly.LoadFrom(path);
Find("Adaptermsj").Invoke(null, null); // force the lazy init
// every internal method is live nowI braced for anti-tamper here. Plenty of protectors call FailFast the second
something smells off. It never fired. Invoking methods by reflection from a clean
process with no debugger attached doesn't look like a debugger, and that's all the
checks were watching for.
Following the thread to the validator#
In Main the key check is one line.
string key = ReadLine().Trim();
if (Contextivh.Sinkcme(key, C, D)) {
RemoteSnapshotStrategy526._deferredCursor731 = true; // the unlocked flag
Print("KEY ACCEPTED...");
}Sinkcme(string, int, int) returns the verdict. The two ints looked like metadata
tokens. But when I decoded them with the runtime's own decoders, which I could
just call by reflection, they came out as 0x81EEFD39 and 0x81EEFD38. Not
tokens. An index and a key into a table. Sinkcme picks a delegate out of an
array and invokes it.
So I dumped the array. Every static delegate field in the assembly, printed with its real target method.
Sinkli[0] -> String::IsNullOrEmpty(String) : Boolean
Sinkli[1] -> StagedSessionBuilder650::SealWindow266(String) : BooleanIndex 0 is an empty-check for some unrelated menu item. Index 1 is the one I
want. SealWindow266(string) returns bool, and it validates the override key.
Here's the whole chain.
Now I had an oracle. I called SealWindow266 with junk and got clean False
answers back. That's enough to brute a weak check. This one leans on the
decryption VM though, so brute force was the wrong tool. The expected value was the
target.
Let the VM decrypt its own answer#
SealWindow266 doesn't store the key. It runs a small stack-based bytecode VM,
WriteManifest53, over a 100-byte program, PackEndpoint237, to rebuild the
expected key at runtime and then compare. That's the string encryption from the
metadata, finally out in the open.
I didn't need to understand the VM either. It returns byte[]. So I called it.
foreach (var name in new[] { "Cachekqs", "WriteManifest53", "PackEndpoint237" })
{
var bytes = (byte[])Find(name).Invoke(null, null);
Console.WriteLine(name + " -> " + Encoding.ASCII.GetString(bytes));
}Cachekqs -> NEXUS-████-████-████
WriteManifest53 -> NEXUS-████-████-████
PackEndpoint237 -> =r5?9=y5?9=d5?9=i5?9... (raw bytecode)That's the key sitting in the output, redacted here. The program decrypted its own secret and handed it back to me. The real value is one click away if you want it.
I checked the VM's math against the bytecode, just to satisfy myself. The program
is a run of 3D XX 35 3F 39 groups. XOR each byte with 0x3C and the opcodes
appear. 0x3D^0x3C is 1, push immediate. 0x35^0x3C is 9. The pushed immediates
are the bytes that matter. XOR those with 0x3C as well and the key falls out one
character at a time.
0x72^0x3C = 'N' 0x79^0x3C = 'E' 0x64^0x3C = 'X' 0x69^0x3C = 'U'
0x6F^0x3C = 'S' 0x11^0x3C = '-' 0x04^0x3C = '8' ... -> NEXUS-████-████-████The whole VM, all that flattening, comes down to XOR a buffer with 0x3C and run
a string compare. All that armor around a byte of XOR.
Proof#
An offline derivation isn't worth much on its own, so I checked it against the real thing. The oracle first, to see how fussy it is.
SealWindow266("<the key>") = True
SealWindow266("<the key>, lowercased") = False (case sensitive)
SealWindow266("WRONG-KEY") = FalseCase sensitive. Then the actual program, driven for real. Pick 7, type the key.
--- CLASSIFIED OPERATIONS (PRO) ---
[7] Authenticate Override Key
[8] Exit Protocol
[+] KEY ACCEPTED. PRO FEATURES UNLOCKED.Box open. The key's back up in the spoiler if you skipped past it.
After#
The protection here was real work. Flattening, a decryption VM, reflection
proxying, anti-debug, and that sneaky <PrivateImplementationDetails> naming to
blind decompilers. Against a static read it holds up fine, and if I'd stayed
stubborn about doing it all on paper I'd probably still be sitting there.
But every one of those tricks exists to stop you reading the code. Not one of them stops you running it. I quit treating the binary as a document to decode and started treating it as a library to call. After that it fell apart, because the one thing the program can't hide is that it has to decrypt the answer before it can check it. So I let it, and read the answer off the console.