Writing a PoC for a Denial of Service in Go's SSH library (CVE-2020-9283)
The vulnerability
On February 20th, 2020, the Go team announced a new denial-of-service vulnerability had been patched in the golang/x/crypto library:
Version v0.0.0-20200220183623-bac4c82f6975 of golang.org/x/crypto fixes a vulnerability in the golang.org/x/crypto/ssh package which allowed peers to cause a panic in SSH servers that accept public keys and in any SSH client.
An attacker can craft an ssh-ed25519 or sk-ssh-…@openssh.com public key, such that the library will panic when trying to verify a signature with it. Clients can deliver such a public key and signature to any golang.org/x/crypto/ssh server with a PublicKeyCallback, and servers can deliver them to any golang.org/x/crypto/ssh client.
This issue was discovered and reported by Alex Gaynor, Fish in a Barrel, and is tracked as CVE-2020-9283.
This issue seemed super interesting to me since I’ve spent a good amount of time hacking away at Go-based SSH servers during my career so I decided to take a stab at reverse engineering the patch to see if I could construct a working proof-of-concept (PoC) script for the vulnerability.
The fix
As mentioned in the initial announcement, the commit that fixed the issue was bac4c82f6975 so I started my search there. As it turns out the changeset was pretty small (only 23 lines were changed; all in the same file).
Most of the changes centered around checking the length of an ed25519 keys to ensure that the provided key’s length was equal to the expected length for an ed25519 key (32 bytes). That seems to indicate that the way to cause a panic is simply to send a SSH public key that is shorter than 32 bytes. That sounds like something that shouldn’t be too hard to do.
Building a test environment
In order to test the PoC, I needed to build an SSH server using the vulnerable version of the library. This was actually pretty simple thanks to golang.org/x/crypto/ssh.
All this server does is listen for SSH connections on port 2022 using public key authentication. For our purposes, we don’t really care about the value of the key so we simply set PublicKeyCallback to return nil
to blindly accept any key that we’re given. PublicKeyCallback
’s second argument is ssh.PublicKey
so its likely we will have already triggered our panic
by this point anyways since that would require parsing the key and possibly validating the signature.
In order to ensure we’re running the vulnerable version of golang.org/x/crypto
, we also need to include a go.mod
file that pins the library at the appropriate version:
module github.com/mark-adams/exploits/CVE-2020-9283/target-vulnerable
go 1.13
require golang.org/x/crypto v0.0.0-20200219234226-1ad67e1f0ef4
Now we can run a quick test to make sure we have a working SSH server:
$ go run .
2020/12/15 10:37:22 Vulnerable SSH server running on 0.0.0.0:2022
and now we generate a test public key and trigger an example connection:
$ ssh-keygen -b 2048 -t rsa -f testkey -q -N ""
$ ssh localhost -i testkey -p 2022
Connection to localhost closed.
and if we look back at our server logs, we see:
2020/12/15 10:40:17 user authenticated successfully from [::1]:51537
Neat. It looks like we have a working SSH server. 🎉
The SSH Protocol
The SSH protocol is defined in a bunch of different IETF RFCs. In our case the two primary ones that we are interested in are:
- RFC 4253: The Secure Shell (SSH) Transport Layer Protocol; and
- RFC 4252: The Secure Shell (SSH) Authentication Protocol
As informative as reading the RFCs can be, it is often more useful to simply look at verbose output from a sample connection and see what we can learn from that. To get the sort of verbosity we’re looking for, running a command like $ ssh localhost -i testkey -p 2022 -vvv
should suffice.
Doing so gives us some insight into how the SSH protocol is put together.
Key Exchange
One of the first things we see in the output is
debug3: send packet: type 20
debug1: SSH2_MSG_KEXINIT sent
debug3: receive packet: type 20
debug1: SSH2_MSG_KEXINIT received
These SSH_MSG_KEXINIT messages being exchanged between the client and server (described in RFC 4253 7.1) kick off the key exchange process and help the client and server agree on which algorithms make sense for conducting the rest of the handshake.
Next up, we see a couple other messages:
debug3: send packet: type 30
debug1: expecting SSH2_MSG_KEX_ECDH_REPLY
debug3: receive packet: type 31
These messages are part of the key exchange process and help the server and client establish a shared secret with each other. These are defined in RFC 5656 7.1.
debug3: send packet: type 21
debug2: set_newkeys: mode 1
debug1: rekey out after 134217728 blocks
debug1: SSH2_MSG_NEWKEYS sent
debug1: expecting SSH2_MSG_NEWKEYS
debug3: receive packet: type 21
debug1: SSH2_MSG_NEWKEYS received
debug2: set_newkeys: mode 0
debug1: rekey in after 134217728 blocks
These messages wrap up the key exchange process and are defined in RFC 4253 7.3.
User Authentication
As interesting as key exchange is, we are looking for the point in the handshake where we send our public key to the server so we can replace it with a specially crafted key designed to cause the panic.
Next up in the logs, we see something more interesting:
debug1: Will attempt key: testkey RSA SHA256:C1kVTeLCnNvKTX1Jl9UUKrJ5D1leqZRC6LgKnyzxPxE explicit
debug2: pubkey_prepare: done
debug3: send packet: type 5
debug3: receive packet: type 6
debug2: service_accept: ssh-userauth
debug1: SSH2_MSG_SERVICE_ACCEPT received
debug3: send packet: type 50
debug3: receive packet: type 51
debug1: Authentications that can continue: publickey
debug3: start over, passed a different list publickey
debug3: preferred publickey,keyboard-interactive,password
debug3: authmethod_lookup publickey
debug3: remaining preferred: keyboard-interactive,password
debug3: authmethod_is_enabled publickey
debug1: Next authentication method: publickey
debug1: Offering public key: testkey RSA SHA256:C1kVTeLCnNvKTX1Jl9UUKrJ5D1leqZRC6LgKnyzxPxE explicit
debug3: send packet: type 50
debug2: we sent a publickey packet, wait for reply
debug3: receive packet: type 60
debug1: Server accepts key: testkey RSA SHA256:C1kVTeLCnNvKTX1Jl9UUKrJ5D1leqZRC6LgKnyzxPxE explicit
debug3: sign_and_send_pubkey: RSA SHA256:C1kVTeLCnNvKTX1Jl9UUKrJ5D1leqZRC6LgKnyzxPxE
debug3: sign_and_send_pubkey: signing using ssh-rsa
debug3: send packet: type 50
debug3: receive packet: type 52
debug1: Authentication succeeded (publickey).
You see a couple things happening here:
send packet: type 5
(SSH_MSG_SERVICE_REQUEST) is sent by the clientreceive packet: type 6
(SSH_MSG_SERVICE_ACCEPT) is received by the clientservice_accept: ssh-userauth
These entries in our output indicate that we’ve now entered the user authentication phase of the handshake.
Now we see:
send packet: type 50
(SSH_MSG_USERAUTH_REQUEST)receive packet: type 51
(SSH_MSG_USERAUTH_FAILURE)Authentications that can continue: publickey
These messages are defined in RFC 4252 and allow the SSH client to determine which authentication methods are valid for the specified user on the remote server. The SSH_MSG_USERAUTH_FAILURE
message includes a list of those fields in it’s response so that the client only needs to try authentication methods that are valid for the specified user.
Next up we see:
send packet: type 50
(SSH_MSG_USERAUTH_REQUEST) is sent by the clientreceive packet: type 60
(SSH_MSG_USERAUTH_PK_OK) is sent by the serverServer accepts key: ...
Wow! A lot of exciting stuff happened there that looks pretty interesting for those of us looking to send a maliciously crafted public key to a remote server. Let’s dive in!
We can infer from the server’s reply with an SSH_MSG_USERAUTH_PK_OK
message that the SSH_MSG_USERAUTH_REQUEST
was the client’s attempt to send the username of the user and the user’s public key to the server to see if the server will accept it. The server’s reply is the server’s way of saying “Yes, I will accept that key for authenticating that user if you can prove you have the private key”.
The next SSH_MSG_USERAUTH_REQUEST
sent by the client is actually very similar to the first one except the end of the message also contains a signature performed on a set of connection-related information which proves the client actually possesses the private key. The method for constructing these messages and the corresponding signature are detailed in RFC 4252 Section 7.
Since the server possesses the same information that was signed by the client, it is able to use the public key to decrypt the signature from the second SSH_MSG_USERAUTH_REQUEST
message to validate that the client possesses the public key.
This is actually the breakthrough that we’ve been looking for. At this stage in the handshake, the server has taken the public key provided by the client, parsed it, and is actually attempting to use it to validate the signature sent by the client.
Setting up a connection
There are a couple different ways to go about creating an SSH connection and getting us to the stage where we could send our malicious public key to the server.
Using most existing SSH frameworks (like golang.org/x/crypto/ssh
) likely won’t work well since these libraries are an abstraction over the underlying protocol and don’t give us the ability to send raw SSH_MSG_USERAUTH_REQUEST
messages when we need to.
We could write our own SSH handshake implementation but that seems like a lot of work that it would be nice to avoid if we can.
Luckily for us, there is a reasonable compromise. The paramiko Python library provides a nice balance between handling some of the messy parts for us (like key exchange) while also letting us send raw SSH packets when we need to.
Installing paramiko
is easy and just involves a simple pip install paramiko
. Once that is out of the way, we can construct a simple SSH client script:
In the first part of our script, we open a TCP socket to the host / port of our SSH server. We then pass that socket to a new paramiko.Transport
instance and call the start_client()
method. This triggers the first part of the SSH handshake (Key Exchange) detailed earlier in this post. This will put us in a perfect spot for us to proceed with the next phase (user authentication) and send our malicious key.
Authenticating with Paramiko
Before we can send a malicious SSH key, we need to find how to send arbitrary SSH protocol messages with paramiko
. Luckily, paramiko
has a paramiko.Message
class perfect for constructing SSH protocol messages and the paramiko.Transport
has a ._send_message()
method which will drop that message onto the socket.
Now we can construct a slightly simplified version of the authentication flow to skip quickly to the part where we get the server to parse our key:
- Send a
SSH_MSG_SERVICE_REQUEST
asking forssh-userauth
service - Send a
SSH_MSG_USERAUTH_REQUEST
message including our malicious public key and signature.
We’re able to skip a couple of steps here for a number of reasons:
- We don’t have to wait for the server to reply to our
SSH_MSG_SERVICE_REQUEST
because this is a proof of concept and the server will likely reply withSSH_MSG_USERAUTH_FAILURE
anyways. - We don’t have to send the initial
SSH_MSG_USERAUTH_REQUEST
containing the public key but no signature because our proof-of-concept script is barebones and we don’t really care about telling the person running the script that the remote user doesn’t exist. Plus, for our test server, it accepts all users so we know we will succeed no matter what.
To construct the SSH_MSG_SERVICE_REQUEST
, we just take a look at RFC 4253 and mirror that in our Python code. The spec from the RFC looks like this:
byte SSH_MSG_SERVICE_REQUEST
string service name
and the corresponding Python code looks like this:
Next, we will focus on sending the SSH_MSG_USERAUTH_REQUEST
containing our malicious public key. These messages are specific to the authentication method that you want to use (in this case publickey
) and are defined in RFC 4252. Once again, you’ll notice that the spec for these messages is pretty similar to the code that we’re writing in Python. The RFC defines the message structure as:
byte SSH_MSG_USERAUTH_REQUEST
string user name
string service name
string "publickey"
boolean TRUE
string public key algorithm name
string public key to be used for authentication
string signature
and our Python code looks like this:
The astute reader will note that we are missing the public key itself and the signature from our message that we are constructing. That is the next thing we are going to tackle.
Sending the malicious key
Once again, some quick Googling tells us that the SSH public key format for ed25519 keys is defined by RFC 8709 and has the following format:
string "ssh-ed25519"
string key
That’s pretty straightforward. We know that ed25519 keys are expected to have a key
that is 32 bytes long. For this attack, we simply want to ensure that key
is less than 32 bytes long. We can do that with the following Python:
Take notice that key-that-is-too-short
is 21 bytes long and 21 < 32 which meets our requirement for the key being too short.
The final part of constructing our payload is to add the signature
required at the end of the message. Luckily for , we’re trying to get the server to panic
while verifying the signature so we don’t actually need the signature to validate. In fact, we can even put in an empty string if we want and that’s exactly what we’re going to do:
Now that we’ve composed our completed SSH_MSG_USERAUTH_REQUEST
message we can go ahead and send it to trigger the panic
and crash the server:
Did it work?
At this point, we can run back over and check our vulnerable server and see if our attack worked.
panic: ed25519: bad public key length: 21
goroutine 64 [running]:
crypto/ed25519.Verify(0xc000026ccf, 0x15, 0x2c, 0xc00000b600, 0x88, 0x100, 0xc000026cf7, 0x0, 0x0, 0x20)
/usr/local/go/src/crypto/ed25519/ed25519.go:205 +0x477
golang.org/x/crypto/ed25519.Verify(...)
/Users/User/dev/go/pkg/mod/golang.org/x/crypto@v0.0.0-20200219234226-1ad67e1f0ef4/ed25519/ed25519_go113.go:72
golang.org/x/crypto/ssh.ed25519PublicKey.Verify(0xc000026ccf, 0x15, 0x2c, 0xc00000b600, 0x88, 0x100, 0xc000072f80, 0x28, 0x3f)
/Users/User/dev/go/pkg/mod/golang.org/x/crypto@v0.0.0-20200219234226-1ad67e1f0ef4/ssh/keys.go:587 +0x19d
golang.org/x/crypto/ssh.(*connection).serverAuthenticate(0xc000139500, 0xc00010b6c0, 0x11, 0x40, 0x0)
/Users/User/dev/go/pkg/mod/golang.org/x/crypto@v0.0.0-20200219234226-1ad67e1f0ef4/ssh/server.go:567 +0x1624
golang.org/x/crypto/ssh.(*connection).serverHandshake(0xc000139500, 0xc00010b6c0, 0x12185fb, 0x1b, 0x13919c0)
/Users/User/dev/go/pkg/mod/golang.org/x/crypto@v0.0.0-20200219234226-1ad67e1f0ef4/ssh/server.go:277 +0x5e7
golang.org/x/crypto/ssh.NewServerConn(0x12531e0, 0xc0000100c8, 0xc00010b040, 0x0, 0x0, 0x0, 0x0, 0x0)
/Users/User/dev/go/pkg/mod/golang.org/x/crypto@v0.0.0-20200219234226-1ad67e1f0ef4/ssh/server.go:206 +0x18e
main.handleConnection(0x12531e0, 0xc0000100c8, 0xc00010b040)
/Users/User/dev/exploits/CVE-2020-9283/target-vulnerable/main.go:42 +0x6a
created by main.main
/Users/User/dev/exploits/CVE-2020-9283/target-vulnerable/main.go:93 +0x245
exit status 2
and sure enough, it worked! 🏆
The fix explained
Interestingly, the panic
is actually caused by validation logic in golang.org/x/crypto/ed25519 that checks to see if the key bytes are the proper length when Verify()
is called.
The fix is actually pretty straightforward. The same validation logic is duplicated in golang.org/x/crypto/ssh such that the key length is checked prior to calling ed25519.Verify()
and an error is returned instead of triggering the panic
later on in the process.
That’s all folks!
Thanks for taking the time to walkthrough this vulnerability with me and construct a working proof-of-concept. If you’d like to see the whole thing, you can view it on GitHub here:
https://github.com/mark-adams/exploits/tree/master/CVE-2020-9283