the shed // ruby // linux // hardening

Core dumps are a debugging gift and a security leak in one file: they hold passwords, keys and session data, and a crash loop can fill /var overnight. This script checks where cores go, who can read them and how much disk they already eat.

Step through the build below:

core_dump_audit.rb

Most teams never look at crash-dump policy until a disk fills or a security review asks why a setuid binary dumped memory into a world-readable directory. The answer lives in four places: kernel.core_pattern (where cores go, or which program they are piped to), fs.suid_dumpable (whether setuid programs may dump at all), limits.conf (per-login ulimit -c) and the dump directories themselves. The script reads all four straight from /proc and /etc and prints the exact fix for anything wrong.

// core_dump_audit.rb

#!/usr/bin/env ruby
# frozen_string_literal: true
# core_dump_audit.rb - audit how a Linux host handles crashes / core dumps.
# Checks: core_pattern handler, suid_dumpable, ulimit -c in limits.d, and how
# much disk existing dump directories consume (a crash loop can fill /var).
# Usage: ruby core_dump_audit.rb [--root DIR] [--json] [--dump-warn-mb N]
# --root prefixes every path, so a captured tree can be audited anywhere.
# Exit: 0 OK, 1 WARN, 2 CRIT.  Stdlib only.
require 'optparse'
require 'json'

opts = { root: '', json: false, dump_warn_mb: 1024, dump_crit_mb: 5120 }
OptionParser.new do |o|
  o.on('--root DIR') { |v| opts[:root] = v.chomp('/') }
  o.on('--json') { opts[:json] = true }
  o.on('--dump-warn-mb N', Integer) { |v| opts[:dump_warn_mb] = v }
  o.on('--dump-crit-mb N', Integer) { |v| opts[:dump_crit_mb] = v }
end.parse!

def path(opts, p) = "#{opts[:root]}#{p}"
def slurp(opts, p) = (File.read(path(opts, p)).strip rescue nil)

Finding = Struct.new(:sev, :check, :detail, :fix)
findings = []

# 1. Where do cores go?
pattern = slurp(opts, '/proc/sys/kernel/core_pattern')
if pattern.nil?
  findings << Finding.new('WARN', 'core_pattern', 'unreadable', nil)
elsif pattern.start_with?('|')
  handler = pattern[1..].split.first
  base = File.basename(handler)
  known = %w[systemd-coredump apport abrt-hook-ccpp].any? { |k| base.include?(k) }
  if known then findings << Finding.new('OK', 'core_pattern', "piped to #{base} (managed)", nil)
  else findings << Finding.new('WARN', 'core_pattern', "piped to unrecognised handler #{handler}",
                               'confirm the handler is trusted and runs as an unprivileged user')
  end
elsif !pattern.start_with?('/')
  findings << Finding.new('WARN', 'core_pattern',
    "relative pattern '#{pattern}': cores land in each crashing process's cwd (possibly world-readable)",
    'sysctl -w kernel.core_pattern=/var/lib/coredumps/core.%e.%p.%t')
else
  findings << Finding.new('OK', 'core_pattern', "absolute path #{pattern}", nil)
end

# 2. SUID programs dumping core can leak root-only memory.
sd = slurp(opts, '/proc/sys/fs/suid_dumpable')
case sd
when '0' then findings << Finding.new('OK', 'suid_dumpable', '0 (setuid programs never dump)', nil)
when '2' then findings << Finding.new('WARN', 'suid_dumpable', '2 (suidsafe: only safe if core_pattern is piped or absolute)', 'sysctl -w fs.suid_dumpable=0')
when '1' then findings << Finding.new('CRIT', 'suid_dumpable', '1 (setuid processes dump readable by the user)', 'sysctl -w fs.suid_dumpable=0')
else findings << Finding.new('WARN', 'suid_dumpable', "unexpected value #{sd.inspect}", nil)
end

# 3. limits.conf / limits.d "core" lines - effective ulimit -c for logins.
limit_files = Dir[path(opts, '/etc/security/limits.conf'), path(opts, '/etc/security/limits.d/*.conf')].sort
core_lines = limit_files.flat_map do |f|
  File.readlines(f).map(&:strip).reject { |l| l.empty? || l.start_with?('#') }
      .map(&:split).select { |a| a[2] == 'core' }.map { |a| [File.basename(f), a] }
