the shed // windows / security

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_env_audit.rb

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

Full script + README on GitHub: ruby-devops-toolkit/path-env-audit

path_env_audit.rb workflow diagram

Split PATH → classify each entry in order → CRIT if a writable dir precedes the system dirs.
prerequisites

What you need

requirements
  • 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.
the whole script

path_env_audit.rb

The complete script. Note the split between the pure PathAudit.classify logic and the thin CLI wrapper.

path_env_audit.rbruby
#!/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
walkthrough

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:

path_env_audit_test.rbruby
#!/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
1
exploitable CRIT
9
logic tests
0
gems required
troubleshooting

Common issues

common gotchas
  • On Windows, writable-before-system seems too eager. ACLs on C:\ProgramData and some app dirs vary; confirm with icacls before 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.
extending

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.