目录
修改readonly字段:
问题的研究:
其实大家都懂的,我就不多废话,直接重点:
比如:
const int i = 23;
const int ii = i + 1;
当你用const常量时,比如:
Console.WriteLine(ii);
实际这个语句会被编译成:
//因为ii=i(23)+1
Console.WriteLine(24);
所以const常量看起来是字段,实际根本不是,const常量的值是中间语言(IL)本地支持的。
所以:
class a
{
public readonly int i;
public a(int arg)
{
i = arg;
}
}
class b : a
{
//错误!编译不会通过
public b(int arg)
{
i = arg;
}
//正确
public b(int arg)
: base(arg)
{ }
}
readonly字段在本类的构造函数外不能被赋值,也不能被传入ref/out方法内,但是在构造函数内可以:
class a
{
public readonly int i;
public a()
{
//可以这样做
test(ref i);
}
void test(ref int id)
{
id = 4;
}
}
好了,下面开始修改readonly字段。
要说什么方法不用我们另外做手脚直接了当快速轻松得修改readonly只读属性莫过于使用.NET中的正规方式,当你直接对readonly字段进行“=”赋值操作然后编译,VS肯定给你个错误拒绝编译。但是另一种动态赋值方式——反射你有没有试过呢???
好吧或许你曾经问过某高手说readonly字段能不能被修改,他一定会回答:“笨蛋,这还用问,能修改还叫readonly吗?”。于是聪明人知道了并记住了(或许聪明人根本不会问这样的问题)。但是……一个笨的很笨的小笨他太笨了所以还是想试试,于是先用第一种直接静态赋值方式,VS编译错误。又尝试另一种运行后的动态赋值,于是他成功了!
是的,静态方式编译器不允许,但是动态赋值——反射却背叛了它,运行后用反射想怎么改就怎么改!
看下面代码:
class Program
{
static readonly object data = 1;
static void Main()
{
Console.WriteLine(data);
mod();
Console.WriteLine(data);
}
static void mod()
{
//得到静态的非公有字段信息
var fieldInfo = typeof(Program).GetField("data", BindingFlags.Static | BindingFlags.NonPublic);
fieldInfo.SetValue(null, "MOD");
}
}
输出:
1
MOD
值被成功修改了。
如果你经常用C#调用非托管代码的话,P/Invoke和各种Marshal操作你会很熟悉。而另一个常用到的System.Runtime.InteropServices的StructLayout和FieldOffset特性是用来定义一个显示的字段内存布局的结构体。
没人可以阻止我们把两个字段对在一个位置,然后用一个非readonly字段去彻底控制一个readonly字段!!!
代码
using System;
using System.Runtime.InteropServices;
namespace Mgen.TTC
{
[StructLayout(LayoutKind.Explicit)]
struct a
{
[FieldOffset(0)]
public string s;
[FieldOffset(0)]
public readonly string READ_ONLY;
//readonly字段
public a(string arg)
{
//初始化readonly字段
s = READ_ONLY = arg;
}
}
class Program
{
static void Main()
{
//此时oa的readonly字段被初始化成字符串:"常量"
a oa = new a("常量");
Console.WriteLine(oa.READ_ONLY);
oa.s = "你被改了……";
Console.WriteLine(oa.READ_ONLY);
}
}
}
输出:
常量
你被改了……
很好,这里修改变量s就等价于修改常量字段:READ_ONLY。两个字段是对在同一个内存位置的,然后通过修改一个非readonly字段去间接修改一个readonly字段。
当你使用Visual Studio(或其他C#编译器)时对readonly字段进行赋值肯定结果是编译错误。于是你很想强行通过这条语句(于是你又非常使劲得按了下Ctrl + F5,结果……),好吧C#编译器都一个样,无法去改变它,但是C#幕后的IL并非如此。接下来让我们用ilasm.exe创建一个对readonly字段进行直接赋值的程序。
ilasm的编译需要一个IL文件,这个IL文件就是文本文件,你可以自己写IL,不过这个太麻烦了,我们可以用IL反编译程序ildasm.exe来把一个托管程序集的IL代码框架先生成,然后导出,最后加入自己的代码再用ilasm编译。
首先创建一个C#控制台程序,然后加一个readonly只读字段,编译(这样做我们就不用从头开始写IL)。如下:
class Program
{
static readonly string s = "hehe";
static void Main(string[] args)
{ }
}
接着用ildasm.exe打开这个程序。
(ildasm在Windows SDK中,Windows SDK路径在:%PROGRAMFILES%\Microsoft SDKs\Windows)
在File菜单选择Dump(或直接按Ctrl+D)。选择OK将IL文件保存。
然后用文本编辑器打开这个IL文件,找到Main函数(可以通过查找文字:Main)。在主函数.entrypoint指令下加入下列指令:
代表4条C#语句:输出s,修改s,再输出s,Console.Read()。
(注意下面的ConsoleApplication1.Program::s代表s字段的完整命名空间,根据你的程序做适当修改)
.maxstack 8
IL_0000: ldsfld string ConsoleApplication1.Program::s //s进栈
IL_0005: call void [mscorlib]System.Console::WriteLine(string) //输出s
IL_000a: ldstr bytearray (AB 88 EE 4F 39 65 ) //字符串被修改"
IL_000f: stsfld string ConsoleApplication1.Program::s //将"被修改"赋值给s
IL_0014: ldsfld string ConsoleApplication1.Program::s //s进栈
IL_0019: call void [mscorlib]System.Console::WriteLine(string) //输出s
IL_001e: call int32 [mscorlib]System.Console::Read() //Console.Read()
IL_0023: pop
IL_0024: ret
接下来就用ilasm.exe来编译IL文件。
(ilasm在.NET Framework安装目录下,路径:%SYSTEMROOT%\microsoft.net\framework\)
将刚才修改后的IL文件路径作为第一个参数传入ilasm.exe中,ilasm便会将IL编译成可执行文件。然后运行这个exe。
程序会输出:
hehe
被修改
你可以在Reflector(或ILSpy)中打开这个程序,它的C#代码正好是C#编译器所不允许编译的结果:
internal class Program
{
// Fields
private static readonly string s = "hehe";
// Methods
private static void Main(string[] args)
{
Console.WriteLine(s);
s = "被修改";
Console.WriteLine(s);
Console.Read();
}
}
注意:
下文会引用Common Language Infrastructure (CLI) 标准的内容,如果你想亲自看一下CLI标准的内容。可以在这里下载:http://www.ecma-international.org/publications/standards/Ecma-335.htm
看完了上面的东西,你一定会问为什么?为什么执行环境没有去有效得保护一个readonly字段?为什么IL允许去修改一个readonly字段而C#不可以?
让我们开始分析一个readonly字段。如下C#代码:
readonly int i;
接下来,你应该猜到了,又开始IL了……
(汗……别抱怨怎么又得从IL上说起,我也不想这样做,其实IL也挺无奈的,人家很低调的,但总被一些人给不停得揭露出来……仿佛他们研究得很深奥似的……(我在自嘲,没说别人))
上面代码会被编译成下面的IL:
.field private initonly int32 i
可以看出来,readonly字段就是一个带有initonly的字段,一切的不同就在这个initonly修饰符上。
根据CLI标准(ECMA-334),第二部分:元数据(Partition II Metadata),16. 定义和引用字段(16 Defining and referencing fields),1. 字段的属性(Attributes of field),2. 字段约定属性(Field contract attributes)中对initonly修饰符的描述:
initonly marks fields which are constant after they are initialized. These fields shall only be mutated inside a constructor. If the field is a static field, then it shall be mutated only inside the type initializer of the type in which it was declared. If it is an instance field, then it shall be mutated only in one of the instance constructors of the type in which it was defined. It shall not be mutated in any other method or in any other constructor, including constructors of derived classes.
[Note: The use of ldflda or ldsflda on an initonly field makes code unverifiable. In unverifiable code, the VES need not check whether initonly fields are mutated outside the constructors. The VES need not report any errors if a method changes the value of a constant. However, such code is not valid. end note]
上面第一段就是在说readonly的用法:initonly字段在初始化后就成为常量。这种字段只能在构造函数中改变(进行赋值)。如果是静态字段,只能在当前类静态构造函数中或初始设定项。如果是非静态字段,只能在当前类的非静态构造函数中使用(或类型初始设定项),不能在其他方法或者其他构造函数中,包括派生类的构造函数。
第二段找到了重点(以Note开始)。对initonly字段使用ldflda和ldsflda会使代码无法验证。在无法通过验证的代码环境中,VES不需要检查initonly字段是否在构造函数外发生改变,VES也不需要报错如果一个方法改变了这个只读字段的值。但是,这种代码不是合法的。
其中VES(Virtual Execution System):虚拟执行系统是指代码的运行环境,而微软的CLI执行就是CLR。
IL指令:ldflda(ldsflda用于静态字段)用于将字段的地址进栈。当使用ref/out关键字时,或者调用值类型的成员时,CLR都会把相应对象的地址压入栈中。(其实上面提到ldflda/ldsflda是指去修改readonly字段,所以不一定只限于ldflda/ldsflda,当然也没准其他IL指令会间接用到ldflda/ldsflda)
OK,那么其实CLI标准没有强制要求执行环境必须要确保readonly只读字段必须遵守行为准则,只要readonly字段被非法修改,代码将会无法验证(unverifiable)。下面的问题就是无法验证的代码又是一个什么概念?
当MSIL被JIT编译时,如果程序集没有被赋予跳过代码验证的权限时,那么代码必须要通过代码验证过程,代码验证直接确保了程序的类型安全,使得对象可以绝对隔离并且免受恶意破坏,代码验证所要做的是如下具体3点:
(强烈建议阅读MSDN - 托管执行过程 - 将 MSIL 编译为本机代码 - 代码验证(http://msdn.microsoft.com/zh-cn/library/k5532s8a.aspx))
上面提到的“跳过代码验证的权限”就是SecurityPermissionFlag.SkipVerification枚举值,而本机运行的.NET程序集默认是被给予全部权限的,因此会包含这个权限。IL代码验证是不发生的。接下来要做的就是通过创建一个没有“跳过代码验证的”应用程序域,在这个应用程序域中运行上面用IL创建的那个修改只读字段的程序集,看看会发生什么情况。
创建另一个控制台应用程序(最好把刚才的IL编译的修改只读字段的程序拷贝到此程序的编译目录下,这样可以用相对路径),然后创建一个应用程序域,并执行刚才的程序集:
static void Main()
{
//创建一个没有跳过代码验证权限的应用程序域
var appdomain = CreateSandbox();
//testApp.exe是刚才用IL编译的修改readonly只读字段的程序的路径
appdomain.ExecuteAssembly("testApp.exe");
}
static AppDomain CreateSandbox()
{
//初始化空的权限集
var pset = new PermissionSet(PermissionState.None);
//添加Execution(执行)安全权限,没有此权限代码不会执行
pset.AddPermission(new SecurityPermission(SecurityPermissionFlag.Execution));
//添加其他权限
pset.AddPermission(new FileIOPermission(PermissionState.Unrestricted));
pset.AddPermission(new UIPermission(PermissionState.Unrestricted));
//必须设置AppDomainSetup.ApplicationBase,否则无法成功创建应用程序域
var adSetup = new AppDomainSetup();
adSetup.ApplicationBase = AppDomain.CurrentDomain.SetupInformation.ApplicationBase;
var domain = AppDomain.CreateDomain("新的AppDomain",
null,
adSetup,
pset);
return domain;
}
运行结果:有异常抛出。很好,这正是我们想要的结果:
Unhandled Exception: System.Security.VerificationException: Operation could dest
abilize the runtime.
at ConsoleApplication1() //用IL编译的程序的主函数
at System.AppDomain._nExecuteAssembly(RuntimeAssembly assembly, String[] args
)
at System.AppDomain.ExecuteAssembly(String assemblyFile, String[] args)
at System.AppDomain.ExecuteAssembly(String assemblyFile)
at System.AppDomain.ExecuteAssembly(String assemblyFile)
at Mgen.TTC.Program.Main() //当前程序的主函数
可以看到System.Security.VerificationException异常被抛出。”operation could destabilize the runtime”:指操作可能使运行时刻不稳定。因为这个用IL编译的程序集包含上面提到过的“无法验证”的代码,而当前执行的应用程序域又没有被给予“跳过代码验证的安全权限”,因此代码验证此刻会发生,当程序集运行时,JIT边编译边进行代码验证,而觉察到这个“修改只读属性”的语句,它打破了代码验证所强制的运行时刻类型安全,显然破坏了运行时刻稳定性,接着马上抛出VerificationException异常,程序集执行失败。
如果你把上面的应用程序域的限制权限改成全部权限:
var pset = new PermissionSet(PermissionState.Unrestricted);
重新运行一下程序,这次没有任何异常抛出,另一个程序集的只读字段会继续被修改并输出正确结果。
hehe
被修改
如果你仔细看了文章,那么“总结”对你来意义不大,因为你肯定已经自己理解了。当然如果你没时间全看完,总结告诉你的是(请从上往下看):