.NET 中各種混淆(Obfuscation)的含義、原理、實際效果和不同級別的差異(使用 SmartAssembly)

長文預警!!!

UWP 程序有 .NET Native 可以將程序集編譯爲本機代碼,逆向的難度會大很多;而基於 .NET Framework 和 .NET Core 的程序卻沒有 .NET Native 的支持。雖然有 Ngen.exe 可以編譯爲本機代碼,但那只是在用戶計算機上編譯完後放入了緩存中,而不是在開發者端編譯。

於是有很多款混淆工具來幫助混淆基於 .NET 的程序集,使其稍微難以逆向。本文介紹 Smart Assembly 各項混淆參數的作用以及其實際對程序集的影響。


本文不會講 SmartAssembly 的用法,因爲你只需打開它就能明白其基本的使用。

感興趣可以先下載:.NET Obfuscator, Error Reporting, DLL Merging - SmartAssembly

準備

我們先需要準備程序集來進行混淆試驗。這裏,我使用 Whitman 來試驗。它在 GitHub 上開源,並且有兩個程序集可以試驗它們之間的相互影響。

準備程序集

額外想吐槽一下,SmartAssembly 的公司 Red Gate 一定不喜歡這款軟件,因爲界面做成下面這樣竟然還長期不更新:

無力吐槽的界面

而且,如果要成功編譯,還得用上同爲 Red Gate 家出品的 SQL Server,如果不裝,軟件到處彈窗報錯。只是報告錯誤而已,幹嘛還要開發者裝一個那麼重量級的 SQL Server 啊!詳見:Why is SQL Server required — Redgate forums

SmartAssembly

SmartAssembly 本質上是保護應用程序不被逆向或惡意篡改。目前我使用的版本是 6,它提供了對 .NET Framework 程序的多種保護方式:

  • 強簽名 Strong Name Signing
  • 自動錯誤上報 Automated Error Reporting
    • SmartAssembly 會自動向 exe 程序注入異常捕獲與上報的邏輯。
  • 功能使用率上報 Feature Usage Reporting
    • SmartAssembly 會修改每個方法,記錄這些方法的調用次數並上報。
  • 依賴合併 Dependencies Merging
    • SmartAssembly 會將程序集中你勾選的的依賴與此程序集合併成一個整的程序集。
  • 依賴嵌入 Dependencies Embedding
    • SmartAssembly 會將依賴以加密並壓縮的方式嵌入到程序集中,運行時進行解壓縮與解密。
    • 其實這只是方便了部署(一個 exe 就能發給別人),並不能真正保護程序集,因爲實際運行時還是解壓並解密出來了。
  • 裁剪 Pruning
    • SmartAssembly 會將沒有用到的字段、屬性、方法、事件等刪除。它聲稱刪除了這些就能讓程序逆向後代碼更難讀懂。
  • 名稱混淆 Obfuscation
    • 修改類型、字段、屬性、方法等的名稱。
  • 流程混淆 Control Flow Obfuscation
    • 修改方法內的執行邏輯,使其執行錯綜複雜。
  • 動態代理 References Dynamic Proxy
    • SmartAssembly 會將方法的調用轉到動態代理上。
  • 資源壓縮加密 Resources Compression and Encryption
    • SmartAssembly 會將資源以加密並壓縮的方式嵌入到程序集中,運行時進行解壓縮與解密。
  • 字符串壓縮加密 Strings Encoding
    • SmartAssembly 會將字符串都進行加密,運行時自動對其進行解密。
  • 防止 MSIL Disassembler 對其進行反編譯 MSIL Disassembler Protection
    • 在程序集中加一個 Attribute,這樣 MSIL Disassembler 就不會反編譯這個程序集。
  • 密封類
    • 如果 SmartAssembly 發現一個類可以被密封,就會把它密封,這樣能獲得一點點性能提升。
  • 生成調試信息 Generate Debugging Information
    • 可以生成混淆後的 pdb 文件

以上所有 SmartAssembly 對程序集的修改中,我標爲 粗體 的是真的在做混淆,而標爲 斜體 的是一些輔助功能。

