the shed // windows hardening

The spooler runs as SYSTEM on every Windows box you own, including your domain controllers. Seven registry values decide whether a normal user can load a DLL into it. Here is a Ruby audit that reads all seven — and a collector pattern that lets you test Windows-only logic on Linux.

Step through the build below — the problem, the code, the design decisions, and the real output captured while testing it.

win_print_spooler_audit.rb

The Print Spooler runs as SYSTEM, is enabled by default on every Windows install including domain controllers, and exposes an RPC interface that lets clients install printer drivers. “Install a driver” means “load a DLL into a SYSTEM process”.

That is why PrintNightmare turned into a two-year patch treadmill, and why the mitigation is not a single hotfix but a set of registry policies that are easy to set once and then silently regress the next time somebody edits a GPO.

The dangerous state is invisible from the Services console. A box can be fully patched and still exploitable, because a GPO sets NoWarningNoElevationOnInstall=1 — a value Microsoft’s own guidance describes as making the system vulnerable by design. And a box can look alarming but be fine, because it genuinely is a print server with driver installation restricted to administrators.

“Patched” and “configured” are different questions. Only the second one is answerable from the registry, and that is what this script answers.

win_print_spooler_audit.rb — WMI + registry, no gems

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# win_print_spooler_audit.rb -- Audit the Windows Print Spooler attack surface.
#
# THE PROBLEM
# -----------
# The Print Spooler runs as SYSTEM, is enabled by default on every Windows
# install including domain controllers, and exposes an RPC interface that lets
# clients install printer drivers. "Install a driver" means "load a DLL into a
# SYSTEM process". That combination is why PrintNightmare (CVE-2021-1675 /
# CVE-2021-34527) turned into a two-year patch treadmill, and why the mitigation
# is not a single hotfix but a set of registry policies that are easy to set
# once and then silently regress the next time somebody edits a GPO.
#
# The dangerous state is not obvious from the Services console. A box can be
# fully patched and still be exploitable because a GPO sets
# NoWarningNoElevationOnInstall=1 -- a value Microsoft's own advisory calls out
# as making the system "vulnerable by design". Conversely, a box can look
# alarming (spooler running) and be perfectly fine, because it genuinely is a
# print server with restricted driver installation.
#
# WHAT THIS SCRIPT DOES
# ---------------------
# It reads the spooler's real configuration -- the service state via WMI, the
# printers and drivers via WMI, and the seven registry values that actually
# decide whether driver installation is privileged -- and grades each one
# PASS / WARN / FAIL with the exact remediation.
#
#   * Spooler service state and start mode, judged against the host's role
#   * RestrictDriverInstallationToAdministrators  (the primary mitigation)
#   * Point and Print: NoWarningNoElevationOnInstall, UpdatePromptSettings
#   * Point and Print: Restricted / TrustedServers / ServerList
#   * Package Point and Print only, and its server allow-list
#   * RpcAuthnLevelPrivacyEnabled (CVE-2021-1678 relay hardening)
#   * RegisterSpoolerRemoteRpcEndPoint (inbound remote print RPC)
#   * Inventory of installed drivers and shared printers, with FILE: ports flagged
#
# Read-only. It never stops the spooler, never writes a registry value, and
# never removes a driver. Every finding prints the command you would run.
#
# Usage:
#   ruby win_print_spooler_audit.rb                       # audit this host
#   ruby win_print_spooler_audit.rb --role print-server   # relax spooler checks
#   ruby win_print_spooler_audit.rb --json
#   ruby win_print_spooler_audit.rb --fixture sample.json # replay captured data
#   ruby win_print_spooler_audit.rb --capture out.json    # save this host's data
#
# Exit codes:  0 = clean   1 = warnings   2 = failures   3 = usage error
require 'json'
require 'optparse'
require 'time'
WINDOWS = RUBY_PLATFORM =~ /mswin|mingw|cygwin/ ? true : false
if WINDOWS
  require 'win32ole'
  require 'win32/registry'
end
# ===========================================================================
# Collectors
#
# Everything that touches the OS lives behind this interface. That is what
# makes the audit logic testable on a machine that has no spooler at all: the
# FixtureCollector replays a captured (or hand-written) JSON snapshot through
# exactly the same code path the live audit uses.
# ===========================================================================
# Reads live state from WMI and the registry.
class WindowsCollector
  HKLM = 0x80000002
  def initialize
    @wmi = WIN32OLE.connect('winmgmts://./root/cimv2')
  end
  def service(name)
    q = "SELECT Name, State, StartMode, StartName FROM Win32_Service WHERE Name='#{name}'"
    row = @wmi.ExecQuery(q).each.first
    return nil unless row
    { 'name' => row.Name, 'state' => row.State,
      'start_mode' => row.StartMode, 'start_name' => row.StartName }
  end
  def printers
    @wmi.ExecQuery('SELECT Name, Shared, ShareName, PortName, DriverName, Published, Local ' \
                   'FROM Win32_Printer').map do |p|
      { 'name' => p.Name, 'shared' => p.Shared, 'share_name' => p.ShareName,
        'port' => p.PortName, 'driver' => p.DriverName,
        'published' => p.Published, 'local' => p.Local }
    end
  rescue WIN32OLERuntimeError
    []
  end
  def drivers
    @wmi.ExecQuery('SELECT Name, DriverPath, Version, SupportedPlatform FROM Win32_PrinterDriver')
        .map do |d|
      { 'name' => d.Name, 'path' => d.DriverPath,
        'version' => d.Version, 'platform' => d.SupportedPlatform }
    end
  rescue WIN32OLERuntimeError
    []
  end
  def os_role
    row = @wmi.ExecQuery('SELECT ProductType, Caption FROM Win32_OperatingSystem').each.first
    return {} unless row
    # ProductType: 1 = workstation, 2 = domain controller, 3 = member server
    { 'product_type' => row.ProductType.to_i, 'caption' => row.Caption }
  rescue WIN32OLERuntimeError
    {}
  end
  # Returns nil when the value (or the whole key) is absent, which is a
  # meaningful state in its own right: "policy not configured".
  def reg_value(subkey, name)
    Win32::Registry::HKEY_LOCAL_MACHINE.open(subkey) do |k|
      begin
        k[name]
      rescue Win32::Registry::Error
        nil
      end
    end
  rescue Win32::Registry::Error
    nil
  end
