Recently, I was reading the source code for Linux’s PAM (as one does), and I found something funny. It can enable lateral movement during a red team engagement. Now, I don’t know if this trick is well-known or not, but to be honest, I don’t care much. It’s my blog, and I’ll write whatever the fuck I want ! Besides, I didn’t get to try it outside of a lab of my own creation, so it might just not be that good actually. Who knows. At least, you’ll go to sleep having learned something new.
Now, let me just say that the Linux-PAM repo is good code. It’s a well-maintained, serious codebase. But, there is this four-month old commit that turns off a security check that just nerd-sniped me.
Before the fun part, some background. If you already know how PAM works you can skip two sections down.
PAM ? From the Office ?
Every time you log into a Linux box, something has to decide whether to let you
in or not. In the old days each program did that on its own. login read
/etc/passwd, then su read /etc/passwd, then ftpd also read /etc/passwd,
and if you wanted to add two-factor auth you had to patch all of them.
You can easily see all the problems that can happen if you don’t centralize all
your authentication systems. Things get out of sync, a policy that works for one
thing doesn’t for something else. In short, it’s a nightmare.
PAM (Pluggable Authentication Modules) is the fix for that (yay!).
It comes in the form of a set of .so files that will centralize the implementation.
The decision happens from this set of modules, and you can administer it all in
one place. How neat ! From the programmer’s standpoint, you don’t have to care
about the why, it’s just a yes or a no. This idea comes from 1995, before Cloud
and all the IAM policies, micro-services, policy-enforcement points, and all the
(coughs) joy that comes with it.
The original spec (Yes I actually read it, dear reader. But don’t look so impressed,
it’s sitting there in the repo. Didn’t take much effort to be honest) is
OSF RFC 86.0, “Unified Login with Pluggable Authentication Modules”,
by Samar and Schemers at SunSoft, and Linux-PAM has it availalbe in
doc/specs/rfc86.0.txt.
If you want to read what a good design document looked like thirty years ago.
There are separate implementations of that idea. Linux-PAM is the one for Linux. macOS, FreeBSD and NetBSD use OpenPAM. Everything in this post is Linux-PAM only. I don’t know whether it works for other implementations.
The important thing is that PAM splits “logging in” into four separate questions:
- auth: are you who you say you are? (passwords, keys, tokens)
- account: ok, but are you allowed to log in right now? (expired? banned? wrong machine?)
- password: how do we change your credentials?
- session: what needs setting up before you get a shell? (home dir, limits, env)
That account stage is the one to keep in mind here. It runs after the
authentication succeeds. You have already proven who you are.
Now something decides whether that gets you access to anything.
how to tell PAM what to do ?
The config lives in /etc/pam.d/, one file per service. Here is a stripped-down
sshd, for example:
auth required pam_unix.so
account required pam_nologin.so
account required pam_access.so
session required pam_limits.so
Three columns: 1. which stage, 2. is it important or not, 3. and which module.
requiredhear means a failure fails the whole check. There are others (requisite,sufficient,optional), obviously.Fun consequence: a typo in
/etc/pam.d/sshdcan lock every single person out of a machine, permanently, including you. Ask me how I know lol.
Who calls PAM? Basically everything that lets a human in. sshd, sudo, su,
login, cron, systemd-logind, every graphical login manager, polkit. On a
stock Debian, libpam0g is
Priority: required. It is very critical software.
pam_access ; the thing we actually care about
You see, PAM is more than a helpful receptionist. For instance, pam_access
is an account module. It reads a file (for example /etc/security/access.conf)
and decides who may log in from where. The format is three colon-separated fields:
permission : users : origins
So this means “let the wheel group in from the local network, deny everyone else”:
+ : (wheel) : 192.168.0.0/16
- : ALL : ALL
Simple enough. Now here is a quiz. What does this line mean?
+ : [email protected] : ALL
If you read that as “let the user [email protected] in from anywhere”, you are
wrong. :))
the big funny
The users field supports a syntax that does not seem well-known. From the shipped sample config, not the man page (!!):
A pattern of the form user@host is matched when the login name matches the “user” part, and when the “host” part matches the local machine name.
The host there is not where you are connecting from, that’s the third
field. It’s the machine you are connecting to. It is literally the output of
gethostname() on the box evaluating the rule.
I am guessing it exists so that you can push one access.conf to your whole
fleet and still scope a rule to a single machine, like this:
+ : ops@bastion : ALL
- : ALL : ALL
ops gets in on bastion, no problem. On the other boxes, line two
takes precedence. One file, per-host policy, no templating. It’s actually a nice
little feature.
Now, for the first thing that made me suspicious: that quote is not from
access.conf(5). The user@host form is not in the man page at all:
$ grep -c "user@host" modules/pam_access/access.conf.5.xml
0
You can check it yourself.
From what I could find, its only specification anywhere is a comment in the example config file. The sample file does not even include an example rule using it, all of its examples use plain names and groups.
I went looking for anyone using it in the wild (bad code from github) and did not find anything. The code and the comment both date back to the initial revision. Hm.
Here we have a security-relevant rule documented as a comment in a sample file that nobody appears to use. Give me a second to shoot Chekhov’s gun.
second ingredient: the @ symbol
On a modern corporate Linux box, your username probably has an @ in it already.
If you run SSSD against Active Directory, use_fully_qualified_names makes every
user come back as name@domain. Not their email, their actual POSIX login name:
$ getent passwd [email protected]
[email protected]:x:1001:1001::/home/[email protected]:/bin/bash
You might think that is an exotic opt-in but it is the default. Red Hat documents
its neatly: “By default, you must specify fully qualified usernames,
like [email protected]”, straight from the docs.
Canonical’s authd, the newer Ubuntu login stack for Entra and Google IAM, does
the same thing. Its own config examples
use [email protected] style names, and there’s an open issue
asking authd to add username validation, because right now it does not have any.
None of this bothers the Linux kernel, of course (nothing bothers the Linux kernel,
except funny USB packets, as we’ve already
established). It only stores uids, since usernames are merely a userspace fiction,
and the only characters that can actually break /etc/passwd are : and newline.
systemd’s own docs have been grumbling about it for years, in
USER_NAMES.md:
sssd is known to generate user names with embedded
@and white-space characters, as well as non-ASCII (i.e. UTF-8) user/group names.
and a bit further down:
It also allows embedding
@(which is confusing to MTAs).
the fix that broke things
Put the two ingredients together and pam_access has a real problem.
It sees the string [email protected], splits it at the @, compares
alice against [email protected], and denies everybody.
Fully qualified usernames were completely broken for a time.
Someone filed issue #979.
It was subsequently fixed in commit
ef0ce28b,
in May 2026. Twelve lines at the top of
user_match():
/*
* Exact match for fully qualified username (user@domain) to prevent
* fully qualified usernames from being incorrectly parsed as user@host
* patterns.
*/
if (strchr(string, '@') != NULL && strcasecmp(tok, string) == 0) {
...
return YES;
}
string is your login name, tok is the config token. If your name has an @
in it and matches the token, it returns YES.
Returns YES from where though ? From the top of the function. Before this:
} else if ((at = strchr(at, '@')) != NULL) {
/* split user@host pattern */
if (item->hostname == NULL)
return NO;
...
rv = from_match (pamh, at + 1, &fake_item);
The hostname check never runs. Not “runs and passes”, it just never reached.
So + : ops@bastion : ALL stops being a rule about the bastion hostname and
instead becomes a rule about anyone literally named ops@bastion, everywhere this file exists.
wait, does it actually work ?
Consider a machine running Debian 13, with hostname web01, a stock openssh-server,
and real password auth.
# useradd -m --badname '[email protected]'
# echo '[email protected]:Passw0rd123' | chpasswd
# hostname
web01
# cat /etc/security/access.conf
+ : [email protected] : ALL
- : ALL : ALL
The rule says corp.example.com. We are on web01. Carol should be denied.
Swap only which pam_access.so the account stack loads:
--- sshd + Debian's pam_access 1.7.0-5 ---
ssh exit=255 (denied, correct)
--- sshd + pam_access built from current master ---
LOGGED IN as [email protected] on web01
ssh exit=0
And we get lateral access !
pressing shift
Everything above has a catch, though. The config token has to name an account that does not exist yet, so you can go create it. That is a bit contrived since admins usually write rules about real people. (how annoying /s)
Well, turns out you actually do not need that. Look at the comparison one more time:
strcasecmp(tok, string) == 0
strcasecmp. Unix login names are case-sensitive. strcasecmp is not. Those two
facts really do not get along.
Take a real, existing, privileged account. Make a case variant of it:
# useradd -m --badname '[email protected]' # the actual admin
# useradd -m --badname '[email protected]' # me:)
uid=1004([email protected]) <- them
uid=1005([email protected]) <- me, an entirely separate account:))
Both exist happily. Separate uids, separate groups, separate home directories.
useradd only blocks the byte-identical name:
# useradd -m --badname '[email protected]'
useradd: user '[email protected]' already exists
The policy grants the admin and only on secure.example.net. We are on web01.
I log in as myself, with my own password, over real sshd:
--- Debian's pam_access 1.7.0-5 ---
ssh exit=255
--- pam_access from master ---
IN as [email protected] uid=1005
ssh exit=0
I inherited an administrator’s login policy by using caps-lock. lol
One real limit: most directories enforce uniqueness case-insensitively, AD and LDAP
caseIgnoreMatchincluded, so you cannot register the variant there AFAIK.
I went and checked string_match() too. This bit isn’t actually new (and it has
nothing to do with @). pam_access has been comparing plain usernames with
strcasecmp since the very first commit of the file, back in 2000:
// string_match(), basically unchanged since ea488580 (2000-06-20)
if (strcasecmp(tok, "ALL") == 0) { /* all: always matches */
return (ALL);
} else if (string != NULL) {
if (strcasecmp(tok, string) == 0) { /* try exact match */
return (YES);
}
}
A simple git blame
dates the exact-match branch to 2005, and the function itself to 2000. So a boring
rule like + : bob : ALL, with no @, no SSSD, no patch, has been
case-insensitively matchable by an account named BOB for twenty-five years, on
every single pam_access install that ever existed. Great! If you slap Cloud
services on top of it, I’m sure you can see potential problems.
My demo up there went through the new FQDN code path because that’s what I was already poking at but it turns out I didn’t even need to.
Here is why I think the host-scoping one is interesting though: that bypass needs
an admin to have used the user@host syntax on purpose.
who gets to pick your username anyway ?
Which raises the obvious question. Can you just… choose your name?
On a stock Linux ? Not yourself, no. I checked properly, as an unprivileged user on a clean box:
Renaming is privileged. So the name comes from provisioning and this is where we get silly.
ForgeRock and PingOne ship userName like this out of the box, and this is
the actual default schema:
"userName": { "userEditable": true, "minLength": 1,
"policies": [ {"policyId": "cannot-contain-characters",
"params": {"forbiddenChars": ["/"]}} ] }
Okta goes further and makes @ mandatory by default: Okta usernames default
to email format,
and self-service registration auto-fills the username from the email you signed
up with, so a complete stranger types their own login and generic SCIM passes it
downstream unsanitized.
I ran a Keycloak to check it too. Its default username validator is
called username-prohibited-characters,
which sounds promising but it does not block @.
It cannot, actually: that validator explicitly turns itself off when the realm
has “email as username” enabled, a real setting, not an edge case. With
self-registration open, a complete stranger picks their own username from the
signup form and preferred_username carries it downstream.
And the OIDC spec confirms this. Core 1.0 §5.1:
MAY be any valid JSON string including special characters such as
@,/, or whitespace. The RP MUST NOT rely upon this value being unique
They put @ in the example list. §5.7 says email, preferred_username and
name MUST NOT be used as unique identifiers. Provisioning code uses them as
keys anyway.
To be fair, some of the ecosystem does convert properly.
Google Cloud OS Login
turns [email protected] into user_example_com.
JumpCloud forbids @ outright.
seems like openssh had the same problem
sshd_config has had AllowUsers and DenyUsers forever, and they take exactly
the same USER@HOST syntax. Same ambiguity, same character, same problem ?
Here is match_user()
from OpenSSH’s match.c:
if (strrchr(pattern, '@') == NULL)
return match_pattern(user, pattern);
pat = xstrdup(pattern);
p = strrchr(pat, '@');
*p++ = '\0';
if ((ret = match_pattern(user, pat)) == 1)
ret = match_host_and_ip(host, ipaddr, p);
Now, here are two learnings for us.
It never exact-matches the whole pattern first. If there is an @, the host half
gets checked, end of story. OpenSSH can fail to match a fully qualified username,
which is annoying, but it never silently drops a security rule you wrote !!
In my opinion, this is a bit better.
And, it splits on strrchr the last @. PAM uses strchr (on the first @).
That one character is very important because with strrchr you can write
AllowUsers [email protected]@bastion
and it works. User [email protected], host bastion, both enforced, no
ambiguity, no new syntax.
With strchr that sentence is simply not expressible, which is exactly why
pam_access ended up having to pick one reading over the other instead of
supporting both.
so what ?
(obama voice)Folks! Let me be clear! I don’t want to see LinkedIn “security gurus” claim that this completely breaks PAM on Linux, or whatever the fuck.
It is not a PAM bypass. It is in one module and only half of that module.
You still have to authenticate. pam_unix, pam_faillock, sshd’s own AllowUsers,
sudoers and SELinux all still apply.
It is not privilege escalation. Nobody gets root from this.
In the case-collision demo I am still uid 1005 with my own groups, and the very
same run shows me bouncing off the admin’s home directory with Permission denied.
I did not steal an identity.
It is not impersonation. You cannot take an existing account’s name, uniqueness is enforced at every layer.
It is not remotely sprayable. The match is strcasecmp against a specific token,
so you need to know what is in /etc/security/access.conf. Insider or
post-recon, not some drive-by attack.
The host-scoping half is also, honestly, mostly theoretical. It needs the
user@host syntax, and I could not find a single person using it. But, you may
encounter it in weird infras, or red team assignements.
The other half of it, the case-collision thing, is the one thing I would actually
worry about, because it needs no obscure syntax, no @, and, like I said above,
no recent patch either. Just a plain username in access.conf, which is the
normal thing to have, plus somewhere the attacker can register a case variant.
That’s it, you’re done.
What it is.
Basically, this is a segmentation control policy silently letting you through,
when it shouldn’t. pam_access answers “may this account log in here”.
Bypassing it enables lateral movement.
More importantly and this is the part I actually care about: an authorization function is choosing which policy to apply based on the contents of a string the subject may influence.
The @ is a detail. Patch the @ handling if you want. The strcasecmp thing
is a second, independent instance of the same carelessness.
And honestly, the most likely (bad) outcome is not that you get pwned at all.
Consider that an admin who wrote + : ops@bastion : ALL, believes it is scoped
to one machine because the sample config says so is actually wrong on all the
fleet they’re looking over.
The fix is not smarter parsing because the ambiguity is in-between services, and
unresolvable by guessing. Given [email protected], there is genuinely no way to
know whether the admin meant a hostname or a username. So stop guessing and make
them say it:
account required pam_access.so fqdn_usernames
} else if (!item->fqdn_usernames && (at = strchr(at, '@')) != NULL) {
/* split user@host pattern, unchanged */
One option, one decision, made by a real person. Now, go read your access.conf.
See you next time :)~
links
- linux-pam issue #979, the bug the patch was fixing
- commit ef0ce28b, the patch itself
- user_match(), the function with the bug
- string_match(), the 2000-era- strcasecmpthat makes the case-collision variant possible
- the shipped access.confsample, the only placeuser@hostis documented
- OpenSSH match.c, how to do it right
- systemd USER_NAMES.md, a long sigh about what a username is
- OIDC Core §5.1, on not trusting preferred_username
- pam.d(5)if you want to actually learn the stack language
- Red Hat: connecting RHEL to AD with SSSD, where use_fully_qualified_namesis documented
- canonical/authd config docs and the issue asking for username validation
- ForgeRock/PingOne default userNameschema
- Okta: character restrictions on usernames
- Keycloak’s UsernameProhibitedCharactersValidator
- Google Cloud OS Login troubleshooting docs, for the user_example_comformat
- JumpCloud naming conventions for users