後面我只會說明其混淆功能。

裁剪 Pruning

我故意在 Whitman.Core 中寫了一個沒有被用到的 internalUnusedClass,如果我們開啓了裁剪,那麼這個類將消失。

消失的類
▲ 沒用到的類將消失

特別注意,如果標記了 InternalsVisibleTo,尤其注意不要不小心被誤刪了。

名稱混淆 Obfuscation

類/方法名與字段名的混淆

名稱混淆中,類名和方法名的混淆有三個不同級別:

  • 等級 1 是使用 ASCII 字符集
  • 等級 2 是使用不可見的 Unicode 字符集
  • 等級 3 是使用高級重命名算法的不可見的 Unicode 字符集

需要注意:對於部分程序集,類與方法名(NameMangling)的等級只能選爲 3,否則混淆程序會無法完成編譯

字段名的混淆有三個不同級別:

  • 等級 1 是源碼中字段名稱和混淆後字段名稱一一對應
  • 等級 2 是在一個類中的不同字段使用不同名稱即可(這不廢話嗎,不過 SmartAssembly 應該是爲了強調與等級 1 和等級 3 的不同,必須寫一個描述)
  • 等級 3 是允許不同類中的字段使用相同的名字(這樣能夠更加讓人難以理解)

需要注意:對於部分程序集,字段名(FieldsNameMangling)的等級只能選爲 2 或 3,否則混淆程序會無法完成編譯

實際試驗中,以上各種組合經常會出現無法編譯的情況。

下面是 Whitman 中 RandomIdentifier 類中的部分字段在混淆後的效果:

// Token: 0x04000001 RID: 1
[CompilerGenerated]
[DebuggerBrowsable(DebuggerBrowsableState.Never)]
private int \u0001;

// Token: 0x04000002 RID: 2
private readonly Random \u0001 = new Random();

// Token: 0x04000003 RID: 3
private static readonly Dictionary<int, int> \u0001 = new Dictionary<int, int>();

這部分的原始代碼可以在 冷算法:自動生成代碼標識符(類名、方法名、變量名) 找到。

如果你需要在混淆時使用名稱混淆,你只需要在以上兩者的組合中找到一個能夠編譯通過的組合即可,不需要特別在意等級 1~3 的區別,因爲實際上都做了混淆,1~3 的差異對逆向來說難度差異非常小的。

需要 特別小心如果有 InternalsVisibleTo 或者依據名稱的反射調用,這種混淆下極有可能掛掉!!!請充分測試你的軟件,切記!!!

轉移方法 ChangeMethodParent

如果開啓了 ChangeMethodParent,那麼混淆可能會將一個類中的方法轉移到另一個類中,這使得逆向時對類型含義的解讀更加匪夷所思。

排除特定的命名空間

如果你的程序集中確實存在需要被按照名稱反射調用的類型,或者有 internal 的類/方法需要被友元程序集調用,請排除這些命名空間。

流程混淆 Control Flow Obfuscation

列舉我在 Whitman.Core 中的方法:

public string Generate(bool pascal)
{
    var builder = new StringBuilder();
    var wordCount = WordCount <= 0 ? 4 - (int) Math.Sqrt(_random.Next(0, 9)) : WordCount;
    for (var i = 0; i < wordCount; i++)
    {
        var syllableCount = 4 - (int) Math.Sqrt(_random.Next(0, 16));
        syllableCount = SyllableMapping[syllableCount];
        for (var j = 0; j < syllableCount; j++)
        {
            var consonant = Consonants[_random.Next(Consonants.Count)];
            var vowel = Vowels[_random.Next(Vowels.Count)];
            if ((pascal || i != 0) && j == 0)
            {
                consonant = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(consonant);
            }

            builder.Append(consonant);
            builder.Append(vowel);
        }
    }

    return builder.ToString();
}

▲ 這個方法可以在 冷算法:自動生成代碼標識符(類名、方法名、變量名) 找到。

流程混淆修改方法內部的實現。爲了瞭解各種不同的流程混淆級別對代碼的影響,我爲每一個混淆級別都進行反編譯查看。

