the shed // binary security

A world-writable directory in a SUID binary’s RPATH is a local root. Here is a pure-Ruby ELF parser — no readelf, no objdump, no checksec — that finds them, along with the RELRO, PIE and exec-stack gaps your build pipeline has been shipping.

Step through the build below — the problem, the code, the design decisions, and the real output captured while testing it.

elf_hardening_audit.rb

A binary’s RPATH is a list of directories the dynamic linker searches for shared libraries before the system paths. If one of those directories is writable by a non-root user — or is a relative path, which resolves against whatever the current working directory happens to be — then anyone who can write there can drop a malicious .so and have it loaded into the process.

When the binary is also SUID root, that is a straight local privilege escalation. And it is almost always introduced by accident: a -Wl,-rpath,./lib somebody added to get a test running, or a build system that bakes in the temporary build directory and ships it.

The same ELF headers also tell you whether the binary was compiled with the mitigations every distro has shipped by default for a decade — RELRO, a non-executable stack, PIE. Vendor drops and in-house pipelines routinely miss them, and nobody notices, because nothing breaks.

checksec answers some of this. It is a 1,000-line bash script that shells out to readelf. Here is the same job in Ruby, reading the bytes directly.

elf_hardening_audit.rb — a ~150-line ELF parser, stdlib only

