Creating a .NET IL Obfuscator Using Fody

Table of Contents

In game development, keeping core logic and runtime data obscured is key—not just to protect your intellectual property, but to stay a step ahead of anyone attempting to reverse engineer or exploit your game.

While commercial obfuscators and code virtualizers exist, they often come with heavy overhead and limited flexibility for custom features. This is why creating my own obfuscation mechanisms using IL code weaving was crucial. Doing so allows me to maintain complete control over what gets obfuscated, how it’s done, and how runtime-sensitive values are protected and destroyed—without relying on a third-party solution.

With custom weavers, I can:

  1. Obfuscate nearly every aspect of the code
    • Classes, namespaces, methods, properties, fields, parameters, and local variables are all renamed systematically.
  2. Manipulate class structure for confusion
    • Reorder members safely while preserving autogenerated constructors and static initializers.
    • Introduce random fake members and functions to mislead anyone inspecting the assembly.
  3. Protect runtime-sensitive data with the assistance of generic helper classes
    • HiddenValue<T>: obscures references to sensitive class instances using clever GC (Garbage Collector) handle manipulation, preventing direct access to objects in memory.
    • EncryptedValue<T>: wraps values in a handler that handles serialization/deserialization & encryption/decryption. The specific algorithms for encryption and decryption are automatically injected at compile time via our Encryption Weaver, ensuring that each build can use randomized routines for supported types.
    • EphemeralValue<T>: wraps values in a handler that handles serialization/deserialization, encryption/decryption as well as the lifetime of the decrypted bytes and deserialized object on the stack/heap. The encryption/decryption algorithms are also injected at compile time using our Encryption Weaver.

This approach gives me a lightweight, fully controllable obfuscation pipeline that combines aggressive structural protection with runtime value security—making it far harder for hackers to reverse engineer or manipulate the game. These systems can also be extended, as discussed later in this post.

This post will focus on the “what”, “why” and challenges encountered during development for the multitude of features. If I found the implementation of a obfuscation method to be relatively straightforward, a code sample may not be provided.

Getting Started with Fody

Fody is a widely used framework for .NET that enables compile-time IL weaving, allowing developers to modify assemblies automatically as part of the build process.

Common Uses for Fody

  • Automatic Code Injection – Add boilerplate code for logging, validation, or property change notifications.
  • Aspect-Oriented Programming – Implement aspects such as method interception, exception handling, or performance tracking.
  • Custom IL Transformations – Create project-specific weavers for tasks such as injecting attributes, modifying method bodies, or generating additional helper code.

Installing Fody

To get started, import the Fody Nuget package using the package manager for the application you wish to weave into.

Next, install any pre-built code weavers into the project as well.

You’ll now notice that a FodyWeavers.xml file has been generated in the targeted project as a result of installing Fody.

<Weavers xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance" xsi:noNamespaceSchemaLocation="FodyWeavers.xsd">
  <EncryptionWeaver />
</Weavers>

You can specify multiple weavers within the Weavers tag.

Creating a Custom Weaver

If you’re going to create your own weaver like I have, you’ll also need to create a new “Class Library” project in the same solution as the target project. Rename the default class as you desire as well as import the below helper package in the weaver project.

Here’s an example weaver project layout (App is the project being weaved, EncryptionWeaver.Fody is my custom weaver):

Lastly, build the weaver and add a reference in your target project to the generated .dll as well as a project reference to the weaver project like so:

Core Features of My Obfuscation Weaver

My custom IL obfuscation weaver systematically transforms assemblies to make reverse engineering significantly more difficult. Runtime behavior is preserved throughout all of the obfuscation methods.

1. Enum Renaming

C#: Before
enum TestEnum {
    value = 1,
    value2 = 2,
    value3
}
C#: After
internal enum %V7GILNOKTE8AMTOAMTK4DOW8FS70%
{
		%REIMKPAGG2Y02% = 1,
		%UHDXYI8175U28WP8U2F4XM60IO%,
		%326SAN8K5Y0Z1IZHN2SQN1HI%
}

What it does

Renames both enum types and all of their individual values.

Effect

Makes it difficult to infer the purpose or meaning of enums, while preserving correct runtime usage.

Complexities

No major difficulties/complexities in the implementation.

2. Method Renaming

C#: Before
public class Person 
{
    public Person() { }
    public void DoSomething() { }
    public virtual void SomeAction() { }
    public virtual void SomeAction2() { }
}
C#: After
public class Person
{
		public Person(){}
		public void %L13GOYCPZHUH8%(){}
		public virtual void %G6IR3BETX7G1YV9O%(){}
		public virtual void %NPP0MF7A5PC3D7N30C2NRAIS8XWP%(){}
}

What it does

Renames all method identifiers, including instance methods, static methods, and virtual overrides.

Effect

Hinders identification of functions by requiring the attacker inspect the logic contained in the function and optionally assign their own name within their reverse engineering tools.

Complexities

There were a couple of major complexities with developing method renaming obfuscation. Below are the considerations.

What methods/functions should be skipped? There are some functions that should be skipped to preserve functionality such as Constructors, Overrides like ToString, GetHashCode & Equals as well as any implicit operators.

What about virtual methods and their overrides? When renaming methods, I should ensure to preserve the functionality of overrides and thusly assign the overrides the same random name the base method is assigned. As seen below, I strategically rename all non base methods prior to renaming their overrides if they exist.

What about references? Simply renaming the method with Fody doesn’t automatically update the IL references throughout the rest of the program. To complete that, I must manually inspect each instruction and determine if it’s a reference to the newly named method and update it if it is.

C#
/// <summary>
/// Is the specified method a type we don't wish to rename?
/// </summary>
/// <param name="method"></param>
/// <returns></returns>
private bool ShouldSkipMethod(MethodDefinition method) {
    if (method.IsConstructor) return true;
    if (method.Name == "ToString" || method.Name == "GetHashCode" || method.Name == "Equals") return true;
    if (method.Name.StartsWith("op_")) return true;
    return false;
}

