SUMTEC -- There's a thing in my bloglet.

But it's not only one. It's many. It's the same as other things but it exactly likes nothing else...

  博客园 :: 首页 :: 博问 :: 闪存 :: 新随笔 :: 联系 :: 订阅 订阅 :: 管理 ::

上一节,我介绍了StrongName的意义和机制,这一节就讲一下如何使用StrongName,包括如何利用StrongName防止盗版。因为有一些图表和代码,可能会比较长,希望不会占用大家太多的时间。

首先我们得让自己的程序拥有强命名,这个怎么做呢?在工程下面有一个AssemblyInfo.cs(或者.vb),在里面你会找到AssemblyKeyFile以及AssemblyKeyName的标签(Attribute),找不倒也没有关系,可以自己添加,看:

VB:
<Assembly: AssemblyKeyName("MyKey")>

C#:
[assembly: AssemblyKeyName("MyKey")]

如果是AssemblyKeyFile的话,MyKey的地方就填上你的密钥对文件的全路径。在这里我建议大家使用KeyName的方式,所谓KeyName方式就是把密钥对放在操作系统所保管的容器当中,上面的MyKey就是某个密钥对所在容器的名称。为什么建议使用容器而不是文件,这个在后面讲解。

现在我们知道怎么在代码里面添加点什么东西了,可是这个MyKey又如何得到呢?我们就要到[vs.net]\sdk\v1.1\bin(这个是Framework 1.1的情况),或者[vs.net]\FrameworkSDK\v1.0.xxxx\bin(这个是Framework 1.0的情况,有没有v1.0.xxxx我忘了),运行sn.exe。可以看到有很多的参数,我只对其中一部分进行解释:

首先用sn -k keyfile.snk产生密钥对文件,这里面的keyfile.snk就是文件名,文件名的后缀没有任何限制,这个文件就是AssemblyKeyFile所允许使用的文件。如果你想把这个文件直接生成到某个目录底下,那么就需要给出全路径。但是为了安全理由,我们需要做下一步工作:安装到容器当中。我们运行sn -i keyfile.snk MyKey 就可以把某个文件安装到容器当中了。在这里我建议大家:
1、保管好原始的snk文件,因为这个密钥对是无法从容器当中完全导出到一个文件当中的,其中的公开密钥可以导出来,但是私钥不可以。所以如果万一你的系统需要重装,或者需要在别的机子上进行签名操作,那么就必须要有这个原始的snk文件来安装到容器当中。
2、为了避免你忘记哪一个文件被安装到哪一个容器,请最好保持文件名与容器名完全一致(除了后缀之外)
此外,请大家注意这一点:容器名称是大小写敏感的,也就是说MyKey和mykey是两码事。

当你完成了这一步之后,程序就可以被编译了,编译出来的就是强命名程序集了。这样的程序已经是不可以修改的了,因为普通用户是没有私钥的(请参考前一节)。那么我们如何利用强命名来防止盗版呢?我们知道,只要让这个程序能够绑定到一个物理设备(的序号)上面,就能够防止盗版了。现在很多的技术都是这样的,除了没有强命名。比如说,帮定硬盘的序列号,网卡的序列号,等等。很多时候为了不让黑客知道到底保存了什么,程序都会通过加密的步骤,包括数据加密和执行代码加密。而我们现在可以有一个简单一点的,但是相对来说还是比较安全的办法:

首先我们在程序里面包含一个类:

public class AntiPirate
{
   static private string sHardDiskID = “1234567890ABCDEF“; // 我们假设一共有16位
   static public bool CheckIt()
   {
      return (GetHardDiskID() == sHardDIskID);
   }
}

然后在程序启动的时候调用AntiPirate.CheckIt(),如果为true就正常继续运行,否则你爱怎么处理怎么处理。这里面的GetHardDiskID是一个函数,也许是API也许是你自己写的,无论如何,我建议您把这个函数用static private的方式写在AntiPirate里面。到了这一步,这个程序是没有办法运行的,因为一个硬盘的序列号基本上不可能等于1234567890ABCDEF。我们需要在程序正式添加到安装包之前,对这个123……进行修改。直接修改源代码可以吗?可以,但是每次都要重新编译一遍,显然不划算,对于大批量生产也十分不利:首先必须要有VS.NET,其次必须在制作安装的地方拥有一个源代码,最后,本身的一些序列号管理问题使得你还是需要另外写一个程序来完成制作安装的部分工作。那么怎么办呢?我的办法是,直接对编译出来的exe或者dll进行改动。但是这样又会面临一个问题:改动过之后就无法通过强命名验证了,所以我们要重新签名。

