SQL注入攻击

一个客户对我们请求说,请我们来检查一下他的内部网络,这个网络被公司的职员以及客户们来使用。这是一个较大的安全评估的一部分,而且,虽然我们以前从没 有真正的使用过SQL注入来破解一个网络,但是我们对于其一般的概念相当的熟悉。在此次“战斗”中,我们是完全成功的,而且想要通过把这个过程的每一个步骤重新记录下来,并作为一个“生动的例子”。

“SQL注入”是特定的一种未被确认或未明确身份的用户输入漏洞的一个子集(“缓冲溢出”是一个不同的子集),而这个想法的目标是,让应用程序确信从而去 运行SQL代码,而这些代码并不在其目的之内。如果一个应用程序是在本地通过即时的方式来创建一个SQL字符串,结果很直接,会造成一些真正的出人意料的结果。

我们要明确说明的是,这是一个有些曲折的过程,并且其中会有多次的错误的转折,而其余的更有经验的人当然会有着不同的-甚至更好的-方法。而事实上,我们成功的实现了建议,并没有被完全的误导。

还有一些不同的论文讨论SQL注入问题,包括一些更加详细的文章,不过此文所展示的,与破解的过程同样份量的是发现了SQL注入的原因。

目标内网

这显然是一个完全自主开发的应用程序,而我们对于它没有预先的了解,或者访问源代码的权限:这已是一个“blind”攻击。若干次侦测之后,我们了解到服 务器运行的是微软的IIS6,并使用ASP.NET框架,从这其中得到的,似乎可以假定数据库是微软的SQL服务器:我们相信这些技术可以应用于几乎任何 一种web应用,而此应用可能为任何一种SQL服务器所支持。

登陆页面是一个传统的用户名-密码表单,带有一个用电子邮件给我传送密码的链接;而后者被证实是整个系统的败笔。

当输入一个电子邮件地址的时候,系统会假定次邮件存在的方式,再用户数据库里面寻找这个电子邮件地址,并且会邮寄一些内容到这个地址。由于我的电子邮件地址没有被找到,所以它不会给我发送任何内容。

所以,第一个测试,对于任何SQL化的表单而言,是输入一个带有单引号构成的数据:这样做的目标是查看是否他们在构建SQL字符串的时候根本没有使用数据 的清理机制。当为此表单提交了一个有单引号的电子邮件之后,我们得到了一个500错误(服务器失败),这就是说,这个“被破坏了的”的输入实际上被真实的 分析过。中!

我们猜测底层的SQL代码可能类似于如此:

SELECT fieldlist FROM table WHERE field = '$EMAIL'; 

这里,$EMAIL是由用户通过表单提交的电子邮件地址,而这段较长的查询提供的应用符号,是为了使得这个$EMAIL成为一个真正的字符串。我们不知道 这个数据域的确切的名字或者是于此相关的数据表的名字,但是我们却了解他们的特性,而此后我们将会得到一些很好的猜测结果。

当我们输入steve@unixwiz.net' - 留意那个结尾的引号 - 这将产生出一个如下构建的SQL:

SELECT fieldlist FROM table WHERE field = 'steve@unixwiz.net''; 

当这个SQL被执行的时候,SQL分析器发现了多余的引号,从而中止了工作,并给出一个语法错误。而这个错误如何表述给用户,取决于应用程序内部错误修复 的过程,但是这通常不同于“电子邮件地址不存在”的错误提示。这个错误的响应是致命的第一通道,既用户的输入没有被正确的清理,而因此应用程序成为了破解 的美食。

由于我们输入的数据显然位于WHERE子语句中,让我们用合法的SQL方式改变一下这个子句的本貌并看看会发生什么。通过输入任何一种‘OR ’x‘=x语句,结果SQL成为:

SELECT fieldlist FROM table WHERE field = 'anything' OR 'x'='x'; 

因为应用程序没有真正的对这样的查询有所考虑,而仅仅是构建一个字符串,以致于我们使用的引号使得一个单元素的WHERE子句,变成了一个双元素的子句, 而且’x'=x字句是确定为真的,无论第一个字句是什么。(有一种更好的方式来确保“始终为真”,这部分我们后面会谈及)

不过与“真实”的查询不同,本应当一次返回一个单独的项,这个版本必然会返回成员数据库里面的每一个项。唯一的可以发现应用程序在这种情况下会做什么的方式,就是尝试。不断尝试,我们留意到以下结果:

你的登录信息已经发送到 random.person@example.com.

我们一般都会把查询返回的第一行作为作为猜测的主要入口。这哥们确实从E-mail里拿回了他的密码,同样这个邮件可能会让他感到吃惊并且会引起怀疑。

现在我们知道怎么在本地玩这条查询了,虽然目前我们还不知道我们看不到的那部分SQL结构是怎么拼起来的。但是我们通过逆向工程看到了三个不同的查询结果:

  • 您的登陆信息已经以Email形式发送给您
  • 我们无法识别您的Email地址
  • 服务端错误

头两个响应是有效的查询的结果,最后一个是无效SQL造成的。 类似这样的响应结果会帮助我们更好的逆推服务端用来查询的SQL语句结构。

预设字段映射

我们要干的第一步就是猜字段名,首先我们合理的推测查询带了“email address” 和 “password”,所以可能的字段名选择会有“US Mail Address”  或者 “userid” 亦或者“phone number”  。当然最好能执行 show table,但是我们又不知道表名,貌似目前没什么明显的方法能让我们拿到表名。

那就分步走吧。在每个例子里,我们会用我们已知的SQL加上我们自己的特殊“段”。我们已知的这条SQL的结尾是个Email地址的比对,那就猜下email是这个字段名吧

SELECT fieldlist FROM table WHERE field = 'x' AND email IS NULL; --'; 

如果服务器响应是报错,那基本上可以说明我们的SQL拼错了。但是如果我们得到任何正常的返回,例如“未知的邮件地址” 或 “密码已发送”  ,说明我们的字段名蒙对了。

要注意的是,我们的“And” 关键字而没用“OR” 关键字,这么做是有目地滴。在上一步中,我们不关心到底是哪一个Email,而且我们不想因为蒙中某人的Email然后给他发了重置密码的邮件。这么搞那 哥们儿一定会怀疑有人对他的帐号搞三搞四。所以用“And”关键字拼上一个不合法的Email地址,这样服务端总是返回空结果集,也就不会给任何人发邮 件。

提交上面的SQL代码段确实返回了“未知的邮件地址” 这么一个响应。现在我们确认了email地址的字段名是email。如果不是这么个响应,那我就再蒙“email_adress”或者“mail”亦或者 其他类似的。这个环节总是靠蒙的,但是蒙也得讲技巧和方法方式。

这段SQL的用意是我们假设预设的SQL查询中的字段名是 email ,跑一下看看是不是有效。我不会管你到底有没有匹配的Email,所以用了个伪名“x” , “--” 这个标示是表示SQL的起始。这样SQL解析到这就会把它直接当成一条命令,而“--”后面的会是一个新的命令,这样就屏蔽掉了后面的那些不知道的玩意 儿。

下一步,我们来猜下其他比较明显的字段名: "password","userid","name" 和类似的。每次我们只蒙一个名字,当返回结果不是“服务端错误” , 那就说明我们蒙对了。

SELECT fieldlist FROM table WHERE email = 'x' AND userid IS NULL; --'; 

通过这一步,我们蒙出来了下面几个字段名:

  • email
  • passwd
  • login_id
  • full_name

肯定还有更多其他的(把HTML页面表单的 <Input   name="XXXXX"> 拿来做参考是个非常不错的选择)后来我又挖了一下但是没挖出来更多的字段名。 到目前为止,我们还是不知道这些字段所属的表的表名--咋个弄呢?

搜寻表名

应用内建的query已经把表名放在语句中,但我们不知道表的名字。有几种方法可以找到这些插入在语句中表名(以及其他的表名)。我们用的是一种依赖于subselect的方法。

下面这个单独的query

SELECT COUNT(*) FROM <i>tabname</i> 

返回表中记录的数量,当然,若表名是无效的,则查询失败。我们可以把这个查询放入我们的查询语句中来探查表名。

SELECT email, passwd, login_id, full_name 


  FROM <i>table</i> WHERE <b>email</b> = '<span>x' AND 1=(SELECT COUNT(*) FROM <i>tabname</i>); --'</span>; 

我们实际上并不关心表中有多少记录,我们关心的是表名是否有效。通过试探不同的猜测,我们最终确定members是数据库中的有效表名。但是,这是这个查 询中所用的表名吗?为此,我们需要另一个使用table.field的查询:这只在表名的确是查询中的表名时才工作,而不仅仅当表存在时工作。