// <summary>
/// Rename methods.
/// </summary>
/// <param name="module"></param>
private void RenameMethods(ModuleDefinition module) {
   //Get all types in this module.
   var types = GetAllTypes(module);

   //Get all methods that are not virtual.
   var nonVirtualMethods = types
       .SelectMany(t => t.Methods)
       .Where(m => !ShouldSkipMethod(m) && !m.IsVirtual)
       .ToList();

   //Loop all non virtual methods.
   foreach (var method in nonVirtualMethods) {
       //Pick a new name.
       string newName = GenerateUniqueName();

       //Rename references.
       UpdateAllMethodReferences(module, method, newName);

       //Set the new name of the method.
       method.Name = newName;
   }

   //Get all methods that are virtual.
   var virtualMethods = types
       .SelectMany(t => t.Methods)
       .Where(m => !ShouldSkipMethod(m) && m.IsVirtual)
       .ToList();

   //Loop all virtual methods.
   foreach (var method in virtualMethods) {
       //If it's not a new slot, don't rename it yet.
       if (!method.IsNewSlot)
           continue;

       //Pick a new name.
       string newName = GenerateUniqueName();

       //Get all methods that override this method.
       var overrideMethods = virtualMethods.Where(m => !m.IsNewSlot && m.GetBaseMethod() == method);
       foreach (var overrideMethod in overrideMethods) {
           //Rename references.
           UpdateAllMethodReferences(module, overrideMethod, newName);

           //Set the new name of the method.
           overrideMethod.Name = newName;
       }

       //Rename references.
       UpdateAllMethodReferences(module, method, newName);

       //Set the new name of the method.
       method.Name = newName;
   }
 }
 
/// <summary>
/// Update method references.
/// </summary>
/// <param name="module">Module to perform the operations on.</param>
/// <param name="method">Method we're renaming.</param>
/// <param name="newName">The new name for this method.</param>
private void UpdateAllMethodReferences(ModuleDefinition module, MethodDefinition method, string newName) {
    //Get all types.
    var types = GetAllTypes(module);

    //Loop ecah type.
    foreach (var type in types) {
        //Loop each method.
        foreach (var iMethod in type.Methods) {
            //If the indexed method doesn't have a body, we can skip it.
            if (!iMethod.HasBody)
                continue;

            //Loop each instruction.
            foreach (var instr in iMethod.Body.Instructions) {
                //Check if the operand is for a method.
                if (instr.Operand is MethodReference mr) {
                    //Resolve the method this instruciton is referencing.
                    MethodDefinition resolved = mr is MethodSpecification ms ? ms.ElementMethod.Resolve() : mr.Resolve();

                    //If it's resolved, set the new name.
                    if (resolved == method) {
                        //Handle generic types.
                        if (mr is MethodSpecification ms2)
                            ms2.ElementMethod.Name = newName;
                        //Not a generic type.
                        else
                            mr.Name = newName;
                    }
                }
            }
        }
    }
}

3. Property Renaming

C#: Before
public class Person 
{
    public int Value { get; private set; }
}
C#: After
public class Person
{
	  public int %V1BXOK9C9BGJDIR8XIW% { get; private set; }
}

What it does

Renames property names as well as their associated getters and setters. References throughout the IL are updated to match.

Effect

Obscures the purpose of properties and what data they may potentially hold.

Complexities

Are the properties virtual? If so, they need to be assigned the same names as their base property.

4. Field Renaming

C#: Before
class Program
 {
     static void Main(string[] args) {
         Console.WriteLine(Test);
         Console.ReadLine();
     }
     public static EncryptedValue<string> Test;
 }
C#: After
internal class Program
{
    private static void Main(string[] args){
			Console.WriteLine(Program.%W93089JQBNW55DI6GBQ%);
			Console.ReadLine();
		}
		public static EncryptedValue<string> %W93089JQBNW55DI6GBQ%;
}

What it does

Renames all fields, both instance and static, with no exceptions.

Effect

Hinders attackers from determining the purpose of fields.

Complexities

What about references? Same as with methods – simply renaming a field doesn’t necessarily update all IL references. I need to do that manually.

C#
/// <summary>
/// Rename fields.
/// </summary>
/// <param name="module"></param>
private void RenameFields(ModuleDefinition module) {
    //Get all types.
    var types = GetAllTypes(module);

    //Loop each type.
    foreach (var type in types) {
        //Enums handled elsewhere.
        if (type.IsEnum)
            continue;

        //Loop each field.
        foreach (var field in type.Fields) {
            //Skip compiler-generated and special names (e.g. backing fields, .cctor stuff).
            if (field.Name.StartsWith("<") || field.IsSpecialName)
                continue;

            //Pick a new name.
            string newName = GenerateUniqueName();

            //Update references.
            UpdateAllFieldReferences(module, field, newName);

            //Set the name.
            field.Name = newName;
        }
    }
}

/// <summary>
/// Update field references.
/// </summary>
/// <param name="module">Module to perform the operations on.</param>
/// <param name="field">Field we're renaming.</param>
/// <param name="newName">The new name for this field.</param>
private void UpdateAllFieldReferences(ModuleDefinition module, FieldDefinition field, string newName) {
    //Get all types.
    var types = GetAllTypes(module);

    //Loop each type.
    foreach (var type in types) {
        //Loop each method.
        foreach (var iMethod in type.Methods) {
            //If the indexed method doesn't have a body, skip.
            if (!iMethod.HasBody)
                continue;

            //Loop each instruction.
            foreach (var instr in iMethod.Body.Instructions) {
                //Check if the operand is for a field.
                if (instr.Operand is FieldReference fr) {
                    //Resolve the field this instruction is referencing.
                    FieldDefinition resolved = fr.Resolve();

                    //If it's resolved and is the field being renamed, set the new name.
                    if (resolved == field) {
                        fr.Name = newName;
                    }
                }
            }
        }
    }
}

