the shed // windows // uac hardening

UAC is only a security boundary if its registry knobs are set right. One zero in the wrong place turns elevation prompts into a rubber stamp. Here is a Ruby auditor that grades them and prints the exact fix.

Step through the build below:

win_uac_audit.rb

The problem: User Account Control is controlled by a handful of values under Policies\System. Setting EnableLUA=0, silencing admin prompts, or leaving LocalAccountTokenFilterPolicy=1 (which hands remote local admins a full token) quietly removes protection, and a GUI check of the slider will not show all of it. This script reads the values, applies Windows defaults for absent ones, and grades each PASS/WARN/FAIL.

#!/usr/bin/env ruby
# frozen_string_literal: true
# win_uac_audit.rb - grade User Account Control settings read from
#   HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System
# On Windows it reads the live registry (win32/registry, stdlib). Anywhere else
# use --fixture values.json. --self-test runs built-in fixtures on any OS.
# Exit: 0 all PASS, 1 WARN, 2 FAIL
require 'json'
require 'optparse'
KEY = 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System'
# name => [good-test lambda, severity if bad, why, fix]
RULES = {
  'EnableLUA' => [->(v) { v == 1 }, 'FAIL', 'UAC is switched off entirely', 'Set EnableLUA=1 and reboot'],
  'ConsentPromptBehaviorAdmin' => [->(v) { [2, 5].include?(v) }, 'FAIL',
                                   'admins elevate with no prompt (0) or without secure desktop (1,3,4)',
                                   'Set to 2 (always prompt) or 5 (default)'],
  'ConsentPromptBehaviorUser' => [->(v) { [0, 1, 3].include?(v) }, 'WARN',
                                  'unexpected value - standard users should get a credential prompt (3, default, 1) or be denied (0)',
                                  'Set to 3 (default), 1, or 0'],
  'PromptOnSecureDesktop' => [->(v) { v == 1 }, 'FAIL',
                              'prompts render on the user desktop where malware can click them',
                              'Set PromptOnSecureDesktop=1'],
  'FilterAdministratorToken' => [->(v) { v == 1 }, 'WARN',
                                 'built-in Administrator (RID 500) is exempt from Admin Approval Mode',
                                 'Set FilterAdministratorToken=1'],
  'EnableInstallerDetection' => [->(v) { v == 1 }, 'WARN', 'installers do not trigger elevation prompts',
                                 'Set EnableInstallerDetection=1'],
  'EnableVirtualization' => [->(v) { v == 1 }, 'WARN', 'legacy apps write straight to protected locations',
                             'Set EnableVirtualization=1'],
  'LocalAccountTokenFilterPolicy' => [->(v) { v.nil? || v.zero? }, 'FAIL',
                                      'remote local-admin logons get a full token (pass-the-hash lateral movement)',
                                      'Delete the value or set it to 0']
}.freeze
# Missing values fall back to Windows defaults before judging.
DEFAULTS = { 'EnableLUA' => 1, 'ConsentPromptBehaviorAdmin' => 5, 'ConsentPromptBehaviorUser' => 3,
             'PromptOnSecureDesktop' => 1, 'FilterAdministratorToken' => 0, 'EnableInstallerDetection' => 1,
             'EnableVirtualization' => 1, 'LocalAccountTokenFilterPolicy' => nil }.freeze
def read_registry
  require 'win32/registry'
  Win32::Registry::HKEY_LOCAL_MACHINE.open(KEY) do |reg|
    RULES.keys.to_h do |name|
      [name, (reg[name] rescue nil)]
    end
  end
end
def evaluate(values)
  RULES.map do |name, (ok, sev, why, fix)|
    v = values.key?(name) && !values[name].nil? ? values[name] : DEFAULTS[name]
    pass = ok.call(v)
    { setting: name, value: v, status: pass ? 'PASS' : sev, why: pass ? nil : why, fix: pass ? nil : fix }
  end
end
def exit_code(results)
  return 2 if results.any? { |r| r[:status] == 'FAIL' }
  results.any? { |r| r[:status] == 'WARN' } ? 1 : 0
end
def report(results, json)
  if json
    puts JSON.pretty_generate(results)
  else
    results.each do |r|
      puts format('%-5s %-31s = %s', r[:status], r[:setting], r[:value].inspect)
      puts "        why: #{r[:why]}\n        fix: #{r[:fix]}" if r[:why]
    end
  end
end
def self_test
  good = DEFAULTS.merge('ConsentPromptBehaviorAdmin' => 2, 'FilterAdministratorToken' => 1)
  bad  = good.merge('EnableLUA' => 0, 'ConsentPromptBehaviorAdmin' => 0, 'LocalAccountTokenFilterPolicy' => 1)
  raise 'good fixture should pass' unless exit_code(evaluate(good)).zero?
  raise 'bad fixture should fail' unless exit_code(evaluate(bad)) == 2
  fails = evaluate(bad).count { |r| r[:status] == 'FAIL' }
  raise "expected 3 FAILs, got #{fails}" unless fails == 3
  puts 'self-test OK (3 assertions)'