沒有混淆
▲ 沒有混淆

0 級流程混淆

0 級流程混淆
▲ 0 級流程混淆

1 級流程混淆

1 級流程混淆
▲ 1 級流程混淆

可以發現 0 和 1 其實完全一樣。又被 SmartAssembly 耍了。

2 級流程混淆

2 級流程混淆代碼很長,所以我沒有貼圖:

// Token: 0x06000004 RID: 4 RVA: 0x00002070 File Offset: 0x00000270
public string Generate(bool pascal)
{
    StringBuilder stringBuilder = new StringBuilder();
    StringBuilder stringBuilder2;
    if (-1 != 0)
    {
        stringBuilder2 = stringBuilder;
    }
    int num2;
    int num = num2 = this.WordCount;
    int num4;
    int num3 = num4 = 0;
    int num6;
    int num8;
    if (num3 == 0)
    {
        int num5 = (num <= num3) ? (4 - (int)Math.Sqrt((double)this._random.Next(0, 9))) : this.WordCount;
        if (true)
        {
            num6 = num5;
        }
        int num7 = 0;
        if (!false)
        {
            num8 = num7;
        }
        if (false)
        {
            goto IL_10E;
        }
        if (7 != 0)
        {
            goto IL_134;
        }
        goto IL_8E;
    }
    IL_6C:
    int num9 = num2 - num4;
    int num10;
    if (!false)
    {
        num10 = num9;
    }
    int num11 = RandomIdentifier.SyllableMapping[num10];
    if (6 != 0)
    {
        num10 = num11;
    }
    IL_86:
    int num12 = 0;
    int num13;
    if (!false)
    {
        num13 = num12;
    }
    IL_8E:
    goto IL_11E;
    IL_10E:
    string value;
    stringBuilder2.Append(value);
    num13++;
    IL_11E:
    string text;
    bool flag;
    if (!false)
    {
        if (num13 >= num10)
        {
            num8++;
            goto IL_134;
        }
        text = RandomIdentifier.Consonants[this._random.Next(RandomIdentifier.Consonants.Count)];
        value = RandomIdentifier.Vowels[this._random.Next(RandomIdentifier.Vowels.Count)];
        flag = ((pascal || num8 != 0) && num13 == 0);
    }
    if (flag)
    {
        text = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(text);
    }
    if (!false)
    {
        stringBuilder2.Append(text);
        goto IL_10E;
    }
    goto IL_86;
    IL_134:
    if (num8 >= num6)
    {
        return stringBuilder2.ToString();
    }
    num2 = 4;
    num4 = (int)Math.Sqrt((double)this._random.Next(0, 16));
    goto IL_6C;
}

▲ 2 級流程混淆

這時就發現代碼的可讀性降低了,需要耐心才能解讀其含義。

3 級流程混淆

以下是 3 級流程混淆:

// Token: 0x06000004 RID: 4 RVA: 0x0000207C File Offset: 0x0000027C
public string Generate(bool pascal)
{
    StringBuilder stringBuilder = new StringBuilder();
    int num2;
    int num = num2 = this.WordCount;
    int num4;
    int num3 = num4 = 0;
    int num7;
    int num8;
    string result;
    if (num3 == 0)
    {
        int num5;
        if (num > num3)
        {
            num5 = this.WordCount;
        }
        else
        {
            int num6 = num5 = 4;
            if (num6 != 0)
            {
                num5 = num6 - (int)Math.Sqrt((double)this._random.Next(0, 9));
            }
        }
        num7 = num5;
        num8 = 0;
        if (false)
        {
            goto IL_104;
        }
        if (7 == 0)
        {
            goto IL_84;
        }
        if (!false)
        {
            goto IL_12A;
        }
        return result;
    }
    IL_73:
    int num9 = num2 - num4;
    num9 = RandomIdentifier.SyllableMapping[num9];
    IL_81:
    int num10 = 0;
    IL_84:
    goto IL_114;
    IL_104:
    string value;
    stringBuilder.Append(value);
    num10++;
    IL_114:
    string text;
    bool flag;
    if (!false)
    {
        if (num10 >= num9)
        {
            num8++;
            goto IL_12A;
        }
        text = RandomIdentifier.Consonants[this._random.Next(RandomIdentifier.Consonants.Count)];
        value = RandomIdentifier.Vowels[this._random.Next(RandomIdentifier.Vowels.Count)];
        flag = ((pascal || num8 != 0) && num10 == 0);
    }
    if (flag)
    {
        text = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(text);
    }
    if (!false)
    {
        stringBuilder.Append(text);
        goto IL_104;
    }
    goto IL_81;
    IL_12A:
    if (num8 < num7)
    {
        num2 = 4;
        num4 = (int)Math.Sqrt((double)this._random.Next(0, 16));
        goto IL_73;
    }
    result = stringBuilder.ToString();
    return result;
}