end
# Replays a JSON snapshot. Same interface, no OS dependency.
class FixtureCollector
  def initialize(path)
    @data = JSON.parse(File.read(path))
  end
  def service(name) = (@data['services'] || {})[name]
  def printers     = @data['printers'] || []
  def drivers      = @data['drivers']  || []
  def os_role      = @data['os'] || {}
  def reg_value(subkey, name)
    (@data.dig('registry', subkey) || {})[name]
  end
  def to_h = @data
end
# ===========================================================================
# The registry values that matter
# ===========================================================================
PRINTERS_POLICY = 'SOFTWARE\Policies\Microsoft\Windows NT\Printers'
POINT_AND_PRINT = 'SOFTWARE\Policies\Microsoft\Windows NT\Printers\PointAndPrint'
PRINT_CONTROL   = 'System\CurrentControlSet\Control\Print'
# ===========================================================================
# Audit
# ===========================================================================
SEVERITY_ORDER = { 'FAIL' => 0, 'WARN' => 1, 'INFO' => 2, 'PASS' => 3 }.freeze
# File.dirname is platform-aware, which is exactly wrong here: we may be parsing
# a Windows path on a Linux box (fixture replay), where File.dirname('C:\a\b.dll')
# returns '.'. Split on both separators explicitly so the remediation command is
# correct no matter where the audit runs.
def win_dirname(path)
  parts = path.to_s.split(%r{[\\/]})
  return path.to_s if parts.size < 2
  parts[0..-2].join('\\')
end
def finding(sev, id, message, remediation = nil, **extra)
  { 'severity' => sev, 'id' => id, 'message' => message,
    'remediation' => remediation }.merge(extra.transform_keys(&:to_s)).compact