end
if $PROGRAM_NAME == __FILE__
  o = {}
  OptionParser.new do |op|
    op.on('--fixture F') { |v| o[:fixture] = v }
    op.on('--json') { o[:json] = true }
    op.on('--self-test') { o[:self] = true }
  end.parse!
  (self_test; exit 0) if o[:self]
  values = o[:fixture] ? JSON.parse(File.read(o[:fixture])) : read_registry
  results = evaluate(values)
  report(results, o[:json])
  exit exit_code(results)
end

1. A RULES hash maps each setting to a test lambda, a severity, a why and a fix. 2. Missing values fall back to DEFAULTS before judging. 3. Live mode reads HKLM via win32/registry; --fixture reads JSON so it runs anywhere. 4. Exit code is 2 on any FAIL, 1 on WARN. Honest note: the registry call cannot run on Linux, so logic was verified with fixtures and --self-test.

PASS  EnableLUA                       = 1
FAIL  ConsentPromptBehaviorAdmin      = 0
        why: admins elevate with no prompt (0) or without secure desktop (1,3,4)
        fix: Set to 2 (always prompt) or 5 (default)
PASS  ConsentPromptBehaviorUser       = 3
FAIL  PromptOnSecureDesktop           = 0
        why: prompts render on the user desktop where malware can click them
        fix: Set PromptOnSecureDesktop=1
WARN  FilterAdministratorToken        = 0
        why: built-in Administrator (RID 500) is exempt from Admin Approval Mode
        fix: Set FilterAdministratorToken=1
PASS  EnableInstallerDetection        = 1
PASS  EnableVirtualization            = 1
FAIL  LocalAccountTokenFilterPolicy   = 1
        why: remote local-admin logons get a full token (pass-the-hash lateral movement)
        fix: Delete the value or set it to 0
self-test OK (3 assertions)
Get the code

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

Flow diagram: registry to defaults to rules to results

registry values in, graded findings out
prerequisites

What you need

  • Ruby 3.0+ for Windows (RubyInstaller) for live mode; any OS for --fixture and --self-test
  • No gems: win32/registry ships with Ruby on Windows
  • Read access to HKLM (standard users can read this key)
the script

Full code

win_uac_audit.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
# win_uac_audit.rb - grade User Account Control settings read from
#   HKLM\SOFTWARE\Microsoft\Windows\CurrentVersion\Policies\System
# On Windows it reads the live registry (win32/registry, stdlib). Anywhere else
# use --fixture values.json. --self-test runs built-in fixtures on any OS.
# Exit: 0 all PASS, 1 WARN, 2 FAIL
require 'json'
require 'optparse'
KEY = 'SOFTWARE\\Microsoft\\Windows\\CurrentVersion\\Policies\\System'
# name => [good-test lambda, severity if bad, why, fix]
RULES = {
  'EnableLUA' => [->(v) { v == 1 }, 'FAIL', 'UAC is switched off entirely', 'Set EnableLUA=1 and reboot'],
  'ConsentPromptBehaviorAdmin' => [->(v) { [2, 5].include?(v) }, 'FAIL',
                                   'admins elevate with no prompt (0) or without secure desktop (1,3,4)',
                                   'Set to 2 (always prompt) or 5 (default)'],
  'ConsentPromptBehaviorUser' => [->(v) { [0, 1, 3].include?(v) }, 'WARN',
                                  'unexpected value - standard users should get a credential prompt (3, default, 1) or be denied (0)',
                                  'Set to 3 (default), 1, or 0'],
  'PromptOnSecureDesktop' => [->(v) { v == 1 }, 'FAIL',
                              'prompts render on the user desktop where malware can click them',
                              'Set PromptOnSecureDesktop=1'],
  'FilterAdministratorToken' => [->(v) { v == 1 }, 'WARN',
                                 'built-in Administrator (RID 500) is exempt from Admin Approval Mode',
                                 'Set FilterAdministratorToken=1'],
  'EnableInstallerDetection' => [->(v) { v == 1 }, 'WARN', 'installers do not trigger elevation prompts',
                                 'Set EnableInstallerDetection=1'],
  'EnableVirtualization' => [->(v) { v == 1 }, 'WARN', 'legacy apps write straight to protected locations',
                             'Set EnableVirtualization=1'],
  'LocalAccountTokenFilterPolicy' => [->(v) { v.nil? || v.zero? }, 'FAIL',
                                      'remote local-admin logons get a full token (pass-the-hash lateral movement)',
                                      'Delete the value or set it to 0']
}.freeze
# Missing values fall back to Windows defaults before judging.
DEFAULTS = { 'EnableLUA' => 1, 'ConsentPromptBehaviorAdmin' => 5, 'ConsentPromptBehaviorUser' => 3,
             'PromptOnSecureDesktop' => 1, 'FilterAdministratorToken' => 0, 'EnableInstallerDetection' => 1,
             'EnableVirtualization' => 1, 'LocalAccountTokenFilterPolicy' => nil }.freeze