重新签名有两种办法:第一种就是通过sn这个工具进行。sn -R abc.exe keyfile.snk 或者sn -Rc abc.exe MyKey,就可以进行重新签名,可是这里有两个问题制约着我们选用这个方法。首先,sn不能够自动判断应该使用哪一个文件或者容器进行签名,需要手动来指定。其次,我们如果要写一个程序进行防盗版修改工作的自动化,将很难控制签名操作,比如说如何判断签名是否成功以及签名工作是否已经完成等等,因此我选择了用自己写的代码来完成签名步骤。

这个计划听起来有点不可思议,实际上确实是困难重重——如果没有人指导的话,但是当你看完这个贴子之后就会觉得其实听容易的。

首先我要告诉大家的是,签名这个操作是有现成的东西可以用的,这个东西甚至在MSDN里面就提到了。你在MSDN里面可以查找StrongNameSignatureGeneration,就可以看到一部分的文档了。但是,这里的文档并不完整,建议您直接看strongname.h,这个文件在sdk\v1.1\include里面。这里我着重讲两个函数,我们主要使用这两个函数:

// Hash and sign a manifest.
SNAPI StrongNameSignatureGeneration(LPCWSTR     wszFilePath,        // [in] valid path to the PE file for the assembly
                                    LPCWSTR     wszKeyContainer,    // [in] desired key container name
                                    BYTE       *pbKeyBlob,          // [in] public/private key blob (optional)
                                    ULONG       cbKeyBlob,
                                    BYTE      **ppbSignatureBlob,   // [out] signature blob
                                    ULONG      *pcbSignatureBlob);

// Verify a strong name/manifest against a public key blob.
SNAPI StrongNameSignatureVerification(LPCWSTR wszFilePath,      // [in] valid path to the PE file for the assembly
                                      DWORD   dwInFlags,        // [in] flags modifying behaviour (see below)
                                      DWORD  *pdwOutFlags);     // [out] additional output info (see below)

看到这些英文提示,大概就能够猜出分别是什么用途了吧?很明显,第一个用于签名,第二个用于验证。其中签名函数的参数有以下几点需要解释:

1、keyBlob和keyContainer是互斥的,只有其中一个会生效,keyBlob优先。而keyBlob你可以直接从snk文件读出来。
2、改函数仅仅将签名的结果返回给你(通过ppbSignatureBlob),而不会直接帮你写到文件当中去的。

对于第二点我们就比较头痛了,天晓得签名的结果应该放到文件的什么地方,也许我能够看出来,但是程序怎么计算出来是另外一码事。不用担心,这个后面会给您介绍的。好了,现在通过StrongNameSignatureGeneration获得签名了,并且假设已经写到文件当中,是不是就万事大吉什么事情也没有了呢?“不是!”很快就有人告诉我应该再用StrongNameSignatureVerification进行验证。这个没错,确实是需要进行验证,确保没有用错密钥。但是即使完成了这一步,也仅仅保证CLI部分是正确的,如果您足够仔细,您还会发现另外一个地方需要改动。如果您生成的是一个.NET CF的exe/dll,那么修改之后再用sn进行重新签名,你就会发现在CLI之前的部分还有一个位置被修改了。这个位置通过查找PE文件格式的说明,你就会发现那个是PE Optional Header - WinNT Specific Fields里面的CheckSum。

60

4

Header Size

Combined size of MS-DOS Header, PE Header, PE Optional Header and padding; shall be a multiple of the file alignment.

64

4

File Checksum

Always 0 (see Section 23.1).

68

2

SubSystem

Subsystem required to run this image.  Shall be either IMAGE_SUBSYSTEM_WINDOWS_CE_GUI (0x3) or IMAGE_SUBSYSTEM_WINDOWS_GUI (0x2).

