VPS & Server security checklist

Linux Services & Processes Audit: Cron, Ports & Kernel Checks (2026)

Every running service on your VPS is attack surface. A cryptominer hiding inside a cron job, an unneeded Bluetooth daemon on a production box, a root-owned process bound to a public interface. This page documents all 9 checks in the CtrlOps Services & Processes audit: what each one reads, the exact value that passes, the values that warn or fail, and the command to fix it.

Hiren KalariyaLast reviewed: Aug 22, 202617 min read
9
checks in this audit
1
rated high severity
11s
automated run time
2
need root or sudo

Every pass, warning and failure threshold on this page is transcribed from the vps-security-services-processes audit script that CtrlOps runs, and a build check fails if the two ever disagree. See how the audit runs.

A services audit reviews the running daemons, scheduled jobs, listening processes and kernel hardening settings on a Linux server, to verify that only the services you chose are active and that the OS itself is configured to limit the damage when something goes wrong. This page documents the 9 checks in the CtrlOps Services & Processes audit, in the order the audit runs them.

Every threshold below is transcribed from the audit script itself, not from general advice. Where the script passes a value, this page says it passes; where the script warns rather than fails, this page says so and explains why the distinction matters.

Key takeaways

Nine checks cover four layers: what is running, what is listening, what is scheduled, and what the kernel does when something gets compromised. A host that passes all nine has a small, reviewed attack surface with mandatory access control and kernel exploit mitigations in place.

  • One check is rated HIGH, suspicious cron jobs. A download piped straight into a shell is the most common persistence mechanism after a compromise, and this check hunts for exactly that pattern.
  • Only two need root: mapping external listeners to process names, and reading user crontabs under /var/spool/cron. The other seven run as any user, which makes this the least privilege-hungry audit in the set.
  • The whole audit takes about 11 seconds when CtrlOps runs it over your existing SSH connection, against roughly 20 to 25 minutes by hand.
  • More than 40 running services is a warning. A single-purpose VPS rarely needs that many, and most of the excess was installed by something else as a dependency.
#CheckSeverityWhat it catchesRoot needed
1Unneeded servicesLOWcups, avahi, bluetooth, ModemManager on a serverNo
2Running service countLOWMore than 40 active systemd servicesNo
3External listener processesMEDIUMRoot-owned processes on public-facing portsYes
4Suspicious cron jobsHIGHDownload-and-execute persistence in crontabsYes
5Systemd timersLOWMore than 30 scheduled timersNo
6Time synchronisationLOWMissing or failing NTP syncNo
7Audit daemonMEDIUMauditd missing or stoppedNo
8AppArmor / SELinuxMEDIUMNo mandatory access control activeNo
9Kernel hardeningMEDIUMPartial ASLR, unrestricted dmesg, ICMP redirectsNo
What the severities mean on this page. HIGH is a direct indicator of compromise or a straight path to one, which is why only check 4 carries it. MEDIUM is the absence of a defence layer that limits blast radius: nothing is broken, but nothing catches you either. LOW is informational, worth reviewing rather than fixing tonight. Four of the nine checks are counts rather than verdicts, and a count can only ever pass, which is why each one carries a threshold that turns it into a real finding.

Check 1: Are unnecessary services running?

Low severityNo sudo needed

Desktop services such as CUPS (printing), Avahi (mDNS discovery), Bluetooth and ModemManager have no business running on a production VPS. Each adds code that listens for input, parses untrusted data, and carries its own CVE history. They are installed by default on many distributions and enabled automatically, which is exactly why nobody notices them.

The audit asks systemctl is-active about those four names specifically. It is a deliberately short list of things that are never right on a headless server, rather than an attempt to judge your whole service inventory.

How to check manually

systemctl is-active cups avahi-daemon bluetooth ModemManager
Result thresholds this check applies
ResultWhenWhat it means
PASSNone of cups, avahi-daemon, bluetooth or ModemManager is activeNo commonly unneeded desktop services are running on this host.
WARNOne or more of the four is activeThe running ones are named. Stop, disable and mask each that you do not use.
SKIPsystemctl is not availableThis is not a systemd host, so the check has nothing to query.