5. Parameter and Local Variable Renaming

C#: Before
private void Set(T value) {
    lock (_lock) {
        GCHandle handle = GCHandle.Alloc(value, GCHandleType.Normal);

        _handle = handle;
    }
}
C#: After
private void Set(T %BC6I5XML9ADQD9IA9%){
			lock (this._lock){
				GCHandle %9B9SL72A0297UWN2T7FE% = GCHandle.Alloc(%BC6I5XML9ADQD9IA9%, GCHandleType.Normal);
				
				this._handle = %9B9SL72A0297UWN2T7FE%;
			}
}

What it does

Renames all method parameters and local variables, skipping compiler-generated identifiers.

Effect

Obscures internal method logic, making it harder to reverse engineer variable roles or data flow.

Complexities

No major difficulties/complexities in the implementation.

6. Class Obfuscation

C#: Before
class Program 
{
    static void Main(string[] args)
    {
        Console.WriteLine(Test);
        Console.ReadLine();
    }
    public static EncryptedValue<string> Test;
}
C#: After
internal class %UPSF8ILX3FY8JR604PKUAJIL47Z%
{
		private static void Main(string[] args)
		{
			Console.WriteLine(%UPSF8ILX3FY8JR604PKUAJIL47Z%.Test);
			Console.ReadLine();
		}
		public static %F8F4HWM8DHN7YO22CL%<string> Test;
}

What it does

Renames all classes, including nested types.

Effect:

Conceals the overall structure and relationships of types within the assembly.

Complexities

No major difficulties/complexities in the implementation.

7. Namespace Renaming

What it does

Renames namespaces globally, with a probabilistic approach where existing obfuscated namespaces are reused more frequently the more classes that there is in a program. Functions even when classes change namespaces like the above instance of HiddenValue and EncryptedValue no longer being in the same namespace.

Effect

Masks assembly organization, making it more difficult to map types to functional modules.

Complexities

No major difficulties/complexities in the implementation.

8. Class Member Reordering

C#: Before
class Program 
{
    public static EncryptedValue<string> Str1 = "1";
    public static EncryptedValue<string> Str2 = "2";
    public static EncryptedValue<string> Str3 = "3";
    public static EncryptedValue<string> Str4 = "4";
    public static EncryptedValue<string> Str5 = "5";
}
C#: After
internal class Program
{
		public static EncryptedValue<string> Str2 = "2";
		public static EncryptedValue<string> Str4 = "4";
		public static EncryptedValue<string> Str5 = "5";
		public static EncryptedValue<string> Str3 = "3";
		public static EncryptedValue<string> Str1 = "1";
}

What it does

Reorders all fields within a class while preserving constructor and static initializer behavior.

Effect

Obscures the logical order of members, disrupting assumptions made by anyone inspecting the IL. This is useful to throw off “offsets” used in cheats. This is particularly useful for requiring extra effort to be exerted by requiring hackers to update their cheat offsets for each deployment of your game, even if the core structure of the game class doesn’t change.

Complexities

True field order is shown in the constructor IL, moving fields breaks DnSpy initializers. To resolve this, I must update the order of the IL instructions in the constructor which is quite dangerous. (This is just a prototype for this feature, additional IL modification may be required).

C#
/// <summary>
/// Reorders all members (fields and properties) of all types within the provided module.
/// This method serves as the entry point for member reordering, 
/// invoking the recursive reordering function on each top-level type.
/// </summary>
/// <param name="module">The module whose types' members will be reordered.</param>
private void ReorderMembers(ModuleDefinition module) {
    foreach (var type in module.Types)
        ReorderMembersRecursive(type);
}

