String encryption gets all the attention in .NET protection, and for good reason — a decompiler's string list is a roadmap. But strings are only half the literals in your code. The other half are numbers, and the specific numbers are very often the logic itself: the threshold a licensing check compares against, the opcode in a protocol switch, the bitmask that selects a feature, the magic value that validates a header. All of those compile to plain, instantly-readable load instructions. Constant encryption is the numeric counterpart to string encryption, and on security-sensitive code it matters just as much.
How a literal number compiles
When you write a numeric literal, the C# compiler emits one of the ldc ("load constant") instructions, with the value sitting right there in the IL. Consider a trivial check:
if (attempts > 3 && headerMagic == 0x5A4D)
Reject();
The IL carries both numbers in the clear:
ldloc.0 // attempts
ldc.i4.3 // the literal 3
ble.s IL_0014
ldloc.1 // headerMagic
ldc.i4 0x5A4D // the literal "MZ" PE signature
bne.un.s IL_0014
call Reject
A decompiler reconstructs this as exactly the source you wrote — 3 and 0x5A4D and all. The second constant is especially telling: 0x5A4D is the MZ DOS-header signature, so a reader instantly knows this code is sniffing for a PE file, without a single comment or string to help them. Small integers have their own short opcodes (ldc.i4.0 through ldc.i4.8, ldc.i4.s for one byte), larger ones use the full ldc.i4/ldc.i8/ldc.r8, but in every case the value is a literal operand anyone can read.
What constant encryption does
Constant encryption rewrites those literal loads so the real value never appears in the IL. Each protected constant is stored in an encoded form and replaced at the call site with a small decode routine that reconstructs it at runtime. Conceptually, the ldc becomes a call:
// before:
ldc.i4 0x5A4D
// after (conceptually):
ldc.i4 0x9C31 // an encoded token, not the real value
call int32 <Decode>::N(int32) // returns 0x5A4D at runtime
The decode routine is generated by the obfuscator and is itself obscured — it is not a single obvious xor with a visible key sitting next to the data. The effect is that a static decompiler sees Decode.N(0x9C31) where the source had 0x5A4D. The number is gone from the listing; recovering it means actually running (or faithfully emulating) the decode logic, which is exactly the work static analysis is trying to avoid.
The numbers worth hiding
Not every integer is interesting — a loop that counts to arr.Length reveals nothing. What constant encryption protects is the class of literals that are the behaviour:
-
Thresholds and limits in checks — the
> 3retry cap, a trial-day count, a seat limit. Seeing the number tells an attacker precisely what boundary to push against. -
Magic numbers and signatures — header bytes like
0x5A4D, format markers, checksum seeds. These label exactly what the code is parsing or validating. -
Protocol and state opcodes — the integer cases in a
switchthat drives a state machine or wire protocol. In the clear, the whole protocol is enumerable from the jump table. -
Feature-flag bitmasks — a value like
0x04AND-ed against an entitlements field names the exact bit that unlocks a feature, which is an invitation to force it. - Status and result codes — the specific value a security routine returns on success, which an attacker would love to make the code produce unconditionally.
In each case the number carries the meaning, and removing it from the static listing raises the cost of understanding or tampering with the check.
Where it fits with the other transforms
Constant encryption is one layer, and it is strongest in combination. On its own it hides values but leaves the surrounding shape intact; paired with string encryption it closes the other half of the literal leak, and paired with control-flow obfuscation it hides not just what the constants are but where the comparisons happen. A licensing check protected by all three no longer presents a reader with a tidy if (days > 30) against a visible literal — the value is decoded at runtime, the string messages are encrypted, and the branch itself is buried in a flattened control graph.
It also composes with method-level protection: a constant that is only ever decoded inside an encrypted or virtualized method never exists in plain form in the shipped assembly at all, because the method body carrying the decode call is itself protected until execution.
A note on cost and scope
There is no free protection. A plain ldc.i4 is a single instruction; a decoded constant is a method call, so every protected literal trades a direct load for a small amount of runtime work. For ordinary code this is invisible. The one place to think about it is a genuinely hot numeric loop that reads the same constant millions of times — there, blanket constant encryption can show up in a profile. The answer is scoping, not avoidance: apply constant encryption to the code where the numbers are secrets worth keeping — licensing, protocol handling, validation, entitlement checks — and leave the arithmetic kernels that contain no sensitive literals alone. Nebula lets you target protection at the types and methods that warrant it, so the security-relevant constants are hidden while the performance-critical math stays a plain load.
The takeaway is simple: a decompiler reads your numbers as readily as your strings, and on security-sensitive code the numbers are frequently the whole story. Encrypt the constants that encode your logic, combine the transform with string and control-flow protection, and scope it to where it earns its cost — and the tidy, self-explaining literals that used to hand a reader your checks disappear from the listing.
Top comments (0)