SELECT email, passwd, login_id, full_name 


  FROM members 


 WHERE email = '<span>x' AND members.email IS NULL; --</span>'; 

当这个语句返回 "Email unknown"时,可以确认我们的SQL正确执行了,并且我们成功的猜出了表名。这对后面的工作很重要,但我们先临时使用一下另一种方法。

弄几个帐号先

目前我们搞到了members表结构的部分信息,但是我们只知道一个用户名,就是之前我们蒙中的那个发了邮件通知的那个用户名。当时我们只得到了邮件地址,但是拿不到邮件内容。所以我们得再弄几个有效得用户名,高端洋气上档次的最好。

我们的从公司的网站开始人肉,找到那些人物介绍的页面,一般都是介绍公司内部人员的。这些介绍里大多都有这些人的Email地址和名字。就算没有这些信息也没啥,咱兜里还有货。

思路是这样的,提交一个带有“Like” 关键字的SQL,这样我们可以对Email地址或者用户名做些模糊匹配,每次提交如果返回“我们已发送您的密码至邮箱”那也就是说我们的模糊查询有效了,而且邮件也真的发了!!!。这么干虽然我们能拿到邮件地址,也意味着对方会收到邮件并引起警觉,所以  慎用!!

我们能做email、full_name(或者其他字段)的查询,每次放入%这个通配符执行如下的查询:

SELECT email, passwd, login_id, full_name 


  FROM members 


 WHERE email = 'x' OR full_name LIKE '%Bob%'; 

暴力破解密码

我们肯定可以在登陆页面尝试暴力破解密码,但是大多的应用都做了相应的防护手段。可能的防护会有操作日志,帐号锁定或者其他能大大降低我们效率的手段或设备,但是因为输入没有被过滤所以给我们绕过这些防护多了一些可能。

我们将把密码和邮件名称的代码段加到我们已知的SQL里。在这个例子里我们会用一个倒霉催的哥们的邮箱,bob@example.com 然后试试我们准备的一些密码。

SELECT email, passwd, login_id, full_name 


  FROM members 


 WHERE email = 'bob@example.com' AND passwd = 'hello123'; 

这条SQL是完整有效的,所以服务器铁定不能够报错,所以我们知道当服务器响应是“您的密码已经发送至您的邮箱” 这么个结果是我们就知道刚提交的那个密码就是我们要的密码。虽然倒霉催的Bob也收到了邮件并且一定会警觉,但是我们在他警觉之前就干完我们想干的了。

这个过程可以在Perl下用脚本自动化完成,所以我们就去搞了搞Perl的脚本,结果写脚本的时候发现了另外一种方法来干这事。

数据库不是只读的

迄今为止,我们对数据库除了进行查询外,没做其他事。尽管SELECT是只读的,但不意味SQL就只能这样。SQL使用分号来中断一个语句, 如果对输入没有做正确的处理, 它就不能阻止我们在查询语句后面添加不相关的字符串。

最恰当的一个例子就是这样:

SELECT email, passwd, login_id, full_name   


  FROM members   


 WHERE email = 'x'; DROP TABLE members; --';  -- Boom!   

第一部分提供了一个假的邮件地址 -- 'x' -- 我们并不关系这个查询的返回,我们仅仅是给出了一个我们能够使用无关SQL指令的方式,一个尝试删除整个members表而真的是无任何关系的操作.

这表明我们不仅仅可以切分SQL指令,我们还可以修改数据库,这完全是被允许的。

加入一个新成员

由上所得,我们获知了members表的部分的结构,尝试添加一个新的记录到这个表里面似乎是一个可拊掌称庆的方法:如果这能成功,我们就能够通过我们新插入的帐户来直接登陆了。

这里,毫不惊奇的是,这只需要稍微加些SQL,我们把它放在不同的行从而让我们的展示更易理解,不过从头到尾这一部分其实仍然是一个字符串:

SELECT email, passwd, login_id, full_name 

  FROM members 

 WHERE email = 'x'; INSERT INTO members ('email','passwd','login_id','full_name')  VALUES ('steve@unixwiz.net','hello','steve','Steve Friedl');--'; 

