- Mon 18 May 2026
- 14 min read
- FreeBSD
- #freebsd, #security, #mac, #mac_do, #privilege-delegation, #sudo, #doas, #hardening, #sysctl
Table of Contents
Almost every FreeBSD install I touch grows a security/sudo or security/doas package within the first ten minutes. It is reflex at this point. The pattern is: install base, create an admin user, pkg install sudo, drop a file in sudoers.d, move on.
Modern FreeBSD quietly makes that habit unnecessary. mdo(1) and its backing policy module mac_do(4) are in the base system. There is no package to install, no extra repository to trust, no second parser to keep current. The whole thing is one kernel module, one sysctl with a rule string, and a tiny userland command. On the hosts I have migrated, it has replaced sudo entirely.
mdo(1) first appeared in FreeBSD 14.2; the group-related and fine-grained credential controls used below are FreeBSD 15.0-era functionality. If you are on 14.2 or 14.3, check the local man pages first: basic mdo use exists there, but several of the 15.0 rule constructs shown below may not parse.
It is also surprisingly little-known. mac_do shipped with the MAC framework rework, but most write-ups about FreeBSD privilege escalation still assume you reach for sudo. This article is the walkthrough I wish I had read before flipping over the first box: how to enable it, how the rule language actually works, the patterns I use day to day, and a short closing detour into the hardening sysctls that sit next to the mac_do rule on my systems.
Everything below is on FreeBSD 15.0. If you are on an older release, check MAC_DO(4) first, because the rule grammar tightened up between versions.
What mdo Actually Is
mdo is, on the surface, very boring. You run it, the kernel checks the active mac_do ruleset for whether your UID is allowed to become the requested target, and if it is, your process gets its credentials swapped in place. There is no setuid binary in the middle, no separate daemon, no PAM stack, no per-command policy file. The decision lives in the kernel.
Compared to sudo, the practical differences are:
- No
sudoersparser. Rules are a single sysctl string. - No setuid root binary on disk to audit, patch, or worry about.
mdois not setuid; the credential swap is a kernel operation gated by the MAC framework. - No per-command allowlist.
mac_dois about which identities can be assumed by which users, not which programs they may run. If you want command-level control, you still wantsudo. - No password prompt by default. Authentication is identity-based: your UID is on the source side of a rule or it is not. On a workstation that is jarring; on a server with no interactive logins except via SSH keys, it is correct.
That last bullet is the most important one to internalise. mdo is not sudo with a different parser. It is a different model. It assumes the gate is at the SSH layer, not at the shell.
A first run looks exactly like the user described it:
chofstede@nethost:~ $ mdo
root@nethost:~ #
No password, no banner, no log line in /var/log/messages or /var/log/security.
A consequence worth stating plainly: treat every account allowed by mac_do as effectively privileged. If an attacker gets that user’s SSH key or shell, mdo will not provide a second authentication barrier. The trade is intentional, but it has to be reflected in how you manage the source-side accounts (key hygiene, no shared logins, no interactive password fallback on SSH).
Enabling mac_do
Two pieces have to be in place: the kernel module loaded at boot, and a rule string set via sysctl. Neither survives a reboot unless you put it in the right config file, so do both at once.
Loader configuration
On the host above, /boot/loader.conf looks like this. The relevant line is mac_do_load="YES":
aesni_load="YES"
cc_htcp_load="YES"
crypto_load="YES"
cryptodev_load="YES"
virtio_random_load="YES"
mac_do_load="YES" # <---- HERE
kern.geom.label.disk_ident.enable="0"
kern.geom.label.gptid.enable="0"
zfs_load="YES"
# Jail accounting
kern.racct.enable=1
# Disable uart
hint.uart.0.disabled="1"
hint.uart.1.disabled="1"
security.bsd.allow_destructive_dtrace=0
A few things worth flagging in passing, because they are habits I keep across every box:
aesni_load,cryptodev_load, andcrypto_loadgive you accelerated AES and the in-kernel crypto framework. Useful forgeli, IPsec, and anything that pulls/dev/crypto.cc_htcp_loadplus the matchingnet.inet.tcp.cc.algorithm=htcpfurther down switches the default TCP congestion control to H-TCP, which behaves better than NewReno on long fat pipes. Not required, but it is on every server I run.virtio_random_loadmatters on VMs: it exposes the host’s RNG to the guest so the kernel does not have to wait on entropy at boot.kern.racct.enable=1turns on resource accounting, which is whatrctland per-jail accounting depend on. Worth doing on any jail host, even if you do not yet enforce limits.security.bsd.allow_destructive_dtrace=0keeps DTrace strictly observational. The default already nudges this way; I make it explicit so a future me cannot footgun a production box withdtrace -w.
Once mac_do_load="YES" is in place, a kldstat after reboot confirms the module is in:
root@nethost:~ # kldstat
Id Refs Address Size Name
1 56 0xffffffff80200000 1f4dad0 kernel
2 1 0xffffffff8214e000 4120 virtio_random.ko
3 1 0xffffffff82153000 3618 cc_htcp.ko
4 1 0xffffffff82157000 620c10 zfs.ko
5 1 0xffffffff82778000 8808 cryptodev.ko
6 1 0xffffffff82783000 88c8 mac_do.ko
[...]
If you do not want to reboot to test, kldload mac_do will load it live and you can immediately set a rule via sysctl. Just remember to put mac_do_load="YES" in loader.conf afterwards or the next reboot will quietly disarm the whole thing.
Rule configuration
Rules live under security.mac.do.rules. On the box this article is built around, the relevant chunk of /etc/sysctl.conf is:
# mac_do configuration
security.mac.do.rules="uid=1001>uid=0,gid=*,+gid=*"
That one line is the entire policy. Let us pick it apart.
The Rule Language
Each rule has two halves separated by >: the from side, and the to side. The from side matches the calling process’s credentials. The to side describes what the caller is allowed to ask for.
The example above reads roughly as:
A process with UID 1001 may become UID 0, with any primary GID, and may keep or add any supplementary groups.
Broken out:
uid=1001is the from clause. Only my admin user matches.uid=0is the requested target UID. Becoming root.gid=*allows any primary group ID on the target side. Without an explicit target-sidegid=clause,mac_dofalls back to its default group-preservation rules, which is usually not what you want for a root-style transition.+gid=*allows any supplementary groups on the target side. Together withgid=*, this lifts all group-side constraints from the transition, so the new credentials can hold any group set the caller asks for. That is what you usually want when becoming root.
Multiple rules can be concatenated with ;. A more layered policy might look like:
security.mac.do.rules="uid=1001>uid=0,gid=*,+gid=*;gid=wheel>uid=0,gid=*,+gid=*"
That allows my UID 1001 personally, and additionally any member of wheel, to escalate to root. On boxes with multiple admins this is more maintainable than listing UIDs.
You can also constrain the target much more tightly. To allow a deploy user to act as the application user app and nothing else:
security.mac.do.rules="uid=2001>uid=1500,gid=1500,+gid=1500"
Here 2001 is the deploy account, 1500 is the application’s UID and GID, and no wildcards are present, so the deploy user can become app and only app.
The full grammar is in MAC_DO(4). It is worth reading once cover to cover; the man page is genuinely good and includes the corner cases around supplementary groups, the meaning of bare gid= versus +gid=, and how multiple rules compose. The summary I keep in my head:
- From side:
uid=and/orgid=identifies who can ask. - To side:
uid=,gid=, and optional+gid=describes what they may become. *means any.+on the to side means “may gain”, as opposed to “must match exactly”.- Rules are effectively ORed together: if any rule authorises the requested credential transition, it is allowed. There is no allow/deny ordering to worry about, just a union of permissions.
Persisting versus live
/etc/sysctl.conf applies at boot. To change the policy without rebooting:
sysctl security.mac.do.rules="uid=1001>uid=0,gid=*,+gid=*"
The kernel parses the string immediately, and any failure to parse is reported back as an EINVAL from the sysctl call, with no partial state left behind. That makes it safe to iterate: a syntactically broken rule string is simply rejected, and the previous ruleset stays in effect.
To confirm what the kernel actually has loaded:
sysctl security.mac.do.rules
Useful as a sanity check after editing, before you reach for mdo and discover it does nothing.
mac_do rules are also jail-aware; security.mac.do.rules is a jail-specific sysctl, so a rule set in the host is not automatically the same as the rule visible inside a jail. If you plan to use mdo inside jails, read the jail support section of mac_do(4) rather than assuming the host rule applies everywhere.
Day-to-Day Use
On the hosts I have flipped over, mdo covers every case I used to use sudo for, with two patterns:
# Open a root shell
mdo
# Run a single command as root
mdo pkg upgrade
By default, mdo behaves as if -u root had been specified, so mdo pkg upgrade requests a transition to UID 0. Other targets are available with -u, -g, -G, and the related flags described in mdo(1), but the transition itself still has to be authorised by the active mac_do ruleset. In other words, the ruleset authorises requested credentials; mdo is not a sudoers-style command policy and does not pick a target on its own.
Behaviourally:
- The new shell inherits the environment of the caller. That is closer to
sudo -Ethan tosudo -i. If you want a clean login environment, runmdo -i(and yes, the flag exists; checkmdo(1)). PATHis preserved unless you do something about it. On a server this is fine; on anything where the caller’sPATHis not trusted, you would want a wrapper that resets it.- There is no TTY ticket cache. Every invocation is independent. Annoying in interactive use, excellent for scripts.
For Ansible-style automation, mdo is a become method by way of become_method: su with a tweaked command, or you can write a tiny custom become plugin. I have been using a wrapper script that just execs mdo, which works well enough for my fleet. A proper Ansible plugin is on the list, but the lack of an interactive password prompt means existing methods already cover most cases.
Where mdo Is Not the Right Tool
To be clear about the trade-offs:
- If you need per-command policy (“alice may run
service nginx restartand nothing else”),mac_docannot express that. Stick withsudoor use OS-level packaging (a small setuid wrapper for the one operation, audited carefully). - If you need interactive password authentication,
mdodoes not give it to you. That is a deliberate model choice; the gate is meant to be at SSH. - If you need extensive logging of what was run,
sudo‘s session logging is still ahead.mac_dologs the credential transition to the kernel log, but not the command line. Pair it withauditdand a sensibleaudit_controlpolicy if you need command auditing.
For multi-tenant systems, mixed-trust environments, or anything where you need fine-grained command-level policy and rotating credentials, sudo is still the right answer. For a single-admin server with SSH-key access and a clean separation between an admin account and root, mdo is hard to argue with.
Optional: The Hardening Block Around It
The mac_do rule lives in /etc/sysctl.conf alongside a handful of other settings I treat as “always on”. They are not strictly about mdo, but they are the surrounding context of a hardened FreeBSD host, and they are cheap. The full file on this host is:
security.bsd.see_other_uids=0
security.bsd.see_other_gids=0
security.bsd.see_jail_proc=0
security.bsd.unprivileged_read_msgbuf=0
security.bsd.unprivileged_proc_debug=0
kern.randompid=1
vfs.zfs.vdev.min_auto_ashift=12
kern.elf64.aslr.enable=1
kern.elf32.aslr.enable=1
net.inet.tcp.blackhole=2
net.inet.udp.blackhole=1
net.inet.icmp.drop_redirect=1
net.inet.tcp.drop_synfin=1
net.inet.tcp.cc.algorithm=htcp
net.link.bridge.pfil_member=1
net.link.bridge.pfil_bridge=0
net.link.bridge.pfil_onlyip=1
# mac_do configuration
security.mac.do.rules="uid=1001>uid=0,gid=*,+gid=*"
Quickly, line by line, because each of these has saved me from at least one bad day:
security.bsd.see_other_uids=0andsee_other_gids=0hide processes owned by other users fromps,top, and friends. On a shared host or jail host this is the single highest-value privacy toggle.see_jail_proc=0hides jailed processes from the host listing for non-root callers. Useful when you have a “support” user on the host that should not be able to enumerate tenant workloads.unprivileged_read_msgbuf=0keepsdmesgout of unprivileged hands. The kernel ring buffer leaks more than you would think, especially on boot.unprivileged_proc_debug=0blocks unprivileged use ofptraceand friends. This is the same idea as Linux’s Yamaptrace_scope=1, and it stops one whole class of credential-stealing primitive.kern.randompid=1randomises PID allocation. Cheap, makes a small but real class of timing attacks harder.kern.elf64.aslr.enable=1andkern.elf32.aslr.enable=1turn on ASLR for ELF binaries. On FreeBSD 15 the defaults are already friendly, but I set them explicitly so they cannot regress under me.net.inet.tcp.blackhole=2andudp.blackhole=1make the host stop sending RST/ICMP-unreachable on closed ports. Scanners get silence instead of confirmation. Easy win against opportunistic scans.net.inet.icmp.drop_redirect=1drops ICMP redirect packets, which an attacker on the local segment could otherwise use to repoint your routes.net.inet.tcp.drop_synfin=1drops malformedSYN|FINpackets, an old fingerprinting and evasion trick.net.link.bridge.pfil_*causespfto filter traffic on bridge member interfaces, which is what you want as soon as you put jails on a bridge.
kern.securelevel is the one knob from this family that is not in this file, deliberately. On a server that I still want to be able to maintain remotely, I keep securelevel at the default. On appliance-style boxes (DNS resolvers, a small router) I bump it via /etc/rc.conf:
kern_securelevel_enable="YES"
kern_securelevel="2"
At level 2, no filesystem may be mounted “less secure”, schg flags become immutable, and the system clock can only move forward. It is an excellent lockdown for boxes you do not log into often, and a foot-cannon for everything else.
Wrap
mdo is small. The userland command is a thin wrapper, the policy is a single sysctl string, and the kernel module is unobtrusive. That smallness is exactly why it has displaced sudo on most of my fleet: there is less to configure, less to keep up to date, and less to get wrong.
The trade-off is real. mac_do is identity-based and intentionally has no per-command policy or interactive auth. If your model needs those, keep sudo. If your model already treats SSH as the gate and root as a credential the admin account is allowed to assume, mdo is a near-perfect fit and one fewer port to track.
MAC_DO(4) is the resource to read next; the rule grammar has more depth than the examples above show, particularly around gid= semantics and how multiple rules compose. Set up a test rule, kick it with sysctl, and see how it behaves before committing it to sysctl.conf. The kernel will reject anything malformed without applying it, so iteration is genuinely safe.
Comments
You can use your Mastodon or other ActivityPub account to comment on this article by replying to the associated post.
Search for the copied link on your Mastodon instance to reply.
Loading comments...