Hardware Dongle Binding
Bind a .NET application to a hardware licensing dongle so that swapped or stubbed dongle DLLs cannot bypass Code Encryption.
Vendors of hardware licensing dongles — KEYLOK, SafeNet Sentinel, Wibu CodeMeter, Marx CrypToken, and similar — ship a native DLL that their customers P/Invoke from .NET to authenticate against the physical device. The headline threat against such integrations is DLL replacement: an attacker substitutes the vendor’s DLL with a stub that returns “license valid” for every call, and the protected application happily proceeds.
This article describes a pattern that closes that attack by combining Babel Obfuscator’s Code Encryption with three additional defensive layers. A runnable reference PoC is available for download at the end of the page.
The wrong mental model
The naive approach is to use the dongle as a Boolean licence gate:
if (!Keylok.IsAuthenticated()) Environment.Exit(1);
RunBusinessLogic();This collapses under a one-line DLL swap. A fake Keylok.dll that returns true for every call defeats the check entirely. Encrypting just the IsAuthenticated wrapper does not help either, because that wrapper has to call the native DLL one way or another — if the DLL is faked, the wrapper has nothing to verify against.
The right mental model
The dongle is not a licence checker. It is a decryption oracle.
Code Encryption transforms IL method bodies into Babel-VM bytecode and encrypts them with an AES key derived from a password. The password is provided at run time by a password callback method. In a hardware-bound integration, that callback queries the dongle for the password rather than reading it from a license file:
managed business code --(encrypted with password K)
native dongle DLL --(plain, talks to the dongle hardware)
dongle hardware --(stores K behind tamper-resistant crypto)A swapped DLL is no longer a problem. It can lie about anything it likes, but it cannot fabricate the password — without it, the Babel Virtual Machine has nothing to decrypt and the application becomes inert.
The native DLL cannot be encrypted by Babel — it is unmanaged code and the dongle vendor controls it. The trick is that the native DLL does not need to be encrypted: encrypting the managed business logic that uses the dongle achieves the same goal, because a fake DLL is unable to release the decryption password.
Glossary
The rest of this article refers to a small set of symbols and operations. Skim the table once before reading the layers below; everything else builds on it.
| Symbol | Name | Where it lives | Who creates it | What it is |
|---|---|---|---|---|
| K | Babel password (core) | On the dongle (wrapped), released at runtime | The developer, at packaging time | The password Babel uses to encrypt and decrypt the business methods. Per-customer in production. |
| HK | Hardware key | Inside the dongle silicon, never readable | The dongle vendor, at provisioning time | The dongle’s tamper-resistant key. Used by the dongle to wrap and unwrap data on the device itself. |
| token | Wrapped password | Embedded resource in the obfuscated assembly | The developer, at packaging time | token = wrap(K, HK). Useless without the dongle, which is the only entity that can recover K. |
| B | Bootstrap password | Consumed at obfuscation; [Obfuscation] attribute stripped from the output | The developer, once per product | Fixed password that encrypts the password-fetch code (the chained-encryption layer). |
| L | Liveness password | Consumed at obfuscation; [Obfuscation] attribute stripped from the output | The developer, once per product | Fixed password that encrypts the challenge/response verifier code. |
| I | Integrity password | Consumed at obfuscation; [Obfuscation] attribute stripped from the output | The developer, once per product | Fixed password that encrypts the native-DLL signature-pinning code. |
| nonce | Fresh random bytes | Generated in memory at every call | The application, at runtime | A 16-byte random challenge sent to the dongle. Different every call, so responses cannot be replayed. |
| K_i / HK_i | Per-customer variants | One pair per shipped license | The developer at delivery time | Each customer gets a unique K_i in their build and a unique HK_i provisioned on their dongle. |
A few terms about the operations themselves:
- wrap / unwrap — the dongle’s pair of cryptographic operations.
wrap(K, HK)produces the token at packaging time (typically AES encryption usingHKas key); the dongle performs the matchingunwrap(token, HK)at runtime and returnsK. The vendor’s native DLL is the courier; the actual crypto runs inside the dongle. - source — a Babel concept: a named group of encrypted methods that share a password. Each
[Obfuscation(Feature="msil encryption:source=<name>;...")]attribute assigns its method to a source. This article uses four sources:core(business code, passwordK),bootstrap(password fetch, passwordB),liveness(verifier, passwordL), andintegrity(DLL check, passwordI). - password callback — the static method Babel calls at runtime to obtain the password for a given source. Marked with
[Obfuscation(Feature="msil encryption get password")]. See Password Protected Code for the base mechanism.
Four defensive layers
A production-quality integration combines four layers.
1. Code Encryption with a dongle-supplied password
Apply msil encryption to the business-critical methods using a per-product password, then implement Babel’s password callback so that it asks the dongle to unwrap a stored token into K:
[Obfuscation(
Feature = "msil encryption:source=core;password=<K>;internal=true",
Exclude = false)]
public static decimal ComputeQuote(decimal basePrice, int quantity)
{
// real business logic
}
[Obfuscation(Feature = "msil encryption get password", Exclude = false)]
internal static string GetEncryptionPassword(string source)
{
return Inner(source);
}Inner reads a ciphertext (the token) from an embedded resource, passes it to the dongle, and returns the plaintext password. At packaging time the developer computes token = wrap(K, HK) once, where HK is the hardware-protected key already provisioned on the customer’s dongle.
2. Chained encryption of the callback itself
The callback Inner is itself a juicy target for an attacker who wants to short-circuit the dongle. Encrypt it under a second, fixed password:
[Obfuscation(
Feature = "msil encryption:source=bootstrap;password=<B>;internal=true",
Exclude = false)]
private static string Inner(string source)
{
byte[] token = LoadToken(source);
byte[] plain = new byte[token.Length];
int rc = KeylokInterop.DongleUnwrap(
token, token.Length, plain, plain.Length, out int written);
if (rc != 0) throw new InvalidOperationException("Dongle unwrap failed");
return Encoding.ASCII.GetString(plain, 0, written);
}B is supplied to Babel at obfuscation time and consumed there: Babel strips the [Obfuscation] attribute from the output assembly entirely. The password does not appear in the shipped binary as metadata, as a custom-attribute blob, or as a plain-text string a decompiler could surface. Recovering it requires defeating Babel’s own internal protection of the encrypted method body — a meaningfully harder task than reading a constant out of a .dll. Its only job is to protect the dongle-query algorithm from static analysis: combined with control-flow obfuscation and string encryption, this is enough to deter all but the most determined reverse engineer.
3. Per-call liveness check inside encrypted code
This is the layer that closes the sniff-and-replay attack:
An attacker borrows a real dongle, hooks the managed code, captures the unwrapped
K, and ships a fake DLL that returnsKdirectly. Babel’s decryption succeeds with the capturedKand the business methods appear to run.
The fix is to have the business methods themselves (already encrypted under source=core) perform a fresh challenge/response with the dongle on every invocation:
[Obfuscation(Feature = "msil encryption:source=core;password=<K>;internal=true",
Exclude = false)]
public static decimal ComputeQuote(decimal basePrice, int quantity)
{
if (!DongleLiveness.Verify()) return -1m; // silent sabotage
// real business logic
}
[Obfuscation(Feature = "msil encryption:source=liveness;password=<L>;internal=true",
Exclude = false)]
internal static bool Verify()
{
byte[] nonce = RandomNumberGenerator.GetBytes(16);
byte[] resp = new byte[16];
int rc = KeylokInterop.DongleChallenge(nonce, resp);
if (rc != 0) return false;
byte[] expected = ComputeExpectedMac(nonce); // uses a public verifier
return CryptographicOperations.FixedTimeEquals(resp, expected);
}The captured K lets Babel decrypt Verify itself, but does not contain the dongle’s challenge-response secret — the fresh nonce cannot be answered without the dongle. Asymmetric dongles (ECDSA, RSA) make this airtight; symmetric dongles still raise the bar significantly, because the verifier key only lives in encrypted code.
Two important tactics inside Verify:
- Fresh nonce per call. No memoisation, no caching.
- Silent sabotage on failure (return a wrong value, an empty result, a corrupted byte), not
throw. An exception betrays the location of the check; a wrong number does not. The attacker discovers the check only by comparing many runs against a reference.
4. Native DLL integrity pinning
Independent of the dongle’s correctness, the application can verify the identity of the DLL it is about to call:
[Obfuscation(Feature = "msil encryption:source=integrity;password=<I>;internal=true",
Exclude = false)]
internal static bool VerifyNativeDll(string path)
{
var cert = X509Certificate.CreateFromSignedFile(path);
const string EXPECTED_THUMBPRINT = "AABBCC..."; // pinned at build time
return WinTrust.VerifyAuthenticode(path)
&& cert.GetCertHashString().Equals(EXPECTED_THUMBPRINT,
StringComparison.OrdinalIgnoreCase);
}The certificate thumbprint of the vendor’s publisher (KEYLOK Inc., Thales-SafeNet, Wibu-Systems, …) is pinned inside an encrypted integrity source. This blocks substitution with any DLL not signed by the legitimate vendor — including DLLs that pass the liveness check because they secretly forward to a real dongle.
Together with Babel’s Tampering Detection, which injects post-obfuscation integrity checks into the assembly itself, this gives five active defences against the modify-and-redistribute attack.
Per-customer keying
A single shared K across all customers creates a class-break risk: a successful crack of one installation releases the whole product family. The recommended deployment is per-customer Babel builds:
- Provision the customer’s dongle with a uniquely generated hardware key
HK_iand a uniquely generated Babel passwordK_i. - At delivery time, re-run Babel against that customer’s source tree with
K_isubstituted into the[Obfuscation]attributes (or supplied via XML rules). - Compute
token_i = wrap(K_i, HK_i)and embed it in the build.
Cracking customer i requires extracting K_i from their installation and HK_i from their dongle. Neither helps with customer j. The operational cost is one Babel build per delivery, which is automatable in CI in a few minutes per customer.
Threat model summary
| Threat | Closed by |
|---|---|
| Static decompilation of the business logic | Code Encryption (core) |
Replacement with a return success stub DLL | Code Encryption + Liveness |
| Hooking the managed password callback | Chained encryption + Tampering Detection |
Capture of K followed by a fake DLL with hardcoded K | Liveness check |
Patching the IL to remove Verify() calls | Tampering Detection |
| Replacement with a differently-signed DLL | Authenticode pinning |
| Compromise spreading across customers | Per-customer keying |
| Skipping the Babel Virtual Machine entirely | Methods do real work, not gating |
Practical checklist
- Apply
msil encryptionwithsource=coreto business methods. - Apply
msil encryptionwithsource=bootstrapto the password fetch. - Apply
msil encryptionwithsource=livenessto the verifier. - Apply
msil encryptionwithsource=integrityto the DLL check. - Implement
[Obfuscation(Feature="msil encryption get password")]. - Embed the wrapped token as a resource.
- Build with
--tamperingdetection --antidebugging --controlflow --stringencryption. - Sign the assembly (
SignAssembly=true). - Pin the native DLL’s Authenticode thumbprint.
- Issue per-customer builds with unique
K_i.
Reference implementation
Download: HardwareDongleBinding.zip (20 KB)
The zip contains a self-contained runnable proof of concept. It uses a mock native DLL with XOR primitives so that it builds without any external dependencies; the structure is identical to a production integration that replaces the mock with the vendor’s real native library.
Prerequisites
The PoC builds and runs on Windows, macOS and Linux. Pick the row for your platform:
| Platform | C toolchain | PowerShell | .NET | Babel |
|---|---|---|---|---|
| Windows | Visual Studio 2022 Developer Command Prompt | built-in powershell.exe | .NET 8 SDK | babel.exe on PATH |
| macOS | Xcode Command Line Tools (xcode-select --install) | pwsh (brew install powershell) | .NET 8 SDK | babel from the Mac build (babel_mac_11.7.0.0) on PATH |
| Linux | cc / clang (apt install build-essential) | pwsh | .NET 8 SDK | babel on PATH |
Build
# 1. Extract the zip
Expand-Archive HardwareDongleBinding.zip -DestinationPath . # Windows
# or: unzip HardwareDongleBinding.zip # macOS / Linux
cd HardwareDongleBinding
# 2. End-to-end build: tokens, managed app, obfuscation, native libs
./Scripts/build.ps1On Windows open a Visual Studio Developer Command Prompt first so that cl.exe is on PATH; on macOS / Linux run from any shell. build.ps1 performs five steps:
- Wrap each per-source Babel password with the mock dongle’s
HKand write the resulting token files intoDongleBindingDemo/Tokens/*.bin. - Build the managed project with
dotnet build -c Release. - Obfuscate the resulting assembly with Babel:
--tamperingdetection --antidebugging --controlflow --stringencryption. - Compile the three native variants with the platform’s C compiler:
- Windows:
DongleMock.dll,DongleMock_Fake.dll,DongleMock_Sniff.dllviacl.exe. - macOS:
libDongleMock.dylib,libDongleMock_Fake.dylib,libDongleMock_Sniff.dylibviaclang. - Linux:
libDongleMock.so,libDongleMock_Fake.so,libDongleMock_Sniff.soviacc.
- Windows:
- Stage the genuine library next to the obfuscated assembly.
The C# [DllImport("DongleMock")] resolves to the platform-appropriate filename automatically.
Three scenarios
./Scripts/run-legit.ps1 # OK
./Scripts/run-swap-attack.ps1 # BLOCKED: Babel cannot decrypt
./Scripts/run-sniff-attack.ps1 # BLOCKED: liveness check failsLegitimate run — the genuine mock dongle is in place; business methods decrypt and execute:
ComputeQuote(120, 250, 0.15) = 24225.00
GenerateReportToken(ACME) = REPORT-ACME CORP-XXXXXXXX
Result: OKSwap attack — replaces DongleMock.dll with a stub that returns zeros for every call. Babel derives the wrong password from those zero bytes and cannot decrypt the method bodies:
[EXCEPTION] ...: BVM decryption failed
Result: BLOCKEDSniff/replay attack — the more interesting attack: the fake DLL returns the captured Babel password directly, so decryption succeeds. The per-call liveness check inside each business method nevertheless detects the missing dongle (the fake cannot answer a fresh challenge) and each method short-circuits into its silent sabotage branch:
ComputeQuote(120, 250, 0.15) = -1
GenerateReportToken(ACME) = REPORT-UNAVAILABLE
Result: BLOCKEDEach scenario corresponds to a row in the threat model table above.
Adapting to a real dongle
To turn the PoC into a production integration, change four files:
| File | What to change |
|---|---|
DongleBindingDemo/KeylokInterop.cs | Replace DongleMock with the vendor’s DLL name; align signatures with the vendor’s API. |
DongleBindingDemo/DongleAuth.cs | Replace the XOR-style unwrap with the vendor’s AES read-protection call. |
DongleBindingDemo/DongleLiveness.cs | Replace the XOR challenge with the vendor’s challenge/response API (HMAC, ECDSA, …). |
DongleBindingDemo/ProtectedLogic.cs | Move your real business methods under source=core; keep the DongleLiveness.Verify() guard. |
The Babel [Obfuscation] attributes, the tampering hook, and the build scripts require no changes.