end
unlimited = core_lines.select { |_, a| a[3] == 'unlimited' && a[1] == 'soft' || a[3] == 'unlimited' && a[1] == '-' }
if unlimited.any?
  findings << Finding.new('WARN', 'limits', "unlimited core size set in #{unlimited.map(&:first).uniq.join(', ')}", 'use a finite cap, e.g. "* hard core 0" on non-debug hosts')
elsif core_lines.empty?
  findings << Finding.new('OK', 'limits', 'no core overrides (distro default: soft limit 0)', nil)
else
  findings << Finding.new('OK', 'limits', "#{core_lines.size} core limit line(s), none unlimited", nil)
end

# 4. Disk consumed by existing dumps.
dump_dirs = %w[/var/lib/systemd/coredump /var/crash /var/lib/apport/coredump /var/spool/abrt]
dump_dirs.each do |d|
  full = path(opts, d)
  next unless Dir.exist?(full)
  files = Dir.glob("#{full}/**/*").select { |f| File.file?(f) }
  mb = files.sum { |f| File.size(f) } / 1_048_576.0
  sev = mb >= opts[:dump_crit_mb] ? 'CRIT' : mb >= opts[:dump_warn_mb] ? 'WARN' : 'OK'
  newest = files.map { |f| File.mtime(f) }.max
  detail = format('%d dump(s), %.1f MB%s', files.size, mb, newest ? ", newest #{newest.strftime('%Y-%m-%d %H:%M')}" : '')
  findings << Finding.new(sev, "dumps:#{d}", detail, sev == 'OK' ? nil : 'coredumpctl / rm old dumps; set MaxUse= in coredump.conf')
end

worst = (%w[CRIT WARN OK] & findings.map(&:sev)).first
if opts[:json]
  puts JSON.pretty_generate(overall: worst, findings: findings.map(&:to_h))
else
  puts "core-dump-audit: #{worst}"
  findings.each do |f|
    puts format('  [%-4s] %-28s %s', f.sev, f.check, f.detail)
    puts "         fix: #{f.fix}" if f.fix
  end
end
exit({ 'OK' => 0, 'WARN' => 1, 'CRIT' => 2 }[worst])

1. Classify core_pattern. A leading | means the kernel pipes the core to a handler. Known handlers (systemd-coredump, apport, abrt) are OK; an unknown handler is WARN, because it runs as root with the crashing process’s memory. A relative pattern such as core drops files in the process’s cwd.

2. Grade suid_dumpable. 0 is safe, 2 (suidsafe) is only safe with a piped or absolute pattern, and 1 lets setuid processes dump memory readable by the user – CRIT.

3. Parse limits.conf and limits.d. Comment and blank lines are skipped, each line is split into domain/type/item/value, and only core items are kept. unlimited soft or both limits is a WARN.

4. Measure dump directories. Known dump dirs (/var/lib/systemd/coredump, /var/crash, apport, abrt) are walked and summed, with WARN/CRIT at configurable MB thresholds and the newest file’s time shown.

5. Portable via –root. Every path is prefixed by --root, so a captured tree from another host can be audited on your laptop – and the tests build fake trees in a temp dir.

// captured in the sandbox

core-dump-audit: CRIT
  [WARN] core_pattern                 relative pattern 'core': cores land in each crashing process's cwd (possibly world-readable)
         fix: sysctl -w kernel.core_pattern=/var/lib/coredumps/core.%e.%p.%t
  [CRIT] suid_dumpable                1 (setuid processes dump readable by the user)
         fix: sysctl -w fs.suid_dumpable=0
  [WARN] limits                       unlimited core size set in 99-core.conf
         fix: use a finite cap, e.g. "* hard core 0" on non-debug hosts
  [WARN] dumps:/var/lib/systemd/coredump 1 dump(s), 1500.0 MB, newest 2026-09-29 16:45
         fix: coredumpctl / rm old dumps; set MaxUse= in coredump.conf
exit=2
Finished in 0.547363s, 10.9617 runs/s, 16.4425 assertions/s.

6 runs, 9 assertions, 0 failures, 0 errors, 0 skips
Get the code

Full script + README on GitHub: ruby-devops-toolkit/core-dump-audit

Data flow diagram for core_dump_audit.rb

How the pieces fit together
the problem

Why this matters

Most teams never look at crash-dump policy until a disk fills or a security review asks why a setuid binary dumped memory into a world-readable directory. The answer lives in four places: kernel.core_pattern (where cores go, or which program they are piped to), fs.suid_dumpable (whether setuid programs may dump at all), limits.conf (per-login ulimit -c) and the dump directories themselves. The script reads all four straight from /proc and /etc and prints the exact fix for anything wrong.

