🛡️ 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

Video: Hardening FreeBSD (FreeBSD video)
Prefer to watch? This short video runs this page's main commands on a real FreeBSD 15.0 machine, with on-screen captions and no voiceover. Click to play, or watch it on YouTube.

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

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:

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.

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]

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
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

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

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
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

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:

⚠️ 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.

Why did the securelevel refuse the sysadmin's request?<br>
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

← The Boot Loader and Kernel Tuning: loader.conf, sysctl and Single-User Mode · ↑ FreeBSD Corner · Containers on FreeBSD: Podman, OCI Images and Jails →