One no_root_squash on a wildcard client turns a file server into a root shell for the whole network, and nobody re-reads /etc/exports after the ticket closes. A gem-free Ruby script that audits the exports file and the live exportfs -v table against eleven rules, with exit codes cron can act on.
Step through the build below:
NFS is thirty years old and still the fastest way to give away root. One line in /etc/exports — /srv/ci *(rw,no_root_squash), added “temporarily” for a build agent — means any host that can reach port 2049 can mount the share as root, drop a setuid shell into it, and run it on the file server. No exploit required; it is the documented behaviour of the options.
The dangerous options are well known (no_root_squash, insecure, * clients, async, /8 networks, sec=sys) and nobody reads /etc/exports after it is written. Meanwhile exportfs -v shows what the kernel is actually serving, defaults included, which is not always the same thing.
nfs_exports_audit.rb parses both, applies eleven rules, and exits 2 on anything that hands out root or writes to the world.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# nfs_exports_audit.rb — audit /etc/exports (and the live exportfs table) for
# the NFS misconfigurations that turn a file server into a root shell.
#
# Findings (severity in brackets):
# [CRIT] WORLD_RW export is rw to '*' / everyone
# [CRIT] NO_ROOT_SQUASH remote root == local root (rw). WARN when ro.
# [CRIT] INSECURE 'insecure' lets any unprivileged client port mount it
# [CRIT] SENSITIVE_PATH /, /etc, /root, /usr, /var, /home, /boot exported rw
# [WARN] WORLD_RO readable by everyone (still a data leak)
# [WARN] BROAD_CLIENT CIDR shorter than /16 or a *.wildcard hostname
# [WARN] ASYNC 'async' — acknowledged writes can be lost on a crash
# [WARN] SEC_SYS_ONLY AUTH_SYS on a broad client (uid/gid are client-asserted)
# [WARN] NESTED_WIDER a sub-directory is exported more openly than its parent
# [WARN] MISSING_PATH the exported directory does not exist on this host
# [WARN] NO_SUBTREE_OPT subtree_check left implicit (exportfs warns about this too)
#
# Usage:
# ruby nfs_exports_audit.rb # /etc/exports + /etc/exports.d/*.exports
# ruby nfs_exports_audit.rb --exports ./exports # audit a captured file
# ruby nfs_exports_audit.rb --live # parse "exportfs -v" (what the kernel
# # actually serves, incl. defaults)
# ruby nfs_exports_audit.rb --json
#
# Exit codes: 0 clean, 1 warnings only, 2 any CRIT. Stdlib only.
require 'optparse'
require 'json'
require 'open3'
SENSITIVE = %w[/ /etc /root /usr /var /home /boot /bin /sbin /lib /lib64 /opt].freeze
NFS_DEFAULTS = %w[ro sync wdelay hide root_squash no_all_squash secure subtree_check_unset].freeze
Export = Struct.new(:path, :client, :options, :source, :line, keyword_init: true) do
def opts
@opts ||= options.to_s.split(',').map(&:strip).reject(&:empty?)
end
def rw? = opts.include?('rw')
def world? = client == '*' || client == '<world>' || client.empty? # exportfs -v prints <world> for *
def root_squash? = !opts.include?('no_root_squash')
def sec = (opts.find { |o| o.start_with?('sec=') } || 'sec=sys').sub('sec=', '')
end
# ---------------------------------------------------------------------------
# Parsing /etc/exports
# /srv/share 10.0.0.0/24(rw,sync,no_subtree_check) *.corp.example.com(ro)
# "/path with spaces" host(rw)
# /srv/pub * # a bare client with no (options) uses the defaults
# line continuations with trailing backslash are honoured
# ---------------------------------------------------------------------------
def parse_exports(text, source: '/etc/exports')
logical = []
buf = +''
text.each_line.with_index(1) do |raw, n|
line = raw.sub(/#.*/, '').rstrip
if line.end_with?('\\')
buf << line.chomp('\\') << ' '
next
end
buf << line
logical << [buf.strip, n] unless buf.strip.empty?
buf = +''
end
logical.flat_map do |line, n|
# path is either "quoted" or the first whitespace-free token
if line.start_with?('"')
path = line[/\A"([^"]+)"/, 1]
rest = line.sub(/\A"[^"]+"\s*/, '')
else
path, rest = line.split(/\s+/, 2)
end
rest ||= ''
# each client spec: host, host(opts). Options never contain whitespace.
specs = rest.scan(/(\S+?)\(([^)]*)\)|(\S+)/).map { |h1, o, h2| h1 ? [h1, o] : [h2, ''] }
specs = [['*', '']] if specs.empty? # "/path" alone == everyone, defaults
specs.map { |client, o| Export.new(path: path, client: client, options: o, source: source, line: n) }
end
end
# "exportfs -v" prints one export per line with the *effective* option set:
# /srv/share 10.0.0.0/24(sync,wdelay,hide,no_subtree_check,sec=sys,rw,secure,root_squash,no_all_squash)
# Long paths wrap onto the next line — join a bare path line with its successor.
def parse_exportfs(text)
lines = text.lines.map(&:rstrip).reject(&:empty?)
joined = []
lines.each do |l|
if l =~ /\A\S+\z/ && !l.include?('(') # bare path, continuation follows
joined << l
elsif joined.last && !joined.last.include?('(')
joined[-1] = "#{joined.last} #{l.strip}"
else
joined << l
end
end
joined.flat_map { |l| parse_exports(l, source: 'exportfs -v') }
end
def read_exports_tree(main = '/etc/exports', dir = '/etc/exports.d')
files = [main] + Dir.glob(File.join(dir, '*.exports')).sort
files.select { |f| File.exist?(f) }.flat_map { |f| parse_exports(File.read(f), source: f) }
end
# ---------------------------------------------------------------------------
# Client-spec classification
# ---------------------------------------------------------------------------
def broad_client?(client)
return true if client == '*' || client == '<world>' || client.empty?
return true if client.start_with?('*') || client.include?('?') # *.example.com
if client =~ %r{\A[\d.]+/(\d+)\z} || client =~ %r{\A[0-9a-f:]+/(\d+)\z}i
bits = Regexp.last_match(1).to_i
v6 = client.include?(':')
return v6 ? bits < 48 : bits < 16
end
if client =~ %r{\A[\d.]+/([\d.]+)\z} # dotted netmask form 10.0.0.0/255.0.0.0
mask = Regexp.last_match(1).split('.').map(&:to_i).sum { |octet| octet.to_s(2).count('1') }
return mask < 16
end
false
end
# ---------------------------------------------------------------------------
# Rules
# ---------------------------------------------------------------------------
Finding = Struct.new(:severity, :code, :path, :client, :detail, keyword_init: true)
def audit(exports, check_paths: true)
f = []
add = ->(sev, code, e, detail) { f << Finding.new(severity: sev, code: code, path: e.path, client: e.client, detail: detail) }
exports.each do |e|
if e.world?
e.rw? ? add.('CRIT', 'WORLD_RW', e, 'writable by every host that can reach the server')
: add.('WARN', 'WORLD_RO', e, 'readable by every host that can reach the server')
end
unless e.root_squash?
add.(e.rw? ? 'CRIT' : 'WARN', 'NO_ROOT_SQUASH', e, "remote root is local root#{e.rw? ? ' with write access' : ''} — add root_squash")
end
add.('CRIT', 'INSECURE', e, "'insecure' accepts mounts from unprivileged source ports (any user on the client)") if e.opts.include?('insecure')
if e.rw? && SENSITIVE.include?(e.path.chomp('/').empty? ? '/' : e.path.chomp('/'))
add.('CRIT', 'SENSITIVE_PATH', e, "#{e.path} exported read-write")
end
add.('WARN', 'BROAD_CLIENT', e, 'client spec matches a very large set of hosts') if broad_client?(e.client) && !e.world?
add.('WARN', 'ASYNC', e, "'async' acknowledges writes before they hit disk") if e.opts.include?('async')
if e.sec == 'sys' && broad_client?(e.client) && !e.world? # world exports are already CRIT/WARN above
add.('WARN', 'SEC_SYS_ONLY', e, 'AUTH_SYS trusts the uid/gid the client asserts; consider sec=krb5p')
end
unless e.opts.include?('subtree_check') || e.opts.include?('no_subtree_check') || e.source == 'exportfs -v'
add.('WARN', 'NO_SUBTREE_OPT', e, 'neither subtree_check nor no_subtree_check given; exportfs will default to no_subtree_check and warn')
end
if check_paths && e.source != 'exportfs -v' && !File.directory?(e.path)
add.('WARN', 'MISSING_PATH', e, 'exported path does not exist on this host')
end
end
# Nested exports: /srv (ro, 10.0.0.0/24) and /srv/data (rw, *) — the child
# undoes whatever restriction the parent expressed.
exports.each do |child|
exports.each do |parent|
next if child.equal?(parent) || parent.path == child.path
next unless child.path.start_with?(parent.path.chomp('/') + '/')
wider_client = (child.world? && !parent.world?) || (broad_client?(child.client) && !broad_client?(parent.client))
wider_mode = child.rw? && !parent.rw?
if wider_client || wider_mode
add.('WARN', 'NESTED_WIDER', child, "exported more openly than parent #{parent.path} (#{parent.client}#{parent.rw? ? ',rw' : ',ro'})")
end
end
end
f.uniq { |x| [x.code, x.path, x.client] }
end
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
opts = { exports: nil, live: false, exportfs_file: nil, json: false, check_paths: true }
OptionParser.new do |o|
o.banner = 'Usage: nfs_exports_audit.rb [options]'
o.on('--exports FILE', 'audit this exports file instead of /etc/exports (+ exports.d)') { |v| opts[:exports] = v }
o.on('--live', 'audit the running export table via "exportfs -v"') { opts[:live] = true }
o.on('--exportfs FILE', 'audit a captured "exportfs -v" output') { |v| opts[:exportfs_file] = v }
o.on('--no-path-check', 'do not verify exported directories exist') { opts[:check_paths] = false }
o.on('--json', 'JSON output') { opts[:json] = true }
end.parse!
exports =
if opts[:exportfs_file]
parse_exportfs(File.read(opts[:exportfs_file]))
elsif opts[:live]
out, st = Open3.capture2e('exportfs', '-v')
abort "exportfs -v failed: #{out.strip}" unless st.success?
parse_exportfs(out)
elsif opts[:exports]
parse_exports(File.read(opts[:exports]), source: opts[:exports])
else
read_exports_tree
end
findings = audit(exports, check_paths: opts[:check_paths])
crit = findings.count { |x| x.severity == 'CRIT' }
warn = findings.count { |x| x.severity == 'WARN' }
status = crit.positive? ? 'CRIT' : (warn.positive? ? 'WARN' : 'OK')
if opts[:json]
puts JSON.pretty_generate(status: status, exports: exports.map(&:to_h), critical: crit, warnings: warn,
findings: findings.map(&:to_h))
else
puts "nfs_exports_audit #{exports.size} export entr#{exports.size == 1 ? 'y' : 'ies'} from #{exports.map(&:source).uniq.join(', ')}"
puts '-' * 92
puts format('%-28s %-24s %-4s %-10s %s', 'PATH', 'CLIENT', 'MODE', 'ROOT', 'OPTIONS')
exports.each do |e|
puts format('%-28s %-24s %-4s %-10s %s', e.path[0, 28], e.client[0, 24], e.rw? ? 'rw' : 'ro', e.root_squash? ? 'squashed' : 'NOT SQUASH', e.options[0, 40])
end
puts
if findings.empty?
puts 'no findings'
else
findings.sort_by { |x| [x.severity == 'CRIT' ? 0 : 1, x.path] }.each do |x|
puts format('[%-4s] %-15s %-24s %-18s %s', x.severity, x.code, x.path[0, 24], x.client[0, 18], x.detail)
end
end
puts
puts "#{status}: #{crit} critical, #{warn} warning(s)"
end
exit(crit.positive? ? 2 : (warn.positive? ? 1 : 0))
One parser for two formats. /etc/exports lines and exportfs -v lines are both path client(options) client(options).... parse_exports handles the annoying parts — quoted paths with spaces, trailing-backslash continuations, comments, a bare client with no parentheses — and returns one Export struct per client spec. parse_exportfs only has to re-join wrapped lines and translate <world>.
Rules are one-liners on the struct. world?, rw?, root_squash? and sec are tiny predicates, so audit() reads like the policy it enforces: world + rw is CRIT, no_root_squash is CRIT when rw and WARN when ro, insecure is always CRIT.
Client breadth is computed, not guessed. broad_client? flags *, wildcard hostnames, CIDRs shorter than /16 (or /48 for IPv6) and dotted-netmask forms like 10.0.0.0/255.0.0.0 — with a popcount on the mask, no ipaddr dependency.
Nested exports are the sneaky one. /srv/projects to one /24 read-write and /srv/projects/ci to * — the child quietly undoes the parent’s restriction. A second pass compares every export to every ancestor export and raises NESTED_WIDER.
nfs_exports_audit 9 export entries from /fx/nfs/exports -------------------------------------------------------------------------------------------- PATH CLIENT MODE ROOT OPTIONS /srv/projects 10.20.0.0/24 rw squashed rw,sync,no_subtree_check /srv/projects 10.20.1.0/24 rw squashed rw,sync,no_subtree_check /srv/projects/ci * rw NOT SQUASH rw,sync,no_root_squash,no_subtree_check /srv/public * ro squashed ro,sync,no_subtree_check /srv/backups backup01.corp.example.co rw squashed rw,sync,no_subtree_check,sec=krb5p /home *.corp.example.com rw squashed rw,async,no_subtree_check /srv/legacy 10.0.0.0/8 rw NOT SQUASH rw,insecure,no_root_squash,sec=sys /srv/media files 192.168.1.0/255.255.255. ro squashed ro,no_subtree_check /srv/scratch 10.20.0.0/24 rw squashed rw [CRIT] SENSITIVE_PATH /home *.corp.example.com /home exported read-write [CRIT] INSECURE /srv/legacy 10.0.0.0/8 'insecure' accepts mounts from unprivileged source ports (any user on the client) [CRIT] NO_ROOT_SQUASH /srv/legacy 10.0.0.0/8 remote root is local root with write access — add root_squash [CRIT] WORLD_RW /srv/projects/ci * writable by every host that can reach the server [CRIT] NO_ROOT_SQUASH /srv/projects/ci * remote root is local root with write access — add root_squash [WARN] SEC_SYS_ONLY /home *.corp.example.com AUTH_SYS trusts the uid/gid the client asserts; consider sec=krb5p [WARN] ASYNC /home *.corp.example.com 'async' acknowledges writes before they hit disk [WARN] BROAD_CLIENT /home *.corp.example.com client spec matches a very large set of hosts [WARN] NO_SUBTREE_OPT /srv/legacy 10.0.0.0/8 neither subtree_check nor no_subtree_check given; exportfs will default to no_subtree_check and warn [WARN] SEC_SYS_ONLY /srv/legacy 10.0.0.0/8 AUTH_SYS trusts the uid/gid the client asserts; consider sec=krb5p [WARN] BROAD_CLIENT /srv/legacy 10.0.0.0/8 client spec matches a very large set of hosts [WARN] NESTED_WIDER /srv/projects/ci * exported more openly than parent /srv/projects (10.20.0.0/24,rw) [WARN] WORLD_RO /srv/public * readable by every host that can reach the server [WARN] NO_SUBTREE_OPT /srv/scratch 10.20.0.0/24 neither subtree_check nor no_subtree_check given; exportfs will default to no_subtree_check and warn CRIT: 5 critical, 9 warning(s) $ ruby nfs_exports_audit.rb --exportfs fixtures/exportfs-v.txt # what the kernel serves nfs_exports_audit 4 export entries from exportfs -v -------------------------------------------------------------------------------------------- PATH CLIENT MODE ROOT OPTIONS /srv/projects 10.20.0.0/24 rw squashed sync,wdelay,hide,no_subtree_check,sec=sy /srv/projects 10.20.1.0/24 rw squashed sync,wdelay,hide,no_subtree_check,sec=sy /srv/projects/ci <world> rw NOT SQUASH sync,wdelay,hide,no_subtree_check,sec=sy /srv/public <world> ro squashed sync,wdelay,hide,no_subtree_check,sec=sy [CRIT] WORLD_RW /srv/projects/ci <world> writable by every host that can reach the server [CRIT] NO_ROOT_SQUASH /srv/projects/ci <world> remote root is local root with write access — add root_squash [WARN] NESTED_WIDER /srv/projects/ci <world> exported more openly than parent /srv/projects (10.20.0.0/24,rw) [WARN] WORLD_RO /srv/public <world> readable by every host that can reach the server CRIT: 2 critical, 2 warning(s)
Full script + README on GitHub: ruby-devops-toolkit/nfs-exports-audit
Six options, one root shell
NFS has no concept of authentication in its default sec=sys mode. The client tells the server “this request is from uid 1001” and the server believes it. The only things standing between an attacker on the network and every file on the export are the options in /etc/exports: root_squash (map remote root to nobody), secure (only accept mounts from privileged source ports, so a non-root user on the client cannot forge requests), and the client spec (which hosts may mount at all). Turn off any one of them for convenience and the rest matter a lot less.
The second problem is drift between intent and reality. /etc/exports is what you wrote; exportfs -v is what rpc.mountd loaded, with every default filled in and every exports.d fragment merged. The audit reads both, so a fragment someone dropped into /etc/exports.d/ last quarter gets the same scrutiny as the main file.
What you need
- Ruby 3.0+ (tested on 3.4). Uses endless method definitions, so 2.x will not parse it. Standard library only:
optparse,json,open3. - A Linux NFS server (nfs-kernel-server / nfs-utils). Reading
/etc/exportsneeds no privileges;--liverunsexportfs -v, which needs root on most distros. - Or no server at all:
--exports FILEand--exportfs FILEaudit captured files, so you can run it in CI against the exports file in your config repo, or againstssh nas01 exportfs -v > nas01.txt.
nfs_exports_audit.rb
The full script: two parsers, a client-breadth classifier, one audit function that returns Finding structs, and the text/JSON printer.
#!/usr/bin/env ruby
# frozen_string_literal: true
#
# nfs_exports_audit.rb — audit /etc/exports (and the live exportfs table) for
# the NFS misconfigurations that turn a file server into a root shell.
#
# Findings (severity in brackets):
# [CRIT] WORLD_RW export is rw to '*' / everyone
# [CRIT] NO_ROOT_SQUASH remote root == local root (rw). WARN when ro.
# [CRIT] INSECURE 'insecure' lets any unprivileged client port mount it
# [CRIT] SENSITIVE_PATH /, /etc, /root, /usr, /var, /home, /boot exported rw
# [WARN] WORLD_RO readable by everyone (still a data leak)
# [WARN] BROAD_CLIENT CIDR shorter than /16 or a *.wildcard hostname
# [WARN] ASYNC 'async' — acknowledged writes can be lost on a crash
# [WARN] SEC_SYS_ONLY AUTH_SYS on a broad client (uid/gid are client-asserted)
# [WARN] NESTED_WIDER a sub-directory is exported more openly than its parent
# [WARN] MISSING_PATH the exported directory does not exist on this host
# [WARN] NO_SUBTREE_OPT subtree_check left implicit (exportfs warns about this too)
#
# Usage:
# ruby nfs_exports_audit.rb # /etc/exports + /etc/exports.d/*.exports
# ruby nfs_exports_audit.rb --exports ./exports # audit a captured file
# ruby nfs_exports_audit.rb --live # parse "exportfs -v" (what the kernel
# # actually serves, incl. defaults)
# ruby nfs_exports_audit.rb --json
#
# Exit codes: 0 clean, 1 warnings only, 2 any CRIT. Stdlib only.
require 'optparse'
require 'json'
require 'open3'
SENSITIVE = %w[/ /etc /root /usr /var /home /boot /bin /sbin /lib /lib64 /opt].freeze
NFS_DEFAULTS = %w[ro sync wdelay hide root_squash no_all_squash secure subtree_check_unset].freeze
Export = Struct.new(:path, :client, :options, :source, :line, keyword_init: true) do
def opts
@opts ||= options.to_s.split(',').map(&:strip).reject(&:empty?)
end
def rw? = opts.include?('rw')
def world? = client == '*' || client == '<world>' || client.empty? # exportfs -v prints <world> for *
def root_squash? = !opts.include?('no_root_squash')
def sec = (opts.find { |o| o.start_with?('sec=') } || 'sec=sys').sub('sec=', '')
end
# ---------------------------------------------------------------------------
# Parsing /etc/exports
# /srv/share 10.0.0.0/24(rw,sync,no_subtree_check) *.corp.example.com(ro)
# "/path with spaces" host(rw)
# /srv/pub * # a bare client with no (options) uses the defaults
# line continuations with trailing backslash are honoured
# ---------------------------------------------------------------------------
def parse_exports(text, source: '/etc/exports')
logical = []
buf = +''
text.each_line.with_index(1) do |raw, n|
line = raw.sub(/#.*/, '').rstrip
if line.end_with?('\\')
buf << line.chomp('\\') << ' '
next
end
buf << line
logical << [buf.strip, n] unless buf.strip.empty?
buf = +''
end
logical.flat_map do |line, n|
# path is either "quoted" or the first whitespace-free token
if line.start_with?('"')
path = line[/\A"([^"]+)"/, 1]
rest = line.sub(/\A"[^"]+"\s*/, '')
else
path, rest = line.split(/\s+/, 2)
end
rest ||= ''
# each client spec: host, host(opts). Options never contain whitespace.
specs = rest.scan(/(\S+?)\(([^)]*)\)|(\S+)/).map { |h1, o, h2| h1 ? [h1, o] : [h2, ''] }
specs = [['*', '']] if specs.empty? # "/path" alone == everyone, defaults
specs.map { |client, o| Export.new(path: path, client: client, options: o, source: source, line: n) }
end
end
# "exportfs -v" prints one export per line with the *effective* option set:
# /srv/share 10.0.0.0/24(sync,wdelay,hide,no_subtree_check,sec=sys,rw,secure,root_squash,no_all_squash)
# Long paths wrap onto the next line — join a bare path line with its successor.
def parse_exportfs(text)
lines = text.lines.map(&:rstrip).reject(&:empty?)
joined = []
lines.each do |l|
if l =~ /\A\S+\z/ && !l.include?('(') # bare path, continuation follows
joined << l
elsif joined.last && !joined.last.include?('(')
joined[-1] = "#{joined.last} #{l.strip}"
else
joined << l
end
end
joined.flat_map { |l| parse_exports(l, source: 'exportfs -v') }
end
def read_exports_tree(main = '/etc/exports', dir = '/etc/exports.d')
files = [main] + Dir.glob(File.join(dir, '*.exports')).sort
files.select { |f| File.exist?(f) }.flat_map { |f| parse_exports(File.read(f), source: f) }
end
# ---------------------------------------------------------------------------
# Client-spec classification
# ---------------------------------------------------------------------------
def broad_client?(client)
return true if client == '*' || client == '<world>' || client.empty?
return true if client.start_with?('*') || client.include?('?') # *.example.com
if client =~ %r{\A[\d.]+/(\d+)\z} || client =~ %r{\A[0-9a-f:]+/(\d+)\z}i
bits = Regexp.last_match(1).to_i
v6 = client.include?(':')
return v6 ? bits < 48 : bits < 16
end
if client =~ %r{\A[\d.]+/([\d.]+)\z} # dotted netmask form 10.0.0.0/255.0.0.0
mask = Regexp.last_match(1).split('.').map(&:to_i).sum { |octet| octet.to_s(2).count('1') }
return mask < 16
end
false
end
# ---------------------------------------------------------------------------
# Rules
# ---------------------------------------------------------------------------
Finding = Struct.new(:severity, :code, :path, :client, :detail, keyword_init: true)
def audit(exports, check_paths: true)
f = []
add = ->(sev, code, e, detail) { f << Finding.new(severity: sev, code: code, path: e.path, client: e.client, detail: detail) }
exports.each do |e|
if e.world?
e.rw? ? add.('CRIT', 'WORLD_RW', e, 'writable by every host that can reach the server')
: add.('WARN', 'WORLD_RO', e, 'readable by every host that can reach the server')
end
unless e.root_squash?
add.(e.rw? ? 'CRIT' : 'WARN', 'NO_ROOT_SQUASH', e, "remote root is local root#{e.rw? ? ' with write access' : ''} — add root_squash")
end
add.('CRIT', 'INSECURE', e, "'insecure' accepts mounts from unprivileged source ports (any user on the client)") if e.opts.include?('insecure')
if e.rw? && SENSITIVE.include?(e.path.chomp('/').empty? ? '/' : e.path.chomp('/'))
add.('CRIT', 'SENSITIVE_PATH', e, "#{e.path} exported read-write")
end
add.('WARN', 'BROAD_CLIENT', e, 'client spec matches a very large set of hosts') if broad_client?(e.client) && !e.world?
add.('WARN', 'ASYNC', e, "'async' acknowledges writes before they hit disk") if e.opts.include?('async')
if e.sec == 'sys' && broad_client?(e.client) && !e.world? # world exports are already CRIT/WARN above
add.('WARN', 'SEC_SYS_ONLY', e, 'AUTH_SYS trusts the uid/gid the client asserts; consider sec=krb5p')
end
unless e.opts.include?('subtree_check') || e.opts.include?('no_subtree_check') || e.source == 'exportfs -v'
add.('WARN', 'NO_SUBTREE_OPT', e, 'neither subtree_check nor no_subtree_check given; exportfs will default to no_subtree_check and warn')
end
if check_paths && e.source != 'exportfs -v' && !File.directory?(e.path)
add.('WARN', 'MISSING_PATH', e, 'exported path does not exist on this host')
end
end
# Nested exports: /srv (ro, 10.0.0.0/24) and /srv/data (rw, *) — the child
# undoes whatever restriction the parent expressed.
exports.each do |child|
exports.each do |parent|
next if child.equal?(parent) || parent.path == child.path
next unless child.path.start_with?(parent.path.chomp('/') + '/')
wider_client = (child.world? && !parent.world?) || (broad_client?(child.client) && !broad_client?(parent.client))
wider_mode = child.rw? && !parent.rw?
if wider_client || wider_mode
add.('WARN', 'NESTED_WIDER', child, "exported more openly than parent #{parent.path} (#{parent.client}#{parent.rw? ? ',rw' : ',ro'})")
end
end
end
f.uniq { |x| [x.code, x.path, x.client] }
end
# ---------------------------------------------------------------------------
# main
# ---------------------------------------------------------------------------
opts = { exports: nil, live: false, exportfs_file: nil, json: false, check_paths: true }
OptionParser.new do |o|
o.banner = 'Usage: nfs_exports_audit.rb [options]'
o.on('--exports FILE', 'audit this exports file instead of /etc/exports (+ exports.d)') { |v| opts[:exports] = v }
o.on('--live', 'audit the running export table via "exportfs -v"') { opts[:live] = true }
o.on('--exportfs FILE', 'audit a captured "exportfs -v" output') { |v| opts[:exportfs_file] = v }
o.on('--no-path-check', 'do not verify exported directories exist') { opts[:check_paths] = false }
o.on('--json', 'JSON output') { opts[:json] = true }
end.parse!
exports =
if opts[:exportfs_file]
parse_exportfs(File.read(opts[:exportfs_file]))
elsif opts[:live]
out, st = Open3.capture2e('exportfs', '-v')
abort "exportfs -v failed: #{out.strip}" unless st.success?
parse_exportfs(out)
elsif opts[:exports]
parse_exports(File.read(opts[:exports]), source: opts[:exports])
else
read_exports_tree
end
findings = audit(exports, check_paths: opts[:check_paths])
crit = findings.count { |x| x.severity == 'CRIT' }
warn = findings.count { |x| x.severity == 'WARN' }
status = crit.positive? ? 'CRIT' : (warn.positive? ? 'WARN' : 'OK')
if opts[:json]
puts JSON.pretty_generate(status: status, exports: exports.map(&:to_h), critical: crit, warnings: warn,
findings: findings.map(&:to_h))
else
puts "nfs_exports_audit #{exports.size} export entr#{exports.size == 1 ? 'y' : 'ies'} from #{exports.map(&:source).uniq.join(', ')}"
puts '-' * 92
puts format('%-28s %-24s %-4s %-10s %s', 'PATH', 'CLIENT', 'MODE', 'ROOT', 'OPTIONS')
exports.each do |e|
puts format('%-28s %-24s %-4s %-10s %s', e.path[0, 28], e.client[0, 24], e.rw? ? 'rw' : 'ro', e.root_squash? ? 'squashed' : 'NOT SQUASH', e.options[0, 40])
end
puts
if findings.empty?
puts 'no findings'
else
findings.sort_by { |x| [x.severity == 'CRIT' ? 0 : 1, x.path] }.each do |x|
puts format('[%-4s] %-15s %-24s %-18s %s', x.severity, x.code, x.path[0, 24], x.client[0, 18], x.detail)
end
end
puts
puts "#{status}: #{crit} critical, #{warn} warning(s)"
end
exit(crit.positive? ? 2 : (warn.positive? ? 1 : 0))
How it works, step by step
1. Parsing /etc/exports without a grammar
The format looks trivial and is not. Comments start with # anywhere on a line. A trailing backslash continues the entry on the next line. Paths with spaces must be double-quoted. A client can be a hostname, an IP, a CIDR, a dotted-netmask pair, a wildcard, an NIS netgroup (@group), or nothing at all — /srv/pub alone exports to everyone with defaults. parse_exports first collapses continuations into logical lines (remembering the original line number for error messages), then peels off the path (quoted or first token) and scans the remainder with one regex: (\S+?)\(([^)]*)\)|(\S+) — “a client with an option list” or “a bare client”.
2. The Export struct and its predicates
Every client spec becomes its own struct, so a line with three clients produces three exports and each is judged on its own options. opts splits the option string once and memoises it. rw?, world? (which also accepts the <world> token exportfs prints), root_squash? (the default is squash, so we look for its negation) and sec (default sys) keep audit() readable.
3. Deciding what “broad” means
broad_client? returns true for *, for any pattern containing * or ?, for IPv4 prefixes shorter than /16 and IPv6 prefixes shorter than /48, and for dotted-netmask forms by counting the one-bits in the mask (255.0.0.0 → 8). Hostnames and small networks are not broad. This feeds BROAD_CLIENT directly and SEC_SYS_ONLY indirectly: AUTH_SYS on a hostname is a judgement call, AUTH_SYS on a /8 is a finding.
4. The rule set
Rules are evaluated per export in severity order. WORLD_RW and NO_ROOT_SQUASH (rw) are CRIT because they are direct write access for untrusted parties; INSECURE is CRIT because it turns “root on an allowed client” into “anyone on an allowed client”; SENSITIVE_PATH is CRIT for read-write exports of system directories. The WARN tier is for things that are wrong but not immediately exploitable: ASYNC data-loss, NO_SUBTREE_OPT (which exportfs itself warns about), MISSING_PATH for stale entries. Findings are de-duplicated on [code, path, client].
5. Nested exports
After the per-export pass, a second loop compares each export to every other export whose path is an ancestor. If the child is exported to a wider client set, or read-write where the parent was read-only, NESTED_WIDER is raised on the child with the parent’s spec in the message. This is the pattern behind most “how did the build agents get write access to the whole tree?” incidents.
A realistic bad exports file
The fixture below is a composite of exports files seen in the wild: a “temporary” world-writable CI share with no_root_squash, a legacy export to a /8 with insecure, home directories exported async to a wildcard domain, and a couple of perfectly fine entries for contrast.
# /etc/exports — NFS file server nas01
/srv/projects 10.20.0.0/24(rw,sync,no_subtree_check) \
10.20.1.0/24(rw,sync,no_subtree_check)
/srv/projects/ci *(rw,sync,no_root_squash,no_subtree_check) # jenkins agents, "temporary"
/srv/public *(ro,sync,no_subtree_check)
/srv/backups backup01.corp.example.com(rw,sync,no_subtree_check,sec=krb5p)
/home *.corp.example.com(rw,async,no_subtree_check)
/srv/legacy 10.0.0.0/8(rw,insecure,no_root_squash,sec=sys)
"/srv/media files" 192.168.1.0/255.255.255.0(ro,no_subtree_check)
/srv/scratch 10.20.0.0/24(rw)
The same audit against a captured exportfs -v (note the <world> token and the fully expanded option lists) finds the two things that matter and skips the implicit-option warnings, because the kernel has already filled the defaults in:
{
"status": "CRIT",
"exports": [
{
"path": "/srv/projects",
"client": "10.20.0.0/24",
"options": "rw,sync,no_subtree_check",
"source": "/fx/nfs/exports",
"line": 2
},
{
"path": "/srv/projects",
"client": "10.20.1.0/24",
"options": "rw,sync,no_subtree_check",
...
]
}
When it does not behave
- NIS netgroups (
@builders) are treated as a normal hostname. The script cannot expand netgroups withoutinnetgr; if you use them, add a rule or pass--live—exportfs -vdoes not expand them either, so both views agree. - MISSING_PATH on every line when auditing a captured file. Path checks look at the local filesystem. Use
--no-path-checkfor files pulled from other hosts. exportfs -vshows an export twice. That is one line per client spec, and it is what the audit expects. If you see the same path and client twice, you have a duplicate inexports.d;uniqin the audit hides it, so grep the source files.- SEC_SYS_ONLY fires on every entry. Only for broad clients by design. If your whole estate is one /8 and you accept AUTH_SYS, lower the threshold in
broad_client?or filter the code out of the JSON. - Ruby 2.7 syntax error. The struct uses endless method definitions (
def rw? = ...), a 3.0 feature. Expand them to normaldef ... endblocks if you must run on 2.7. - Testing note. Both parsers and all eleven rules were verified against the two fixtures shown above (exports file: 5 CRIT, exit 2; captured
exportfs -v: 2 CRIT), plus the--jsonpath. The--livebranch simply pipesexportfs -vthrough the same parser and was not run against a live NFS server while writing this article.
Where to take it next
- Cross-check with
/proc/fs/nfsd/exportsorshowmount -eto catch exports the kernel has that no file mentions. - Add the NFSv4 pseudo-root rules.
fsid=0andcrossmntchange what a client can see; a rule that flagscrossmnton a broad export is a natural addition. - Fleet mode. Loop
ssh host exportfs -vover an inventory and runparse_exportfs+auditon each; the functions already take strings, not paths. - Pre-commit hook. Run it with
--exportson the file in your config repo so a bad line never reaches a server. - Auto-fix suggestions. For NO_ROOT_SQUASH and INSECURE the fix is mechanical: print the corrected line alongside the finding.