Skip to content

PersistenceExecution

rc.local, SysV init.d and MOTD Scripts: Linux Boot Hooks

Legacy Linux boot and login script locations (rc.local, /etc/init.d, rc?.d links, Upstart jobs, update-motd.d) and how they reveal persistence.

Location
/etc/rc.local
Proves
Whether a script was set to run as root at boot or at every login, and when it was placed there
Timestamps
File mtime/ctime/crtime only; execution times from journal or syslog
Access
root to write; world-readable on most systems
Retention
Until deleted; execution evidence follows journal/syslog rotation
Collection
UAC, cp -a, tar, journalctl

What it is

Before systemd, Linux booted through shell scripts: SysV init ran /etc/init.d/* scripts in the order given by symlinks in /etc/rc?.d/, and /etc/rc.local ran last as a place for local commands. Ubuntu (up to 14.10) and RHEL 6 used Upstart, which read job files from /etc/init/. systemd kept compatibility for years through two generators that turned rc.local and init scripts into units at boot.

Attackers still use these locations because administrators rarely look at them and they run as root (MITRE ATT&CK T1037.004, RC Scripts). On Debian and Ubuntu, the update-motd scripts are a related root-at-login hook.

Where it lives

ArtifactDebian / UbuntuRHEL / Fedora family
rc.local/etc/rc.local (not shipped since Debian 9)/etc/rc.d/rc.local, with /etc/rc.local as a symlink
Init scripts/etc/init.d//etc/rc.d/init.d/ (/etc/init.d points there)
Runlevel links/etc/rc0.d/ ... /etc/rc6.d/, /etc/rcS.d//etc/rc.d/rc0.d/ ... /etc/rc.d/rc6.d/
Upstart jobs (legacy)/etc/init/*.conf (Ubuntu 14.10 and earlier), ~/.config/upstart//etc/init/*.conf (RHEL 6)
MOTD scripts/etc/update-motd.d/NN-name, output in /run/motd.dynamicnot used
systemd compat unitsrc-local.service, generated units in /run/systemd/generator.late/same

What it proves

  • A script placed in one of these paths was intended to run as root at boot (rc.local, init scripts, Upstart) or at each interactive login (MOTD).
  • The file times bound when it was planted.
  • With journal entries for rc-local.service or the generated unit, that it actually ran on a given boot.
  • Not proven: execution, if the file is not executable, if the systemd version no longer supports these hooks, or if the host uses a different init.

Does it still run?

Conditionrc.local/etc/init.d script
systemd up to 259Runs via systemd-rc-local-generator and rc-local.service if the file exists and is executableWrapped as a .service by systemd-sysv-generator
systemd 260 and laterUpstream removed the generator and rc-local.serviceUpstream removed SysV script support
SysV init, OpenRC, BusyBox initDepends on the init's own rulesRuns per runlevel links

Check the installed systemd version (package database or systemctl --version) before concluding that a planted script executed. A distribution may ship its own unit that restores rc.local, so read any rc-local.service present in /etc/systemd/system.

Key fields

ItemMeaning
rc.local permissionsMust be executable to be picked up; RHEL ships it non-executable
First line (#!/bin/sh -e etc.)Interpreter; -e stops at the first failing command
S##name / K##name links in rc?.dStart or kill at that runlevel, in numeric order
### BEGIN INIT INFO blockLSB headers (Provides, Required-Start, Default-Start) used by update-rc.d
# chkconfig: lineRHEL runlevels and priorities
Upstart start on, exec, scriptTrigger event and command
MOTD NN-nameTwo-digit order; run by pam_motd as root through run-parts --lsbsysinit

Timestamps

These are plain files with no internal timestamps. Use mtime, ctime and, on ext4 or XFS v5, the birth time. Symlinks in rc?.d carry their own times, which date the update-rc.d or chkconfig action. Execution evidence comes from the journal (microseconds since the Unix epoch, UTC) or syslog lines.

stat -c '%w | %y | %z | %A %U %n' /mnt/evidence/etc/rc.local /mnt/evidence/etc/rc.d/rc.local 2>/dev/null

Retention

Until deleted. Package-provided init scripts are removed with their package; attacker files are not. Journal entries for rc-local.service and generator warnings survive only within journal or syslog retention.

Collection

UAC collects /etc completely (including rc.local, init.d, rc?.d and update-motd.d) and has a dedicated Upstart artifact for /etc/init, /etc/xdg/upstart and per-user ~/.config/upstart.

tar -C /mnt/evidence -czf bootscripts.tgz etc/rc.local etc/rc.d etc/init.d etc/rc?.d \
  etc/rcS.d etc/init etc/update-motd.d 2>/dev/null
journalctl -D /mnt/evidence/var/log/journal -u rc-local.service -o short-iso

Parsing

R=/mnt/evidence
cat "$R"/etc/rc.local 2>/dev/null
find "$R"/etc/init.d "$R"/etc/rc.d "$R"/etc/rc?.d "$R"/etc/update-motd.d -xdev \
  -printf '%C+ %M %u %p -> %l\n' 2>/dev/null | sort
# unowned MOTD scripts (Debian/Ubuntu); paths are given relative to the image root
for f in "$R"/etc/update-motd.d/*; do dpkg --root="$R" -S "${f#$R}" >/dev/null 2>&1 || echo "unowned: ${f#$R}"; done

On RHEL, the same loop with rpm --root="$R" -qf "${f#$R}" over /etc/rc.d/init.d/* shows which scripts no package owns. Package verification tools are covered in package manager logs.

Investigator tips

  • On RHEL, the journal line rc.local is not marked executable, skipping from the generator at each boot is normal; its disappearance after a certain date means someone made the file executable.
  • A default Debian or Ubuntu install has no /etc/rc.local. Its presence on those systems needs an explanation.
  • An /etc/init.d script with no package owner, or with an mtime far from its neighbours, is a quick lead.
  • MOTD scripts run as root whenever anyone logs in over SSH, so an attacker only needs any user login to trigger them. Correlate with SSH logins.
  • Upstart job files on a systemd host are dead weight, but they may be leftovers from an older compromise that survived upgrades.
  • Also check shell startup files and systemd units: attackers migrate from rc.local to units once they realise it no longer runs.

See also