SELinux: passwd Denied on /dev/ptmx When Run Over SSH Without a TTY¶
The Problem¶
You run an automated credential rotation — or any script that shells out to passwd over a non-interactive SSH connection — and your SELinux alerting lights up:
avc: denied { read write } for comm="passwd" path="/dev/ptmx"
dev="devtmpfs" scontext=system_u:system_r:passwd_t:s0
tcontext=system_u:object_r:ptmx_t:s0 tclass=chr_file permissive=0
The denial arrives in a burst — often several identical records within the same second, on several hosts at once — which reads alarmingly like something probing your fleet. It is almost always the opposite: your own automation, denied by stock policy.
Why It Happens¶
passwd wants a terminal. Under normal interactive use it inherits one from your login session and never touches /dev/ptmx.
When it runs over SSH without a pty allocated — ssh host 'passwd -S root', an Ansible command task, a CI job, or a remote shell tool that defaults to no TTY — there is no terminal to inherit. passwd falls back to opening the pty multiplexer /dev/ptmx itself to create one.
Stock targeted policy has no passwd_t → ptmx_t allow rule, because the interactive path never needs it. So the open is denied.
Two things follow from this:
- The denial is a symptom of how the command was invoked, not of what it was trying to do.
- It will recur on every future automated run until the calling pattern changes. Silencing the alert does not stop it happening.
Confirming it's this, and not something else¶
Check whether the invoking session had a TTY. With OpenSSH, a non-interactive command run has no tty-name. With Tailscale SSH, the session log states it outright:
journalctl --since today | grep -i 'ssh-session.*passwd'
# ... --has-tty=false --tty-name= ... --cmd=passwd -S root
Then confirm the denial's timing lines up with your own automation rather than anything else:
# When was /etc/shadow actually written?
stat -c '%y' /etc/shadow
# Every AVC denial today, with the process that caused it
journalctl --since today | grep 'avc: denied' \
| grep -oE 'comm="[^"]+"|path="[^"]*"' | sort | uniq -c | sort -rn
If the only denials are comm="passwd" path="/dev/ptmx", clustered around your maintenance window, you have this issue and nothing more.
Tools that write /etc/shadow directly never trigger this
Ansible's user module (and chpasswd) modify the shadow file without invoking the passwd binary, so they never enter the passwd_t domain. If a rotation run produced these denials, it is typically the verification step — a passwd -S status check — not the password change itself.
The Fix¶
Fix the caller, not the policy. Use something that never enters passwd_t:
# Password status / last-change date — same information, no passwd_t
chage -l root
# Locked-vs-set check straight from the shadow file
# (a leading ! or * means locked)
awk -F: '$1=="root"{print $2}' /etc/shadow
If you genuinely need passwd -S, allocate a pty so it inherits a terminal instead of opening one:
In Ansible, prefer a module or a shadow-file read over shelling out to passwd. If you must, the command module with a pty is not available — use chage -l instead.
What NOT to Do¶
If setroubleshoot is installed, the alert email will helpfully suggest this:
Do not audit2allow this denial
audit2allow writes the minimum rule that makes the log line disappear — it has no idea whether the access should happen. Here it would permanently grant the passwd_t domain read/write access to the pty multiplexer on every host you apply it to, to silence a denial that has no functional impact whatsoever. You would be widening policy for a cosmetic problem and keeping the badly-invoked command.
audit2allow is the right tool when a confined service genuinely needs an access that stock policy omits. It is the wrong tool when the real answer is "stop calling it that way."
Taming the Alert Noise¶
A single benign command can generate a surprising number of notifications, because AVC alerting is often deliberately thresholdless — you want to hear about the first denial of a real problem. Two patterns worth checking:
-
Netdata's
selinux_avc_denialsalarm (/etc/netdata/health.d/selinux.conf) is typically configuredlookup: max -10mwithwarn: $this > 0— that is, it warns on the first denial in the window, not on some tolerance being exceeded. Check yours before assuming there's a bar to clear: -
A custom "email me on any new AVC" timer will likewise fire on a single denial, and if it runs on a short interval it may mail before you have even finished the maintenance task.
Because both are usually thresholdless by design, a maintenance task that trips a benign denial will page once per host that sees it — which is how one command becomes several emails.
If routine, well-understood maintenance keeps tripping these, add a narrow known-benign filter for the exact signature rather than raising thresholds. Raising a > 0 warn bar to swallow this denial would also cost you first-denial detection of a real one — filtering the specific signature keeps that intact:
# Exclude only this precise denial, leave everything else alerting
grep -v 'comm="passwd" path="/dev/ptmx"'
Keep the filter as specific as possible. A broad grep -v passwd would hide a genuine passwd denial worth seeing.
Verify¶
After switching the caller to chage -l (or adding -t), run the automation again and confirm no new denials:
Notes¶
- The denial is harmless to the password operation — but "harmless" is a conclusion to reach after identifying the process, not a reason to skip investigating an AVC burst.
- The same no-TTY reasoning explains a whole family of automation-only SELinux denials. When a command works interactively and is denied under automation, check for a missing pty before reaching for a policy module.
- Related: SELinux: Fixing Fail2ban grep execmem Denial on Fedora — a case where a policy module genuinely was the right answer, for contrast.
- Setting up the alerting side: Netdata SELinux AVC Denial Monitoring — the
selinux_avc_denialschart and alarm referenced above.