乐者为王

Do one thing, and do it well.

Rails的不安全默认配置——需要知道的13个安全陷阱

英文原文:http://blog.codeclimate.com/blog/2013/03/27/rails-insecure-defaults/

安全的默认配置是构建安全系统的关键。如果开发者必须采取明确的行动来执行安全行为,即使是有经验的开发者最终也会忘记这样做。为此,安全专家说:

不安全的默认配置是不安全的。

Rails作为相对安全的Web框架实至名归。对于许多常见的攻击它都有开箱即用的防护:跨站脚本(XSS)、跨站请求伪造(CSRF)和SQL注入。Rails的核心成员知识渊博,真正关心安全。

然而,有些地方的默认行为可以做得更安全。这篇文章探讨了在Rails 4中修复的Rails 3中的潜在安全性问题,以及那些仍存在危险的安全性问题。我希望这篇文章可以帮助你保护自己的应用程序,同时启发Rails本身的修改。

Rails 3的问题

让我们从主干中已修复的某些Rails 3的问题开始。对于这些问题的解决,Rails团队值得称赞,但它们却毫无价值,因为许多应用程序在未来很多年里仍将运行在Rails 2和3上。

1. 通过有漏洞的#match路由进行CSRF攻击

这里是直接取自于Rails 3生成的config/routes.rb文件的一个示例:

1
2
3
4
5
6
7
WebStore::Application.routes.draw do
  # Sample of named route:
  match 'products/:id/purchase' => 'catalog#purchase',
    :as => :purchase
  # This route can be invoked with
  # purchase_url(:id => product.id)
end

这样做的后果是,对于任何HTTP动词(GET、POST等),/products/:id/purchase路径都将路由到CatalogController#purchase方法。问题是Rails的跨站请求伪造(CSRF)防护不适用于GET请求。你可以在实施CSRF防护的方法中看到这点:

1
2
3
4
5
6
7
8
def verified_request?
  !protect_against_forgery? ||
  request.get? ||
  form_authenticity_token ==
    params[request_forgery_protection_token] ||
  form_authenticity_token ==
    request.headers['X-CSRF-Token']
end

第2行短路CSRF检查:这意味着如果request.get?为true,请求会被认为是“验证过的”,并且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

这段代码有个不易察觉的缺陷。开发者可能想强制整个名字属性仅由字母和空格组成。然而相反的是,这只会强制名字中至少有一行由字母和空格组成。正则表达式匹配的一些示例可以使这个缺陷更加清晰:

1
2
3
4
5
6
7
8
>> /^[a-z ]+$/i =~ "Joe User"
=> 0 # Match

>> /^[a-z ]+$/i =~ " '); -- foo"
=> nil # No match

>> /^[a-z ]+$/i =~ "a\n '); -- foo"
=> 0 # Match

开发者应该使用\A(字符串的开始)和\z(字符串的结尾)锚点替代^(行的开始)和$(行的结尾)。正确的代码是:

1
validates_format_of :name, with: /\A[a-z ]+\z/i

你可以认为开发者有错,而你是对的。然而,正则表达式锚点的行为不一定是明显的,特别是对于没有考虑多行值的开发者。(可能该属性仅被暴露在文本输入字段,而不是文本区域。)

Rails在拯救开发者方面做得不错,这正是Rails 4所做的工作。

最佳实践:尽可能使用\A和\z来锚定正则表达式,而不是^和$。

修复:Rails 4为validates_format_of引入了一个多行选项。如果你的正则表达式使用^和$而不是\A和\z进行锚定,并且没有传递multiline: true,则Rails将引发异常。这是创建更安全的默认行为的一个很好的示例,同时仍然提供控制以在必要的地方覆盖它。

3. 点击劫持

点击劫持或“用户界面伪装攻击”涉及在不可见的页帧中呈现目标站点,并诱骗受害者在点击时执行出乎意料的动作。如果一个站点很容易被点击劫持攻击,攻击者可能会诱骗用户执行不必要的动作,例如单击购买,在Twitter上跟随某人,或更改他们的隐私设置。

