Get listed

Linux hardening: sshd, apt, and audit before the pentest

A steel hatch on a server closet with one coral missing bolt.

A Linux pentest is a look at the services that answer, the patches that did not land, and the privileges a foothold can steal.

An OpenSSH race, a sudoers typo, and a world-writable cron are still the boring findings that become a root shell. The tester should be on a box you own or have written permission to test.

The usual mistake is a Nessus CSV and no one walking the sudoers file the CSV cannot read.

This page is the Linux checks that belong in a test, and the host findings that keep pairing with a web bug.

Qualys published CVE-2024-6387 on 1 July 2024. Ubuntu put the OpenSSH fix in the security pocket. A tester who still finds password SSH on a 24.04 box is not uncovering a novel hole. They are reading 50-cloud-init.conf.

This page is the opposite job: make those findings empty on a box you own. The Ubuntu host guide is the meaning of the sshd lines. The hardening playbook is how those files get recopied after a rebuild. Pair leftover packages with the dependency page. Pair the app surface with the secure coding checklist.

Why the report should be short

Ubuntu 24.04.4 LTS shipped on 12 February 2026. Canonical’s announce mail is the date I am using. Standard security maintenance on that LTS still runs through May 2029. You do not need 26.04 to make a test dull. You need auth that refuses passwords, a pocket that applies itself, and a log that records who became root.

Authorized testers will still run a scanner. That is their contract. Your job is that the first three rows they write are already closed: password SSH, stale openssh-server, no audit trail. Those are configuration debts, not puzzles. A creative finding should have to start at the app, which is the checklist linked above, not a default image. Spend the week of the test on that surface. Do not spend it rediscovering a cloud-init drop-in.

This page will not teach nmap flags, Metasploit modules, or a browser hook. Those belong in a scoped engagement you signed, on a range you named, with a rule that says stop at proof. Publishing them here trains the wrong reader.

A week of testing still has a use. The useful week starts at the app after the host proofs are green. If the first finding is “password SSH on 22 from the world,” you paid for a config review you could have run on Monday. If the first finding is an object-id that the API will fetch for any session, you paid for the right work.

sshd the image cannot undo

Write a drop-in. Do not comment the 200-line stock file and hope a later package prompt keeps your notes. On 24.04, OpenSSH reads every *.conf under /etc/ssh/sshd_config.d/ in lexical order and keeps the first keyword. That is why 00-hardening.conf beats 50-cloud-init.conf, and why 99-hardening.conf loses.

# /etc/ssh/sshd_config.d/00-hardening.conf
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
PubkeyAuthentication yes
AllowUsers deploy
AuthenticationMethods publickey

Install an ed25519 public key in /home/deploy/.ssh/authorized_keys first. Mode 600 on that file, 700 on .ssh, owner deploy. Keep a second console open. Then:

sudo sshd -t
sudo systemctl restart ssh
sudo sshd -T | grep -E 'permitrootlogin|passwordauthentication|kbdinteractiveauthentication|allowusers'

You want permitrootlogin no, passwordauthentication no, kbdinteractiveauthentication no, and allowusers deploy. If any line still says yes, another drop-in won. Cloud images often ship 50-cloud-init.conf with PasswordAuthentication yes. Believe sshd -T, not the file you just saved.

prohibit-password is not no. The compiled OpenSSH default still lets root in with a key. Set no. deploy uses sudo for admin work. Do not keep a password on deploy if you can avoid it. A password is a second authenticator you will paste into a panel one day, and the tester will try it.

Socket activation is the 24.04 surprise. ssh.socket owns the listen port. Auth keywords take effect after systemctl restart ssh. If you later change ListenAddress, reload the socket too. Check with systemctl is-active ssh.socket.

Moving the port off 22 cuts bot noise. It does not replace the drop-in. If you change the port, allow it only from an admin net you can name. A new port that still accepts a password is the same finding with extra typing.

