NTLMv1 and LM fallbacks are still the easiest path to credential relay and cracking. This Ruby script reads five registry settings, applies the OS defaults for anything unset, and gives you a pass/fail verdict per control.
Step through the build below:
Windows keeps NTLM behaviour in a handful of registry values spread across Lsa and Lsa\MSV1_0. Group Policy can set them, but who verifies the result on a given host? When a value is absent the OS default applies, which is often the weak one. This script reads each value, substitutes the documented default when missing, and reports whether the effective setting meets a hardened baseline.
#!/usr/bin/env ruby
# frozen_string_literal: true
# win_ntlm_audit.rb - audit Windows NTLM hardening settings in the registry.
# Live mode (Windows): reads HKLM via win32/registry (stdlib on Windows Ruby).
# Snapshot mode (any OS): --snapshot FILE.json {"<key path>|<value>": <data>, ...}
require 'json'
require 'optparse'
LSA = 'SYSTEM\CurrentControlSet\Control\Lsa'
MSV = 'SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0'
NTLM_SEC = 0x20080000 # require NTLMv2 session security + 128-bit encryption
# [id, key, value, description, predicate, expected text, severity, default-when-missing]
CHECKS = [
['lm-level', LSA, 'LmCompatibilityLevel', 'LAN Manager auth level', ->(v) { v >= 5 }, '>= 5 (NTLMv2 only, refuse LM/NTLM)', :high, 3],
['no-lm-hash', LSA, 'NoLMHash', 'Do not store LM hash', ->(v) { v == 1 }, '1', :high, 1],
['client-sec', MSV, 'NtlmMinClientSec', 'Min client session security', ->(v) { (v & NTLM_SEC) == NTLM_SEC }, '0x20080000 bits set', :medium, 0],
['server-sec', MSV, 'NtlmMinServerSec', 'Min server session security', ->(v) { (v & NTLM_SEC) == NTLM_SEC }, '0x20080000 bits set', :medium, 0],
['restrict-out', MSV, 'RestrictSendingNTLMTraffic', 'Outgoing NTLM restriction', ->(v) { v >= 1 }, '>= 1 (audit or deny)', :low, 0]
].freeze
def read_live(key, name)
require 'win32/registry'
Win32::Registry::HKEY_LOCAL_MACHINE.open(key) { |r| r[name] }
rescue LoadError
abort 'win32/registry unavailable: run on Windows or use --snapshot'
rescue StandardError
nil # key or value missing
end
opts = { snapshot: nil, json: false }
OptionParser.new do |o|
o.on('--snapshot FILE') { |v| opts[:snapshot] = v }
o.on('--json') { opts[:json] = true }
end.parse!
snap = opts[:snapshot] ? JSON.parse(File.read(opts[:snapshot])) : nil
results = CHECKS.map do |id, key, name, desc, pred, want, sev, default|
raw = snap ? snap["#{key}|#{name}"] : read_live(key, name)
effective = raw.nil? ? default : raw
status = pred.call(effective) ? 'pass' : 'fail'
{ id: id, setting: desc, value: raw, effective: effective, expected: want,
severity: sev, status: status, note: raw.nil? ? 'not set; OS default applied' : nil }
end
if opts[:json]
puts JSON.pretty_generate(results)
else
results.each do |r|
shown = r[:value].nil? ? '(unset)' : r[:value].to_s
puts format('%-5s %-30s value=%-12s want %s%s', r[:status].upcase, r[:setting], shown, r[:expected], r[:status] == 'fail' ? " [#{r[:severity]}]" : '')
end
fails = results.count { |r| r[:status] == 'fail' }
puts fails.zero? ? 'NTLM posture: HARDENED' : "NTLM posture: #{fails} setting(s) need attention"
end
exit(results.any? { |r| r[:status] == 'fail' } ? 1 : 0)
1. Declare the checks as data. Each row is id, registry key, value name, description, a lambda predicate, expected text, severity and the default applied when unset. Adding a control is one line.
2. Read the registry. Win32::Registry::HKEY_LOCAL_MACHINE.open(key) { |r| r[name] } returns the value or raises if it is missing; the rescue turns that into nil, which is distinct from a real zero.
3. Apply defaults and evaluate. An unset value is replaced by its OS default before the predicate runs, so a missing NtlmMinServerSec correctly fails. The raw value and a note are kept so the report can say ‘(unset)’.
4. Snapshot mode. With --snapshot file.json the same logic runs from a JSON map of "key|value" to data. This makes the audit testable anywhere and lets you audit exported registry data offline.
$ captured from a real test run
$ ruby win_ntlm_audit.rb --snapshot snap_weak.json FAIL LAN Manager auth level value=1 want >= 5 (NTLMv2 only, refuse LM/NTLM) [high] PASS Do not store LM hash value=1 want 1 FAIL Min client session security value=536870912 want 0x20080000 bits set [medium] FAIL Min server session security value=(unset) want 0x20080000 bits set [medium] FAIL Outgoing NTLM restriction value=(unset) want >= 1 (audit or deny) [low] NTLM posture: 4 setting(s) need attention (exit code 1)
Full script + README on GitHub: ruby-devops-toolkit/win-ntlm-audit
What you need
Windows with RubyInstaller (Ruby 3.x). win32/registry ships with Ruby on Windows. Read access to HKLM is enough; nothing is written. On Linux or macOS use --snapshot with a JSON export.
The complete code
#!/usr/bin/env ruby
# frozen_string_literal: true
# win_ntlm_audit.rb - audit Windows NTLM hardening settings in the registry.
# Live mode (Windows): reads HKLM via win32/registry (stdlib on Windows Ruby).
# Snapshot mode (any OS): --snapshot FILE.json {"<key path>|<value>": <data>, ...}
require 'json'
require 'optparse'
LSA = 'SYSTEM\CurrentControlSet\Control\Lsa'
MSV = 'SYSTEM\CurrentControlSet\Control\Lsa\MSV1_0'
NTLM_SEC = 0x20080000 # require NTLMv2 session security + 128-bit encryption
# [id, key, value, description, predicate, expected text, severity, default-when-missing]
CHECKS = [
['lm-level', LSA, 'LmCompatibilityLevel', 'LAN Manager auth level', ->(v) { v >= 5 }, '>= 5 (NTLMv2 only, refuse LM/NTLM)', :high, 3],
['no-lm-hash', LSA, 'NoLMHash', 'Do not store LM hash', ->(v) { v == 1 }, '1', :high, 1],
['client-sec', MSV, 'NtlmMinClientSec', 'Min client session security', ->(v) { (v & NTLM_SEC) == NTLM_SEC }, '0x20080000 bits set', :medium, 0],
['server-sec', MSV, 'NtlmMinServerSec', 'Min server session security', ->(v) { (v & NTLM_SEC) == NTLM_SEC }, '0x20080000 bits set', :medium, 0],
['restrict-out', MSV, 'RestrictSendingNTLMTraffic', 'Outgoing NTLM restriction', ->(v) { v >= 1 }, '>= 1 (audit or deny)', :low, 0]
].freeze
def read_live(key, name)
require 'win32/registry'
Win32::Registry::HKEY_LOCAL_MACHINE.open(key) { |r| r[name] }
rescue LoadError
abort 'win32/registry unavailable: run on Windows or use --snapshot'
rescue StandardError
nil # key or value missing
end
opts = { snapshot: nil, json: false }
OptionParser.new do |o|
o.on('--snapshot FILE') { |v| opts[:snapshot] = v }
o.on('--json') { opts[:json] = true }
end.parse!
snap = opts[:snapshot] ? JSON.parse(File.read(opts[:snapshot])) : nil
results = CHECKS.map do |id, key, name, desc, pred, want, sev, default|
raw = snap ? snap["#{key}|#{name}"] : read_live(key, name)
effective = raw.nil? ? default : raw
status = pred.call(effective) ? 'pass' : 'fail'
{ id: id, setting: desc, value: raw, effective: effective, expected: want,
severity: sev, status: status, note: raw.nil? ? 'not set; OS default applied' : nil }
end
if opts[:json]
puts JSON.pretty_generate(results)
else
results.each do |r|
shown = r[:value].nil? ? '(unset)' : r[:value].to_s
puts format('%-5s %-30s value=%-12s want %s%s', r[:status].upcase, r[:setting], shown, r[:expected], r[:status] == 'fail' ? " [#{r[:severity]}]" : '')
end
fails = results.count { |r| r[:status] == 'fail' }
puts fails.zero? ? 'NTLM posture: HARDENED' : "NTLM posture: #{fails} setting(s) need attention"
end
exit(results.any? { |r| r[:status] == 'fail' } ? 1 : 0)
Step-by-step walkthrough
1. Declare the checks as data
Each row is id, registry key, value name, description, a lambda predicate, expected text, severity and the default applied when unset. Adding a control is one line.
2. Read the registry
Win32::Registry::HKEY_LOCAL_MACHINE.open(key) { |r| r[name] } returns the value or raises if it is missing; the rescue turns that into nil, which is distinct from a real zero.
3. Apply defaults and evaluate
An unset value is replaced by its OS default before the predicate runs, so a missing NtlmMinServerSec correctly fails. The raw value and a note are kept so the report can say ‘(unset)’.
4. Snapshot mode
With --snapshot file.json the same logic runs from a JSON map of "key|value" to data. This makes the audit testable anywhere and lets you audit exported registry data offline.
What you will see
$ ruby win_ntlm_audit.rb --snapshot snap_weak.json
FAIL LAN Manager auth level value=1 want >= 5 (NTLMv2 only, refuse LM/NTLM) [high]
PASS Do not store LM hash value=1 want 1
FAIL Min client session security value=536870912 want 0x20080000 bits set [medium]
FAIL Min server session security value=(unset) want 0x20080000 bits set [medium]
FAIL Outgoing NTLM restriction value=(unset) want >= 1 (audit or deny) [low]
NTLM posture: 4 setting(s) need attention
(exit code 1)
When it does not work
- Honest note: win32/registry cannot run in the Linux sandbox where this was tested. The logic was verified through
--snapshotagainst weak and hardened fixtures; the live read path is a three-line wrapper around the standard library. - ‘Access denied’: run from an elevated prompt or check that the key ACL allows reads.
- Hardening
LmCompatibilityLevelto 5 can break very old clients or NAS devices: audit first, enforce after.
Extending the script
- Add the
RestrictNTLMInDomainand audit-mode values. - Query remote hosts through WinRM or WMI.
- Emit JSON to a SIEM on a schedule.