为防御点击劫持攻击,站点必须阻止自己被呈现在frame或不受控制的站点的iframe中。较老的浏览器需要丑陋的“页帧破解”JavaScript,但现代浏览器支持X-Frame-Options HTTP报头,它告知浏览器是否允许站点被页帧。这个报头很容易包含,也不可能破坏大多数的网站,所以Rails应该默认包含它。

最佳实践:使用Twitter的secure_headers添加一个X-Frame-Options报头,其值为SAMEORIGIN或DENY。

修复:默认情况下,Rails 4现在发送带有SAMEORIGIN值的X-Frame-Options报头:

1
X-Frame-Options: SAMEORIGIN

这告诉浏览器你的应用程序只能由源自同一个域的页面构成。

4. 用户可读的会话

默认的Rails 3会话存储使用已签名的未加密Cookie。虽然这样可以保护会话免遭篡改,但攻击者可以很容易对会话Cookie的内容进行解码:

1
2
3
4
5
6
7
8
9
10
11
session_cookie = <<-STR.strip.gsub(/\n/, '')
BAh7CEkiD3Nlc3Npb25faWQGOgZFRkkiJTkwYThmZmQ3Zm
dAY7AEZJIgtzZWtyaXQGO…--4c50026d340abf222…
STR

Marshal.load(Base64.decode64(session_cookie.split("--")[0]))
# => {
#   "session_id"  => "90a8f...",
#   "_csrf_token" => "iUoXA...",
#   "secret"      => "sekrit"
# }

在会话中存储任何敏感信息是不安全的。希望这是众所周知的,但即使用户的会话不包含敏感数据,它仍然可能会产生风险。通过解码会话数据,攻击者可以获得在攻击中可以利用的有关应用程序内部的有用信息。例如,可以得知哪个认证系统正在使用(Authlogic、Devise等)。

虽然这不会自行创建漏洞,但它可以帮助攻击者。关于应用程序如何工作的任何信息都可以被用于磨蚀攻击,并且在某些情况下,可以避免触发那些会给开发者提供正在被攻击的早期警告的异常或者绊网。

用户可读会话违反了最低权限原则,因为即使会话数据必须传递给访问者的浏览器,访问者也无需能够读取数据。

最佳实践:不要将任何你不希望攻击者访问的信息放入会话中。

修复:Rails 4将默认会话存储更改为加密的。没有解密密钥,用户在客户端将不再能够解码会话的内容。

尚未解决的问题

这篇文章的剩余部分讨论Rails在发布时仍然存在的安全风险。希望至少有些会被修复,如果是这样,我将更新这篇文章。

1. 详细的服务器报头

默认的Rails服务器是WEBrick(Ruby标准库的一部分),即使在生产中很少运行WEBrick。默认情况下,WEBrick对每个HTTP响应都返回一个详细的服务器报头:

1
2
3
HTTP/1.1 200 OK
# ...
Server: WEBrick/1.3.1 (Ruby/1.9.3/2012-04-20)

看看WEBrick的源代码,你可以看到报头是由几个关键信息组成的:

1
2
"WEBrick/#{WEBrick::VERSION} " +
"(Ruby/#{RUBY_VERSION}/#{RUBY_RELEASE_DATE})",

这暴露了WEBrick的版本,以及正在运行的特定的Ruby补丁级别(因为发布日期对应补丁级别)。有了这些信息,扫描工具可以更有效地针对你的服务器,攻击者可以定制其攻击有效载荷。

最佳实践:避免在生产中运行WEBrick。外面有更好的服务器,像Passenger、Unicorn、Thin和Puma。

修复:虽然此问题的根源在于WEBrick的源代码,但Rails应该将WEBrick配置为使用不那么详细的服务器报头。只打印“Ruby”似乎是个不错的选择。

2. 绑定到0.0.0.0

