String Encryption in .NET Obfuscation: How It Works and Why It Matters
When developers think about protecting a .NET application, they tend to focus on the logic - the algorithms, the license checks, the proprietary processing code. String literals rarely come to mind as a security concern. They should.
Open any unprotected .NET assembly in ILSpy and one of the first things an experienced attacker does is search the strings. Not to understand the code - just to see what is sitting there in plaintext. Connection strings. Internal endpoint URLs. Error messages that reveal implementation details. Cryptographic keys. In one case I encountered while working with ArmDot users, an API key to ChatGPT was hardcoded as a plain text string literal in a shipped assembly. Anyone who downloaded that application could have extracted and used it.
String encryption is not the deepest protection technique available - it does not hide your logic the way virtualization does. It is worth noting, however, that when a method is virtualized, its string literals become part of the encoded bytecode buffer rather than ldstr instructions - they are effectively hidden without needing HideStrings separately. For methods that are not virtualized, string encryption is the dedicated tool for this job.
What string literals look like in an unprotected assembly
Every string literal in your C# or VB.NET source code ends up in the assembly's metadata as plaintext. The compiler emits a ldstr CIL instruction that loads the string onto the evaluation stack - and the string content itself is stored verbatim in the #US (user strings) heap in the assembly's metadata.
This means that without any special tooling, strings are trivially extractable. Any hex editor or decompiler will show them. ILSpy displays them inline in the reconstructed C# output. dnSpy can search across all string literals in an assembly in seconds.
The attack pattern for an experienced reverse engineer often starts here rather than with the code. Searching for strings like serial, license, trial, expire, key, api, secret, password, or connection quickly locates the most security-sensitive parts of the application without requiring any understanding of the logic around them. Once those locations are found, understanding the logic becomes much easier.
How string encryption works
String encryption operates at the CIL instruction level. ArmDot scans the assembly for every ldstr instruction - the opcode that loads a string literal onto the evaluation stack - and replaces it with a small piece of code that reconstructs the original string at runtime.
The replacement code is not simply a decryption call. ArmDot uses a set of different approaches - XOR-based transformations, building the decrypted string on the fly character by character using new string(...), symmetric encryption algorithms - and mixes them across the assembly. Different strings within the same assembly may be protected by different mechanisms. The intent is not just to encrypt but to make the decryption logic itself varied and difficult to pattern-match.
From the CIL perspective: where the original assembly had a single ldstr "my-secret-api-key" instruction, the protected assembly has a sequence of instructions that compute and push the string value at runtime. The string content is no longer stored in the #US heap. An attacker looking at the assembly sees encrypted data and reconstruction logic rather than the original text.
Decryption happens on every access - there is no cache of decrypted strings. This is an intentional security decision. A cache would mean the decrypted string value lives in memory in a predictable location, making it straightforward to find with a memory scanner. Decrypting on every access means the plaintext is ephemeral - it exists on the stack during the method's execution and is then garbage collected.
The performance cost of this approach is deliberately minimal. The decryption algorithms used are fast by design. For the typical application, string encryption has no perceptible impact on startup time or runtime performance. The goal is to hide where strings are loaded, not to wrap them in computationally expensive protection.
What string encryption protects - and what it does not
String encryption is effective against the specific attack of extracting meaningful information from static analysis of the binary. An attacker who searches the assembly for readable strings finds nothing useful. Connection strings, API keys, internal URLs, license-related text, error messages that reveal internals - all of these are hidden from static inspection.
It does not protect against a patient attacker who instruments the running application. If someone attaches a debugger and sets a breakpoint at a method that uses a protected string, they can observe the decrypted value on the stack at runtime. String encryption raises the cost of finding sensitive values - it does not make them inaccessible to someone with debugging access and enough time.
For the most sensitive strings in your application - particularly anything that would be catastrophic if extracted, like private keys or master credentials - string encryption should be one layer of a defense-in-depth stack, not the only protection. Combine it with code virtualization on the methods that use those strings, and the attacker faces both the difficulty of finding the string and the difficulty of following the logic that uses it.
For strings that provide navigational assistance to an attacker - class names referenced by reflection, internal endpoint patterns, error message text that reveals code structure - string encryption alone is a meaningful deterrent. Most attackers will move to a softer target rather than invest in dynamic analysis of a method that reveals nothing in static form.
The strings you did not know you were shipping
One of the things that surprised me most when building ArmDot's string encryption was how much information strings reveal about application internals - and how often developers have no idea what strings are in their shipped binaries.
The most common category is strings that arrived via dependencies: error messages from third-party libraries, internal identifiers from NuGet packages, format strings from logging frameworks. These strings reveal the libraries you are using and their versions, which helps an attacker understand your attack surface.
The second category is strings that developers added during development and forgot to remove: debug output, internal comments turned into string constants, configuration values that were supposed to be externalized.
The third - and most serious - category is secrets. Connection strings to production databases. API keys to external services. Encryption keys used to protect data at rest. These end up in assemblies more often than developers realize, sometimes from configuration mistakes and sometimes from deliberate hardcoding that was "temporary." Once the application ships, those strings are as public as anything on the internet, because anyone can download the binary and read them.
Applying HideStrings assembly-wide takes a few seconds and costs essentially nothing at runtime. The strings in your assembly that you know about will be protected. So will the ones you forgot.
How to apply string encryption in ArmDot
ArmDot uses the HideStrings attribute from the ArmDot.Client NuGet package. It can be applied at assembly, type, or method level, with the same Enable and Inherit inheritance model as other ArmDot attributes.
Encrypting all strings in the assembly:
// In AssemblyInfo.cs or any source file
[assembly: ArmDot.Client.HideStrings]This is the most common and recommended configuration. Apply it once and every string literal in every method in the assembly is protected.
Encrypting strings in a specific type:
[ArmDot.Client.HideStrings]
class ApiClient
{
private const string BaseUrl = "https://internal.api.example.com/v2";
private const string ApiKey = "sk-...";
public void Connect() { ... }
}Excluding a specific method from string encryption:
[ArmDot.Client.HideStrings]
class ConfigurationManager
{
[ArmDot.Client.HideStrings(Enable = false)]
public string GetPublicSupportUrl()
{
// This string is already public - no need to encrypt
return "https://support.example.com";
}
}The Enable = false pattern is useful for methods where the strings are already public-facing information and encryption adds overhead without security benefit. In practice, applying HideStrings assembly-wide without exclusions is the right default - the performance cost is negligible and the protection is comprehensive.
ArmDot removes all HideStrings attributes from the output assembly after processing. The protected binary contains no indication of which strings were encrypted.
String encryption in combination with other techniques
String encryption pairs naturally with the rest of the obfuscation stack.
With symbol renaming: renaming removes meaningful type and method names; string encryption removes meaningful string content. Together they strip the assembly of the two main sources of navigational information an attacker uses to orient themselves.
With code virtualization: if a method contains particularly sensitive strings - a private key, a license validation pattern, a hardcoded secret - virtualize the method and encrypt its strings. An attacker cannot find the string in static analysis and cannot easily follow the logic that uses it in dynamic analysis.
With control flow obfuscation: string encryption hides the content; control flow obfuscation hides the structure of the code that processes it. An attacker who manages to locate a decryption call at runtime still faces obfuscated logic around it.
String encryption alone is a fast, low-cost improvement. As part of a layered approach it becomes part of a stack that requires multiple distinct techniques to defeat simultaneously.
Back to: .NET Obfuscation Techniques: A Technical Overview →
Protect the strings in your .NET assembly with ArmDot
ArmDot encrypts string literals using multiple techniques mixed across the assembly, with no perceptible performance impact. A single [assembly: HideStrings] attribute protects every string in your project. Free trial available - protected assemblies stop working after two weeks.
