One CVE. One advisory. One sentence: "OpenSSH before 10.3 mishandles the authorized_keys principals option in uncommon scenarios involving a principals list in conjunction with a Certificate Authority that makes certain use of comma characters."
That sentence hides three bugs, two working authentication bypasses, and a rabbit hole into the OpenSSH source code that turned up two more undocumented issues that have nothing to do with commas.
This is the story of pulling on a thread.
The Advisory
CVE-2026-35414 landed on April 2, 2026, a Wednesday. OpenSSH 10.3p1 dropped the same day. The release notes are characteristically terse -- a one-paragraph description, a list of conditions, and a credit to Vladimir Tokarev. Severity: not yet assigned.
We pulled the advisory into the Exploit Intelligence Platform , read the three references, and opened the oss-security post . The disclosure repeated the same description almost verbatim. Commas. Certificate Authority. Uncommon scenarios.
"Uncommon scenarios" is a phrase that makes us reach for the source code.
If you're running SSH certificate authentication with per-key CA trust in authorized_keys -- and most organizations doing cert-based SSH auth are -- this one matters. If you're not, you might still want to keep reading, because the two undocumented findings at the end affect anyone using PermitListen restrictions or KRL-based certificate revocation.
Let's start with what the advisory actually describes, then go further than anyone intended.
The Setup
SSH certificates are a cleaner alternative to raw public keys. Instead of scattering authorized_keys entries across every server, you run a Certificate Authority that signs user keys. Servers trust the CA, and any certificate signed by that CA is accepted -- optionally restricted to specific principal names.
A typical authorized_keys entry looks like this:
cert-authority,principals="alice,backup" ssh-ed25519 AAAA... CA_KEY
This says: trust certificates signed by this CA, but only if the certificate's principal list includes "alice" or "backup." A certificate for "eve" gets rejected. Simple access control.
The bug is in how "includes" works.
Bug 1: The Wrong Function for the Job
We cloned both tags -- V_10_2_P1 and V_10_3_P1 -- and ran a diff. The first file that jumped out was auth2-pubkeyfile.c. The function match_principals_option() had been rewritten entirely. Here's what it looked like in 10.2:
static int
match_principals_option(const char *principal_list, struct sshkey_cert *cert)
{
char *result;
u_int i;
for (i = 0; i < cert->nprincipals; i++) {
if ((result = match_list(cert->principals[i],
principal_list, NULL)) != NULL) {
free(result);
return 1;
}
}
return 0;
}
See the call to match_list()? We opened match.c to check what it does. Line 272: #define SEP ",". Line 274 onwards: it splits both arguments on commas, builds arrays of fragments, and cross-matches them. It was written for algorithm negotiation -- the part of the SSH handshake where client and server exchange comma-separated lists of supported ciphers and pick the first common entry. Inputs like "aes256-ctr,aes128-ctr" versus "aes128-ctr,chacha20-poly1305". Its entire purpose is to split both sides on commas.
Using it for principal matching is a category error. And the kind of category error that works perfectly until the one day it doesn't.
Certificate principals are opaque strings. They're length-prefixed binary blobs in the cert format -- no commas in the wire encoding at all. A principal can contain any byte. The principals="" option in authorized_keys uses commas as delimiters between names, but commas inside a principal name are just data.
The problem: match_list() splits both arguments. The authorized list "alice,backup" becomes ["alice", "backup"]. The cert principal "alice,eve" -- one principal, literal comma -- also gets split into ["alice", "eve"]. Then "alice" matches "alice," and the check passes.
An attacker with a certificate whose principal is the single string "alice,eve" gets authenticated as alice.
The Crafting Problem
This is where the PoC got interesting. We tried the obvious thing first:
ssh-keygen -s ca_key -I "test" -n "alice,eve" eve.pub
And got a certificate with two separate principals: "alice" and "eve." Of course. ssh-keygen treats the comma as a delimiter. There's no escape syntax, no quoting mechanism, no way to embed a literal comma. The standard tooling physically cannot create the input that triggers the bug.
We stared at this for a while. The bug exists in the server's parsing logic, but you can't reach it with the standard client tools. This is probably why it survived for so long -- every test case ever written used ssh-keygen to generate certificates, and ssh-keygen can't generate the malicious input. If your fuzzer uses ssh-keygen as a certificate oracle, you'll never produce the byte sequence that triggers this. The bug lives in the space between what the format allows and what the toolchain produces.
We briefly considered patching ssh-keygen itself -- modifying the -n flag parsing to accept a different delimiter. That felt fragile and hard to reproduce. The cleaner approach was to skip the tooling entirely and build the certificate binary from scratch.
The SSH certificate format is straightforward. It's defined in PROTOCOL.certkeys : a nonce, the public key, serial number, type, key ID, a packed list of principals (each as a length-prefixed string), validity window, options, extensions, reserved bytes, the CA's public key, and a signature over all of it.
We wrote craft-cert.py -- 220 lines of Python using the cryptography library for ed25519 signing. It reads the CA's private key (parsing the OpenSSH private key format byte by byte -- openssh-key-v1\0 magic, cipher, kdf, check bytes, key type, seed), reads the user's public key, builds the cert structure with "alice,eve" as a single length-prefixed string in the principals blob, signs the whole thing, and outputs a valid cert file.
# Pack principals -- each is a separate length-prefixed string.
# "alice,eve" is ONE string. The comma is literal, not a delimiter.
principals_inner = b""
for p in principals:
principals_inner += pack_string(p)
The moment of truth was ssh-keygen -L -f eve-cert.pub inside the container:
Principals:
alice,eve
One principal. One line. One comma that shouldn't be there. Feed this to vulnerable sshd, and match_list() rips it apart. Feed it to patched sshd, and strcmp() correctly rejects it as a single non-matching string.
The Fix
OpenSSH 10.3 replaced match_list() with explicit strsep() on the authorized list and strcmp() per principal:
olist = list = xstrdup(principal_list);
for (;;) {
if ((entry = strsep(&list, ",")) == NULL || *entry == '\0')
break;
for (i = 0; i < cert->nprincipals; i++) {
if (strcmp(entry, cert->principals[i]) == 0) {
free(olist);
return 1;
}
}
}
Only the authorized list is split on commas. Each cert principal is compared as an atomic string. The right function for the job. We also ran a full audit of every other match_list() caller in the codebase -- kex.c, kex-names.c, sshconnect2.c, sftp-server.c -- all algorithm negotiation with internal fixed strings. No other misuse.
Bug 2: The Wildcard That Wasn't
We were reading the diff line by line. The match_principals_option fix was clear. Then we scrolled down to sshkey.c and found a second change that had nothing to do with commas. The function sshkey_cert_check_authority() had lost a parameter:
-sshkey_cert_check_authority(const struct sshkey *k,
- int want_host, int require_principal, int wildcard_pattern,
+sshkey_cert_check_authority(const struct sshkey *k,
+ int want_host, int wildcard_pattern,
require_principal was gone. We looked at what it did:
if (k->cert->nprincipals == 0) {
if (require_principal) {
*reason = "Certificate lacks principal list";
return SSH_ERR_KEY_CERT_INVALID;
}
// Falls through to return 0 -- success!
}
When nprincipals is zero and require_principal is zero, the function returns success. No principals in the certificate? No problem. Come right in.
We immediately grep'd for the callers. Who passes require_principal=0?
auth2-pubkeyfile.c:367-- the authorized_keys cert-authority path. The one we'd been staring at all day.auth2-hostbased.c:215-- host-based cert auth.sshkey.c:2470--sshkey_cert_check_host().
The authorized_keys path is the big one. If authorized_keys has cert-authority ssh-ed25519 AAAA... CA_KEY without a principals="" restriction, sshd still checks that the certificate contains the target username as a principal. At least, it's supposed to. With an empty-principals cert, that check is skipped entirely. Eve authenticates as anyone.
We updated craft-cert.py with a --no-principals flag that creates a certificate with an empty principals blob:
python3 craft-cert.py \
--ca-key ca_key \
--user-pub eve.pub \
--no-principals \
--key-id "eve-empty-principals-cert" \
--output eve-cert.pub
ssh-keygen -L on the result:
Principals: (none)
We added a user "bob" to the lab with unrestricted CA trust (cert-authority <CA_KEY>, no principals="" option) and tried Eve's empty cert against bob's account. On vulnerable: uid=1001(bob). On patched: rejected. The fix removes the require_principal parameter entirely -- empty principal lists are always rejected, period.
The Test Matrix
| Vector | Vulnerable (10.2p1) | Patched (10.3p1) |
|---|---|---|
| Eve's comma cert -> alice | BYPASS | REJECTED |
| Eve's empty cert -> bob | BYPASS | REJECTED |
| Alice's legit cert -> alice | SUCCESS | SUCCESS |
| Bob's legit cert -> bob | SUCCESS | SUCCESS |
| No cert, no password | REJECTED | REJECTED |
Both bypasses confirmed. Both correctly rejected on patched. Controls pass on both targets. Two bugs, two auth bypasses, one CVE.
Bug 3: The Argument Swap Nobody Could Trigger
The same function had a third change. In the wildcard matching path at line 2428:
// VULNERABLE: arguments backwards
if (match_pattern(k->cert->principals[i], name)) {
// PATCHED: correct order
if (match_pattern(name, k->cert->principals[i])) {
match_pattern(string, pattern) evaluates wildcards (*, ?) in the second argument. The vulnerable code passes the cert principal as the string and the hostname as the pattern. It's reversed -- a cert principal of "*" would be treated as a literal asterisk (first arg, no wildcard expansion), while a hostname of "*.example.com" would be treated as the pattern. Completely backwards.
But here's the thing: we checked every caller. auth2-pubkeyfile.c:367 passes wildcard_pattern=0. auth2-pubkey.c:565 passes 0. auth2-hostbased.c:215 passes 0. sshconnect.c:1087 via sshkey_cert_check_host() passes wildcard_principals=0. Every single caller disables the wildcard path. The arguments are wrong, but the code that's wrong is never reached.
The fix swaps the arguments AND enables wildcards for host certs (hardcoded wildcard_pattern=1 in sshkey_cert_check_host()). The swap was necessary to avoid introducing a new vulnerability while enabling a feature. A ticking time bomb defused preemptively.
We documented this one but didn't build a PoC. You can't exploit code that never executes. Sometimes a bug is just a bug waiting for its moment.
One CVE, Three Bugs
The advisory describes one vulnerability. The commit fixes three. Two are independently exploitable for authentication bypass. The third is a latent bug that would have gone live the moment anyone enabled wildcard host cert matching.
All three share a root cause: assumptions about how the data would look. Principals won't contain commas (they can). Certificates will always have at least one principal (they don't have to). The hostname is the string and the cert is the pattern (it's the opposite).
The OpenSSH team fixed all three in a single commit. The advisory mentions one. This is why you read the diff.
Going Deeper: What Else Is In Here?
We had the 10.3 source open, a fresh checkout of the entire codebase, and a good mental map of the certificate auth stack. The natural question was obvious: what else is hiding in this code?
We ran four parallel audits. First: every match_list() and match_pattern_list() caller in the codebase, checking whether any other code path feeds user-controlled comma-containing data through these functions. Second: the full certificate validation chain -- sshkey_cert_check_authority, sshkey_cert_check_host, sshkey_certify, every caller -- looking for remaining conditional bypasses, integer overflow in nprincipals, time validation edge cases, critical options handling. Third: the authorized_keys option parser (auth-options.c, opt_dequote(), sshauthopt_parse()) for injection and quoting bugs. Fourth: the broader authentication and channel subsystems for anything adjacent.
The match_list() audit came back clean. Every remaining caller is algorithm negotiation: kex.c for key exchange, kex-names.c for algorithm validation, sshconnect2.c for client-side method selection, sftp-server.c for request filtering. All use fixed internal strings, never user-controlled data. We also traced every match_pattern_list() call -- 30+ sites across the codebase. All correct: the first argument is always a single value (a username, a group name, a hostname), the second is always an admin-controlled comma-separated pattern from configuration files. No misuse.
The option parser audit was thorough and mostly reassuring. opt_dequote() in misc.c handles \" escapes correctly, rejects unterminated strings, and allocates strlen(s)+1 bytes (can't overflow because escapes shrink output). The valid_env_name() function restricts environment variable names to [a-zA-Z0-9_]. The deserialization paths validate bounds. safe_path_fd() uses fstat-on-fd to avoid TOCTOU races. The TrustedUserCAKeys path uses require_principal=1, so Bug 2 never applied there. Good code. Careful code.
But two findings stood out. Neither one is in the certificate code. They were in the parts of the codebase we only reached because we followed the function call chains out of the auth subsystem and into channels and revocation.
Finding: PermitListen Doesn't Apply to Unix Sockets
PermitListen is the sshd_config directive that controls what remote forwarding listeners a user can create. PermitListen 127.0.0.1:8080 means the user can only set up -R 8080:target:22 on localhost. Any other port is denied. That's the theory.
The enforcement lives in remote_open_match() at channels.c:4247. We were reading it because the channels code uses match_pattern() for hostname comparisons, and we wanted to verify it wasn't susceptible to the same argument-swap bug. It wasn't. But we found something better:
static int
remote_open_match(struct permission *allowed_open, struct Forward *fwd)
{
/* XXX add ACLs for streamlocal */
if (fwd->listen_path != NULL)
return 1;
if (fwd->listen_host == NULL || allowed_open->listen_host == NULL)
return 0;
if (allowed_open->listen_port != FWD_PERMIT_ANY_PORT &&
allowed_open->listen_port != fwd->listen_port)
return 0;
...
Read line 4254 again. /* XXX add ACLs for streamlocal */. The developers know. When the forwarding target is a Unix domain socket (fwd->listen_path != NULL), the function returns 1 unconditionally. No ACL check. No permission comparison. Just yes.
We followed the call chain to be sure. Client sends [email protected] global request. Server validates allow_streamlocal_forwarding and disable_forwarding flags. Then calls channel_setup_remote_fwd_listener(), which calls check_rfwd_permission(), which iterates permissions calling remote_open_match() for each. Every call returns 1 for streamlocal. The permission check is structurally present but functionally hollow.
A restricted user who should only be able to listen on 127.0.0.1:8080 can do:
ssh -R /tmp/backdoor.sock:internal-server:22 target
The Unix socket listener is created with zero restrictions. It provides the same tunneling capability as a TCP port. Any local process on the server can connect to it and be forwarded through the SSH tunnel. PermitListen gives administrators a false sense of restriction.
The asymmetry is what makes this interesting. We checked PermitOpen (which restricts local -L forwarding) and it handles streamlocal correctly via channel_connect_to_path() -- full ACL enforcement, proper permission iteration, the works. Only PermitListen for remote -R forwarding is broken. The XXX comment has been there since streamlocal support was added. It's a known gap that was never closed.
Mitigation: Set AllowStreamLocalForwarding no in sshd_config for any user subject to PermitListen restrictions. This disables Unix socket forwarding entirely, which is blunt but effective.
Finding: Certificates with Serial Zero Can't Be Revoked by Serial
This one came from reading krl.c while tracing how cert validation interacts with revocation. SSH Key Revocation Lists (KRLs) are the mechanism for revoking compromised certificates. The most common bulk approach is serial-range revocation: "revoke all certificates with serial numbers 1 through 1000." This is how organizations handle key compromise at scale -- you don't need to know every certificate's fingerprint, just the serial range.
In krl.c:1176:
/*
* Zero serials numbers are ignored (it's the default when the
* CA doesn't specify one).
*/
if (key->cert->serial == 0)
return 0;
The comment is honest. Serial 0 is the default when ssh-keygen -s ca_key -I "alice" -n alice user.pub is called without -z <serial>. Many deployments -- maybe most -- don't assign serial numbers. Revoking "all serial-zero certificates" would nuke every legacy cert in the infrastructure. The design decision makes sense.
But the consequence is surgical and easy to miss. Serial-zero certificates are immortal against the most common revocation method. We traced the full code path to be sure. ssh_krl_revoke_cert_by_serial_range() at line 333 rejects lo == 0 with SSH_ERR_INVALID_ARGUMENT -- you can't even add serial 0 to a KRL's serial revocation tree. And ssh_krl_revoke_key() at line 458, when it encounters serial zero, silently falls back to key_id revocation. That fallback depends on the admin having set a meaningful key_id when they signed the cert -- which is another thing that's often left as default.
Picture the incident response scenario. Your CA key is compromised. You need to revoke everything it ever signed. You generate a KRL with a serial range covering all issued certificates. You deploy it. Every certificate with serial 1 through 50,000 is revoked. The three certificates that were signed during the initial setup phase, before someone wrote the serial-number-assignment script -- serial 0, all three -- sail right through.
The fallback chain does exist. You can revoke by key_id, by SHA256 fingerprint, or by explicit key blob. And you can always revoke the entire CA. But the most ergonomic path -- the one that every tutorial and every incident response playbook describes first -- silently skips the one serial number that every default-configured certificate has.
For organizations running SSH CAs: always assign serial numbers. ssh-keygen -s ca_key -z $(date +%s) -n alice user.pub -- timestamp, counter, random, anything. Serial zero is a dead zone for revocation, and you won't find out until the day you need it.
The Lab
Everything described here is reproducible. The CVE-2026-35414 lab ships with:
- Dockerfile.vulnerable -- OpenSSH 10.2p1 built from source
- Dockerfile.patched -- OpenSSH 10.3p1 built from source
- craft-cert.py -- Custom SSH certificate builder (literal-comma principals and empty principal lists)
- poc/poc.py -- Vector 1: comma-injected principal exploit
- poc/poc_vector2.py -- Vector 2: empty-principals exploit
- poc/control_test.py -- Control tests proving the lab setup is valid
cd CVE-2026-35414
docker compose build && docker compose up -d
# Vector 1: comma injection -> alice's account
python3 poc/poc.py localhost -p 2222
# Vector 2: empty principals -> bob's account
python3 poc/poc_vector2.py localhost -p 2222
# Verify patched rejects both
python3 poc/poc.py localhost -p 2223
python3 poc/poc_vector2.py localhost -p 2223
The key trick was building craft-cert.py. Standard ssh-keygen can't create a certificate with a literal comma in a principal name, and it can't create a certificate with zero principals. The script constructs the SSH certificate binary directly -- parsing the OpenSSH private key format, packing principals as length-prefixed strings, assembling the cert structure, and signing with ed25519. It's the only way to exercise these bugs, and it's why they probably survived as long as they did. If your test harness can't generate the input, your test harness can't find the bug.
Lessons
1. Read the diff, not the advisory. The CVE-2026-35414 advisory describes one bug. The commit fixes three. Two are exploitable. If you only patch based on the advisory text, you might conclude this is a niche issue affecting "uncommon scenarios." The empty-principals bypass affects anyone using cert-authority in authorized_keys without a principals restriction -- a common deployment pattern.
2. Wrong function, right result (until it isn't). match_list() worked fine for principal matching as long as no one put commas in principal names. It probably worked fine for years. The bug isn't a typo or an off-by-one -- it's a function that does exactly what it's designed to do, applied to a context where that behavior is dangerous. These are the hardest bugs to find in code review because the code looks correct line by line.
3. The test harness IS the security boundary. ssh-keygen can't generate comma-containing principals. So nobody ever tested with them. The vulnerability lived in the gap between what the wire format allows and what the tools produce. If you're fuzzing certificate handling, fuzz the binary format directly, not through ssh-keygen.
4. Default values are security decisions. Serial zero. require_principal=0. Empty strings treated as wildcards. Every default is a bet that the common case is also the safe case. Sometimes it isn't. The serial-zero revocation gap has probably bitten more organizations than the comma bug ever will.
5. Restrictions that don't restrict. PermitListen does exactly what it says for TCP. It does nothing for Unix sockets. The XXX comment in the source code is honest about this, but the sshd_config documentation isn't. If you're relying on PermitListen to contain users, check AllowStreamLocalForwarding too.
6. Pull on threads. We started with one CVE and ended with five findings. The advisory pointed us at the certificate code. The certificate code pointed us at the match functions. The match function audit pointed us at the channel code. Most of the interesting bugs in mature codebases aren't in the first function you read -- they're three function calls deep, in the code that someone wrote eight years ago and everyone assumes is correct.
Timeline
| Date | Event |
|---|---|
| 2026-04-02 | CVE-2026-35414 published, OpenSSH 10.3p1 released |
| 2026-04-02 | oss-security disclosure by Vladimir Tokarev |
| 2026-04-02 | EIP builds lab, confirms two exploitable vectors from the CVE |
| 2026-04-02 | Source diff reveals two additional undisclosed bugs in the same commit |
| 2026-04-02 | Full codebase audit identifies PermitListen and KRL gaps |
| 2026-04-03 | This post |