🛡️ Hardening FreeBSD: A Tested Baseline
Patch what is vulnerable, shut the SSH door properly, hide your processes from other users, cut the listening ports, lock files with flags and securelevel, and keep an audit trail. Every step tested, including trying to get in
The approach
Hardening a server is not one magic setting. It is a habit: reduce what is exposed, patch what is vulnerable, give every user and program only what it needs, and keep a record of what happens. This page walks through a baseline for a FreeBSD server, in that order, on a real machine, and each step is proved: a before, an after, and an attempt to do the thing that should now be refused.
Our test machine is a FreeBSD 15.0-RELEASE virtual machine from the other pages, so it carries some of their leftovers, and that is useful. Several things this page fixes were weak on purpose or by accident in our lab: SSH accepted passwords, a web server was built from an old tree, and syslogd listened on the network. Real servers are often like this too, which is why you check.
Not covered here, because other pages do it: the firewalls (pf and ipfw), jails as a container for risky services (jails), and boot environments as an undo button (upgrades). A hardened machine uses all of them.
Step 1: know what is vulnerable
Patching has two halves: the base system (freebsd-update, from the upgrade page) and the packages you installed. For packages, FreeBSD has a built-in vulnerability checker:
⚠️ Newer package repository (checked October 2026): the package repository now targets FreeBSD 15.1, while 15.0-RELEASE is end of life. On a 15.0 machine pkg may stop with Newer FreeBSD version for package ... [y/N], and the version numbers in the output below may differ from yours. To carry on, run export IGNORE_OSVERSION=yes first, or better, move to a supported release (see the upgrading guide).
root@practice:~ # pkg audit -F 2>&1 | head -8 Fetching vuln.xml.xz: .......... done nginx-1.28.0_3,3 is vulnerable: www/nginx -- Remote Code Execution/DoS CVE: CVE-2026-42926 CVE: CVE-2026-42945 CVE: CVE-2026-42946 CVE: CVE-2026-42934 CVE: CVE-2026-40460 root@practice:~ # pkg audit -q nginx-1.28.0_3,3 root@practice:~ # pkg upgrade -y nginx 2>&1 | grep -E "UPGRADED|nginx|Number|REMOVED|REINSTALLED|Installed" Installed packages to be UPGRADED: nginx: 1.28.0_3,3 -> 1.30.6,3 [FreeBSD-ports] Number of packages to be upgraded: 1 [1/1] Fetching nginx-1.30.6,3: .......... done [1/1] Upgrading nginx from 1.28.0_3,3 to 1.30.6,3... [1/1] Extracting nginx-1.30.6,3: .......... done root@practice:~ # pkg audit 0 problem(s) in 0 package(s) found. root@practice:~ # pkg -j web upgrade -y nginx 2>&1 | grep -E "UPGRADED|nginx-|Number" Installed packages to be UPGRADED: Number of packages to be upgraded: 1 [web.example.org] [1/1] Fetching nginx-1.30.6,3: .......... done [web.example.org] [1/1] Extracting nginx-1.30.6,3: .......... done root@practice:~ # service -j web nginx restart 2>&1 | tail -2 nginx: configuration file /usr/local/etc/nginx/nginx.conf test is successful Starting nginx. root@practice:~ # pkg -j web audit; echo audit-exit=$? pkg: vulnxml file vuln.xml does not exist. Try running 'pkg audit -F' first audit-exit=1 root@practice:~ # pkg -j web audit -F; echo audit-exit=$? [web.example.org] Fetching vuln.xml.xz: 100% 1313 KiB 1.3 MB/s 00:01 0 problem(s) in 0 package(s) found. audit-exit=0 root@practice:~ # fetch -qo /dev/null http://10.0.2.50/ ; echo fetch-exit=$? fetch-exit=0
- pkg audit -F downloads the current list of known vulnerabilities (the -F means "fetch it first") and checks every installed package against it. The machine was running nginx-1.28.0_3,3, and the audit said it is vulnerable: 6 problems in 1 package (the list is cut to 8 lines here), each with its CVE numbers and a link to the full FreeBSD advisory. That package is the web server we compiled from an old ports tree on the ports page. A source build is only as fresh as the tree it came from, and here is the bill.
- pkg audit -q is the quiet form: only the names of vulnerable packages, which is handy in scripts.
- pkg upgrade -y nginx replaced it with the repository's 1.30.6. The next pkg audit says 0 problem(s) in 0 package(s) found: the newer package carries the fixes.
The vulnerability list changes every few days, so what you see will differ. When we first tested this page, in early October, the newest package in the repository (nginx 1.30.4) still had one open problem, a buffer overflow in the HTTP/3 module that was fixed in 1.30.5. The lesson from that: pkg audit can report a problem for which no fixed package exists yet. In that case:
- Check whether it applies to you. A flaw in an optional module (such as HTTP/3) may not matter if you do not use it (read the advisory and test your own configuration). pkg audit compares package versions, so it cannot know how you use the program.
- Reduce the exposure meanwhile: for example a firewall rule that blocks the port, or a build without the option (see the ports and poudriere pages).
- Watch for the update: run pkg audit -F again after each repository update.
One more detail: jails have their own package database. The same nginx was installed in the jail, and pkg -j web upgrade -y nginx upgraded it there, and we restarted the service (the web server still answered). But pkg -j web audit at first said vulnxml file vuln.xml does not exist. Try running 'pkg audit -F' first: each jail needs its own pkg -j NAME audit -F, and then it reported 0 problems. (Running pkg audit -F regularly, for example from cron, is what keeps this honest: from the documentation, not run here. FreeBSD's daily periodic security report can also include it.)
Step 2: close the SSH door properly
SSH is usually the one service that is open to the world. Look at what ours offered before we changed it, from the outside machine:
root@practice:~ # cp /etc/ssh/sshd_config /etc/ssh/sshd_config.before root@practice:~ # sshd -T | grep -iE '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|maxauthtries|logingracetime|allowusers)' logingracetime 120 maxauthtries 6 permitrootlogin no passwordauthentication yes kbdinteractiveauthentication yes laptop:~ $ ssh -o BatchMode=yes -o PubkeyAuthentication=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p 12223 bsd@127.0.0.1 true; echo "exit $?" bsd@127.0.0.1: Permission denied (publickey,password,keyboard-interactive). exit 255
sshd -T prints the effective configuration (what the server will really do, defaults included). Our lab had passwordauthentication yes, kbdinteractiveauthentication yes (another way to type a password), up to 6 tries per connection, and 120 seconds to log in. The probe from the laptop used BatchMode with public keys switched off, which makes ssh try no password at all, so it never prompts, and the server's reply lists every method it is willing to accept: publickey,password,keyboard-interactive. Passwords can be guessed by anyone, all day. Now the change:
root@practice:~ # sed -i '' 's/^PasswordAuthentication yes/PasswordAuthentication no/' /etc/ssh/sshd_config root@practice:~ # cat >> /etc/ssh/sshd_config <<'EOF' > KbdInteractiveAuthentication no > MaxAuthTries 3 > LoginGraceTime 30 > ClientAliveInterval 300 > ClientAliveCountMax 2 > AllowUsers bsd > EOF root@practice:~ # diff /etc/ssh/sshd_config.before /etc/ssh/sshd_config 125c125,131 < PasswordAuthentication yes --- > PasswordAuthentication no > KbdInteractiveAuthentication no > MaxAuthTries 3 > LoginGraceTime 30 > ClientAliveInterval 300 > ClientAliveCountMax 2 > AllowUsers bsd root@practice:~ # sshd -t && echo config-ok config-ok root@practice:~ # sshd -T | grep -iE '^(permitrootlogin|passwordauthentication|kbdinteractiveauthentication|maxauthtries|logingracetime|allowusers)' logingracetime 30 maxauthtries 3 permitrootlogin no passwordauthentication no kbdinteractiveauthentication no allowusers bsd root@practice:~ # service sshd reload Performing sanity check on sshd configuration.
- We saved a copy first (sshd_config.before), then changed PasswordAuthentication to no in place with sed. This file has no Include directory in FreeBSD 15, and for most sshd settings the first value found wins. The only active setting in the file was the one at the bottom. If you instead append a second PasswordAuthentication no below an earlier yes, the earlier line wins and nothing changes. sshd -T is how you catch that.
- Then we appended settings that were only commented-out defaults above: no keyboard-interactive passwords, 3 tries, 30 seconds to log in, ask idle clients to answer every 300 seconds and drop them after two missed answers, and AllowUsers bsd: only that account may log in at all.
- diff shows exactly what changed. sshd -t checks the syntax (it printed nothing and the echo says config-ok), and sshd -T confirms the settings. permitrootlogin no is already FreeBSD's default.
- service sshd reload applies it without cutting existing connections.
Now we try to get in the old ways, from outside:
laptop:~ $ ssh -o BatchMode=yes -o PubkeyAuthentication=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p 12223 bsd@127.0.0.1 true; echo "exit $?" bsd@127.0.0.1: Permission denied (publickey). exit 255 laptop:~ $ ssh -o BatchMode=yes -o PubkeyAuthentication=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p 12223 root@127.0.0.1 true; echo "exit $?" root@127.0.0.1: Permission denied (publickey). exit 255 laptop:~ $ ssh -i /home/liloland/freebsd-lab/id_lab -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p 12223 bsd@127.0.0.1 "id -un; hostname"; echo "exit $?" bsd practice exit 0 root@practice:~ # grep -E "Failed|Invalid|not allowed|refused|Connection closed" /var/log/auth.log | tail -4 | cut -c1-150 Oct 6 16:38:28 practice sshd-session[3209]: Connection closed by authenticating user bsd 10.0.2.2 port 44714 [preauth] Oct 6 16:38:31 practice sshd-session[3259]: Connection closed by authenticating user bsd 10.0.2.2 port 44724 [preauth] Oct 6 16:38:32 practice sshd-session[3261]: User root from 10.0.2.2 not allowed because not listed in AllowUsers Oct 6 16:38:32 practice sshd-session[3261]: Connection closed by invalid user root 10.0.2.2 port 44736 [preauth]
- Password logins are refused, and the reply now lists only publickey. The password methods are no longer even offered.
- root is refused, and the log says why: User root from 10.0.2.2 not allowed because not listed in AllowUsers. (In the auth.log lines you also see the two probes as Connection closed by authenticating user bsd ... [preauth]: connections that gave up before logging in.)
- The key login still works (bsd, practice, exit 0): we closed the doors we meant to, and not the one we use.
The lockout rule: before you reload sshd on a remote machine, make sure your key already works for a normal account that you can su from, keep your current session open, test with a second connection, and only then close the first. (To use root you now log in as bsd and run su -, which is what every capture on these pages does.) Moving sshd to another port or adding tools that ban repeated failures (sshguard, blacklistd) are further layers: from the documentation, not run here.
Step 3: hide processes and information from other users
By default any user can see every other user's processes and read the kernel's message log. On a server with several users, or one where a hacked web server runs as an ordinary user, that helps an attacker look around. FreeBSD has switches for it:
root@practice:~ # ps -ax | wc -l
51
root@practice:~ # su -m bsd -c 'id -un; ps -ax | wc -l'
bsd
53
root@practice:~ # su -m bsd -c 'dmesg | head -1'
---<<BOOT>>---
As root, ps -ax listed 51 processes. The ordinary user bsd saw 53 (the counts differ by a couple, because the commands that do the counting are processes too): so an ordinary user sees everything, and dmesg printed the kernel log without trouble. Now we turn the switches:
root@practice:~ # sysctl security.bsd.see_other_uids=0 security.bsd.see_other_gids=0 security.bsd.unprivileged_read_msgbuf=0 security.bsd.unprivileged_proc_debug=0 kern.randompid=1 kern.coredump=0 security.bsd.see_other_uids: 1 -> 0 security.bsd.see_other_gids: 1 -> 0 security.bsd.unprivileged_read_msgbuf: 1 -> 0 security.bsd.unprivileged_proc_debug: 1 -> 0 kern.randompid: 0 -> 783 kern.coredump: 1 -> 0
- security.bsd.see_other_uids=0 and see_other_gids=0: users cannot see processes (or other details) belonging to other users or groups.
- security.bsd.unprivileged_read_msgbuf=0: only root may read the kernel message buffer (dmesg), which can leak addresses and device details.
- security.bsd.unprivileged_proc_debug=0: ordinary users cannot use process debugging facilities, which closes a trick for spying on other programs (from the documentation, not run here).
- kern.randompid=1: process ID numbers are handed out at random instead of in order, so an attacker cannot predict the next one. (After you switch it on, reading it shows a different random number each time: 0 -> 783 here, and 551 later. That is it working, and not a value to worry about.)
- kern.coredump=0: programs that crash do not write core files, which can contain passwords and keys from memory. (You lose a debugging aid: turn it back on when you need it.)
root@practice:~ # ps -ax | wc -l
51
root@practice:~ # su -m bsd -c 'id -un; ps -ax | wc -l'
bsd
7
root@practice:~ # su -m bsd -c 'dmesg | head -1'
dmesg: sysctl kern.msgbuf: Operation not permitted
root@practice:~ # su -m bsd -c 'ps -ax' | head -4
PID TT STAT TIME COMMAND
3273 - S 0:00.02 sshd-session: bsd@pts/0 (sshd-session)
3274 0 Ss 0:00.01 -sh (sh)
3275 0 S 0:00.01 su -
Now root still sees 51 processes, but bsd sees only 7: its own login, its shell and the few related ones. The last command shows the list as bsd sees it: just the user's own processes. And dmesg as bsd is refused: sysctl kern.msgbuf: Operation not permitted. Making it permanent is the same file as on the tuning page:
root@practice:~ # cat >> /etc/sysctl.conf <<'EOF' > security.bsd.see_other_uids=0 > security.bsd.see_other_gids=0 > security.bsd.unprivileged_read_msgbuf=0 > security.bsd.unprivileged_proc_debug=0 > kern.randompid=1 > kern.coredump=0 > EOF root@practice:~ # service sysctl restart root@practice:~ # grep -c "^security\|^kern.randompid\|^kern.coredump" /etc/sysctl.conf 6 root@practice:~ # sysctl security.bsd.see_other_uids kern.randompid kern.coredump security.bsd.see_other_uids: 0 kern.randompid: 551 kern.coredump: 0
service sysctl restart re-reads /etc/sysctl.conf and applies all six lines, and the sysctl check at the end shows the values in place.
(Another default protection you can see: kern.elf64.aslr.enable is already 1, so the memory layout of programs is randomised: seen in an earlier check, not in a transcript.)
Step 4: fewer listening doors
Every program that listens for connections is a place to attack. List them:
root@practice:~ # sockstat -4l -6l USER COMMAND PID FD PROTO LOCAL ADDRESS FOREIGN ADDRESS www nginx 3165 6 tcp4 10.0.2.50:80 *:* root nginx 3164 6 tcp4 10.0.2.50:80 *:* root syslogd 2042 5 udp4 10.0.2.50:514 *:* root sshd 1544 6 tcp6 *:22 *:* root sshd 1544 7 tcp4 *:22 *:* root syslogd 1184 6 udp6 *:514 *:* root syslogd 1184 7 udp4 *:514 *:* root@practice:~ # sysrc syslogd_flags syslogd_flags: -s
The web servers (two: the nginx pair) and sshd (on IPv4 and IPv6) are expected. But syslogd is listening on the network: UDP port 514 on every address on the host, and on the jail's address as well. That is the remote logging service. We never asked for it. syslogd_flags="-s", the default, means "do not log messages that arrive from remote machines", but the program still holds the port open. -ss means do not listen at all:
root@practice:~ # sysrc syslogd_flags="-ss" syslogd_flags: -s -> -ss root@practice:~ # service syslogd restart Stopping syslogd. Waiting for PIDS: 1184. Starting syslogd. root@practice:~ # sysrc -j web syslogd_flags="-ss" syslogd_flags: -s -> -ss root@practice:~ # service -j web syslogd restart Stopping syslogd. Waiting for PIDS: 2042. Starting syslogd. root@practice:~ # sockstat -4l -6l USER COMMAND PID FD PROTO LOCAL ADDRESS FOREIGN ADDRESS www nginx 3165 6 tcp4 10.0.2.50:80 *:* root nginx 3164 6 tcp4 10.0.2.50:80 *:* root sshd 1544 6 tcp6 *:22 *:* root sshd 1544 7 tcp4 *:22 *:*
We changed the flag on the host and in the jail (sysrc -j web), restarted both, and the list went from 7 sockets to 4: just nginx and sshd. A machine that does not listen cannot be attacked through that door. This is the habit: run sockstat -4l -6l, and for every line be able to say why it is there. Anything you cannot explain, find out, and then switch off or firewall.
Step 5: accounts
root@practice:~ # awk -F: '$7 !~ /nologin|false/ {print $1, $7}' /etc/passwd
root /bin/sh
toor
uucp /usr/local/libexec/uucp/uucico
bsd /bin/sh
root@practice:~ # awk -F: '($2 == "") {print $1}' /etc/master.passwd | wc -l
0
root@practice:~ # pw usershow toor | cut -d: -f1,2,7
toor:*:0
- Accounts with a real login shell: root, toor, uucp and bsd. (toor, "root" backwards, is an extra superuser account. Its password field is *, which means locked, so nobody can log in as it with a password. It exists so that you can give root a different shell: from the documentation.) uucp has a shell program, because of how an old file-copy system worked (from the documentation). If you do not use an account, give it /usr/sbin/nologin as its shell, or remove it. Our list shows what to review.
- No account has an empty password (the count is 0). Always check that.
- Limit who may become root: on FreeBSD, only members of the group wheel may use su to become root. Check who is in it with pw groupshow wheel, and keep it short (from the documentation, not run here).
Step 6: make files unchangeable: flags and securelevel
Permissions say who may change a file, and root can change anything. FreeBSD has a second layer, file flags, that even root must lift before changing a file, and securelevel, which lets the kernel refuse to lift them at all.
root@practice:~ # echo "keep this file" > /root/important.txt root@practice:~ # chflags schg /root/important.txt root@practice:~ # ls -lo /root/important.txt -rw-r--r-- 1 root wheel schg,uarch 15 Oct 6 16:40 /root/important.txt root@practice:~ # rm -f /root/important.txt rm: /root/important.txt: Operation not permitted root@practice:~ # echo "tampering" >> /root/important.txt -su: cannot create /root/important.txt: Operation not permitted root@practice:~ # chflags noschg /root/important.txt root@practice:~ # rm -f /root/important.txt
- chflags schg FILE sets the system immutable flag. ls -lo shows it: schg. (uarch is an "archive" marker that ZFS adds to new files.)
- With the flag set, even root cannot delete the file (rm -f says Operation not permitted), nor add to it (cannot create ... Operation not permitted).
- At the normal security level, root can lift the flag with chflags noschg, and then delete the file. So the flag stops mistakes and ordinary malware, but not an attacker who becomes root.
Securelevel raises the bar:
root@practice:~ # echo "keep this file" > /root/important.txt; chflags schg /root/important.txt; ls -lo /root/important.txt -rw-r--r-- 1 root wheel schg,uarch 15 Oct 6 16:40 /root/important.txt root@practice:~ # sysctl kern.securelevel kern.securelevel: -1 root@practice:~ # sysctl kern.securelevel=1 kern.securelevel: -1 -> 1 root@practice:~ # chflags noschg /root/important.txt chflags: /root/important.txt: Operation not permitted root@practice:~ # kldload if_tap kldload: can't load if_tap: Operation not permitted root@practice:~ # sysctl kern.securelevel=-1 kern.securelevel: 1 sysctl: kern.securelevel=-1: Operation not permitted root@practice:~ # sysctl kern.securelevel kern.securelevel: 1
- kern.securelevel is -1 here: the lowest, "permanently insecure", the default on a normal system.
- We raised it with sysctl kern.securelevel=1: -1 -> 1. Now, even as root: chflags noschg is refused, kldload if_tap (loading a kernel module) is refused (Operation not permitted), and so is lowering the level again (kern.securelevel=-1: Operation not permitted). The level can only go up while the system runs.
- We then rebooted:
root@practice:~ # uptime 4:40PM up 17 secs, 1 user, load averages: 0.28, 0.06, 0.02 root@practice:~ # sysctl kern.securelevel kern.securelevel: -1 root@practice:~ # ls -lo /root/important.txt -rw-r--r-- 1 root wheel schg,uarch 15 Oct 6 16:40 /root/important.txt root@practice:~ # chflags noschg /root/important.txt root@practice:~ # rm -f /root/important.txt
After the reboot the level was back to -1, as the only way to lower it is to go through a (single-user) boot, and the flag had survived (schg was still on the file). We lifted it and deleted the file.
How you use this on a real server (from the documentation, not run here): put kern_securelevel_enable="YES" and kern_securelevel="1" (or 2) in /etc/rc.conf, and set schg on the files you never want changed, such as the kernel and the system programs (/boot/kernel, /sbin, /bin, /usr/sbin, /usr/bin and the like). Level 1 also protects /dev/mem and the loaded firewall's rules (at level 3, the rules cannot be changed). The catch: at level 1 and above you cannot load modules or change flags without a reboot, so load every module you need at boot (kld_list, loader.conf) before the level rises, and plan updates as "lower the level via single-user mode, patch, reboot". We tested only level 1.
Step 7: keep a record: auditing
/var/log tells you what programs chose to write. Auditing is the kernel's own record of security-relevant events, with the user and the address, hard for a program to forge.
root@practice:~ # sysrc auditd_enable=YES auditd_enable: NO -> YES root@practice:~ # service auditd start Starting auditd. root@practice:~ # grep -v '^#' /etc/security/audit_control | grep . dir:/var/audit dist:off flags:lo,aa minfree:5 naflags:lo,aa policy:cnt,argv filesz:2M expire-after:10M root@practice:~ # ls /var/audit 20261006164054.not_terminated current dist remote
- auditd_enable=YES and service auditd start: the audit daemon is running and writing to /var/audit.
- audit_control says what to record. flags:lo,aa: lo is login and logout events, aa is authentication and authorisation (the su records below came from it). naflags is the same for events with no user attached. expire-after:10M keeps at most 10 MB of old records, filesz:2M starts a new file every 2 MB, and minfree:5 stops if less than 5 percent of the disk is free.
- ls /var/audit shows the current file (a link named current) and the older one.
Now some real events: a successful key login, a refused password attempt, and an attempt as a user that does not exist:
laptop:~ $ ssh -i /home/liloland/freebsd-lab/id_lab -o IdentitiesOnly=yes -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p 12223 bsd@127.0.0.1 true; echo "exit $?" exit 0 laptop:~ $ ssh -o BatchMode=yes -o PubkeyAuthentication=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p 12223 bsd@127.0.0.1 true; echo "exit $?" bsd@127.0.0.1: Permission denied (publickey). exit 255 laptop:~ $ ssh -o BatchMode=yes -o PubkeyAuthentication=no -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o LogLevel=ERROR -p 12223 mallory@127.0.0.1 true; echo "exit $?" mallory@127.0.0.1: Permission denied (publickey). exit 255
root@practice:~ # auditreduce /var/audit/2026* | praudit -s | cut -c1-120 header,56,11,AUE_audit_startup,0,Tue Oct 6 16:40:54 2026, + 288 msec text,auditd::Audit startup return,success,0 trailer,56 header,96,11,AUE_openssh,0,Tue Oct 6 16:40:55 2026, + 107 msec subject_ex,bsd,bsd,bsd,bsd,bsd,28694,28694,54966,10.0.2.2 text,successful login bsd return,success,0 trailer,96 header,96,11,AUE_openssh,0,Tue Oct 6 16:41:06 2026, + 32 msec subject_ex,bsd,bsd,bsd,bsd,bsd,39446,39446,40312,10.0.2.2 text,successful login bsd return,success,0 trailer,96 header,97,11,AUE_su,0,Tue Oct 6 16:41:06 2026, + 166 msec [... more successful logins and su(1) records follow ...]
auditreduce reads the audit files (all of them: audit -s had rotated the log once) and praudit -s prints them in a readable short form. It shows AUE_openssh, successful login bsd from the address 10.0.2.2, and AUE_su, successful authentication: bsd became root with su. Every login, and every time anyone becomes root, with time and source. The first record is audit startup.
A caution we learned by testing: the two refused probes are not in the audit trail. They were turned away before any login was attempted (they never sent a password), so there was no authentication to audit. Those show up in /var/log/auth.log, as we saw in step 2. So the two records complement each other: auth.log shows who knocked, and the audit trail shows who got in and who became root. You can audit more (ex for failed or successful program runs, pc for process creation, and so on), at a cost in volume: from the documentation, not run here.
The result, in one script
A small script can summarise the state of the machine. Run it after every change, and from cron:
root@practice:~ # cat > /root/hardening-check.sh <<'EOF'
> #!/bin/sh
> echo "== sshd"
> sshd -T | grep -iE '^(permitrootlogin|passwordauthentication|maxauthtries|allowusers)'
> echo "== sysctl"
> sysctl security.bsd.see_other_uids security.bsd.unprivileged_read_msgbuf kern.coredump kern.securelevel
> echo "== listening sockets"
> sockstat -4l -6l | awk 'NR > 1 { print $2, $5 }'
> echo "== vulnerable packages"
> pkg audit -q | wc -l
> echo "== accounts with an empty password"
> awk -F: '($2 == "") { print $1 }' /etc/master.passwd | wc -l
> EOF
root@practice:~ # sh /root/hardening-check.sh
== sshd
maxauthtries 3
permitrootlogin no
passwordauthentication no
allowusers bsd
== sysctl
security.bsd.see_other_uids: 0
security.bsd.unprivileged_read_msgbuf: 0
kern.coredump: 0
kern.securelevel: -1
== listening sockets
nginx tcp4
nginx tcp4
sshd tcp6
sshd tcp4
== vulnerable packages
0
== accounts with an empty password
0
The output is the whole story: SSH allows only keys for one named user, 3 tries; users cannot see each other's processes or read dmesg; no core files; securelevel is at its normal -1 (we had not made it permanent); 4 listening sockets, each explained; 0 vulnerable packages after the nginx upgrade (in our first October test one package was still open, with no fix released yet); and 0 empty passwords. A baseline does not mean everything is solved: it means you know what is open and why, and the vulnerability list changes every few days.
What we did not do
All from the documentation, not run here:
- MAC (Mandatory Access Control) frameworks: mac_bsdextended (firewall-like rules for the file system, set with ugidfw), mac_portacl (let an unprivileged user bind a low port) and others.
- Capsicum: a way for programs to give up powers they do not need. Many base programs use it.
- Disk encryption: GELI (geli) and ZFS native encryption, for the case where someone walks off with the disk.
- Stopping brute-force guessing: sshguard and blacklistd, which add misbehaving addresses to the pf or ipfw tables from the firewall pages.
- Password and login policy: /etc/login.conf (limits, password aging) and PAM.
- Time, mail and other services: ntpd settings and the mail system, if you run them.
- The base system's own checks: the daily security report from periodic, which checks for changed set-user-ID files and more.
⚠️ Harden in small steps, with a way back. Take a boot environment before you start (the upgrade page), change one thing at a time, test each change from a second connection, and keep the console reachable. The quickest way to turn a hardening session into a self-inflicted outage is to change SSH, the firewall and securelevel all at once on a remote machine.
It said: "I can go up, but I never go back down<br>
without a reboot and a good explanation."
🧪 Try it yourself
On a FreeBSD virtual machine (never first on a production server):
1. Run pkg audit -F. Read the advisory of the first finding. Does it apply to how you use the program? 2. Use sshd -T to see your effective SSH settings. Test what methods the server offers, with ssh -o BatchMode=yes -o PubkeyAuthentication=no .... 3. Make sure a key works, then switch off password logins and root, add AllowUsers, check with sshd -t, reload, and test from a second connection. 4. As an ordinary user, count the processes you can see and try dmesg. Then set the security.bsd switches and compare. 5. List listening sockets with sockstat -4l -6l. For each line, say why it is there. Switch one off. 6. Make a file with chflags schg, try to delete it as root, then lift the flag. 7. Look at kern.securelevel. (Only raise it on a machine you can reboot, and note that it cannot be lowered while running.) 8. Enable auditd, log in and use su, and read the records with auditreduce and praudit -s. 9. Write a small script that prints your baseline, and run it again after each change.
The cheat sheet
- Vulnerabilities: pkg audit -F (fetch and check), pkg audit -q, pkg -j NAME audit -F for a jail, pkg upgrade, freebsd-update fetch install. Read the advisory, then decide.
- SSH: sshd -T (effective settings), sshd -t (syntax check), service sshd reload. PasswordAuthentication no, KbdInteractiveAuthentication no, PermitRootLogin no, MaxAuthTries 3, AllowUsers NAME. First value wins. Test from a second connection.
- Information leaks: security.bsd.see_other_uids=0, see_other_gids=0, unprivileged_read_msgbuf=0, unprivileged_proc_debug=0. Also kern.randompid=1, kern.coredump=0. Persist in /etc/sysctl.conf, apply with service sysctl restart.
- Exposure: sockstat -4l -6l, service -e. syslogd_flags="-ss".
- Accounts: check shells and empty passwords in /etc/passwd and /etc/master.passwd; nologin for service accounts; wheel for su.
- Flags and levels: chflags schg|noschg FILE, ls -lo, sysctl kern.securelevel, kern_securelevel_enable="YES" and kern_securelevel="1" in rc.conf. A level can only be raised while running.
- Audit: auditd_enable="YES", /etc/security/audit_control (flags:lo,aa), audit -s (rotate), auditreduce FILES | praudit -s. Pair it with /var/log/auth.log.
← The Boot Loader and Kernel Tuning: loader.conf, sysctl and Single-User Mode · ↑ FreeBSD Corner · Containers on FreeBSD: Podman, OCI Images and Jails →