A control panel on a server nearly always runs into the same wall: the web process needs to do something it has no rights for. Ban an address, open a port, read the journal. The obvious answer is to grant one narrow line in sudoers, and nothing besides it:
www-data ALL=(root) NOPASSWD: /usr/bin/fail2ban-client set * banip *
The line reads as "bans only". What it actually means is "anything at all, as root". Below is why that happens, how to check it on your own server in five minutes, and what such rules are replaced with.
What * really does
The key detail: sudo matches the command line as a whole rather than argument by argument, and * in the pattern passes happily across spaces — that is, across argument boundaries. The literal banip sitting in the middle of the rule therefore restricts nothing: it is enough for the word banip to appear somewhere in the command, and anything at all may be passed before and after it.
What you look for next is any program that can be handed a command to run. In our case it was right there in the rule. In fail2ban the ban action is defined as text, and that text can be redefined on the fly:
fail2ban-client get portscan actions
sudo fail2ban-client set portscan action nftables-multiport actionban "<command>" banip 192.0.2.10
sudo fail2ban-client set portscan banip 192.0.2.11
The first call replaces the action text of a real action, the second makes the jail fire — and fail2ban, running as root, executes whatever was substituted. We did this on our own server on 4 July: a file in /root was created as root, meaning the web process gained full rights on the machine out of a rule that looked narrow.
Three details came up along the way that other write-ups usually leave out:
- the
-cflag beforesetis cut off by sudo: in the patternsetis nailed directly to the binary name, and nothing can be inserted before it; addactionandactionwithout the wordbanipdo not pass either — but everything needed fits into a singleset … banip …command, so the pattern is satisfied;- the action name has to be a real one, otherwise there is nothing to replace:
fail2ban-client get <jail> actionsshows it.
What else sits on that same list
fail2ban is neither at fault here nor unique. The dangerous thing is the combination of NOPASSWD with a wildcard. What we found next to it on our own servers:
journalctl *— the journal opens through a pager, and a shell can be launched from the pager. The rule looks like it is about reading logs; in practice it is a root shell. The cure is not a narrower pattern but thesystemd-journalgroup: thenjournalctlworks without sudo at all;grep * /var/log/fail2ban.log— the first argument togrepis the pattern, but the wildcard allows a second path to be supplied as well, and grep runs as root. Reading/etc/shadowthrough that rule is one command. The replacement is the same: theadmgroup for reading logs;ufw --force *and bareufw allow/deny/delete *— the web process can switch the firewall off entirely. What makes it worse is that in both cases the panel never used those rules at all: dead grants left behind by early versions of the installer;lynis-scan.sh *— a wrapper that forwards"$@"into a root process is equivalent to a rule with no restrictions.
How to see what you have
What to look at is not the file but the effective rights of one specific user — the one the PHP-FPM pool actually runs as (not always www-data; on one of our machines the panel ran as admin):
ps -o user= -C php-fpm | sort -u
sudo -l -U www-data
sudo -l -U admin
Then the file list itself, and here there are two traps that cost us time:
sudo grep -rn 'NOPASSWD' /etc/sudoers /etc/sudoers.d/
ls -la /etc/sudoers.d/
The first: rules live in two places at once. Ours had the dangerous line both in /etc/sudoers.d/monitor and in the main /etc/sudoers — in the main file in roughly eight copies, accumulated by different versions of the installer. Cleaning one place and calling it done is the usual way of leaving the hole open.
The second: sudo ignores files with a dot in the name. A file called www-data.bak3 in /etc/sudoers.d/ looks like a live rule and reads like a live rule, but has no effect. This cuts both ways: "the rule is there but the rights are not", and the false comfort of a config copy that "is lying right next to it".
What to replace it with
Narrowing the pattern is pointless — a wildcard anywhere in the line puts you back at the start. There is one approach that works: a root wrapper that takes fixed positional arguments, validates them itself, and calls the program with no way to append anything.
#!/bin/sh
# /usr/local/sbin/arciveo-f2b — ban|unban <jail> <ip>, and nothing else
set -eu
action="$1"; jail="$2"; ip="$3"
case "$action" in ban|unban) ;; *) echo "invalid action"; exit 1 ;; esac
case "$jail" in *[!a-zA-Z0-9_-]*|'') echo "invalid jail"; exit 1 ;; esac
printf '%s' "$ip" | grep -Eq '^[0-9a-fA-F:.]+$' || { echo "invalid ip"; exit 1; }
case "$action" in
ban) exec /usr/bin/fail2ban-client set "$jail" banip "$ip" ;;
unban) exec /usr/bin/fail2ban-client set "$jail" unbanip "$ip" ;;
esac
The wrapper is owned by root, mode 755, and — this part is not optional — it must not be writable from the web, or the whole construction is meaningless. Only the wrapper stays in sudoers:
www-data ALL=(root) NOPASSWD: /usr/local/sbin/arciveo-f2b
Arguments are not listed in the rule: the script validates them, and the positions inside it are fixed, so there is nowhere to slip an action text in. Read-only rules — statuses, ss, ipset list — go on separate lines, also without a wildcard wherever that is possible.
Separately, about replacing sudo with groups. The approach is right: systemd-journal for the journal and adm for logs are cheaper and safer than any sudo rule. But you cannot pull the web user out of groups blindly. We removed www-data from one group on a panel host and got 403 on every site at once: Apache there runs as www-data, the site files belong to a different user, and read access came precisely from that group membership. Recovery took a full restart — not a reload — of Apache and PHP-FPM, because old workers keep their former set of groups and produce "it works, then it 403s".
Checking after the change
Before replacing a sudoers file, check its syntax — otherwise you can end up with no sudo at all:
sudo visudo -cf /etc/sudoers.d/monitor
Afterwards, confirm that the old vector is dead, and do it as that same user:
sudo -k
sudo -u www-data sudo -n fail2ban-client set portscan banip 192.0.2.10
The answer should be a password is required, while calling the wrapper with rubbish instead of an address should give invalid ip. There is a trap of its own here: sudo -v caches the credential for fifteen minutes, and after it any sudo -n check passes "successfully". That is exactly how we once confirmed a rule that was not on the server at all. Hence sudo -k before the check is mandatory.
The point of watching
Sudo rules change rarely, but they change quietly: the installer appends to them, the hosting panel adds its own, a removed package leaves its lines behind. A one-off review closes what exists today and says nothing about what the next update brings. The practical conclusion is simple: the list of who can become root belongs in plain sight alongside the rest of the server's state, rather than being remembered after an incident. What that looks like assembled is on the demo pages below.