#!/usr/bin/env ruby
# frozen_string_literal: true
#
# elf_hardening_audit.rb -- Audit ELF binaries for RPATH hijacking and missing
#                           compiler hardening, in pure Ruby. No gems, no
#                           readelf, no objdump, no checksec.
#
# THE PROBLEM
# -----------
# A binary's RPATH/RUNPATH is a list of directories the dynamic linker searches
# for shared libraries *before* the system paths. If one of those directories is
# writable by a non-root user -- or is a relative path, which resolves against
# whatever the current working directory happens to be -- then anyone who can
# write there can drop a malicious .so and have it loaded into the process. When
# the binary is also SUID root, that is a straight local root.
#
# This is not a hypothetical. It is one of the most common findings in any
# serious build-pipeline review, and it is almost always introduced accidentally
# by a `-Wl,-rpath,./lib` or a build system that bakes in a temp build directory.
#
# Meanwhile the same ELF headers tell you whether the binary was compiled with
# the mitigations every distro has shipped by default for a decade: RELRO, a
# non-executable stack, and PIE. Vendor-shipped binaries and anything built by
# an in-house pipeline routinely miss them.
#
# WHAT THIS SCRIPT DOES
# ---------------------
# It parses the ELF headers itself -- program headers, PT_DYNAMIC, the dynamic
# string table -- and reports, per binary:
#
#   * RPATH / RUNPATH entries that are relative, missing, world/group-writable,
#     or owned by a non-root user          (severity scales with SUID + writability)
#   * NEEDED libraries that cannot be resolved on this host
#   * Executable stack        (PT_GNU_STACK carrying the X flag)
#   * No RELRO / Partial RELRO (PT_GNU_RELRO and DT_BIND_NOW / DF_BIND_NOW)
#   * Non-PIE executables      (ET_EXEC rather than ET_DYN + PT_INTERP)
#
# Read-only. It opens files, reads a few hundred bytes of headers, and never
# writes, patches or executes anything it scans.
#
# Usage:
#   ruby elf_hardening_audit.rb /usr/bin /usr/local/bin
#   ruby elf_hardening_audit.rb --json /opt/vendor-app
#   ruby elf_hardening_audit.rb --min-severity FAIL /usr/bin
#   ruby elf_hardening_audit.rb --suid-only /                 # scan the whole box
#
# Exit codes:  0 = clean   1 = warnings   2 = failures   3 = usage error
require 'json'
require 'optparse'
require 'time'
# ===========================================================================
# ELF parsing
# ===========================================================================
class ElfError < StandardError; end
# A deliberately small ELF reader. It only understands what this audit needs:
# the identification bytes, the program header table, PT_DYNAMIC, and the
# dynamic string table. That is roughly 150 lines instead of a 5,000-line
# general-purpose library, and it depends on nothing outside the stdlib.
class ElfFile
  # --- e_type ---
  ET_EXEC = 2
  ET_DYN  = 3
  # --- p_type ---
  PT_LOAD      = 1
  PT_DYNAMIC   = 2
  PT_INTERP    = 3
  PT_GNU_STACK = 0x6474e551
  PT_GNU_RELRO = 0x6474e552
  # --- d_tag ---
  DT_NULL     = 0
  DT_NEEDED   = 1
  DT_STRTAB   = 5
  DT_STRSZ    = 10
  DT_SONAME   = 14
  DT_RPATH    = 15
  DT_BIND_NOW = 24
  DT_RUNPATH  = 29
  DT_FLAGS    = 30
  DT_FLAGS_1  = 0x6ffffffb
  DF_BIND_NOW = 0x08
  DF_1_NOW    = 0x00000001
  DF_1_PIE    = 0x08000000
  attr_reader :path, :bits, :type, :needed, :rpath, :runpath, :soname,
              :interp, :exec_stack, :relro, :bind_now, :pie
  def initialize(path)
    @path = path
    @needed = []
    @rpath = []
    @runpath = []
    File.open(path, 'rb') { |io| parse(io) }
  end
  # Cheap pre-filter: read four bytes and decide whether this file is worth
  # opening properly. Scanning /usr/bin means touching thousands of shell
  # scripts, and we do not want to pay full-parse cost to reject them.
  def self.elf?(path)
    return false unless File.file?(path) && File.size(path) > 64
    File.open(path, 'rb') { |io| io.read(4) } == "\x7FELF".b
  rescue SystemCallError
    false
  end
  private
  def parse(io)
    ident = io.read(16)
    raise ElfError, 'not an ELF file' unless ident && ident[0, 4] == "\x7FELF".b
    @bits = case ident.getbyte(4)
            when 1 then 32
            when 2 then 64
            else raise ElfError, "unknown EI_CLASS #{ident.getbyte(4)}"
            end
    # EI_DATA: 1 = little-endian, 2 = big-endian. Nearly everything is LE, but
    # honouring the byte is two lines and makes the parser correct on s390x.
    @little = ident.getbyte(5) == 1
    raise ElfError, "unknown EI_DATA #{ident.getbyte(5)}" unless [1, 2].include?(ident.getbyte(5))
    # e_type (2) e_machine (2) e_version (4) e_entry e_phoff e_shoff ...
    @type = u16(io.read(2))
    io.read(2)                       # e_machine
    io.read(4)                       # e_version
    io.read(addr_size)               # e_entry
    phoff = uaddr(io.read(addr_size))
    io.read(addr_size)               # e_shoff
    io.read(4)                       # e_flags
    io.read(2)                       # e_ehsize
    phentsize = u16(io.read(2))
    phnum = u16(io.read(2))
    raise ElfError, 'no program headers' if phnum.zero? || phoff.zero?
    raise ElfError, "absurd phnum #{phnum}" if phnum > 128
    phdrs = read_program_headers(io, phoff, phentsize, phnum)
    @loads = phdrs.select { |p| p[:type] == PT_LOAD }
    gnu_stack = phdrs.find { |p| p[:type] == PT_GNU_STACK }
    # p_flags bit 0 is PF_X. A missing PT_GNU_STACK is itself a red flag on
    # Linux: the kernel then falls back to an executable stack.
    @exec_stack = gnu_stack.nil? || (gnu_stack[:flags] & 0x1) == 1
    @relro = phdrs.any? { |p| p[:type] == PT_GNU_RELRO }
    interp_hdr = phdrs.find { |p| p[:type] == PT_INTERP }
    if interp_hdr
      io.seek(interp_hdr[:offset])
      @interp = io.read(interp_hdr[:filesz].to_i).to_s.split("\0").first
    end
    dyn_hdr = phdrs.find { |p| p[:type] == PT_DYNAMIC }
    parse_dynamic(io, dyn_hdr) if dyn_hdr
    # A PIE is ET_DYN *with* an interpreter. A plain shared library is also
    # ET_DYN but has no PT_INTERP, and "is this .so a PIE" is a meaningless
    # question -- so report PIE as nil for libraries and skip the check.
    @pie = if @interp.nil? && @type == ET_DYN
             nil # shared library
           else
             @type == ET_DYN
           end
  end
  def read_program_headers(io, phoff, phentsize, phnum)
    io.seek(phoff)
    raw = io.read(phentsize * phnum).to_s
    raise ElfError, 'truncated program header table' if raw.bytesize < phentsize * phnum
    (0...phnum).map do |i|
      e = raw.byteslice(i * phentsize, phentsize)
      if @bits == 64
        # 64-bit: p_type(4) p_flags(4) p_offset(8) p_vaddr(8) p_paddr(8) p_filesz(8) ...
        { type: u32(e.byteslice(0, 4)), flags: u32(e.byteslice(4, 4)),
          offset: u64(e.byteslice(8, 8)), vaddr: u64(e.byteslice(16, 8)),
          filesz: u64(e.byteslice(32, 8)) }
      else
        # 32-bit: p_type(4) p_offset(4) p_vaddr(4) p_paddr(4) p_filesz(4) p_memsz(4) p_flags(4)
        { type: u32(e.byteslice(0, 4)), offset: u32(e.byteslice(4, 4)),
          vaddr: u32(e.byteslice(8, 4)), filesz: u32(e.byteslice(16, 4)),
          flags: u32(e.byteslice(24, 4)) }
      end
    end
  end
  def parse_dynamic(io, hdr)
    io.seek(hdr[:offset])
    raw = io.read(hdr[:filesz].to_i).to_s
    step = addr_size * 2
    entries = []
    offset = 0
    while offset + step <= raw.bytesize
      tag = uaddr(raw.byteslice(offset, addr_size))
      val = uaddr(raw.byteslice(offset + addr_size, addr_size))
      break if tag == DT_NULL
      entries << [tag, val]
      offset += step
    end
    strtab_vaddr = entries.find { |t, _| t == DT_STRTAB }&.last
    strsz = entries.find { |t, _| t == DT_STRSZ }&.last.to_i
    strtab = read_strtab(io, strtab_vaddr, strsz)
    flags   = entries.find { |t, _| t == DT_FLAGS }&.last.to_i
    flags_1 = entries.find { |t, _| t == DT_FLAGS_1 }&.last.to_i
    @bind_now = entries.any? { |t, _| t == DT_BIND_NOW } ||
                (flags & DF_BIND_NOW) != 0 ||
                (flags_1 & DF_1_NOW) != 0
    entries.each do |tag, val|
      case tag
      when DT_NEEDED  then @needed  << str_at(strtab, val)
      when DT_SONAME  then @soname   = str_at(strtab, val)
      when DT_RPATH   then @rpath   += split_paths(str_at(strtab, val))
      when DT_RUNPATH then @runpath += split_paths(str_at(strtab, val))
      end
    end
    @needed.compact!
  end
  # DT_STRTAB is a virtual address. Translate it to a file offset by finding the
  # PT_LOAD segment that contains it -- offset = p_offset + (vaddr - p_vaddr).
  def read_strtab(io, vaddr, size)
    return nil if vaddr.nil?
    seg = @loads.find { |l| vaddr >= l[:vaddr] && vaddr < l[:vaddr] + l[:filesz] }
    return nil unless seg
    file_off = seg[:offset] + (vaddr - seg[:vaddr])
    size = 1 << 20 if size <= 0 || size > (1 << 20) # sanity clamp
    io.seek(file_off)
    io.read(size)
  end
  def str_at(strtab, offset)
    return nil if strtab.nil? || offset >= strtab.bytesize
    strtab.byteslice(offset..-1).split("\0").first
  end
  def split_paths(str)
    str.to_s.split(':').reject(&:empty?)
  end
  def addr_size = @bits == 64 ? 8 : 4
  def u16(b) = @little ? b.unpack1('v') : b.unpack1('n')
  def u32(b) = @little ? b.unpack1('V') : b.unpack1('N')
  def u64(b) = @little ? b.unpack1('Q<') : b.unpack1('Q>')
  def uaddr(b) = @bits == 64 ? u64(b) : u32(b)
