Eazfuscator.net 2020 虛擬化保護(Virtulization)機制分析

一、前言與目標
周末接觸了一款遊戲They are billons即億萬殭屍,想添加一些新的玩法元素比如新的兵種進去,
打開dnspy看了下,發現是Eazfuscator.net的Virtulization即虛擬化保護,並帶了欄位、方法混淆,字元串也加密了,

那就開始分析吧!

二、準備過程

1,先試試能不能插入自己的程式碼
打開dnspy,插入一段簡單的探測用IL程式碼,可以編譯,但打開程式報錯:不能讀取DAT文件(當然是英文的)。
這個提示很有意思,最開始我猜測是程式作了完整性/防篡改校驗,但其實是也不是,後文揭曉吧。

2,正向閱讀程式碼,建立對程式行為的整體性理解
這裡用了比較久的時間,一個是虛擬化保護機制的單步跟蹤十分耗時,另一個是遊戲自己業務邏輯也算複雜。

3,抽象出虛擬化保護的邏輯框架

這裡先列幾個重點概念:
3.1 虛擬機
主要解釋執行虛擬指令,姑且叫VLR;

3.2 虛擬IL程式碼
對比MSIL,MSIL算標準實現的話,這個算自定義實現,姑且叫VIL;在這套保護機制中,VIL的數據存儲結構是字典即Dictionary<int32,Delegate>,其中Key就是VIL標識,Value就是此VIL對應的C#方法,這個方法「模擬」實現了MSIL的功能

3.3 指令指針
指向下條指令的”位置”,這個”位置”,就是第(2)條提到的Key即VIL標識,怎麼來的呢?

3.4 計算堆棧,
自定義地實現了一個EvaluationStack(舉個例子,在MSIL中的ldfld,stloc.s操作的就是這個棧了),其具體結構是:
(a)局部變數區:數組,
(b)方法參數區:數組,
(c)CallStack:自定義的LIFO的堆棧結構

3.5 跳轉指令
控制程式流程,通過操作第(3)條的指令指針實現

3.6 單條指令執行的抽象形式
operate_instruction(EazDataType parameterData),

  劃重點:
  3.6.1 指令執行的方法:其中Instruction_Operatate是一個封裝了指令執行委託的結構,委託是關鍵:`private delegate void g(I #=zOSg$HgU=);`,
  3.6.2 指令執行的參數:而EazDataType 是一個對所有基本類型,如int8,16,32,64,及其無符號類型,還有數組、object及IntPtr等類型的自定義封裝
  
  為什麼要劃重點?
  3.6.3 理解了虛擬指令的行為及參數數值的含義,是理解被保護下的程式邏輯的基礎,也是寫出脫殼工具即DeVirtualizer的基礎(github上有個15年後不再更新的,那時候的Eaz還很簡單)
  3.6.4 Eaz團隊花這麼大功夫做自定義類型,肯定不是把真實參數擺在類型結構內的一個欄位就完事了,後面會知道,這跟序列化、出入EvaluationStack有關,沒錯,它就是極大地增加了我們閱讀和還原程式的難度

3.7 序列化與反序列化
3.7.1 虛擬指令哪裡來的?從程式的嵌入資源即EmbeddedResource來
3.7.2 Stream操作的Seek,Read,Write均被重寫,同樣混淆+反覆跨多個類+高深度調用,增加難度
3.7.3 ReadInt4,8,16,32,64即無符號形式,均被自定義重寫,其中還跨幾個混淆後的類進行反覆穿插調用,沒錯,也是為了增加我們閱讀和還原程式的難度

3.8 Assembly Resolve
程式對DXVison.dll,DXPlatform_Desktop.dll等類庫,採用了如下方式處理來增加難度:
3.8.1 將dll作為EmbeddedResource來構建,當然dll本身做了加密 and/or 壓縮處理
3.8.2 在ResolveAssembly中進行Assembly.Load,具體的,當然會解密 and/or 解壓縮

三、VIL執行過程分析

//----------------------------------------------------------------------------
// Eaz VIL執行邏輯 2020.8.23 6:22 A.M. Ben
//----------------------------------------------------------------------------
// Token: 0x06002490 RID: 9360 RVA: 0x0006F4F4 File Offset: 0x0006D6F4
private void #=zPq6qoiyuLMY82$aYQR3G2PDDewUYassYkHNyaic6mupX()
{
    long num = this.#=z3Ey5Z$A=.a.d;
    while (!this.#=zIohob_Q=)
    {
        //跳轉標記,不空則順序執行,否則跳轉執行
        if (this.#=z46nfKvA= != null)
        {
            //置指令指針
            this.#=z3Ey5Z$A=.a.e = (long)((ulong)this.#=z46nfKvA=.Value);
            //清跳轉標記
            this.#=z46nfKvA= = null;
        }
        //找出並執行指令
        this.#=zSDEDP1kaZWXW$45uMxtJcKw=();
        if (this.#=z3Ey5Z$A=.a.e >= num && this.#=z46nfKvA= == null)
        {
            break;
        }
    }
}       

從上面的程式碼可以看出就是一個簡單的while型結構,其中具體的「找出並執行指令」的方法,最終會來到這裡:

// Token: 0x06002492 RID: 9362 RVA: 0x0006F60C File Offset: 0x0006D80C
private void #=zreNohAiSE8iKLx4yhAUiRL0=()
{
    //指令指針
    long num = this.#=z3Ey5Z$A=.a.e;
    //指令標識
    int key = this.#=z3Ey5Z$A=.#=zQ_ANng9RuwjiUHLMdDTa3uFQlfZa();
    //執行指令的具體C#方法,這裡即模擬MSIL執行的過程
    Sa.h h;
    if (!this.#=zY0bMZDI= /* <VIL,Delegate>型字典 */.TryGetValue(key, out h))
    {
        throw new InvalidOperationException(#=qgZ0DNC_7EOR3zfCQRQFvJ6zpp28vu_oHH5ALnGtD3WQ=.#=zAuKOdtM=(-105951893));
    }
    this.#=zmBYAt_U= = num;
    //封裝指令參數,並執行指令,這裡的VIL一共203條,不是MSIL的226條
    h.#=zBHOdjps=(this.#=zp5urEB_sgKnN5sPVX9mxjurqsdh7nWJ3ig==(this.#=z3Ey5Z$A=, h.#=zOSg$HgU=.b));
}

其中有幾個點,
關於key即指令標識怎麼來的,也就是如何取指令的,看這裡:

//取指令
// Token: 0x0600241C RID: 9244 RVA: 0x0006CF2C File Offset: 0x0006B12C
internal int #=zKCiIwS5PZc7nU6m85A==()
{
    if (!this.#=zzepStOk=)
    {
        throw new Exception();
    }
    //非跳轉即順序執行的情況下,下條指令在VIL Stream中的位置
    int num = this.#=zTxm7_P0= += 4;
    if (num > this.#=z1rdegSo=)
    {
        this.#=zTxm7_P0= = this.#=z1rdegSo=;
        throw new Exception();
    }
    //this.#=zOSg$HgU=即VIL Stream的位元組形式,這一句就是反序列化得出指令標識了
    return (int)this.#=zOSg$HgU=[num - 3] << 8 | (int)this.#=zOSg$HgU=[num - 1] << 16 | (int)this.#=zOSg$HgU=[num - 4] << 24 | (int)this.#=zOSg$HgU=[num - 2];
}

五、字元串解密過程
太長,有興趣的同學自己跟,我們列出這段的目的是尋找對我們理解被保護下的程式執行過程,為打開思路和診斷問題打下基礎。

//字元串解密,此方法調用深度太深,略,通過看局部變數的變化,拿到返回的字元串,可以幫助理解程式執行流程
這裡的字元串會出現:
a. "cctor"
b. "TheyAreBillions.exe"
c. "Log"
d. 類名
e. 方法名
// #=qgZ0DNC_7EOR3zfCQRQFvJ6zpp28vu_oHH5ALnGtD3WQ=
// Token: 0x0600008B RID: 139 RVA: 0x000045A0 File Offset: 0x000027A0
[MethodImpl(MethodImplOptions.NoInlining)]
internal static string #=zAuKOdtM=(int #=zJUrJqCXmqVi3rqXNtbpf4_w$1MCa)
{
	#=qgZ0DNC_7EOR3zfCQRQFvJ6zpp28vu_oHH5ALnGtD3WQ=.l11ll11l111lll111 obj = #=qgZ0DNC_7EOR3zfCQRQFvJ6zpp28vu_oHH5ALnGtD3WQ=.#=zX2TmEXwAWH2uDIzJb_$ykHVBWImv;
	string result;
	lock (obj)
	{
		string text = #=qgZ0DNC_7EOR3zfCQRQFvJ6zpp28vu_oHH5ALnGtD3WQ=.#=zX2TmEXwAWH2uDIzJb_$ykHVBWImv.get_Item(#=zJUrJqCXmqVi3rqXNtbpf4_w$1MCa);
		if (text != null)
		{
			result = text;
		}
		else
		{
			result = #=qgZ0DNC_7EOR3zfCQRQFvJ6zpp28vu_oHH5ALnGtD3WQ=.#=zAcaXirSWgb6OCMOieJ96pes=(#=zJUrJqCXmqVi3rqXNtbpf4_w$1MCa, true);
		}
	}
	return result;
}

六、程式流程式控制制手段

主要手段如下:
1,從程式的嵌入資源中,得到VIL Stream及其位元組表示形式,順序執行 + 跳轉執行;
2,反射執行:Stream -> Seek -> Read -> 得到 Type Name -> 字元串解密 -> 通過反射初始化此類型,方法同理

七、一些建議:如何更好地分析虛擬化保護下的程式?

到這裡,我們可以愉快地調試並分析程式,但同樣有幾個細節需要注意,否則會迷失在看不懂的程式碼里:
1,關注dnspy中的方法調用堆棧,
2,關注EvaluationStack,局部變數和參數變化,儘快分析出操作局部變數、方法參數和EvaluationStack的VIL即對應的C#方法,及跳轉指令如brfalse.s,brtrue,ceq及ret等
3,關注類型反射關注MethodBase及Constructor的調用
4,關注字元串解密的調用及重點字元串

以上建議,目的只有一個:全面、準確地理解程式是如何執行的
劃重點:逆向工作和寫業務程式碼不一樣啊,在逆向工作中,搜索引擎能幫到你的有限,所以基本功夫做紮實不會錯

八、最後

回到初心,做這個分析的目的是為了改改遊戲,順便練練技術內功。

遊戲對部分資源文件(.dat)加了密,經過分析就是一個帶密碼的標準Zip協議壓縮後的文件,

  1. 如何得到密碼?
a. 修改其使用的zip.dll,列印出密碼
or b. 調試得出
  1. 如何插入自己的程式碼?

前文提到過,插點程式碼程式就起不來了,但難得住我們嗎?

a. 故布疑雲,程式計算解壓密碼
讀懂了程式後,發現其會根據exe本身的內容和大小,做一系列計算,得出zip包的解壓縮密碼
(還記得嗎,當初我以為是Eaz保護後做了完備性/防篡改校驗,其主要意義是保護資源,但是間接也防止了篡改,算一個一箭雙鵰的保護技巧吧 :|)

b. 張冠李戴,我們來代入正確密碼
既然我們改了程式會導致「密碼計算」出錯,那直接寫死個正確密碼不就得了(實際上是多個文件,多套密碼),

c. 偷梁換柱,載入我們修改後的dll
而使用這個密碼並進行解壓縮操作的dll恰好是通過上文提到的作為「EmbeddedResource」載入的,那麼如法炮製,在dnspy中添加資源,並去掉程式對資源文件的解密/哈希過程,載入我們自己的dll即可

3,自由王國開啟,但仍有霧霾籠罩

爽點:
可以Hook進我們自己的程式碼後,基本就是進入了自由王國,想幹啥幹啥,配個圖,實現新兵種添加:

更爽點:
我們脫掉了虛擬機保護了嗎?沒有,我們只是十分熟悉了它並利用規律達到我們的目的。

技術男的終極目標必須得是:寫出一個脫殼機DeVirtualizer!!!

那這個就交給大家吧 ^^

(完)