/// <summary>
/// Recursively reorders all fields (both instance and static) and properties of a type, 
/// including any nested types, in order to obfuscate member layout. 
/// 
/// This method preserves the runtime semantics of the type by carefully capturing and 
/// reinserting field initialization instructions from constructors. Both instance fields 
/// (initialized in instance constructors `.ctor`) and static fields (initialized in 
/// static constructors `.cctor`) are handled. Instructions are shuffled along with their 
/// corresponding fields to ensure that initialization code remains correct and valid.
/// 
/// Key points:
/// 1. Skips interfaces and enums because they cannot contain instance fields that require 
///    reordering.
/// 2. Excludes literal (constant) fields.
/// 3. Shuffles fields randomly to prevent predictable memory layout, which aids obfuscation.
/// 4. Captures initialization instructions for each field in constructors:
///    - For instance fields (`stfld`), captures instructions up to the `ldarg.0` (the `this` reference).
///    - For static fields (`stsfld`), captures all instructions until another field assignment or `ret`.
///    - Includes complex initializations such as method calls (e.g., implicit operators) and new object creation.
/// 5. Removes original initialization instructions from constructors before reinserting them 
///    in the new, shuffled order to maintain correct runtime execution.
/// 6. Updates the order of `TypeDefinition.Fields` to match the new randomized sequence, ensuring 
///    reflection APIs see the new order.
/// 7. Randomly reorders properties independently to further obfuscate type layout.
/// 8. Recursively processes nested types to ensure deep obfuscation throughout the assembly.
///
/// Important considerations:
/// - The function preserves the relationship between a field and its initialization instructions, 
///   preventing runtime errors even for fields with complex initializers.
/// - Both instance and static constructors are modified automatically, so the method works 
///   for types with any combination of field types.
/// - Readonly (`initonly`) fields are left intact in terms of their IL, so the method does not 
///   break compiler-enforced immutability semantics.
///
/// <param name="type">The TypeDefinition object representing the type whose fields and properties
/// are to be reordered and whose nested types will also be processed recursively.</param>
private void ReorderMembersRecursive(TypeDefinition type) {
    //Skip enums + interfaces.
    if (type.IsInterface || type.IsEnum)
        return;

    // --- Reorder Fields ---
    if (type.HasFields) {
        //Include all fields except literal constants
        var fields = type.Fields.Where(f => !f.IsLiteral).ToList();

        //Randomly shuffle the field list for obfuscation purposes
        var shuffled = fields.OrderBy(x => _rand.Next()).ToList();

        // --- Capture initialization instructions for each field ---
        // This dictionary maps constructors to each field and its corresponding IL instruction sequence
        var ctorFieldInstrs = new Dictionary<MethodDefinition, Dictionary<FieldDefinition, List<Instruction>>>();

        foreach (var ctor in type.Methods.Where(m => m.IsConstructor && m.HasBody)) {
            var body = ctor.Body;
            var instructions = body.Instructions.ToList();
            var fieldInstrs = new Dictionary<FieldDefinition, List<Instruction>>();

            for (int i = 0; i < instructions.Count; i++) {
                var instr = instructions[i];

                if ((instr.OpCode == OpCodes.Stfld || instr.OpCode == OpCodes.Stsfld) &&
                    instr.Operand is FieldDefinition field && fields.Contains(field)) {
                    //Capture all instructions contributing to the field initialization
                    var seq = new List<Instruction>();
                    int j = i;

                    //Include the field assignment itself
                    seq.Insert(0, instructions[j]);
                    j--;

                    while (j >= 0) {
                        var prev = instructions[j];

                        //Stop capturing if we reach another field assignment or a return instruction
                        if (prev.OpCode == OpCodes.Stfld || prev.OpCode == OpCodes.Stsfld || prev.OpCode == OpCodes.Ret)
                            break;

                        seq.Insert(0, prev);

                        //Stop at 'ldarg.0' for instance fields only to avoid capturing unrelated instructions
                        if (instr.OpCode == OpCodes.Stfld && prev.OpCode == OpCodes.Ldarg_0)
                            break;

                        j--;
                    }

                    fieldInstrs[field] = seq;
                }
            }

            ctorFieldInstrs[ctor] = fieldInstrs;
        }

        //--- Remove old field initialization instructions from constructors ---
        foreach (var ctorEntry in ctorFieldInstrs) {
            foreach (var seq in ctorEntry.Value.Values)
                foreach (var instr in seq)
                    ctorEntry.Key.Body.Instructions.Remove(instr);
        }

        //--- Insert shuffled field instructions into constructors ---
        foreach (var ctorEntry in ctorFieldInstrs) {
            int insertIndex = 0;
            var body = ctorEntry.Key.Body;

            foreach (var field in shuffled) {
                if (ctorEntry.Value.TryGetValue(field, out var seq)) {
                    foreach (var instr in seq) {
                        body.Instructions.Insert(insertIndex, instr);
                        insertIndex++;
                    }
                }
            }
        }

        //--- Update the FieldDefinition order in the type to match the shuffled order ---
        type.Fields.Clear();
        foreach (var f in shuffled)
            type.Fields.Add(f);
    }

    //--- Reorder Properties Randomly ---
    if (type.HasProperties) {
        var shuffledProps = type.Properties.OrderBy(x => _rand.Next()).ToList();
        type.Properties.Clear();
        foreach (var p in shuffledProps)
            type.Properties.Add(p);
    }

    //--- Recursively process nested types ---
    foreach (var nested in type.NestedTypes)
        ReorderMembersRecursive(nested);
}

9. Fake Member Injection

C#: Before
class Program {
    public static EncryptedValue<string> Str1 = "1";
    public static EncryptedValue<string> Str2 = "2";
    public static EncryptedValue<string> Str3 = "3";
    public static EncryptedValue<string> Str4 = "4";
    public static EncryptedValue<string> Str5 = "5";
}
C#: After
internal class Program
{
		public static EncryptedValue<string> Str4_dummy1 = "4";
		public static EncryptedValue<string> Str3 = "3";
		public static EncryptedValue<string> Str5 = "5";
		public static EncryptedValue<string> Str2 = "2";
		public static EncryptedValue<string> Str3_dummy0 = "3";
		public static EncryptedValue<string> Str4_dummy2 = "4";
		public static EncryptedValue<string> Str2_dummy0 = "2";
		public static EncryptedValue<string> Str3_dummy2 = "3";
		public static EncryptedValue<string> Str2_dummy1 = "2";
		public static EncryptedValue<string> Str3_dummy1 = "3";
		public static EncryptedValue<string> Str1 = "1";
		public static EncryptedValue<string> Str4 = "4";
		public static EncryptedValue<string> Str5_dummy0 = "5";
		public static EncryptedValue<string> Str4_dummy0 = "4";
	}

The above results also had member reordering enabled for clarity. This should be combined with field renaming to not make it obvious which are dummies, I left it disabled for clarity.

Effect

Adds decoy members to mislead reverse engineers, making it difficult to distinguish real members from fake ones while maintaining runtime stability.

What it does

Introduces fully functional fake fields and methods, including clones of any initializers from real members.

Complexities

What about copying initializers from the real member? Yes, this is critical. If I inject a bunch of dummy members but the real member has an initializer, it will be blatantly obvious which fields are fake.

Can’t reverse engineers just find the one without xrefs? Yes, you should combine Fake Member Injection with duplicated functions.

Obscuring code layout is only half the battle — sensitive values and object references at runtime are a second attack surface. My Encryption weaver addresses this with three cooperating generic wrappers: EncryptedValue<T> (encrypt-at-rest), HiddenValue<T> (obscure references) and EphemeralValue<T> (encrypt-at-rest + immediate destruction after use).

Together they minimize plaintext values, hide pointers, and dramatically increase the noise an attacker must sift through when dumping memory. The section below explains how each wrapper behaves.

EncryptedValue<T> & EphemeralValue<T> are written to be inherited. My weaver will supply each child that inherits from these wrappers with their own set of encrypt/decrypt functions. Meaning, you can introduce further entropy by creating a separate EncryptedValue<T>/EphemeralValue<T> inside each class you want extra protection on.

EncryptedValue<T> — encrypt-at-rest

Purpose