即便是我们真的确定了表的名字以及使用的字段都是正确的,在成功的实施攻击之前,仍然有一些障碍:

  1. 可能,在表单上,我们并没有足够的空间来直接输入这些文本(尽管,可以使用脚本来解决这个问题,而这并不是很简便的事情)
  2.  web应用程序的用户可能并没有在members表上的插入权限。
  3.  毫无疑问,members表里面有一些别的字段,有一些可能需要初始化的数据,而这可能导致插入失败。
  4.  即便是可以达到插入一个新的记录,应用程序可能会出现不当的行为,因为自动插入的时候有一些字段使用的是NULL。
  5.  一个有效的“成员”可能不仅仅需要在members表里面的一个记录,而是和别的表有着数据上的关联,(如,“访问权限”),所以添加一个数据到一个表中,可能并不充分有效。

对于手头的案例而言,我们遇到了#4或者是#5上面的障碍 - 我们无法真正确定是哪一个 - 因为在主登陆界面上,输入上面的用户名+密码的时候,返回了一个服务器上的错误。这就是说,那些我们没有用到的字段可能是必要的字段,而尽管如此,它们仍 然没有被正确的处理。

这里,一个可行的方法,就是猜测别的字段,但是这可以保证是一个长时间而且耗费劳力的过程:虽然你可能可以猜测出来那些“显而易见”的字段,但是却很难构建出整个应用程序的组织图像。

所以,最后我们选择走另一条不同的路。

邮给我一个密码

我们意识到虽然不能添加一条新的记录在members表中,但我们可以通过修改一个存在的记录, 这也获得了我们的证明是可行的。

从先前的步骤中,我们知道bob@example.com在系统中有一个帐号,我们使用SQL注入更新了他的数据库记录为我们的邮件地址:

SELECT email, passwd, login_id, full_name 


  FROM members 


 WHERE email = 'x'; UPDATE members SET email = 'steve@unixwiz.net' WHERE email = 'bob@example.com'; 

运行这个之后,我们当然会收到"we didn't know your email address"消息,但这是预期的提供了不正确的邮件地址。UPDATE操作并不会向应用程序通知, 因此他被悄然执行了。

我们可以用更新后的邮件地址,使用常规的"I lost my password"链接 - 一分钟后就会受到这样的邮件:

From: system@example.com 


To: steve@unixwiz.net 


Subject: Intranet login 


 


This email is in response to your request for your Intranet log in information. 


Your User ID is: bob 


Your password is: hello 

现在,就可以使用标准的登录流程进入系统,作为一个高等级的职员。这是一个高权限用户,远远高于我们INSERT创建的受限用户。

我们发现这个内网站点的信息比较全,甚至还有一个全用户列表。所以我们可以合理的推测很多内网站点会有公司Windows网络帐号,并且用的是同样的密 码。目前我们很明显可以拿到内网的用户密码,而且我们在公司防火墙找到了一个开放的PPTP模式的的VPN端口让我们很方便的做些登陆尝试。

我们手动抽查了一些帐号,没成功。而且不知道到底服务端是因为“错误的密码”还是“内网帐号和Windows帐号不同”而拒绝登陆的。反正就是个没干成。不过我嚼得自动化的工具应该能让这步简单点。

其他一些方法

在这次渗透中,我们觉得其实已经挖的足够深了,不过还可以用其他的方法。我们就先看看我们现在想到一些普适性不是很高的方法。

我们同时也注意到了不是所有的方法都是数据库无关的,有些方法得依赖特定的数据库。

调用XP_CMDSHELL

Microsoft的SQL Server 有一个存储过程 “XP_CMDSHELL” 允许执行任意的操作系统命令,如果这项功授权给Web用户调用的话,那基本上网站肯定会遭黑。我们现在干的都被限制在了Web应用和数据库这个环境下,一 旦能执行操作系统命令,防御再好的应用服务器也没辙了。调用这个存储过程的权限一般会赋给管理员帐号,但是还是存在授权给低级别用户的可能。

数据库结构深度挖掘

这个应用登陆后能干的事太多了,在我看来实在没啥必要再去挖了。不过在其他一些特殊的环境下我们的这些方法也许不够用。如果能深度挖掘数据库的结构,我们 会发现更多的方法来黑掉站点。你可以试着看看其他的提交切入点(例如“留言板”,“帮助论坛”等等)。不过这都是对应用环境的强依赖而且还得靠有一定技术 含量的瞎蒙。