▲ 3 級流程混淆

3 級流程混淆並沒有比 2 級高多少,可讀性差不多。不過需要注意的是,這些差異並不是隨機差異,因爲重複生成得到的流程結果是相同的。

4 級流程混淆

以下是 4 級流程混淆:

// Token: 0x06000004 RID: 4 RVA: 0x0000207C File Offset: 0x0000027C
public unsafe string Generate(bool pascal)
{
    void* ptr = stackalloc byte[14];
    StringBuilder stringBuilder = new StringBuilder();
    StringBuilder stringBuilder2;
    if (!false)
    {
        stringBuilder2 = stringBuilder;
    }
    int num = (this.WordCount <= 0) ? (4 - (int)Math.Sqrt((double)this._random.Next(0, 9))) : this.WordCount;
    *(int*)ptr = 0;
    for (;;)
    {
        ((byte*)ptr)[13] = ((*(int*)ptr < num) ? 1 : 0);
        if (*(sbyte*)((byte*)ptr + 13) == 0)
        {
            break;
        }
        *(int*)((byte*)ptr + 4) = 4 - (int)Math.Sqrt((double)this._random.Next(0, 16));
        *(int*)((byte*)ptr + 4) = RandomIdentifier.SyllableMapping[*(int*)((byte*)ptr + 4)];
        *(int*)((byte*)ptr + 8) = 0;
        for (;;)
        {
            ((byte*)ptr)[12] = ((*(int*)((byte*)ptr + 8) < *(int*)((byte*)ptr + 4)) ? 1 : 0);
            if (*(sbyte*)((byte*)ptr + 12) == 0)
            {
                break;
            }
            string text = RandomIdentifier.Consonants[this._random.Next(RandomIdentifier.Consonants.Count)];
            string value = RandomIdentifier.Vowels[this._random.Next(RandomIdentifier.Vowels.Count)];
            bool flag = (pascal || *(int*)ptr != 0) && *(int*)((byte*)ptr + 8) == 0;
            if (flag)
            {
                text = CultureInfo.CurrentCulture.TextInfo.ToTitleCase(text);
            }
            stringBuilder2.Append(text);
            stringBuilder2.Append(value);
            *(int*)((byte*)ptr + 8) = *(int*)((byte*)ptr + 8) + 1;
        }
        *(int*)ptr = *(int*)ptr + 1;
    }
    return stringBuilder2.ToString();
}

▲ 4 級流程混淆

我們發現,4 級已經開始使用沒有含義的指針來轉換我們的內部實現了。這時除了外部調用以外,代碼基本已無法解讀其含義了。

動態代理 References Dynamic Proxy

還是以上一節中我們 Generate 方法作爲示例,在開啓了動態代理之後(僅開啓動態代理,其他都關掉),方法變成了下面這樣:

// Token: 0x06000004 RID: 4 RVA: 0x0000206C File Offset: 0x0000026C
public string Generate(bool pascal)
{
    StringBuilder stringBuilder = new StringBuilder();
    int num = (this.WordCount <= 0) ? (4 - (int)\u0002.\u0002((double)\u0001.~\u0001(this._random, 0, 9))) : this.WordCount;
    for (int i = 0; i < num; i++)
    {
        int num2 = 4 - (int)\u0002.\u0002((double)\u0001.~\u0001(this._random, 0, 16));
        num2 = RandomIdentifier.SyllableMapping[num2];
        for (int j = 0; j < num2; j++)
        {
            string text = RandomIdentifier.Consonants[\u0003.~\u0003(this._random, RandomIdentifier.Consonants.Count)];
            string text2 = RandomIdentifier.Vowels[\u0003.~\u0003(this._random, RandomIdentifier.Vowels.Count)];
            bool flag = (pascal || i != 0) && j == 0;
            if (flag)
            {
                text = \u0006.~\u0006(\u0005.~\u0005(\u0004.\u0004()), text);
            }
            \u0007.~\u0007(stringBuilder, text);
            \u0007.~\u0007(stringBuilder, text2);
        }
    }
    return \u0008.~\u0008(stringBuilder);
}

▲ 動態代理

注意到 _random.Next(0, 9) 變成了 \u0001.~\u0001(this._random, 0, 9)Math.Sqrt(num) 變成了 \u0002.\u0002(num)

也就是說,一些常規方法的調用被替換成了一個代理類的調用。那麼代理類在哪裏呢?

生成的代理類
▲ 生成的代理類

生成的代理類都在根命名空間下。比如剛剛的 \u0001.~\u0001 調用,就是下面這個代理類:

// Token: 0x0200001A RID: 26
internal sealed class \u0001 : MulticastDelegate
{
    // Token: 0x06000030 RID: 48
    public extern \u0001(object, IntPtr);

    // Token: 0x06000031 RID: 49
    public extern int Invoke(object, int, int);

    // Token: 0x06000032 RID: 50 RVA: 0x000030A8 File Offset: 0x000012A8
    static \u0001()
    {
        MemberRefsProxy.CreateMemberRefsDelegates(25);
    }

    // Token: 0x04000016 RID: 22
    internal static \u0001 \u0001;

    // Token: 0x04000017 RID: 23
    internal static \u0001 ~\u0001;
}

字符串編碼與加密 Strings Encoding

字符串統一收集編碼 Encode

字符串編碼將程序集中的字符串都統一收集起來,存爲一個資源;然後提供一個輔助類統一獲取這些字符串。

比如 Whitman.Core 中的字符串現在被統一收集了:

統一收集的字符串和解密輔助類
▲ 統一收集的字符串和解密輔助類

在我的項目中,統一收集的字符串可以形成下面這份字符串(也即是上圖中 Resources 文件夾中的那個文件內容):

