安全的默认值对于构建安全系统至关重要。如果开发者必须采取各种明确行动才能实现安全,最终即使最有经验的开发者也会忘记这么做。出于这个原因,安全专家说:
“不安全的默认值导致系统不安全。”
Rails作为相对安全的Web框架的名声是当之无愧的。对常见的攻击都有直接可用的防护:跨站脚本(XSS),跨站请求伪造(CSRF)和SQL注入。Rails核心开发成员的安全知识丰富,且关注安全。
然而,也有地方的默认行为可以做的更安全。这篇文章探讨了Rails 3存在的潜在安全性问题,一些已在Rails 4修复,一些仍然有风险。我希望这篇文章能帮助你保护自己的应用程序,同时也能激发Rails本身的改变。
Rails 3的问题
我们以主干中已修复的Rails 3问题来开始。修复这些问题时,Rails团队功不可没,但是仍然值得注意,因为很多程序将在Rails 2和3上运行多年。
1. 通过#match路由泄漏进行CSRF攻击
这里有一个例子,直接来自Rails 3生成的config/routes.rb文件:
1 | WebStore::Application.routes.draw do |
3 | match 'products/:id/purchase' => 'catalog#purchase' , |
这样做的结果,对于任何HTTP方法(GET,POST等),都将/products/:id/purchases路由到CatelogController#purchase方法。问题是,Rails的跨站请求伪造(CSRF)防护对GET请求无效。从执行CSRF防护的方法中看到:
2 | !protect_against_forgery? || |
4 | form_authenticity_token == |
5 | params[request_forgery_protection_token] || |
6 | form_authenticity_token == |
7 | request.headers[ 'X-CSRF-Token' ] |
第二行跳过了CSRF检查:意味着如果request.get?为true,请求被认为是“verified”,CSRF检查被跳过。实际上,Rails源代码里,在该方法的前面就有注释:
Get应该是安全和无副作用的。
在程序里,你可以一直使用POST方法来访问/products/:id/purchase。但是因为路由器也允许GET请求,对于任何由#match路由的方法,攻击者都可以绕过CSRF保护。如果你的程序使用旧的通配符路由(已经不推荐),CSRF保护完全无效。
最佳实践: 对于不安全行为,不要使用GET。不要使用#match来添加路由(而应该用#post,#put等代替)。确保没有通配符路由。
解决方法: 现在,如果使用#match来添加路由,Rails需要你指定HTTP方法或via: :all。自动生成的config/routes.rb不再包含注释掉的#match路由。(通配符路由也被删除。)
2. 正则表达式中进行格式验证的锚点
观察下面的验证代码:
1 | validates_format_of :name , with: /^[a-z ]+$/i |
这是一个不明显的bug。开发者可能想强制要求整个name属性仅仅包含字母和空格。然而,它仅仅强制name属性至少一行由字母和空格组成。再看几个正则表达式匹配的例子来澄清一下:
1 | >> /^[a-z ]+$/i =~ "Joe User" |
4 | >> /^[a-z ]+$/i =~ " '); -- foo" |
7 | >> /^[a-z ]+$/i =~ "a\n '); -- foo" |
开发者其实应该用\A(字符串起始)和\z(字符串结束)锚点来代替^(行起始)和$(行结束)。正确的代码应该为:
1 | validates_format_of :name , with: /\ A [a-z ]+\z/i |
你可能会说开发者错了,而你做对了。然而,正则表达式锚点的行为并不显而易见,尤其对于没考虑多行输入的开发者。(也许该属性仅仅漏出一个文本输入区input field,而不是文本框textararea)
Rails在保护开发者方面考虑的不错,这也是在Rails 4中所做到的。
最佳实践:在任何情况下,使用\A和\z作为正则表达式锚点,代替^和$。
修复方法: Rails
4为validates_format_of引入了支持多行的选项。如果你的正则表达式使用^和$而不是\A和\z,且不传递multiline:
true,则Rails将抛出异常。这是创建安全默认行为的例子,且仍然在必要的场合提供控制选项来覆盖它。 3. 点击劫持
点击劫持或“UI纠正攻击”包括在一个可视框架内渲染目标站点,当受害者点击时就能欺骗他采取意想不到的行动。如果一个站点易受点击劫持攻击,一个攻击者可能欺骗用户采取非预期的行动,像一键购买,在Twitter上关注某人或改变他们的隐私设置。
为了抵御点击劫持攻击,一个站点必须防止自己被呈现在一个框架或它不能控制的iframe中。老的浏览器需要丑陋的“框架破坏”JavaScripts,
而现代的浏览器支持可以指示浏览器是否应该允许该网站被加框的X-Frame-Options
HTTP头。这个头很容易被包含,并且不可能破坏大部分网站,因此Rails应该默认包含了它。
最佳实践: 通过Twitter使用安全头RubyGem添加一个值为SAMEORIGIN或DENY的X-Fram-Options头。
修复方法: Rails4默认将X-Frame-Options头的值设为SAMEORIGIN。
1 | X -Frame-Options: SAMEORIGIN |
这告诉浏览器你的应用程序只能被源自相同域的页面加框。
4. 用户可读回话
默认的Rails 3会话存储使用署名的、未加密的cookies。虽然这可以包含会话不被篡改,对于攻击者来说,解码一个会话cookie的内容是轻而易举的事:
01 | session_cookie = <<- STR .strip.gsub(/\n/, '' ) |
02 | BAh7CEkiD3Nlc3Npb25faWQGOgZFRkkiJTkwYThmZmQ3Zm |
03 | dAY7AEZJIgtzZWtyaXQGO…--4c50026d340abf222… |
06 | Marshal.load(Base64.decode64(session_cookie.split( "--" )[ 0 ])) |
在一个会话中存储任何敏感信息都是不安全的。希望这是众所周知的,但即使一个用户的会话不包含敏感信息,也还是会带来奉献。通过解码会话数据,一个攻击者
能够获取一些关于程序的内部结构的、有利于攻击的信息。例如,他可能知道系统用的是什么认证(Authlogic,Devise等待)。
虽然不会创建一个自身的弱点,但它可以帮助攻击者。任何有关应用程序如何工作的信息都可用来磨练功绩,有时也用来避免触发那些会给开发人员预警攻击正在进行的异常或绊网。
用户可读会话违反最小特权原则,因为即使会话数据必须传给访问者的浏览器,他也不需要能够读取这些数据。
最佳实践: 不要将任何你不想让攻击者访问的信息放到一个会话中。
修复方法: Rails 4将默认会话存储改为加密的。在没有解密密钥的情况下,用户在客户端无法解码会话的内容。 未解决的问题
这篇文章剩下的部分会讲一下在出版的时候Rails还存在的的安全风险。希望最少这里的一些会被修复,同时我将会在修复了的情况下更新这篇文章。
1. VerboseServerHeaders
默认的Rails服务器是WEBrick(部分是Ruby标准库),虽然它很少在产品模式下运行WEBrick。作为默认的选择,WEBrick返回在每个HTTP回复一个verboseServerHeader。
3 | Server: WEBrick/ 1 . 3 . 1 (Ruby/ 1 . 9 . 3 / 2012 - 04 - 20 ) |
看下WEBrick的源代码,你可以看到头部是由一些关键性的信息组成:
1 | "WEBrick/#{WEBrick::VERSION} " + |
2 | "(Ruby/#{RUBY_VERSION}/#{RUBY_RELEASE_DATE})" , |
这暴露的WEBrick的版本,同时也包括正在运行的特定的Ruby的补丁级别(因为发布日期与补丁级别匹配)。使用这些信息,spray和prey扫描者更有效地针对你的服务器,同时可以定制他们的攻击让其变得更有效了。
最好的实践: 避免在产品模式运行WEBrick。有其他更好的服务器,如Passenger,Unicorn,Thin和Puma。
修复: 虽然这个问题是起源于WEBrick的源代码,Rails应该配置WEBrick来使用更小的verboseServerHeader。简单点就“Ruby”似乎更好。 2. 绑定到0.0.0.0
如果你启动一个Rails服务器,你会看到一些像下面的东西:
1 | $ ./script/rails server -e production |
3 | => Rails 3 . 2 . 12 application starting in production on http:// 0 . 0 . 0 . 0 : 3000 |
Rails在绑定到0.0.0.0(所以网卡)而不是127.0.0.1(仅本地网卡)。这会在开发模式和产品模式的上下文上都会产生安全风险。
在开发模式,Rails并不安全(举例来说,它渲染诊断500个页面)。此外,开发者可能加载一个混合了产品数据和测试数据的东西(例如用户名:admin/密码:admin)。在三藩市的咖啡店里扫描web服务器的3000端口将可能收到很好的目标。 在生产环境下,Rails必须通过代理服务器来运行。如果没有代理,IP欺骗攻击就会时常发生。如果Rails绑定了0.0.0.0,攻击者就可以轻松绕过代理服务器,直接攻击Rails服务器。
所以,绑定到127.0.0.1比默认的0.0.0.0会更安全。
最佳实践:保证生产环境仲的web服务器进程绑定了最小的一组接口。不要为了调试,而把生产环境数据导入到你的手提电脑。如果你必须这样做,尽可能少的导入数据,并且当它没用的时候立刻删除。
修复:Rails已经提供了一个绑定选项来修改服务器监听的IP地址。我们必须把默认的0.0.0.0修改为127.0.0.1。开发人员如果需要修改生产环境的绑定接口,可以通过修改部署配置来实现。 3. 记录Secret Tokens的版本
每一个Rails app都会获取一个很长,而且是随机生成的secret token,当使用rails new的时候,它会被生成并保存在config/initializers/secret_token.rb。里面的内容类似这样:
1 | WebStore::Application.config.secret_token = '4f06a7a…72489780f' |
因为rails自动创建secret token,所以很多开发者会忽略掉它。但是这个secret
token就像是你的应用的管理员钥匙。如果你拥有了secret
token,那样伪造会话和提升权限就会变得很容易。这是其中一个十分重要而且敏感的数据需要去保护的。加密是保护你的钥匙的最佳办法。
但是很不幸,rails并不能很好的处理这些secret token。secret_token.rb文件会被加入到版本控制当中,复制到GitHub,CI服务器和每一个开发人员的电脑。 最佳实践:在不同的环境中使用不同secret token。在应用中插入ENV变量就可以实现这个目的。另外一个替代方法是,在部署过程中把secret token作为符号链接。
修复:至少,rails必须通过.gitignore来忽略config/initializers
/secret_token.rb文件。开发人员在部署的时候,用一个符号链接来替代生产环境的token,或者把初始化器转变为使用ENV
变量来初始化(例如Heroku)
我将会进一步提出rails创建一个保存serect token的机制方案。我们有大量的库提供安装指引如何把secret token加入到初始化器中,但是这并不是一个好的实践。同时,至少还有俩个解决方案来处理这个问题:ENV变量和初始化器的符号链接。
rails提供一个简单的API给开发人员来管理secret token,而且后台还是可插拔的(就像缓存和会话存储)。 4. 在SQL语句里记录值
Rails提供的config.filter_paramters是阻止累计在产品日志文件的敏感信息的有用方法,例如密码。但它不影响记录在SQL语句的值。
01 | Started POST "/users" for 127 . 0 . 0 . 1 at 2013 - 03 - 12 14 : 26 : 28 - 0400 |
02 | Processing by UsersController |
03 | Parameters: { "utf8" => "✓œ“" , "authenticity_token" => "..." , |
04 | "user" =>{ "name" => "Name" , "password" => "[FILTERED]" }, "commit" => "Create User" } |
05 | SQL ( 7 .2ms) INSERT INTO "users" ( "created_at" , "name" , "password_digest" , |
06 | "updated_at" ) VALUES (?, ?, ?, ?) [[ "created_at" , |
07 | Tue, 12 Mar 2013 18 : 26 : 28 UTC + 00 : 00 ], [ "name" , "Name" ], [ "password_digest" , |
08 | "$2a$10$r/XGSY9zJr62IpedC1m4Jes8slRRNn8tkikn5.0kE2izKNMlPsqvC" ], [ "updated_at" , |
09 | Tue, 12 Mar 2013 18 : 26 : 28 UTC + 00 : 00 ]] |
10 | Completed 302 Found in 91ms (ActiveRecord: 8 .8ms) |
Rails在产品模式的默认日志级别(info)不会记录SQL语句。这里的防线是有时候开发者会在调试的时候增加日志级别。在这期间,应用程序会写入敏
感数据到日志文件,然后在服务器上长时间持久化。一个攻击者获得阅读服务器上文件权限能够简单地通过grep来找到数据。
最佳实践: 要对产品级日志记录了什么保持清醒。如果你临时增加日志级别,记录下敏感数据,一旦它不再需要了请立刻删除那些数据。
修复:Rails能改变config.filter_parameters成为
config.filter_logs的样子,并且将其同时应用到参数和SQL语句。它可能不能在所有情形下正确的过滤SQL语句(因为它需要一个SQL
解析器),但对于标准的插入与更新可能有80/20的解决方案。
作为一个备选方案,如果它包含有对过滤数值的引用,Rails可以编辑整个SQL语句(例如,编辑所有包含“password”的语句),至少在产品模式是如此。 5. 离线重定向
许多应用包含一个action控制器,它根据上下文将用户带到一个不同地址。最常见的例子是Session控制器,引导最近验证的用户到他们想到的目的地址,或者引导到一个默认的目的地址:
01 | class SignupsController < ApplicationController |
04 | if params[ :destination ].present? |
05 | redirect_to params[ :destination ] |
07 | redirect_to dashboard_path |
这引起了风险,攻击者可以创建一个URL,导致在信任用户登陆以后被引导到恶意网站:
1 | https://example.com/sessions/ new ?destination=http://evil.com/ |
未验证的重定向可以被用作钓鱼,或者摧毁用户对你的信任,因为看起来是你带他们到一个恶意网站的。即使一个警惕的用户,在他们第一次页面加载以后,也可能
不会检查URL地址栏来确认没有被钓鱼。这个问题非常严重,以致它被加入了最新一版的OWASP 十大应用安全威胁。 最佳实践:通过一个hash#转向时,使用选项 only_path:true 去限制转向到当前站点。
1 | redirect_to params.merge(only_path: true ) |
当通过
字符串时,你能解析它为一个路径。
1 | redirect_to URI .parse(params[ :destination ]).path<span style= "font-size: 10pt; line-height: 1.5; font-family: 'sans serif',tahoma,verdana,helvetica;" ></span> |
修复方法:默认情况下,Rails应该只允许转向同一域名或在白名单上的域名。在少数情况下,外部转向的地方是有计划的。开发者应该要求通过anexternal:true选项到redirect_to 按顺序去决定加入更多危险的行为。 6. 利用link_to的跨站脚本 (XSS)
许多开发者没有意识到link_to助手的HREF属性可以被用来注入JavaScript脚本。这里是一个不安全代码的例子:
1 | <%= link_to "Homepage" , user.homepage_url %> |
假设用户可以通过更新他们的注册信息修改homepage_url的值,这就引起了XSS风险。像这样:
1 | user.homepage_url = "javascript:alert('hello')" |
将会生成这个HTML:
1 | < a href = "javascript:alert('hello')" >Homepage</ a > |
点击此链接将会执行攻击者提供的脚本。Rails的XSS保护不能阻止它。在社区转移到低调的JavaScript技术以前,这曾经是必要而且常见的,但现在成为一个残留的弱点。
最佳实践: 避免在HREF中使用不信任的输入。当你必须允许用户控制HREF时,先将输入通过URI.parse转换,并且清醒的检查协议与主机地址。
修复: Rails 应该默认在link_to助手中只允许目录,HTTP, HTTPS 和mailto:href 值。开发者应该通过传入link_to助手选项,选择进入不安全的行为,或者只是简单的link_to不支持这些,而开发者可以手工修改链接。
7. SQL注入
Rails做了个相当好的工作来阻止常见的SQL注入(SQLi)攻击,所以开发者会认为Rails是免疫SQLi的,并不是这样的。设想一个开发者需要基于参数拉取订单表的小计或者总计。他们可能这样写:
1 | Order.pluck(params[ :column ]) |
这样做是不安全的。明显地,用户能够纂改应用程序来获取订单表上他们想要的的任何列数据。然而,一些不明显的东西是攻击者也能够拉取其他表值。例如:
1 | params[ :column ] = "password FROM users--" |
2 | Order.pluck(params[ :column ]) |
会变成:
1 | SELECT password FROM users |
类似的,column_name属性在#calculate上实际是能接受任意的SQL的:
1 | params[ :column ] = "age) FROM users WHERE name = 'Bob'; --" |
2 | Order.calculate( :sum , params[ :column ]) |
会变成:
1 | SELECT SUM (age) FROM users WHERE name = 'Bob' ; |
控制#calculate方法的column_name属性允许攻击者从任意列或者任意表中拉取指定的值。 Rails-SQLi.org 详细讲述哪些ActiveRecord方法和选项允许使用SQL,而且提供例子说明它们是会遭到什么样的攻击。
最佳实践:了解你使用的APIs,同时要知道它们在什么情况下会允许超乎你预期的危险的操作,还有接受输入的白名单。
修复:很难找到解决所有问题的方案,因为不同的环境有不同的解决方案。通常,ActiveRecord APIs只允许经常使用的SQL片段,方法column_name的参数一般只接受列名。如果开发者想自己控制的更多,需要使用其他APIs。
点击Justin Collins的Twitter关于编写rails-sqli.org的信息,了解更多详情。 8. YAML 反序列化
就如大多数ruby开发人员在一月所学到的,使用YAML反序列化不信任的数据会带来安全隐患。网上已经有很多关于使用YAML的攻击的文章,所以我不会
在这里阐述了,但是总的来说,如果攻击者可以入侵YAML,它们就可以随意的在服务器上运行代码。我们的应用无需做任何事情,只需有序的载入YAML,就
会变得脆弱不堪。
虽然rails已经打了补丁,来避免转换的YAML通过HTTP请求发送到服务器。但是仍使用YAML作为#serialize功能和#store功能的默认的序列化格式,以下是存在风险的代码:
1 | class User < ActiveRecord::Base |
5 | store :theme , accessors: [ :color , :bgcolor ] |
大多数rails开发人员不会喜欢把随意的ruby代码保存在数据库,并且在读取记录后来解释运行它们,但是,这其实就等价于使用YAML反序列化的后
果。当存储的数据不包含随意的ruby对象的时候,它就违反了最小特权原则。这个允许在数据库里面保存数据的漏洞,最终会导致整个服务器被攻击者控制。
尤其当自认为YAML看起来起来安全时,实际上很危险。YAML格式是在远程代码执行漏洞爆发前,由数百名熟练的开发者所确定的。然而如今他是Ruby社区的重点之一,Rails的新开发者显然不会再经历YAML远程攻击之祸。
最佳实践:使用JSOM序列化格式而不是YAML序列化
1 | class User < ActiveRecord::Base |
2 | serialize :preferences , JSON |
3 | store :theme , accessors: [ :color , :bgcolor ], coder: JSON |
修复:Rails应该改变默认序列化格式,从YAML转到JSON。YAML应该作为Gem中的一个选择。 9. 批量赋值
Rails
4改用strong_parameters方式来处理批量赋值的漏洞,代替attr_accessible。params对象现在是
ActionController::Parameters的实例。strong_parameters工作的方式是检查那个Parameters实例为
“允许的”——开发者指定了哪个键(及类型)是可接受的。
总之这是一个积极的变化,却引入了attr_accessible体系中没有的新攻击方式。看下面的代码:
1 | params = { user: { admin: true }.to_json } |
4 | @user = User. new ( JSON .parse(params[ :user ])) |
JSON.parse返回一个常规的Ruby
Hash,而不是ActionController::Parameters的实例。用了strong_parameters,默认行为是允许Hash实
例通过批量赋值来设置任何属性。同样的问题发生在sinatra,在Sinatra程序中通过params访问ActiveRecord模型时——
Sinatra不会把这个Hash包装为ActionController::Parameters实例。 最佳实践: 当使用ActiveRecord模型和其它web框架时(或从缓存、队列中反序列化数据时),尽可能使用Rails自带的解析。将输入数据包装到ActionController::Parameters,这样strong_parameters可以生效。
修复方式: 还不明确什么是最好的处理方式。Rails可以重写反序列化方法,比如JSON.parse,返回ActionController::Parameters实例,但这相对有侵入性且可能导致兼容问题。
相关的开发者可以在敏感区域(比如User#admin)结合采用strong_parameters和attr_accessible来得到额外的保护,但对于多数情况可能矫枉过正。这只是一个需要心中有数的问题,期待能尽快解决。
感谢Brendon Murphy,使我意识到这个问题。
感谢Adam Baldwin、Justin Collins、Neil Matatell、Noah Davis和Aaron Patterson对本文的审阅。 |