ハードウェアドングルへの紐付け
.NET アプリケーションをハードウェアライセンスドングルに紐付け、ドングルの DLL をすり替えたりスタブ化したりしても、コード暗号化を回避できないようにします。
KEYLOK、SafeNet Sentinel、Wibu CodeMeter、Marx CrypToken などのハードウェアライセンスドングルのベンダーは、ネイティブ DLL を提供しています。ベンダーの顧客は、物理デバイスに対して認証するために、この DLL を .NET から P/Invoke で呼び出します。こうした統合に対する最大の脅威は DLL の置き換えです。攻撃者がベンダーの DLL を、すべての呼び出しに「ライセンスは有効」と返すスタブに差し替えると、保護されたアプリケーションはそのまま処理を続けてしまいます。
この記事では、Babel Obfuscator のコード暗号化に 3 つの防御レイヤーを追加して組み合わせることで、この攻撃を防ぐパターンを説明します。実行可能なリファレンス PoC は、ページの最後でダウンロードできます。
誤った考え方
単純な方法は、ドングルを、真偽値を返すライセンスのゲートとして使うことです。
if (!Keylok.IsAuthenticated()) Environment.Exit(1);
RunBusinessLogic();この方法は、DLL をすり替えるだけで破られます。すべての呼び出しに true を返す偽の Keylok.dll があれば、チェックは完全に無効になります。IsAuthenticated ラッパーだけを暗号化しても役に立ちません。このラッパーは、いずれにせよネイティブ DLL を呼び出さなければならないからです。DLL が偽物であれば、ラッパーには照合する相手がありません。
正しい考え方
ドングルは、ライセンスをチェックする装置ではありません。復号オラクルです。
コード暗号化は、IL のメソッド本体を Babel VM のバイトコードに変換し、パスワードから導出した AES キーで暗号化します。パスワードは、実行時にパスワードコールバックメソッドから提供されます。ハードウェアに紐付けた統合では、このコールバックは、ライセンスファイルからパスワードを読み取るのではなく、ドングルに問い合わせてパスワードを取得します。
managed business code --(encrypted with password K)
native dongle DLL --(plain, talks to the dongle hardware)
dongle hardware --(stores K behind tamper-resistant crypto)DLL をすり替えられても、もう問題にはなりません。偽の DLL はどんな嘘でも返せますが、パスワードを作り出すことはできません。パスワードがなければ、Babel Virtual Machine は何も復号できず、アプリケーションは動作しなくなります。
ネイティブ DLL を Babel で暗号化することはできません。アンマネージドコードであり、ドングルのベンダーが管理しているからです。ポイントは、ネイティブ DLL を暗号化する必要がないことです。ドングルを使用するマネージドのビジネスロジックを暗号化すれば、同じ目的を達成できます。偽の DLL は、復号用のパスワードを取り出せないからです。
用語集
この記事では以降、少数の記号と操作を使って説明します。以下のレイヤーを読む前に、次の表に一度目を通してください。以降の説明はすべて、この表を前提にしています。
| 記号 | 名前 | 保管場所 | 作成者 | 内容 |
|---|---|---|---|---|
| K | Babel パスワード(core) | ドングル上(ラップされた状態)。実行時に取り出される | 開発者(パッケージ化時) | Babel がビジネスメソッドの暗号化と復号に使用するパスワード。本番環境では顧客ごとに異なります。 |
| HK | ハードウェアキー | ドングルのチップ内。読み出しは不可能 | ドングルのベンダー(プロビジョニング時) | ドングルの耐タンパー性のあるキー。ドングルが、デバイス上でデータをラップおよびアンラップするために使用します。 |
| token | ラップされたパスワード | 難読化されたアセンブリの埋め込みリソース | 開発者(パッケージ化時) | token = wrap(K, HK)。ドングルがなければ役に立ちません。K を復元できるのはドングルだけです。 |
| B | ブートストラップパスワード | 難読化時に消費される。[Obfuscation] 属性は出力から除去される | 開発者(製品ごとに 1 回) | パスワード取得コードを暗号化する固定のパスワード(チェーン暗号化のレイヤー)。 |
| L | ライブネスパスワード | 難読化時に消費される。[Obfuscation] 属性は出力から除去される | 開発者(製品ごとに 1 回) | チャレンジ/レスポンスの検証コードを暗号化する固定のパスワード。 |
| I | 整合性パスワード | 難読化時に消費される。[Obfuscation] 属性は出力から除去される | 開発者(製品ごとに 1 回) | ネイティブ DLL の署名をピン留めするコードを暗号化する固定のパスワード。 |
| nonce | 新しいランダムなバイト列 | 呼び出しのたびにメモリ内で生成される | アプリケーション(実行時) | ドングルに送信される 16 バイトのランダムなチャレンジ。呼び出しのたびに異なるため、レスポンスをリプレイできません。 |
| K_i / HK_i | 顧客ごとのバリエーション | 出荷するライセンスごとに 1 組 | 開発者(納品時) | 各顧客は、ビルドに固有の K_i を、ドングルにプロビジョニングされた固有の HK_i を受け取ります。 |
操作そのものに関する用語をいくつか説明します。
- wrap / unwrap:ドングルが備える一対の暗号化操作です。
wrap(K, HK)は、パッケージ化時に token を生成します(通常はHKをキーとする AES 暗号化です)。ドングルは実行時に、対応するunwrap(token, HK)を実行してKを返します。ベンダーのネイティブ DLL は運び役にすぎず、実際の暗号処理はドングルの内部で実行されます。 - ソース:Babel の概念で、パスワードを共有する暗号化されたメソッドの名前付きグループです。それぞれの
[Obfuscation(Feature="msil encryption:source=<name>;...")]属性が、そのメソッドをソースに割り当てます。この記事では 4 つのソースを使用します。core(ビジネスコード、パスワードK)、bootstrap(パスワードの取得、パスワードB)、liveness(検証コード、パスワードL)、integrity(DLL のチェック、パスワードI)です。 - パスワードコールバック:特定のソースのパスワードを取得するために、Babel が実行時に呼び出す静的メソッドです。
[Obfuscation(Feature="msil encryption get password")]でマークします。基本的な仕組みについては、パスワードで保護されたコードを参照してください。
4 つの防御レイヤー
本番環境に耐える品質の統合では、4 つのレイヤーを組み合わせます。
1. ドングルが提供するパスワードによるコード暗号化
製品ごとのパスワードを使って、ビジネス上重要なメソッドに msil encryption を適用します。次に、保管されているトークンをドングルにアンラップさせて K を得るように、Babel のパスワードコールバックを実装します。
[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 は、埋め込みリソースから暗号文(token)を読み取ってドングルに渡し、平文のパスワードを返します。開発者はパッケージ化時に token = wrap(K, HK) を 1 回計算します。HK は、顧客のドングルにすでにプロビジョニングされている、ハードウェアで保護されたキーです。
2. コールバック自体のチェーン暗号化
コールバックの Inner 自体も、ドングルを迂回したい攻撃者にとって格好の標的です。これを、2 つ目の固定パスワードで暗号化します。
[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 は難読化時に Babel に渡され、その場で消費されます。Babel は出力アセンブリから [Obfuscation] 属性を完全に除去します。このパスワードは、メタデータとしても、カスタム属性の BLOB としても、デコンパイラーが取り出せる平文の文字列としても、配布されるバイナリには現れません。パスワードを復元するには、暗号化されたメソッド本体に対する Babel 独自の内部保護を破る必要があり、これは .dll から定数を読み取るよりもはるかに難しい作業です。このパスワードの役割は、ドングルへの問い合わせのアルゴリズムを静的解析から保護することだけです。制御フロー難読化や文字列暗号化と組み合わせれば、よほど執念深いリバースエンジニア以外は断念させるのに十分です。
3. 暗号化されたコード内での呼び出しごとのライブネスチェック
これは、傍受リプレイ攻撃を防ぐレイヤーです。
攻撃者は本物のドングルを借り、マネージドコードをフックして、アンラップされた
Kを取得し、Kを直接返す偽の DLL を配布します。取得されたKで Babel の復号は成功し、ビジネスメソッドは動作しているように見えます。
対策は、ビジネスメソッド自体(すでに source=core で暗号化されています)に、呼び出しのたびにドングルとの新しいチャレンジ/レスポンスを実行させることです。
[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);
}取得された K があれば、Babel は Verify 自体を復号できますが、この値にはドングルのチャレンジ/レスポンス用のシークレットは含まれていません。新しいノンスには、ドングルがなければ応答できません。非対称方式のドングル(ECDSA、RSA)なら、これは完全な対策になります。対称方式のドングルでも、検証用のキーは暗号化されたコードの中にしか存在しないため、攻撃の難度は大きく上がります。
Verify の中では、2 つの重要な工夫をしています。
- 呼び出しごとに新しいノンス:メモ化もキャッシュもしません。
- 静かな妨害:失敗時は
throwせず、誤った値、空の結果、壊れたバイトを返します。例外はチェックの場所を明かしてしまいますが、誤った数値は明かしません。攻撃者は、多数の実行結果を基準と比較しなければ、チェックの存在に気づけません。
4. ネイティブ DLL の整合性のピン留め
ドングルが正しく動作するかどうかとは別に、アプリケーションは、これから呼び出す DLL の身元を検証できます。
[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);
}ベンダーの発行元(KEYLOK Inc.、Thales-SafeNet、Wibu-Systems など)の証明書の拇印は、暗号化された integrity ソースの中にピン留めされます。これにより、正規のベンダーが署名していない DLL への差し替えはすべてブロックされます。本物のドングルにひそかに転送することでライブネスチェックを通過する DLL も例外ではありません。
難読化後の整合性チェックをアセンブリ自体に挿入する Babel の改ざん検出と合わせると、改変して再配布する攻撃に対して、5 つの能動的な防御が得られます。
顧客ごとのキー設定
すべての顧客で 1 つの K を共有すると、クラスブレイクのリスクが生じます。1 つのインストールのクラックに成功すれば、製品ファミリー全体が破られるからです。推奨される運用は、顧客ごとの Babel ビルドです。
ドングルをプロビジョニングする
顧客のドングルに、固有に生成したハードウェアキー HK_i と、固有に生成した Babel パスワード K_i をプロビジョニングします。
顧客のパスワードでビルドし直す
納品時に、[Obfuscation] 属性に K_i を代入して(または XML ルールで指定して)、その顧客のソースツリーに対して Babel を再実行します。
ラップされたトークンを埋め込む
token_i = wrap(K_i, HK_i) を計算し、ビルドに埋め込みます。
顧客 i をクラックするには、そのインストールから K_i を、そのドングルから HK_i を抽出する必要があります。どちらも顧客 j には役に立ちません。運用上のコストは、納品ごとに Babel のビルドが 1 回必要になることです。これは CI で自動化でき、顧客 1 件あたり数分で済みます。
脅威モデルのまとめ
| 脅威 | 対策 |
|---|---|
| ビジネスロジックの静的なデコンパイル | コード暗号化(core) |
return success を返すスタブ DLL への置き換え | コード暗号化 + ライブネスチェック |
| マネージドのパスワードコールバックのフック | チェーン暗号化 + 改ざん検出 |
K の取得と、K をハードコードした偽の DLL | ライブネスチェック |
IL にパッチを当てて Verify() の呼び出しを除去 | 改ざん検出 |
| 異なる署名の DLL への置き換え | Authenticode のピン留め |
| 顧客間での侵害の拡大 | 顧客ごとのキー設定 |
| Babel Virtual Machine を完全に迂回 | メソッドが可否の判定ではなく実際の処理を行うこと |
実践チェックリスト
msil encryptionをsource=coreでビジネスメソッドに適用します。msil encryptionをsource=bootstrapでパスワード取得コードに適用します。msil encryptionをsource=livenessで検証コードに適用します。msil encryptionをsource=integrityで DLL のチェックに適用します。[Obfuscation(Feature="msil encryption get password")]を実装します。- ラップされたトークンをリソースとして埋め込みます。
--tamperingdetection --antidebugging --controlflow --stringencryptionを指定してビルドします。- アセンブリに署名します(
SignAssembly=true)。 - ネイティブ DLL の Authenticode の拇印をピン留めします。
- 固有の
K_iを使って、顧客ごとのビルドを発行します。
リファレンス実装
ダウンロード:HardwareDongleBinding.zip(20 KB)
この zip には、単体で実行できる概念実証が含まれています。外部の依存関係なしでビルドできるように、XOR のプリミティブを使ったモックのネイティブ DLL を使用しています。構造は、モックをベンダーの実際のネイティブライブラリに置き換えた本番環境向けの統合と同じです。
前提条件
この PoC は、Windows、macOS、Linux でビルドして実行できます。お使いのプラットフォームの行を参照してください。
| プラットフォーム | C ツールチェーン | PowerShell | .NET | Babel |
|---|---|---|---|---|
| Windows | Visual Studio 2022 Developer Command Prompt | 組み込みの powershell.exe | .NET 8 SDK | PATH 上の babel.exe |
| macOS | Xcode Command Line Tools(xcode-select --install) | pwsh(brew install powershell) | .NET 8 SDK | PATH 上の babel。babel_net80/babel_net90/babel_net100 の zip パッケージ、または dotnet ツール Babel.Obfuscator.Tool から入手します |
| Linux | cc / clang(apt install build-essential) | pwsh | .NET 8 SDK | PATH 上の babel |
ビルド
# 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.ps1Windows では、cl.exe が PATH に含まれるように、先に Visual Studio Developer Command Prompt を開いてください。macOS と Linux では、任意のシェルから実行できます。build.ps1 は次の 5 つの手順を実行します。
- ソースごとの Babel パスワードをモックドングルの
HKでラップし、生成されたトークンファイルをDongleBindingDemo/Tokens/*.binに書き出します。 dotnet build -c Releaseでマネージドプロジェクトをビルドします。- 生成されたアセンブリを、
--tamperingdetection --antidebugging --controlflow --stringencryptionを指定して Babel で難読化します。 - プラットフォームの C コンパイラーで、3 種類のネイティブライブラリをコンパイルします。
- Windows:
cl.exeでDongleMock.dll、DongleMock_Fake.dll、DongleMock_Sniff.dllをコンパイルします。 - macOS:
clangでlibDongleMock.dylib、libDongleMock_Fake.dylib、libDongleMock_Sniff.dylibをコンパイルします。 - Linux:
ccでlibDongleMock.so、libDongleMock_Fake.so、libDongleMock_Sniff.soをコンパイルします。
- Windows:
- 正規のライブラリを、難読化されたアセンブリの隣に配置します。
C# の [DllImport("DongleMock")] は、プラットフォームに合ったファイル名に自動的に解決されます。
3 つのシナリオ
./Scripts/run-legit.ps1 # OK
./Scripts/run-swap-attack.ps1 # BLOCKED: Babel cannot decrypt
./Scripts/run-sniff-attack.ps1 # BLOCKED: liveness check fails正規の実行:本物のモックドングルが所定の場所にあり、ビジネスメソッドは復号されて実行されます。
ComputeQuote(120, 250, 0.15) = 24225.00
GenerateReportToken(ACME) = REPORT-ACME CORP-XXXXXXXX
Result: OKすり替え攻撃:DongleMock.dll を、すべての呼び出しにゼロを返すスタブに置き換えます。Babel はそのゼロのバイト列から誤ったパスワードを導出するため、メソッド本体を復号できません。
[EXCEPTION] ...: BVM decryption failed
Result: BLOCKED傍受リプレイ攻撃:より興味深い攻撃です。偽の DLL は取得済みの Babel パスワードを直接返すため、復号は成功します。それでも、各ビジネスメソッド内の呼び出しごとのライブネスチェックが、ドングルがないことを検出し(偽の DLL は新しいチャレンジに応答できません)、各メソッドは静かな妨害の分岐に入ります。
ComputeQuote(120, 250, 0.15) = -1
GenerateReportToken(ACME) = REPORT-UNAVAILABLE
Result: BLOCKED各シナリオは、前述の脅威モデルの表の 1 行に対応しています。
実際のドングルへの適用
PoC を本番環境向けの統合にするには、4 つのファイルを変更します。
| ファイル | 変更内容 |
|---|---|
DongleBindingDemo/KeylokInterop.cs | DongleMock をベンダーの DLL 名に置き換え、シグネチャをベンダーの API に合わせます。 |
DongleBindingDemo/DongleAuth.cs | XOR 方式のアンラップを、ベンダーの AES 読み取り保護の呼び出しに置き換えます。 |
DongleBindingDemo/DongleLiveness.cs | XOR のチャレンジを、ベンダーのチャレンジ/レスポンス API(HMAC、ECDSA など)に置き換えます。 |
DongleBindingDemo/ProtectedLogic.cs | 実際のビジネスメソッドを source=core の下に移し、DongleLiveness.Verify() のガードは残します。 |
Babel の [Obfuscation] 属性、改ざん検出のフック、ビルドスクリプトは変更不要です。