end
class SpoolerAudit
  def initialize(collector, role: nil)
    @c = collector
    @role = role
    @findings = []
    @facts = {}
  end
  attr_reader :findings, :facts
  def run
    check_service_state
    check_restrict_driver_install
    check_point_and_print
    check_package_point_and_print
    check_rpc_hardening
    inventory_printers
    inventory_drivers
    [@findings, @facts]
  end
  private
  def add(*args, **kw) = @findings << finding(*args, **kw)
  # --- 1. Is the spooler even running, and should it be? -------------------
  def check_service_state
    svc = @c.service('Spooler')
    os = @c.os_role
    @facts['os'] = os
    @facts['spooler'] = svc
    if svc.nil?
      add('INFO', 'spooler.absent', 'Print Spooler service not found on this host')
      return
    end
    running = svc['state'].to_s.casecmp('running').zero?
    product_type = os['product_type'].to_i
    role = @role || case product_type
                    when 2 then 'domain-controller'
                    when 3 then 'member-server'
                    else 'workstation'
                    end
    @facts['role'] = role
    if !running
      add('PASS', 'spooler.state',
          "Print Spooler is #{svc['state']} (start mode #{svc['start_mode']}) -- " \
          'the RPC attack surface is closed')
      return
    end
    case role
    when 'domain-controller'
      add('FAIL', 'spooler.on_dc',
          'Print Spooler is RUNNING on a domain controller',
          'A DC has no business printing. This is the single highest-value spooler ' \
          'target on the network (it enables the printer-bug coercion used in ' \
          'NTLM relay to AD CS). Disable it: ' \
          'Stop-Service Spooler -Force; Set-Service Spooler -StartupType Disabled')
    when 'print-server'
      add('INFO', 'spooler.on_print_server',
          'Print Spooler is running on a declared print server (expected)',
          'Role declared via --role print-server; the driver-install policies below ' \
          'carry the whole weight of the mitigation here.')
    when 'member-server'
      add('WARN', 'spooler.on_member_server',
          'Print Spooler is running on a member server that is not a declared print server',
          'If this box does not serve printers, disable the spooler: ' \
          'Set-Service Spooler -StartupType Disabled. If it does, re-run with ' \
          '--role print-server.')
    else
      add('INFO', 'spooler.on_workstation',
          'Print Spooler is running on a workstation (normal, but keep the policies below correct)')
    end
    if svc['start_name'] && !svc['start_name'].to_s.match?(/LocalSystem/i)
      add('INFO', 'spooler.account',
          "Spooler runs as #{svc['start_name']} rather than LocalSystem")
    end
  end
  # --- 2. The primary PrintNightmare mitigation ---------------------------
  def check_restrict_driver_install
    v = @c.reg_value(PRINTERS_POLICY, 'RestrictDriverInstallationToAdministrators')
    @facts['RestrictDriverInstallationToAdministrators'] = v
    # Microsoft changed the DEFAULT to "restricted" in the August 2021 updates,
    # so an absent value is safe on a patched host -- but only implicitly, and a
    # single GPO can flip it. Absent is a WARN, not a PASS.
    if v.nil?
      add('WARN', 'pnp.restrict_driver_install.unset',
          'RestrictDriverInstallationToAdministrators is not configured',
          'Patched hosts default to restricted, but the value is unset so nothing ' \
          'pins it. Set it explicitly: reg add "HKLM\\' + PRINTERS_POLICY +
          '" /v RestrictDriverInstallationToAdministrators /t REG_DWORD /d 1 /f')
    elsif v.to_i == 1
      add('PASS', 'pnp.restrict_driver_install',
          'RestrictDriverInstallationToAdministrators = 1 (only admins may install drivers)')
    else
      add('FAIL', 'pnp.restrict_driver_install',
          "RestrictDriverInstallationToAdministrators = #{v} -- non-admins may install printer drivers",
          'This is the PrintNightmare mitigation and it is explicitly disabled. ' \
          'Set it to 1: reg add "HKLM\\' + PRINTERS_POLICY +
          '" /v RestrictDriverInstallationToAdministrators /t REG_DWORD /d 1 /f')
    end
  end
  # --- 3. Point and Print warning suppression -----------------------------
  def check_point_and_print
    no_warn = @c.reg_value(POINT_AND_PRINT, 'NoWarningNoElevationOnInstall')
    update  = @c.reg_value(POINT_AND_PRINT, 'UpdatePromptSettings')
    @facts['NoWarningNoElevationOnInstall'] = no_warn
    @facts['UpdatePromptSettings'] = update
    if no_warn.to_i == 1
      add('FAIL', 'pnp.no_warning',
          'NoWarningNoElevationOnInstall = 1 -- driver install prompts are suppressed entirely',
          'Microsoft states a system with this value set is vulnerable by design. ' \
          'Delete it or set 0: reg add "HKLM\\' + POINT_AND_PRINT +
          '" /v NoWarningNoElevationOnInstall /t REG_DWORD /d 0 /f')
    else
      add('PASS', 'pnp.no_warning',
          "NoWarningNoElevationOnInstall = #{no_warn.nil? ? 'not set' : no_warn} (prompts intact)")
    end
    if update.to_i == 1
      add('FAIL', 'pnp.update_prompt',
          'UpdatePromptSettings = 1 -- driver UPDATE prompts are suppressed',
          'Same class of bypass as NoWarningNoElevationOnInstall, on the update path. ' \
          'Set to 0: reg add "HKLM\\' + POINT_AND_PRINT +
          '" /v UpdatePromptSettings /t REG_DWORD /d 0 /f')
    else
      add('PASS', 'pnp.update_prompt',
          "UpdatePromptSettings = #{update.nil? ? 'not set' : update} (prompts intact)")
    end
    restricted = @c.reg_value(POINT_AND_PRINT, 'Restricted')
    trusted    = @c.reg_value(POINT_AND_PRINT, 'TrustedServers')
    list       = @c.reg_value(POINT_AND_PRINT, 'ServerList')
    @facts['PointAndPrint.Restricted'] = restricted
    @facts['PointAndPrint.TrustedServers'] = trusted
    @facts['PointAndPrint.ServerList'] = list
    if restricted.to_i == 1 && trusted.to_i == 1 && !list.to_s.strip.empty?
      add('PASS', 'pnp.trusted_servers',
          "Point and Print restricted to an explicit server list (#{list})")
    elsif restricted.to_i == 1 && trusted.to_i != 1
      add('WARN', 'pnp.trusted_servers',
          'Point and Print is Restricted but TrustedServers is not enforced -- ' \
          'clients may pull drivers from any server',
          'Set TrustedServers=1 and populate ServerList with your print servers only.')
    else
      add('WARN', 'pnp.trusted_servers',
          'Point and Print server allow-listing is not configured',
          'Restrict driver sources: set Restricted=1, TrustedServers=1 and ' \
          'ServerList="printsrv01.corp.example;printsrv02.corp.example" under HKLM\\' +
          POINT_AND_PRINT)
    end
  end
  # --- 4. Package Point and Print -----------------------------------------
  def check_package_point_and_print
    only = @c.reg_value(PRINTERS_POLICY + '\PackagePointAndPrintOnly', 'PackagePointAndPrintOnly')
    only = @c.reg_value(POINT_AND_PRINT, 'PackagePointAndPrintOnly') if only.nil?
    srv  = @c.reg_value(PRINTERS_POLICY + '\PackagePointAndPrintServerList',
                        'PackagePointAndPrintServerList')
    @facts['PackagePointAndPrintOnly'] = only
    @facts['PackagePointAndPrintServerList'] = srv
    if only.to_i == 1
      add('PASS', 'pnp.package_only',
          'PackagePointAndPrintOnly = 1 -- only signed, packaged drivers may be installed')
    else
      add('WARN', 'pnp.package_only',
          "PackagePointAndPrintOnly = #{only.nil? ? 'not set' : only} -- unpackaged (v3) drivers are allowed",
          'Packaged drivers must be digitally signed as a unit, which closes the ' \
          'unsigned-DLL path. Enable via GPO: Computer Configuration > Policies > ' \
          'Administrative Templates > Printers > "Only use Package Point and Print".')
    end
    if only.to_i == 1 && srv.to_s.strip.empty?
      add('WARN', 'pnp.package_serverlist',
          'Package Point and Print is on but no approved server list is set',
          'Populate PackagePointAndPrintServerList so clients only accept packages ' \
          'from your print servers.')
    end
  end
  # --- 5. RPC hardening ----------------------------------------------------
  def check_rpc_hardening
    privacy = @c.reg_value(PRINT_CONTROL, 'RpcAuthnLevelPrivacyEnabled')
    @facts['RpcAuthnLevelPrivacyEnabled'] = privacy
    # 1 (or absent on a patched host) = packet privacy enforced. 0 = explicitly
    # weakened, which re-opens the CVE-2021-1678 relay path.
    if privacy.nil?
      add('PASS', 'rpc.authn_privacy',
          'RpcAuthnLevelPrivacyEnabled not set (patched hosts default to enforced)')
    elsif privacy.to_i == 1
      add('PASS', 'rpc.authn_privacy', 'RpcAuthnLevelPrivacyEnabled = 1 (packet privacy enforced)')
    else
      add('FAIL', 'rpc.authn_privacy',
          'RpcAuthnLevelPrivacyEnabled = 0 -- spooler RPC authentication has been downgraded',
          'This was explicitly set to a weakened value and re-enables the ' \
          'CVE-2021-1678 NTLM relay path. Remove the value or set it to 1 under HKLM\\' +
          PRINT_CONTROL)
    end
    endpoint = @c.reg_value(PRINTERS_POLICY, 'RegisterSpoolerRemoteRpcEndPoint')
    @facts['RegisterSpoolerRemoteRpcEndPoint'] = endpoint
    if endpoint.to_i == 2
      add('PASS', 'rpc.remote_endpoint',
          'RegisterSpoolerRemoteRpcEndPoint = 2 (inbound remote print RPC disabled)')
    elsif @facts.dig('spooler', 'state').to_s.casecmp('running').zero? && @facts['role'] != 'print-server'
      add('WARN', 'rpc.remote_endpoint',
          "RegisterSpoolerRemoteRpcEndPoint = #{endpoint.nil? ? 'not set' : endpoint} -- " \
          'this host accepts inbound remote print RPC',
          'On anything that is not a print server, disable inbound remote printing ' \
          'while keeping local printing working: reg add "HKLM\\' + PRINTERS_POLICY +
          '" /v RegisterSpoolerRemoteRpcEndPoint /t REG_DWORD /d 2 /f')
    end
  end
  # --- 6. Inventory --------------------------------------------------------
  def inventory_printers
    printers = @c.printers
    @facts['printer_count'] = printers.size
    shared = printers.select { |p| p['shared'] }
    @facts['shared_printer_count'] = shared.size
    shared.each do |p|
      add('INFO', "printer.shared:#{p['name']}",
          "shared printer '#{p['name']}' (share #{p['share_name']}, driver #{p['driver']})",
          nil, port: p['port'], published: p['published'])
    end
    # A FILE: port writes the spooled job to a path chosen at print time. On a
    # shared printer that is a remote arbitrary-file-write primitive.
    printers.select { |p| p['port'].to_s.upcase.start_with?('FILE:') }.each do |p|
      sev = p['shared'] ? 'FAIL' : 'WARN'
      add(sev, "printer.file_port:#{p['name']}",
          "printer '#{p['name']}' uses a FILE: port#{p['shared'] ? ' AND IS SHARED' : ''}",
          'A FILE: port turns a print job into a file write performed by the spooler ' \
          '(SYSTEM). Remove the port or unshare the printer.')
    end
  end
  def inventory_drivers
    drivers = @c.drivers
    @facts['driver_count'] = drivers.size
    # Drivers whose payload lives outside the protected driver store are the
    # ones worth a human look -- that is where sideloaded v3 drivers land.
    outside = drivers.reject do |d|
      d['path'].to_s.downcase.include?('\\system32\\spool\\drivers') ||
        d['path'].to_s.downcase.include?('\\windows\\system32\\driverstore')
    end
    outside.each do |d|
      add('WARN', "driver.outside_store:#{d['name']}",
          "driver '#{d['name']}' loads from #{d['path']} (outside the protected driver store)",
          'Verify the publisher and that the path is not writable by non-admins ' \
          "(icacls \"#{win_dirname(d['path'])}\").")
    end
    add('INFO', 'driver.count', "#{drivers.size} printer driver(s) installed") if drivers.any?
  end