How to fix it

# Stop and disable in one step
sudo systemctl disable --now cups avahi-daemon bluetooth ModemManager

# Then mask, so nothing can start them again
sudo systemctl mask cups avahi-daemon bluetooth ModemManager
Mask, do not just disable. A disabled unit can still be started by hand, and more importantly it can still be pulled in as a dependency of something else you install later, which is how these come back months after you turned them off. Masking symlinks the unit to /dev/null so no start succeeds by any route. systemctl unmask reverses it if you ever genuinely need the service.

Check 2: How many services are running?

Low severityNo sudo needed

The number of active systemd services is a proxy for attack surface. A fresh Ubuntu server runs roughly 20 to 25. If yours shows 45 or 50, either something installed software you did not choose or a package you did install pulled in daemons you never configured.

The audit counts running .service units and warns above 40. A count on its own could only ever pass, so the threshold is what turns this from inventory into a finding.

How to check manually

systemctl list-units --type=service --state=running --no-legend | grep -c '\.service'
Result thresholds this check applies
ResultWhenWhat it means
PASS40 or fewer services runningNormal range for a single-purpose host. The exact count is reported, and every one of them is still attack surface worth reviewing.
WARNMore than 40 services runningWell beyond what a single-purpose VPS needs. Disable and mask everything unused.
SKIPsystemctl is not availableThis is not a systemd host.

How to fix it

# Read the list before changing anything
systemctl list-units --type=service --state=running --no-pager

# Find out what a unit actually does
systemctl cat <service-name>

# Then stop and disable the ones you do not need
sudo systemctl disable --now <service-name>
Not every unfamiliar name is unnecessary. systemd-resolved, dbus, polkit, systemd-journald and systemd-logind are load-bearing, and disabling them breaks the host in ways that are not obvious until the next reboot. Read systemctl cat on anything you do not recognise before you touch it. The count is a leading indicator, not a to-do list.

Check 3: What processes listen on external interfaces?

Medium severityNeeds sudo

A process listening on 0.0.0.0 or :: accepts connections from the public internet. That is expected for SSH, nginx and your application server. It is not expected for a database, a debug endpoint or a metrics agent that only ever needed loopback.

The audit runs ss -tlnp as root to map every externally listening socket to its owning process, then does the part that matters: it resolves each PID back to its owner and flags any root-owned process outside a small expected set (sshd, systemd, nginx, apache2, httpd, dockerd, containerd). Listing process names alone could only ever pass. A root-owned process on a public socket is the real finding, because a pre-authentication bug in it is instant host root with no privilege escalation step in between.

How to check manually

# Every external listener, with the owning process
sudo ss -tlnp | grep -vE '127\.0\.0\.1:|\[::1\]:'