cQ==dw==cg==dA==eQ==cA==cw==ZA==Zg==Zw==aA==ag==aw==bA==eg==eA==
Yw==dg==Yg==bg==bQ==dHI=ZHI=Y2g=d2g=c3Q=YQ==ZQ==aQ==bw==dQ==YXI=
YXM=YWk=YWlyYXk=YWw=YWxsYXc=ZWU=ZWE=ZWFyZW0=ZXI=ZWw=ZXJlaXM=aXI=
b3U=b3I=b28=b3c=dXI=MjAxOC0wOC0yNlQxODoxMDo0Mw==`VGhpcyBhc3NlbWJseSBoYXMgY
mVlbiBidWlsdCB3aXRoIFNtYXJ0QXNzZW1ibHkgezB9LCB3aGljaCBoYXMgZXhwaXJlZC4=RXZhbHVh
dGlvbiBWZXJzaW9uxVGhpcyBhc3NlbWJseSBoYXMgYmVlbiBidWlsdCB3aXRoIFNtYXJ0QXNzZW1ibHk
gezB9LCBhbmQgdGhlcmVmb3JlIGNhbm5vdCBiZSBkaXN0cmlidXRlZC4=IA==Ni4xMi41Ljc5OQ==
U21hcnRBc3NlbWJseQ==UGF0aA==U29mdHdhcmVcUmVkIEdhdGVc(U29mdHdhcmVcV293NjQzMk5vZ
GVcUmVkIEdhdGVc

雖然字符串難以讀懂,但其實我原本就是這麼寫的;給你看看我的原始代碼就知道了(來自 冷算法:自動生成代碼標識符(類名、方法名、變量名)):

private static readonly List<string> Consonants = new List<string>
{
    "q","w","r","t","y","p","s","d","f","g","h","j","k","l","z","x","c","v","b","n","m",
    "w","r","t","p","s","d","f","g","h","j","k","l","c","b","n","m",
    "r","t","p","s","d","h","j","k","l","c","b","n","m",
    "r","t","s","j","c","n","m",
    "tr","dr","ch","wh","st",
    "s","s"
};

生成的字符串獲取輔助類就像下面這樣不太容易讀懂:

// Token: 0x0200000A RID: 10
public class Strings
{
    // Token: 0x0600001C RID: 28 RVA: 0x00002B94 File Offset: 0x00000D94
    public static string Get(int stringID)
    {
        stringID -= Strings.offset;
        if (Strings.cacheStrings)
        {
            object obj = Strings.hashtableLock;
            lock (obj)
            {
                string text;
                Strings.hashtable.TryGetValue(stringID, out text);
                if (text != null)
                {
                    return text;
                }
            }
        }
        int index = stringID;
        int num = (int)Strings.bytes[index++];
        int num2;
        if ((num & 128) == 0)
        {
            num2 = num;
            if (num2 == 0)
            {
                return string.Empty;
            }
        }
        else if ((num & 64) == 0)
        {
            num2 = ((num & 63) << 8) + (int)Strings.bytes[index++];
        }
        else
        {
            num2 = ((num & 31) << 24) + ((int)Strings.bytes[index++] << 16) + ((int)Strings.bytes[index++] << 8) + (int)Strings.bytes[index++];
        }
        string result;
        try
        {
            byte[] array = Convert.FromBase64String(Encoding.UTF8.GetString(Strings.bytes, index, num2));
            string text2 = string.Intern(Encoding.UTF8.GetString(array, 0, array.Length));
            if (Strings.cacheStrings)
            {
                try
                {
                    object obj = Strings.hashtableLock;
                    lock (obj)
                    {
                        Strings.hashtable.Add(stringID, text2);
                    }
                }
                catch
                {
                }
            }
            result = text2;
        }
        catch
        {
            result = null;
        }
        return result;
    }

    // Token: 0x0600001D RID: 29 RVA: 0x00002CF4 File Offset: 0x00000EF4
    static Strings()
    {
        if (Strings.MustUseCache == "1")
        {
            Strings.cacheStrings = true;
            Strings.hashtable = new Dictionary<int, string>();
        }
        Strings.offset = Convert.ToInt32(Strings.OffsetValue);
        using (Stream manifestResourceStream = Assembly.GetExecutingAssembly().GetManifestResourceStream("{f6b5a51a-b2fb-4143-af01-e2295062799f}"))
        {
            int num = Convert.ToInt32(manifestResourceStream.Length);
            Strings.bytes = new byte[num];
            manifestResourceStream.Read(Strings.bytes, 0, num);
            manifestResourceStream.Close();
        }
    }

    // Token: 0x0400000C RID: 12
    private static readonly string MustUseCache = "0";

    // Token: 0x0400000D RID: 13
    private static readonly string OffsetValue = "203";

    // Token: 0x0400000E RID: 14
    private static readonly byte[] bytes = null;

    // Token: 0x0400000F RID: 15
    private static readonly Dictionary<int, string> hashtable;

    // Token: 0x04000010 RID: 16
    private static readonly object hashtableLock = new object();

    // Token: 0x04000011 RID: 17
    private static readonly bool cacheStrings = false;

    // Token: 0x04000012 RID: 18
    private static readonly int offset = 0;
}

生成字符串獲取輔助類後,原本寫着字符串的地方就會被替換爲 Strings.Get(int) 方法的調用。

字符串壓縮加密 Compress

前面那份統一收集的字符串依然還是明文存儲爲資源,但還可以進行壓縮。這時,Resources 中的那份字符串資源現在是二進制文件(截取前 256 字節):

00000000:   7b7a    7d02    efbf    bdef    bfbd    4def    bfbd    efbf
00000010:   bd7e    6416    efbf    bd6a    efbf    bd22    efbf    bd08
00000020:   efbf    bdef    bfbd    4c42    7138    72ef    bfbd    efbf
00000030:   bd54    1337    efbf    bd0e    22ef    bfbd    69ef    bfbd
00000040:   613d    efbf    bd6e    efbf    bd35    efbf    bd0a    efbf
00000050:   bd33    6043    efbf    bd26    59ef    bfbd    5471    efbf
00000060:   bdef    bfbd    2cef    bfbd    18ef    bfbd    6def    bfbd
00000070:   efbf    bdef    bfbd    64ef    bfbd    c9af    efbf    bdef
00000080:   bfbd    efbf    bd4b    efbf    bdef    bfbd    66ef    bfbd
00000090:   1e70    efbf    bdef    bfbd    ce91    71ef    bfbd    1d5e
000000a0:   1863    efbf    bd16    0473    25ef    bfbd    2204    efbf
000000b0:   bdef    bfbd    11ef    bfbd    4fef    bfbd    265a    375f
000000c0:   7bef    bfbd    19ef    bfbd    d5bd    efbf    bdef    bfbd
000000d0:   efbf    bd70    71ef    bfbd    efbf    bd05    c789    efbf
000000e0:   bd51    eaae    beef    bfbd    ee97    adef    bfbd    0a33
000000f0:   d986    141c    2bef    bfbd    efbf    bdef    bfbd    1fef

這份壓縮的字符串在程序啓動的時候會進行一次解壓,隨後就直接讀取解壓後的字符串了。所以會佔用啓動時間(雖然不長),但不會佔用太多運行時時間。

爲了能夠解壓出這些壓縮的字符串,Strings 類相比於之前會在讀取後進行一次解壓縮(解密)。可以看下面我額外標註出的 Strings 類新增的一行。

   using (Stream manifestResourceStream = Assembly.GetExecutingAssembly().GetManifestResourceStream("{4f639d09-ce0f-4092-b0c7-b56c205d48fd}"))
   {
       int num = Convert.ToInt32(manifestResourceStream.Length);
       byte[] buffer = new byte[num];
       manifestResourceStream.Read(buffer, 0, num);
++     Strings.bytes = SimpleZip.Unzip(buffer);
       manifestResourceStream.Close();
   }

至於嵌入其中的解壓與解密類 SimpleZip,我就不能貼出來了,因爲反編譯出來有 3000+ 行:

3000+ 行的解壓與解密類

字符串緩存 UseCache

與其他的緩存策略一樣,每次獲取字符串都太消耗計算資源的話,就可以拿內存空間進行緩存。

在實際混淆中,我發現無論我是否開啓了字符串緩存,實際 Strings.Get 方法都會緩存字符串。你可以回到上面去重新閱讀 Strings.Get 方法的代碼,發現其本來就已帶緩存。這可能是 SmartAssembly 的 Bug。

使用類的內部委託獲取字符串 UseImprovedEncoding

之前的混淆都會在原來有字符串地方使用 Strings.Get 來獲取字符串。而如果開啓了這一選項,那麼 Strings.Get 就不是全局調用的了,而是在類的內部調用一個委託字段。

比如從 Strings.Get 調用修改爲 \u0010(),,而 \u0010 是我們自己的類 RandomIdentifier 內部的被額外加進去的一個字段 internal static GetString \u0010;

防止 MSIL Disassembler 對其進行反編譯 MSIL Disassembler Protection

這其實是個沒啥用的選項,因爲我們程序集只會多出一個全局的特性:

[assembly: SuppressIldasm]

只有 MSIL Disassembler 和基於 MSIL Disassembler 的工具認這個特性。真正想逆向程序集的,根本不會在乎 MSIL Disassembler 被禁掉。

dnSpy 和 dotPeek 實際上都忽略了這個特性,依然能毫無障礙地反編譯。

dnSpy 可以做挺多事兒的,比如:

密封

OtherOptimizations 選項中,有一項 SealClasses 可以將所有可以密封的類進行密封(當然,此操作不會修改 API)。

在上面的例子中,由於 RandomIdentifier 是公有類,可能被繼承,所以只有預先寫的內部的 UnusedClass 被其標記爲密封了。

// Token: 0x02000003 RID: 3
internal sealed class UnusedClass
{
    // Token: 0x06000007 RID: 7 RVA: 0x000026D0 File Offset: 0x000008D0
    internal void Run()
    {
    }

    // Token: 0x06000008 RID: 8 RVA: 0x000026D4 File Offset: 0x000008D4
    internal async Task RunAsync()
    {
    }
}

實際項目中,我該如何選擇

既然你希望選擇“混淆”,那麼你肯定是希望能進行最大程度的保護。在保證你沒有額外產生 Bug,性能沒有明顯損失的情況下,能混淆得多厲害就混淆得多厲害。

基於這一原則,我推薦的混淆方案有(按推薦順序排序):

  1. 流程混淆
    • 建議必選
    • 直接選用 4 級流程(不安全代碼)混淆,如果出問題才換爲 3 級(goto)混淆,理論上不需要使用更低級別
    • 流程混淆對性能的影響是非常小的,因爲多執行的代碼都是有編譯期級別優化的,沒有太多性能開銷的代碼
    • 流程混淆僅影響實現,不修改 API,所以基本不會影響其他程序各種對此程序集的調用
  2. 名稱混淆
    • 儘量選擇
    • 任意選擇類/方法名和字段名的級別,只要能編譯通過就行(因爲無論選哪個,對程序的影響都一樣,逆向的難度差異也較小)
    • 名稱混淆不影響程序執行性能,所以只要能打開,就儘量打開
    • 如果有 InternalsVisibleTo 或者可能被其他程序集按名稱反射調用,請:
      • 關閉此混淆
      • 使用 Exclude 排除特定命名空間,使此命名空間下的類/方法名不進行名稱混淆
      • 如果你能接受用 Attribute 標記某些類不應該混淆類名,也可以使用這些標記(只是我不推薦這麼做,這讓混淆污染了自己的代碼)
  3. 動態代理
    • 推薦選擇
    • 動態代理僅影響實現,不修改 API,所以基本不會影響其他程序各種對此程序集的調用
    • 動態代理會生成新的類/委託來替換之前的方法調用,所以可能造成非常輕微的性能損失(一般可以忽略)
  4. 字符串壓縮加密
    • 可以選擇
    • 由於所有的字符串都被統一成一個資源,如果額外進行壓縮加密,那麼逆向時理解程序的含義將變得非常困難(沒有可以參考的錨點)
    • 會對啓動時間有輕微的性能影響,如果額外壓縮加密,那麼會有更多性能影響;如果你對啓動性能要求較高,還是不要選了
    • 會輕微增加內存佔用和讀取字符串時的 CPU 佔用,如果你對程序性能要求非常高,還是不要選了

以上四種混淆方式從四個不同的維度對你類與方法的實現進行了混淆,使得你寫的類的任何地方都變得無法辨認。流程混淆修改方法內實現的邏輯,名稱混淆修改類/屬性/方法的名稱,動態代理將方法內對其他方法的調用變得不再直接,字符串壓縮加密將使得字符串不再具有可讀的含義。對逆向閱讀影響最大的就是以上 4 種混淆了,如果可能,建議都選擇開啓。

如果你的程序中有需要保護的“嵌入的資源”,在沒有自己的保護手段的情況下,可以使用“資源壓縮加密”。不過,我更加推薦你自己進行加密。

至於 SmartAssembly 推薦的其他選項,都是噱頭重於實際效果:

  • 裁剪
    • 一般也不會有多少開發者會故意往程序集中寫一些不會用到的類吧!
  • 依賴合併/依賴嵌入
    • 並不會對逆向造成障礙,開不開啓差別不大,反而降低了性能
  • 防止 MSIL Disassembler 反編譯
    • 並不會對逆向造成障礙,防君子不防小人
  • 密封類
    • 聲稱可以提升性能,但這點性能提升微乎其微

SmartAssembly 的官方文檔寫得還是太簡單了,很難得到每一個設置項的含義和實際效果。

以上這些信息的得出,離不開 dnSpy 的反編譯。


參考資料

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章