end
# ===========================================================================
# Reporting
# ===========================================================================
COLOR = { 'FAIL' => "\e[31m", 'WARN' => "\e[33m", 'PASS' => "\e[32m", 'INFO' => "\e[36m" }.freeze
RESET = "\e[0m"
def print_report(findings, facts, color:, source:)
  puts '=' * 78
  puts "  WINDOWS PRINT SPOOLER AUDIT -- #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
  puts '=' * 78
  puts
  puts "  Data source       : #{source}"
  puts "  Host role         : #{facts['role'] || 'unknown'}"
  puts "  OS                : #{facts.dig('os', 'caption') || 'unknown'}"
  puts "  Spooler           : #{facts.dig('spooler', 'state') || 'n/a'} " \
       "(start #{facts.dig('spooler', 'start_mode') || 'n/a'})"
  puts "  Printers          : #{facts['printer_count'] || 0} " \
       "(#{facts['shared_printer_count'] || 0} shared)"
  puts "  Drivers installed : #{facts['driver_count'] || 0}"
  puts
  puts '-' * 78
  puts
  findings.sort_by { |f| [SEVERITY_ORDER[f['severity']] || 9, f['id']] }.each do |f|
    tag = format('[%-4s]', f['severity'])
    tag = "#{COLOR[f['severity']]}#{tag}#{RESET}" if color
    puts "#{tag} #{f['message']}"
    if f['remediation']
      f['remediation'].scan(/.{1,86}(?:\s|\z)/).map(&:strip).reject(&:empty?).each_with_index do |ln, i|
        puts(i.zero? ? "       -> #{ln}" : "          #{ln}")
      end
    end
    puts
  end
  counts = findings.map { |f| f['severity'] }.tally
  puts '-' * 78
  puts "  #{counts.fetch('FAIL', 0)} fail   #{counts.fetch('WARN', 0)} warn   " \
       "#{counts.fetch('INFO', 0)} info   #{counts.fetch('PASS', 0)} pass"
  puts '=' * 78
