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