Paj Digital Labs
All articles
Hosting & Server Infrastructure

Hardening a Fresh VPS Beyond the Basics: Kernel, Auditd and Least Privilege

Standard SSH hardening fails against web-shells; true host security requires kernel sysctl isolation, execution blocking on temporary mounts, and auditd logging.

Paj Digital Labs 6 min read

Every standard VPS hardening checklist stops at the same three steps: disable root login, enforce SSH keys, and turn on ufw. While that stops basic credential bruteforcing on port 22, it leaves the host wide open the moment an attacker gains a web-shell via an unpatched application vulnerability or a compromised dependency.

When an unprivileged user process gets compromised, standard default Linux installations leave kernel pointers visible, allow process tracing, permit unrestricted access to /tmp and /dev/shm for binary execution, and generate zero security audit logs for critical file alterations.

We stopped treating default distro configurations as production-ready years ago. Here is how we harden fresh Debian 12 and Ubuntu 22.04 LTS instances at the kernel, auditing, and execution layer before running any workload.

Kernel Hardening via Sysctl

The default Linux kernel prioritizes compatibility over isolation. If an attacker lands an unprivileged shell as www-data or nobody, they should not be able to inspect kernel memory addresses, attach debuggers to other processes, or abuse legacy networking mechanisms.

We write runtime security configurations directly to /etc/sysctl.d/99-security-hardening.conf.

ini
class="text-code-comment"># /etc/sysctl.d/99-security-hardening.conf
class="text-code-comment">
# Hide kernel pointers from unprivileged users to mitigate local privilege escalation exploits
kernel.kptr_restrict = 2
class="text-code-comment">
# Restrict dmesg buffer access to root to prevent kernel memory address leaking
kernel.dmesg_restrict = 1
class="text-code-comment">
# Prevent process tracing (ptrace) across unprivileged user namespaces
kernel.yama.ptrace_scope = 2
class="text-code-comment">
# Disable BPF JIT compiler exposure to unprivileged users
net.core.bpf_jit_harden = 2
kernel.unprivileged_bpf_disabled = 1
class="text-code-comment">
# Protect FIFO and regular file creation in world-writable sticky directories (/tmp)
fs.protected_fifos = 2
fs.protected_regular = 2
fs.protected_symlinks = 1
fs.protected_hardlinks = 1
class="text-code-comment">
# Disable ICMP redirects to prevent MITM routing modifications
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
net.ipv4.conf.all.send_redirects = 0
net.ipv4.conf.default.send_redirects = 0
class="text-code-comment">
# Enable reverse path filtering to prevent IP spoofing
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
class="text-code-comment">
# Ignore ICMP echo requests broadcast
net.ipv4.icmp_echo_ignore_broadcasts = 1
class="text-code-comment">
# TCP SYN Cookies to mitigate TCP SYN flood attacks
net.ipv4.tcp_syncookies = 1

Apply these settings immediately without restarting:

bash
sysctl -p /etc/sysctl.d/99-security-hardening.conf

Setting kernel.yama.ptrace_scope = 2 ensures that a process can only attach to child processes it created using ptrace if explicitly requested, blocking malicious memory inspection or process injection between web worker processes running under the same service account.

Continuous System Telemetry with Auditd

If an attacker modifies a binary, drops a web shell into /tmp, or calls execve on a reverse shell, standard system logs will miss it completely. syslog records application status, not kernel-level execution events.

We deploy auditd on every node to log process execution, file integrity changes, and privilege escalation attempts.

Install the service:

bash
apt-get install -y auditd audispd-plugins

Then purge the default configuration and load explicit rules under /etc/audit/rules.d/audit.rules. We focus on syscall tracking, execution in temporary directories, and changes to authentication databases.

text
class="text-code-comment"># Clear existing rules
-D
class="text-code-comment">
# Set buffer size for heavy event volume
-b 8192
class="text-code-comment">
# Failure mode: 1 = log error and continue, 2 = kernel panic (use 1 for standard VPS)
-f 1
class="text-code-comment">
# Monitor modifications to core identity and credential files
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/gshadow -p wa -k identity
-w /etc/sudoers -p wa -k privilege_config
-w /etc/sudoers.d/ -p wa -k privilege_config
class="text-code-comment">
# Monitor privilege escalation executions
-w /usr/bin/sudo -p x -k privileged_execution
-w /usr/bin/su -p x -k privileged_execution
class="text-code-comment">
# Monitor kernel module loading and unloading
-a always,exit -F arch=b64 -S init_module -S finit_module -S delete_module -k kernel_modules
class="text-code-comment">
# Monitor execution of files in world-writable directories
-a always,exit -F arch=b64 -S execve -F dir=/tmp -k suspicious_execution
-a always,exit -F arch=b64 -S execve -F dir=/var/tmp -k suspicious_execution
-a always,exit -F arch=b64 -S execve -F dir=/dev/shm -k suspicious_execution
class="text-code-comment">
# Make rules immutable until next reboot
-e 2

Reload auditd to enforce:

bash
augenrules --load

To parse these logs efficiently during incident response, avoid grepping raw files in /var/log/audit/audit.log. Use ausearch and aureport:

bash
class="text-code-comment"># Query all suspicious execution attempts in /tmp or /dev/shm
ausearch -k suspicious_execution -i
class="text-code-comment">
# Generate a report on all user authentication and privilege changes
aureport --auth --summary

Setting -e 2 renders the audit rules immutable. Even if an attacker manages to get root privileges, they cannot clear or alter active auditd rules without issuing a hard reboot of the VPS, giving your remote syslog shipper enough time to transport the event trail off-host.

Service Isolation with Native Systemd Sandboxing

Running services under dedicated non-root users is mandatory, but it is no longer sufficient. If your Nginx worker, Node.js backend, or Python process is compromised, a non-root user can still browse /etc, read configuration files belonging to other services, and execute binaries dropped into /dev/shm.

Instead of running Docker for micro-isolation on small hosts where overhead matters, we use systemd's built-in namespace containment directives.

Below is an override configuration for an application service (e.g., /etc/systemd/system/node-app.service.d/security.conf):

ini
[Service]
class="text-code-comment"># Restrict filesystem access
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
class="text-code-comment">
# Expose only explicit read/write directories needed by the process
ReadWritePaths=/var/log/node-app /var/lib/node-app/data
ReadOnlyPaths=/usr/share/node-app
class="text-code-comment">
# Prevent process from acquiring elevated privileges via setuid/setgid binaries
NoNewPrivileges=yes
class="text-code-comment">
# Kernel security isolation
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictNamespaces=yes
class="text-code-comment">
# Lock down network and system architecture capabilities
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
CapabilityBoundingSet=CAP_NET_BIND_SERVICE

Reload systemd and restart the target service:

bash
systemctl daemon-reload
systemctl restart node-app.service

With ProtectSystem=strict, the process sees the entire root filesystem (/usr, /boot, /etc, /var) as read-only. Any attempt by a web shell to write a persistent script into /etc/cron.d/ or /var/www/ instantly yields an EPERM (Operation not permitted) error at the kernel level.

NoNewPrivileges=yes explicitly blocks execution of binaries with setuid bits (like sudo or pkexec), neutralising the vast majority of local privilege escalation exploits.

Least Privilege Sudo and Linux Capabilities

Giving deployment users full NOPASSWD: ALL access in /etc/sudoers completely negates user isolation. If a deployment SSH key leaks, the host is instantly lost.

Eliminating Root for Low-Port Bindings

Services do not need root access just to bind to ports 80 and 443. Use Linux file capabilities (setcap) or systemd socket activation.

To allow a binary to bind to privileged ports without root:

bash
setcap class="text-code-string">'cap_net_bind_service=+ep' /usr/bin/node

To verify granted capabilities:

bash
getcap /usr/bin/node

Granular Sudoers Configuration

When automated CI/CD runners or service accounts need to run maintenance actions, limit sudo access strictly to explicitly defined commands, with absolute paths, and disable environment passing.

Edit rules inside /etc/sudoers.d/deploy-automation using visudo:

text
class="text-code-comment"># /etc/sudoers.d/deploy-automation

Defaults:deploy_user !env_reset
Defaults:deploy_user secure_path=class="text-code-string">"/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin"
class="text-code-comment">
# Allow deployment user to only control specific application daemons
deploy_user ALL=(root) NOPASSWD: /usr/bin/systemctl reload nginx.service, \
                                  /usr/bin/systemctl restart node-app.service, \
                                  /usr/bin/systemctl status node-app.service

Never allow wildcard operators () inside path definitions in sudoers files. For instance, /usr/bin/systemctl restart allows an operator or compromised user to restart auditd or bring down security services.

Operational Friction and Tradeoffs

Hardening system internals introduces constraints that break poorly designed deployment scripts and aggressive monitoring agents.

| Setting | Threat Vector Mitigated | Failure Mode / Breakage Risk | | :--- | :--- | :--- | | kernel.yama.ptrace_scope = 2 | Memory dumping & process injection | APM agents (e.g., local profilers, gdb, strace) fail unless executed as root. | | fs.protected_regular = 2 | Unprivileged file overwrite in /tmp | Legacy scripts or legacy web apps writing temp files using predictable paths crash. | | ProtectSystem=strict | Unauthorized host filesystem changes | Application logging fails completely if log paths are not explicitly passed via ReadWritePaths. | | NoNewPrivileges=yes | Local privilege escalation via setuid | Legitimate sub-processes attempting to invoke sudo or switch users will fail. | | auditd -e 2 | Silent rule modification by intruders | System management toolsets cannot adjust logging filters dynamically without a full reboot. |

Practical Verification

Once applied, run local checks to ensure the controls hold up:

1. Test process execution containment: Try executing a binary directly from /tmp as a non-root user. 2. Test ptrace bounds: Run strace -p <PID> as the service user against a process owned by the same user. 3. Verify audit logging: Execute a sudo command with a deliberate wrong password and inspect ausearch -k privileged_execution.

Hardening a VPS is not a matter of stacking as many third-party security agents as possible onto a tiny node. It comes down to turning on the standard isolation controls built directly into systemd, the Linux kernel, and the audit subsystem—and leaving no easy path for an unprivileged process compromise to become a system-wide incident.

Paj Digital Labs

The engineering journal of Paj Digital Solutions — hosting, server infrastructure and web engineering, written by the people who run the servers.

Related reading