end
# Dump everything the audit reads, so a Windows host can be captured once and
# replayed (or regression-tested) anywhere.
def capture(collector, path)
  reg_keys = {
    PRINTERS_POLICY => %w[RestrictDriverInstallationToAdministrators
                          RegisterSpoolerRemoteRpcEndPoint],
    POINT_AND_PRINT => %w[NoWarningNoElevationOnInstall UpdatePromptSettings
                          Restricted TrustedServers ServerList PackagePointAndPrintOnly],
    PRINT_CONTROL => %w[RpcAuthnLevelPrivacyEnabled],
    PRINTERS_POLICY + '\PackagePointAndPrintOnly' => %w[PackagePointAndPrintOnly],
    PRINTERS_POLICY + '\PackagePointAndPrintServerList' => %w[PackagePointAndPrintServerList]
  }
  registry = {}
  reg_keys.each do |key, names|
    registry[key] = names.to_h { |n| [n, collector.reg_value(key, n)] }.compact
  end
  data = { 'captured_at' => Time.now.utc.iso8601,
           'os' => collector.os_role,
           'services' => { 'Spooler' => collector.service('Spooler') },
           'printers' => collector.printers,
           'drivers' => collector.drivers,
           'registry' => registry }
  File.write(path, JSON.pretty_generate(data))
  path
end
# ===========================================================================
# Entry point
# ===========================================================================
def main(argv)
  opts = { json: false, color: $stdout.tty?, role: nil, fixture: nil, capture: nil }
  parser = OptionParser.new do |o|
    o.banner = 'Usage: ruby win_print_spooler_audit.rb [options]'
    o.on('--role ROLE', %w[workstation member-server print-server domain-controller],
         'Declare the host role (default: inferred from WMI)') { |v| opts[:role] = v }
    o.on('--fixture PATH', 'Audit a captured JSON snapshot instead of this host') { |v| opts[:fixture] = v }
    o.on('--capture PATH', 'Write this host\'s spooler state to PATH and exit') { |v| opts[:capture] = v }
    o.on('--json', 'Emit JSON instead of a text report') { opts[:json] = true }
    o.on('--[no-]color', 'Force ANSI colour on/off') { |v| opts[:color] = v }
    o.on('-h', '--help', 'Show this help') { puts o; exit 0 }
  end
  begin
    parser.parse!(argv)
  rescue OptionParser::ParseError => e
    warn "error: #{e.message}"
    warn parser.to_s
    return 3
  end
  if opts[:fixture]
    unless File.file?(opts[:fixture])
      warn "error: fixture not found: #{opts[:fixture]}"
      return 3
    end
    collector = FixtureCollector.new(opts[:fixture])
    source = "fixture #{opts[:fixture]}"
  else
    unless WINDOWS
      warn 'error: live audit requires Windows (win32ole + win32/registry).'
      warn '       On Linux/macOS, replay a captured snapshot: --fixture sample.json'
      return 3
    end
    collector = WindowsCollector.new
    source = 'live host (WMI + registry)'
  end
  if opts[:capture]
    path = capture(collector, opts[:capture])
    puts "captured spooler state to #{path}"
    return 0
  end
  findings, facts = SpoolerAudit.new(collector, role: opts[:role]).run
  if opts[:json]
    puts JSON.pretty_generate('generated_at' => Time.now.utc.iso8601,
                              'source' => source,
                              'facts' => facts,
                              'findings' => findings)
  else
    print_report(findings, facts, color: opts[:color], source: source)
  end
  return 2 if findings.any? { |f| f['severity'] == 'FAIL' }
  return 1 if findings.any? { |f| f['severity'] == 'WARN' }
  0
end
exit(main(ARGV)) if __FILE__ == $PROGRAM_NAME

Every OS call sits behind a five-method collector. service, printers, drivers, os_role, reg_value. Three implementations satisfy it: the live WindowsCollector (WMI + registry, about 40 lines), a FixtureCollector that replays captured JSON, and a MockCollector that is a plain Ruby Hash. That is the entire OS coupling.