Protect values stored in fields or statics by keeping them encrypted at rest and forcing any write to go through the class’s encryption path. This prevents straightforward memory inspection or naïve in-memory edits from producing valid decrypted values.

Implementation / Behavior

  • Set serializes the provided value via a SerializationFactory (type-checked by IsTypeSupported), encrypts the resulting byte array, and stores that encrypted blob in a private field.
  • Get decrypts the stored byte array, deserializes it back to the correct type, and returns the result.
  • Thread-safety is enforced with a lock around both get and set to avoid races and inconsistent reads/writes.
  • The class exposes placeholder Encrypt / Decrypt methods; the weaver injects concrete, per-build randomized implementations at compile time so each build can use different routines and the plaintext/encryption lifecycle is not trivially reproducible across builds.
  • Convenience features such as implicit operators and ToString() forwarders make the wrapper easy to use in code while still funneling access through the encrypted storage.

Memory visibility & lifetime

  • Reading Value necessarily produces a decrypted instance of T. For reference types (e.g., string, arrays, complex objects) that instance is allocated on the managed heap and will remain reachable until it goes out of scope and the GC can collect it. For value types, the decrypted copy may live on the stack or be boxed to the heap depending on how it’s used.
  • Temporary buffers used during fetches — I manually destroy the contents of the decrypted byte array on the heap after serializing the bytes to a result. Even though the Value may still be on the heap/stack, the byte array from evaluation won’t be.
  • The encrypted blob stored in the EncryptedValue<T> field itself appears as random-looking bytes in memory, offering no obvious visual cues that would identify the contained logical value.

Practical effect for attackers

  • A memory dump captured during or shortly after a Value read often contains plaintext, but that plaintext is one among many similar allocations and temporary buffers; locating which plaintext corresponds to the logical variable an attacker cares about requires correlation work (execution tracing, repeated dumps, or instrumentation).
  • Directly writing a value into the encrypted field in memory is ineffective: an attacker would have to (a) locate the correct encrypted blob, (b) produce the exact serialized byte representation used by SerializationFactory for that type, and (c) apply the exact encryption routine injected into the build—without access to those routines this is extremely difficult. Per-build randomization of encryption routines increases this difficulty further.
  • In short, the wrapper turns a single straightforward edit (patch this address to X) into a multi-step problem (find, serialize correctly, encrypt correctly, and place the result) that is noisy and error-prone.

Best practices & caveats

  • Minimize decrypted lifetime. Decrypt only in the narrowest possible scope, use the value immediately, and avoid storing the decrypted instance in long-lived structures.
  • Combine with detection: pair encrypt-at-rest with tamper/hook detection and runtime integrity checks to make exploitation noisy and detectable.
  • Accept the managed-runtime reality: decrypted instances must exist while used. EncryptedValue<T> obscures at-rest data and raises the bar for manipulation, but it cannot eliminate transient plaintext values while the program legitimately uses the value.

Configuration for the Encryption Weaver

C#
/// <summary>
/// Minimum and maximum number of operations to inject into a method.
/// </summary>
private const int OpsMinCount = 20;
private const int OpsMaxCount = 50;

/// <summary>
/// Minimum and maximum number of operations per "group" in Op generated methods.
/// </summary>
private const int GroupSizeMin = 5;
private const int GroupSizeMax = 30;

/// <summary>
/// Minimum and maximum number of local variables to introduce into Op generated methods.
/// </summary>
private const int LocalVarMin = 5;
private const int LocalVarMax = 10;

/// <summary>
/// Minimum and maximum values for operands of most operations.
/// </summary>
private const int OperandMin = 10000;
private const int OperandMax = 999999;

Example result of the generated encryption + decryption routines (symmetric of one another)

Note: For production, one should advance the weaver to spread the instructions of these routines across multiple functions, inject dead functions/instructions, introduce loops to the routines to make it even further difficult to analyze instead of having a one-stop copy + paste ready routine that attackers can locate and duplicate.

C#
  public static byte Op(long b)
	{
		long num = b - 414613L - 99791L;
		long num2 = (long)(~(long)(~(long)(((ulong)(num & 255L) >> 1 | (ulong)((ulong)num << 8 - 1)) - 718364UL ^ 120941UL)));
		long num3 = (num2 << 6 | (long)((ulong)(num2 & 255L) >> 8 - 6)) + 103607L + 817371L + 942873L ^ 877800L;
		num2 = num3;
		num3 = ~(~((num2 << 7 | (long)((ulong)(num2 & 255L) >> 8 - 7)) + 228228L - 155645L ^ 721338L) - 140712L);
		num = ~(num3 ^ 910971L);
		long num4 = (long)(~(long)(~(long)((((ulong)(num & 255L) >> 1 | (ulong)((ulong)num << 8 - 1)) ^ 322073UL) - 220708UL - 372235UL - 324261UL ^ 475700UL)));
		long num5 = ~(num4 << 6 | (long)((ulong)(num4 & 255L) >> 8 - 6));
		num3 = (num5 << 3 | (long)((ulong)(num5 & 255L) >> 8 - 3));
		num3 = (long)((ulong)(num3 & 255L) >> 1 | (ulong)((ulong)num3 << 8 - 1));
		long num6 = num3 - 873828L;
		num4 = (long)(~(long)((ulong)(num6 & 255L) >> 4 | (ulong)((ulong)num6 << 8 - 4)));
		num2 = (long)(~((ulong)(num4 & 255L) >> 1 | (ulong)((ulong)num4 << 8 - 1)) + 404135UL ^ 250279UL);
		num3 = (long)(~(long)(~(long)(~(~((ulong)(num2 & 255L) >> 6 | (ulong)((ulong)num2 << 8 - 6))) - 426762UL + 879282UL + 519837UL ^ 592443UL)));
		return (byte)(num3 & 255L);
	}

	// Token: 0x06000025 RID: 37 RVA: 0x00002E54 File Offset: 0x00001054
	public static byte Op(long b)
	{
		long num = ~(~((~(~b) ^ 592443L) - 519837L - 879282L + 426762L));
		long num2 = ~(((num << 6 | (long)((ulong)(num & 255L) >> 8 - 6)) ^ 250279L) - 404135L);
		long num3 = ~(num2 << 1 | (long)((ulong)(num2 & 255L) >> 8 - 1));
		long num4 = (num3 << 4 | (long)((ulong)(num3 & 255L) >> 8 - 4)) + 873828L;
		num = (num4 << 1 | (long)((ulong)(num4 & 255L) >> 8 - 1));
		long num5 = num;
		long num6 = (long)(~(long)((ulong)(num5 & 255L) >> 3 | (ulong)((ulong)num5 << 8 - 3)));
		num = (long)(~(~((ulong)(num6 & 255L) >> 6 | (ulong)((ulong)num6 << 8 - 6))) ^ 475700UL);
		long num7 = num + 324261L + 372235L + 220708L ^ 322073L;
		num7 = (~(~(~(num7 << 1 | (long)((ulong)(num7 & 255L) >> 8 - 1)) ^ 910971L) + 140712L) ^ 721338L) + 155645L - 228228L;
		num5 = (long)((((ulong)(num7 & 255L) >> 7 | (ulong)((ulong)num7 << 8 - 7)) ^ 877800UL) - 942873UL - 817371UL - 103607UL);
		num = (long)(~(long)((ulong)(num5 & 255L) >> 6 | (ulong)((ulong)num5 << 8 - 6)));
		num2 = (~num ^ 120941L) + 718364L;
		num = (num2 << 1 | (long)((ulong)(num2 & 255L) >> 8 - 1)) + 99791L + 414613L;
		return (byte)(num & 255L);
	}