def read_registry
  require 'win32/registry'
  Win32::Registry::HKEY_LOCAL_MACHINE.open(KEY) do |reg|
    RULES.keys.to_h do |name|
      [name, (reg[name] rescue nil)]
    end
  end
end
def evaluate(values)
  RULES.map do |name, (ok, sev, why, fix)|
    v = values.key?(name) && !values[name].nil? ? values[name] : DEFAULTS[name]
    pass = ok.call(v)
    { setting: name, value: v, status: pass ? 'PASS' : sev, why: pass ? nil : why, fix: pass ? nil : fix }
  end
end
def exit_code(results)
  return 2 if results.any? { |r| r[:status] == 'FAIL' }
  results.any? { |r| r[:status] == 'WARN' } ? 1 : 0
end
def report(results, json)
  if json
    puts JSON.pretty_generate(results)
  else
    results.each do |r|
      puts format('%-5s %-31s = %s', r[:status], r[:setting], r[:value].inspect)
      puts "        why: #{r[:why]}\n        fix: #{r[:fix]}" if r[:why]
    end
  end
end
def self_test
  good = DEFAULTS.merge('ConsentPromptBehaviorAdmin' => 2, 'FilterAdministratorToken' => 1)
  bad  = good.merge('EnableLUA' => 0, 'ConsentPromptBehaviorAdmin' => 0, 'LocalAccountTokenFilterPolicy' => 1)
  raise 'good fixture should pass' unless exit_code(evaluate(good)).zero?
  raise 'bad fixture should fail' unless exit_code(evaluate(bad)) == 2
  fails = evaluate(bad).count { |r| r[:status] == 'FAIL' }
  raise "expected 3 FAILs, got #{fails}" unless fails == 3
  puts 'self-test OK (3 assertions)'
end
if $PROGRAM_NAME == __FILE__
  o = {}
  OptionParser.new do |op|
    op.on('--fixture F') { |v| o[:fixture] = v }
    op.on('--json') { o[:json] = true }
    op.on('--self-test') { o[:self] = true }
  end.parse!
  (self_test; exit 0) if o[:self]
  values = o[:fixture] ? JSON.parse(File.read(o[:fixture])) : read_registry
  results = evaluate(values)
  report(results, o[:json])
  exit exit_code(results)
end
walkthrough

How it works, step by step

Data-driven rules. Instead of a chain of ifs, RULES is a table. Adding a check means adding one row, and the report code never changes.

Defaults. Windows treats a missing value as its built-in default (for example ConsentPromptBehaviorAdmin is 5). Judging a raw nil would produce false alarms, so evaluate substitutes the default first. LocalAccountTokenFilterPolicy defaults to absent, which is the safe state.

Registry access. Win32::Registry::HKEY_LOCAL_MACHINE.open reads each value, rescuing the error for values that do not exist.

Testability. --self-test builds one good and one bad settings hash and asserts the exit codes and the number of FAILs, so CI on Linux can guard the logic.

example output

Running it

win_uac_audit.rb
PASS EnableLUA = 1
FAIL ConsentPromptBehaviorAdmin = 0
why: admins elevate with no prompt (0) or without secure desktop (1,3,4)
fix: Set to 2 (always prompt) or 5 (default)
PASS ConsentPromptBehaviorUser = 3
FAIL PromptOnSecureDesktop = 0
why: prompts render on the user desktop where malware can click them
fix: Set PromptOnSecureDesktop=1
WARN FilterAdministratorToken = 0
why: built-in Administrator (RID 500) is exempt from Admin Approval Mode
fix: Set FilterAdministratorToken=1
PASS EnableInstallerDetection = 1
PASS EnableVirtualization = 1
FAIL LocalAccountTokenFilterPolicy = 1
why: remote local-admin logons get a full token (pass-the-hash lateral movement)
fix: Delete the value or set it to 0
self-test OK (3 assertions)
troubleshooting

When things go sideways

  • LoadError win32/registry: you are not on Windows; use --fixture.
  • Domain GPO overrides: policy writes the same keys, so the registry shows the effective value, but run gpresult to find the source.
  • Testing note: the live registry path could not be executed in the Linux sandbox; only the rule engine was tested, with fixtures and the self-test.
extending

Where to take it next

  • Scan a fleet via remote registry or WinRM and aggregate
  • Add ValidateAdminCodeSignatures and EnableSecureUIAPaths
  • Diff against a saved baseline like the registry-drift tool

Tha-Shed

Type a brief, get a finished film. Characters, voices and music included.

Start creating