When Go 1.12 was released, I was very excited to test out the new opt-in support for TLS 1.3. TLS 1.3 is a major improvement to the main security protocol of the web.
I was eager to try it out in a tool I had written for work which allowed me to scan what TLS parameters were supported by a server. In TLS, the client presents a set of cipher suites to the server that it supports, and the server chooses the best one to use, where “best” is typically a reasonable trade-off of security and performance.
In order to enumerate what cipher suites a server supports, a client must make individual connections, each offering a single cipher suite at a time. If the server rejects the handshake, you know the cipher suite is not supported.
For TLS 1.2 and below, this is pretty straightforward:
func supportedTLS12Ciphers(hostname string) []uint16 {
// Taken from https://golang.org/pkg/crypto/tls/#pkg-constants
var allCiphers = []uint16{
tls.TLS_RSA_WITH_RC4_128_SHA,
tls.TLS_RSA_WITH_3DES_EDE_CBC_SHA,
tls.TLS_RSA_WITH_AES_128_CBC_SHA,
tls.TLS_RSA_WITH_AES_256_CBC_SHA,
tls.TLS_RSA_WITH_AES_128_CBC_SHA256,
tls.TLS_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_RC4_128_SHA,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_CBC_SHA,
tls.TLS_ECDHE_RSA_WITH_RC4_128_SHA,
tls.TLS_ECDHE_RSA_WITH_3DES_EDE_CBC_SHA,
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA,
tls.TLS_ECDHE_RSA_WITH_AES_256_CBC_SHA,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_CBC_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_CBC_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_ECDSA_WITH_AES_128_GCM_SHA256,
tls.TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384,
tls.TLS_ECDHE_RSA_WITH_CHACHA20_POLY1305,
tls.TLS_ECDHE_ECDSA_WITH_CHACHA20_POLY1305,
}
var supportedCiphers []uint16
for _, c := range allCiphers {
cfg := &tls.Config{
ServerName: hostname,
CipherSuites: []uint16{c},
MinVersion: tls.VersionTLS12,
MaxVersion: tls.VersionTLS12,
}
conn, err := net.Dial("tcp", hostname+":443")
if err != nil {
panic(err)
}
client := tls.Client(conn, cfg)
client.Handshake()
client.Close()
if client.ConnectionState().CipherSuite == c {
supportedCiphers = append(supportedCiphers, c)
}
}
return supportedCiphers
}
After writing the barebones code to support TLS 1.3 in the tool, I discovered something unfortunate: Go does not allow you to select what TLS 1.3 cipher suites are sent to the server. The rationale makes sense: TLS 1.3 greatly simplified both what is contained within a cipher suite and how many are supported. Unless and until there is a weakness in a TLS 1.3 cipher suite, there’s nothing to be gained in allowing them to be customized.
Still, this tool was one of the rare situations where it makes sense,
and I wanted to see if I could hack it in. Enter go:linkname
. Buried
deep in Go’s compiler documentation:
//go:linkname localname importpath.name
The //go:linkname directive instructs the compiler to use “importpath.name” as the object file symbol name for the variable or function declared as “localname” in the source code. Because this directive can subvert the type system and package modularity, it is only enabled in files that have imported “unsafe”.
Well hello! This looks promising. If there is a function or variable in Go’s standard library that specifies what the list of TLS 1.3 ciphers are, we can override that in our tool by instructing the Go complier to use our local implementation instead of the one in the standard library.
Let’s dig into the standard library’s TLS 1.3 implementation. In
crypto/tls/handshake_client.go
[link],
we have:
if hello.supportedVersions[0] == VersionTLS13 {
hello.cipherSuites = append(hello.cipherSuites, defaultCipherSuitesTLS13()...)
// ...
}
Great! Let’s just override this defaultCipherSuitesTLS13()
function.
In crypto/tls/common.go
[link]:
func defaultCipherSuitesTLS13() []uint16 {
once.Do(initDefaultCipherSuites)
return varDefaultCipherSuitesTLS13
}
This complicates things a bit. This calls an initialization function
lazily on first use, and that function manipulates a bunch of internal
default lists beyond just the TLS 1.3 cipher suites list. We don’t want
to mess with any of that. But in that initDefaultCipherSuites
function, we have this
[link]:
varDefaultCipherSuitesTLS13 = []uint16{
TLS_AES_128_GCM_SHA256,
TLS_CHACHA20_POLY1305_SHA256,
TLS_AES_256_GCM_SHA384,
}
Ah ha! A package global variable is assigned the cipher suite values. And because this initialization function is only ever called once, we can initialize the list and then take control of it in our code.
// Using go:linkname requires us to import unsafe
import (
"crypto/tls"
_ "unsafe"
)
// We bring the real defaultCipherSuitesTLS13 function from the
// crypto/tls package into our own package. This lets us perform
// that lazy initialization of the cipher list when we want.
//go:linkname defaultCipherSuitesTLS13 crypto/tls.defaultCipherSuitesTLS13
func defaultCipherSuitesTLS13() []uint16
// Next we bring the `varDefaultCipherSuitesTLS13` slice into our
// package. This is what we manipulate to get the cipher suites.
//go:linkname varDefaultCipherSuitesTLS13 crypto/tls.varDefaultCipherSuitesTLS13
var varDefaultCipherSuitesTLS13 []uint16
// Also keep a variable around for the real default set, so we
// can reset it once we're finished.
var realDefaultCipherSuitesTLS13 []uint16
func init() {
// Initialize the TLS 1.3 ciphersuite set; this populates
// varDefaultCipherSuitesTLS13 under the covers
realDefaultCipherSuitesTLS13 = defaultCipherSuitesTLS13()
}
func supportedTLS13Ciphers(hostname string) []uint16 {
var supportedCiphers []uint16
for _, c := range realDefaultCipherSuitesTLS13 {
cfg := &tls.Config{
ServerName: hostname,
MinVersion: tls.VersionTLS13,
}
// Override the internal slice!
varDefaultCipherSuitesTLS13 = []uint16{c}
conn, err := net.Dial("tcp", hostname+":443")
if err != nil {
panic(err)
}
client := tls.Client(conn, cfg)
client.Handshake()
client.Close()
if client.ConnectionState().CipherSuite == c {
supportedCiphers = append(supportedCiphers, c)
}
}
// Reset the internal slice back to the full set
varDefaultCipherSuitesTLS13 = realDefaultCipherSuitesTLS13
return supportedCiphers
}
As you can see, we used go:linkname
to subvert package modularity for
both a function and a variable. We use a package init
function to
populate the default cipher suites list, and then we override it as we
iterate and attempt connections with only a single supported cipher
suite. Finally, we make sure to clean things up and set the default
list back to the full set for any future uses.
Lastly, let’s glue things together:
func main() {
hostname := os.Args[1]
fmt.Println("Supported TLS 1.2 ciphers")
for _, c := range supportedTLS12Ciphers(hostname) {
fmt.Printf(" %s\n", tls.CipherSuiteName(c))
}
fmt.Println()
fmt.Println("Supported TLS 1.3 ciphers")
for _, c := range supportedTLS13Ciphers(hostname) {
fmt.Printf(" %s\n", tls.CipherSuiteName(c))
}
}
$ go run cipherlist.go joeshaw.org
Supported TLS 1.2 ciphers
TLS_ECDHE_RSA_WITH_AES_128_GCM_SHA256
TLS_ECDHE_RSA_WITH_AES_256_GCM_SHA384
Supported TLS 1.3 ciphers
TLS_AES_128_GCM_SHA256
TLS_CHACHA20_POLY1305_SHA256
TLS_AES_256_GCM_SHA384
There you have it.
go:linkname
should be used very sparingly. Consider carefully
whether you must use it, or whether you can solve your problem another
way. For me, the alternative was to import all of crypto/tls
to make
some minor edits. It would also freeze me into a point in time of the
Go TLS stack and put the burden of upgrading onto me. While I know that
there are no compatibility guarantees with Go’s crypto/tls
internals,
using go:linkname
allows me to use the TLS stack provided by current
and future versions of Go as long as the particular pieces I am using
don’t change. I can live with that.
The full code for this test program lives in this Github repository.