Implementation of the EncryptedValue<T> Wrapper

C#
/// <summary>
/// Wrapper for encrypting a value type in memory.
/// 
/// Getting the value returns a live instance of the object that may get pushed onto
/// the heap/stack depending on usage.
/// 
/// Alternatives:
///     For strings/sensitive value types, use EphemeralValue - this wrapper will help aid the control of the decrypted value on the stack/heap.
///     
///     For reference types, use HiddenValue - this wrapper will obscure access using an encrypted GCHandle.
/// </summary>
/// <typeparam name="T"></typeparam>
public class EncryptedValue<T> where T : struct {
    #region Globals
    /// <summary>
    /// Encrypted bytes.
    /// </summary>
    private byte[] _encBytes;

    /// <summary>
    /// Lock for accessing the encrypted data.
    /// </summary>
    private readonly object _lock = new object();

    /// <summary>
    /// Getter/Setter.
    /// </summary>
    public T Value {
        get => Get();
        set => Set(value);
    }
    #endregion

    #region Constructor
    /// <summary>
    /// Parameterless constructor.
    /// </summary>
    public EncryptedValue() { }

    /// <summary>
    /// Default constructor with value specified.
    /// </summary>
    /// <param name="value"></param>
    public EncryptedValue(T value) => Value = value;
    #endregion

    #region Methods - Getters/Setters
    /// <summary>
    /// Getter for the decrypted value.
    /// </summary>
    /// <returns></returns>
    private T Get() {
        //Create an array for the bytes we're working with.
        byte[] bytes = { };

        //Obtain the lock and copy the encrypted bytes.
        lock (_lock)
            bytes = (byte[])_encBytes.Clone();

        //Decrypt
        Decrypt(bytes);

        //Get the value.
        T result = Deserialize(bytes);

        //Clear the cloned byte array from memory which now contains decrypted bytes.
        MemoryCleaner.Destroy(bytes);

        //Return the result
        return result;
    }

    /// <summary>
    /// Setter for the encrypted value.
    /// </summary>
    /// <param name="value"></param>
    private void Set(T value) {
        //Serialize the object to a byte array.
        byte[] bytes = Serialize(value);

        //Encrypt the bytes.
        Encrypt(bytes);

        //Save the result.
        lock (_lock)
            _encBytes = bytes;
    }
    #endregion

    #region Methods - Serialization
    /// <summary>
    /// Serialize value to bytes.
    /// </summary>
    private byte[] Serialize(T value) {
        //Validate this type is supported.
        if (!SerializationFactory.IsTypeSupported(typeof(T)))
            throw new NotImplementedException();

        //Return the result.
        return SerializationFactory.Serialize(value);
    }

    /// <summary>
    /// Deserialize bytes back into T.
    /// </summary>
    private T Deserialize(byte[] data) {
        //Validate this type is supported.
        if (!SerializationFactory.IsTypeSupported(typeof(T)))
            throw new NotImplementedException();

        //Return the result.
        return SerializationFactory.Deserialize<T>(data);
    }
    #endregion

    #region Methods - Encrypt/Decrypt
    /// <summary>
    /// Placeholder encrypt routine.
    /// 
    /// Encryption routines will be injected at complile time with Fody.
    /// </summary>
    protected virtual void Encrypt(byte[] decrypted) { }

    /// <summary>
    /// Placeholder decrypt routine.
    /// 
    /// Decryption routines will be injected at compile time with Fody.
    /// </summary>
    protected virtual void Decrypt(byte[] encrypted) { }
    #endregion

    #region Methods - ToString
    /// <summary>
    /// Pass to string rquest to the value ToString.
    /// </summary>
    /// <returns></returns>
    public override string ToString() => Value.ToString();
    #endregion

    #region Implicit Operators - Getters
    /// <summary>
    /// Implicit getter for the decrypted value.
    /// </summary>
    /// <param name="value"></param>
    public static implicit operator T(EncryptedValue<T> value) => value == null || value == default ? default : value.Value;
    #endregion

    #region Implicit Operators - Setters
    /// <summary>
    /// Implicit constructor for creating a new encrypted value.
    /// </summary>
    /// <param name="value"></param>
    public static implicit operator EncryptedValue<T>(T value) => new EncryptedValue<T>(value);
    #endregion
}

