LLMNR, NetBIOS-NS, mDNS and WPAD are the four ways a mistyped hostname becomes a captured NTLM hash — and each one is a registry value or a per-adapter flag that quietly drifts back on. A Ruby audit that reads them through WMI and win32/registry, prints PASS/FAIL with the exact fix, and exits non-zero until the box is actually hardened.
Step through the build below:
Windows never takes “no” from DNS. When a name does not resolve — a typo, a decommissioned share, a laptop looking for its office printer from a coffee shop — the resolver falls back to asking the local segment: LLMNR, NetBIOS name service, mDNS, and a WPAD lookup for a proxy. Anyone on that segment can answer “that’s me”. The client then authenticates to the impostor with NTLM, no click required.
That is the whole trick behind Responder and ntlmrelayx, and it has been the first move in internal penetration tests for a decade because the fixes are boring registry values that nobody verifies after the GPO is written: EnableMulticast, TcpipNetbiosOptions, EnableMDNS, SMB1, SMB signing, WinHttpAutoProxySvc.
win_nameres_audit.rb reads all of them — per adapter via WMI, per policy via the registry — and prints PASS/FAIL with the exact fix, exiting 2 while any CRIT remains.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# win_nameres_audit.rb — audit a Windows host for the name-resolution and SMB
# settings that make credential-relay attacks (Responder, ntlmrelayx) work.
#
# When DNS fails to resolve a name, Windows falls back to asking the local
# network: LLMNR, NetBIOS-NS and mDNS broadcasts, plus a WPAD lookup for a proxy.
# Anyone on the same segment can answer "that's me", collect an NTLMv2 hash or
# relay the authentication, and be a domain user before lunch. Every one of
# these fallbacks can be turned off; this script checks whether they were.
#
# Checks (severity):
# [CRIT] LLMNR EnableMulticast policy missing or != 0
# [CRIT] NETBIOS NetBIOS over TCP/IP enabled on an IP-enabled adapter
# [CRIT] SMB1_ENABLED SMB1 server component present / not disabled
# [WARN] SMB_SIGN_SRV LanmanServer RequireSecuritySignature != 1
# [WARN] SMB_SIGN_CLI LanmanWorkstation RequireSecuritySignature != 1
# [WARN] MDNS EnableMDNS != 0 (Windows 10 1703+ answers mDNS by default)
# [WARN] WPAD_AUTO proxy auto-detect on, or WinHttpAutoProxySvc not disabled
# [WARN] NETBT_NODE NodeType != 2 (P-node) so NetBIOS still broadcasts
# [INFO] WINS a WINS server is configured (legacy, but not exploitable by itself)
# [INFO] DNS_NOT_LOCAL adapter DNS servers are not RFC1918/link-local (public resolvers on a domain host)
#
# Usage: ruby win_nameres_audit.rb [--json] (run elevated for HKLM policy keys)
# Exit: 0 clean, 1 warnings only, 2 any CRIT
#
# Requirements: Ruby 3.x on Windows; win32ole (stdlib) and win32-registry (default gem).
# The analysis layer takes plain Hashes/Arrays, so it is unit-tested on Linux with
# fixtures — see test_win_nameres_audit.rb.
require 'optparse'
require 'json'
# --------------------------------------------------------------------------
# Data sources — the only code that touches Windows.
# --------------------------------------------------------------------------
class WindowsSources
def initialize
require 'win32ole'
require 'win32/registry'
@wmi = WIN32OLE.connect('winmgmts:\\\\.\\root\\cimv2')
end
# One Hash per IP-enabled adapter (WMI Win32_NetworkAdapterConfiguration).
def adapters
q = 'SELECT Description, IPEnabled, IPAddress, DHCPEnabled, DNSServerSearchOrder, TcpipNetbiosOptions, WINSPrimaryServer FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled = TRUE'
@wmi.ExecQuery(q).map do |a|
{ description: a.Description.to_s, ip: Array(a.IPAddress).map(&:to_s), dhcp: a.DHCPEnabled ? true : false,
dns: Array(a.DNSServerSearchOrder).map(&:to_s), netbios: a.TcpipNetbiosOptions.to_i, wins: a.WINSPrimaryServer.to_s }
end
end
# SMB1 feature state via WMI Win32_OptionalFeature (InstallState 1 = enabled, 2 = disabled)
def smb1_feature_state
@wmi.ExecQuery("SELECT InstallState FROM Win32_OptionalFeature WHERE Name = 'SMB1Protocol-Server'").map { |f| f.InstallState.to_i }.first
rescue WIN32OLERuntimeError
nil
end
# Registry values as a flat Hash of "HIVE\\path\\value" => data (nil when absent).
REG_VALUES = {
'llmnr' => ['HKLM', 'SOFTWARE\Policies\Microsoft\Windows NT\DNSClient', 'EnableMulticast'],
'mdns' => ['HKLM', 'SYSTEM\CurrentControlSet\Services\Dnscache\Parameters', 'EnableMDNS'],
'nodetype' => ['HKLM', 'SYSTEM\CurrentControlSet\Services\NetBT\Parameters', 'NodeType'],
'smb1' => ['HKLM', 'SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters', 'SMB1'],
'sign_srv' => ['HKLM', 'SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters', 'RequireSecuritySignature'],
'sign_cli' => ['HKLM', 'SYSTEM\CurrentControlSet\Services\LanmanWorkstation\Parameters', 'RequireSecuritySignature'],
'wpad_svc' => ['HKLM', 'SYSTEM\CurrentControlSet\Services\WinHttpAutoProxySvc', 'Start'],
'wpad_policy' => ['HKLM', 'SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\Internet Settings', 'EnableAutoProxyResultCache'],
'wpad_user' => ['HKCU', 'SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections', 'DefaultConnectionSettings']
}.freeze
def registry
REG_VALUES.transform_values do |hive, path, name|
root = hive == 'HKLM' ? Win32::Registry::HKEY_LOCAL_MACHINE : Win32::Registry::HKEY_CURRENT_USER
root.open(path, Win32::Registry::KEY_READ | 0x0100) { |k| k[name] }
rescue Win32::Registry::Error
nil
end
end
end
# --------------------------------------------------------------------------
# Analysis — pure Ruby. adapters is an Array of Hashes, reg a Hash of the
# keys above, smb1_state an Integer or nil.
# --------------------------------------------------------------------------
module Analyzer
Check = Struct.new(:severity, :code, :status, :detail, :fix, keyword_init: true)
PRIVATE_DNS = [/\A10\./, /\A192\.168\./, /\A172\.(1[6-9]|2\d|3[01])\./, /\A127\./, /\A169\.254\./, /\Afe80:/i, /\Afd/i, /\A::1\z/].freeze
def self.run(adapters, reg, smb1_state)
c = []
# LLMNR: policy value 0 disables. Absent = enabled (the default).
llmnr = reg['llmnr']
c << Check.new(severity: 'CRIT', code: 'LLMNR', status: llmnr.to_i.zero? && !llmnr.nil? ? 'PASS' : 'FAIL',
detail: llmnr.nil? ? 'EnableMulticast policy not set (LLMNR on by default)' : "EnableMulticast=#{llmnr}",
fix: 'reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient" /v EnableMulticast /t REG_DWORD /d 0 /f')
# NetBIOS over TCP/IP per adapter: 0 = via DHCP (usually on), 1 = on, 2 = off
nb_on = adapters.select { |a| a[:netbios] != 2 }
c << Check.new(severity: 'CRIT', code: 'NETBIOS', status: nb_on.empty? ? 'PASS' : 'FAIL',
detail: nb_on.empty? ? 'disabled on all IP-enabled adapters' : nb_on.map { |a| "#{a[:description]} (TcpipNetbiosOptions=#{a[:netbios]})" }.join('; '),
fix: 'wmic nicconfig where IPEnabled=true call SetTcpipNetbios 2 (or Set-NetAdapterBinding / DHCP option 001)')
# mDNS: EnableMDNS 0 disables; absent = enabled on Win10 1703+
md = reg['mdns']
c << Check.new(severity: 'WARN', code: 'MDNS', status: !md.nil? && md.to_i.zero? ? 'PASS' : 'FAIL',
detail: md.nil? ? 'EnableMDNS not set (mDNS responder on by default)' : "EnableMDNS=#{md}",
fix: 'reg add HKLM\SYSTEM\CurrentControlSet\Services\Dnscache\Parameters /v EnableMDNS /t REG_DWORD /d 0 /f')
# NetBT node type: 2 = P-node (WINS only, no broadcast). 1 B, 4 M, 8 H.
nt = reg['nodetype']
c << Check.new(severity: 'WARN', code: 'NETBT_NODE', status: nt.to_i == 2 ? 'PASS' : 'FAIL',
detail: nt.nil? ? 'NodeType not set (H-node when WINS configured, else B-node broadcasts)' : "NodeType=#{nt}",
fix: 'reg add HKLM\SYSTEM\CurrentControlSet\Services\NetBT\Parameters /v NodeType /t REG_DWORD /d 2 /f')
# SMB1: feature disabled (InstallState 2) or SMB1=0 in LanmanServer
smb1 = reg['smb1']
smb1_off = (smb1_state == 2) || (!smb1.nil? && smb1.to_i.zero?)
c << Check.new(severity: 'CRIT', code: 'SMB1_ENABLED', status: smb1_off ? 'PASS' : 'FAIL',
detail: "SMB1Protocol-Server InstallState=#{smb1_state.inspect}, LanmanServer\\SMB1=#{smb1.inspect}",
fix: 'Disable-WindowsOptionalFeature -Online -FeatureName SMB1Protocol / Set-SmbServerConfiguration -EnableSMB1Protocol $false')
# SMB signing
c << Check.new(severity: 'WARN', code: 'SMB_SIGN_SRV', status: reg['sign_srv'].to_i == 1 ? 'PASS' : 'FAIL',
detail: "LanmanServer RequireSecuritySignature=#{reg['sign_srv'].inspect}",
fix: 'Set-SmbServerConfiguration -RequireSecuritySignature $true')
c << Check.new(severity: 'WARN', code: 'SMB_SIGN_CLI', status: reg['sign_cli'].to_i == 1 ? 'PASS' : 'FAIL',
detail: "LanmanWorkstation RequireSecuritySignature=#{reg['sign_cli'].inspect}",
fix: 'Set-SmbClientConfiguration -RequireSecuritySignature $true')
# WPAD: service Start 4 = disabled. DefaultConnectionSettings byte 8 bit 0x08 = auto-detect on.
svc_disabled = reg['wpad_svc'].to_i == 4
dcs = reg['wpad_user']
autodetect = dcs.is_a?(String) && dcs.bytesize > 8 ? (dcs.getbyte(8) & 0x08) != 0 : nil
wpad_fail = !svc_disabled || autodetect == true
c << Check.new(severity: 'WARN', code: 'WPAD_AUTO', status: wpad_fail ? 'FAIL' : 'PASS',
detail: "WinHttpAutoProxySvc Start=#{reg['wpad_svc'].inspect}#{autodetect.nil? ? '' : ", IE auto-detect=#{autodetect}"}",
fix: 'sc config WinHttpAutoProxySvc start= disabled; untick "Automatically detect settings"; add a wpad DNS record pointing at a sinkhole')
# WINS / DNS hygiene (informational)
wins = adapters.select { |a| !a[:wins].empty? }
c << Check.new(severity: 'INFO', code: 'WINS', status: wins.empty? ? 'PASS' : 'FAIL',
detail: wins.empty? ? 'no WINS servers' : wins.map { |a| "#{a[:description]} -> #{a[:wins]}" }.join('; '), fix: 'remove WINS once NetBIOS is off')
pub = adapters.flat_map { |a| a[:dns].reject { |d| PRIVATE_DNS.any? { |re| d =~ re } }.map { |d| "#{a[:description]} -> #{d}" } }
c << Check.new(severity: 'INFO', code: 'DNS_NOT_LOCAL', status: pub.empty? ? 'PASS' : 'FAIL',
detail: pub.empty? ? 'all adapter DNS servers are private/link-local' : pub.join('; '), fix: 'point domain members at internal resolvers only')
c
end
end
def summarize(checks)
fails = checks.reject { |k| k.status == 'PASS' }
crit = fails.count { |k| k.severity == 'CRIT' }
warn = fails.count { |k| k.severity == 'WARN' }
[crit.positive? ? 'CRIT' : (warn.positive? ? 'WARN' : 'OK'), crit, warn]
end
def print_text(adapters, checks)
status, crit, warn = summarize(checks)
puts "win_nameres_audit #{Time.now.strftime('%Y-%m-%d %H:%M')} #{adapters.size} IP-enabled adapter(s)"
puts '-' * 100
adapters.each do |a|
puts format(' %-40s ip=%-16s dhcp=%-5s netbios=%-1s dns=%s', a[:description][0, 40], a[:ip].first, a[:dhcp], a[:netbios], a[:dns].join(','))
end
puts
puts format('%-6s %-14s %-5s %s', 'SEV', 'CHECK', 'RESULT', 'DETAIL')
checks.each do |k|
puts format('%-6s %-14s %-5s %s', k.severity, k.code, k.status, k.detail)
puts format('%-6s %-14s %-5s fix: %s', '', '', '', k.fix) if k.status == 'FAIL' && k.severity != 'INFO'
end
puts
puts "#{status}: #{crit} critical, #{warn} warning(s) — #{checks.count { |k| k.status == 'PASS' }}/#{checks.size} checks pass"
end
if __FILE__ == $PROGRAM_NAME
opts = { json: false }
OptionParser.new do |o|
o.banner = 'Usage: win_nameres_audit.rb [--json]'
o.on('--json', 'JSON output') { opts[:json] = true }
end.parse!
abort 'win_nameres_audit.rb only runs on Windows (needs win32ole + win32/registry)' unless RUBY_PLATFORM =~ /mingw|mswin|cygwin/
src = WindowsSources.new
adapters = src.adapters
checks = Analyzer.run(adapters, src.registry, src.smb1_feature_state)
status, crit, warn = summarize(checks)
if opts[:json]
puts JSON.pretty_generate(status: status, critical: crit, warnings: warn, adapters: adapters, checks: checks.map(&:to_h))
else
print_text(adapters, checks)
end
exit(crit.positive? ? 2 : (warn.positive? ? 1 : 0))
end
One class touches Windows; the rest is Hashes. WindowsSources#adapters queries Win32_NetworkAdapterConfiguration WHERE IPEnabled = TRUE and returns plain Hashes. #registry reads nine named values with KEY_WOW64_64KEY and returns nil for absent ones — absence is itself a finding for LLMNR and mDNS. #smb1_feature_state asks Win32_OptionalFeature.
Analyzer.run is ten explicit checks. Each produces a Check with severity, PASS/FAIL, the observed value, and a copy-pasteable fix. Severities are deliberate: LLMNR, NetBIOS and SMB1 are CRIT because each alone enables a well-known attack; signing, mDNS, node type and WPAD are WARN; WINS and public DNS servers are INFO.
WPAD needs a byte, not a value. Internet Explorer’s “Automatically detect settings” lives in the binary DefaultConnectionSettings blob — byte 8, bit 0x08. The script reads it with getbyte and also checks the WinHttpAutoProxySvc start type, because WinHTTP clients use WPAD independently of the browser.
Tested on Linux with fixtures. Because the analyzer takes plain data, the harness feeds it an unhardened workstation (expect CRIT) and the same box after the GPO (expect OK) and asserts on both.
win_nameres_audit 2026-09-11 15:53 3 IP-enabled adapter(s)
----------------------------------------------------------------------------------------------------
Intel(R) Ethernet Connection I219-LM ip=10.20.5.41 dhcp=true netbios=0 dns=10.20.0.10,10.20.0.11
Intel(R) Wi-Fi 6 AX201 160MHz ip=192.168.1.57 dhcp=true netbios=0 dns=8.8.8.8,1.1.1.1
Hyper-V Virtual Ethernet Adapter ip=172.28.0.1 dhcp=false netbios=2 dns=
SEV CHECK RESULT DETAIL
CRIT LLMNR FAIL EnableMulticast policy not set (LLMNR on by default)
fix: reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient" /v EnableMulticast /t REG_DWORD /d 0 /f
CRIT NETBIOS FAIL Intel(R) Ethernet Connection I219-LM (TcpipNetbiosOptions=0); Intel(R) Wi-Fi 6 AX201 160MHz (TcpipNetbiosOptions=0)
fix: wmic nicconfig where IPEnabled=true call SetTcpipNetbios 2 (or Set-NetAdapterBinding / DHCP option 001)
WARN MDNS FAIL EnableMDNS not set (mDNS responder on by default)
fix: reg add HKLM\SYSTEM\CurrentControlSet\Services\Dnscache\Parameters /v EnableMDNS /t REG_DWORD /d 0 /f
WARN NETBT_NODE FAIL NodeType not set (H-node when WINS configured, else B-node broadcasts)
fix: reg add HKLM\SYSTEM\CurrentControlSet\Services\NetBT\Parameters /v NodeType /t REG_DWORD /d 2 /f
CRIT SMB1_ENABLED FAIL SMB1Protocol-Server InstallState=1, LanmanServer\SMB1=nil
fix: Disable-WindowsOptionalFeature -Online -FeatureName SMB1Protocol / Set-SmbServerConfiguration -EnableSMB1Protocol $false
WARN SMB_SIGN_SRV FAIL LanmanServer RequireSecuritySignature=0
fix: Set-SmbServerConfiguration -RequireSecuritySignature $true
WARN SMB_SIGN_CLI FAIL LanmanWorkstation RequireSecuritySignature=0
fix: Set-SmbClientConfiguration -RequireSecuritySignature $true
WARN WPAD_AUTO FAIL WinHttpAutoProxySvc Start=3, IE auto-detect=true
fix: sc config WinHttpAutoProxySvc start= disabled; untick "Automatically detect settings"; add a wpad DNS record pointing at a sinkhole
INFO WINS PASS no WINS servers
INFO DNS_NOT_LOCAL FAIL Intel(R) Wi-Fi 6 AX201 160MHz -> 8.8.8.8; Intel(R) Wi-Fi 6 AX201 160MHz -> 1.1.1.1
CRIT: 3 critical, 5 warning(s) — 1/10 checks pass
=== after hardening (GPO applied, SMB1 removed, NetBIOS off) ===
win_nameres_audit 2026-09-11 15:53 3 IP-enabled adapter(s)
----------------------------------------------------------------------------------------------------
Intel(R) Ethernet Connection I219-LM ip=10.20.5.41 dhcp=true netbios=2 dns=10.20.0.10,10.20.0.11
Intel(R) Wi-Fi 6 AX201 160MHz ip=192.168.1.57 dhcp=true netbios=2 dns=10.20.0.10,10.20.0.10
Hyper-V Virtual Ethernet Adapter ip=172.28.0.1 dhcp=false netbios=2 dns=
SEV CHECK RESULT DETAIL
CRIT LLMNR PASS EnableMulticast=0
CRIT NETBIOS PASS disabled on all IP-enabled adapters
WARN MDNS PASS EnableMDNS=0
WARN NETBT_NODE PASS NodeType=2
CRIT SMB1_ENABLED PASS SMB1Protocol-Server InstallState=2, LanmanServer\SMB1=0
WARN SMB_SIGN_SRV PASS LanmanServer RequireSecuritySignature=1
WARN SMB_SIGN_CLI PASS LanmanWorkstation RequireSecuritySignature=1
WARN WPAD_AUTO PASS WinHttpAutoProxySvc Start=4, IE auto-detect=false
INFO WINS PASS no WINS servers
INFO DNS_NOT_LOCAL PASS all adapter DNS servers are private/link-local
OK: 0 critical, 0 warning(s) — 10/10 checks pass
assertions: 6/6 passed
Full script + README on GitHub: ruby-devops-toolkit/win-nameres-audit
The fallback chain
Name resolution on Windows is a chain, not a lookup. The hosts file and DNS come first; when they fail, the OS broadcasts the question to everyone nearby using LLMNR (UDP 5355), NetBIOS name service (UDP 137) and, since Windows 10 1703, mDNS (UDP 5353). Separately, if proxy auto-detection is on, every WinHTTP client asks for a host literally named wpad — and falls through the same chain when DNS has no answer. An attacker running Responder on the segment replies to all of it, the victim connects to the attacker’s SMB or HTTP listener, and NTLM authentication happens automatically. The captured NTLMv2 response can be cracked offline, or relayed live to any server that does not require SMB signing.
None of this is new, and every step has a documented mitigation. The operational problem is drift: a new NIC arrives with NetBIOS set to “default” (on, via DHCP), an image predates the GPO, SMB1 gets re-enabled for a printer, a browser reset ticks “automatically detect settings” again. A checklist catches it once. A script you can schedule catches it every night.
What you need
- Ruby 3.x on Windows (RubyInstaller).
win32oleships in the standard library on Windows;win32-registryis a bundled default gem (gem install win32-registryifrequirefails). - Windows 10 / Server 2016 or later. The registry paths and
Win32_OptionalFeatureclass exist on older versions too, but the mDNS check only means something on 1703+. - Run elevated to read the HKLM policy and service keys reliably and to query
Win32_OptionalFeature. - Any OS for the test harness — it never loads
win32ole.
win_nameres_audit.rb
The full script: one WindowsSources class, one Analyzer module, a printer, and a main guarded by if __FILE__ == $PROGRAM_NAME.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# win_nameres_audit.rb — audit a Windows host for the name-resolution and SMB
# settings that make credential-relay attacks (Responder, ntlmrelayx) work.
#
# When DNS fails to resolve a name, Windows falls back to asking the local
# network: LLMNR, NetBIOS-NS and mDNS broadcasts, plus a WPAD lookup for a proxy.
# Anyone on the same segment can answer "that's me", collect an NTLMv2 hash or
# relay the authentication, and be a domain user before lunch. Every one of
# these fallbacks can be turned off; this script checks whether they were.
#
# Checks (severity):
# [CRIT] LLMNR EnableMulticast policy missing or != 0
# [CRIT] NETBIOS NetBIOS over TCP/IP enabled on an IP-enabled adapter
# [CRIT] SMB1_ENABLED SMB1 server component present / not disabled
# [WARN] SMB_SIGN_SRV LanmanServer RequireSecuritySignature != 1
# [WARN] SMB_SIGN_CLI LanmanWorkstation RequireSecuritySignature != 1
# [WARN] MDNS EnableMDNS != 0 (Windows 10 1703+ answers mDNS by default)
# [WARN] WPAD_AUTO proxy auto-detect on, or WinHttpAutoProxySvc not disabled
# [WARN] NETBT_NODE NodeType != 2 (P-node) so NetBIOS still broadcasts
# [INFO] WINS a WINS server is configured (legacy, but not exploitable by itself)
# [INFO] DNS_NOT_LOCAL adapter DNS servers are not RFC1918/link-local (public resolvers on a domain host)
#
# Usage: ruby win_nameres_audit.rb [--json] (run elevated for HKLM policy keys)
# Exit: 0 clean, 1 warnings only, 2 any CRIT
#
# Requirements: Ruby 3.x on Windows; win32ole (stdlib) and win32-registry (default gem).
# The analysis layer takes plain Hashes/Arrays, so it is unit-tested on Linux with
# fixtures — see test_win_nameres_audit.rb.
require 'optparse'
require 'json'
# --------------------------------------------------------------------------
# Data sources — the only code that touches Windows.
# --------------------------------------------------------------------------
class WindowsSources
def initialize
require 'win32ole'
require 'win32/registry'
@wmi = WIN32OLE.connect('winmgmts:\\\\.\\root\\cimv2')
end
# One Hash per IP-enabled adapter (WMI Win32_NetworkAdapterConfiguration).
def adapters
q = 'SELECT Description, IPEnabled, IPAddress, DHCPEnabled, DNSServerSearchOrder, TcpipNetbiosOptions, WINSPrimaryServer FROM Win32_NetworkAdapterConfiguration WHERE IPEnabled = TRUE'
@wmi.ExecQuery(q).map do |a|
{ description: a.Description.to_s, ip: Array(a.IPAddress).map(&:to_s), dhcp: a.DHCPEnabled ? true : false,
dns: Array(a.DNSServerSearchOrder).map(&:to_s), netbios: a.TcpipNetbiosOptions.to_i, wins: a.WINSPrimaryServer.to_s }
end
end
# SMB1 feature state via WMI Win32_OptionalFeature (InstallState 1 = enabled, 2 = disabled)
def smb1_feature_state
@wmi.ExecQuery("SELECT InstallState FROM Win32_OptionalFeature WHERE Name = 'SMB1Protocol-Server'").map { |f| f.InstallState.to_i }.first
rescue WIN32OLERuntimeError
nil
end
# Registry values as a flat Hash of "HIVE\\path\\value" => data (nil when absent).
REG_VALUES = {
'llmnr' => ['HKLM', 'SOFTWARE\Policies\Microsoft\Windows NT\DNSClient', 'EnableMulticast'],
'mdns' => ['HKLM', 'SYSTEM\CurrentControlSet\Services\Dnscache\Parameters', 'EnableMDNS'],
'nodetype' => ['HKLM', 'SYSTEM\CurrentControlSet\Services\NetBT\Parameters', 'NodeType'],
'smb1' => ['HKLM', 'SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters', 'SMB1'],
'sign_srv' => ['HKLM', 'SYSTEM\CurrentControlSet\Services\LanmanServer\Parameters', 'RequireSecuritySignature'],
'sign_cli' => ['HKLM', 'SYSTEM\CurrentControlSet\Services\LanmanWorkstation\Parameters', 'RequireSecuritySignature'],
'wpad_svc' => ['HKLM', 'SYSTEM\CurrentControlSet\Services\WinHttpAutoProxySvc', 'Start'],
'wpad_policy' => ['HKLM', 'SOFTWARE\Policies\Microsoft\Windows\CurrentVersion\Internet Settings', 'EnableAutoProxyResultCache'],
'wpad_user' => ['HKCU', 'SOFTWARE\Microsoft\Windows\CurrentVersion\Internet Settings\Connections', 'DefaultConnectionSettings']
}.freeze
def registry
REG_VALUES.transform_values do |hive, path, name|
root = hive == 'HKLM' ? Win32::Registry::HKEY_LOCAL_MACHINE : Win32::Registry::HKEY_CURRENT_USER
root.open(path, Win32::Registry::KEY_READ | 0x0100) { |k| k[name] }
rescue Win32::Registry::Error
nil
end
end
end
# --------------------------------------------------------------------------
# Analysis — pure Ruby. adapters is an Array of Hashes, reg a Hash of the
# keys above, smb1_state an Integer or nil.
# --------------------------------------------------------------------------
module Analyzer
Check = Struct.new(:severity, :code, :status, :detail, :fix, keyword_init: true)
PRIVATE_DNS = [/\A10\./, /\A192\.168\./, /\A172\.(1[6-9]|2\d|3[01])\./, /\A127\./, /\A169\.254\./, /\Afe80:/i, /\Afd/i, /\A::1\z/].freeze
def self.run(adapters, reg, smb1_state)
c = []
# LLMNR: policy value 0 disables. Absent = enabled (the default).
llmnr = reg['llmnr']
c << Check.new(severity: 'CRIT', code: 'LLMNR', status: llmnr.to_i.zero? && !llmnr.nil? ? 'PASS' : 'FAIL',
detail: llmnr.nil? ? 'EnableMulticast policy not set (LLMNR on by default)' : "EnableMulticast=#{llmnr}",
fix: 'reg add "HKLM\SOFTWARE\Policies\Microsoft\Windows NT\DNSClient" /v EnableMulticast /t REG_DWORD /d 0 /f')
# NetBIOS over TCP/IP per adapter: 0 = via DHCP (usually on), 1 = on, 2 = off
nb_on = adapters.select { |a| a[:netbios] != 2 }
c << Check.new(severity: 'CRIT', code: 'NETBIOS', status: nb_on.empty? ? 'PASS' : 'FAIL',
detail: nb_on.empty? ? 'disabled on all IP-enabled adapters' : nb_on.map { |a| "#{a[:description]} (TcpipNetbiosOptions=#{a[:netbios]})" }.join('; '),
fix: 'wmic nicconfig where IPEnabled=true call SetTcpipNetbios 2 (or Set-NetAdapterBinding / DHCP option 001)')
# mDNS: EnableMDNS 0 disables; absent = enabled on Win10 1703+
md = reg['mdns']
c << Check.new(severity: 'WARN', code: 'MDNS', status: !md.nil? && md.to_i.zero? ? 'PASS' : 'FAIL',
detail: md.nil? ? 'EnableMDNS not set (mDNS responder on by default)' : "EnableMDNS=#{md}",
fix: 'reg add HKLM\SYSTEM\CurrentControlSet\Services\Dnscache\Parameters /v EnableMDNS /t REG_DWORD /d 0 /f')
# NetBT node type: 2 = P-node (WINS only, no broadcast). 1 B, 4 M, 8 H.
nt = reg['nodetype']
c << Check.new(severity: 'WARN', code: 'NETBT_NODE', status: nt.to_i == 2 ? 'PASS' : 'FAIL',
detail: nt.nil? ? 'NodeType not set (H-node when WINS configured, else B-node broadcasts)' : "NodeType=#{nt}",
fix: 'reg add HKLM\SYSTEM\CurrentControlSet\Services\NetBT\Parameters /v NodeType /t REG_DWORD /d 2 /f')
# SMB1: feature disabled (InstallState 2) or SMB1=0 in LanmanServer
smb1 = reg['smb1']
smb1_off = (smb1_state == 2) || (!smb1.nil? && smb1.to_i.zero?)
c << Check.new(severity: 'CRIT', code: 'SMB1_ENABLED', status: smb1_off ? 'PASS' : 'FAIL',
detail: "SMB1Protocol-Server InstallState=#{smb1_state.inspect}, LanmanServer\\SMB1=#{smb1.inspect}",
fix: 'Disable-WindowsOptionalFeature -Online -FeatureName SMB1Protocol / Set-SmbServerConfiguration -EnableSMB1Protocol $false')
# SMB signing
c << Check.new(severity: 'WARN', code: 'SMB_SIGN_SRV', status: reg['sign_srv'].to_i == 1 ? 'PASS' : 'FAIL',
detail: "LanmanServer RequireSecuritySignature=#{reg['sign_srv'].inspect}",
fix: 'Set-SmbServerConfiguration -RequireSecuritySignature $true')
c << Check.new(severity: 'WARN', code: 'SMB_SIGN_CLI', status: reg['sign_cli'].to_i == 1 ? 'PASS' : 'FAIL',
detail: "LanmanWorkstation RequireSecuritySignature=#{reg['sign_cli'].inspect}",
fix: 'Set-SmbClientConfiguration -RequireSecuritySignature $true')
# WPAD: service Start 4 = disabled. DefaultConnectionSettings byte 8 bit 0x08 = auto-detect on.
svc_disabled = reg['wpad_svc'].to_i == 4
dcs = reg['wpad_user']
autodetect = dcs.is_a?(String) && dcs.bytesize > 8 ? (dcs.getbyte(8) & 0x08) != 0 : nil
wpad_fail = !svc_disabled || autodetect == true
c << Check.new(severity: 'WARN', code: 'WPAD_AUTO', status: wpad_fail ? 'FAIL' : 'PASS',
detail: "WinHttpAutoProxySvc Start=#{reg['wpad_svc'].inspect}#{autodetect.nil? ? '' : ", IE auto-detect=#{autodetect}"}",
fix: 'sc config WinHttpAutoProxySvc start= disabled; untick "Automatically detect settings"; add a wpad DNS record pointing at a sinkhole')
# WINS / DNS hygiene (informational)
wins = adapters.select { |a| !a[:wins].empty? }
c << Check.new(severity: 'INFO', code: 'WINS', status: wins.empty? ? 'PASS' : 'FAIL',
detail: wins.empty? ? 'no WINS servers' : wins.map { |a| "#{a[:description]} -> #{a[:wins]}" }.join('; '), fix: 'remove WINS once NetBIOS is off')
pub = adapters.flat_map { |a| a[:dns].reject { |d| PRIVATE_DNS.any? { |re| d =~ re } }.map { |d| "#{a[:description]} -> #{d}" } }
c << Check.new(severity: 'INFO', code: 'DNS_NOT_LOCAL', status: pub.empty? ? 'PASS' : 'FAIL',
detail: pub.empty? ? 'all adapter DNS servers are private/link-local' : pub.join('; '), fix: 'point domain members at internal resolvers only')
c
end
end
def summarize(checks)
fails = checks.reject { |k| k.status == 'PASS' }
crit = fails.count { |k| k.severity == 'CRIT' }
warn = fails.count { |k| k.severity == 'WARN' }
[crit.positive? ? 'CRIT' : (warn.positive? ? 'WARN' : 'OK'), crit, warn]
end
def print_text(adapters, checks)
status, crit, warn = summarize(checks)
puts "win_nameres_audit #{Time.now.strftime('%Y-%m-%d %H:%M')} #{adapters.size} IP-enabled adapter(s)"
puts '-' * 100
adapters.each do |a|
puts format(' %-40s ip=%-16s dhcp=%-5s netbios=%-1s dns=%s', a[:description][0, 40], a[:ip].first, a[:dhcp], a[:netbios], a[:dns].join(','))
end
puts
puts format('%-6s %-14s %-5s %s', 'SEV', 'CHECK', 'RESULT', 'DETAIL')
checks.each do |k|
puts format('%-6s %-14s %-5s %s', k.severity, k.code, k.status, k.detail)
puts format('%-6s %-14s %-5s fix: %s', '', '', '', k.fix) if k.status == 'FAIL' && k.severity != 'INFO'
end
puts
puts "#{status}: #{crit} critical, #{warn} warning(s) — #{checks.count { |k| k.status == 'PASS' }}/#{checks.size} checks pass"
end
if __FILE__ == $PROGRAM_NAME
opts = { json: false }
OptionParser.new do |o|
o.banner = 'Usage: win_nameres_audit.rb [--json]'
o.on('--json', 'JSON output') { opts[:json] = true }
end.parse!
abort 'win_nameres_audit.rb only runs on Windows (needs win32ole + win32/registry)' unless RUBY_PLATFORM =~ /mingw|mswin|cygwin/
src = WindowsSources.new
adapters = src.adapters
checks = Analyzer.run(adapters, src.registry, src.smb1_feature_state)
status, crit, warn = summarize(checks)
if opts[:json]
puts JSON.pretty_generate(status: status, critical: crit, warnings: warn, adapters: adapters, checks: checks.map(&:to_h))
else
print_text(adapters, checks)
end
exit(crit.positive? ? 2 : (warn.positive? ? 1 : 0))
end
How it works, step by step
1. Adapters via WMI
Win32_NetworkAdapterConfiguration has one instance per adapter, including dozens of virtual and disconnected ones, so the query filters on IPEnabled = TRUE. The properties that matter: TcpipNetbiosOptions (0 = use DHCP setting, which almost always means on; 1 = on; 2 = off), DNSServerSearchOrder, DHCPEnabled and WINSPrimaryServer. WMI returns arrays as nil when empty, hence Array(...) everywhere.
2. Nine registry values, read defensively
REG_VALUES maps a short key to [hive, path, value]. Each is opened read-only with 0x0100 (KEY_WOW64_64KEY) so a 32-bit Ruby is not redirected into WOW6432Node, and any Win32::Registry::Error — key missing, value missing, access denied — becomes nil. That matters because for EnableMulticast and EnableMDNS a missing value means the feature is on; the analyzer treats nil as FAIL for those and says so in the detail string.
3. The checks
LLMNR passes only when the policy value exists and is 0. NETBIOS passes only when every IP-enabled adapter reports 2; the detail lists the offenders by name. MDNS mirrors LLMNR. NETBT_NODE wants P-node (2), the only node type that never broadcasts. SMB1_ENABLED passes if the optional feature reports InstallState 2 (disabled) or LanmanServer\SMB1 is 0 — either is sufficient. SMB_SIGN_SRV/CLI check RequireSecuritySignature on both sides, because relay works in either direction. WPAD_AUTO fails if the WinHttpAutoProxySvc start type is not 4 (disabled) or the IE auto-detect bit is set. WINS and DNS_NOT_LOCAL are informational.
4. Decoding DefaultConnectionSettings
The “Automatically detect settings” checkbox has no DWORD of its own. It is bit 3 of byte 8 in a binary blob under Internet Settings\Connections; the other bits in that byte are “direct” (0x01), “use proxy” (0x02) and “use auto-config script” (0x04). dcs.getbyte(8) & 0x08 is the whole decode. win32/registry returns REG_BINARY values as a Ruby String, which is why the check guards on is_a?(String) and length.
5. Summarise, print, exit
summarize counts failing checks by severity and returns a status. The text printer shows the adapters, then one line per check and — only for failing CRIT/WARN checks — a fix: line with the reg add / PowerShell command. The exit code is the usual 0/1/2 contract so a scheduled task or RMM can alert on it.
The harness: before and after the GPO
The script refuses to run on non-Windows, so it ships with a stub harness that hands Analyzer.run what WMI and the registry would have returned. The first fixture is a typical domain-joined laptop that nobody hardened (LLMNR default, NetBIOS via DHCP on two NICs, SMB1 still installed, signing off, WPAD auto-detect on, public DNS on the Wi-Fi adapter). The second is the same machine after the GPO and SMB1 removal.
# test_win_nameres_audit.rb — stub harness: feeds Analyzer realistic fixtures
# (what WMI and the registry would return) so the scoring runs on any OS.
require_relative 'win_nameres_audit'
# A typical domain-joined workstation that nobody has hardened.
adapters = [
{ description: 'Intel(R) Ethernet Connection I219-LM', ip: ['10.20.5.41', 'fe80::1c2a:3b4c:5d6e:7f80'], dhcp: true,
dns: ['10.20.0.10', '10.20.0.11'], netbios: 0, wins: '' },
{ description: 'Intel(R) Wi-Fi 6 AX201 160MHz', ip: ['192.168.1.57'], dhcp: true,
dns: ['8.8.8.8', '1.1.1.1'], netbios: 0, wins: '' },
{ description: 'Hyper-V Virtual Ethernet Adapter', ip: ['172.28.0.1'], dhcp: false,
dns: [], netbios: 2, wins: '' }
]
# IE DefaultConnectionSettings blob: byte 8 = 0x09 -> direct (0x01) + auto-detect (0x08)
dcs = [0x46, 0, 0, 0, 0x2a, 0, 0, 0, 0x09, 0, 0, 0].pack('C*')
reg_unhardened = { 'llmnr' => nil, 'mdns' => nil, 'nodetype' => nil, 'smb1' => nil, 'sign_srv' => 0, 'sign_cli' => 0,
'wpad_svc' => 3, 'wpad_policy' => nil, 'wpad_user' => dcs }
checks = Analyzer.run(adapters, reg_unhardened, 1)
print_text(adapters, checks)
status, crit, warn = summarize(checks)
raise "expected CRIT, got #{status}" unless status == 'CRIT'
raise 'LLMNR should fail when policy absent' unless checks.find { |c| c.code == 'LLMNR' }.status == 'FAIL'
raise 'NETBIOS should list two adapters' unless checks.find { |c| c.code == 'NETBIOS' }.detail.scan('TcpipNetbiosOptions').size == 2
raise 'WPAD should detect auto-detect bit' unless checks.find { |c| c.code == 'WPAD_AUTO' }.detail.include?('auto-detect=true')
raise 'public DNS should be reported' unless checks.find { |c| c.code == 'DNS_NOT_LOCAL' }.detail.include?('8.8.8.8')
puts
puts '=== after hardening (GPO applied, SMB1 removed, NetBIOS off) ==='
hardened = adapters.map { |a| a.merge(netbios: 2, dns: a[:dns].map { |d| d.start_with?('10.') ? d : '10.20.0.10' }) }
reg_hardened = { 'llmnr' => 0, 'mdns' => 0, 'nodetype' => 2, 'smb1' => 0, 'sign_srv' => 1, 'sign_cli' => 1,
'wpad_svc' => 4, 'wpad_policy' => nil, 'wpad_user' => [0x46, 0, 0, 0, 0x2b, 0, 0, 0, 0x01, 0, 0, 0].pack('C*') }
checks2 = Analyzer.run(hardened, reg_hardened, 2)
print_text(hardened, checks2)
status2, = summarize(checks2)
raise "expected OK after hardening, got #{status2}" unless status2 == 'OK'
puts 'assertions: 6/6 passed'
exit(crit.positive? ? 2 : (warn.positive? ? 1 : 0))
When it does not behave
- NETBIOS fails on an adapter you do not care about. Hyper-V, WSL and VPN adapters are IP-enabled and often default to NetBIOS on. Either set them to 2 as well (there is no downside) or add a description filter to
adapters. - LLMNR passes but Responder still gets hits. Check mDNS and NetBIOS — Responder listens on all three. Also confirm the policy is applied (
gpresult /h), not just present in a GPO nobody linked. - SMB1_ENABLED reports
InstallState=nil.Win32_OptionalFeatureneeds elevation and is absent on some Server Core builds. TheLanmanServer\SMB1value is the fallback; set it to 0 explicitly if you want the check to pass without the feature query. - WPAD_AUTO fails after you disabled the service. The IE auto-detect bit is per-user and the script reads
HKCUfor the account running it. Deploy the setting via user GPO, or accept WARN for the service account. WIN32OLERuntimeError: Access is denied. Run elevated; WMI’s DCOM permissions block standard users from some classes.- Testing note. Windows APIs are not available on Linux, so
WindowsSourceswas reviewed against the documentedwin32ole/Win32::Registryinterfaces but not executed while writing this article. The complete analysis layer (Analyzer.run,summarize,print_text) was executed via the harness above on Ruby 3.4 — unhardened fixture CRIT, hardened fixture OK, six assertions passing. Run it once by hand on a Windows host before scheduling.
Where to take it next
- More relay-surface checks. LDAP signing and channel binding on domain controllers (
LDAPServerIntegrity,LdapEnforceChannelBinding), NTLM restrictions (LmCompatibilityLevel,RestrictSendingNTLMTraffic), and IPv6 router-discovery for mitm6 all follow the same read-a-value-compare pattern. - Fleet mode over WMI.
WIN32OLE.connect("winmgmts://#{host}/root/cimv2")reaches remote adapters; pair it withWin32::Registry‘sRegConnectRegistryequivalent or a remote PowerShell call for the policy values. - Auto-remediate with a flag. Every check already carries its fix string; an
--applymode that runs them (elevated, with a log) is a small addition — keep NETBIOS changes last, they briefly disrupt SMB sessions. - Baseline diff. Store
--jsonper host and alert on transitions from PASS to FAIL, which is the “it drifted back” signal this whole script exists for. - Linux twin.
avahi-daemon(mDNS) andsystemd-resolved‘sLLMNR=andMulticastDNS=settings are the same attack surface on the other side of the office.