上面这个表是节录自sdk\v1.1\Tool Developers Guide\docs\Partition II Metadata.doc的一部分,很可惜的是,无论是sdk里面的所有文档还是到Google上面搜索,你几乎都不会知道这个FileChecksum应该怎么样计算。绝大部分的文档都只会告诉你:这个位置一般情况下为0,除了一些核心文件会要求不为0,因此我们不需要关心。稍微好一点的描述会告诉你:这个计算方法微软没有公开,而我们只需要设为0就可以让系统跳过检查。可是如果原来的文件有Checksum,设为0在PC上面是可以跳过检查的,但是在PDA上面就不好说了。此外设为0是一种逃避,我觉得还是要做得尽善尽美比较好。于是我们继续Goooo,好不容易Goooo到一条告诉你,可以通过ImageHlp.dll来计算获得,可是用起来实在是太麻烦了。别急,我千辛万苦终于找到一个计算的源代码(出处已经忘了,用汇编写的),可惜有点小错误,不要紧,自己慢慢调试吧。

这个Checksum其实很简单,总体上说来就是用带进位加法计算每一个字节,然后加上文件的总长度,最后减去高32bits就得到Checksum了。这个是我的C++代码:

  FileStream *fs = new FileStream(“Afile.exe“);
  BinaryWriter *bw = new BinaryWriter(fs);
  unsigned int checksum;
  int i, c, j, c2, cx;
  int filelen;

  fs->Seek(0, SeekOrigin::Begin);
  filelen = (int) fs->Length;
  checksum = 0;
  cx = filelen>>1;
  c = cx;
  for (i = 0; i < c;)
  {
   c2 = cx - i;
   if (c2 > 0x10000)
   {
    c2 = 0x10000;
   }

   for (j = 0; j < c2; j++)
   {
    checksum += br->ReadUInt16();
   }
   checksum = (checksum & 0xffff) + (checksum >> 16);
   i += c2;
  }
  checksum = (checksum & 0xffff) + (checksum >> 16);
  checksum += filelen;
  checksum = (checksum & 0xffff) - (checksum >> 16);
  
  bw->Seek(iPECheckSumPos, SeekOrigin::Begin);
  bw->Write(checksum & 0xffff);

有点凌乱,因为这是经过优化的。奇怪为什么会有FileStream?这个是托管扩展的代码,因为用C++来写全部的东西太麻烦了,我只打算把签名相关的部分做成一个托管类库,然后其它东西就让C#来完成。之所以用C++托管是因为签名(strongname)必须用C++来写,而且还有一个东西也最好用C++来完成。嗯,现在好像关于要修改些什么都有了很清楚的答案了,让我们回过头来看看还缺一些什么。

现在我们没解决的问题有两个:第一个是怎么知道某个exe/dll应该用哪一个容器还是哪一个文件来签名,第二个是签名数据应该写到文件的什么位置,这个位置应该怎么计算。

关于第一个问题,估计大家跟我一样,第一个反应就是安通过在.NET里面用Reflection加载Assembly,然后通过GetCustomAttributes的方式就可以获得啊。问题是.NET的Reflection只提供了加载不提供卸载,加载了的文件会被锁定无法写入,因此我们就可能会想自己手动从exe/dll当中直接获得。在研究过CLI之后,我们可以得知大部分的结构信息以及标签(Attribute)信息都是以MetaData的形式保存的。而这些信息在CLI里面分别存在#String、#US以及#Blob这三个流里面,这其中也包括前面提到的这个标签。如果说我们自己写代码直接从文件当中获取,理论上是完全可行的,但是这么做也未免也太麻烦了。其实大家可以参考[vs.net]\SDK\v1.1\Tool Developers Guide\Samples\metainfo里面的例子,这里用到了CLR所提供的非托管接口。这个例子包含了将所有的MetaData读出来的代码(准确点讲,他不包括非程序集的Attribute),所以您需要做的仅仅是将获得Assembly里面的Attribute这部分代码拷贝出来就行了。

