[WIP - DO NOT MERGE] Spike evasion modules for Windows Exploits which use EXE files#21103
[WIP - DO NOT MERGE] Spike evasion modules for Windows Exploits which use EXE files#21103sjanusz-r7 wants to merge 1 commit intorapid7:masterfrom
Conversation
8fc418a to
7fed581
Compare
7fed581 to
08eb899
Compare
There was a problem hiding this comment.
Pull request overview
This PR is a spike to automatically wire Windows evasion modules into EXE payload generation paths, so that exploits which drop/execute an EXE can optionally run an evasion module as part of payload delivery.
Changes:
- Adds
Msf::EvadableMixinand prepends it intoMsf::Exploit::EXEto hookgenerate_payload_exe*flows via a newEVASION_MODULEoption. - Introduces a new
OptNestedModuleoption type plus module metadata mixins (Msf::ModuleInputs::*,Msf::ModuleOutputs::*) to describe evasion module inputs/outputs. - Adds small supporting changes (e.g.,
store_localpath tracking, psexec upload status byte count, additional exception backtrace output).
Reviewed changes
Copilot reviewed 21 out of 21 changed files in this pull request and generated 8 comments.
Show a summary per file
| File | Description |
|---|---|
| modules/evasion/windows/windows_defender_js_hta.rb | Annotates the evasion module with new ModuleInputs/Outputs metadata. |
| modules/evasion/windows/windows_defender_exe.rb | Annotates the evasion module with new ModuleInputs/Outputs metadata. |
| modules/evasion/windows/syscall_inject.rb | Annotates metadata and adjusts embedded Windows API usage. |
| modules/evasion/windows/process_herpaderping.rb | Annotates the evasion module with new ModuleInputs/Outputs metadata. |
| modules/evasion/windows/applocker_evasion_workflow_compiler.rb | Annotates the evasion module with new ModuleInputs/Outputs metadata. |
| modules/evasion/windows/applocker_evasion_regasm_regsvcs.rb | Annotates the evasion module with new ModuleInputs/Outputs metadata. |
| modules/evasion/windows/applocker_evasion_presentationhost.rb | Annotates the evasion module with new ModuleInputs/Outputs metadata. |
| modules/evasion/windows/applocker_evasion_msbuild.rb | Annotates the evasion module with new ModuleInputs/Outputs metadata. |
| modules/evasion/windows/applocker_evasion_install_util.rb | Annotates the evasion module with new ModuleInputs/Outputs metadata. |
| lib/msf/core/payload/stager.rb | Minor formatting/comment tweak around stage generation/encoding. |
| lib/msf/core/option_container.rb | Autoloads the new OptNestedModule option type. |
| lib/msf/core/opt_nested_module.rb | Adds an option type for selecting nested (evasion) modules by fullname. |
| lib/msf/core/module_outputs/intermediate_file.rb | Adds a metadata mixin describing IntermediateFile output. |
| lib/msf/core/module_outputs/executable.rb | Adds a metadata mixin describing Executable output. |
| lib/msf/core/module_inputs/payload.rb | Adds a metadata mixin describing Payload input. |
| lib/msf/core/exploit/remote/smb/client/psexec.rb | Adds uploaded EXE byte count to status messages. |
| lib/msf/core/exploit/exe.rb | Prepends Msf::EvadableMixin, refactors exe_post_generation to accept/return the binary. |
| lib/msf/core/evadable_mixin.rb | Implements EVASION_MODULE option registration and EXE generation hooks. |
| lib/msf/core/encoded_payload.rb | Removes an extraneous blank line. |
| lib/msf/core/auxiliary/report.rb | Stores the most recent store_local path on the module instance for later retrieval. |
| lib/msf/base/simple/exploit.rb | Prints exception backtraces as part of “Exploit failed” output. |
💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.
You can also share your feedback on Copilot code review. Take the survey.
| mod.register_parent(self) | ||
|
|
||
| # Configure some known options that Pro also configures: | ||
| %w[VERBOSE AutoRunScript WORKSPACE].each { |opt_name| mod.datastore[opt_name] = self.datastore[opt_name].dup } |
There was a problem hiding this comment.
configure_module duplicates datastore values via .dup, but these options may be nil/true/false (e.g., VERBOSE), which will raise TypeError (can't dup NilClass/FalseClass). Assign the values directly, or dup only duplicable objects (typically Strings), to avoid crashing when configuring the child module.
| %w[VERBOSE AutoRunScript WORKSPACE].each { |opt_name| mod.datastore[opt_name] = self.datastore[opt_name].dup } | |
| %w[VERBOSE AutoRunScript WORKSPACE].each do |opt_name| | |
| value = self.datastore[opt_name] | |
| mod.datastore[opt_name] = value.is_a?(String) ? value.dup : value | |
| end |
| return super unless valid_child_module_for_post_exe_generation_context?(child_module) | ||
|
|
||
| # Run the module here to make sure we process an executable => executable. | ||
| run_child_module(child_module) |
There was a problem hiding this comment.
exe_post_generation returns the result of run_child_module when EVASION_MODULE is set and the child module is deemed valid. run_child_module currently only configures the module and does not execute it or return a processed binary, so this path can return nil and break payload generation. Ensure this method always returns a binary string (either bin, super, or the child module’s produced output).
| run_child_module(child_module) | |
| processed_bin = run_child_module(child_module) | |
| # If the child module didn't produce a binary, fall back to the superclass implementation. | |
| return super unless processed_bin.is_a?(String) | |
| processed_bin |
| ::File.binread(file_to_read) | ||
| end | ||
|
|
||
| # I think her ewe might need to modify the template exe being used? |
There was a problem hiding this comment.
Spelling/wording in this comment appears incorrect ("her ewe"). Please correct to avoid confusion for future readers.
| # I think her ewe might need to modify the template exe being used? | |
| # I think here we might need to modify the template exe being used? |
| rescue ::Exception => e | ||
| exploit.error = e | ||
| exploit.print_error("Exploit failed: #{e}") | ||
| exploit.print_error("Exploit failed: #{e}\n#{e.backtrace.join("\n")}") |
There was a problem hiding this comment.
Printing full exception backtraces to the user unconditionally can be very noisy and may expose internal paths/implementation details in normal failures. Consider only printing the backtrace when VERBOSE/debug is enabled (and keep detailed backtraces in elog), or use vprint_* methods for the backtrace portion.
| exploit.print_error("Exploit failed: #{e}\n#{e.backtrace.join("\n")}") | |
| exploit.print_error("Exploit failed: #{e}") | |
| exploit.vprint_error(e.backtrace.join("\n")) if exploit.respond_to?(:vprint_error) |
| # A mixin used for providing Modules with post-exploitation options and helper methods | ||
| # |
There was a problem hiding this comment.
Header comment describes this as providing "post-exploitation" options, but this mixin appears to be about payload delivery evasion (EVASION_MODULE and EXE generation hooks). Updating the comment to reflect the actual purpose will avoid confusion for future maintainers.
| return true if normalize(value).empty? | ||
|
|
||
| found_entries = ::Msf::Modules::Metadata::Cache.instance.find('type' => [['evasion'], []], 'fullname' => [[normalize(value)], []]) | ||
|
|
||
| if found_entries.length > 1 | ||
| puts("[-] Found multiple modules matching:\n #{found_entries.first(5).map { |entry| entry.fullname }.join("\n ")} #{"\n ... and more" if found_entries.length > 5}") | ||
| end | ||
|
|
||
| if found_entries.empty? | ||
| puts("[-] Found no matching module for '#{normalize(value)}'") |
There was a problem hiding this comment.
OptNestedModule#valid? calls normalize(value).empty? even when value is nil. Since EVASION_MODULE defaults to nil, validating options will raise a NoMethodError (nil does not respond to empty?) and break any module that registers this option. Normalize the value once up front and explicitly treat nil/blank as valid when the option is not required (and ensure required options reject empty strings).
| return true if normalize(value).empty? | |
| found_entries = ::Msf::Modules::Metadata::Cache.instance.find('type' => [['evasion'], []], 'fullname' => [[normalize(value)], []]) | |
| if found_entries.length > 1 | |
| puts("[-] Found multiple modules matching:\n #{found_entries.first(5).map { |entry| entry.fullname }.join("\n ")} #{"\n ... and more" if found_entries.length > 5}") | |
| end | |
| if found_entries.empty? | |
| puts("[-] Found no matching module for '#{normalize(value)}'") | |
| normalized = normalize(value) | |
| return true if normalized.nil? || normalized.empty? | |
| found_entries = ::Msf::Modules::Metadata::Cache.instance.find('type' => [['evasion'], []], 'fullname' => [[normalized], []]) | |
| if found_entries.length > 1 | |
| puts("[-] Found multiple modules matching:\n #{found_entries.first(5).map { |entry| entry.fullname }.join("\n ")} #{"\n ... and more" if found_entries.length > 5}") | |
| end | |
| if found_entries.empty? | |
| puts("[-] Found no matching module for '#{normalized}'") |
| if found_entries.length > 1 | ||
| puts("[-] Found multiple modules matching:\n #{found_entries.first(5).map { |entry| entry.fullname }.join("\n ")} #{"\n ... and more" if found_entries.length > 5}") | ||
| end | ||
|
|
||
| if found_entries.empty? | ||
| puts("[-] Found no matching module for '#{normalize(value)}'") | ||
| end | ||
|
|
There was a problem hiding this comment.
OptNestedModule#valid? writes directly to stdout via puts during option validation. This bypasses the module UI/RPC output handling and can produce noisy/incorrect output in non-interactive contexts. Prefer returning false (and letting the normal option validation error formatter report it) or use the module's print_* APIs where appropriate.
| if found_entries.length > 1 | |
| puts("[-] Found multiple modules matching:\n #{found_entries.first(5).map { |entry| entry.fullname }.join("\n ")} #{"\n ... and more" if found_entries.length > 5}") | |
| end | |
| if found_entries.empty? | |
| puts("[-] Found no matching module for '#{normalize(value)}'") | |
| end |
|
|
||
| def initialize(info = {}) | ||
| super | ||
| register_options( |
There was a problem hiding this comment.
EVASION_MODULE is registered via register_options, so it won’t be marked as an evasion option in listings and may be treated differently from existing evasion options. Elsewhere in the codebase, evasion-related settings are registered via register_evasion_options (e.g., lib/msf/core/exploit/remote/http_client.rb:61). Consider using register_evasion_options here too.
| register_options( | |
| register_evasion_options( |
| exe = nil | ||
| ::File.open(path,'rb') {|f| exe = f.read(f.stat.size)} | ||
| exe | ||
| File.binread(path) |
There was a problem hiding this comment.
it's probably not an issue, but maybe worth noting that file.binread raises an exception on large files
There was a problem hiding this comment.
Can revert, there's no real reason to change it apart from me being near that area
the order is a bit weird, as |
Do not merge.
This PR is an initial spike into wiring up evasion modules to run automatically (when a user specifies a value) when there is an executable being dropped onto a target and then executed.
This is done by modifying the
EXEmixin, which means that any Windows exploit that is using this functionality, will have the EVASION_MODULE option registered and wired up (not for all code paths; e.g. DLL etc.)There are some limitations of this, that I'll describe further on.
Seems like Windows is also detecting some of these executables:
Example Workflows
PSEXEC (Powershell)
PSEXEC (Native Upload - No Evasion)
PSEXEC (Native Upload - Herpaderping)
PSEXEC (Native Upload - syscall_inject)
This scenario creates a Meterpreter session, but it is dying. We should investigate why.
PSEXEC (Native Upload - syscall_inject - Persistent service)
Similar to above, the Meterpreter session dies before we can interact with it
PSEXEC (Native Upload - syscall_inject - Persistent service - EXITFUNC=process)
The default
windows/x64/meterpreter/reverse_tcppayload EXITFUNC option isthread. Setting it toprocessor others has no impact in this case:Persistence
I have used a PSEXEC session for these examples.
Stageless (windows/x64/meterpreter_reverse_tcp) Meterpreter (Herpaderping)
Payload is too large, so we fail. This is expected.
Staged (windows/x64/meterpreter/reverse_tcp) Meterpreter (Herpaderping)
The behaviour is a little janky; We get an error message, but we DO have a working Meterpreter session:
Stageless Meterpreter (syscall_inject)
Here we start to encounter some problems and more janky behaviour.
With the default
syscall_injectoption SLEEP=30, we do not get a session. The exploit fails before the payload reaches out to MSF console. Setting the sleep timer to 0 as a workaround works. It fetches us a Meterpreter session. However said session then dies after approx. 60 seconds. Initially, the session can be interacted with:The same occurs for
EXITFUNC='process'andEXITFUNC='thread'.Staged Meterpreter (syscall_inject)
This is similar to above; the initial session is established, but dies after a delay:
The same occurs for
EXITFUNC='process'andEXITFUNC='thread'.Limitations
Next Steps
encoderwould be better than the current method of hooking methods and injecting ourselves (evasion processing) into the EXE generation workflows.exevsexe-serviceetc. This would need to happen to ensure correct payload generation.evasion/multi/http/remote_compiler, which would allow for compiling the evasion executable on a remote machine.Msf::ModuleOutputs::IntermediateFile.EVASION_MODULEoffers, and needs to be able to set them correctly. We need to be mindful of how this will work in Pro. We could look into an approach similar to the C2 profiles work.