← Guides

A sudo wildcard is a root shell with extra steps

NOPASSWD:/some/dir/* looks like a scoped grant. It reads like "this user can run these specific tools." It isn't, and it doesn't.

Serhii Drobot · · 7 min read

The line

This one is from our own production server. Not a lab, not an example - the machine that serves this company:

/etc/sudoers.d/hestiaweb:
  hestiaweb  ALL=NOPASSWD:/usr/local/hestia/bin/*

A control panel installed it. It says: the user hestiaweb may run anything in /usr/local/hestia/bin/ as root, without being asked for a password.

That directory holds around two hundred shell scripts. So the grant isn't "these tools" - it's "two hundred programs, as root, no password, and whatever gets added to that directory tomorrow."

Why the wildcard doesn't scope anything

The trouble comes in three layers, and each one is worse than the last.

The directory is a moving target

You didn't grant access to a reviewed set of programs. You granted access to a path. Every package update that drops a new script in there extends the grant, silently. Nobody re-reviews sudoers after apt upgrade.

The scripts take arguments

sudo /usr/local/hestia/bin/v-something --arg "$UNTRUSTED" runs as root. Everything that script does with that argument, it does as root. If any one of two hundred scripts passes an argument into a shell, or writes to a path the caller controls, or reads a config file the caller can create - that's root, and the sudoers line permitted it by design.

You are not auditing one sudo rule. You are auditing two hundred programs, plus every argument they accept, forever. Nobody does that.

The user is the first thing an attacker gets

This is the part people miss. hestiaweb isn't a human. It's the account the panel's web interface runs under. Which means the path is:

  • Someone finds a bug in a site the panel serves, or in the panel itself
  • They're now hestiaweb - normally a fairly boring outcome
  • Except hestiaweb has passwordless sudo into two hundred root scripts

The line collapses "attacker got a web shell" into "attacker got the box." That's the same shape as mounting docker.sock into a container: a boundary that exists everywhere else in your stack, deleted in one line of config, by a vendor, for convenience.

A note on what this guide won't do. There is no proof-of-concept here. The point isn't which script in which panel version is exploitable this month - it's that the rule makes the question unanswerable. Argument-handling bugs in root-executed scripts are a class, not an event.

Find yours

Every NOPASSWD line on the box, including the drop-in directory people forget:

sudo grep -r "NOPASSWD" /etc/sudoers /etc/sudoers.d/

Read every result and ask two questions: does it contain a wildcard, and does the user belong to a service that faces the internet? Both yes is the one that matters.

Or let the scanner do it, along with the rest of the host's exposure in the same read-only pass:

sudo ./owlzops-mapper audit

NOPASSWD entries surface under CIS 5.4.2, and blanket NOPASSWD: ALL grants under CIS 5.3. Free, single binary, nothing leaves the server - source here.

One thing worth knowing: commented-out lines don't count. Cloud images ship # root ALL=(ALL) NOPASSWD:ALL in /etc/sudoers.d/90-cloud-init-users. A naive grep flags it and you spend an hour on nothing.

Why you probably can't just delete the line

Here's the advice you'll find everywhere: remove the wildcard, scope each rule to a specific command. Correct. Now watch what happens when you do it on a panel-managed box.

Our own crontab, on the same server:

*/2 * * * * sudo /usr/local/hestia/bin/v-update-sys-queue restart
 */5 * * * * sudo /usr/local/hestia/bin/v-update-sys-queue backup
 20 4 * * *  sudo /usr/local/hestia/bin/v-update-letsencrypt-ssl
 41 4 * * *  sudo /usr/local/hestia/bin/v-update-sys-hestia-all
                 …and about twenty more

All of it runs as hestiaweb, through that sudoers line, every two minutes. Delete the line and the panel's queue stops, certificates stop renewing, backups stop running. You will find out about it later, from a customer.

So "remove the wildcard" isn't a plan on this class of machine. This is a structural property of the software you chose. The honest options are narrower:

What to actually do

1. Enumerate and scope - where you can

List what actually gets called. Cron tells you most of it. Replace the wildcard with the specific commands that appear, and keep the list in version control so the next upgrade doesn't silently widen it again:

# instead of:  hestiaweb ALL=NOPASSWD:/usr/local/hestia/bin/*
hestiaweb ALL=NOPASSWD:/usr/local/hestia/bin/v-update-sys-queue
hestiaweb ALL=NOPASSWD:/usr/local/hestia/bin/v-update-letsencrypt-ssl
hestiaweb ALL=NOPASSWD:/usr/local/hestia/bin/v-backup-users

Test this on staging, not on the box your customers are on. And know the trade: you've cut two hundred programs down to three. You haven't fixed argument handling inside those three - you've made the audit finite instead of impossible. That's still a large win.

2. Don't fight the vendor if it fights back

Some panels regenerate /etc/sudoers.d/ on update and put the wildcard straight back. If yours does, scoping is a treadmill: you'll re-fix it every release until you stop noticing. Skip to the next step rather than pretending.

3. When you can't remove it, make it loud

That's what a compensating control is: you accept a risk you can't eliminate, and you make sure it can't happen quietly. This is what's running on our server right now:

# /etc/audit/rules.d/hardening.rules
-w /usr/local/hestia/bin -p x -k hestia_sudo
-w /etc/sudoers.d -p wa -k sudoers_change
-a always,exit -F arch=b64 -S execve -F euid=0 -F auid>=1000 -F auid!=-1 -k root_exec

Three rules, three questions answered:

  • Line 1 - every execution out of that directory is logged. Normal panel cron is a steady, boring rhythm. Anything else stands out.
  • Line 2 - every write to /etc/sudoers.d/ is logged. If an attacker widens their own grant, you have the record.
  • Line 3 - every time an ordinary user's session ends up executing as root, it's logged. That's the escalation itself, whichever path it took.

Two things matter about this. First, the default auditd ruleset is worthless here - it's volume, not signal, and it trains you to ignore the logs. Write rules for the risk you actually have. Second, a log nobody reads is not a control. Ship it somewhere off-box and alert on root_exec, or you've built theatre.

The short version

  • A wildcard in a sudoers rule grants a directory, not a program - and the directory grows.
  • If the user is a web service account, the rule turns any web bug into host root.
  • Control panels ship these because their own cron depends on them. Deleting the line breaks the panel.
  • Scope to specific commands where the vendor lets you. Test on staging.
  • Where you can't, log every execution out of that path and every sudoers change - and read the logs.

We finished our own server at 13/100, not 0, and this rule is part of what's left. That's the honest outcome. The full write-up is here - what we closed, what we didn't, and why.

Want to know which of yours are real?

Most sudo findings are noise; one or two are a straight line from a web bug to root. Telling them apart is the job. An Infrastructure Security Audit is three days, read-only, $2,995 - every exposure ranked by what an attacker reaches first, with the remediation order and the compensating controls for what can't move. Run the free scanner first - if it comes back clean, we'll tell you so rather than sell you an audit.