如果你启动一个Rails服务器,你将看到如下信息:

1
2
3
$ ./script/rails server -e production
=> Booting WEBrick
=> 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页面)。另外,开发者可以加载生产数据和测试数据的混合(例如,username:admin/password:admin)。在旧金山咖啡店扫描Web服务器的端口3000可能会得到很好的目标。

在生产中,Rails应该运行在代理后面。没有代理,IP欺骗攻击就会经常发生。但是如果Rails绑定在0.0.0.0上,根据部署配置可以通过直接攻击Rails来轻松绕过代理。

因此,在所有Rails环境中,绑定到127.0.0.1是比0.0.0.0更安全的默认配置。

最佳实践:确保你的Web服务器进程在生产中绑定最小的接口集。避免在笔记本电脑上加载生产数据进行调试。如果你必须这样做,请加载最小的数据集,并在不需要时立即将其删除。

修复:Rails已经提供了--binding选项来更改服务器侦听的IP地址。默认值应该从0.0.0.0更改为127.0.0.1。需要在生产中绑定到其它接口的开发者可以在部署配置中进行更改。

3. 版本化的保密令牌

当使用rails new创建时,每个Rails应用程序都会在config/initializers/secret_token.rb中获取一个长的、随机生成的保密令牌。它看起来像这样:

1
WebStore::Application.config.secret_token = '4f06a7a…72489780f'

由于保密令牌是Rails自动创建的,所以大多数开发者不会去考虑它。但这个保密令牌就像你的应用程序的根密钥。如果你有保密令牌,伪造会话和提升权限就非常简单。它是要保护的敏感数据最关键的部分之一。加密其实就是你的密钥管理最佳实践。

不幸的是,Rails在处理这些保密令牌时没有达到预期效果。secret_token.rb文件最后会被检入到版本控制中,然后复制到GitHub、CI服务器和每个开发者的笔记本电脑。

最佳实践:在每个环境中使用不同的保密令牌。通过ENV变量注入应用程序。或者,在部署期间符号链接生产保密令牌。

修复:Rails至少应该默认使用.gitignore忽略config/initializers/secret_token.rb文件。当部署或更改初始化程序以使用ENV变量(例如Heroku)时,开发者可以符号链接生产令牌。

我会进一步提出Rails为保密令牌创建的存储机制。有许多库提供涉及将保密令牌检入到初始化程序的安装指令,这是个坏的实践。与此同时,至少有两个流行的策略来处理这个问题:ENV变量和符号链接的初始化程序。

Rails给开发者提供了一个简单的API来管理保密令牌,并且具有可交换的后端(如缓存存储和会话存储)。

4. 在SQL语句中记录值

Rails提供的config.filter_parameters是用来防止像密码这样的敏感信息累积在生产日志文件中的一种有用的方法。但它并不影响在SQL语句中值的记录:

1
2
3
4
5
6
7
8
9
10
Started POST "/users" for 127.0.0.1 at 2013-03-12 14:26:28 -0400
Processing by UsersController#create as HTML
  Parameters: {"utf8"=>"✓œ“", "authenticity_token"=>"...",
  "user"=>{"name"=>"Name", "password"=>"[FILTERED]"}, "commit"=>"Create User"}
  SQL (7.2ms)  INSERT INTO "users" ("created_at", "name", "password_digest",
  "updated_at") VALUES (?, ?, ?, ?)  [["created_at",
  Tue, 12 Mar 2013 18:26:28 UTC +00:00], ["name", "Name"], ["password_digest",
  "$2a$10$r/XGSY9zJr62IpedC1m4Jes8slRRNn8tkikn5.0kE2izKNMlPsqvC"], ["updated_at",
  Tue, 12 Mar 2013 18:26:28 UTC +00:00]]
Completed 302 Found in 91ms (ActiveRecord: 8.8ms)

