the shed // linux // nfs

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

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.

$ cat nfs_exports_audit.rb # stdlib only

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

$ ruby nfs_exports_audit.rb –exports fixtures/exports –no-path-check # exit 2

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

Full script + README on GitHub: ruby-devops-toolkit/nfs-exports-audit

01 // the problem

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 six /etc/exports options that decide whether an NFS server is a root shell

What each option means and which finding it maps to.

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.

02 // prerequisites

What you need

Requirements
  • 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/exports needs no privileges; --live runs exportfs -v, which needs root on most distros.
  • Or no server at all: --exports FILE and --exportfs FILE audit captured files, so you can run it in CI against the exports file in your config repo, or against ssh nas01 exportfs -v > nas01.txt.
03 // the code

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.

nfs_exports_audit.rbruby
#!/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))
nfs_exports_audit.rb architecture

Both inputs share the same shape, so one parser feeds one rule set.
04 // walkthrough

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.

05 // example output

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.

fixtures/exportstext
# /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)
ruby nfs_exports_audit.rb –exports fixtures/exports –no-path-check
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)

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:

ruby nfs_exports_audit.rb –exportfs fixtures/exportfs-v.txt
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)
nfs_exports_audit.rb –json (excerpt)json
{
  "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",
    ...
  ]
}
11
finding types
2
input formats
0
gems required
06 // troubleshooting

When it does not behave

Common issues
  • NIS netgroups (@builders) are treated as a normal hostname. The script cannot expand netgroups without innetgr; if you use them, add a rule or pass --live — exportfs -v does 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-check for files pulled from other hosts.
  • exportfs -v shows 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 in exports.d; uniq in 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 normal def ... end blocks 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 --json path. The --live branch simply pipes exportfs -v through the same parser and was not run against a live NFS server while writing this article.
07 // extending

Where to take it next

Ideas
  • Cross-check with /proc/fs/nfsd/exports or showmount -e to catch exports the kernel has that no file mentions.
  • Add the NFSv4 pseudo-root rules. fsid=0 and crossmnt change what a client can see; a rule that flags crossmnt on a broad export is a natural addition.
  • Fleet mode. Loop ssh host exportfs -v over an inventory and run parse_exportfs + audit on each; the functions already take strings, not paths.
  • Pre-commit hook. Run it with --exports on 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.