TL;DR
WCTF2018的一道.NET
逆向题,利用了.NET
的即时编译(JIT)
机制,动态修改了Func3函数的函数指针,写了一段脱壳代码进去,异或解密了了一段汇编并将Func4和Func5的函数指针指过去,从而修改了Func4和Func5的逻辑。
Solve
注:得到了FAKEFLAG的话可以跳过PART1
PART1 FAKEFLAG
使用dnSpy反编译程序,发现在Main
函数中调用了Lib.Verify
函数验证了读取的字符串:
private static void Main()
{
Console.Write("Enter your flag: ");
if (Lib.Verify(Console.ReadLine().Trim()))
{
Console.WriteLine("Great :)");
return;
}
Console.WriteLine("Wrong :(");
}
进一步观察Lib.Verify函数:
public static bool Verify(string s)
{
byte[] bytes = Encoding.ASCII.GetBytes(s);
if (bytes.Length != 32)
{
return false;
}
byte[] array = Lib.Func2();
Lib.Func3(array, bytes);
Lib.Func4(array, bytes);
Lib.Func5(array, bytes);
for (int i = 0; i < 32; i++)
{
if (bytes[i] != array[3104 + i])
{
return false;
}
}
return true;
}
我们的输入保存在bytes
中,首先验证了其长度要为32,然后通过Func2
函数生成了一个byte数组array
,之后依次调用了Func3
、Func4
、Func5
三个函数对bytes
和array
进行了操作,最后将bytes
的最终值与array
后面的一段进行了比较。
于是我们首先看Func2函数是如何生成array
数组的:
public static byte[] Func2()
{
AesCryptoServiceProvider aesCryptoServiceProvider = new AesCryptoServiceProvider();
aesCryptoServiceProvider.BlockSize = 128;
aesCryptoServiceProvider.KeySize = 128;
aesCryptoServiceProvider.Mode = CipherMode.CBC;
aesCryptoServiceProvider.Padding = PaddingMode.PKCS7;
byte[] result;
using (BinaryReader binaryReader = new BinaryReader(new MemoryStream(Resources.bin)))
{
aesCryptoServiceProvider.IV = binaryReader.ReadBytes(16);
aesCryptoServiceProvider.Key = binaryReader.ReadBytes(16);
using (CryptoStream cryptoStream = new CryptoStream(binaryReader.BaseStream, aesCryptoServiceProvider.CreateDecryptor(), CryptoStreamMode.Read))
{
using (MemoryStream memoryStream = new MemoryStream())
{
cryptoStream.CopyTo(memoryStream);
result = memoryStream.ToArray();
}
}
}
return result;
}
可以看出,这段代码其中就是读取了一段资源(Resources.bin),并对其进行了AES解密。解密用的IV和key也在其中。我们可以自己写代码解密,也可以直接动态调试拿到。
之后我们再看另外三个Func:
public static void Func3(byte[] b, byte[] x)
{
byte[] array = new byte[32];
for (int i = 0; i < 32; i++)
{
array[i] = (byte)b.Skip(i * 32).Take(32).Zip(x, (byte x1, byte x2) => (int)(x1 * x2)).Sum();
}
Array.Copy(array, x, 32);
}
public static void Func4(byte[] b, byte[] x)
{
byte[] array = new byte[32];
for (int i = 0; i < 32; i++)
{
array[i] = (byte)b.Skip(1024 + i * 32).Take(32).Zip(x, (byte x1, byte x2) => (int)(x1 * x2)).Sum();
}
Array.Copy(array, x, 32);
}
public static void Func5(byte[] b, byte[] x)
{
byte[] array = new byte[32];
for (int i = 0; i < 32; i++)
{
array[i] = (byte)(b.Skip(2048 + i * 32).Take(32).Zip(x, (byte x1, byte x2) => (int)(x1 * x2)).Sum() + (int)b[3072 + i]);
}
Array.Copy(array, x, 32);
}
这三个函数的逻辑都非常清晰,在32次循环中,每次从b数组(即之前aes解密出的array
)读取32个byte,分别与x(即我们输入的bytes
)的对应byte做乘法,用最终的sum构建一个新数组,最后复制回x
数组。所以很明显,这就是将array
中的一段数据作为一个32*32
矩阵,将我们的输入作为一个32*1
的向量,做了一个矩阵乘法。注意Func5最后在结果上又加了一个向量。
有了这些逻辑,我们就很容易实现出一个逆过程,求出三个矩阵的逆,把过程反过来进行一次就好了。然而,最后我们的程序得到了一段FAKEFLAGFAKEFLAGFAKEFLAGFAKEFLAG
,尝试在程序中输入也无法通过。那么是哪里出了问题呢?
PART2 - REAL FLAG
为了寻找问题所在,我们首先尝试用DNSPY动态调试,发现Func3的返回值虽然与我们预期的一样,但是在步入Func3时却没有进Func3,反而是进了Func2。另外Func4和Func5中的断点都没有进去,函数的返回结果也与我们的预期不一样。
于是我们猜测,一定有某个地方对这几个函数做了手脚。于是我们从头把代码看了一遍,最终在Resources.Resources()
中看到了一段可疑的代码:
unsafe static Resources()
{
IntPtr intPtr = ldftn(Func) - 16;
long num = *intPtr;
IntPtr intPtr2 = ldftn(Func) - 8;
long num2 = *intPtr2;
ref long ptr = ldftn(Func) - 16;
IntPtr intPtr3 = ldftn(Func) + 5;
long num3 = (long)(*(intPtr3 + 1));
ptr = *(intPtr3 + (IntPtr)(((int)(*(intPtr3 + 2)) << 3) + 3)) + (num3 << 3);
ref long ptr2 = ldftn(Func) - 8;
object obj = *(ldftn(Func) - 16);
object obj2;
for (;;)
{
obj2 = obj;
if (*obj2 == 5)
{
break;
}
obj = obj2 + 16;
}
ptr2 = *(obj2 + 8);
long num4 = *(ldftn(Func) - 8);
*num4 = 6293447916875450697L;
long num5 = num4 + 8L;
*num5 = 996842507592L;
long num6 = num5 + 8L;
*num6 = -5023708761407594752L;
long num7 = num6 + 8L;
*num7 = 2247216228701921188L;
long num8 = num7 + 8L;
*num8 = 5195160555404404409L;
long num9 = num8 + 8L;
*num9 = 543045289092056715L;
long num10 = num9 + 8L;
*num10 = 612363414786457928L;
long num11 = num10 + 8L;
*num11 = 5245003925894368584L;
long num12 = num11 + 8L;
*num12 = 3816147333L;
long num13 = num12 + 8L;
object obj3 = *(ldftn(Func) - 16);
object obj4;
for (;;)
{
obj4 = obj3;
if (*obj4 == 6)
{
break;
}
obj3 = obj4 + 16;
}
*(obj4 + 8) = *(ldftn(Func) - 8);
object obj5 = *(ldftn(Func) - 16);
object obj6;
for (;;)
{
obj6 = obj5;
if (*obj6 == 7)
{
break;
}
obj5 = obj6 + 16;
}
*(obj6 + 8) = *(ldftn(Func) - 8) + 89L;
object obj7 = *(ldftn(Func) - 16);
object obj8;
for (;;)
{
obj8 = obj7;
if (*obj8 == 8)
{
break;
}
obj7 = obj8 + 16;
}
*(obj8 + 8) = *(ldftn(Func) - 8) + 170L;
*intPtr2 = num2;
*intPtr = num;
}
首先我们可以注意到9个奇怪的long,这些数值被写到了*(ldftn(Func) - 8)
开始的一段地址中。
之后的3个for循环,分别从*(ldftn(Func) - 16)
开始,找到06、07和08开始的16个byte,并把后8byte分别改为*(ldftn(Func) - 8)
、*(ldftn(Func) - 8) + 89
和*(ldftn(Func) - 8) + 170
的地址。联想到之前Func3、Func4、Func5函数无法正常调试且逻辑被修改,我们就可以自然地想到就是在这里修改了这三个函数的指针。而且06、07、08也分别对应了Func3、Func4、Func5 token的最后一个byte:
进一步分析函数指针被修改后指向的位置,Func3的指针被改到了
*(ldftn(Func) - 16)
,而这就是之前9个long写到的地方。可以猜测这9个long就是Func3函数的实际代码。我们再计算一下这9个long的总长度:9*8=72
,而Func4指向的是*(ldftn(Func) - 8) + 89
,显然是在修改的9个long的后面。那这部分的代码是从哪里来的呢?为了搞清具体逻辑,我们再进行动态调试,把断点下在Resources构造函数后面(不知道为什么Resources函数里面无法下断点),在内存中搜索找到这9个long的地址:我们发现了一段疑似的汇编代码。重新调试一次,把断点设在Resources函数前面,将这段内存的内容前后对比一下:
发现确实就是修改了这段汇编代码的开头,并且把三个函数的指针指到了这段汇编代码中。
于是我们Dump下这段内存,用IDA查看,发现这段汇编其实就是Func2函数(那个AES解密函数),只是修改了开头的一部分。但是很显然这两部分并无法拼合在一起,而且也无法想象Func4和Func5其实只是执行Func2的一部分,所以这段代码应该不是最终运行的代码。
于是我们这次把断点下载Func4和Func5执行后,然后再尝试Dump这段汇编:
发现后面的代码被更新了,然后Func4和Func5的入口看起来也比较正常了。这次我们再用IDA打开,逻辑就十分清晰了:
Func3前面一段代码不会影响返回结果(实际上是脱壳代码,后面会详细分析这部分),只在最后做了一个矩阵乘法,跟原来的Func3逻辑是一样的。
Func4对矩阵做了转置,并进行了5次矩阵乘法,并在最后将把向量里的值都异或了0x5A。
Func5中则是将向量异或了Func4中矩阵的前32个Byte,再进行矩阵乘法。这两个操作循环重复10次。
整体逻辑跟之前相差不大,贴一份robin大佬的解题代码,运行得到真Flag:
flag{884RN4SoqUt9Cu87pVSPG0ndA8}
I=Integers(256)
def func1(mat,input):
return mat*input.T
def func1_inv(mat,input):
return (~mat)*input
def func2(mat,input):
for i in range(5):
input=mat.T*input
input=[int(x[0]) for x in input]
return [x^^0x5a for x in input]
def func2_inv(mat,input):
input=[x^^0x5a for x in input]
input=matrix(I,input)
input=input.T
for i in range(5):
input=(~mat.T)*input
return input
def func3(mat,input,iv):
for i in range(10):
input=[x^^y for x,y in zip(input,iv)]
input=matrix(I,input)
input=mat*input.T
input=[int(x[0]) for x in input]
return input
def func3_inv(mat,input,iv):
for i in range(10):
input=matrix(I,input)
input=(~mat)*(input.T)
input=[int(x[0]) for x in input]
input=[x^^y for x,y in zip(input,iv)]
return input
f=open("bin","rb")
data=f.read()
f.close()
m1=map(ord,data[:1024])
m2=map(ord,data[1024:1024*2])
m3=map(ord,data[1024*2:1024*3])
m4=map(ord,data[1024*3:1024*3+32])
m5=map(ord,data[1024*3+32:1024*32+64])
input=[48]*32
input=matrix(I,input)
ma1=[m1[i*32:i*32+32] for i in range(32)]
ma2=[m2[i*32:i*32+32] for i in range(32)]
ma3=[m3[i*32:i*32+32] for i in range(32)]
ma1=matrix(I,ma1)
ma2=matrix(I,ma2)
ma3=matrix(I,ma3)
rm1= func1(ma1,input)
assert( input.T == func1_inv(ma1,rm1))
rm2=func2(ma2, rm1)
assert( func2_inv(ma2,rm2)==rm1)
rm3=func3(ma3,rm2,[int(x) for x in ma2[0]])
assert( func3_inv(ma3,rm3,[int(x) for x in ma2[0]])==rm2)
re2=func3_inv(ma3,m5,[int(x) for x in ma2[0]])
re1=func2_inv(ma2,re2)
re0=func1_inv(ma1,re1)
re=[int(x[0]) for x in re0]
flag=map(chr,re)
print "".join(flag)
Appendix
这道题虽然解决了,但其中还有一些细节我们还是没有搞懂。例如为什么.NET程序的函数最终指向一段汇编,Func5和Func6的汇编代码是从哪里出来等。在比赛后,我们又进行了一点简单的探索。
.NET即时编译机制
.NET中的C#、VB.NET、F#等语言的编译过程并不是像C/C++一样直接编译出原生代码,而是首先编译成IL中间语言,在运行时,再由JIT (Just-In-Time) compiler根据需要,将IL编译成汇编代码。我们运行的.NET的exe中实际上保存的就是IL,而不是汇编代码:
实际执行中,函数的调用需要通过Method Table来找到method的地址。在程序运行的开始,函数指针指向一个Stub。在函数被调用时,JIT compiler会编译这个method并将原本指向Stub的指针指向编译好的代码。于是,之后调用该函数就直接执行这段汇编代码。
我们可以使用WinDbg工具来帮助我们理解这一过程:
> !DumpMT -md 0007ff9ec805b00
EEClass: 00007ff9ec992068
Module: 00007ff9ec804118
Name: WCTF2018Rev.Lib
mdToken: 0000000002000003
File: D:\ctf\wctf\WCTF2018Rev_Release.exe
BaseSize: 0x18
ComponentSize: 0x0
Slots in VTable: 11
Number of IFaces in IFaceMap: 0
--------------------------------------
MethodDesc Table
Entry MethodDesc JIT Name
00007ffa4ae3c000 00007ffa4a997fb8 PreJIT System.Object.ToString()
00007ffa4aea40f0 00007ffa4a997fc0 PreJIT System.Object.Equals(System.Object)
00007ffa4af14490 00007ffa4a997fe8 PreJIT System.Object.GetHashCode()
00007ffa4ae6f390 00007ffa4a998000 PreJIT System.Object.Finalize()
00007ff9ec9100c0 00007ff9ec805af8 NONE WCTF2018Rev.Lib..ctor()
00007ff9ec910500 00007ff9ec805a98 JIT WCTF2018Rev.Lib.Verify(System.String)
00007ff9ec910098 00007ff9ec805aa8 NONE WCTF2018Rev.Lib.Func(Int32)
00007ff9ec9100a0 00007ff9ec805ab8 NONE WCTF2018Rev.Lib.Func2()
00007ff9ec9100a8 00007ff9ec805ac8 NONE WCTF2018Rev.Lib.Func3(Byte[], Byte[])
00007ff9ec9100b0 00007ff9ec805ad8 NONE WCTF2018Rev.Lib.Func4(Byte[], Byte[])
00007ff9ec9100b8 00007ff9ec805ae8 NONE WCTF2018Rev.Lib.Func5(Byte[], Byte[])
观察Verify函数中,在运行Func2之前的Method Table。内存中Method Table的格式是每个函数8个byte的MethodDesc加上后面8个byte的Method Entry(就是函数指针,之后称Method Entry):
00007ff9`ec805a98 03 00 00 21 05 00 28 00 00 05 91 ec f9 7f 00 00 (Verify)
00007ff9`ec805aa8 04 00 02 20 06 00 28 00 98 00 91 ec f9 7f 00 00 (Func)
00007ff9`ec805ab8 05 00 04 20 07 00 28 20 a0 00 91 ec f9 7f 00 00 (Func2)
00007ff9`ec805ac8 06 00 06 20 08 00 28 00 a8 00 91 ec f9 7f 00 00 (Func3)
00007ff9`ec805ad8 07 00 08 20 09 00 28 00 b0 00 91 ec f9 7f 00 00 (Func4)
00007ff9`ec805ae8 08 00 0a 20 0a 00 28 20 b8 00 91 ec f9 7f 00 00 (Func5)
还没有被JIT编译的函数,其Method Entry指向一个Stub(在WinDbg中显示为NONE)
> dd 00007ff9ec910098
00007ff9`ec910098 614313e8 05025e5f (Stub of Func) 61430be8 04045e5f (Stub of Func2)
00007ff9`ec9100a8 614303e8 03065e5f (Stub of Func3) 6142fbe8 02085e5f (Stub of Func4)
00007ff9`ec9100b8 6142f3e8 010a5e5f (Stub of Func5) 6142ebe8 000c5e5f
由于Verify函数已经被执行,所以已经被JIT编译,其Method Entry指向其汇编实现,而其他函数都指向Stub。
如果我们完整地运行完程序(Func3、Func4、Func5均已执行完):
> !DumpMT -md 00007ff9ec835b00
EEClass: 00007ff9ec9c2068
Module: 00007ff9ec834118
Name: WCTF2018Rev.Lib
mdToken: 0000000002000003
File: D:\ctf\wctf\WCTF2018Rev_Release.exe
BaseSize: 0x18
ComponentSize: 0x0
Slots in VTable: 11
Number of IFaces in IFaceMap: 0
--------------------------------------
MethodDesc Table
Entry MethodDesc JIT Name
00007ffa4ae3c000 00007ffa4a997fb8 PreJIT System.Object.ToString()
00007ffa4aea40f0 00007ffa4a997fc0 PreJIT System.Object.Equals(System.Object)
00007ffa4af14490 00007ffa4a997fe8 PreJIT System.Object.GetHashCode()
00007ffa4ae6f390 00007ffa4a998000 PreJIT System.Object.Finalize()
00007ff9ec9400c0 00007ff9ec835af8 NONE WCTF2018Rev.Lib..ctor()
00007ff9ec940500 00007ff9ec835a98 JIT WCTF2018Rev.Lib.Verify(System.String)
00007ff9ec940098 00007ff9ec835aa8 NONE WCTF2018Rev.Lib.Func(Int32)
00007ff9ec9405c0 00007ff9ec835ab8 JIT WCTF2018Rev.Lib.Func2()
00007ff9ec9405c0 00007ff9ec835ab8 JIT WCTF2018Rev.Lib.Func2() (shoule be Func3)
00007ff9ec940619 00007ff9ec835ab8 JIT WCTF2018Rev.Lib.Func2() (shoule be Func4)
00007ff9ec94066a 00007ff9ec835ab8 JIT WCTF2018Rev.Lib.Func2() (shoule be Func5)
这里由于Method Table被改掉了,WinDbg显示有点问题。不过Entry还是可以正常看,说明我们之前的分析结果是正确的:
修改之后,Func3指向了Func2的汇编地址(Func2汇编的前一部分是被9个long修改过的),Func4指向了Func2 + 0x59, Func5指向了Func2 + 0xaa。
到这里我们的第一个问题就解决了。顺便附上WinDbg用到的相关命令:
- 加载SOS.dll
> .load C:\Windows\Microsoft.NET\Framework64\v4.0.30319\SOS.dll
- 设断点并运行到断点
> bp 7ffa9bcece5d
> g
- 加载sos模块
> .loadby sos clr
- 找到Method Table地址
> !Name2EE * WCTF2018Rev.Lib
--------------------------------------
Module: 00007ff9ec834118
Assembly: WCTF2018Rev_Release.exe
Token: 0000000002000003
MethodTable: 00007ff9ec835b00
EEClass: 00007ff9ec9c2068
Name: WCTF2018Rev.Lib
--------------------------------------
- 打印Method Table
!DumpMT -md 00007ff9ec835b00
EEClass: 00007ff9ec9c2068
Module: 00007ff9ec834118
Name: WCTF2018Rev.Lib
mdToken: 0000000002000003
File: D:\ctf\wctf\WCTF2018Rev_Release.exe
BaseSize: 0x18
ComponentSize: 0x0
Slots in VTable: 11
Number of IFaces in IFaceMap: 0
--------------------------------------
MethodDesc Table
Entry MethodDesc JIT Name
00007ffa4ae3c000 00007ffa4a997fb8 PreJIT System.Object.ToString()
00007ffa4aea40f0 00007ffa4a997fc0 PreJIT System.Object.Equals(System.Object)
00007ffa4af14490 00007ffa4a997fe8 PreJIT System.Object.GetHashCode()
00007ffa4ae6f390 00007ffa4a998000 PreJIT System.Object.Finalize()
00007ff9ec9400c0 00007ff9ec835af8 NONE WCTF2018Rev.Lib..ctor()
00007ff9ec940500 00007ff9ec835a98 JIT WCTF2018Rev.Lib.Verify(System.String)
00007ff9ec940098 00007ff9ec835aa8 NONE WCTF2018Rev.Lib.Func(Int32)
00007ff9ec9405c0 00007ff9ec835ab8 JIT WCTF2018Rev.Lib.Func2()
00007ff9ec9405c0 00007ff9ec835ab8 JIT WCTF2018Rev.Lib.Func2()
00007ff9ec940619 00007ff9ec835ab8 JIT WCTF2018Rev.Lib.Func2()
00007ff9ec94066a 00007ff9ec835ab8 JIT WCTF2018Rev.Lib.Func2()
- 打印Method Description
> !DumpMD 00007ff9ec835ab8
Method Name: WCTF2018Rev.Lib.Func2()
Class: 00007ff9ec9c2068
MethodTable: 00007ff9ec835b00
mdToken: 0000000006000005
Module: 00007ff9ec834118
IsJitted: yes
CodeAddr: 00007ff9ec9405c0
Transparency: Critical
参考:
浅谈.NET中的IL代码
Debugging .NET with WinDbg
Method加壳
另一个问题就是Func4和Func5的汇编是如何生成的。
我们已经知道Method在运行时,根据Method Entry找到Method的汇编代码。而在Resources函数中,Func4和Func5的Method Entry指针都被指向了Func2中一段无意义代码中,只有Func3指向的是一段修改过的代码(看起来比较像脱壳代码)。所以我们再来仔细看一下这段代码的实现:
我们可以看到,在for循环中,v5指向的地址被修改了共
38*8=0x130
个字节,而v5指向的恰恰就是Func3函数的结束部分(从0x44开始):修改的这部分就是Func4和Func5的汇编代码。而这些代码是从哪里来的呢?
我们继续观察脱壳代码,发现其实就是把array数组从0x8开始的部分与一个常数进行了
0x1F2FB740F5455FA4
异或解密(实际上每次异或都会把这个常数按位循环移动一下)。就是说,这个array数组(就是resource.bin那个资源)不仅构造出了个FAKEFLAG,构造出了个真FLAG,还特么藏了两个函数在里面(你以为我是Flag,其实我还是个函数)。不得不说出题人还真的有点想法(可惜线下赛基本被所有队做出来了‧_‧)。
未解决问题
有一个没搞明白的地方,就是在Resources函数中,遍历Method Table的指针:
IntPtr intPtr = ldftn(Func) + 5;
long num = (long)(*(intPtr + 1));
ptr = *(intPtr + (IntPtr)(((int)(*(intPtr + 2)) << 3) + 3)) + (num << 3);
这个ptr指针是如何计算出来的,还是没弄明白……