Prerequisites – Linux
  • Ruby 2.7+ (tested on 3.3.6); stdlib only
  • Linux; reading /proc/sys and /etc/security works unprivileged, though some dump directories are root-only (they are then skipped or undercounted)
the script

Complete code

core_dump_audit.rbruby
#!/usr/bin/env ruby
# frozen_string_literal: true
# core_dump_audit.rb - audit how a Linux host handles crashes / core dumps.
# Checks: core_pattern handler, suid_dumpable, ulimit -c in limits.d, and how
# much disk existing dump directories consume (a crash loop can fill /var).
# Usage: ruby core_dump_audit.rb [--root DIR] [--json] [--dump-warn-mb N]
# --root prefixes every path, so a captured tree can be audited anywhere.
# Exit: 0 OK, 1 WARN, 2 CRIT.  Stdlib only.
require 'optparse'
require 'json'

opts = { root: '', json: false, dump_warn_mb: 1024, dump_crit_mb: 5120 }
OptionParser.new do |o|
  o.on('--root DIR') { |v| opts[:root] = v.chomp('/') }
  o.on('--json') { opts[:json] = true }
  o.on('--dump-warn-mb N', Integer) { |v| opts[:dump_warn_mb] = v }
  o.on('--dump-crit-mb N', Integer) { |v| opts[:dump_crit_mb] = v }
end.parse!

def path(opts, p) = "#{opts[:root]}#{p}"
def slurp(opts, p) = (File.read(path(opts, p)).strip rescue nil)

Finding = Struct.new(:sev, :check, :detail, :fix)
findings = []

# 1. Where do cores go?
pattern = slurp(opts, '/proc/sys/kernel/core_pattern')
if pattern.nil?
  findings << Finding.new('WARN', 'core_pattern', 'unreadable', nil)
elsif pattern.start_with?('|')
  handler = pattern[1..].split.first
  base = File.basename(handler)
  known = %w[systemd-coredump apport abrt-hook-ccpp].any? { |k| base.include?(k) }
  if known then findings << Finding.new('OK', 'core_pattern', "piped to #{base} (managed)", nil)
  else findings << Finding.new('WARN', 'core_pattern', "piped to unrecognised handler #{handler}",
                               'confirm the handler is trusted and runs as an unprivileged user')
  end
elsif !pattern.start_with?('/')
  findings << Finding.new('WARN', 'core_pattern',
    "relative pattern '#{pattern}': cores land in each crashing process's cwd (possibly world-readable)",
    'sysctl -w kernel.core_pattern=/var/lib/coredumps/core.%e.%p.%t')
else
  findings << Finding.new('OK', 'core_pattern', "absolute path #{pattern}", nil)
end

# 2. SUID programs dumping core can leak root-only memory.
sd = slurp(opts, '/proc/sys/fs/suid_dumpable')
case sd
when '0' then findings << Finding.new('OK', 'suid_dumpable', '0 (setuid programs never dump)', nil)
when '2' then findings << Finding.new('WARN', 'suid_dumpable', '2 (suidsafe: only safe if core_pattern is piped or absolute)', 'sysctl -w fs.suid_dumpable=0')
when '1' then findings << Finding.new('CRIT', 'suid_dumpable', '1 (setuid processes dump readable by the user)', 'sysctl -w fs.suid_dumpable=0')
else findings << Finding.new('WARN', 'suid_dumpable', "unexpected value #{sd.inspect}", nil)
end

# 3. limits.conf / limits.d "core" lines - effective ulimit -c for logins.
limit_files = Dir[path(opts, '/etc/security/limits.conf'), path(opts, '/etc/security/limits.d/*.conf')].sort
core_lines = limit_files.flat_map do |f|
  File.readlines(f).map(&:strip).reject { |l| l.empty? || l.start_with?('#') }
      .map(&:split).select { |a| a[2] == 'core' }.map { |a| [File.basename(f), a] }
end
unlimited = core_lines.select { |_, a| a[3] == 'unlimited' && a[1] == 'soft' || a[3] == 'unlimited' && a[1] == '-' }
if unlimited.any?
  findings << Finding.new('WARN', 'limits', "unlimited core size set in #{unlimited.map(&:first).uniq.join(', ')}", 'use a finite cap, e.g. "* hard core 0" on non-debug hosts')
elsif core_lines.empty?
  findings << Finding.new('OK', 'limits', 'no core overrides (distro default: soft limit 0)', nil)
else
  findings << Finding.new('OK', 'limits', "#{core_lines.size} core limit line(s), none unlimited", nil)