end
# ===========================================================================
# Library resolution
# ===========================================================================
# Mirrors (a useful subset of) the dynamic linker's search order so we can tell
# whether a DT_NEEDED entry would actually resolve at runtime.
class LibraryResolver
  DEFAULT_DIRS = %w[
    /lib /lib64 /usr/lib /usr/lib64 /usr/local/lib /usr/local/lib64
  ].freeze
  def initialize(root: '/')
    @root = root
    @dirs = build_search_path
    @cache = {}
  end
  attr_reader :dirs
  def resolve(soname, rpath: [], runpath: [], origin: nil)
    key = [soname, rpath, runpath, origin]
    return @cache[key] if @cache.key?(key)
    # Real linker order: DT_RPATH (only when DT_RUNPATH is absent), then
    # LD_LIBRARY_PATH, then DT_RUNPATH, then the cache and default dirs.
    search = []
    search += expand(rpath, origin) if runpath.empty?
    search += expand(runpath, origin)
    search += @dirs
    hit = search.find { |d| File.exist?(File.join(d, soname)) }
    @cache[key] = hit && File.join(hit, soname)
  end
  private
  # $ORIGIN expands to the directory holding the binary. $LIB and $PLATFORM are
  # expanded approximately; getting them exactly right needs the loader's own
  # notion of the platform string, which is not worth replicating here.
  def expand(paths, origin)
    paths.map do |p|
      p.gsub('$ORIGIN', origin.to_s).gsub('${ORIGIN}', origin.to_s)
       .gsub('$LIB', 'lib64').gsub('${LIB}', 'lib64')
    end
  end
  def build_search_path
    dirs = DEFAULT_DIRS.map { |d| File.join(@root, d) }
    # Multiarch dirs (/usr/lib/x86_64-linux-gnu) are where Debian keeps almost
    # everything, and they are not in the hardcoded list above.
    Dir.glob(File.join(@root, 'usr/lib/*-linux-gnu*')).each { |d| dirs << d }
    Dir.glob(File.join(@root, 'lib/*-linux-gnu*')).each { |d| dirs << d }
    dirs += ld_so_conf_dirs
    dirs.select { |d| File.directory?(d) }.uniq
  end
  def ld_so_conf_dirs
    files = [File.join(@root, 'etc/ld.so.conf')] +
            Dir.glob(File.join(@root, 'etc/ld.so.conf.d/*.conf'))
    files.flat_map do |f|
      File.readlines(f, chomp: true).filter_map do |line|
        line = line.sub(/#.*/, '').strip
        next if line.empty? || line.start_with?('include')
        File.join(@root, line)
      end
    rescue SystemCallError
      []
    end
  end
end
# ===========================================================================
# Directory security classification
# ===========================================================================
DirRisk = Struct.new(:state, :detail, keyword_init: true)
# Decide how dangerous a single RPATH/RUNPATH directory is.
def classify_dir(dir, origin)
  return DirRisk.new(state: :relative, detail: 'relative path -- resolves against the CWD of whoever runs the binary') \
    if !dir.start_with?('/') && !dir.include?('$ORIGIN')
  resolved = dir.gsub('$ORIGIN', origin.to_s).gsub('${ORIGIN}', origin.to_s)
  return DirRisk.new(state: :unresolvable, detail: 'contains a loader variable we cannot expand') \
    if resolved.include?('$')
  st = begin
    File.stat(resolved)
  rescue SystemCallError
    return DirRisk.new(state: :missing, detail: 'directory does not exist (a future attacker can create it)')
  end
  return DirRisk.new(state: :notdir, detail: 'exists but is not a directory') unless st.directory?
  mode = st.mode
  if (mode & 0o002) != 0
    DirRisk.new(state: :world_writable, detail: format('world-writable (mode %04o)', mode & 0o7777))
  elsif (mode & 0o020) != 0 && st.gid != 0
    DirRisk.new(state: :group_writable, detail: format('group-writable by gid %d (mode %04o)', st.gid, mode & 0o7777))
  elsif st.uid != 0
    DirRisk.new(state: :nonroot_owner, detail: "owned by uid #{st.uid}, not root")
  else
    DirRisk.new(state: :ok, detail: format('root-owned, mode %04o', mode & 0o7777))
  end
end
# ===========================================================================
# Audit
# ===========================================================================
SEVERITY_ORDER = { 'FAIL' => 0, 'WARN' => 1, 'INFO' => 2 }.freeze
def finding(sev, path, check, message, remediation)
  { 'severity' => sev, 'path' => path, 'check' => check,
    'message' => message, 'remediation' => remediation }
end
def audit_binary(path, resolver, opts)
  elf = begin
    ElfFile.new(path)
  rescue ElfError, SystemCallError => e
    return [finding('INFO', path, 'parse', "skipped: #{e.message}", nil)] if opts[:verbose]
    return []
  end
  st = File.stat(path)
  suid = (st.mode & 0o4000) != 0
  sgid = (st.mode & 0o2000) != 0
  privileged = suid || sgid
  origin = File.dirname(File.realpath(path))
  out = []
  return [] if opts[:suid_only] && !privileged
  # --- 1. RPATH / RUNPATH -------------------------------------------------
  { 'RPATH' => elf.rpath, 'RUNPATH' => elf.runpath }.each do |kind, dirs|
    dirs.each do |dir|
      risk = classify_dir(dir, origin)
      next if risk.state == :ok || risk.state == :notdir
      sev = case risk.state
            when :world_writable, :relative then privileged ? 'FAIL' : 'FAIL'
            when :group_writable, :nonroot_owner then privileged ? 'FAIL' : 'WARN'
            when :missing then privileged ? 'FAIL' : 'WARN'
            else 'WARN'
            end
      tag = privileged ? (suid ? ' [SUID]' : ' [SGID]') : ''
      out << finding(sev, path, "#{kind.downcase}.#{risk.state}",
                     "#{kind} '#{dir}'#{tag}: #{risk.detail}",
                     rpath_fix(kind, dir, risk, privileged))
    end
  end
  # RPATH (as opposed to RUNPATH) cannot be overridden by LD_LIBRARY_PATH and is
  # deprecated; even a safe RPATH is worth flagging on a privileged binary.
  if elf.rpath.any? && elf.runpath.empty?
    out << finding(privileged ? 'WARN' : 'INFO', path, 'rpath.deprecated',
                   "uses legacy DT_RPATH (#{elf.rpath.join(':')}) instead of DT_RUNPATH",
                   'Rebuild with `-Wl,--enable-new-dtags` so the entry becomes DT_RUNPATH.')
  end
  # --- 2. Unresolvable NEEDED libraries -----------------------------------
  elf.needed.each do |lib|
    next if resolver.resolve(lib, rpath: elf.rpath, runpath: elf.runpath, origin: origin)
    out << finding('WARN', path, 'needed.missing',
                   "NEEDED library '#{lib}' does not resolve on this host",
                   'The binary will fail at exec time. Install the providing package ' \
                   "or fix the search path (`ldd #{path}`).")
  end
  # --- 3. Compiler hardening ---------------------------------------------
  if elf.exec_stack
    out << finding('FAIL', path, 'hardening.exec_stack',
                   'executable stack (PT_GNU_STACK carries PF_X, or is absent)',
                   'Rebuild with `-z noexecstack`; usually caused by a hand-written .S ' \
                   'file missing a .note.GNU-stack section.')
  end
  unless elf.relro
    out << finding(privileged ? 'FAIL' : 'WARN', path, 'hardening.no_relro',
                   'no RELRO -- the GOT stays writable for the life of the process',
                   'Rebuild with `-Wl,-z,relro -Wl,-z,now` for full RELRO.')
  end
  if elf.relro && !elf.bind_now
    out << finding('WARN', path, 'hardening.partial_relro',
                   'partial RELRO only (lazy binding leaves the GOT writable)',
                   'Add `-Wl,-z,now` to get full RELRO.')
  end
  if elf.pie == false
    out << finding(privileged ? 'FAIL' : 'WARN', path, 'hardening.no_pie',
                   'not a PIE -- the executable loads at a fixed address, defeating ASLR',
                   'Rebuild with `-fPIE -pie`.')
  end
  out
end
def rpath_fix(kind, dir, risk, privileged)
  case risk.state
  when :world_writable, :group_writable
    "Anyone who can write to #{dir} can preload code into this process" +
      (privileged ? ' AT ELEVATED PRIVILEGE. Treat as a live local-privilege-escalation path. ' : '. ') +
      "Fix the directory permissions (`chmod go-w #{dir}`) or strip the entry with `patchelf --remove-rpath`."
  when :relative
    'A relative search path resolves against the caller\'s working directory. ' \
    'Rebuild without it, or rewrite it as $ORIGIN-relative with ' \
    "`patchelf --set-rpath '$ORIGIN/../lib'`."
  when :missing
    "#{dir} does not exist today, so the entry is inert -- until somebody with " \
    'write access to its parent creates it. Remove the stale entry.'
  when :nonroot_owner
    "#{dir} is not root-owned; its owner can replace libraries loaded by this binary."
  else
    'Review this search-path entry.'
  end
end
# ===========================================================================
# Scanning
# ===========================================================================
SKIP_DIRS = %w[/proc /sys /dev /run /snap].freeze
def collect_candidates(roots, recursive:)
  files = []
  roots.each do |root|
    if File.file?(root)
      files << root
      next
    end
    pattern = recursive ? File.join(root, '**', '*') : File.join(root, '*')
    Dir.glob(pattern, File::FNM_DOTMATCH).each do |p|
      next if SKIP_DIRS.any? { |s| p.start_with?(s) }
      next if File.symlink?(p) # follow-once: the real file is scanned separately
      next unless File.file?(p)
      files << p
    end
  end
  files.uniq
end
# ===========================================================================
# Reporting
# ===========================================================================
COLOR = { 'FAIL' => "\e[31m", 'WARN' => "\e[33m", 'INFO' => "\e[36m" }.freeze
RESET = "\e[0m"
def print_report(findings, stats, color:)
  puts '=' * 78
  puts "  ELF HARDENING & RPATH AUDIT -- #{Time.now.strftime('%Y-%m-%d %H:%M:%S')}"
  puts '=' * 78
  puts
  puts "  Files examined    : #{stats[:examined]}"
  puts "  ELF objects       : #{stats[:elf]}  (#{stats[:suid]} SUID/SGID)"
  puts "  Findings          : #{findings.size}"
  puts
  puts '-' * 78
  findings.group_by { |f| f['path'] }
          .sort_by { |_, fs| fs.map { |f| SEVERITY_ORDER[f['severity']] }.min }
          .each do |path, fs|
    puts
    puts "  #{path}"
    fs.sort_by { |f| SEVERITY_ORDER[f['severity']] }.each do |f|
      tag = format('[%-4s]', f['severity'])
      tag = "#{COLOR[f['severity']]}#{tag}#{RESET}" if color
      puts "    #{tag} #{f['message']}"
      puts "           -> #{f['remediation']}" if f['remediation']
    end
  end
  counts = findings.map { |f| f['severity'] }.tally
  puts
  puts '-' * 78
  puts "  #{counts.fetch('FAIL', 0)} fail   #{counts.fetch('WARN', 0)} warn   #{counts.fetch('INFO', 0)} info"
  puts '=' * 78
end
# ===========================================================================
# Entry point
# ===========================================================================
def main(argv)
  opts = { json: false, recursive: true, suid_only: false, verbose: false,
           min_severity: 'INFO', color: $stdout.tty?, root: '/' }
  parser = OptionParser.new do |o|
    o.banner = 'Usage: ruby elf_hardening_audit.rb [options] PATH [PATH...]'
    o.on('--json', 'Emit JSON instead of a text report') { opts[:json] = true }
    o.on('--[no-]recursive', 'Recurse into subdirectories (default: yes)') { |v| opts[:recursive] = v }
    o.on('--suid-only', 'Only report on SUID/SGID binaries') { opts[:suid_only] = true }
    o.on('--min-severity SEV', %w[FAIL WARN INFO], 'FAIL, WARN or INFO') { |v| opts[:min_severity] = v }
    o.on('--root PATH', 'Treat PATH as / when resolving libraries') { |v| opts[:root] = v }
    o.on('--verbose', 'Report files that could not be parsed') { opts[:verbose] = true }
    o.on('--[no-]color', 'Force ANSI colour on/off') { |v| opts[:color] = v }
    o.on('-h', '--help', 'Show this help') { puts o; exit 0 }
  end
  begin
    parser.parse!(argv)
  rescue OptionParser::ParseError => e
    warn "error: #{e.message}"
    warn parser.to_s
    return 3
  end
  if argv.empty?
    warn 'error: no paths given'
    warn parser.to_s
    return 3
  end
  missing = argv.reject { |p| File.exist?(p) }
  unless missing.empty?
    warn "error: no such path: #{missing.join(', ')}"
    return 3
  end
  resolver = LibraryResolver.new(root: opts[:root])
  candidates = collect_candidates(argv, recursive: opts[:recursive])
  stats = { examined: candidates.size, elf: 0, suid: 0 }
  findings = []
  candidates.each do |path|
    next unless ElfFile.elf?(path)
    stats[:elf] += 1
    begin
      stats[:suid] += 1 if (File.stat(path).mode & 0o6000) != 0
    rescue SystemCallError
      next
    end
    findings.concat(audit_binary(path, resolver, opts))
  end
  threshold = SEVERITY_ORDER[opts[:min_severity]]
  findings.select! { |f| SEVERITY_ORDER[f['severity']] <= threshold }
  if opts[:json]
    puts JSON.pretty_generate('generated_at' => Time.now.utc.iso8601,
                              'search_path' => resolver.dirs,
                              'summary' => stats,
                              'findings' => findings)
  else
    print_report(findings, stats, color: opts[:color])
  end
  return 2 if findings.any? { |f| f['severity'] == 'FAIL' }
  return 1 if findings.any? { |f| f['severity'] == 'WARN' }
  0
end
exit(main(ARGV)) if __FILE__ == $PROGRAM_NAME

The vaddr-to-file-offset translation is the one genuinely fiddly step. DT_STRTAB in the dynamic array is a virtual address, not a file offset. To read it you find the PT_LOAD segment containing that address and compute p_offset + (vaddr - p_vaddr). Skip this and you read garbage from the middle of the file — which is exactly the bug that makes hand-rolled ELF parsers report mysteriously empty RPATHs.

32-bit and 64-bit program headers differ in field order, not just width. In 64-bit, p_flags comes immediately after p_type. In 32-bit it is the last field. Getting that wrong gives you an executable-stack finding on every binary you scan.

Severity scales with privilege. A world-writable RPATH on a user-level tool is bad. On a SUID root binary it is an incident. The same check emits a different severity based on the 0o6000 bits in the file mode.

PIE is nil for shared libraries, not false. A .so is ET_DYN like a PIE but has no PT_INTERP. “Is this library a PIE” is a meaningless question, so the check is skipped rather than answered wrongly — the difference between a tool people trust and one they mute.

$ ruby elf_hardening_audit.rb /tmp/elfdemo # exit 2

==============================================================================
  ELF HARDENING & RPATH AUDIT -- 2026-09-16 12:15:46
==============================================================================
  Files examined    : 5
  ELF objects       : 4  (1 SUID/SGID)
  Findings          : 8
------------------------------------------------------------------------------
  /tmp/elfdemo/legacy-daemon
    [FAIL] RUNPATH '/tmp/elfdemo/badlib': world-writable (mode 0777)
           -> Anyone who can write to /tmp/elfdemo/badlib can preload code into this process. Fix the directory permissions (`chmod go-w /tmp/elfdemo/badlib`) or strip the entry with `patchelf --remove-rpath`.
    [FAIL] executable stack (PT_GNU_STACK carries PF_X, or is absent)
           -> Rebuild with `-z noexecstack`; usually caused by a hand-written .S file missing a .note.GNU-stack section.
    [WARN] no RELRO -- the GOT stays writable for the life of the process
           -> Rebuild with `-Wl,-z,relro -Wl,-z,now` for full RELRO.
    [WARN] not a PIE -- the executable loads at a fixed address, defeating ASLR
           -> Rebuild with `-fPIE -pie`.
  /tmp/elfdemo/report-tool
    [FAIL] RUNPATH '/opt/reporting/lib' [SUID]: directory does not exist (a future attacker can create it)
           -> /opt/reporting/lib does not exist today, so the entry is inert -- until somebody with write access to its parent creates it. Remove the stale entry.
  /tmp/elfdemo/vendor-agent
    [FAIL] RPATH './lib': relative path -- resolves against the CWD of whoever runs the binary
           -> A relative search path resolves against the caller's working directory. Rebuild without it, or rewrite it as $ORIGIN-relative with `patchelf --set-rpath '$ORIGIN/../lib'`.
    [INFO] uses legacy DT_RPATH (./lib) instead of DT_RUNPATH
           -> Rebuild with `-Wl,--enable-new-dtags` so the entry becomes DT_RUNPATH.
  /tmp/elfdemo/metrics-agent
    [WARN] partial RELRO only (lazy binding leaves the GOT writable)
           -> Add `-Wl,-z,now` to get full RELRO.
------------------------------------------------------------------------------
  4 fail   3 warn   1 info
==============================================================================
elf exit=2
Get the code

Full script + README on GitHub: ruby-devops-toolkit/elf-rpath-audit

the setup

What you need

prerequisites
  • Ruby 2.7+ (tested on 3.0.2). Standard library only.
  • Linux, or any ELF platform — the parser handles 32- and 64-bit, little- and big-endian. Library resolution assumes a Linux-style /etc/ld.so.conf.d layout.
  • Root is not required to parse binaries, but you need read access to the directories you scan and stat() on the RPATH directories to judge their permissions.
  • It is read-only: it opens files, reads a few hundred bytes of headers, and never writes, patches or executes anything it scans.
why it matters

One writable directory is a code-execution primitive

The dynamic linker searches RPATH before the system paths

Three facts, all readable from the filesystem, that together make one finding.

Notice what the search order implies. LD_LIBRARY_PATH is ignored for SUID binaries — that is a deliberate security measure, and people assume it means SUID binaries are safe from library injection. It does not. DT_RPATH is baked into the binary and is consulted first, SUID or not.

THE PRECEDENCE RULE THAT SURPRISES PEOPLE

DT_RPATH is only consulted when DT_RUNPATH is absent. If a binary has both, RPATH is silently ignored. That is why modern toolchains emit RUNPATH by default, and why a legacy RPATH is worth flagging on its own.
the shape of it

Reading an ELF without a library

From four magic bytes to a hijackable library path

Five steps, all of them File#read and String#unpack.

The parser is small because it only understands what the audit needs. There is no section header table, no symbol table, no relocation processing — all of that is real ELF, and none of it answers the question.

1. Identify, cheaply

Scanning /usr/bin means touching thousands of shell scripts. You do not want to pay full-parse cost to reject them:

the pre-filterruby
def self.elf?(path)
  return false unless File.file?(path) && File.size(path) > 64
  File.open(path, 'rb') { |io| io.read(4) } == "\x7FELF".b
rescue SystemCallError
  false
end

Four bytes. Note the .b — without forcing binary encoding, the comparison fails on a UTF-8 default external encoding, and every ELF file looks like a shell script.

2. Read the header, respecting both classes

Byte 4 is EI_CLASS (1 = 32-bit, 2 = 64-bit) and byte 5 is EI_DATA (1 = little-endian). Everything downstream depends on those two, so the reader defines its integer helpers in terms of them:

endian-aware readersruby
def addr_size = @bits == 64 ? 8 : 4
def u16(b) = @little ? b.unpack1('v') : b.unpack1('n')
def u32(b) = @little ? b.unpack1('V') : b.unpack1('N')
def u64(b) = @little ? b.unpack1('Q<') : b.unpack1('Q>')
def uaddr(b) = @bits == 64 ? u64(b) : u32(b)

Honouring EI_DATA is two lines and makes the parser correct on s390x. Almost nobody will run it there, but the alternative is a parser that is silently wrong rather than one that says so.

3. Program headers, and the 32/64 field-order trap

read_program_headersruby
if @bits == 64
  # 64-bit: p_type(4) p_flags(4) p_offset(8) p_vaddr(8) p_paddr(8) p_filesz(8) ...
  { type: u32(e.byteslice(0, 4)), flags: u32(e.byteslice(4, 4)),
    offset: u64(e.byteslice(8, 8)), vaddr: u64(e.byteslice(16, 8)),
    filesz: u64(e.byteslice(32, 8)) }
else
  # 32-bit: p_type(4) p_offset(4) p_vaddr(4) p_paddr(4) p_filesz(4) p_memsz(4) p_flags(4)
  { type: u32(e.byteslice(0, 4)), offset: u32(e.byteslice(4, 4)),
    vaddr: u32(e.byteslice(8, 4)), filesz: u32(e.byteslice(16, 4)),
    flags: u32(e.byteslice(24, 4)) }
end

This is not a width change. p_flags moves from the end of the struct to the second field. If you write the 64-bit layout and just narrow the integers, you read p_offset as the flags on every 32-bit binary, and every one of them reports an executable stack.

4. The vaddr translation

The dynamic array stores DT_RPATH as an index into a string table, and locates that string table by virtual address. Turning that into a file offset is the step everyone gets wrong:

read_strtabruby
# DT_STRTAB is a virtual address. Translate it to a file offset by finding the
# PT_LOAD segment that contains it -- offset = p_offset + (vaddr - p_vaddr).
def read_strtab(io, vaddr, size)
  return nil if vaddr.nil?
  seg = @loads.find { |l| vaddr >= l[:vaddr] && vaddr < l[:vaddr] + l[:filesz] }
  return nil unless seg
  file_off = seg[:offset] + (vaddr - seg[:vaddr])
  size = 1 << 20 if size <= 0 || size > (1 << 20) # sanity clamp
  io.seek(file_off)
  io.read(size)
end

The sanity clamp matters more than it looks. DT_STRSZ comes from the file, and this tool is pointed at untrusted vendor binaries by design. A corrupt or hostile value would otherwise become io.read(4_000_000_000).

step 5

Judging a directory

Once you have the paths, the judgement is a File.stat and a decision table:

classify_dirruby
mode = st.mode
if (mode & 0o002) != 0
  DirRisk.new(state: :world_writable, detail: format('world-writable (mode %04o)', mode & 0o7777))
elsif (mode & 0o020) != 0 && st.gid != 0
  DirRisk.new(state: :group_writable, detail: format('group-writable by gid %d', st.gid))
elsif st.uid != 0
  DirRisk.new(state: :nonroot_owner, detail: "owned by uid #{st.uid}, not root")
else
  DirRisk.new(state: :ok, detail: format('root-owned, mode %04o', mode & 0o7777))
end

Two cases deserve their own treatment. A relative path is flagged before any stat, because there is nothing to stat — it resolves against the caller’s CWD at runtime, which an attacker chooses. A missing directory is flagged too: the entry is inert today, and becomes live the moment somebody with write access to its parent creates it.

$ORIGIN is fine

$ORIGIN is expanded against the binary’s real directory before classification. An $ORIGIN-relative RPATH is the correct way to ship a bundled application, so flagging it would be noise — and a tool that cries wolf about correct practice gets muted.

the payoff

Four deliberately broken binaries

Build the test cases with gcc — every one of these flags is something a real build system has emitted by accident:

build the demobash
gcc -o vendor-agent  app.c -Wl,-rpath,./lib -Wl,--disable-new-dtags
gcc -o legacy-daemon app.c -Wl,-rpath,/tmp/elfdemo/badlib -no-pie -Wl,-z,norelro -z execstack
gcc -o report-tool   app.c -Wl,-rpath,/opt/reporting/lib && chmod u+s report-tool
gcc -o metrics-agent app.c -Wl,-z,relro -Wl,-z,lazy

Every check fires, and each finding carries the command that fixes it. See the output tab above for the full run.

4
fail
3
warn
1
info

And on a real system?

A full sweep of /usr/bin on a stock Debian container parses 1,081 ELF objects and reports one finding. That is the right shape for an audit tool: if a hardening scanner is noisy on a distro’s own binaries, you will stop reading its output by the second run.

elf_hardening_audit.rb –min-severity FAIL /usr/bin
Files examined : 1382
ELF objects : 1081 (15 SUID/SGID)
Findings : 1
/usr/bin/sudo
[FAIL] RUNPATH '/usr/libexec/sudo' [SUID]: owned by uid 65534, not root

That single finding is an artefact of the container’s overlay filesystem rather than a real vulnerability — but the detection is correct, and on a real host a non-root-owned RUNPATH directory on sudo would be exactly what you want to hear about.

when it goes wrong

Troubleshooting

“skipped: not an ELF file” on things you expected to be binaries

Use --verbose to see them. Usually shell scripts, ar archives, or symlinks into /etc/alternatives. Symlinks are skipped on purpose — the real target is scanned separately, so you do not get duplicate findings.

A NEEDED library is reported missing but the binary runs fine

The resolver reads /etc/ld.so.conf and ld.so.conf.d/*.conf, not /etc/ld.so.cache (which is a binary format). If a package installs into a directory registered with ldconfig without a .conf file, the resolver will not see it. Compare against ldd <binary>.

Everything reports “no RELRO” on musl or Alpine

Check the toolchain defaults before believing the finding: ld --verbose | grep -i relro and gcc -Q --help=common | grep -i pie tell you what your compiler actually does.

A $ORIGIN RPATH shows as unresolvable

$LIB and $PLATFORM are expanded approximately — exact expansion needs the loader’s own platform string. Anything still containing a $ after expansion is reported as unresolvable rather than silently judged safe.

patchelf is suggested but not installed

apt install patchelf / dnf install patchelf. The real fix is at build time; the patchelf suggestion is for binaries you cannot rebuild, like vendor drops.

next

Where to take it

extending the script
  • CI gate. --json --min-severity FAIL plus the non-zero exit is a drop-in build-break for any pipeline that produces binaries. Start with --suid-only if the full sweep is too noisy on day one.
  • Baseline and diff. Store the JSON and fail only on findings that are new relative to the last run, so existing debt stops drowning out regressions.
  • More hardening checks. Stack canaries (__stack_chk_fail in the dynamic symbol table), FORTIFY_SOURCE (*_chk symbols) and CET/IBT markers in .note.gnu.property are all readable — they need the section header table, which this parser deliberately skips.
  • Package correlation. Map each finding back to its owning package with dpkg -S or rpm -qf, so the report routes itself to whoever owns it.
  • Container images. Point --root at an unpacked image layer and audit it before it ships, rather than after it is running.