Writing a PoC for a Denial of Service in Go's SSH library (CVE-2020-9283)
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.
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.
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.
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 client
receive packet: type 6(SSH_MSG_SERVICE_ACCEPT) is received by the client
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 client
receive packet: type 60(SSH_MSG_USERAUTH_PK_OK) is sent by the server
Server 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”.
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.
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 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
- Send a
SSH_MSG_USERAUTH_REQUESTmessage 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_REQUESTbecause this is a proof of concept and the server will likely reply with
- We don’t have to send the initial
SSH_MSG_USERAUTH_REQUESTcontaining 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/Useremail@example.com/ed25519/ed25519_go113.go:72 golang.org/x/crypto/ssh.ed25519PublicKey.Verify(0xc000026ccf, 0x15, 0x2c, 0xc00000b600, 0x88, 0x100, 0xc000072f80, 0x28, 0x3f) /Users/Userfirstname.lastname@example.org/ssh/keys.go:587 +0x19d golang.org/x/crypto/ssh.(*connection).serverAuthenticate(0xc000139500, 0xc00010b6c0, 0x11, 0x40, 0x0) /Users/Useremail@example.com/ssh/server.go:567 +0x1624 golang.org/x/crypto/ssh.(*connection).serverHandshake(0xc000139500, 0xc00010b6c0, 0x12185fb, 0x1b, 0x13919c0) /Users/Userfirstname.lastname@example.org/ssh/server.go:277 +0x5e7 golang.org/x/crypto/ssh.NewServerConn(0x12531e0, 0xc0000100c8, 0xc00010b040, 0x0, 0x0, 0x0, 0x0, 0x0) /Users/Useremail@example.com/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
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: