动态方法和 CLR 的一些乐趣 (第 2 部分)
使用 CLR 和由动态程序集创建的方法做一些疯狂而危险的事情
引言
这个周末,我使用 DynamicAssembly
和 ILGenerator
编写了一个微小的泛型方法 DirectCast
,这为神秘的 CLR 内部打开了一个小小的窗口。
本部分包含以下章节。欢迎任何批评。
微小、恶毒的 DirectCast 方法
本文讨论的这个微小方法 DirectCast<TX, TY>
可以借助 DynamicAssembly
类和不到 20 行的代码来构建。它就在这里
static void Main(string[] args) {
const string FileName = "ClrHacker.dll";
var a = AppDomain.CurrentDomain
.DefineDynamicAssembly(new AssemblyName("ClrHacker"), AssemblyBuilderAccess.RunAndSave);
var mod = a.DefineDynamicModule(FileName);
var type = mod.DefineType("ClrHacker",
TypeAttributes.Public | TypeAttributes.Sealed |
TypeAttributes.Abstract | TypeAttributes.Class);
CreateDirectCastMethod(type);
type.CreateType();
a.Save(FileName);
Console.WriteLine(FileName + " was saved. Now you can reference it in other projects.");
}
CreateDirectCastMethod
列在下面
static void CreateDirectCastMethod(TypeBuilder type) {
var m = type.DefineMethod("DirectCast", MethodAttributes.Public | MethodAttributes.Static);
var g = m.DefineGenericParameters("TX", "TY");
m.SetParameters(g[0]);
m.SetReturnType(g[1]);
var il = m.GetILGenerator();
il.Emit(OpCodes.Ldarg_0);
il.Emit(OpCodes.Ret);
}
构建并运行程序。您将得到一个名为 ClrHacker.dll 的小型程序集,其中包含一个名为 DirectCast
的泛型方法,它位于 ClrHacker
类中,在 ILSpy
中看起来像这样。这个小方法 DirectCast
是本文的主角。它接受 TX
的一个实例并将其作为 TY
返回。
public static TY DirectCast<TX, TY>(TX P_0)
{
return (TY)P_0;
}
您永远无法通过 C# 编译器构建这样的方法,因为它不允许您直接转换 P_0
,其类型为未知的 TX
,到另一个未知的类型 TY
,而这两个类型参数都没有任何限制或关系。
您也无法像这样构建一个工作的 DynamicMethod
,因为如果您使用 CreateDirectCastMethod
中的代码构建一个类似的 DynamicMethod
,在运行该方法时会抛出一个验证异常。
然而,当我构建了一个 DynamicAssembly
,将其保存到磁盘,并在另一个项目中引用它时,上述方法竟然能够运行并产生了一些炫酷的结果。
似是而非、似非而是的布尔值
许多 C# 程序员都知道,Boolean
值只能是 true
或 false
。怎么可能同时既不是 true
也不是 false
呢?
它是如何发生的。
True 和 False 的底层值
使用 DirectCast<TX, TY>
,我们可以将 boolean
值转换为整数值。让我们运行以下代码,我们将在控制台上看到 t(rue) = 1
和 f(alse) = 0
。
var t = ClrHacker.DirectCast<bool, byte>(true);
var f = ClrHacker.DirectCast<bool, byte>(false);
Console.WriteLine("t(rue) = " + t.ToString());
Console.WriteLine("f(alse) = " + f.ToString());
实际上,CLR 中的布尔值是 1
或 0
。
更多的布尔值及其之间的比较
反过来,我们可以使用 DirectCast<TX, TY>
来创建其他一些 bool
值,方法是将整数值转换为 bool
!
var TRUE = true;
var FALSE = false;
bool b8 = ClrHacker.DirectCast<byte, bool>(8);
Console.WriteLine("b8 = " + b8.ToString());
Console.WriteLine("b8 == true: " + (b8 == true).ToString());
Console.WriteLine("b8 == false: " + (b8 == false).ToString());
Console.WriteLine("b8 == TRUE: " + (b8 == TRUE).ToString());
Console.WriteLine("b8 == FALSE: " + (b8 == FALSE).ToString());
在上面的代码中,我们首先将局部变量 TRUE
和 FALSE
分配给相应的 bool
值,然后直接将 **数字 8** 转换为 bool
值并存储到局部变量 b8
中。结果如下所示
b8 = True
b8 == true: True
b8 == false: False
b8 == TRUE: False
b8 == FALSE: False
这是结果的解读
- 对
b8
(请记住它实际上是8
) 执行Boolean.ToString
方法,它打印True
。这是预期的。 (b8 == true).ToString
将被编译成执行与b8.ToString
相同的事情。(您可以对此以及下面的内容进行验证,可以使用 ILSpy)。(b8 == false).ToString
将被编译成加载b8
和 **数字 0** 到评估堆栈上,并执行ceq
(检查相等性) 操作。(b8 == TRUE)
将被编译成加载b8
和Boolean
变量TRUE
到评估堆栈上,并执行ceq
操作。(b8 == FALSE)
也会发生类似的事情。
检查最后两行,我们会看到存在一个 bool
值,它既不等于现有的 true
值,也不等于另一个现有的 false
值。
现在我们用 **数字 7** 再次创建另一个 *数值布尔值*。
bool b7 = ClrHacker.DirectCast<byte, bool>(7);
Console.WriteLine("b7 = " + b7.ToString());
Console.WriteLine("b7 == true: " + (b7 == true).ToString());
Console.WriteLine("b7 == false: " + (b7 == false).ToString());
Console.WriteLine("b7 == TRUE: " + (b7 == TRUE).ToString());
Console.WriteLine("b7 == FALSE: " + (b7 == FALSE).ToString());
猜猜您会看到什么,并与输出进行比较。
b7 = True
b7 == true: True
b7 == false: False
b7 == TRUE: False
b7 == FALSE: False
如果您已经习惯了似是而非、似非而是的布尔值,那么这里就没有什么新鲜事了。那么,如何比较 b8
和 b7
呢?
Console.WriteLine("b8 = " + b8.ToString());
Console.WriteLine("b7 = " + b7.ToString());
Console.WriteLine("b8 == b7: " + (b8 == b7).ToString());
输出显示 b8
和 b7
都打印 True
到控制台,但它们不相等。原因其实非常简单,它们实际上是不同的数字。
b8 = True
b7 = True
b8 == b7: False
证明布尔值是单字节
sizeof(bool)
会告诉您,一个 bool
值是单个字节。
使用 DirectCast
,我们编写以下代码
bool b15 = ClrHacker.DirectCast<int, bool>(15);
bool b255 = ClrHacker.DirectCast<int, bool>(255);
bool b1023 = ClrHacker.DirectCast<int, bool>(1023);
bool b1024 = ClrHacker.DirectCast<int, bool>(1024);
Console.WriteLine("b255 == b15: " + (b255 == b15).ToString());
Console.WriteLine("b255 == b1023: " + (b255 == b1023).ToString());
Console.WriteLine("b255 == b1024: " + (b255 == b1024).ToString());
这里,我们有四个 bool
值,分别由数字 15
、255
、1023
和 1024
创建。选择这四个数字的原因是,前两个在字节范围内,而后两个需要两个字节。在执行 DirectCast<int, bool>
时,后两个将被截断为单个字节,如果 bool
类型是单字节的话。
结果如下
b255 == b15: False
b255 == b1023: True
b255 == b1024: False
由于 1023
(二进制 1 1111 1111
) 被截断为 255
(二进制 1111 1111
),所以比较返回 True
。而 1024
(二进制 10 0000 0000
) 被截断为 0
,比较返回 False
。
为了证明这一点,我们可以执行以下代码将这些 bool
值转换回 int
Console.WriteLine("(int)b1023 = " + ClrHacker.DirectCast<bool, int>(b1023).ToString());
Console.WriteLine("(int)b1024 = " + ClrHacker.DirectCast<bool, int>(b1024).ToString());
我们将看到 1023
被截断为 255
(int)b1023 = 255
(int)b1024 = 0
有关布尔值内部机制的更多信息可以在这里阅读:C# 中布尔值的大小是多少?它真的占用 4 个字节吗?。
任务:修改私有字段
几乎所有的 C# 程序员都知道,private
字段在定义它们的类之外是无法访问的,但它们可以通过反射、动态方法或 P/Invoke 封送来修改。
使用 DirectCast
,还有另一种方法。没有反射,没有动态方法,没有封送。
警告我们将探索 CLR 中隐藏的一个 **危险** 部分!
我们这里进行的操作可能会完全导致应用程序崩溃。
我不建议您在生产环境中使用此技巧。
好公民——类 Me
我们从一个好公民开始,一个普通的类 Me
,它包含两个 public
属性(以及编译器相应生成的两个后备字段)和一个 public
方法。
sealed class Me
{
public DateTime Date { get; set; }
public string Word { get; set; }
public void Tell() {
Console.Write("Me: ");
Console.WriteLine(Date.ToShortDateString() + "(" + Date.Ticks.ToString("X16")
+ "," + Date.Ticks.ToString() + "): " + Word);
}
}
可怜害羞的类 A
“害羞的类” A
是 Me
的“双胞胎姐妹”。A
拥有与 Me
相同数量和类型的字段,还有一个 public
方法。但是 A
中的所有字段都是 private
,其构造函数也是如此。
sealed class A
{
DateTime value;
string text;
private A() { }
public void Print() {
Console.Write("A: ");
Console.WriteLine(value.ToString() + ", " + text);
}
}
直接转换 Me 到 A
通常,A
是如此 private
以至于它无法初始化且无法修改。但是恶毒的 DirectCast
方法为我们打开了一个后门。
最初,我们实例化一个 Me
的实例并将其赋给局部变量 me
。
Me me = new Me { Date = new DateTime(1997, 7, 1), Word = "Hello world!" };
me.Tell();
之后,我们使用 DirectCast
将变量 me
转换为另一个变量 a
,类型为 A
。
// make a reference to me
A a = ClrHacker.DirectCast<Me, A>(me);
a.Print();
控制台上的结果显示
Me: 1997/7/1(08BE53C8DA54C000,630033120000000000): Hello world!
A: 1997/7/1 0:00:00, Hello world!
我们从未给 a
赋任何字段值。但是,它打印了与 me
相同的值。
提问我们能说我们曾经实例化过类
A
的实例吗?(答案在下面)
更改私有字段
现在,我们将使类 A
中的 Print
方法打印另一个内容,而无需触碰变量 a
。
我们更改了好公民 me
的值。
me.Date = new DateTime(1999, 12, 31);
我们还可以选择调用 me
的 Tell
方法和 a
的 Print
方法,以验证更改。
me.Tell();
a.Print();
结果如下
Me: 1999/12/31(08C121391D7A8000,630821952000000000): Hello world!
A: 1999/12/31 0:00:00, Hello world!
提问好公民
Me
是否被利用来做坏事——操纵不可触及的A
?
它是如何工作的?
在这种情况下,类 Me
和类 A
都是引用类型。在 CLR 中,**引用类型是指向内存地址的指针**。
C 程序员非常清楚,如果两个 *C 结构体* 具有相同的内存布局,那么它们可以来回转换。
对于 CLR 的 class
(引用类型),似乎也是如此。变量 me
和变量 a
实际上指向相同的内存槽。我们可以说,当我们改变变量 me
指向的内存槽的 Date
属性时,我们也改变了变量 a
指向的相应字段。
未伪装的视图
Visual Studio 中的调试器将揭示 a
底层信息仍然是一个 Me
类。
为什么调试器知道变量 a
是伪装的 Me
类,而 CLR 仍然假装变量 a
是 A
类呢?
这是因为 .NET CLR 中对象的内存布局与原生 C 程序不同。每个 .NET 对象都有一些开销,称为 *对象头* 和 *方法表指针*(详情请参阅托管对象内部,第 1 部分。布局)。尽管我们通过让两个不同的变量指向同一数据槽成功地欺骗了 CLR,但调试器仍然可以通过检查对象头来发现变量 a
的底层类型。同时,如果我们运行以下代码,我们也会在控制台上读到“ClrFun2.Me”。
Console.WriteLine(a.GetType().FullName)
因此,在上面的章节中,我们实际上是在处理伪装的 Me
实例,它看起来像另一个类。
是否有可能实例化一个 A
的实例并更改其字段值?是的。
通过公共“虫洞”修改私有字段
在基本类库中,有一个方法 FormatterServices.GetUninitializedObject
,它可以创建一个未初始化的对象。因此,我们可以使用它来创建一个 A
的实例——分配相应的内存槽。然后我们 DirectCast
A
到 Me
并更改 Me
的 public
属性。A
实例中的字段将同时更改。
var u = System.Runtime.Serialization.FormatterServices.GetUninitializedObject(typeof(A)) as A;
me = ClrHacker.DirectCast<A, Me>(u); // open a public wormhole
Console.WriteLine(u.GetType().FullName);
u.Print();
me.Tell();
me.Date = new DateTime(1999, 12, 25); // change me, change u
me.Word = "Happy X'Mas";
u.Print();
me.Tell();
控制台输出如下
ClrFun2.A
A: 0001/1/1 0:00:00,
Me: 0001/1/1(0000000000000000,0):
A: 1999/12/25 0:00:00, Happy X'Mas
Me: 1999/12/25(08C11C821F000000,630816768000000000): Happy X'Mas
现在我们成功地初始化了一个无法公开初始化的对象并为其赋值。
DirectCasted 的原始值类型
我们已经了解到 DirectCast
方法可以伪装引用类型。那么值类型呢?
在这篇文章的前一章中,我们已经将整数类型 DirectCast
到了布尔类型。GetType
函数会像这样揭露它吗?
让我们用以下代码进行测试
bool b = ClrHacker.DirectCast<int, bool>(7);
Console.WriteLine(b.GetType().FullName);
我们将在控制台上读到“*System.Boolean
*”,而不是“System.Int32
”。为什么 GetType
函数未能揭示 DirectCast
ed 值?
如果我们用 ildasm 反编译上面的代码,我们可以读到以下关于在调用 GetType
之前发生的事件的 IL 代码。
ldc.i4.7
call !!1 ClrHacker::DirectCast<int32, bool>(!!0)
box [mscorlib]System.Boolean
call instance class [mscorlib]System.Type [mscorlib]System.Object::GetType()
第一行将 Int32
数字 7 加载到评估堆栈上。
第二行消耗堆栈上的数字并调用 DirectCast
方法,该方法将值推回评估堆栈。
任何值类型都必须先 box
成一个引用实例,然后才能访问其成员。.NET CLR 中。要 box
一个值类型,必须提供其类型。因此,C# 编译器使用变量 b
是 Boolean
类型的信息,并生成指令来 box
DirectCast
的返回值,然后 call
GetType
函数。
如果变量 b
是引用类型,则不会发生 box
操作。因此,在调用 GetType
之前没有类型信息交换,从而返回变量的原始类型,如上一节所示。
修改不可变值类型实例
让我们再举一个例子,将自定义值类型与原始类型进行转换
long b = ClrHacker.DirectCast<DateTime, long>(DateTime.MaxValue);
Console.WriteLine(b);
上面的例子将因以下消息而崩溃。
Unhandled Exception: System.InvalidProgramException: Common Language Runtime detected
an invalid program.
C/C++ 程序员可能会认为,既然在不安全上下文中 sizeof(DateTime)
返回 8,这与 sizeof(long)
的结果相同,那么这两种类型应该可以 DirectCast
。然而,这在 CLR 世界中是行不通的。
如果我们用下面的代码将 long
类型替换为另一个自定义值类型 DateTimeStruct
,它就可以工作。
struct DateTimeStruct
{
public int T, U;
}
DateTimeStruct b = ClrHacker.DirectCast<DateTime, DateTimeStruct>(DateTime.MaxValue);
急需帮助如果有人知道为什么它行不通,请评论。
要将 DateTime
实例 DirectCast
到 long
类型,我们必须使用另一个版本的 DirectCast
,它可以由以下代码构建。方法体与先前版本相同,唯一的区别是参数和返回类型都更改为 ref
类型。随后,此方法可以为值类型实例打开“虫洞”(引用)。
// creates: ref TY DirectCast<TX, TY>(ref TX) static void CreateRefDirectCastMethod(TypeBuilder type) { var m = type.DefineMethod("DirectCast", MethodAttributes.Public | MethodAttributes.Static); var g = m.DefineGenericParameters("TX", "TY"); m.SetParameters(g[0].MakeByRefType()); m.SetReturnType(g[1].MakeByRefType()); var il = m.GetILGenerator(); il.Emit(OpCodes.Ldarg_0); il.Emit(OpCodes.Ret); }
然后,我们可以使用以下代码来利用新的 DirectCast
方法。
var t = DateTime.MaxValue;
ref long b = ref ClrHacker.DirectCast<DateTime, long>(ref t);
// or
// long b = ClrHacker.DirectCast<DateTime, long>(ref t);
Console.WriteLine($"b = {b}");
我们将在控制台上看到以下内容,这与 DateTime.MaxValue.Ticks
的值相同,大约是 DateTime
的内部值。
b = 3155378975999999999
ref
关键字将 DateTime
的地址传递给 DirectCast
方法,然后当 DirectCast
返回时,ref long b
保持对 DateTime
结构数据部分的引用。
因此,我们可以通过操作变量 b
提供的“虫洞”来更改 t
的值,而不直接接触它。以下代码片段演示了这一点。
var t = DateTime.MaxValue;
ref long b = ref ClrHacker.DirectCast<DateTime, long>(ref t);
// or
// long b = ClrHacker.DirectCast<DateTime, long>(ref t);
Console.WriteLine($"b = {b}");
Console.WriteLine($"t = {t}");
Console.WriteLine("Changing b...");
b = TimeSpan.TicksPerHour + TimeSpan.TicksPerMinute + TimeSpan.TicksPerSecond;
Console.WriteLine($"b = {b}");
Console.WriteLine($"t = {t}");
我们将在控制台上看到 t
的值已从 9999/12/31 23:59:59 更改为 0001/1/1 1:01:01。
b = 3155378975999999999
t = 9999/12/31 23:59:59
Changing b...
b = 36610000000
t = 0001/1/1 1:01:01
为了协变性而强制转换集合
DirectCast
也可用于转换集合类型。
警告这也很 **危险**!如果它导致应用程序崩溃,它就崩溃了。您没有任何机会捕获任何异常,也看不到任何堆栈跟踪或 Windows 事件查看器中的异常消息。
List<T> 不支持协变性或逆变性
在以下示例中,有一个 BaseClass
的 List<T>
,但其中的所有项都是 SubClass
类型,它是 BaseClass
的派生类。
var b = new List<BaseClass> {
new SubClass { N = 1, Prefix = "A", S2 = "START" },
new SubClass { N = 2, Prefix = "B" },
//new BaseClass { N = 3 },
new SubClass { N = 4, Prefix = "D", S1 = "END" },
};
BaseClass
和 SubClass
的定义如下
class BaseClass
{
public int N { get; set; }
}
sealed class SubClass : BaseClass
{
public string Prefix { get; set; }
public string S1 { get; set; }
public string S2 { get; set; }
}
强制逆变性的 DirectCasting
由于 List<T>
不支持协变性或逆变性,因此无法隐式或显式地将 List<BaseClass>
转换为 List<SubClass>
。使用 DirectCast
,您可以做到这一点,如下面的代码所示
foreach (SubClass item in ClrHacker.DirectCast<List<BaseClass>, List<SubClass>>(b)) {
Console.WriteLine(String.Join(",", item.Prefix, item.N, item.S1, item.S2));
}
取消注释初始化变量 b
的代码片段中的第 4 行(//new BaseClass { N = 3 }
),程序在运行到第 3 项时可能会崩溃,该项不是 SubClass
而是 BaseClass
。
崩溃的原因有点复杂但又很简单。
List<SubClass>
中的第 3 项实际上是一个BaseClass
。SubClass
需要额外的字节来填充其额外的属性(Prefix
、S1
和S2
)——这些字段确实存在,但它们在BaseClass
中不存在。- 填充这些字段将导致 CLR 读取超出
BaseClass
实例占用的内存槽的范围。通常不会导致应用程序崩溃,如果SubClass
的额外字段不超过应用程序内存的边界。CLR 只是将一些“垃圾字节”读入这些额外字段。 - 非常不幸的是,在这个例子中,
SubClass
的额外字段都是String
,而 .NETString
的内存布局前面有一个数字,表示String
的长度。因此,“垃圾字节”可能告诉 CLR 这三个String
的长度可能是非常大的数字。 - 当调用上面代码片段中的
Console.WriteLine
时,CLR 不得不访问item.Prefix
、item.S1
或item.S2
的内容,这些属性的错误长度将导致 CLR 读取超出操作系统分配给应用程序的内存边界,从而导致执行引擎崩溃。
DirectCasted 项目在枚举中的奇怪行为
枚举器生成的 DirectCast
ed item
的行为有点特别。您既不能将其与 null
进行比较,也不能测试它是否 is
SubClass
类型。
var b = new List<BaseClass> {
new SubClass { N = 1, Prefix = "A", S2 = "START" },
new SubClass { N = 2, Prefix = "B" },
new BaseClass { N = 3 },
new SubClass { N = 4, Prefix = "D", S1 = "END" },
};
foreach (SubClass item in ClrHacker.DirectCast<List<BaseClass>, List<SubClass>>(b)) {
if (item == null || item is SubClass == false) {
continue; // this cannot be used to filter out the BaseClass instance
}
Console.WriteLine(String.Join(",", item.Prefix, item.N, item.S1, item.S2));
}
运行上面的代码片段,程序很可能仍然会崩溃而不会抛出任何异常。
**注意**:为什么这个例子中 item is SubClass == false
不起作用是因为编译器已经知道从 foreach
语句来看 item
必须是 SubClass
类型,因此它只是将 is
操作更改为与 null
的比较。用 ILSpy 反编译上面的代码,您会看到这一点。
通过类型比较防止崩溃
正如我们之前所知,对 DirectCast
ed 实例调用 GetType
方法可以揭示其真实类型。防止崩溃的一种方法是强制进行类型比较并跳过不是 SubClass
的项。
foreach (SubClass item in ClrHacker.DirectCast<List<BaseClass>, List<SubClass>>(b)) {
if (item.GetType() != typeof(SubClass)) {
continue;
}
Console.WriteLine(String.Join(",", item.Prefix, item.N, item.S1, item.S2));
}
当然,这很麻烦,不完整(我们也通过上述方式过滤了 SubClass
的子类),而且效率低下,与不使用 DirectCast
ed List
的版本相比。与不使用 DirectCast
ed List
的版本相比,这既麻烦又不完整(我们也过滤了 SubClass
的子类),而且效率低下。
foreach (BaseClass item in b) {
var sub = item as SubClass;
if (sub == null) {
continue;
}
Console.WriteLine(String.Join(",", sub.Prefix, sub.N, sub.S1, sub.S2));
}
只有当您 100% 确定项的类型正确时,才应该使用对集合的 DirectCast
。它实在太危险了。如果您的代码中散布着许多对 DirectCast
的调用,您将很难找出崩溃的根源。
关注点
- 对伪装的
DirectCast
对象调用GetType()
将揭示其真实类型。但是对于值类型对象,在DirectCast
之后会创建一个目标类型的新实例。那么,将小值类型(如int
)DirectCast
到大值类型(如Guid
)是否安全? - 如果我们将一个小类
DirectCast
到一个大类,很有可能会抛出AccessViolationException
,因为后者需要更多的内存槽,而前者没有。但是如果我们把一个大类DirectCast
到一个小类会发生什么? - 看起来我们可以通过将引用对象
DirectCast
到long
来快速获取引用类型实例的内存地址,例如DirectCast<string, long>(anInstanceOfString)
。 - 实际上,这种技巧已经存在很长时间了。我们可以在 .NET Core 库之外的程序集
System.Runtime.CompilerServices.Unsafe
中找到更多信息。源代码可以在 GitHub 上找到,其中包含 CS 伪装和相应的 IL 实现。可以通过 NuGet 下载程序集库。他们使用 IL 和 ilasm 来制作这样一个库。而在本文中,我们通过DynamicAssembly
实现了类似的功能,并且我们得到的程序集甚至可以移植到更早的 .NET 平台,最早可以追溯到 .NET Framework 2.0。
历史
- 2018-9-17:初次发布
- 2018-9-19:+
DirectCast
集合 - 2019-4-16:添加了关于类似实现和 .NET Core 库更多功能的信息
- 2019-10-23:添加了关于直接转换原始值类型的信息
- 2019-10-24:通过
DirectCast
方法的重载添加了关于直接转换自定义值类型到原始值类型的信息