nil is a distinct state, and getting it backwards is the most common mistake. An absent RestrictDriverInstallationToAdministrators is a WARN, not a PASS — patched hosts default to restricted, but nothing pins it and one GPO flips it. An absent RpcAuthnLevelPrivacyEnabled is genuinely a PASS, because the patched default is enforced and the only way to weaken it is to explicitly write 0.

Host role changes the verdict, not just the wording. Win32_OperatingSystem.ProductType gives 1 (workstation), 2 (domain controller) or 3 (member server). A running spooler is FAIL on a DC, WARN on a member server, INFO on a workstation. --role print-server overrides the inference.

The test harness caught a real bug. File.dirname is platform-aware, so on Linux File.dirname('C:\\Program Files\\Acme\\prn.dll') returns "." — and the remediation string said icacls ".". A win_dirname helper fixes it and a test pins it.

$ ruby win_print_spooler_audit.rb –fixture vulnerable-dc.json # exit 2

==============================================================================
  WINDOWS PRINT SPOOLER AUDIT -- 2026-09-16 12:15:46
==============================================================================
  Data source       : fixture fixtures/vulnerable-dc.json
  Host role         : domain-controller
  OS                : Microsoft Windows Server 2022 Standard
  Spooler           : Running (start Auto)
  Printers          : 3 (2 shared)
  Drivers installed : 3
------------------------------------------------------------------------------
[FAIL] NoWarningNoElevationOnInstall = 1 -- driver install prompts are suppressed entirely
       -> Microsoft states a system with this value set is vulnerable by design. Delete it or
          set 0: reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows NT\Printers\PointAndPrint" /v
          NoWarningNoElevationOnInstall /t REG_DWORD /d 0 /f
[FAIL] RestrictDriverInstallationToAdministrators = 0 -- non-admins may install printer drivers
       -> This is the PrintNightmare mitigation and it is explicitly disabled. Set it to 1: reg
          add "HKLM\SOFTWARE\Policies\Microsoft\Windows NT\Printers" /v
          RestrictDriverInstallationToAdministrators /t REG_DWORD /d 1 /f
[FAIL] UpdatePromptSettings = 1 -- driver UPDATE prompts are suppressed
       -> Same class of bypass as NoWarningNoElevationOnInstall, on the update path. Set to 0:
          reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows NT\Printers\PointAndPrint" /v
          UpdatePromptSettings /t REG_DWORD /d 0 /f
[FAIL] printer 'Archive to PDF' uses a FILE: port AND IS SHARED
       -> A FILE: port turns a print job into a file write performed by the spooler (SYSTEM).
          Remove the port or unshare the printer.
[FAIL] RpcAuthnLevelPrivacyEnabled = 0 -- spooler RPC authentication has been downgraded
       -> This was explicitly set to a weakened value and re-enables the CVE-2021-1678 NTLM
          relay path. Remove the value or set it to 1 under
          HKLM\System\CurrentControlSet\Control\Print
[FAIL] Print Spooler is RUNNING on a domain controller
       -> A DC has no business printing. This is the single highest-value spooler target on the
          network (it enables the printer-bug coercion used in NTLM relay to AD CS). Disable it:
          Stop-Service Spooler -Force; Set-Service Spooler -StartupType Disabled
[WARN] driver 'AcmeLabel v2 Driver' loads from C:\Program Files\AcmeLabel\bin\acmeprn.dll (outside the protected driver store)
       -> Verify the publisher and that the path is not writable by non-admins (icacls
          "C:\Program Files\AcmeLabel\bin").
[WARN] PackagePointAndPrintOnly = not set -- unpackaged (v3) drivers are allowed
       -> Packaged drivers must be digitally signed as a unit, which closes the unsigned-DLL
          path. Enable via GPO: Computer Configuration > Policies > Administrative Templates >
          Printers > "Only use Package Point and Print".
[WARN] Point and Print is Restricted but TrustedServers is not enforced -- clients may pull drivers from any server
       -> Set TrustedServers=1 and populate ServerList with your print servers only.
[WARN] RegisterSpoolerRemoteRpcEndPoint = not set -- this host accepts inbound remote print RPC
       -> On anything that is not a print server, disable inbound remote printing while keeping
          local printing working: reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows NT\Printers"
          /v RegisterSpoolerRemoteRpcEndPoint /t REG_DWORD /d 2 /f
[INFO] 3 printer driver(s) installed
[INFO] shared printer 'Archive to PDF' (share ARCHIVE, driver Microsoft Print To PDF)
[INFO] shared printer 'HP LaserJet M404 (Floor 3)' (share FLOOR3-HP, driver HP Universal Printing PCL 6)
------------------------------------------------------------------------------
  6 fail   4 warn   3 info   0 pass
==============================================================================
$ ruby test_spooler_audit.rb # on Linux, no Windows in sight

======================================================================
  win_print_spooler_audit.rb -- mock collector test harness
  ruby 3.0.2 on x86_64-linux-gnu
======================================================================
baseline: fully hardened workstation
  ok    hardened host yields zero FAIL findings
  ok    hardened host yields zero WARN findings
  ok    stopped spooler reported as PASS
  ok    role inferred as workstation
registry regressions
  ok    RestrictDriverInstallation=0 is FAIL
  ok    RestrictDriverInstallation unset is WARN, not PASS
  ok    NoWarningNoElevationOnInstall=1 is FAIL
  ok    UpdatePromptSettings=1 is FAIL
  ok    RpcAuthnLevelPrivacyEnabled=0 is FAIL
  ok    RpcAuthnLevelPrivacyEnabled absent is PASS (patched default)
  ok    Restricted without TrustedServers is WARN
  ok    PackagePointAndPrintOnly=0 is WARN