EphemeralValue<T> (encrypt-at-rest + immediate destruction after use)

Similar to EncryptedValue but explicitly controls the lifetime of the decrypted data through the use of callbacks to help reduce the likelihood the decrypted data remains on the stack/heap after use is completed (assuming the callback doesn’t create a new boxed copy itself or call APIs that do so).

C#
/// <summary>
/// Wrapper for greatly obscuring a reference type in memory through encryption and very limited
/// unecnrypted access using callbacks that destroy the value after the use callback completes.
/// 
/// This wrapper combines encryption, pointer obscuring to the stored encrypted value bytes and explicitly
/// controls the lifetime that the decrypted value and bytes remains on the stack/heap.
/// 
/// Alternatives:
///     For value types, use EncryptedValue - this wrapper will encrypt the value in memory.
///     
///     For hidden values that can be leaked to the stack/heap, use HiddenValue - this 
///     wrapper will obscure access using an encrypted GCHandle.
/// </summary>
/// <typeparam name="T"></typeparam>
public class EphemeralValue<T> {
    #region Globals
    /// <summary>
    /// Backing bytes.
    /// </summary>
    private HiddenValue<byte[]> _encBytes = new byte[0];

    /// <summary>
    /// Lock for accessing the encrypted data.
    /// </summary>
    private readonly object _lock = new object();
    #endregion

    #region Constructor
    /// <summary>
    /// Parameterless constructor.
    /// </summary>
    public EphemeralValue() => ValidateType();

    /// <summary>
    /// Default constructor.
    /// </summary>
    /// <param name="value">Value to use.</param>
    /// <param name="eraseOrigin">Should we attempt to free the original value from memory?</param>
    public EphemeralValue(T value, bool eraseOrigin = true) {
        //Validate the type.
        ValidateType();

        //Set the value.
        Set(value, eraseOrigin);
    }

    /// <summary>
    /// Validate the type is supported.
    /// 
    /// For it to be supported, we must be able to serialize it and have a destroying method defined.
    /// </summary>
    private void ValidateType() {
        if (!SerializationFactory.IsTypeSupported(typeof(T)) || !MemoryCleaner.IsSupported(typeof(T)))
            throw new NotImplementedException();
    }
    #endregion

    #region Methods - Setters
    /// <summary>
    /// Set the value.
    /// </summary>
    /// <param name="value">Value to assign.</param>
    /// <param name="eraseOrigin">Should we attempt to free the original value from memory?</param>
    private void Set(T value, bool eraseOrigin = true) {
        //Serialize the value.
        byte[] bytes = Serialize(value);

        //Encrypt it.
        Encrypt(bytes);

        //Save the encrypted bytes.
        _encBytes = bytes;

        //If we're erasing the origin object, do so now.
        if (eraseOrigin)
            MemoryCleaner.Destroy(ref value);
    }
    #endregion

    #region Methods - Getters
    /// <summary>
    /// Getter for the decrypted value.
    /// 
    /// Disposes of the decrypted bytes and the deserialized object as soon as the callback is completed.
    /// </summary>
    /// <param name="cb"></param>
    public void Get(Action<T> cb) {
        //Validate
        if (cb == null || _encBytes == null) {
            cb(default);
            return;
        }

        //Get a clone of the encrypted bytes.
        byte[] bytes = { };
        lock (_lock)
            bytes = (byte[])_encBytes.Value.Clone();
        if (bytes.Length < 1) {
            cb(default);
            return;
        }

        //Decrypt the bytes.
        Decrypt(bytes);

        try {
            //Deserialize it.
            T deserialized = Deserialize(bytes);

            //Perform the callback.
            try {
                cb(deserialized);
            } finally {
                MemoryCleaner.Destroy(ref deserialized);
            }
        } finally {
            //Forcefully destroy the bytes on the heap.
            MemoryCleaner.Destroy(bytes);
        }
    }
    #endregion

    #region Methods - Serialization
    /// <summary>
    /// Serialize value to bytes.
    /// </summary>
    private byte[] Serialize(T value) {
        //Validate this type is supported.
        if (!SerializationFactory.IsTypeSupported(typeof(T)))
            throw new NotImplementedException();

        //Return the result.
        return SerializationFactory.Serialize(value);
    }

    /// <summary>
    /// Deserialize bytes back into T.
    /// </summary>
    private T Deserialize(byte[] data) {
        //Validate this type is supported.
        if (!SerializationFactory.IsTypeSupported(typeof(T)))
            throw new NotImplementedException();

        //Return the result.
        return SerializationFactory.Deserialize<T>(data);
    }
    #endregion

    #region Methods - Encrypt/Decrypt
    /// <summary>
    /// Placeholder encrypt routine.
    /// 
    /// Encryption routines will be injected at complile time with Fody.
    /// </summary>
    protected virtual void Encrypt(byte[] decrypted) { }

    /// <summary>
    /// Placeholder decrypt routine.
    /// 
    /// Decryption routines will be injected at compile time with Fody.
    /// </summary>
    protected virtual void Decrypt(byte[] encrypted) { }
    #endregion

    #region Implicit Operators - Setters
    /// <summary>
    /// Implicit constructor for creating a new EphemeralValue.
    /// </summary>
    /// <param name="value"></param>
    public static implicit operator EphemeralValue<T>(T value) => new EphemeralValue<T>(value);
    #endregion
}

HiddenValue<T> — obscure references

Purpose

Store an indirect, encrypted reference to a sensitive object instead of a direct field reference. The goal is to deny attackers a simple pointer chain to follow in memory and to force extra steps (and potential race conditions) before a target instance object can be resolved.

Implementation / Behavior

  • Set(T value) allocates a GCHandle (type Normal) for the target object and stores that GCHandle inside an EncryptedValue<GCHandle>.
  • Get() decrypts the EncryptedValue<GCHandle> to recover the GCHandle and then reads its Target property to return the live object reference.
  • Access is synchronized with a lock to ensure thread-safety.
  • A finalizer (destructor) calls a cleanup routine to free the GCHandle if it still exists, preventing leaked handles.

