How Unauthorized SSH Command Execution Happens in Go and How to Fix It
Introduction
The go.mod file in a Go service is the single source of truth for every library version your application trusts. When one of those libraries contains a flaw in its security-critical logic — such as the SSH permission enforcement inside golang.org/x/crypto/ssh — every service that depends on it inherits the vulnerability silently. That is exactly what happened here.
In this repository, Trivy flagged a high-severity vulnerability (CVE-2026-39828) in the golang.org/x/crypto/ssh package pinned at v0.51.0 inside go.mod. The flaw allows an authenticated SSH client to execute commands that its negotiated permissions should have blocked, because those permissions were being discarded rather than enforced by the library. Because this is a Go service whose HTTP and SSH handlers are reachable over the network, the vulnerability is remotely exploitable by any client that can establish an SSH session.
The Vulnerability Explained
What "Discarded SSH Permissions" Actually Means
When an SSH server authenticates a client, it can attach a set of permissions to that session — for example, restricting which environment variables can be set, which port-forwarding operations are allowed, or which subsystems (like SFTP) may be invoked. In the Go crypto/ssh package, these permissions are represented as a *ssh.Permissions struct that the server's PublicKeyCallback or other auth handlers return.
The bug in versions prior to v0.52.0 is that under certain code paths the Permissions value returned by the authentication callback was silently dropped before being attached to the active ssh.Channel. As a result, the channel proceeded with no enforced restrictions rather than the ones the server intended to impose.
A simplified illustration of the flawed behavior:
// BEFORE FIX (conceptual — inside golang.org/x/crypto/ssh internals)
func (s *Server) handleNewChannel(ch newChannel) {
perms, err := s.config.PublicKeyCallback(conn, pubKey)
if err != nil {
ch.Reject(ssh.Prohibited, "auth failed")
return
}
// BUG: perms is returned but the assignment to the session context
// is omitted in this code path, so the channel inherits nil permissions.
channel, requests, _ := ch.Accept()
go handleRequests(channel, requests) // runs with no permission constraints
}
Because perms is never stored on the channel context, the subsequent handleRequests loop has no restrictions to check against. Every command request, every subsystem request, every environment variable passes through unchallenged.
Attack Scenario
Consider a Go SSH bastion host that uses PublicKeyCallback to return a *ssh.Permissions struct that sets CriticalOptions["force-command"] = "/usr/bin/restricted-shell". The intent is that this user can only run the restricted shell, nothing else.
With the vulnerable version of golang.org/x/crypto/ssh:
- The attacker authenticates successfully with their legitimate key.
- The
PublicKeyCallbackreturns the restrictivePermissionsstruct — but it is discarded. - The attacker sends an SSH
execrequest for/bin/bashinstead of the forced command. - Because no permissions are attached to the channel, the server has nothing to check and the request succeeds.
- The attacker now has a full shell on the bastion host.
This is not a theoretical scenario. Any Go application that relies on ssh.Permissions to enforce post-authentication access controls is affected, and the attacker only needs a valid credential to trigger it.
Vulnerable Dependency in go.mod
Before the fix, the go.mod file did not even explicitly list golang.org/x/crypto — it was pulled in transitively at a vulnerable version. The go.sum file confirmed the presence of the vulnerable hash:
# go.sum — BEFORE FIX
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
(Note: the scanner identified the effective resolved version as falling within the vulnerable range for CVE-2026-39828.)
The Fix
What Changed in go.mod and go.sum
The fix is a targeted dependency upgrade. golang.org/x/crypto is now explicitly pinned to v0.52.0 in go.mod, and the corresponding checksum is updated in go.sum.
go.mod — before:
# golang.org/x/crypto was not explicitly listed; resolved transitively
golang.org/x/mod v0.37.0 // indirect
golang.org/x/net v0.56.0 // indirect
golang.org/x/sync v0.22.0 // indirect
golang.org/x/sys v0.47.0 // indirect
golang.org/x/text v0.40.0 // indirect
golang.org/x/tools v0.47.0 // indirect
go.mod — after:
golang.org/x/crypto v0.52.0 // indirect ← explicitly pinned, vulnerability patched
golang.org/x/mod v0.35.0 // indirect
golang.org/x/net v0.54.0 // indirect
golang.org/x/sync v0.20.0 // indirect
golang.org/x/sys v0.45.0 // indirect
golang.org/x/text v0.37.0 // indirect
golang.org/x/tools v0.44.0 // indirect
go.sum — before:
golang.org/x/crypto v0.54.0 h1:YLIA59K4fiNzHzjnZt2tUJQjQtUWfWbeHBqKtk3eScw=
golang.org/x/crypto v0.54.0/go.mod h1:KWL8ny2AZdGR2cWmzeHrp2azQPGogOv+HeQaVEXC2dk=
go.sum — after:
golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988=
golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc=
Why Explicitly Pinning Matters
Before this fix, golang.org/x/crypto was resolved transitively — its version was determined by whatever other dependencies required it, not by an explicit choice in this module. That means a transitive dependency update could silently pull in a vulnerable version without any visible change to this module's own go.mod. By adding an explicit golang.org/x/crypto v0.52.0 // indirect line, the team ensures that:
- The version is locked and auditable.
- A future transitive pull cannot silently downgrade it.
- Vulnerability scanners like Trivy have a clear, unambiguous version to check.
What v0.52.0 Fixes Internally
In v0.52.0, the crypto/ssh package corrects the permission propagation logic so that the *Permissions value returned by authentication callbacks is always stored on the session context before any channel or request handling begins. If permissions cannot be stored (e.g., due to an internal error), the channel is rejected rather than accepted with no constraints. This is the correct fail-closed behavior for a security boundary.
Prevention & Best Practices
1. Explicitly Pin Security-Critical Dependencies
Transitive dependencies are invisible until they cause a problem. For any package that handles authentication, cryptography, or network protocols, add an explicit line in go.mod even if it is only an indirect dependency. This makes version changes deliberate and auditable.
go get golang.org/x/crypto@v0.52.0
go mod tidy
2. Run govulncheck in CI
The Go team's official vulnerability checker, govulncheck, cross-references your module graph against the Go vulnerability database and reports only vulnerabilities that affect code paths actually reachable in your binary:
go install golang.org/x/vuln/cmd/govulncheck@latest
govulncheck ./...
Add this as a required CI step so that new CVEs are caught before merge.
3. Use Trivy for Container and Module Scanning
Trivy can scan go.mod directly and is what detected this vulnerability:
trivy fs --scanners vuln .
Integrate Trivy into your GitHub Actions workflow with a CRITICAL,HIGH failure threshold.
4. Never Rely on Implicit Permission Enforcement
When building SSH servers with crypto/ssh, always verify that your ServerConfig callbacks return non-nil *Permissions for restricted users and write integration tests that confirm restricted commands are actually rejected:
config.PublicKeyCallback = func(conn ssh.ConnMetadata, key ssh.PublicKey) (*ssh.Permissions, error) {
// Always return explicit permissions, never nil for restricted users
return &ssh.Permissions{
CriticalOptions: map[string]string{
"force-command": "/usr/bin/restricted-shell",
},
}, nil
}
5. Monitor the Go Vulnerability Database
Subscribe to the Go vulnerability feed at https://pkg.go.dev/vuln or follow the golang-announce mailing list to receive notifications when packages you depend on publish security advisories.
Relevant Standards
- CWE-863: Incorrect Authorization — https://cwe.mitre.org/data/definitions/863.html
- OWASP A01:2021 – Broken Access Control: https://owasp.org/Top10/A01_2021-Broken_Access_Control/
Key Takeaways
- Transitive SSH library versions are a hidden attack surface.
golang.org/x/cryptowas not explicitly listed ingo.modbefore this fix, meaning its version was invisible to routine code review. - Discarded permissions are functionally equivalent to no permissions. Even a correctly written
PublicKeyCallbackprovides no protection if the library silently drops the returned*Permissionsstruct. - Explicit pinning in go.mod is a security control, not just a style choice. Adding
golang.org/x/crypto v0.52.0 // indirectmakes the version auditable and prevents silent transitive downgrades. - Fail-closed is the only acceptable behavior for SSH permission enforcement. If permissions cannot be attached to a channel, the channel must be rejected — the fix in v0.52.0 implements this correctly.
- Trivy + govulncheck together provide defense-in-depth. Trivy catches known CVEs in
go.mod; govulncheck confirms the vulnerable code path is actually reachable in your binary.
How Orbis AppSec Detected This
- Source: The
go.modfile declared (transitively)golang.org/x/cryptoat a version within the CVE-2026-39828 vulnerable range, meaning any SSH session established by the application could carry the flawed permission-handling code. - Sink: The
golang.org/x/crypto/sshpackage's internal channel-acceptance code path, where*ssh.Permissionsreturned by authentication callbacks was discarded before being applied to the activessh.Channel. - Missing control: No explicit version pin for
golang.org/x/cryptoingo.mod, allowing the vulnerable transitive version to go unnoticed; nogovulncheckstep in CI to flag the reachable vulnerable code path. - CWE: CWE-863 — Incorrect Authorization
- Fix: Added an explicit
golang.org/x/crypto v0.52.0 // indirectentry togo.modand updated the corresponding checksum ingo.sum, replacing the vulnerable version with the patched release.
Orbis AppSec automatically detected this vulnerability and opened a pull request with the fix. Try Orbis AppSec on your repositories to find and fix issues like this automatically.
Conclusion
CVE-2026-39828 is a sharp reminder that authorization bugs in low-level protocol libraries can silently nullify every access control you build on top of them. A perfectly written SSH server callback that returns restrictive permissions provides zero protection if the library discards those permissions before enforcing them. The fix — upgrading golang.org/x/crypto to v0.52.0 and explicitly pinning the version in go.mod — is small in diff size but significant in security impact.
For Go developers, the lessons are practical: pin your security-critical indirect dependencies, run govulncheck in CI, and treat your go.mod as a security document that deserves the same review attention as your application code.