A directory that any user can write to, sitting on PATH ahead of C:\Windows\System32, is a free privilege escalation: drop a malicious python.exe there and it runs instead of the real one. Here’s a pure-Ruby audit that finds it, on Windows and Unix.
Step through the build below — the problem, the full script, the pure-logic design, and the real test output:
PATH is an overlooked attack surface. If a user-writable directory appears before the real system directories, an attacker’s same-named binary wins name resolution — a classic local privilege-escalation and persistence trick (MITRE ATT&CK T1574.007). Relative entries, duplicates, and empty elements cause their own subtle bugs.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# path_env_audit.rb -- audit the PATH environment variable for privilege-
# escalation and hygiene problems, on Windows and Unix.
#
# PATH is a classic, overlooked attack surface: if a directory that any user
# can write to appears on PATH *ahead of* the real system directories, an
# attacker drops a malicious `python.exe` / `ls` there and it runs instead of
# the real one. Unquoted or relative entries and duplicates cause their own
# bugs. This audits the live PATH (or one you pass in) and ranks the findings.
#
# ruby path_env_audit.rb # audit this process's PATH
# ruby path_env_audit.rb --path "$SOMEPATH" # audit a specific value
# ruby path_env_audit.rb --json
#
# Stdlib only: json, optparse, etc. No gems. The classification is pure and is
# exercised by path_env_audit_test.rb on any platform. Exit codes: 0/1/2.
require 'json'
require 'optparse'
module PathAudit
module_function
WORLD_WRITABLE_HINT = /\A(\/tmp|\/var\/tmp|\/dev\/shm|C:\\Users\\Public|C:\\Temp|C:\\Windows\\Temp)/i.freeze
# entries: array of raw PATH strings, in order. writable: ->(dir){bool} lets
# the caller inject a real writability probe (or a stub for tests).
def classify(entries, windows:, writable: nil)
sep = windows ? '\\' : '/'
findings = []
seen = {}
system_seen = false
entries.each_with_index do |raw, idx|
entry = raw.to_s
if entry.strip.empty?
findings << ['WARN', 'empty-entry', idx, 'empty PATH element (implicitly means current directory)']
next
end
# A relative entry means "current working directory dependent" -- unsafe.
absolute = windows ? entry =~ /\A([a-zA-Z]:\\|\\\\)/ : entry.start_with?('/')
findings << ['WARN', 'relative-entry', idx, "relative directory on PATH: #{entry}"] unless absolute
# Duplicates: waste and can mask ordering intent.
key = windows ? entry.downcase : entry
if seen[key]
findings << ['INFO', 'duplicate-entry', idx, "#{entry} already appears at position #{seen[key]}"]
else
seen[key] = idx
end
# Unquoted-looking Windows entry with spaces (only meaningful when the
# PATH was reconstructed from an unsplit string; flagged as hygiene).
if windows && entry.include?(' ') && entry.include?('"')
findings << ['WARN', 'embedded-quote', idx, "PATH entry contains a quote: #{entry}"]
end
user_writable =
if writable
begin writable.call(entry) rescue false end
else
entry =~ WORLD_WRITABLE_HINT ? true : false
end
is_system = system_dir?(entry, windows)
system_seen ||= is_system
if user_writable && !system_seen
findings << ['CRIT', 'writable-before-system', idx,
"user-writable dir #{entry} precedes the system directories on PATH"]
elsif user_writable
findings << ['WARN', 'writable-entry', idx, "user-writable dir on PATH: #{entry}"]
end
end
findings.map { |sev, code, pos, detail| { severity: sev, code: code, position: pos, detail: detail } }
end
def system_dir?(entry, windows)
if windows
entry =~ /\AC:\\Windows(\\System32|\\SysWOW64)?\\?\z/i ? true : false
else
%w[/usr/bin /bin /usr/sbin /sbin /usr/local/bin].include?(entry)
end
end
end
if __FILE__ == $PROGRAM_NAME
options = { json: false, path: nil }
OptionParser.new do |o|
o.banner = 'Usage: ruby path_env_audit.rb [options]'
o.on('--path VALUE', 'audit this PATH string instead of the live one') { |v| options[:path] = v }
o.on('--json', 'JSON output') { options[:json] = true }
end.parse!
windows = RUBY_PLATFORM =~ /mswin|mingw|cygwin/ ? true : false
sep = windows ? ';' : ':'
raw = options[:path] || ENV['PATH'].to_s
entries = raw.split(sep, -1)
# Real writability probe: directory exists and is writable by us.
probe = lambda { |dir| File.directory?(dir) && File.writable?(dir) }
findings = PathAudit.classify(entries, windows: windows, writable: probe)
rank = { 'CRIT' => 0, 'WARN' => 1, 'INFO' => 2 }
findings.sort_by! { |f| [rank[f[:severity]], f[:position]] }
crit = findings.count { |f| f[:severity] == 'CRIT' }
warn = findings.count { |f| f[:severity] == 'WARN' }
if options[:json]
puts JSON.pretty_generate('platform' => windows ? 'windows' : 'unix',
'entries' => entries.size,
'findings' => findings.map { |f| f.transform_keys(&:to_s) },
'summary' => { 'crit' => crit, 'warn' => warn,
'info' => findings.size - crit - warn })
else
puts "PATH audit -- #{entries.size} entries (#{windows ? 'windows' : 'unix'})"
puts
if findings.empty?
puts 'no findings -- clean.'
else
findings.each { |f| puts format('%-5s %-24s #%-3d %s', f[:severity], f[:code], f[:position], f[:detail]) }
puts
puts "#{crit} critical, #{warn} warning, #{findings.size - crit - warn} info"
end
end
exit(crit.positive? ? 2 : warn.positive? ? 1 : 0)
end
The audit splits PATH preserving order and, crucially, tracks whether it has passed a system directory yet — a writable dir found before that point is the CRIT. Writability comes from an injected probe: the CLI uses a real File.writable? check; the test harness injects a stub, so the pure logic is verified identically on every platform.
PASS unix writable dir before system PASS unix writable dir after system PASS unix clean path PASS empty entry flagged PASS relative entry flagged PASS duplicate entry flagged PASS windows writable before system PASS windows clean path PASS windows relative entry all tests passed --- live audit --- PATH audit -- 5 entries (unix) CRIT writable-before-system #0 user-writable dir /tmp precedes the system directories on PATH WARN relative-entry #3 relative directory on PATH: relthing INFO duplicate-entry #4 /usr/bin already appears at position 1 1 critical, 1 warning, 1 info
Full script + README on GitHub: ruby-devops-toolkit/path-env-audit
What you need
- Ruby 2.7+ (tested on 3.0.2) — stdlib only:
json,optparse. No gems. - Windows or Unix. The classification is pure and is exercised by the included test harness on any platform.
path_env_audit.rb
The complete script. Note the split between the pure PathAudit.classify logic and the thin CLI wrapper.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# path_env_audit.rb -- audit the PATH environment variable for privilege-
# escalation and hygiene problems, on Windows and Unix.
#
# PATH is a classic, overlooked attack surface: if a directory that any user
# can write to appears on PATH *ahead of* the real system directories, an
# attacker drops a malicious `python.exe` / `ls` there and it runs instead of
# the real one. Unquoted or relative entries and duplicates cause their own
# bugs. This audits the live PATH (or one you pass in) and ranks the findings.
#
# ruby path_env_audit.rb # audit this process's PATH
# ruby path_env_audit.rb --path "$SOMEPATH" # audit a specific value
# ruby path_env_audit.rb --json
#
# Stdlib only: json, optparse, etc. No gems. The classification is pure and is
# exercised by path_env_audit_test.rb on any platform. Exit codes: 0/1/2.
require 'json'
require 'optparse'
module PathAudit
module_function
WORLD_WRITABLE_HINT = /\A(\/tmp|\/var\/tmp|\/dev\/shm|C:\\Users\\Public|C:\\Temp|C:\\Windows\\Temp)/i.freeze
# entries: array of raw PATH strings, in order. writable: ->(dir){bool} lets
# the caller inject a real writability probe (or a stub for tests).
def classify(entries, windows:, writable: nil)
sep = windows ? '\\' : '/'
findings = []
seen = {}
system_seen = false
entries.each_with_index do |raw, idx|
entry = raw.to_s
if entry.strip.empty?
findings << ['WARN', 'empty-entry', idx, 'empty PATH element (implicitly means current directory)']
next
end
# A relative entry means "current working directory dependent" -- unsafe.
absolute = windows ? entry =~ /\A([a-zA-Z]:\\|\\\\)/ : entry.start_with?('/')
findings << ['WARN', 'relative-entry', idx, "relative directory on PATH: #{entry}"] unless absolute
# Duplicates: waste and can mask ordering intent.
key = windows ? entry.downcase : entry
if seen[key]
findings << ['INFO', 'duplicate-entry', idx, "#{entry} already appears at position #{seen[key]}"]
else
seen[key] = idx
end
# Unquoted-looking Windows entry with spaces (only meaningful when the
# PATH was reconstructed from an unsplit string; flagged as hygiene).
if windows && entry.include?(' ') && entry.include?('"')
findings << ['WARN', 'embedded-quote', idx, "PATH entry contains a quote: #{entry}"]
end
user_writable =
if writable
begin writable.call(entry) rescue false end
else
entry =~ WORLD_WRITABLE_HINT ? true : false
end
is_system = system_dir?(entry, windows)
system_seen ||= is_system
if user_writable && !system_seen
findings << ['CRIT', 'writable-before-system', idx,
"user-writable dir #{entry} precedes the system directories on PATH"]
elsif user_writable
findings << ['WARN', 'writable-entry', idx, "user-writable dir on PATH: #{entry}"]
end
end
findings.map { |sev, code, pos, detail| { severity: sev, code: code, position: pos, detail: detail } }
end
def system_dir?(entry, windows)
if windows
entry =~ /\AC:\\Windows(\\System32|\\SysWOW64)?\\?\z/i ? true : false
else
%w[/usr/bin /bin /usr/sbin /sbin /usr/local/bin].include?(entry)
end
end
end
if __FILE__ == $PROGRAM_NAME
options = { json: false, path: nil }
OptionParser.new do |o|
o.banner = 'Usage: ruby path_env_audit.rb [options]'
o.on('--path VALUE', 'audit this PATH string instead of the live one') { |v| options[:path] = v }
o.on('--json', 'JSON output') { options[:json] = true }
end.parse!
windows = RUBY_PLATFORM =~ /mswin|mingw|cygwin/ ? true : false
sep = windows ? ';' : ':'
raw = options[:path] || ENV['PATH'].to_s
entries = raw.split(sep, -1)
# Real writability probe: directory exists and is writable by us.
probe = lambda { |dir| File.directory?(dir) && File.writable?(dir) }
findings = PathAudit.classify(entries, windows: windows, writable: probe)
rank = { 'CRIT' => 0, 'WARN' => 1, 'INFO' => 2 }
findings.sort_by! { |f| [rank[f[:severity]], f[:position]] }
crit = findings.count { |f| f[:severity] == 'CRIT' }
warn = findings.count { |f| f[:severity] == 'WARN' }
if options[:json]
puts JSON.pretty_generate('platform' => windows ? 'windows' : 'unix',
'entries' => entries.size,
'findings' => findings.map { |f| f.transform_keys(&:to_s) },
'summary' => { 'crit' => crit, 'warn' => warn,
'info' => findings.size - crit - warn })
else
puts "PATH audit -- #{entries.size} entries (#{windows ? 'windows' : 'unix'})"
puts
if findings.empty?
puts 'no findings -- clean.'
else
findings.each { |f| puts format('%-5s %-24s #%-3d %s', f[:severity], f[:code], f[:position], f[:detail]) }
puts
puts "#{crit} critical, #{warn} warning, #{findings.size - crit - warn} info"
end
end
exit(crit.positive? ? 2 : warn.positive? ? 1 : 0)
end
How it works, step by step
Order is everything
PATH is split on the platform separator (; on Windows, : on Unix), preserving order. classify walks the entries in sequence and tracks whether it has passed a system directory yet — a user-writable directory found before that point is the critical finding, because an attacker’s same-named binary there would win name resolution over the real one.
An injected writability probe
Writability is decided by a probe passed into the classifier. The CLI injects a real File.directory? && File.writable? check; the test harness injects a stub set of “writable” directories, so the tests touch no real filesystem and run identically on Windows and Linux.
Verified anywhere
Because the decision logic is pure, it is fully unit-tested regardless of platform. Here’s the test harness:
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# path_env_audit_test.rb -- tests for the pure PATH classification logic.
# Runs on any platform; injects a stub writability probe so no real dirs are
# touched. ruby path_env_audit_test.rb
require_relative 'path_env_audit'
$fail = 0
def check(desc, entries, windows, writable_set, expected)
probe = ->(d) { writable_set.include?(d) }
got = PathAudit.classify(entries, windows: windows, writable: probe).map { |f| f[:code] }.sort
if got == expected.sort
puts "PASS #{desc}"
else
$fail += 1
puts "FAIL #{desc}: expected #{expected.sort.inspect}, got #{got.inspect}"
end
end
# Unix: writable /tmp before /usr/bin is the classic CRIT.
check('unix writable dir before system',
['/tmp/bin', '/usr/bin', '/bin'], false, ['/tmp/bin'],
['writable-before-system'])
# Unix: writable dir AFTER system dirs is only a WARN.
check('unix writable dir after system',
['/usr/bin', '/opt/tools'], false, ['/opt/tools'],
['writable-entry'])
check('unix clean path', ['/usr/bin', '/bin', '/usr/local/bin'], false, [], [])
check('empty entry flagged', ['/usr/bin', ''], false, [], ['empty-entry'])
check('relative entry flagged', ['bin', '/usr/bin'], false, [], ['relative-entry'])
check('duplicate entry flagged',
['/usr/bin', '/bin', '/usr/bin'], false, [], ['duplicate-entry'])
# Windows: user-writable dir before C:\Windows\System32 => CRIT.
check('windows writable before system',
['C:\\Users\\Public\\bin', 'C:\\Windows\\System32'], true, ['C:\\Users\\Public\\bin'],
['writable-before-system'])
check('windows clean path',
['C:\\Windows\\System32', 'C:\\Windows'], true, [], [])
check('windows relative entry',
['tools', 'C:\\Windows\\System32'], true, [], ['relative-entry'])
puts
if $fail.zero?
puts 'all tests passed'
else
puts "#{$fail} test(s) FAILED"; exit 1
end
Common issues
- On Windows,
writable-before-systemseems too eager. ACLs onC:\ProgramDataand some app dirs vary; confirm withicaclsbefore treating a finding as exploitable. - A trusted tools dir is flagged
writable-entry. Expected if it’s writable; lock down its ACL or accept it as a known exception downstream.
Where to take it next
On Windows, resolve each directory’s real NTFS ACL instead of the writable probe for a definitive verdict; add an allowlist so known-good entries don’t re-alert; or emit JSON to a SIEM and mail on any CRIT.