Memory visibility & lifetime

  • No plain object reference is stored in a field of the encapsulating class; instead the encrypted handle is the persistent field..
  • Calling the cleanup routine (or letting the finalizer run) frees the handle, removing that explicit reference; it does not retroactively erase any decrypted objects or temporary buffers that were already allocated while the object was in use.

Practical effect for attackers

  • Attackers can no longer rely on simple pointer arithmetic or static offsets (e.g., “Class A starts at address X and has a field of class B at 0x10, so class B has a pointer at X + 0x10”) to reach related objects, because the reference is hidden and encrypted.
  • To reach the true object they must: locate the HiddenValue instance, decrypt the EncryptedValue<GCHandle> correctly, reconstruct or interpret the resulting handle, and then resolve the handle’s Target, likely through recreation of critical GC routines — all while coping with GC behavior (objects can move, lifetimes can change), which can introduce race conditions between handle resolution and object relocation or reclamation.

Best practices & caveats

  • Minimize the window in which the handle is decrypted and the Target is used. Keep the resolved reference scoped and short-lived.
  • Always free handles promptly (prefer explicit disposal over relying on the finalizer) to reduce the time the object is kept reachable.
C#
public class HiddenValue<T> {
    #region Globals
    /// <summary>
    /// Public access.
    /// </summary>
    public T Value {
        get => Get();
        set => Set(value);
    }

    /// <summary>
    /// Handle for the hidden object.
    /// </summary>
    private EncryptedValue<GCHandle> _handle;

    /// <summary>
    /// Lock for accessing the hidden value across multiple threads.
    /// </summary>
    private readonly object _lock = new object();
    #endregion

    #region Constructors
    /// <summary>
    /// Default constructor.
    /// </summary>
    /// <param name="value"></param>
    public HiddenValue(T value) => Value = value;
    #endregion

    #region Deconstructors
    ~HiddenValue() => FreeHandle();
    #endregion

    /// <summary>
    /// Free the handle for this hidden value if one is allocated.
    /// </summary>
    private void FreeHandle() {
        lock (_lock) {
            //Get the handle.
            GCHandle handle = _handle;

            //If the handle isn't set, we can't possibly free it.
            if (handle == default)
                return;

            //Free the decrypted handle.
            handle.Free();
        }
    }

    /// <summary>
    /// Get the object this hidden value is for.
    /// </summary>
    /// <returns></returns>
    private T Get() {
        lock (_lock) {
            //Get the handle.
            GCHandle handle = _handle;
    
            //Return default.
            if (handle == default)
                return default;
    
            //Return the value.
            return (T)handle.Target;
        }
    }

    /// <summary>
    /// Set the hidden value.
    /// </summary>
    /// <param name="value"></param>
    private void Set(T value) {
        lock (_lock) {
            //Allocate a handle to the value.
            GCHandle handle = GCHandle.Alloc(value, GCHandleType.Normal);

            //Save the handle.
            _handle = handle;
        }
    }

    #region Implicit Operators - Getters
    /// <summary>
    /// Implicit getter for the hidden value.
    /// </summary>
    /// <param name="value"></param>
    public static implicit operator T(HiddenValue<T> value) => value.Value;
    #endregion

    #region Implicit Operators - Setters
    /// <summary>
    /// Implicit constructor for creating a new hidden value.
    /// </summary>
    /// <param name="value"></param>
    public static implicit operator HiddenValue<T>(T value) => new HiddenValue<T>(value);
    #endregion
}

Why Build a Custom Obfuscator

Creating my own IL obfuscation pipeline with Fody wasn’t just an academic exercise — it now offers me a solid framework for obfuscation in any of my future projects. Commercial obfuscators and code virtualizers often impose heavy performance costs, restrict flexibility, or lack support for the precise features I needed. By writing my own weavers, I gained:

  • Full control over which elements are obfuscated and how.
  • Extensibility to add further custom features.
  • Lightweight integration into the build process without unnecessary bloat.

What I Achieved

Throughout the project, I built a two-pronged defense:

  1. Structural Obfuscation (Renamer Weaver)
    • Obfuscated enums, classes, methods, properties, fields, parameters, and local variables.
    • Randomized namespaces with probabilistic reuse to confuse patterns.
    • Reordered class members while preserving correct initialization order.
    • Injected fake members to pollute decompilation output and distract reverse engineers.
  2. Runtime Value Protection (Encryption Weaver)
    • EncryptedValue<T> ensures fields are encrypted-at-rest, with decryption routines woven at build time for per-build variability.
    • HiddenValue<T> obscures references by hiding them behind encrypted GCHandles, forcing attackers to resolve multiple layers before reaching the target object.
    • EphemeralValue<T> encrypts data and helps aid the lifetime of the decrypted objects on the stack/heap using callback access mechanisms.

Together, these systems create an obfuscation pipeline that doesn’t just rename symbols — it also helps secure value integrity in memory.


Limitations to Acknowledge

Despite these protections, it’s important to remain realistic about what obfuscation can and cannot do:

  • No defense is absolute — a determined attacker with memory inspection tools and enough time can eventually trace decrypted data or bypass obfuscation by injecting a module into the protected process.
  • Maintenance costs — custom weavers must be updated when frameworks, compilers, or project codebases evolve. Unlike a commercial obfuscator, there’s no vendor maintaining compatibility for us.

These limitations are not failures — they are trade-offs to raising the cost and effort required for reverse engineering.


The Practical Effect

The end result is not unbreakable security, but friction. An attacker can no longer simply decompile assemblies and read names like PlayerHealth or GameManager. Instead, they face:

  • Randomized, meaningless identifiers.
  • Fake members muddying the codebase.
  • Runtime encryption that prevents straightforward memory scanning.
  • Layered decryption steps that introduce race conditions and complexity.

This added effort often turns opportunistic attacks into impractical ones, discouraging all but the most determined reverse engineers.