The security pocket is the patch

sshd hardening is a snapshot. CVE-2024-6387 was a signal-handler race in the daemon you locked last quarter. The control that closed it for people who were not at the keyboard was the security origin, not a new theme for Apache. Install unattended-upgrades if it is missing, then turn that origin on.

sudo apt-get install -y unattended-upgrades
sudo dpkg-reconfigure -plow unattended-upgrades
// /etc/apt/apt.conf.d/20auto-upgrades
APT::Periodic::Update-Package-Lists "1";
APT::Periodic::Unattended-Upgrade "1";
// /etc/apt/apt.conf.d/50unattended-upgrades
Unattended-Upgrade::Allowed-Origins {
 "${distro_id}:${distro_codename}-security";
};
Unattended-Upgrade::Automatic-Reboot "false";

Identifiers stay 20auto-upgrades, 50unattended-upgrades, and the -security origin. Do not add the -updates pocket unless you have decided that pocket is in scope. Security is the one that closed the sshd race. A pin on openssh-server is how that race lives into 2026. Do not hold that pin unless you are mid-incident.

Leave Automatic-Reboot false on a single VPS. A kernel that reboots at 04:00 without a drain is an outage. When you have two hosts, flip the boolean on the idle one. Proof is a timer and a journal, not a checkbox in a panel:

systemctl is-enabled unattended-upgrades
systemctl list-timers | grep apt-daily
sudo unattended-upgrade --dry-run
apt-get --just-print upgrade | grep -E 'openssh-server|linux-image'

A host that never logs a run is a timer you did not enable. A host that lists openssh-server as upgradable after a week is a pin or a broken origin. Fix that before you invite a tester. Extra packages they will mention, telnetd, rsh-server, an ftp daemon you forgot, come off with apt-get purge. That is inventory, not a scan technique.

apt-mark showhold
dpkg-query -W -f '${db:Status-Status} ${Package}\n' | awk '$1=="installed" && $2 ~ /telnet|rsh|ftp|nis|talk/'

A hold on openssh-server or linux-image is a finding you wrote yourself. Remove the hold after the incident that justified it. Snap packages that listen, snap list, get the same look. If you do not use snap, do not leave a leftover daemon there because apt never saw it.

auditd so privilege use is a row

noble man page for auditd. Ubuntu 24.04’s package is auditd 1:3.1.2-2.1build1.1. The daemon writes records. ausearch and aureport read them. augenrules compiles every file under /etc/audit/rules.d/ into /etc/audit/audit.rules. A tester who can sudo to root without a line in that log will write “no detection” even if sshd is clean.

sudo apt-get install -y auditd audispd-plugins
# /etc/audit/rules.d/00-hardening.rules
-D
-b 8192
-f 1
-w /etc/ssh/sshd_config -p wa -k sshd_config
-w /etc/ssh/sshd_config.d/ -p wa -k sshd_dropin
-w /etc/passwd -p wa -k identity
-w /etc/shadow -p wa -k identity
-w /etc/group -p wa -k identity
-w /etc/sudoers -p wa -k identity
-w /etc/sudoers.d/ -p wa -k identity
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=unset -k root_exec

Load and prove the same names:

sudo augenrules --load
sudo auditctl -l | grep -E 'sshd_dropin|identity|root_exec'
sudo systemctl is-active auditd
# After you sudo -i as deploy, this should print a row:
sudo ausearch -k root_exec -ts recent

root_exec is noisy on a busy builder. Keep it on the production web host where root shells are rare. On a CI runner, drop that one line and keep the identity watches. -f 1 is failure flag: panic is 2, and I am not setting that on a single VPS. The noble man page also notes a kernel boot param audit=1 so early processes are marked auditable. Add it in the bootloader if you care about the gap before the daemon starts. Check /proc/cmdline.