在生产模式(info)中的默认Rails日志级别不会记录SQL语句。这里的风险在于,有时开发者会在调试时暂时提高生产中的日志级别。在这期间,应用程序可能会将敏感数据写入日志文件,然后保存在服务器上很长时间。获得读取服务器上的文件的访问权限的攻击者可以使用简单的grep查找数据。

最佳实践:知道在生产日志级别什么被记录。如果临时增加日志级别,导致敏感数据被记录,在不需要时要立即删除该数据。

修复:Rails可以将config.filter_parameters选项改成类似于config.filter_logs,并将其应用于参数和SQL语句。在所有情况下都正确过滤SQL语句是不可能的(因为它需要一个SQL解析器),但对于标准的插入和更新它可能是个80/20解决方案。

或者,如果包含对过滤值的引用,则Rails可以修改整个SQL语句(例如,修改包含“password”的所有语句),至少在生产模式下。

5. 离线重定向

许多应用程序包含需要根据上下文将用户发送到不同位置的控制器动作。最常见的示例是SessionsController,它将新验证的用户引导到其预期的目标网址或者默认的目标网址:

1
2
3
4
5
6
7
8
9
10
class SignupsController < ApplicationController
  def create
    # ...
    if params[:destination].present?
      redirect_to params[:destination]
    else
      redirect_to dashboard_path
    end
  end
end

这会产生风险,攻击者可以构建一个URL,导致不知情的用户在登录后被发送到恶意站点:

1
https://example.com/sessions/new?destination=http://evil.com/

无效的重定向可用于钓鱼式攻击,或者可能会损害用户对你的信任,因为你似乎将他们发送到恶意网站。即使是警惕的用户也可能不会在首个页面加载后检查网址栏以确保他们不被钓鱼。这个问题是足够严重的,已经成为最新版的OWASP十大应用安全威胁。

最佳实践:将散列传递到#redirect_to时,使用only_path: true选项将重定向限制为当前主机:

1
redirect_to params.merge(only_path: true)

当传递字符串时,你可以解析它来提取路径:

1
redirect_to URI.parse(params[:destination]).path

修复:默认情况下,Rails应该只允许同个域内(或白名单)的重定向。对于需要外部重定向的罕见情况,应该要求开发者将external: true选项传递给redirect_to,以便选择更危险的行为。

许多开发者没有意识到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应该只允许路径、HTTP、HTTPS和mailto:href等值在link_to帮助器中。开发者必须通过传递选项给link_to帮助器以选择不安全的行为,或者link_to可能直接不支持这些,开发者需要手工制作他们的链接。

7. SQL注入

Rails在防止常见的SQL注入(SQLi)攻击方面做得比较好,所以开发者可能会认为Rails对SQLi是免疫的。当然不是这样。假设开发者需要根据参数完成orders表中的小计或总计。他们可能写:

1
Order.pluck(params[:column])

这样做是不安全的。显然,用户现在可以操纵应用程序从他们希望的orders表中检索任何数据列。然而,不太明显的是,攻击者还可以从其它表中提取值。例如:

1
2
params[:column] = "password FROM users--"
Order.pluck(params[:column])

将会变成:

1
SELECT password FROM users-- FROM "orders"

同样,#calculate的column_name属性实际上接受任意SQL:

1
2
params[:column] = "age) FROM users WHERE name = 'Bob'; --"
Order.calculate(:sum, params[:column])

将会变成:

1
SELECT SUM(age) FROM users WHERE name = 'Bob'; --) AS sum_id FROM "orders"

控制#calculate方法的column_name属性允许攻击者从任意表中的任意列中提取特定值。

rails-sqli.org详细介绍了哪些ActiveRecord方法和选项允许SQL,且带有它们如何被攻击的示例。

最佳实践:了解你使用的API以及它们在什么情况下可能允许比你预期的更危险的操作。使用最安全的API,和预期输入的白名单。

修复:这个问题难以批量解决,因为正确的解决方案因语境而异。一般来说,ActiveRecord API应该只允许常用的SQL片段。名为column_name的方法参数只能接受列名。可以为需要更多控制的开发者提供替代API。

