Ruby如何扰乱你已经运行的应用程序

145 阅读2分钟

简介

Ruby的动态特性既是它的优势也是它的劣势。在运行期间能够重新打开系统类,虽然很有用,但也可能导致意外的行为。本文介绍了一个这样的案例:仅仅需要一个宝石就可以在应用程序的一个完全不同的领域里把事情搞得一团糟。

诡异的错误

最近,在将Diffend监视器连接到我的一个系统后,它开始报告一个奇怪的错误。

uninitialized constant Whenever

whenever-1.0.0/lib/whenever/numeric.rb:3:in `respond_to?'
lib/ruby/2.7.0/bundler/settings.rb:368:in `=='
lib/ruby/2.7.0/bundler/settings.rb:368:in `=='
lib/ruby/2.7.0/bundler/settings.rb:368:in `converted_value'
lib/ruby/2.7.0/bundler/settings.rb:94:in `[]'
lib/ruby/2.7.0/bundler/fetcher.rb:80:in `'
lib/ruby/2.7.0/bundler/fetcher.rb:11:in `'
lib/ruby/2.7.0/bundler/fetcher.rb:9:in `'
diffend-monitor-0.2.36/lib/diffend/build_bundler_definition.rb:18:in `call'
diffend-monitor-0.2.36/lib/diffend/execute.rb:22:in `build_definition'
diffend-monitor-0.2.36/lib/diffend/execute.rb:12:in `call'
diffend-monitor-0.2.36/lib/diffend/track.rb:21:in `start'
diffend-monitor-0.2.36/lib/diffend/monitor.rb:42:in `block in '

发生错误的那一行只是对Bundler API方法的一个委托。

::Bundler::Fetcher.disable_endpoint = nil

而在Bundler本身,它只是一个attr_accessor

class << self
  attr_accessor :disable_endpoint, :api_timeout, :redirect_limit
end

那么,这一切与Whenever gem有什么关系?没有关系。

我们与Whenever无关,但这并不意味着Whenever与我们无关。

需要一个 gem 并不只意味着它的代码被加载。它还意味着该宝石可以执行它想要的任何操作,无论是合法的还是恶意的

diffend-monitor 被要求时,它就会启动自己的Ruby线程,并开始报告数据。而这里就是Whenever启动的时刻。它是在监控器之后被要求的。因此,监视器的代码已经在运行了。理论上,这两者应该完全分开。Whenever和Diffend做的是完全不同的事情,它们有各自的命名空间。

不幸的是,事实证明,Whenever是以不正确的方式给Numeric 类打了猴子补丁。

Numeric.class_eval do
  def respond_to?(method, include_private = false)
    super || Whenever::NumericSeconds.public_method_defined?(method)
  end

  def method_missing(method, *args, &block)
    if Whenever::NumericSeconds.public_method_defined?(method)
      Whenever::NumericSeconds.new(self).send(method)
    else
      super
    end
  end
end

这个补丁看起来很安全,但有一个非常大的假设:Whenever::NumericSeconds 需要被访问。如果我们看一下Whenever的代码加载文件,我们会发现,在Whenever::NumericSeconds 出现之前,这个补丁是必须的。

require 'whenever/numeric'
require 'whenever/numeric_seconds'

这意味着,任何在第一个文件加载后,但在第二个文件之前调用#method_missing 的行为都会失败。

这可能发生吗?当然可以!因为Ruby的require不是阻塞式的。Ruby的require不是阻塞的。这意味着,Ruby VM可以在任何一个文件之后停止require,并在其他线程中切换上下文来做其他事情。

当理解了上述内容后,构建一个复制代码只是几秒钟的事。

Thread.new do
  while true
    begin
      1.respond_to?(:elo)
      sleep 0.00001
    rescue => e
      p e
    end
  end
end

sleep 0.2

require 'whenever'

下面是它执行时的表现。

我已经在Whenever中创建了一个问题,希望它的维护者能解决这个问题。同时,还有一个问题要问:我们能不能以某种方式解决这个问题,这样它就不会破坏我们的代码?

在库被打上补丁之前缓解这个问题

对于这类问题,没有银弹。由于任何宝石都可以将自己的补丁引入到其他类中,潜在的问题是无穷无尽的。在这个特殊的案例中,一旦所有的东西都被加载,代码最终是 "OK "的。我们决定要做的事情是非常微不足道的。我们决定给应用程序足够的时间来要求所有有可能破坏执行的东西。

Thread.new do
  sleep 0.5

  while true
    begin
      1.respond_to?(:elo)
      sleep 0.00001
    rescue => e
      p e
    end
  end
end

这种睡眠确保了只要在通过Bundler进行宝石需求的过程中没有什么重的事情发生,我们就不会在后台线程中执行我们自己的逻辑时,出现部分加载、损坏的猴子补丁。