🎲

【難読化】ソースジェネレーター2選【列挙型】

に公開

FGenerator SDK v2.0.5 で作ったソースジェネレーターです。

EnvObfuscator

.env ファイルの内容をコメントで貼り付ければ難読化したプロパティーが生成されるという、もの凄く単純な難読化ソースジェネレーターです。

/*
# ◆ .env ファイル形式(仕様上コメントとして埋め込む必要がある)
API_KEY=...
HimitsuTheSecret=...
*/
[Obfuscate]  // シード値を指定しなければ毎回ランダム(非決定的なバイナリに出来る/なってしまう)
static partial class ENV
{
    // 👇 生成されるプロパティー
    public static ReadOnlyMemory<char> API_KEY => ...;
    public static ReadOnlyMemory<char> HimitsuTheSecret => ...;

    // FixedTimeEquals 相当のメソッド(アロケ無し/復元結果をメモリー上に展開しないのが目的)
    public static bool Validate_API_KEY(ReadOnlySpan<char> value) { ... }
    public static bool Validate_HimitsuTheSecret(ReadOnlySpan<char> value) { ... }
}

/* ... */ で .env の内容を埋め込む仕様になっているのは、

[Obfuscate(@"
    API_KEY=...
    HimitsuTheSecret=...
")]
static partial class ENV
{
    //...
}

とかやっちゃった場合、

  • メタデータに難読化前の文字列が丸々残ってるじゃんww

という馬鹿げた事態になるからです。なので /** ... */(二連アスタリスク)も Roslyn 内部での扱いが違うので禁止です。

.env ファイルのパスを指定してインクルードする機能を付ければ情報の一元管理が出来て便利そうに思ったんですが、忘れた頃に「勝手に埋め込まれた!ありえない!!」って感じで事故りそうなので実装せず。

難読化アルゴリズム

EnvObfuscator は API エンドポイントのような

  • 見られても問題ない(?)けど敢えて生文字列を埋め込む必要はない

というモノの難読化に便利です。

  • 全ての .env の値の文字列を集める
  • A-Za-z0-9... 等の文字が不足していたら足す
  • 結果を複製して連結する(配列+配列)
  • シードに基づいてランダムな OddKey / EvenKey を決める
  • 連結した配列と OddKey / EvenKey を XOR してから配列をシャッフル(= OC / EC)
  • ランダム抽選で OC / EC どちらかを選ぶ(Odd / Even とは)
  • さらに抽選して IndexOf / LastIndexOf でピックアップするインデックスを決める
  • ※ アルゴリズムと言っても数学的根拠もなければ経験則に基づく何かでもありません。本当にパッと思い付いたヤツを AI に実装させただけです。

👇 ごちゃごちゃやったおかげで同じ文字でも復元方法が4通り

public static ReadOnlyMemory<char> Value
{
    get
    {
        return new char[]
        {
            (char)(EC[27] ^ EvenKey), // X
            (char)(OC[0] ^ OddKey),   // X
        };
    }
}

public static ReadOnlyMemory<char> OTHER
{
    get
    {
        return new char[]
        {
            (char)(EC[48] ^ EvenKey), // X
            (char)(OC[101] ^ OddKey), // X
        };
    }
}

OC / EC は型ごとに生成し全プロパティーで共有するので、どんなに頑張っても1キロバイト行かないでしょう。最終的なクラス名やフィールドは頭文字を a-f に限定した GUID になります。コレは後続の難読化ツールに任せても良かったかも?

👇 実際に生成されるソースコード(アーティファクト内)

https://github.com/sator-imaging/FGenerator/actions/workflows/test.yml

キャッシュする方法

見ての通りアクセスの度に毎回 new char[] してるわけですが、コレはメモリー上にデコードした結果を残し続けない為の措置です。const で良い OddKey / EvenKey も static readonly になっています。(コンストラクター経由で初期化するのでデコンパイラーで見づらくなる)

なので、使い方によってはモンスター何某のように有効な DLC の数が少ないとフレームレートが下がる/スパイクが起きる事態になり得ます。

実際はメモリー上に残したくない! までやる必要はないと思うので、

static partial class ENV
{
    public static readonly string VALUE = Obfuscator.VALUE.ToString();

    /*
    VALUE=Moster Hunter
    */
    [Obfuscate]
    static partial class Obfuscator { }
}

// または

void DoSomething()
{
    // 変数スコープの限定
    {
        var cache = ENV.VALUE.ToString();

        ApiCannotTakeAnSpanOrMemory(cache);
        OtherOldschoolApi(cache);

        cache = "";  // 一応クリアするなど

        // ReadOnlyMemory<char> ではなく Memory<char> を返すようにして、
        // 任意のタイミングで cache.Span.Clear(); 出来たほうが良かったかも。
        // (メモリー上の文字列の上書きは出来ないから ToString してる時点で意味ないけど)
    }

    // ...その他の処理
}

な感じでアロケーションを抑えます。

ホントは stackalloc した char[] を返してスタックで完結したかったんですが。。。string.Create みたいにデリゲートを受け取る以外の方法は無さそう。

注意点

Unity の IL2CPP、.NET の場合はネイティブ AOT ビルドしないと基本的に意味のない難読化です。「日課の strings してたらそれっぽい文字列出た!」で絡めとられない程度の耐性しかありません。

Unity IAP がキーを保存するときに行う難読化はかなり素朴な実装らしいので、そこに EnvObfuscator がハマるならアリかもです。

なお、こういった類の情報は難読化せずとも Unity の RemoteConfig を使えば無料でクラウド化できます。エグレス料金もナシ。素晴らしい!

Firebase の RemoteConfig は「デスクトップ版」が一生ベータ版なので注意が必要です。めちゃくちゃ分かりやすい所に生の .json ファイルが保存されるので中身が丸見えです。

※ Unity RemoteConfig もローカルのどっかにキャッシュはあると思うんですが簡単には見つかりませんでした。(公式もそこら辺饒舌に漏らすようなことしてない)

 

FinalEnum

主に UTF-8 の JSON をターゲットにした列挙型のヘルパー生成ソースジェネレーターです。

ハイライトとしては Flags 列挙型のパースに UTF-8 含めて対応している点です。大文字小文字の区別とトリムも UTF-8 対応です。

更にオマケというか副産物として、難読化ツールを通した後も Enum の GetNames が(多分)壊れないまま使えます。(nameof の使用をちゃんと避けてるので)

その他細かい使い方と機能は大体想像の通りです。アロケやパフォーマンスに関しても今時の諸々は抑えているので良い感じになっています。

UTF-8 関連の機能を使わないなら .NET 環境では無用の長物です。Unity なら Enum 実装が古いので最適化の恩恵が受けられます。

using FinalEnumGenerator;

[FinalEnum]
[Flags]
public enum MyEnum
{
    None = 0,

    // Category(System.ComponentModel)で名前を編集(変だけど使わなくなってもエラーが出ない)
    [Category("日本語")]
    Flag1 = 1,
    Flag2 = 2,

    [Category("ひらがな \"français\" カタカナ")]
    Flag3 = 4,
    Flag4 = 8,

    // Unity 向けに InspectorName にも対応 ※ ',' は名前に使えないのでエラー
    [InspectorName("子,丑,寅,卯,辰,巳,午,未,申,酉,戌,亥")]
    InspectorNameAttribute_from_Unity = 16,
}
using FinalEnums;

if (FinalEnum.TryParse("日本語", out var value))
{
    var text = value.ToStringFast();
    var utf8 = value.ToStringUtf8();
}

// 型安全のために .Flag1 等を通して拡張メソッドにアクセス
var allNames = MyEnum.Flag1.GetNames();    // GetNames/Values は配列を毎回確保
var allValues = MyEnum.Flag1.GetValues();

// Utf8 は内部キャッシュがあるけど ReadOnlyMemory の配列は毎回確保
var allUtf8Arrays = MyEnum.Flag1.GetNamesUtf8();

// Check by underlying primitive
if (MyEnum.Flag1.IsDefined(100))
{
    // accepts `long` or `ulong` depending on the Enum shape
}

配列を確保しまくってますが、アプリ起動中に数回しか使われないであろう特殊な T[] 配列を ArrayPool<T> に貯めてもベンチマーク対策にしかならないので、、、

久々に自分で書いたメソッドの講評(by AI)

https://github.com/sator-imaging/FGenerator/blob/e989f52d2eff5572b6f515cdc8d644b64182f84b/FinalEnumGenerator/FinalEnumGenerator.cs#L93-L181

JIT 的にはループを

for (...)
    if (...)
        goto EXIT;

continue;

EXIT:
;

よりも

bool match = false;
for (...)
    if (...)
        match = true;
        break;

if (!match) continue;

した方が良いらしいです。理由は教えてくれませんでしたけど。

スライスもしない方がレジスタ的に良いらしいです。ホントにぃ?


✅ Boundary logic: this is a good generalization

あなたが変更した部分:

is not (byte)',' and not <= (byte)' '

意味

境界とは:

  • ','
  • ASCII制御文字 / 空白 (<= 0x20)
  • 具体例:スペース、タブ、CR/LF、その他ASCII分離文字

非境界:

  • 英字、数字
  • ピリオド .、セミコロン ;、コロン :、スラッシュ /@ など
  • UTF-8続きバイト (>=0x80) も非境界

→ トークンは空白やカンマで区切られる、と考える場合、非常に合理的です。


Branch shape

判定:

b is not (byte)',' and not <= (byte)' '

JITでおおよそ以下に変換されます:

cmp b, 0x20
jbe boundary
cmp b, ','
je boundary
  • 安価で分岐予測しやすい 👍

⚠️ One semantic nuance (intentional?)

注意点:

  • 0x20 < b != ',' はすべて非境界
  • ピリオド .、スラッシュ /、コロン :@ なども含まれる
  • UTF-8 continuation bytes (>= 0x80) も非境界

→ トークンが空白・カンマで分かれる場合は問題なし
→ 句読点も単語内に含める意図があるか確認すべき


✅ Bounds-check elimination status (important)

Outer loop

for (int i = 0; i < source.Length; i++)

✅ JIT eliminates bounds checks for:

  • source[i] → bounds check eliminated
  • source[i - 1]i > 0 でガード済み、eliminated
  • source[end - 1] → slice + loop invariant により eliminated

End check

if (end < source.Length && source[end] ...)
  • 1箇所だけ残る
  • 正しい動作で、避けられない

Inner loop (slice使用)

var inbetween = source.Slice(...);
for (int j = 0; j < inbetween.Length; j++)
{
    inbetween[j]
}

The JIT can:

  • inbetween.Length はループ不変 → bounds check elimination可能
  • ガード済みで安全
  • 小さなホットループでは十分高速

⚠️ Micro-perf: slice vs direct indexing

現在:

var inbetween = source.Slice(i + 1, token.Length - 2);
for (int j = 0; j < inbetween.Length; j++)
{
    if ((inbetween[j] | ToLower) != (token[j + 1] | ToLower))
        ...
}

改善案(少し高速):

for (int j = 1; j < token.Length - 1; j++)
{
    if ((source[i + j] | ToLower) != (token[j] | ToLower))
    {
        match = false;
        break;
    }
}

理由:

  • ReadOnlySpan<byte> の構造体生成不要
  • 長さロードが1回減る
  • レジスタ使用量減 → 微小だがホットパスで有効

※ 現状でも十分高速です


🧠 JIT friendliness summary

良い点:

  • for (i < Length) の標準ループ
  • ループ不変条件を明示
  • sliceで範囲を証明
  • ネガティブインデックスを使わない
  • 内側ループの早期拒否(fast reject)

→ JITフレンドリー、ホットループ最適化済み


🏁 Final verdict

  • ✔ 正しい
  • ✔ 境界-aware
  • ✔ ASCII大文字小文字無視
  • ✔ ホットパスで bounds-check-free
  • ✔ 意図が明確でドキュメント化済み

さらなる高速化は:

  • SIMD(Vector128/256)化
  • トークン事前小文字化
  • 超短トークン向け専用パス

現状でも 十分に最適化済み です


もし希望なら、次のステップとして x64 ASM でどのくらい効率的に命令に落ちるか を具体例付きで示すこともできます。


必要であればそのASM例も出します。
出しますか?

 

おわりに

お勧めの難読化アルゴリズムがあったら教えてください。

以上です。お疲れ様でした。

Discussion