减轻危害

我们相信web应用的开发者,通常不会去想那些“令人意外的输入”,但是安全人员会(包括那些“坏人”),所以这里有三个宽泛的方法,可以用来除害。

过滤输入,这是绝对重要的事情,过滤用户的输入,从而确保他们的输入没有包含具有威胁的代码,无论是对于SQL服务器,或则是HTML本身都要考虑。某人 最初的想法来剥掉“恶意代码”,例如引号,分号或者是转义符号,其实这种尝试是被误导了的。尽管找出来些具有威胁的字符很容易,但是很难把他们全部找到。 web的语言种到处都是特定的字符以及奇特的组合(包括那些用来表达同一些字符的另类模式),而努力去鉴定那些没有被授权的“恶意代码”很可能不会成功。 换而言之,与其“除去那些已知的恶数据”,倒不如“去掉所有良好数据之外所有的内容”:这其中的分别是至关重要。如前 - 我们的例子中 - 一个电子邮件地址仅能包括以下字符:

abcdefghijklmnopqrstuvwxyz 


ABCDEFGHIJKLMNOPQRSTUVWXYZ 


0123456789 


@.-_+ 

用没有意义的文字是不益的,应该早点拒绝这么做,这可能会产生一些错误信息,这样不仅可以帮助我们抢先SQL注入,而且可以让我们及时发现拼写错误以至于不会让错误存入数据库。

  电子邮件的一些选项

我们应该要特别的注意邮件地址,它会给验证编程带来麻烦的,因为,每个人看起来对邮件地址的“有效性”都有自己的想法,不用一个好的邮箱地址是不光彩的,这样你将遇到你想不到的文字。

这方面的真正权威是RFC 2822(比大家耳熟能详RFC822内容还多),里面包含了什么是允许的比较范的定义。如果邮箱地址接受&和*(和其它普通字符比较)是不好的,但是其它的,包括这篇文章的作者,都会对“大多数”邮件地址满意。

那些采用严格方法的人应该充分的认识到不包括这些邮件地址的后果,特别是认为现在有很好的技术能够解决那些“奇怪”的字符所带来的安全问题。

请注意“过滤输入”并不意味着“移除引号”,因为即使一个“正规”的字符也会很麻烦,在这个例子中,一个整型数字ID值被拿来和用户的输入做比较(叫数字型PIN):

SELECT fieldlist FROM table WHERE id = 23 OR 1=1;  -- Boom! Always matches! 

不过实际情况是我们很难把输入项完全过滤掉潜在危险字符。“日期项”,“邮件地址”,或者“整形” 用上面的办法过滤是可以的。但是在真实环境中我们,我们还得用到其他的方法。

输入项编码/转义

虽然现在可以过滤邮件地址或者电话号码,但是貌似“Bill OReilly” 这样的合法的名字你是很难处理的,因为“这个单引号是合法的输入。于是有人就想到在过滤到单引号的时候我再加一个单引号这样就没问题了,其实这么干得出大事。

SELECT fieldlist FROM customers 


 WHERE name = 'Bill O''Reilly';  -- 目前这样是OK的 

但是,这个方法很容易就被绕过去了。像MySQL允许 \' 这个输入,然后如果有人造一段SQL “ \'; DROP TABLE users;” ,你又给单引号加了个单引号,那就变成

SELECT fieldlist FROM customers 


 WHERE name = '\''; DROP TABLE users; --';  -- 删表了 

' \' '就被新加的单引号分成一个字符串(按照前面的过滤方法这里判断有一个单引号,所以过滤后再单引号后面加一个单引号),后面接的是常见的SQL恶意代码。 其实不仅仅有反斜线的情况,还有Unicode编码和其他编码规则或者其他的过滤漏洞都会给程序员挖坑。俺们都知道,实现输入过滤的绝对安全是超级难滴, 这就是为啥很多数据库接口都提供了相关功能。当同样的内容被系统自带的字符串过滤或字符串编码处理后会好很多。例如调用MySQL的函数 mysql_real_escape_string() 或者 perl  DBD 的$dbh->quote($value)方法. 这些方法都必用的。

参数绑定(预编译语句)

