Home / Blog

Safe sudo: let a user manage one service without giving away root

Ismail Ait ZaidBy Ismail Ait Zaid··7 min read

Karim, from the web team, needs to restart Apache and read its logs. Give him the root password? No. Add him to the wheel group? He'd be root on the whole server. The right answer is a precise sudoers rule. You still have to write it without leaving a door open: here are the traps I show in my courses, and the rule I use in production.

Every example below was tested with sudo 1.9.15, the version shipped with RHEL 10.

The starting point

Create a dedicated file, always with visudo, so a typo can't break sudo:

sudo visudo -f /etc/sudoers.d/audit-web
# WHO    WHERE  AS WHOM   WHAT
karim    ALL=(root)       /usr/bin/journalctl -u httpd

Karim can run sudo journalctl -u httpd. But as soon as he types sudo journalctl -u httpd --since today, sudo refuses. Why?

Rule #1: arguments in the rule mean an exact match

When a rule lists arguments, the command must repeat them exactly, no more, no less.

RuleWhat Karim can run
/usr/bin/journalctl -u httpdonly journalctl -u httpd
/usr/bin/journalctl ""only journalctl, with no arguments at all
/usr/bin/journalctlany arguments: -u sshd, -u httpd -u sshd…
Trap #1: a command with no arguments in the rule allows all arguments. /usr/bin/journalctl alone gives access to every log on the system.

The * trap

The natural reflex to allow "more options" is to add a wildcard:

karim  ALL=(root)  /usr/bin/systemctl * httpd

It looks safe: only commands ending with httpd go through. Here is what sudo actually allows:

CommandResult
systemctl restart httpd✅ allowed
systemctl edit httpd⚠️ allowed: opens an editor as root
systemctl stop sshd httpd⚠️ allowed: also stops SSH
systemctl status httpd⚠️ allowed: opens less as root

In sudoers, * matches any sequence of characters, spaces included. It doesn't check the action, only the end of the line. Same for logs: with /usr/bin/journalctl -u httpd *, Karim can type -u httpd -u sshd and read the SSH logs. And the plain form, journalctl -u httpd, is no longer allowed: the wildcard expects something after it.

The right solution: a regular expression

Since sudo 1.9.10, arguments that start with ^ and end with $ are treated as a regular expression. You can finally say "exactly these actions, on exactly this service":

# /etc/sudoers.d/audit-web
Cmnd_Alias HTTPD_SVC  = /usr/bin/systemctl ^(start|stop|restart|reload|enable|disable|is-active|is-enabled) httpd([.]service)?$, \
                        /usr/bin/systemctl ^status httpd([.]service)? --no-pager$

Cmnd_Alias HTTPD_LOGS = /usr/bin/journalctl ^-u httpd([.]service)?( --since (today|yesterday))?( --no-pager)?$

karim  ALL=(root)  NOEXEC: HTTPD_SVC, HTTPD_LOGS

Result, tested command by command:

CommandResult
systemctl restart httpd / stop httpd.service✅ allowed
systemctl status httpd --no-pager✅ allowed
journalctl -u httpd --since today✅ allowed
systemctl edit httpd❌ denied
systemctl stop sshd httpd❌ denied
journalctl -u httpd -u sshd❌ denied
On RHEL 9, sudo is version 1.9.5 and doesn't support regex. List each allowed command one by one instead, without *. It's longer, but just as safe.

The hidden trap: the pager

journalctl and systemctl status show their output in less. Started by sudo, less runs as root. And less can run commands: type !bash and you get a shell… as root.

karim → sudo journalctl -u httpd → less (root) → !bash → root shell

Recent systemd versions start the pager in "secure" mode under sudo, which blocks this escape. But you don't build security on a default behaviour. Two protections, in the rule itself:

  • NOEXEC: stops the allowed program from launching another program (pager, shell, editor);
  • --no-pager required by the regex: output goes straight to the terminal, without less.

The same logic applies to vi, less, find, awk, tar and many others: they can all open a shell. The GTFOBins site lists them. To edit a system file, allow sudoedit rather than an editor.

Several rules? The last one wins

When several lines match the same command, sudo applies the last one. These two files give opposite results:

# Karim CANNOT stop httpd: the second line wins
karim  ALL=(root)  /usr/bin/systemctl ^stop httpd$
karim  ALL=(root)  !/usr/bin/systemctl

# Karim CAN stop httpd
karim  ALL=(root)  !/usr/bin/systemctl
karim  ALL=(root)  /usr/bin/systemctl ^stop httpd$

Files in /etc/sudoers.d/ are read in alphabetical order: a zz-… file can override an audit-… file.

Test before you ship

No need to log in as Karim to check a rule:

# 1. Syntax
sudo visudo -cf /etc/sudoers.d/audit-web

# 2. Everything Karim can do
sudo -l -U karim

# 3. One specific command: prints its path if allowed, nothing otherwise
sudo -l -U karim /usr/bin/systemctl edit httpd

# 4. Denied attempts
sudo journalctl _COMM=sudo --since today | grep karim

In short

  • A command without arguments in the rule allows all arguments.
  • * only checks the overall shape of the line and matches spaces: avoid it.
  • Prefer a ^…$ regex (sudo ≥ 1.9.10, RHEL 10) or an exact list (RHEL 9).
  • Add NOEXEC: and --no-pager to close the pager door.
  • Never allow systemctl edit, an editor or a pager as root.
  • Test with sudo -l -U before you ship.

Hardening your RHEL servers?

I audit your sudo rules and access, or train your Linux teams on security best practices.

Contact me ← All articles

Need help with this?

Newsletter

One Cloud & DevOps tip a month

OpenShift, Kubernetes, Terraform, security: one practical tip from the field, straight to your inbox. No spam.