XSS:跨站脚本(Cross-site scripting)
CSRF:跨站请求伪造(Cross-site request forgery)
在那个年代,大家一般用拼接字符串的方式来构造动态SQL 语句创建应用,于是SQL 注入成了很流行的攻击方式。在这个年代,参数化查询已经成了普及用法,我们已经离SQL 注入很远了。但是,历史同样悠久的XSS 和CSRF 却没有远离我们。由于之前已经对XSS 很熟悉了,所以我对用户输入的数据一直非常小心。如果输入的时候没有经过Tidy 之类的过滤,我一定会在模板输出时候全部转义。所以个人感觉,要避免XSS 也是很容易的,重点是要“小心”。但最近又听说了另一种跨站攻击CSRF ,于是找了些资料了解了一下,并与XSS 放在一起做个比较。
XSS:脚本中的不速之客
XSS 全称“跨站脚本”,是注入攻击的一种。其特点是不对服务器端造成任何伤害,而是通过一些正常的站内交互途径,例如发布评论,提交含有JavaScript 的内容文本。这时服务器端如果没有过滤或转义掉这些脚本,作为内容发布到了页面上,其他用户访问这个页面的时候就会运行这些脚本。
运行预期之外的脚本带来的后果有很多中,可能只是简单的恶作剧——一个关不掉的窗口:
while (true)
{
alert("你关不掉我~");
}
也可以是盗号或者其他未授权的操作——我们来模拟一下这个过程,先建立一个用来收集信息的服务器:(略)
于是每个访问到含有该评论的页面的用户都会遇到麻烦——他们不知道背后正悄悄的发起了一个请求,是他们所看不到的。而这个请求,会把包含了他们的帐号和其他隐私的信息发送到收集服务器上。
我们知道AJAX 技术所使用的XMLHttpRequest 对象都被浏览器做了限制,只能访问当前域名下的URL,所谓不能“跨域”问题。这种做法的初衷也是防范XSS,多多少少都起了一些作用,但不是总是有用, 正如上面的注入代码,用iframe 也一样可以达到相同的目的。甚至在愿意的情况下,我还能用iframe 发起POST 请求。当然,现在一些浏览器能够很智能地分析出部分XSS 并予以拦截,例如新版的Firefox、Chrome 都能这么做。但拦截不总是能成功,何况这个世界上还有大量根本不知道什么是浏览器的用户在用着可怕的IE6。从原则上将,我们也不应该把事关安全性的责任 推脱给浏览器,所以防止XSS 的根本之道还是过滤用户输入。用户输入总是不可信任的,这点对于Web 开发者应该是常识。
正如上文所说,如果我们不需要用户输入HTML 而只想让他们输入纯文本,那么把所有用户输入进行HTML 转义输出是个不错的做法。似乎很多Web 开发框架、模版引擎的开发者也发现了这一点,Django 内置模版和Jinja2 模版总是默认转义输出变量的。如果没有使用它们,我们自己也可以这么做。PHP 可以用htmlspecialchars 函数,Python 可以导入cgi 模块用其中的cgi.escape 函数。如果使用了某款模版引擎,那么其必自带了方便快捷的转义方式。
真正麻烦的是,在一些场合我们要允许用户输入HTML,又要过滤其中的脚本。Tidy 等HTML 清理库可以帮忙,但前提是我们小心地使用。仅仅粗暴地去掉script 标签是没有用的,任何一个合法HTML 标签都可以添加onclick 一类的事件属性来执行JavaScript。对于复杂的情况,我个人更倾向于使用简单的方法处理,简单的方法就是白名单重新整理。用户输入的HTML 可能拥有很复杂的结构,但我们并不将这些数据直接存入数据库,而是使用HTML 解析库遍历节点,获取其中数据(之所以不使用XML 解析库是因为HTML 要求有较强的容错性)。然后根据用户原有的标签属性,重新构建HTML 元素树。构建的过程中,所有的标签、属性都只从白名单中拿取。这样可以确保万无一失——如果用户的某种复杂输入不能为解析器所识别(前面说了HTML 不同于XML,要求有很强的容错性),那么它不会成为漏网之鱼,因为白名单重新整理的策略会直接丢弃掉这些未能识别的部分。最后获得的新HTML 元素树,我们可以拍胸脯保证——所有的标签、属性都来自白名单,一定不会遗漏。
现在看来,大多数Web 开发者都了解XSS 并知道如何防范,往往大型的XSS 攻击(包括前段时间新浪微博的XSS 注入)都是由于疏漏。我个人建议在使用模版引擎的Web 项目中,开启(或不要关闭)类似Django Template、Jinja2 中“默认转义”(Auto Escape)的功能。在不需要转义的场合,我们可以用类似{{ myvar | raw }} 的方式取消转义。这种白名单式的做法,有助于降低我们由于疏漏留下XSS 漏洞的风险。
另外一个风险集中区域,是富AJAX 类应用(例如豆瓣网的阿尔法城)。这类应用的风险并不集中在HTTP 的静态响应内容,所以不是开启模版自动转义能就能一劳永逸的。再加上这类应用往往需要跨域,开发者不得不自己打开危险的大门。这种情况下,站点的安全非常 依赖开发者的细心和应用上线前有效的测试。现在亦有不少开源的XSS 漏洞测试软件包(似乎有篇文章提到豆瓣网的开发也使用自动化XSS 测试),但我都没试用过,故不予评价。不管怎么说,我认为从用户输入的地方把好关总是成本最低而又最有效的做法。
CSRF:冒充用户之手
起初我一直弄不清楚CSRF 究竟和XSS 有什么区别,后来才明白CSRF 和XSS 根本是两个不同维度上的分类。XSS 是实现CSRF 的诸多途径中的一条,但绝对不是唯一的一条。一般习惯上把通过XSS 来实现的CSRF 称为XSRF。
CSRF 的全称是“跨站请求伪造”,而XSS 的全称是“跨站脚本”。看起来有点相似,它们都是属于跨站攻击——不攻击服务器端而攻击正常访问网站的用户,但前面说了,它们的攻击类型是不同维度上的分 类。CSRF 顾名思义,是伪造请求,冒充用户在站内的正常操作。我们知道,绝大多数网站是通过cookie 等方式辨识用户身份(包括使用服务器端Session 的网站,因为Session ID 也是大多保存在cookie 里面的),再予以授权的。所以要伪造用户的正常操作,最好的方法是通过XSS 或链接欺骗等途径,让用户在本机(即拥有身份cookie 的浏览器端)发起用户所不知道的请求。
严格意义上来说,CSRF 不能分类为注入攻击,因为CSRF 的实现途径远远不止XSS 注入这一条。通过XSS 来实现CSRF 易如反掌,但对于设计不佳的网站,一条正常的链接都能造成CSRF。
例如,一论坛网站的发贴是通过GET 请求访问,点击发贴之后JS 把发贴内容拼接成目标URL 并访问:
http://www.2cto.com /bbs/create_post.php?title=标题&content=内容
那么,我只需要在论坛中发一帖,包含一链接
http://www.2cto.com /bbs/create_post.php?title=我是脑残&content=哈哈
只要有用户点击了这个链接,那么他们的帐户就会在不知情的情况下发布了这一帖子。可能这只是个恶作剧,但是既然发贴的请求可以伪造,那么删帖、转帐、改密码、发邮件全都可以伪造。
如何解决这个问题,我们是否可以效仿上文应对XSS 的做法呢?过滤用户输入, 不允许发布这种含有站内操作URL 的链接。这么做可能会有点用,但阻挡不了CSRF,因为攻击者可以通过QQ 或其他网站把这个链接发布上去,为了伪装可能还使用bit.ly 压缩一下网址,这样点击到这个链接的用户还是一样会中招。所以对待CSRF ,我们的视角需要和对待XSS 有所区别。CSRF 并不一定要有站内的输入,因为它并不属于注入攻击,而是请求伪造。被伪造的请求可以是任何来源,而非一定是站内。所以我们唯有一条路可行,就是过滤请求的 处理者。
比较头痛的是,因为请求可以从任何一方发起,而发起请求的方式多种多样,可以通过iframe、ajax(这个不能跨域,得先XSS)、Flash 内部发起请求(总是个大隐患)。由于几乎没有彻底杜绝CSRF 的方式,我们一般的做法,是以各种方式提高攻击的门槛。
首先可以提高的一个门槛,就是改良站内API 的设计。对于发布帖子这一类创建资源的操作,应该只接受POST 请求,而GET 请求应该只浏览而不改变服务器端资源。当然,最理想的做法是使用REST 风格的API 设计,GET、POST、PUT、DELETE 四种请求方法对应资源的读取、创建、修改、删除。现在的浏览器基 本不支持在表单中使用PUT 和DELETE 请求方法,我们可以使用ajax 提交请求(例如通过jquery-form 插件,我最喜欢的做法),也可以使用隐藏域指定请求方法,然后用POST 模拟PUT 和DELETE (Ruby on Rails 的做法)。这么一来,不同的资源操作区分的非常清楚,我们把问题域缩小到了非GET 类型的请求上——攻击者已经不可能通过发布链接来伪造请求了,但他们仍可以发布表单,或者在其他站点上使用我们肉眼不可见的表单,在后台用js 操作,伪造请求。
接下来我们就可以用比较简单也比较有效的方法来防御CSRF,这个方法就是“请求令牌”。读过《J2EE 核心模式》的同学应该对“同步令牌”应该不会陌生,“请求令牌”和“同步令牌”原理是一样的,只不过目的不同,后者是为了解决POST 请求重复提交问题,前者是为了保证收到的请求一定来自预期的页面。实现方法非常简单,首先服务器端要以某种策略生成随机字符串,作为令牌(token), 保存在Session 里。然后在发出请求的页面,把该令牌以隐藏域一类的形式,与其他信息一并发出。在接收请求的页面,把接收到的信息中的令牌与Session 中的令牌比较,只有一致的时候才处理请求,否则返回HTTP 403 拒绝请求或者要求用户重新登陆验证身份。
请求令牌虽然使用起来简单,但并非不可破解,使用不当会增加安全隐患。使用请求令牌来防止CSRF 有以下几点要注意:
虽然请求令牌原理和验证码有相似之处,但不应该像验证码一样,全局使用一个Session Key。因为请求令牌的方法在理论上是可破解的,破解方式是解析来源页面的文本,获取令牌内容。如果全局使用一个Session Key,那么危险系数会上升。原则上来说,每个页面的请求令牌都应该放在独立的Session Key 中。我们在设计服务器端的时候,可以稍加封装,编写一个令牌工具包,将页面的标识作为Session 中保存令牌的键。
在ajax 技术应用较多的场合,因为很有请求是JavaScript 发起的,使用静态的模版输出令牌值或多或少有些不方便。但无论如何,请不要提供直接获取令牌值的API。这么做无疑是锁上了大门,却又把钥匙放在门口,让我们的请求令牌退化为同步令牌。
第一点说了请求令牌理论上是可破解的,所以非常重要的场合,应该考虑使用验证码(令牌的一种升级,目前来看破解难度极大),或者要求用户再次输入密码(亚马逊、淘宝的做法)。但这两种方式用户体验都不好,所以需要产品开发者权衡。
无论是普通的请求令牌还是验证码,服务器端验证过一定记得销毁。忘记销毁用过的令牌是个很低级但是杀伤力很大的错误。我们学校的选课系统就有这个问题,验证码用完并未销毁,故只要获取一次验证码图片,其中的验证码可以在多次请求中使用(只要不再次刷新验证码图片),一直用到Session 超时。这也是为何选课系统加了验证码,外挂软件升级一次之后仍然畅通无阻。
如下也列出一些据说能有效防范CSRF,其实效果甚微的方式甚至无效的做法。
通过referer 判定来源页面:referer 是在HTTP Request Head 里面的,也就是由请求的发送者决定的。如果我喜欢,可以给referer 任何值。当然这个做法并不是毫无作用,起码可以防小白。但我觉得性价比不如令牌。
过滤所有用户发布的链接:这个是最无效的做法,因为首先攻击者不一定要从站内发起请求(上面提到过了),而且就算从站内发起请求,途径也远远不知链接一 条。比如<img src="./create_post.php" /> 就是个不错的选择,还不需要用户去点击,只要用户的浏览器会自动加载图片,就会自动发起请求。
在请求发起页面用alert 弹窗提醒用户:这个方法看上去能干扰站外通过iframe 发起的CSRF,但攻击者也可以考虑用window.alert = function(){}; 把alert 弄哑,或者干脆脱离iframe,使用Flash 来达到目的。
总体来说,目前防御CSRF 的诸多方法还没几个能彻底无解的。所以CSDN 上看到讨论CSRF 的文章,一般都会含有“无耻”二字来形容(另一位有该名号的貌似是DDOS 攻击)。作为开发者,我们能做的就是尽量提高破解难度。当破解难度达到一定程度,网站就逼近于绝对安全的位置了(虽然不能到达)。上述请求令牌方法,就我 认为是最有可扩展性的,因为其原理和CSRF 原理是相克的。CSRF 难以防御之处就在于对服务器端来说,伪造的请求和正常的请求本质上是一致的。而请求令牌的方法,则是揪出这种请求上的唯一区别——来源页面不同。我们还可 以做进一步的工作,例如让页面中token 的key 动态化,进一步提高攻击者的门槛。本文只是我个人认识的一个总结,便不讨论过深了