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
- Distributions
- 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
Tools
Compare all tools- UACCLI · open source
- VelociraptorPlatform · open source
- grep / zgrepCLI · built into Linux
- journalctlCLI · built into Linux
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
| Artifact | Debian / Ubuntu | RHEL / 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.dynamic | not used |
| systemd compat units | rc-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.serviceor 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?
| Condition | rc.local | /etc/init.d script |
|---|---|---|
| systemd up to 259 | Runs via systemd-rc-local-generator and rc-local.service if the file exists and is executable | Wrapped as a .service by systemd-sysv-generator |
| systemd 260 and later | Upstream removed the generator and rc-local.service | Upstream removed SysV script support |
| SysV init, OpenRC, BusyBox init | Depends on the init's own rules | Runs 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
| Item | Meaning |
|---|---|
| rc.local permissions | Must 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?.d | Start or kill at that runlevel, in numeric order |
### BEGIN INIT INFO block | LSB headers (Provides, Required-Start, Default-Start) used by update-rc.d |
# chkconfig: line | RHEL runlevels and priorities |
Upstart start on, exec, script | Trigger event and command |
MOTD NN-name | Two-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, skippingfrom 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.dscript 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.