Forwarding those rows to a place a human will see is the leftover work. journald on one box is better than no rules. A second host or a SIEM is better than a file the attacker can truncate. This page stops at a rule file you can name and a search that returns a row after a sudo.

Prove the watches without waiting for a break-in. Edit a comment in 00-hardening.conf, save, then:

sudo ausearch -k sshd_dropin -ts recent
sudo ausearch -k identity -ts recent
sudo aureport --summary

You want a row for the drop-in write. If ausearch prints “no matches” after a write you just made, the rules did not load or the key does not match. Re-run augenrules --load and auditctl -l. A tester who adds a sudoers fragment should also land on identity. That is the closed finding: “auditd running, rules loaded, privilege use leaves a key.” The open finding is “no auditd” or a default empty rules.d.

Prove the host before anyone arrives

You are not scanning the internet. You are proving the host you will hand to the tester matches the files in git.

  1. sshd -T prints permitrootlogin no and passwordauthentication no.
  2. ls /etc/ssh/sshd_config.d/ shows 00-hardening.conf. If 50-cloud-init.conf exists, it must not win those keywords.
  3. unattended-upgrades is enabled. The journal shows a recent run or a next timer. openssh-server is not sitting in the upgradable list.
  4. auditctl -l shows sshd_dropin, identity, and root_exec unless you dropped the last on purpose.
  5. A login as root with a key you did not authorize fails. A login as deploy with the laptop key works. Password auth is refused.
sudo sshd -T | grep -E 'permitrootlogin|passwordauthentication|kbdinteractiveauthentication|allowusers'
# Expect: permitrootlogin no
# passwordauthentication no
# kbdinteractiveauthentication no
# allowusers deploy

ls -l /etc/ssh/sshd_config.d/00-hardening.conf
systemctl is-enabled unattended-upgrades
sudo auditctl -l | grep -E 'sshd_dropin|identity|root_exec'

# From your laptop, against this host only.
ssh -o PreferredAuthentications=password -o PubkeyAuthentication=no deploy@app-a.example
# Expect: Permission denied

ssh -i ~/.ssh/deploy deploy@app-a.example
# Expect: a shell
Password SSH dies at sshd. Stale OpenSSH dies at the security pocket. A root shell still leaves a row.
TESTER / ADMIN LAPTOP
 |
 v
 ssh deploy@app-a
 |
 +-- password -> sshd -T: passwordauthentication no
 +-- root key -> PermitRootLogin no
 +-- deploy key -> shell, sudo leaves root_exec
 |
 v
 apt -security pocket
 auditd 00-hardening.rules
 proof sshd -T, timers, ausearch

If a later rebuild wipes the drop-in, the playbook page is the rerun. If 443 is the only public port, the Ubuntu host page is deny incoming, 22 from an address you can name. Do not open 22 to the world so the tester can “have an easier time.” That is a finding you planted.

Two more rows testers still write when the three proofs above are sloppy. World-writable home directories and a world-readable authorized_keys are Unix mode bugs, not scanner magic. namei -l /home/deploy/.ssh/authorized_keys should show 700 on .ssh and 600 on the key file, owner deploy. A second user with a leftover key from a laptop that left the company is the other row. awk '{print $NF}' /home/deploy/.ssh/authorized_keys should list only comments you can name. Remove a key the day the laptop leaves. None of that is a payload. It is the same inventory as packages.

Questions we keep getting

Can I leave password SSH on for the tester and close it after?

No. The report will include it. The window is also the window anyone else has. Hand them the deploy key, a jump host, or a console. Do not weaken auth to make the week smoother.

Is fail2ban a substitute for the drop-in?

No. fail2ban watches logs after a guess. PasswordAuthentication no means the guess never starts. Keep fail2ban if you want a tripwire on the key path. Do not skip sshd -T because a jail exists.

Does this page replace an application test?

No. Closed sshd and a current package list make the host boring. The app still needs the checklist: injection, object access, session cookies. Hire the tester for that surface once the host proofs above are green.