host role logic
  ok    running spooler on a DC is FAIL
  ok    DC role inferred from ProductType=2
  ok    --role print-server downgrades the spooler finding to INFO
  ok    running spooler on a plain member server is WARN
printer and driver inventory
  ok    shared FILE: port printer is FAIL
  ok    unshared FILE: port printer is WARN
  ok    driver outside the driver store is WARN
  ok    remediation shows the Windows parent dir, not "."
  ok    driver inside the driver store is not flagged
fixture round-trip and JSON shape
  ok    FixtureCollector reproduces the MockCollector result
  ok    every finding carries severity, id and message
  ok    every FAIL/WARN carries a remediation
----------------------------------------------------------------------
  24 passed, 0 failed
----------------------------------------------------------------------
Get the code

Full script + README on GitHub: ruby-devops-toolkit/win-print-spooler-audit

the setup

What you need

prerequisites
  • Ruby 2.7+ for Windows (RubyInstaller). win32ole and win32/registry ship with it — no gems.
  • Windows for a live audit, from an elevated prompt: reading HKLM\SOFTWARE\Policies\... and querying WMI both need administrator rights.
  • Any platform — Linux, macOS — for --fixture replay and for the test suite.
  • It is read-only. It never stops the spooler, writes a registry value, or removes a driver. Every finding prints the command you would run.
the shape of it

Seven values stand between a user and SYSTEM

The registry values that decide whether driver installation is privileged

A fully patched host is still exploitable if a GPO sets the wrong one of these.

These are not obscure. They are the exact values Microsoft’s own KB articles tell you to set, and the reason they drift is that they live in four different registry keys, are usually set by GPO rather than by hand, and nothing on the machine ever tells you when one of them changes.

the pattern

How to test Windows-only code on Linux

One audit, two data sources, zero Windows required for the tests

Five methods are the whole OS coupling. Everything that makes a judgement is testable anywhere.

This is the structural decision worth stealing even if you never audit a spooler. win32ole and win32/registry do not exist off Windows, so a naive script is untestable anywhere except the machine it is meant to audit — which in practice means untested until it reaches a domain controller.

Put every OS call behind an interface and that problem disappears:

the collector interfaceruby
service(name)     # -> { 'state' => 'Running', 'start_mode' => 'Auto', ... }
printers          # -> [ { 'name' => ..., 'shared' => true, 'port' => ... } ]
drivers           # -> [ { 'name' => ..., 'path' => ... } ]
os_role           # -> { 'product_type' => 2, 'caption' => ... }
reg_value(key, n) # -> value or nil

The live implementation is thin enough to read in one sitting:

WindowsCollectorruby
def initialize
  @wmi = WIN32OLE.connect('winmgmts://./root/cimv2')
end
def service(name)
  q = "SELECT Name, State, StartMode, StartName FROM Win32_Service WHERE Name='#{name}'"
  row = @wmi.ExecQuery(q).each.first
  return nil unless row
  { 'name' => row.Name, 'state' => row.State,
    'start_mode' => row.StartMode, 'start_name' => row.StartName }
end
# Returns nil when the value (or the whole key) is absent, which is a
# meaningful state in its own right: "policy not configured".
def reg_value(subkey, name)
  Win32::Registry::HKEY_LOCAL_MACHINE.open(subkey) do |k|
    begin
      k[name]
    rescue Win32::Registry::Error
      nil
    end
  end
rescue Win32::Registry::Error
  nil
end
Why the nested rescue

Two failure modes, two meanings, same answer. The inner rescue catches “key exists, value does not”. The outer catches “key does not exist at all”. Both mean “policy not configured”, and collapsing them here is what lets every check downstream treat nil as a single, well-defined state.

step 1

The unset trap

Here is the check that most homegrown spooler scripts get wrong:

check_restrict_driver_installruby
# Microsoft changed the DEFAULT to "restricted" in the August 2021 updates,
# so an absent value is safe on a patched host -- but only implicitly, and a
# single GPO can flip it. Absent is a WARN, not a PASS.
if v.nil?
  add('WARN', 'pnp.restrict_driver_install.unset',
      'RestrictDriverInstallationToAdministrators is not configured',
      'Patched hosts default to restricted, but the value is unset so nothing ' \
      'pins it. Set it explicitly: ...')
elsif v.to_i == 1
  add('PASS', 'pnp.restrict_driver_install',
      'RestrictDriverInstallationToAdministrators = 1 (only admins may install drivers)')
else
  add('FAIL', 'pnp.restrict_driver_install',
      "RestrictDriverInstallationToAdministrators = #{v} -- non-admins may install printer drivers",
      'This is the PrintNightmare mitigation and it is explicitly disabled. ...')
end

Three states, three severities. A script that treats nil as 0 gives you a false alarm on every patched host in the fleet; one that treats it as 1 tells you a machine is protected when nothing is pinning it. Neither is useful.

And the inverse case, in the same script, goes the other way:

check_rpc_hardeningruby
# 1 (or absent on a patched host) = packet privacy enforced. 0 = explicitly
# weakened, which re-opens the CVE-2021-1678 relay path.
if privacy.nil?
  add('PASS', 'rpc.authn_privacy',
      'RpcAuthnLevelPrivacyEnabled not set (patched hosts default to enforced)')