说到这里,顺便给大家介绍一下MetaData。MetaData里面用处最大的地方是记录参数类型信息,比如说:string FunctionA(int a, int b); 在MetaData里面就会类似这样记录:
// Argument Token
0x10000001 string int int
// Function Token
0x80000023 0x0000001A 0x10000001
// String Table
0x0000001A “FunctionA”

上面的数据都是不真实的,很可能连格式都不正确,但是能够示意出大概的含义。比如说如果要调用FunctionA,那么在IL里面就会用call 0x80000023来表示。执行这一句的时候,系统会自动找到0x10000001得出应该需要两个int的结论,然后就会察看参数堆栈里面是否有两个int。当然,MetaData还会包括各种的字符串数据,比如说MessageBox.Show("hello");这个源代码,编译之后"hello"就通过Meta来保存了。所有的字符串都不会直接出现在IL里面,而是通过这个字符串在MetaData里面的Token(相当于唯一标识符)来表示。

关于MetaData,目前我的研究并不深入,等以后有时间全部研究清楚了再给大家写一篇文章。现在我们还是回到“防盗版”这个任务里面来。

可以说第一个问题本身虽然比较复杂,但是解决起来非常简单,因为所要做的也就是拷贝一下代码。但是第二个问题要解决就比较困难了,因为我们得自行研究PE格式以及CLI格式,这里我就给大家快速攻关一下:

首先我们要找到CLI格式当中保存强命名的地方在那里,我们可以看下面这个表格(节选):

24

8

Resources

Location of CLI resources. (See Partition V_alink=Partition_V ).

32

8

StrongNameSignature

RVA of the hash data for this PE file used by the CLI loader for binding and versioning

40

8

CodeManagerTable

Always 0 (see Section 23.1).

也就是说强命名的RVA值在CLI Header偏移32字节处,这个RVA是什么,我们可以先不管它,我们先找找CLI Header在什么位置,我们看PE Optional Header里面的Data Directories(节选):

200

8

Delay Import Descriptor

Always 0 (see Section 23.1).

208

8

CLI Header

CLI Header with directories for runtime data, (see clause 24.3.1).

216

8

Reserved

Always 0 (see Section 23.1).

哦,CLI Header的RVA值在PE Optional Header偏移208字节处(根据定义,在Data Directories里面的都是RVA),那么PE Optional Header应该在什么位置呢?PE Optional Header的前面有一个20个字节的PE File Header,而这个PE File Header应该立即跟在PE\0\0的后面,PE\0\0的位置则在DOS Header偏移0x3c这里用4个字节记录。好,现在要解决什么是RVA了。关于RVA的定义及计算方法,原文如下:

The PE format frequently uses the term RVA (Relative Virtual Address). An RVA is the address of an item once loaded into memory, with the base address of the image file subtracted from it (i.e. the offset from the base address where the file is loaded). The RVA of an item will almost always differ from its position within the file on disk. To compute the file position of an item with RVA r, search all the sections in the PE file to find the section with RVA s, length l and file position p in which the RVA lies, ie s £ r < s+l. The file position of the item is then given by p+(r-s).

也就是说RVA包括两个值:位置和长度,这里我们只需要关心位置就够了。但是上面这段文字里面所提到的通过RVA得到文件偏移量的计算方法我没看懂,尤其是s应该是什么弄不清楚,我也从来没有办法计算出来。比如说:
RVA-Position of CLI Header = 0x2008,File Position of CLI Header = 0x1008(此时File Alignment = 0x1000)
或者
RVA-Position of CLI Header = 0x2008,File Position of CLI Header = 0x208(此时File Alignment = 0x0200)
对于这两组数据,我实在是搞不懂怎么从RVA-Position=>FilePosition。也许这个还跟内存块对齐大小有关系,并且没个执行文件Mapped之后前面会有一块内存用于保存环境变量,然后是文件头,这样就构成了RVA-Position里面的0x2000,与此对应的FilePostion就是一个FileAlignment的大小。但是我对于这么去计算实在是没有兴趣,因此我用了一个比较偷懒的办法去计算着一堆的东西。