end

# 4. Disk consumed by existing dumps.
dump_dirs = %w[/var/lib/systemd/coredump /var/crash /var/lib/apport/coredump /var/spool/abrt]
dump_dirs.each do |d|
  full = path(opts, d)
  next unless Dir.exist?(full)
  files = Dir.glob("#{full}/**/*").select { |f| File.file?(f) }
  mb = files.sum { |f| File.size(f) } / 1_048_576.0
  sev = mb >= opts[:dump_crit_mb] ? 'CRIT' : mb >= opts[:dump_warn_mb] ? 'WARN' : 'OK'
  newest = files.map { |f| File.mtime(f) }.max
  detail = format('%d dump(s), %.1f MB%s', files.size, mb, newest ? ", newest #{newest.strftime('%Y-%m-%d %H:%M')}" : '')
  findings << Finding.new(sev, "dumps:#{d}", detail, sev == 'OK' ? nil : 'coredumpctl / rm old dumps; set MaxUse= in coredump.conf')
end

worst = (%w[CRIT WARN OK] & findings.map(&:sev)).first
if opts[:json]
  puts JSON.pretty_generate(overall: worst, findings: findings.map(&:to_h))
else
  puts "core-dump-audit: #{worst}"
  findings.each do |f|
    puts format('  [%-4s] %-28s %s', f.sev, f.check, f.detail)
    puts "         fix: #{f.fix}" if f.fix
  end
end
exit({ 'OK' => 0, 'WARN' => 1, 'CRIT' => 2 }[worst])
walkthrough

How it works, step by step

1. Classify core_pattern

A leading | means the kernel pipes the core to a handler. Known handlers (systemd-coredump, apport, abrt) are OK; an unknown handler is WARN, because it runs as root with the crashing process’s memory. A relative pattern such as core drops files in the process’s cwd.

2. Grade suid_dumpable

0 is safe, 2 (suidsafe) is only safe with a piped or absolute pattern, and 1 lets setuid processes dump memory readable by the user – CRIT.

3. Parse limits.conf and limits.d

Comment and blank lines are skipped, each line is split into domain/type/item/value, and only core items are kept. unlimited soft or both limits is a WARN.

4. Measure dump directories

Known dump dirs (/var/lib/systemd/coredump, /var/crash, apport, abrt) are walked and summed, with WARN/CRIT at configurable MB thresholds and the newest file’s time shown.

5. Portable via –root

Every path is prefixed by --root, so a captured tree from another host can be audited on your laptop – and the tests build fake trees in a temp dir.

example output

Run it

Tested live in a Linux sandbox: 6 minitest cases against fixture trees (healthy, relative pattern, unknown pipe handler, suid_dumpable=1, unlimited limit, dump-size thresholds) plus a run against the sandbox’s own /proc/sys.

core_dump_audit.rb
core-dump-audit: CRIT
[WARN] core_pattern relative pattern 'core': cores land in each crashing process's cwd (possibly world-readable)
fix: sysctl -w kernel.core_pattern=/var/lib/coredumps/core.%e.%p.%t
[CRIT] suid_dumpable 1 (setuid processes dump readable by the user)
fix: sysctl -w fs.suid_dumpable=0
[WARN] limits unlimited core size set in 99-core.conf
fix: use a finite cap, e.g. "* hard core 0" on non-debug hosts
[WARN] dumps:/var/lib/systemd/coredump 1 dump(s), 1500.0 MB, newest 2026-09-29 16:45
fix: coredumpctl / rm old dumps; set MaxUse= in coredump.conf
exit=2
Finished in 0.547363s, 10.9617 runs/s, 16.4425 assertions/s.
6 runs, 9 assertions, 0 failures, 0 errors, 0 skips
troubleshooting

When it doesn't work

Troubleshooting
  • Dump dir shows 0 files – the directory is root-only (0700). Run with sudo for a true size.
  • Wrong for containers – core_pattern is a host-wide (non-namespaced) setting; run the audit on the host, not inside a container.
  • Changes don’t stick – sysctl -w is temporary. Persist the suggested fixes in a file under /etc/sysctl.d/.
next steps

Extending it

Ideas
  • Read /etc/systemd/coredump.conf and check Storage=, MaxUse=, ProcessSizeMax=
  • Cross-check coredumpctl list for the most-crashing binaries
  • Write the suggested sysctls to a reviewable drop-in
  • Run across a fleet and diff

Tha-Shed

Type a brief, get a finished film. Characters, voices and music included.

Start creating