# Which of those run as root
sudo ss -tlnp | awk 'NR>1' | grep -vE '127\.0\.0\.1:|\[::1\]:' | while read -r line; do
  pid=$(printf '%s' "$line" | grep -oE 'pid=[0-9]+' | head -1 | cut -d= -f2)
  [ -n "$pid" ] && printf '%s %s\n' "$(sudo stat -c '%U' "/proc/$pid" 2>/dev/null)" \
    "$(printf '%s' "$line" | grep -oE '"[^"]+"' | head -1 | tr -d '"')"
done | sort -u
Result thresholds this check applies
ResultWhenWhat it means
PASSExternal listeners exist with no unexpected root-owned process, or nothing is listening externally at allThe listening process names are reported. Nothing runs as root beyond the expected service set.
WARNA root-owned process outside the expected set is listening externallyThe process names are listed. A pre-auth bug in any of them is instant host root. Drop privileges or bind to 127.0.0.1.
SKIPRoot was unavailable, or ss is not installedPort-to-process mapping needs both. Re-run with sudo, or install iproute2.

Note what a PASS with nothing listening means: it is reported as a pass because there is no exposure, but on a server that is supposed to be serving something, it is worth a second look. An empty listener list on a web host means the web server is not running.

How to fix it

# Bind to loopback in the service config (Redis example)
# /etc/redis/redis.conf
bind 127.0.0.1 ::1

# Or keep it reachable but drop it off uid 0
# /etc/systemd/system/myapp.service
[Service]
User=appuser
Group=appgroup

sudo systemctl daemon-reload
sudo systemctl restart redis myapp
Redis, Memcached and Elasticsearch are the usual offenders, because all three historically shipped defaults that bind to every interface, and all three have had remote code execution CVEs. CVE-2022-0543, the Redis Lua sandbox escape, gave unauthenticated remote root on any instance reachable on 0.0.0.0. Binding to loopback is a one-line change that removes the entire attack class, and it composes with the firewall and Docker port checks rather than duplicating them: this check tells you which process and which user, that one tells you whether the firewall or a container is what exposed it.

Check 4: Are there suspicious cron jobs?

High severityNeeds sudo

Download-and-execute is the most common persistence mechanism after a server compromise, and the shape is always the same: curl or wget piped directly into bash, sh, dash or zsh. A legitimate job downloads a file and then runs it as a separate step. A malicious one pipes, because piping keeps the payload off disk where a file scanner could find it.

The audit reads /etc/crontab and /etc/cron.d, the four periodic directories, and, with root, the user crontabs under /var/spool/cron. It matches the pipe specifically. An earlier version accepted && and ; as well, which flagged ordinary deployment hooks like curl -f ... && systemctl reload nginx as persistence, so the pattern was narrowed to the thing that actually distinguishes a backdoor.

How to check manually

# System crontabs and periodic directories (no root needed, these are 0644/0755)
grep -rhE '(curl|wget)[^|]*\|[[:space:]]*(/bin/|/usr/bin/)?(ba|da|z|k)?sh([[:space:]]|$)' \
  /etc/crontab /etc/cron.d /etc/cron.hourly /etc/cron.daily /etc/cron.weekly /etc/cron.monthly \
  2>/dev/null | grep -v '^[[:space:]]*#'

# User crontabs are mode 1730 and do need root
sudo grep -rhE '(curl|wget)[^|]*\|[[:space:]]*(/bin/|/usr/bin/)?(ba|da|z|k)?sh([[:space:]]|$)' \
  /var/spool/cron /var/spool/cron/crontabs 2>/dev/null | grep -v '^[[:space:]]*#'

# Total cron entries, excluding comments and environment assignments
grep -rh '^[^#]' /etc/crontab /etc/cron.d 2>/dev/null \
  | grep -vE '^(SHELL|PATH|MAILTO|HOME)=' | grep -cE '^[0-9*@]'
Result thresholds this check applies
ResultWhenWhat it means
PASSNo download-and-execute pattern was found in any readable crontabThe total cron entry count is reported. No pipe-to-shell persistence detected, which is not the same as no backdoor. Review each entry anyway.
FAILThe pattern matched anywhereThe first matching line is shown, truncated. Classic persistence. Treat the host as compromised and investigate before you delete anything.

Two details about how the result reads. There is no SKIP row: without root the check still runs across the system crontabs and reports its verdict with a "system tabs only" note attached, because a backdoor in /etc/cron.d is worth finding even from an unprivileged session. And on a FAIL it reports only the first match, shortened to 90 characters, which is enough to recognise the pattern and not enough to be the whole investigation.

How to fix it

# Read a user crontab
sudo crontab -l -u <username>

# Edit it to remove the entry
sudo crontab -e -u <username>

# What was touched in the last week?
sudo find /etc/cron.d /var/spool/cron -type f -mtime -7 -ls
Do not just delete the cron entry. A download-and-execute line in cron means the server was compromised; the cron job is the persistence, not the way in. Deleting it removes your evidence and none of the access. Find the initial vector, look for the other backdoors that usually accompany it (added SSH keys, new accounts, modified binaries, a second timer), treat every credential that touched the host as exposed, and plan to rebuild from a known-good image. Note also what this check cannot see: a cron job that runs a binary already dropped to disk matches nothing here. Pair it with file integrity monitoring for that.

Check 5: How many systemd timers are scheduled?

Low severityNo sudo needed

Systemd timers are the modern replacement for cron, and they are just as good a persistence mechanism. A stock server carries roughly 5 to 10, for log rotation, man-db updates, fstrim and similar. Past 30, something created timers nobody reviewed.

The audit counts every timer, active and inactive, via systemctl list-timers --all. Inactive ones count deliberately: a timer that is scheduled but not currently running is still scheduled.

How to check manually

systemctl list-timers --all --no-legend | grep -c '\.timer'
Result thresholds this check applies
ResultWhenWhat it means
PASS30 or fewer timers scheduledNormal range. The count is reported. Investigate any name you do not recognise.
WARNMore than 30 timers scheduledMore than a stock install carries. Check systemctl list-timers for anything you did not create.
SKIPsystemctl is not availableThis is not a systemd host.

How to fix it

# Every timer, with the unit it triggers
systemctl list-timers --all --no-pager

# What does this one actually run?
systemctl cat <timer-name>.timer

# Turn one off
sudo systemctl disable --now <timer-name>.timer
Timers hide better than cron entries. Cron lives in a handful of well-known files. Timers are spread across /etc/systemd/system, /usr/lib/systemd/system and /run/systemd/system, so there is no single file to read. The count is the cheap sanity check: if it moved since your last review and you did not install anything, find out what created the difference.

Check 6: Is the clock synchronized?

Low severityNo sudo needed

Every timestamp in every log this server writes depends on the system clock. When it drifts, log correlation across hosts stops working, incident timelines become guesses, and TLS handshakes can fail outright when the server time falls outside a certificate validity window. NTP sync is free and needs no attention once enabled.

The audit looks for an active time service among chronyd, chrony, systemd-timesyncd, ntpd, ntpsec and openntpd, falls back to checking for a running chronyd or ntpd process by name, and then reads timedatectl show -p NTPSynchronized.

How to check manually

# Is any sync service running?
systemctl is-active chronyd chrony systemd-timesyncd ntpd ntpsec openntpd 2>/dev/null

# Does the system consider the clock synchronised?
timedatectl show -p NTPSynchronized --value
Result thresholds this check applies
ResultWhenWhat it means
PASSA time service is running and the clock is not reported as unsynchronisedThe service providing sync is named in the result. Timestamps can be trusted.
WARNNo time service was found and timedatectl returned nothingNothing is keeping this clock accurate. Enable systemd-timesyncd or install chrony.
WARNNTPSynchronized reports noA service is present but is not reaching its upstream servers. Check egress rules for UDP 123.

There is no SKIP here, and the PASS is slightly weaker than it looks: the check passes when a service is running and the clock is not explicitly reported as unsynchronised, which includes hosts where timedatectl is unavailable to answer either way. On those, treat the pass as "a sync service is running" rather than "the clock is provably correct".

How to fix it

# Simplest: use the built-in
sudo timedatectl set-ntp true

# Or install chrony for more control over sources
sudo apt install chrony     # Debian/Ubuntu
sudo dnf install chrony     # RHEL/Rocky/Alma
sudo systemctl enable --now chronyd
Check egress before blaming the service. The second WARN case, service present but not synchronised, is almost always a network problem rather than a configuration one: NTP needs outbound UDP 123, and a host with a restrictive egress policy will run a perfectly healthy chrony that never reaches a source. If you tightened outbound rules recently, that is where to look. Check 6 of the firewall audit covers the egress side of the same problem.

Check 7: Is auditd running?

Medium severityNo sudo needed

Application logs record what your software did. auditd records what the operating system did: syscalls, file access, privilege changes, module loads. Without it, a compromise leaves no trail beyond whatever the application chose to write down, which is rarely the part you need. Most distributions ship it and do not enable it.

The audit checks for the binary, then whether the service is running, treating "not installed" and "installed but stopped" as different findings.

How to check manually

# Installed?
command -v auditctl

# Running?
systemctl is-active auditd
Result thresholds this check applies
ResultWhenWhat it means
PASSauditd is installed and runningA syscall-level trail is being recorded. Keep a rule set loaded, because an idle auditd records almost nothing.
FAILauditd is installed but not runningIt crashed or was never enabled. Run systemctl enable --now auditd.
WARNauditd is not installed at allNo syscall-level recording. A compromise leaves no forensic trail beyond application logs.

The severity ordering here is deliberate and worth noticing: installed-but-stopped fails while not-installed only warns. A host that never had auditd is a decision. A host where auditd is installed and dead is a promise that is not being kept, and somebody probably believes it is running.

How to fix it

# Install
sudo apt install auditd      # Debian/Ubuntu
sudo dnf install audit       # RHEL/Rocky/Alma

sudo systemctl enable --now auditd

# Give it rules, or it watches almost nothing
sudo cp /usr/share/audit/sample-rules/30-stig.rules /etc/audit/rules.d/
sudo augenrules --load
A running auditd with no rules is theatre. Starting the daemon is step one of two. Without a rule set it records a trickle of default events and none of the things you would actually want during an incident. The shipped STIG sample rules cover privilege escalation, access to sensitive paths and module loading, which is a reasonable starting point. This check can only see whether the daemon is up; whether it is watching anything useful is on you.

Check 8: Is AppArmor or SELinux enforcing?

Medium severityNo sudo needed

Unix permissions say who may read or write a file. Mandatory access control adds a second, independent layer: even a process running as root can only touch what its policy allows. Without it, a compromised nginx worker can read /etc/shadow, write anywhere and execute anything. With it enforcing, the same compromised process is stuck inside its declared profile.

The audit checks SELinux first via getenforce, then falls back to AppArmor by looking for /sys/kernel/security/apparmor.

How to check manually

# SELinux
getenforce

# AppArmor: is it present, and how many profiles are loaded?
ls /sys/kernel/security/apparmor/policy/profiles 2>/dev/null | wc -l
sudo aa-status
Result thresholds this check applies
ResultWhenWhat it means
PASSSELinux reports Enforcing, or AppArmor is present on the hostFor AppArmor the loaded profile count is reported. Confirm your network-facing services are actually among the confined ones.
WARNSELinux is present but reports Permissive or DisabledThe current mode is named. A service exploit is bounded only by unix permissions. Set it to enforcing.
WARNNeither AppArmor nor SELinux is activeNothing confines a compromised service beyond file permissions. Install and enable one.

Read the AppArmor pass carefully. It triggers on AppArmor being present, and reports the profile count as information rather than as a condition. An Ubuntu host with AppArmor loaded and zero meaningful profiles passes this check. That is the honest limit of a cheap read-only probe, and it is why the result text tells you to confirm your network-facing services are confined: run sudo aa-status and look for nginx, your app server and your database in the enforce list, not just a non-zero number.

How to fix it

# Debian/Ubuntu: usually pre-installed
sudo systemctl enable --now apparmor
sudo aa-status

# RHEL/Rocky/Alma
sudo setenforce 1
sudo sed -i 's/^SELINUX=.*/SELINUX=enforcing/' /etc/selinux/config
Test in permissive mode first, always. Switching SELinux from disabled straight to enforcing on a running production host can lock you out completely, and relabelling on the next boot can take a long time on a large filesystem. Both systems have a log-only mode, aa-complain for AppArmor and setenforce 0 for SELinux. Run for a week, review denials with ausearch -m AVC, fix them, and only then enforce.

Check 9: Are kernel hardening settings applied?

Medium severityNo sudo needed

Four kernel parameters decide whether basic exploit mitigations are on. Full ASLR randomises memory layout so an overflow cannot jump to a predictable address. dmesg_restrict stops unprivileged users reading kernel ring buffer messages that leak those addresses. Reverse-path filtering drops spoofed source addresses. Refusing ICMP redirects prevents routing table manipulation.

The audit reads all four straight out of /proc/sys and warns if any is below baseline.

How to check manually

cat /proc/sys/kernel/randomize_va_space          # want exactly 2
cat /proc/sys/kernel/dmesg_restrict              # want exactly 1
cat /proc/sys/net/ipv4/conf/all/rp_filter        # anything but 0
cat /proc/sys/net/ipv4/conf/all/accept_redirects # anything but 1
Result thresholds this check applies
ResultWhenWhat it means
PASSASLR is full, dmesg is restricted, reverse-path filtering is on and ICMP redirects are refusedAll four checked mitigations are at or above baseline.
WARNOne or more settings are below baselineThe specific parameter names are listed. Set them in /etc/sysctl.d and apply with sysctl --system.
SKIP/proc/sys could not be readKernel settings are not inspectable here, which usually means a container rather than a host.

The four are not tested identically, and the asymmetry is intentional. ASLR and dmesg_restrict must equal their target value, so a parameter that cannot be read counts against you: those two exist on every Linux kernel, and an unreadable one is a problem in itself. Reverse-path filtering and ICMP redirects are only flagged on the exact bad value (0 and 1 respectively), so a missing parameter does not count against you and rp_filter=2, the looser but still valid mode used on asymmetrically routed hosts, passes rather than warning.

How to fix it

sudo tee /etc/sysctl.d/99-hardening.conf > /dev/null << 'EOF'
kernel.randomize_va_space = 2
kernel.dmesg_restrict = 1
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
net.ipv4.conf.all.accept_redirects = 0
net.ipv4.conf.default.accept_redirects = 0
net.ipv6.conf.all.accept_redirects = 0
net.ipv6.conf.default.accept_redirects = 0
EOF

# Apply now, persists across reboots
sudo sysctl --system
Docker hosts are handled, not flagged. net.ipv4.ip_forward = 1 is required for container networking, and the audit never counts it as a finding. When it sees ip_forward enabled on a host with Docker installed, it appends a note saying so to the result, on a pass as well as a warning, so nobody spends an afternoon "fixing" a setting that has to stay exactly where it is.

What this audit does not cover

Being explicit about scope is part of the point of publishing thresholds. This audit reads what is running, what is listening, what is scheduled and how the kernel is configured, on the host itself.

  • Container internals. Services and kernel settings are read from the host. What runs inside Docker containers, Kubernetes pods or LXC instances is invisible here, and needs Falco, Sysdig or Trivy instead.
  • Application vulnerabilities. A service can pass all nine checks and still carry a SQL injection or an authentication bypass. That is SAST and DAST territory, a separate discipline.
  • File integrity. Check 4 finds one specific cron pattern. It does not tell you whether a binary changed since last week. AIDE, Tripwire or OSSEC cover that, and they are what catches the backdoor this check is blind to.
  • Network traffic. Knowing what listens is not knowing what talks. An IDS such as Suricata or Snort watches the traffic itself; check 3 only reads the socket table.
  • Behavioural analytics. auditd records syscalls; turning those into "this account did something unusual" needs a SIEM on top.
  • Whether auditd or MAC is configured usefully. Checks 7 and 8 confirm the mechanism is present and running. Neither can tell you the rule set or the profile set is worth anything, and both say so in their result text.

The manual audit problem at scale

Running all 9 checks by hand takes roughly 20 to 25 minutes per server: listing and reading services, parsing ss output and resolving PIDs to owners, grepping four cron locations plus the user tabs, counting timers, checking sync and auditd, then reading MAC status and four sysctls. It is the longest audit in the VPS set, and most of the time goes on the two checks that need correlation rather than a single command.

TaskManual, per serverCtrlOps
List and count servicesAbout 3 minIncluded
Map external listeners to ownersAbout 3 minIncluded
Grep every crontab for persistenceAbout 4 minIncluded
Count and review systemd timersAbout 2 minIncluded
Check time sync and auditdAbout 3 minIncluded
Read MAC status and sysctlsAbout 5 minIncluded
TotalAbout 20 to 25 minAbout 11 seconds
Scored report and PDF exportNot availableOne click

Multiply by a fleet and the arithmetic stops working. Eight servers is three hours; twenty is most of a week of afternoons.

Four of the nine checks are counts, and a count only means something next to the previous one. 34 running services is fine. 34 when it was 26 last month means something installed eight daemons and nobody noticed. The same is true of the timer count and the cron entry count: read once they are trivia, read in sequence they are the earliest warning you get that a host changed underneath you. That is the server management drift problem in its purest form, and it is the thing a manual audit is structurally worst at, because nobody keeps last month's numbers in a spreadsheet.


How CtrlOps runs all 9 checks in one click

Instead of running these commands on every server by hand, CtrlOps runs the whole Services & Processes audit over the SSH connection you already have open. Nothing is installed on the server: no agent, no daemon, no package, no new credentials. The checks are read-only POSIX shell that executes, streams its results back, and leaves nothing behind.

Watch it run

The recording below walks through the SSH & Access audit rather than this one, but the flow is identical for every audit in the catalog: pick it, watch the checks stream in, read the score, send the failures to the AI Terminal. The written steps that follow cover the same ground if you would rather read than watch.

Step 1: Open the Audit tab

Connect to your server in CtrlOps and open the Audit tab in the left sidebar.

Step 2: Select Services & Processes

Choose the Services & Processes audit from the catalog. All 9 checks are listed with their descriptions, severity levels and the estimated run time (about 11 seconds in total). Toggle off any check you want to skip.

Step 3: Run it

The audit runs over your existing SSH session. Results stream in as they complete, so you watch each check pass, warn or fail rather than waiting on a final report.

Step 4: Read the report

You get a summary containing:

  • A hardening score out of 100
  • A severity breakdown: how many HIGH, MEDIUM and LOW findings
  • Pass, warning, failed and skipped counts
  • A findings table you can sort and filter

Step 5: Fix what failed

Select the failed findings and send them to the AI Terminal. Each proposed fix command appears with an explanation of what it changes. Nothing runs until you review and approve it.

Step 6: Re-run and compare

Run the same audit again after applying the fixes and watch the hardening score move. On this audit the stored history earns its keep twice over, because four of the nine checks are counts and a count is only meaningful against the last one.

Credentials stay on your machine, stored locally, never synced to the cloud. Every command the AI Terminal proposes is shown before execution and gated behind your approval, never auto-run.

Conclusion

Auditing what runs, what listens, what is scheduled and how the kernel is configured is how you keep a server's attack surface small enough to reason about. Nine checks is a lot of ground, but only one of them is an emergency: a download-and-execute line in cron means the host is already compromised, and the other eight are the layers that decide how much that costs you.

Checking all nine by hand takes 20 to 25 minutes per server and produces numbers you will not remember next month. The CtrlOps Security Audit runs the set in about 11 seconds per host, agentlessly, and keeps the counts so the trend is visible without anyone maintaining a spreadsheet.

The neighbouring checklists cover the layers around this one: SSH and access control, the firewall and network perimeter, file system permissions, logging and monitoring and the application and database layer.


Frequently asked questions

A services audit reviews every running daemon, listening process, scheduled job and kernel hardening setting on a Linux server, to verify that only intentionally enabled software is active. The goal is to shrink attack surface by finding unneeded services, root-owned listeners on public ports, suspicious cron entries, and missing defences such as auditd, AppArmor or SELinux and the kernel exploit mitigations.

Run systemctl list-units --type=service --state=running. A fresh Ubuntu server typically runs 20 to 25 services, and this audit warns above 40. Read systemctl cat <name> on anything you do not recognise before disabling it: several unfamiliar names, systemd-resolved, dbus and polkit among them, are load-bearing and break the host if removed.

It is a cron entry that pipes the output of curl or wget straight into a shell. The payload never touches disk, so a file scanner never sees it, which is why it is the most common persistence mechanism left behind after a compromise. The audit matches the pipe specifically and deliberately ignores curl ... && something, because downloading a file and then acting on it is ordinary deployment behaviour rather than a backdoor.

Because only one of them is evidence that something already happened. A download-and-execute cron entry is not a weakness that could be exploited, it is a backdoor that was installed, so it is rated HIGH. The MEDIUM checks are absent defence layers, which change how bad a future compromise gets rather than indicating a present one. The LOW checks are counts and inventory, worth reviewing rather than fixing tonight.

Run sudo ss -tlnp and filter out loopback addresses. That gives you every socket accepting external connections along with the owning PID and process name. The part worth the effort is the next step: resolve each PID with sudo stat -c '%U' /proc/<pid> to find which run as root. A root-owned process on a public port means a pre-authentication bug in it is instant host root, with no privilege escalation needed.

Only two of the nine checks do: mapping external listeners to process names, which needs root to read /proc/<pid> for processes you do not own, and reading user crontabs under /var/spool/cron, which are mode 1730. The cron check does not skip without root; it scans the system crontabs anyway and labels the result accordingly, because a backdoor in /etc/cron.d is worth finding either way.

It is the only durable record of what happened at the OS level, and application logs cannot substitute for it: they record what your software chose to write down, not which files were read or which privileges changed. Install it, enable it, and load a rule set. An idle auditd with no rules records almost nothing, so a passing check here means the daemon is up, not that it is watching anything useful.

Yes, and use whichever your distribution ships: AppArmor on Ubuntu and Debian, SELinux on RHEL, Rocky and Alma. Both confine a service to a declared policy, so a compromised process cannot reach resources outside its profile even when it runs as root. Move to enforcing gradually rather than in one step, using aa-complain or setenforce 0 to log denials for a week first, because going straight to enforcing on a live host can lock you out.

Four form the baseline: kernel.randomize_va_space at 2 for full ASLR, kernel.dmesg_restrict at 1 to block unprivileged kernel log reads, net.ipv4.conf.all.rp_filter at anything other than 0 for reverse-path filtering, and net.ipv4.conf.all.accept_redirects at anything other than 1 to refuse ICMP redirects. Set them in /etc/sysctl.d/99-hardening.conf and apply with sysctl --system. Leave net.ipv4.ip_forward alone on any host running Docker, where 1 is correct and required.

After every software install, configuration change and OS update, because those are exactly what enable services and timers you did not ask for. Monthly is a reasonable floor otherwise. Four of the nine checks are counts, and counts only mean something in sequence, so the value compounds with regularity in a way a single thorough run cannot match.

It reads the host operating system, so it has limited value if your workloads run entirely in Kubernetes or containers with no meaningful host surface. It also does not cover container internals (use Falco or Trivy), application vulnerabilities (SAST and DAST), or network traffic analysis (Suricata or Snort). It is for servers you administer over SSH, where the services, cron and kernel settings are yours to change.

Yes. All nine checks work on any Linux host reachable over SSH: EC2, DigitalOcean Droplets, Linode, Vultr, Hetzner, bare metal or any other VPS. Three of them depend on systemd and report SKIP on a non-systemd init, and check 9 reports SKIP inside a container where /proc/sys is not readable, which is the correct answer rather than a failure.

Audit your fleet

Run all these checks on every server, in one click

CtrlOps runs this audit over your existing SSH connection - no agents, no scripts to manage. $7/user/month after a 1 month free trial - no credit card required.

✓ Start instantly·✓ No credit card·✓ No sneaky autorenewals