elsif privacy.to_i == 1
  add('PASS', 'rpc.authn_privacy', 'RpcAuthnLevelPrivacyEnabled = 1 (packet privacy enforced)')
else
  add('FAIL', 'rpc.authn_privacy',
      'RpcAuthnLevelPrivacyEnabled = 0 -- spooler RPC authentication has been downgraded', ...)
end

Absent is PASS here, because the only way to weaken it is to explicitly write 0. The two checks look almost identical and grade nil in opposite directions — which is exactly the kind of thing a test suite should pin down.

step 2

Role changes the verdict

check_service_stateruby
case role
when 'domain-controller'
  add('FAIL', 'spooler.on_dc',
      'Print Spooler is RUNNING on a domain controller',
      'A DC has no business printing. This is the single highest-value spooler ' \
      'target on the network (it enables the printer-bug coercion used in ' \
      'NTLM relay to AD CS). Disable it: ' \
      'Stop-Service Spooler -Force; Set-Service Spooler -StartupType Disabled')
when 'print-server'
  add('INFO', 'spooler.on_print_server',
      'Print Spooler is running on a declared print server (expected)', ...)
when 'member-server'
  add('WARN', 'spooler.on_member_server', ...)
else
  add('INFO', 'spooler.on_workstation', ...)
end
THE SPOOLER ON A DOMAIN CONTROLLER

It is not just that a DC does not need to print. A reachable spooler is a coercion primitive: it can be made to authenticate to a host you control, and that authentication is a domain controller's machine account.
step 3

The findings WMI gives you for free

Two inventory checks come almost free once you are already querying WMI, and both catch things the policy checks do not.

Shared printers on a FILE: port

inventory_printersruby
# A FILE: port writes the spooled job to a path chosen at print time. On a
# shared printer that is a remote arbitrary-file-write primitive.
printers.select { |p| p['port'].to_s.upcase.start_with?('FILE:') }.each do |p|
  sev = p['shared'] ? 'FAIL' : 'WARN'
  add(sev, "printer.file_port:#{p['name']}",
      "printer '#{p['name']}' uses a FILE: port#{p['shared'] ? ' AND IS SHARED' : ''}",
      'A FILE: port turns a print job into a file write performed by the spooler ' \
      '(SYSTEM). Remove the port or unshare the printer.')
end

Drivers outside the protected driver store

Drivers whose payload lives outside System32\spool\drivers or the DriverStore are where sideloaded v3 drivers land. These are graded WARN rather than FAIL on purpose — the check asks for a human decision instead of asserting a problem, because plenty of legitimate vendor drivers do exactly this.

the payoff

Twenty-four assertions, zero Windows

The test harness drives SpoolerAudit through a MockCollector built from a plain Hash. See the the tests tab above for the full run.

24
assertions
0
failures
0
Windows hosts

Be honest about what this covers. The 24 assertions exercise every judgement the script makes: severity mapping, the nil semantics, role inference, the inventory rules. They do not exercise the WMI queries or the registry reads — those are thin wrappers around single calls, and they are only proven by running the script on Windows.

What the harness buys you is that a logic regression is caught in CI on Linux, instead of on a domain controller at 2am. That is a worthwhile trade, and it costs one interface.

when it goes wrong

Troubleshooting

WIN32OLERuntimeError: failed to create WIN32OLE object

Run from an elevated prompt. WMI’s root/cimv2 namespace and the policy keys under HKLM\SOFTWARE\Policies both need administrator rights.

Every registry value comes back nil on a host you know is configured

Two usual causes. First, 32-bit Ruby on 64-bit Windows gets redirected into HKLM\SOFTWARE\WOW6432Node — install 64-bit Ruby, or open the key with Win32::Registry::KEY_READ | 0x0100 (KEY_WOW64_64KEY). Second, the settings were made in Group Policy but never applied: run gpupdate /force and re-check.

The audit says the policy is right but a normal user can still install a driver

Check for a conflicting GPO further up the precedence chain with gpresult /h report.html, and confirm the machine actually has the July 2021 or later cumulative update. This script reads configuration; it does not verify patch level. Pair it with Get-HotFix or your patch management system.

Fixture output differs from the live run on the same box

Snapshots are point-in-time. Re-capture with --capture before comparing — the captured_at field tells you how stale it is.

A driver legitimately lives outside the driver store

That finding is WARN by design. If it is genuinely approved, filter it in your pipeline by its stable finding id (driver.outside_store:<name>) rather than silencing the whole check.

next

Where to take it

extending the script
  • Fleet sweep. --capture over PowerShell Remoting into a share, then run the audit over every snapshot from one place. Invoke-Command -ComputerName (Get-ADComputer ...) makes this a one-liner.
  • Patch level. Query Win32_QuickFixEngineering in the collector and cross-reference the PrintNightmare KB list, so “patched” and “configured” are reported together.
  • Driver signature verification. Get-AuthenticodeSignature on each driver path, or read the PE certificate table directly, and flag unsigned or self-signed drivers.
  • ACL the spooler directories. A writable C:\Windows\System32\spool\drivers is its own escalation path, independent of the RPC surface.
  • Audit the clients too. The same Point and Print policies apply to every workstation pulling drivers, not just the print server.
  • Wire it to your SIEM. The --json output has a stable id per finding, so you can alert on transitions rather than on every run.