感谢Twitter的Justin Collins撰写rails-sqli.org,它使我意识到了这个问题。

8. YAML反序列化

如同许多Ruby开发者在早期学到的,使用YAML反序列化不可信数据与eval一样不安全。已经有很多基于YAML攻击的文章,所以我不会在这里炒冷饭,但总而言之,如果攻击者可以注入一个YAML有效载荷,他们可以在服务器上执行任意代码。应用程序不需要做任何事情,只要加载YAML就容易易受攻击。

尽管Rails已经打过补丁,以避免解析在HTTP请求中发送给服务器的YAML,但它仍然使用YAML作为#serialize功能的默认序列化格式,就像新的#store功能(它本身就是围绕#serialize的简单包装)那样。危险代码看起来像这样:

1
2
3
4
5
6
class User < ActiveRecord::Base
  # ...
  serialize :preferences

  store :theme, accessors: [ :color, :bgcolor ]
end

大多数Rails开发者不会喜欢在数据库中存储任意Ruby代码,然后在加载记录时对其进行解释执行,按照这种方法,它与使用YAML反序列化功能相当。当存储的数据不包含任意Ruby对象时,它违反了最低权限原则。允许编写数据库中的值的漏洞可以被作为跳板以控制整个服务器。

我特别担心YAML的使用,因为它看起来安全但实际上危险。在远程代码执行(RCE)漏洞暴露之前,YAML格式已经被数以百计的熟练开发者查看多年。虽然这是Ruby社区的头等大事,但明年接手Rails的新开发者将不会经历YAML RCE的惨败。

最佳实践:使用JSON序列化格式而不是因为#serialize和#store使用YAML:

1
2
3
4
class User < ActiveRecord::Base
  serialize :preferences, JSON
  store :theme, accessors: [ :color, :bgcolor ], coder: JSON
end

修复:Rails应该将ActiveRecord的默认序列化格式从YAML切换到JSON。YAML行为应该通过选择加入或者提取到可选的Gem中。

9. 批量赋值

Rails 4从使用attr_accessible处理批量赋值漏洞切换到strong_parameters方法。params对象现在是ActionController::Parameters的实例。strong_parameters检查批量赋值中被使用的Parameters实例是否是“permitted”——开发者已经具体指出了哪些键(和值类型)是预期的。

一般来说,这是个积极的变化,但它确实引入了attr_accessible世界中不存在的一个新的攻击方向。考虑这个示例:

1
2
3
4
params = { user: { admin: true }.to_json }
# => {:user=>"{\"admin\":true}"}

@user = User.new(JSON.parse(params[:user]))

JSON.parse返回一个普通的Ruby Hash,而不是ActionController::Parameters的一个实例。使用strong_parameters的默认行为是允许Hash的实例通过批量赋值来设置任何模型属性。如果在访问ActiveRecord模型时使用Sinatra应用程序中的params会发生同样的问题——Sinatra不会把Hash包装成ActionController::Parameters的实例。

最佳实践:当把ActiveRecord模型与其它Web框架(或从缓存、队列等反序列化数据)结合起来包装ActionController::Parameters中的输入以使strong_parameters工作时,尽可能依赖Rails的开箱即用解析。

修复:目前还不清楚Rails应对这个问题的最佳方法是什么。Rails可以覆盖诸如JSON.parse之类的反序列化方法来返回ActionController::Parameters的实例,但这相对有侵入性,并且可能导致兼容性问题。

担心的开发者可以将strong_parameters与attr_accessible结合起来用于高度敏感的字段(如User#admin)以进行额外的保护,但这在大多数情况下可能会过犹不及。最后,这可能只是我们需要意识到和留心的行为。

感谢Brendon Murphy让我意识到这个问题。

感谢Adam Baldwin、Justin Collins、Neil Matatell、Noah Davis和Aaron Patterson对这篇文章的审阅。

Comments