the shed // ruby // windows // registry

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:

win_ntlm_audit.rb

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)
Get the code

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

prerequisites

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.

win_ntlm_audit.rb pipeline diagram

win_ntlm_audit.rb pipeline
full script

The complete code

win_ntlm_audit.rbruby
#!/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)
how it works

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.

example output

What you will see

terminaltext
$ 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)
troubleshooting

When it does not work

  • Honest note: win32/registry cannot run in the Linux sandbox where this was tested. The logic was verified through --snapshot against 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 LmCompatibilityLevel to 5 can break very old clients or NAS devices: audit first, enforce after.
next steps

Extending the script

  • Add the RestrictNTLMInDomain and audit-mode values.
  • Query remote hosts through WinRM or WMI.
  • Emit JSON to a SIEM on a schedule.

Tha-Shed

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

Start creating