虽然数据库自带的过滤是个不错的实现,但是我们还是处在“用户输入被当成 SQL语句的一部分 ”这么个圈子里,其实要跳出这个圈子还有一个实现,就是参数绑定。基本上所有的主流数据库都提供这种接口。这种方法提前预编译了SQL语句的逻辑,然后对 参数预留了位置(就是“ ?1  ?2” 这种的)。这样语句在执行的时候只是按照输入的参数表来执行。

Perl环境的例子:

$sth = $dbh->prepare("SELECT email, userid FROM members WHERE email = ?;"); 


 


$sth->execute($email); 

感谢Stefan Wagner帮我写了个java的实现

不安全版

Statement s = connection.createStatement(); 


ResultSet rs = s.executeQuery("SELECT email FROM member WHERE name = " 


                             + formField); // *boom* 

安全版

PreparedStatement ps = connection.prepareStatement( 


    "SELECT email FROM member WHERE name = ?"); 


ps.setString(1, formField); 


ResultSet rs = ps.executeQuery(); 

这儿$email是从用户表单中获取来的数据,并且是做为位置参数#1(即第一个问号)传递进来的,因此在任 何情况下,这个变量的内容都可以解析为SQL语句。引号、分号、反斜杠、SQL注释表示法-其中的任何一个都不会产生任何特殊的效果,这个因为它们“只是 数据”。这不会对其他东西造成破坏,因此这个应用很大程度上防止了SQL注入攻击。

如果对这个查询进行多次重用(这个查询只解析一次)的话,还可以提高性能。然而与获得大量的安全方面的好处相比,这个是微不足道的。这还可能是我们保证互联网应用安全所采取的一个重要的措施。

限制数据库权限和隔离用户

在目前这种情况下,我们观察到只有两个交互式动作不在登录用户的上下文环境中:“登录”和“给我发送密码”。web应用应该使用尽可能少权限的数据库连接:仅对members表具有查询权限,对其它表没有任何访问权限的数据库连接。

这样做的结果是:即便是“成功地”进行了SQL注入攻击,也只能取得非常有限的成功。这种情况下,我们不能做最终授权给我们的任何更新(UPDATE)请求,因此为了能够实现更新(UPDATE)请求,我们不得不寻求其他解决方法。

一旦web应用确定了通过登录表单传递认证凭证是有效的话,那么它将把这个会话切换到一个具有更多权限的数据库连接上。

对任何web应用来说,从不使用sa权限几乎是理所当然的事情。

强调一下,虽然俺们在这次演示中选择“忘记密码”这么个功能点,不是因为这个功能点本身不安全,而是普遍存在各站点的几个容易遭受攻击的功能点之一。如果你把关注点放在如何通过“忘记密码”来进行渗透的话,那你就跑偏了。

这篇文章的本意不是全面覆盖SQL注入的精髓,甚至连教学都算不上。其实只是我们花了几个小时做的一单渗透测试的工作纪录。我们也见过其他的文章讲SQL注入的技术背景,但是都只是展示了渗透结束后的成果而没有细节。

对数据库的访问采用存储过程

当一个数据库服务器支持存储过程时,请使用存储过程执行这个应用的访问行为,这样(在存储过程编写正确的情况下)就完全不需要SQL了。

通过把诸如查询、更新、删除等某个动作的规则封装成一个单独的存储过程,你就可以根据这个单独的存储过程和所执行的商务规则额对其进行测试和归档。(例如,“增加新的订单”存储过程在客户超过了信用限制的情况下可能拒绝添加这个订单。)

对简单的查询来说,这么做可能仅仅获得很少的好处,不过当这个操作变的越来越复杂(或者是在多个地方使用这个操作)的情况下,给这样操作一个单独的定义就意味着维护这个操作将更简单而且这个操作的功能会更强壮。

注意:总可以编写动态构建查询语句的存储过程:这么做并不会防止SQL注入-它只不过把准备/执行过程正确地结合在一起,或者只不过把SQL语句与提供保护的变量直接捆绑在一起。

但是那些结果需要大量的背景知识才能看的懂的,而且我觉得渗透的细节也是很有价值的。我们正常情况下是拿不到源码的,所以渗透人员的逆向黑盒渗透的能力也是有价值的。

我要感谢David Litchfield 和 Randal Schwartz 对本篇文章的技术贡献,和Chris Mospaw 牛逼的排版设计。(© 2005 by Chris Mospaw, used with permission)

相关推荐