Skip to content

The Obfuscator Decrypted Its Own Key For Me

A .NET crackme buried under control-flow flattening, a string-decryption VM, and anti-debug. Reading it got me nowhere. So I ran it and let its own code cough up the key.

13 min read

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.

bash
file "Aegis Privacy Protocol.protected.exe"
# PE32 executable ... Intel i386 Mono/.Net assembly, 3 sections

32-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.

text
HMACSHA256   FromBase64String   ToBase64String   Marshal
RegistryKey  CreateSubKey       FileShare        dwDesiredAccess
processHandle  UnauthorizedAccessException  ConsoleKeyInfo  FailFast

Already 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.

text
#US user strings: 0 non-empty      every literal is encrypted
FieldRVA rows: 1                   one big blob mapped into .text
TypeDef: 148   MethodDef: 445

The #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.

bash
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.cs

2,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.

StagedSessionBuilder650.SealWindow266 (excerpt)
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.

csharp
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.

harness, load and reach in
var asm = Assembly.LoadFrom(path);
Find("Adaptermsj").Invoke(null, null);   // force the lazy init
// every internal method is live now

I 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.

csharp
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.

Contextivh.Sinkli, the validator table
Sinkli[0] -> String::IsNullOrEmpty(String) : Boolean
Sinkli[1] -> StagedSessionBuilder650::SealWindow266(String) : Boolean

Index 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.

Main (option 7) Main (option 7) Sinkcme(key, C, D) Sinkcme(key, C, D) Main (option 7)->Sinkcme(key, C, D) Sinkli[1]  (delegate) Sinkli[1]  (delegate) Sinkcme(key, C, D)->Sinkli[1]  (delegate) SealWindow266(key) SealWindow266(key) Sinkli[1]  (delegate)->SealWindow266(key) stack VM stack VM SealWindow266(key)->stack VM compare compare SealWindow266(key)->compare expected key expected key stack VM->expected key expected key->compare true / false true / false compare->true / false
How option 7 reaches the validator

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.

harness, call the decryptor and dump
foreach (var name in new[] { "Cachekqs", "WriteManifest53", "PackEndpoint237" })
{
    var bytes = (byte[])Find(name).Invoke(null, null);
    Console.WriteLine(name + " -> " + Encoding.ASCII.GetString(bytes));
}
text
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.

text
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.

text
SealWindow266("<the key>")             = True
SealWindow266("<the key>, lowercased") = False    (case sensitive)
SealWindow266("WRONG-KEY")             = False

Case sensitive. Then the actual program, driven for real. Pick 7, type the key.

text
   --- 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.