DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Edited on Originally published at delta1labs.com

String encryption in .NET: where the key hides and why it matters

Every .NET assembly leaks its string literals. Run strings over a DLL, or open it in any decompiler, and the connection strings, API endpoints, licence-check messages, feature flags and SQL are all sitting there in the #US heap in plain UTF-16. For an attacker, string literals are a map: "Invalid license key" is a breakpoint waiting to happen, and the method that references it is the licence check.

String encryption exists to remove that map. But the naive version — encrypt the literals, store the key, decrypt on load — solves nothing, because the key ships in the same binary as the ciphertext. The entire problem of string encryption is key handling, and that is where implementations are won or lost. Here is how Nebula.NET approaches it.

The move everyone makes first, and why it fails

The obvious design: pick an AES key at build time, encrypt every literal, drop a static decryptor in the assembly.

// The naive decryptor an attacker loves to find.
internal static class Strings
{
    private static readonly byte[] Key = { 0x8f, 0x2a, /* …32 bytes, right here… */ };
    public static string Get(int id) => Decrypt(_blobs[id], Key);
}
Enter fullscreen mode Exit fullscreen mode

This is worse than no encryption, because it adds a false sense of safety. The key is a constant in the binary. An attacker does not even need to understand your cipher: they set a breakpoint on Decrypt, run the app, and read every string as it is decrypted — or they call Strings.Get in a loop from their own harness and dump the lot. A single static key means the whole string table falls in one step. You have spent CPU to make the literals slightly less greppable and lost nothing an attacker cares about.

So the real design goals are narrower and sharper: no single key decrypts everything, no plaintext key is ever stored, and decryption is deferred so there is no one moment where the entire table is in memory.

Per-string keys derived, not stored

Nebula does not store a key. It stores, per string, the ingredients to derive one at the moment of use, and binds those ingredients to the call site so a string ripped out of context does not decrypt.

At build time each literal is encrypted with a key derived from a global seed (itself split and obfuscated across the assembly, never present as one contiguous blob) combined with a per-string value and a token that identifies the caller. At runtime the decryptor reconstructs the key from those same inputs:

// Shape of the generated runtime decryptor (names are illustrative;
// in a real build these are renamed and inlined into a cold path).
internal static string Resolve(int id, int callerToken)
{
    ref var e = ref _table[id];
    // Key is derived every call from: split seed + per-entry salt + the
    // metadata token of the method asking. No byte of it is stored.
    Span<byte> key = stackalloc byte[32];
    DeriveKey(key, SeedPart0 ^ SeedPart1, e.Salt, callerToken);
    return DecryptUtf16(e.Blob, key, e.Nonce);
}
Enter fullscreen mode Exit fullscreen mode

Two properties fall out of this. First, there is no constant to find — the key exists only for the microseconds Resolve runs, on the stack, then it is gone. Second, because callerToken feeds the derivation, a blob copied and asked for from a different method derives the wrong key and produces garbage. An attacker cannot build a generic "dump all strings" harness, because the correct plaintext depends on who is asking. They are forced back into dynamic analysis of each site.

Deferring decryption so the table is never whole

Even with derived keys, decrypting every string in a static constructor would hand an attacker one memory snapshot containing the entire plaintext table. Nebula defers: a literal is decrypted only when its specific call site runs, and the call site is rewritten to pull from the resolver rather than load a constant. In IL, a ldstr "https://api.example.com" becomes, roughly, a load of the entry id and the caller token followed by a call to the resolver:

// before
IL_000a: ldstr    "https://api.example.com"
// after (conceptually)
IL_000a: ldc.i4   0x14          // entry id
IL_000f: ldc.i4   0x0600001B    // caller token
IL_0014: call     string Nebula.Rt::Resolve(int32, int32)
Enter fullscreen mode Exit fullscreen mode

Because decryption is lazy and per-site, a string guarding a rarely hit code path is only ever plaintext if that path actually executes. There is no single breakpoint and no single snapshot that yields everything — the attacker has to drive every branch, which is exactly the cost obfuscation is meant to impose.

The honest limits

String encryption raises the floor; it does not make strings unrecoverable, and claiming otherwise is dishonest:

  • Anything decrypted can be observed. A determined attacker running your code under a debugger will see each string the moment its site executes. Encryption converts a five-second static dump into per-site dynamic work — valuable, not absolute.
  • Spend it where it counts. Encrypting every literal, including log formats and UI text, bloats the assembly and adds call overhead for no security gain. Scope it to the strings that are genuinely sensitive — endpoints, licence logic, protocol details — and leave the noise as literals. Nebula lets you scope by namespace, type or attribute for exactly this reason.
  • Pair it with the rest. On its own, string encryption still leaves the shape of your methods readable. Combined with control-flow flattening and anti-tamper, the decryptor call sites themselves become hard to reach and reason about, which is the point of running the passes together rather than one in isolation.

The free edition encrypts a small number of strings so you can see the transform and the runtime cost on your own code. The Pro edition removes the cap, adds the call-site binding and seed splitting described above, and runs the pass as part of your build so it applies on every compile rather than as a one-off.

The rule worth remembering: a string cipher is only as good as its key handling. If you can point at the key in the binary, so can an attacker — and then the ciphertext is just a slower way of shipping plaintext.

See it on your own assembly: install the CLI with dotnet tool install --global Delta1Labs.Nebula.Tool, turn on string encryption for one namespace, and compare the #US heap before and after. Read the Nebula.NET docs to get started.

Top comments (0)