DEV Community

Zero Heartbeat
Zero Heartbeat

Posted on Edited on Originally published at delta1labs.com

Obfuscating Unity Game Assemblies: Mono, IL2CPP, and the Keep-Rules Serialization Demands

Ship a Unity game built on the Mono scripting backend and you have, in effect, published your source code. The Assembly-CSharp.dll sitting in your player's Managed folder is ordinary .NET IL, and a decompiler turns it back into readable C# in seconds — your combat formulas, your anti-cheat, your in-app-purchase validation, your secret URLs, all right there. Obfuscation is the standard answer, but Unity is the one .NET environment where naively renaming everything will break your game. Unity leans harder on name-based serialization and reflection than almost any other framework, and the obfuscator has to be told what not to touch.

This post covers what each Unity backend actually exposes, the specific things Unity resolves by name, and the keep-rules that let you obfuscate aggressively without a single broken prefab.

Two backends, two threat models

Unity has two scripting backends, and they change what obfuscation even means.

Mono compiles your scripts to managed assemblies that ship as real .dll files (Assembly-CSharp.dll, Assembly-CSharp-firstpass.dll, and any precompiled assemblies/asmdefs). These are standard CLI images: decompilable, and therefore fully obfuscatable. Renaming, control-flow flattening, and string encryption all apply to the shipped binary exactly as they would to any .NET app.

IL2CPP takes that same managed IL and transpiles it to C++, which is then compiled to a native library for the target platform. There is no managed assembly to rename at runtime — but IL2CPP emits a global-metadata.dat file alongside the binary that preserves type names, method names, field names, and (critically) every string literal in clear text. Tools like Il2CppDumper read that file to reconstruct a near-complete picture of your code. So IL2CPP is not obfuscation; it is transpilation with a metadata sidecar. Protection there means transforming the managed assemblies before IL2CPP runs, so the names and strings that get baked into global-metadata.dat are already encrypted or renamed.


Where obfuscation fits in each Unity backend
<br> .box{fill:none;stroke:#64748b;stroke-width:1.5;rx:8}<br> .lbl{fill:currentColor;font-size:13px}<br> .sm{fill:#64748b;font-size:11px}<br> .ob{stroke:#6366f1;stroke-width:2}<br> .arr{stroke:#64748b;stroke-width:1.5;marker-end:url(#a)}<br>

C# scripts → Roslyn → Assembly-CSharp.dll (managed IL)


Nebula obfuscate (IL)
<!-- Mono branch -->

Mono backend

Ship obfuscated .dll in player
<!-- IL2CPP branch -->

IL2CPP backend

il2cpp.exe → C++ → native


native lib +
global-metadata.dat (names/strings)
Obfuscating the IL first means the metadata IL2CPP bakes in is already renamed & string-encrypted.

What Unity resolves by name — and will break if you rename it

The editor and runtime both do a surprising amount of string- and name-based binding. Each of these is a place where blind renaming corrupts the game.

  • Serialized fields. Every public field and every [SerializeField] private field on a MonoBehaviour or ScriptableObject is saved into scenes, prefabs, and asset files keyed by its exact name. Rename the field and the saved value no longer loads.
  • Script type references. A scene stores "this GameObject has a component of type PlayerController" by type (and assembly) identity. Renaming the class detaches every instance.
  • UnityEvents. Inspector-wired UnityEvent listeners (your button OnClick, etc.) persist the target method name as a string. Rename the method and the button does nothing.
  • String-dispatched calls. SendMessage, BroadcastMessage, Invoke, InvokeRepeating, and AnimationEvent all call methods by string name.
  • Lifecycle & attribute hooks. Unity finds Awake, Start, Update, OnEnable, etc. by name via reflection; it also scans for [RuntimeInitializeOnLoadMethod], [MenuItem], and similar. Magic methods are usually on a built-in keep list, but custom reflection is not.
  • Addressables / asset bundles. Anything you load by type name or by string address at runtime.

A concrete example

Here is a perfectly normal component that renaming would wreck in three different ways:

using UnityEngine;
using UnityEngine.Events;

public class TurretController : MonoBehaviour   // (1) type name is in every scene/prefab using it
{
    [SerializeField] private float fireRate = 0.5f;   // (2) field name is saved in the asset
    [SerializeField] private UnityEvent onFire;        // (3) persistent listeners store a method name

    // Called by name from an AnimationEvent on the fire clip:
    public void SpawnProjectile() { /* ... */ }         // (4) resolved by string, not a direct call
}
Enter fullscreen mode Exit fullscreen mode

If Nebula renames TurretController → a, fireRate → b, and SpawnProjectile → c, then every turret prefab loses its fire-rate value, every scene loses its turret components, and the fire animation stops spawning projectiles — with no compiler error, because none of these bindings are checked at compile time.

The keep-rules that fix it

The goal is not "exclude MonoBehaviours from obfuscation." That would leave most of your code in the clear. The goal is to keep only the names Unity binds to, and obfuscate everything behind them — method bodies, private helpers, algorithms, and string literals.

Nebula expresses this through its keep-list / rename-exclusion rules (the same mechanism covered in keep-lists and rename exclusions). A practical Unity rule set keeps:

  • the names of all types deriving from UnityEngine.MonoBehaviour and UnityEngine.ScriptableObject;
  • all serialized members on those types (public fields and [SerializeField] fields, and [field: SerializeField] auto-properties);
  • any method targeted by a UnityEvent or called via SendMessage/Invoke/AnimationEvent.

Expressed against members, that is "keep the name of any field marked [SerializeField] or public on a UnityEngine.Object subclass, and keep the type name of any MonoBehaviour/ScriptableObject subclass." Method bodies are never affected by name-keep rules, so control-flow flattening and string encryption still apply to SpawnProjectile even though its name is preserved — the logic is protected, only the identifier survives.

Everything else — your pure C# domain logic, networking, save-game format, anti-cheat, and all the string literals in them — gets the full treatment.

Fitting it into the build

Keep obfuscation out of the editor and off developer machines; run it on the artifact. For the Mono backend, add a post-build step (a BuildPipeline post-process or a CI stage) that obfuscates Assembly-CSharp.dll and your other game assemblies in the player's Managed directory, then repackages. For IL2CPP, obfuscate the managed assemblies before the IL2CPP conversion so the generated C++ and global-metadata.dat inherit renamed symbols and encrypted strings. Running protection in CI (see obfuscating on the build server only) means your team keeps debugging clean, readable code and only shipped builds are hardened.

Doing this with Nebula

Nebula obfuscates the managed assemblies a Unity game is built from — renaming, control-flow flattening, and string encryption — with a keep-list model precise enough to preserve exactly the serialized fields, component type names, and event-target methods Unity binds by name, while still hardening every method body behind them. It runs as a post-build or CI step, so your editor stays fast and only the shipped player is protected. Nebula Pro adds code virtualization for the handful of routines that matter most — license checks, anti-cheat, purchase validation.

Start with the free edition to harden your first build, or see the plans when you are ready for virtualization.

Top comments (0)