根据大量的观测,可以得到这么一些经验性的结论:
如果是通过MS的各种编译器编译出来的.NET程序,那么CLI Header必然在PE Optional Header结束之后(并且文件对齐之后)再加上8个字节处,而PE Optional Header的大小可以在PE File Header里面查到,File Alignment也可以在PE Optional Header里面查到,因此CLI Header的计算位置就不难得到了。设ΔRVA为,用Data Directories里面查到的CLR Header RVA-Position值减去刚才计算出来的位置,可得:
StrongNameSignature_File_Position = StrongNameSignature_RVA_Position - ΔRVA
这样我们就可以得到签名信息应该写入的位置了,StrongNameSignatureGeneration声称得128个字节往这里写就没错了。做完这一步,我们就可以重新计算Checksum。当新的Checksum写入之后,整个“防盗版”工作就已经完成了。

应用密码学里面说,一个安全的加密算法不是那种宣称是最安全的,但却不让别人知道源代码的那些算法,而是“任何人都能够知道源代码,但是只要没有你的密钥,就不能够做你能够做的事情”这样的算法。这句话在反盗版里面也一样,StrongName恰好能够达到我们的目的——你甚至可以明确知道我就是对比你的硬盘序列号,也知道在什么地方有一个bne指令,但就是没有办法改。

那么这一种防盗版的方法有什么弱点呢?

第一个弱点是,需要确保外围可信。比如说FBI职员找一个贫民百姓做调查,问这个HardDisk ID是多少,这时候FBI职员因为FBI监管的作用是可信的,但是那么贫民百姓是不可信的,于是整个调查就不可信了。也就是说,如果你获取唯一标识的DLL本身就可以被篡改,那么就明显可以通过修改那个DLL使值输出某个固定值,就可以达到破解的目的了。这就是一旦你的主程序有StrongName,VS.NET会要求所有的依赖项都必须是StrongName的原因。问题是你用来获取硬件标识的DLL基本上不可能完全由托管代码实现,甚至根本就不能够保证本身不会被篡改(否则哪里来那么多的病毒问题)。至于怎么去解决,还得大家自己想办法了。因为我用在PDA上面,通过CoreDll获取PDA的ID,CoreDll用户只有执行权限而没有其它的任何访问权限,不可能被篡改,因此我就可以不考虑这个问题了。

第二个弱点是,需要保证这个安全策略被执行。也就是说您得保证CLR在运行你的程序之前必须进行正常的StrongName检测。在PC上面,首先可以通过更改安全策略跳过某个程序的StrongName检测,这个是MS提供的标准CLR所支持的功能,这个问题可以通过增加“权限许可标签”来限制。其次可以自己写一个CLR来执行.NET程序(比如说Mono),以次达到绕过所有安全检查的步骤。由于强命名只是一个验证步骤,而不是加/解密这样的强制要求,跳过去不作检查是完全可能的。对于PDA来说,前面的问题不存在,因为.NET CF不提供这样的能力。关于这第二个弱点,一般人还是没有办法突破的,除非是MS项要盗你的版。

第三个弱点是,这个不能说,说出来可能会威胁到我们这里现有产品的安全。但是我可以告诉大家,如果有人针对这个弱点进行攻击即使是成功了,也没有办法进行升级操作,并且完全可以被升级程序检验出来。

第四个,也是我所知道的最后一个弱点就是:如果别人得到了你的密钥对,就可以做和你一样的事情了。在这里提醒大家一个事实:堡垒最容易从内部突破,并且是无可救药的。这个弱点对于任何的安全措施来说都是存在的,所以请大家保管好密钥对,千万不能够泄漏出去。这个弱点使得我在前面建议大家,尽量使用AssemblyName的方式,也就是说要把密钥对安装到容器当中再用,而原始的密钥文件就钥小心保管。这样就算你因为需要在某个连接到公网的服务器上面提供类似XP的“在线激活”能力,也不需要担心万一黑客把这个服务器攻陷了会把密钥泄漏出来了,因为密钥在容器当中,没有人能够导出私钥。

关于利用StrongName进行防盗版设计的内容,就讲到这里了。下一次说什么?我也不晓得啊。


文章来源:http://dotnet.blogger.cn/sumtec/articles/700.aspx
posted on 2004-04-09 07:20  Sumtec  阅读(3310)  评论(1编辑  收藏  举报