Hunting Sliver
It’s been a while since I wrote about hunting C2 frameworks, so I thought I would do a new writeup, this time looking at Bishop Fox’s Sliver.
Sliver is a cross-platform implant and c2 toolkit written in Go. Implants can talk to the Command and Control server via a number of different ways:
- Mutual TLS
- HTTP/s
- DNS
- Wireguard
While Sliver is intended for use by legitimate red teams, as expected of all red team tools it has also been used by aunothorised malicious actors.
I’ll cover each of these in order, with the goal of being able to fingerprint a Sliver C2 server based upon a network scan, such as those recorded on Shodan.
Design Decisions Vs Flaws
When discussing the Sliver codebase, I’m choosing to describe the logic that makes it easier to fingerprint as design ‘decisions ‘, instead of calling them ‘flaws’. Based on this Twitter thread from Moloch (Red Team Lead at Bishop Fox), I get the impression that the team is happy with this amount of fingerprinting being possible.
Mutual TLS
Mutual TLS (mTLS) is the recommended C2 communication channel. Using this method, the implant connects to the C2 using TLS, however unlike standard HTTPS both the implant and C2 present a certificate that must be validated by the other.
Attempting to connect to a mTLS endpoint without a valid certificate returns an “INVALID CERTIFICATE” error:
## In Sliver C2 console, start mTLS listner
[server] sliver > mtls --lhost 127.0.0.1 --lport 8000
## In another terminal, try to contact endpoint
$> curl --insecure https://127.0.0.1:8000
curl: (56) OpenSSL SSL_read: error:14094412:SSL routines:ssl3_read_bytes:sslv3 alert bad certificate, errno 0
However, even without a successful connection, we can still fingerprint the server using JARM. JARM is a technique to fingerprint a TLS server by looking at the various parts of the Server Hello message, calculating a hash based on things like:
- Minimum TLS version supported
- Suggested cipher suites
- TLS Extensions uses
As Moloch pointed out on Twitter, Sliver hardcodes the TLS configuration:
tlsConfig := &tls.Config{
RootCAs: sliverCACertPool,
ClientAuth: tls.RequireAndVerifyClientCert,
ClientCAs: sliverCACertPool,
Certificates: []tls.Certificate{cert},
CipherSuites: []uint16{tls.TLS_ECDHE_ECDSA_WITH_AES_256_GCM_SHA384},
PreferServerCipherSuites: true,
MinVersion: tls.VersionTLS12,
}
This makes it serve a fairly static JARM signature:
## In Sliver C2 console, start mTLS listner
[server] sliver > mtls --lhost 127.0.0.1 --lport 8000
## In another terminal, calculate JARM signature
$> python jarm.py --port 8000 127.0.0.1
JARM: 28d28d28d00028d00043d28d28d43d47390d982d099a542ccbc90628951062
Unlike last time I wrote about JARM,the
signatures are now calculated by Shodan’s internet scans. So we can simply search Shodan
for ssl.jarm:"28d28d28d00028d00043d28d28d43d47390d982d099a542ccbc90628951062"
,
and sure enough about 30 results are returned:
While not a 100pc guarantee these are all Sliver C2s, another hint that the JARM
signaturing might be accurate is that almost all return the
SSL Error: ALERT_BAD_CERTIFICATE
that we expect from the mTLS endpoint.
Another hint is that a lot of them also have other ports such as HTTP/80 open, which contains a different-yet-matching set of Sliver fingerprints that I’ll explain below.
HTTP/S
Sliver also has the option of connecting over standard HTTP or HTTPS. Both endpoints use the same code, and as Sliver runs its own encryption underneath the C2 transport even being able to MITM the connection isn’t a guarantee of being able to decrypt the traffic.
We can attempt to look at the JARM signature of the HTTPs endpoint, however as Sliver is using
Go’s inbuilt server.HTTPServer
, the Signature is dependant on the version of Go Sliver is built with:
## Sliver built using go1.16.7 linux/amd64
2ad2ad0002ad2ad00043d2ad2ad43da5207249a18099be84ef3c8811adc883
## Sliver built using go1.17 linux/amd64
2ad2ad00000000000043d2ad2ad43dc4b09cccb7c1d19522df9b67bf57f4fb
I’ve written about the issue before when looking at another C2 framework Koadic, which used the in-built Python HTTP server. Searching for JARM of sliver built on Go 1.16 return over 3000 servers, far too many to all be Sliver:
Searching the signature built off go1.17 returns only 3, however, with further inspection all appear to be versions of Room Ready Designer. However, there are other techniques we can use to fingerprint Slivers HTTP and HTTPS endpoints.
1. HTTP Headers
Sliver’s HTTP/S endpoints pretend to be an Apache server running PHP. It does this by setting a very specific set of partially-random headers in it’s response:
// Code re-formatted for this blog
func (s *SliverHTTPC2) DefaultRespHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(resp http.ResponseWriter, req *http.Request) {
resp.Header().Set(
"Server",
fmt.Sprintf("Apache/2.4.%d (Unix)", insecureRand.Intn(43),
)
resp.Header().Set(
"X-Powered-By",
fmt.Sprintf("PHP/7.%d.%d", insecureRand.Intn(3), insecureRand.Intn(17)),
)
resp.Header().Set(
"Cache-Control",
"no-store, no-cache, must-revalidate"
)
// ...
next.ServeHTTP(resp, req)
})
}
The exact version of Apache and PHP are randomised when the server starts, however the rest of the headers are the same, and are always presented in the same order:
## In Sliver C2 console, start HTTPS listner (HTTP server has same result)
[server] sliver > https --lhost 127.0.0.1 --lport 8000
## In another terminal, see the response from server
$> curl --verbose --insecure https://127.0.0.1:8000
...
< HTTP/1.1 404 Not Found
< Cache-Control: no-store, no-cache, must-revalidate
< Content-Type: application/octet-stream
< Server: Apache/2.4.18 (Unix)
< X-Powered-By: PHP/7.2.3
< Date: Sat, 04 Sep 2021 03:33:23 GMT
< Content-Length: 0
These headers can be easily searched in Shodan:
"HTTP/1.1 404 Not Found" "Cache-Control: no-store, no-cache, must-revalidate" "Content-Type: application/octet-stream" "X-Powered-By: PHP" "Server: Apache"
This returns about 50 results, all of which match the next fingerprint:
The randomised faked versions of Apache and PHP don’t change per request, however, is server starts both an HTTP and HTTPS server, it is likely they present different version numbers. While two legitimate similar-but-slightly-different services may be running on the same server, it is still a bit unusual, and worth noting.
2. HTTP Responses
The HTTP/S endpoints have an interesting design where the implant will send a GET or POST to the URL, faking a request for a file from the server. However, the filename extension determines what type of message the implant is sending, with the domain name, filename, etc. being ignored (and can therefore be anything):
// Procedural C2
// ===============
// .txt = rsakey
// .jsp = start
// .php = session
// .js = poll
// .png = stop
// .woff = sliver shellcode
router.HandleFunc("/{rpath:.*\\.txt$}", s.rsaKeyHandler).MatcherFunc(filterNonce).Methods(http.MethodGet)
router.HandleFunc("/{rpath:.*\\.jsp$}", s.startSessionHandler).MatcherFunc(filterNonce).Methods(http.MethodGet, http.MethodPost)
router.HandleFunc("/{rpath:.*\\.php$}", s.sessionHandler).MatcherFunc(filterNonce).Methods(http.MethodGet, http.MethodPost)
router.HandleFunc("/{rpath:.*\\.js$}", s.pollHandler).MatcherFunc(filterNonce).Methods(http.MethodGet)
router.HandleFunc("/{rpath:.*\\.png$}", s.stopHandler).MatcherFunc(filterNonce).Methods(http.MethodGet)
router.HandleFunc("/{rpath:.*\\.woff[/]{0,1}.*$}", s.stagerHander).Methods(http.MethodGet)
This design decision is intended to obfuscate the communications when being intercepted. Along with the file extension,
an implant is also required to send a keyword parameter named _
with a pseudo crypto nonce.
This nonce is seeded from a set of constant values, so it can be calculated,
however to make it easy a nonce of 0
is also valid:
// EncoderFromNonce - Convert a nonce into an encoder
func EncoderFromNonce(nonce int) (int, Encoder, error) {
encoderID := nonce % EncoderModulus
if encoderID == 0 {
return 0, new(NoEncoder), nil
}
if encoder, ok := EncoderMap[encoderID]; ok {
return encoderID, encoder, nil
}
return -1, nil, errors.New("Invalid encoder nonce")
}
// This filters requests that do not have a valid nonce
func filterNonce(req *http.Request, rm *mux.RouteMatch) bool {
qNonce := req.URL.Query().Get("_")
nonce, err := strconv.Atoi(qNonce)
if err != nil {
httpLog.Warnf("Invalid nonce '%s' ignore request", qNonce)
return false // NaN
}
_, _, err = encoders.EncoderFromNonce(nonce)
if err != nil {
httpLog.Warnf("Invalid nonce (%d) ignore request", nonce)
return false // Not a valid encoder
}
return true
}
This means if we make a request to the server like https://domainname.com/aaaa.jsp?_=0
we can
get past the first part of Sliver’s checks. Most of the message types require the underlying data
to also be encrypted using the pre-defined set of keys built into the implant, which unless we
have an implant built for that C2 the server will reject anything we send it.
However, 2 allow unauthenticated requests:
.txt
the .txt
message is used to get the Servers encryption certifiate:
$> curl --insecure https://127.0.0.1:8000/bonza.txt?_=0
-----BEGIN CERTIFICATE-----
MIICVjCCAdygAwIBAgIQUcfJ+ZFrmUeqvCt60KOL0jAKBggqhkjOPQQDAzAAMB4X
DTIxMDYwMjIyNTEzN1oXDTI0MDYwMTIyNTEzN1owFDESMBAGA1UEAxMJMTI3LjAu
MC4xMIIBIjANBgkqhkiG9w0BAQEFAAOCAQ8AMIIBCgKCAQEA1XFG27/HF7/dqZVy
qjhSUD3Q8t73IQObRGh+80g2UmmWz0RPFhgj8oXzUZUNumVbYG68rGedjGu/yGOd
OrJjrcLiuNdJY1dbtopRLWk3bhrdaby3syodPpoL/bNXJbKvdpEaUCjQb2xZp+2+
xnI9bWdLjQLqXcBH5BhLfmMwLLrwqD8W+2hgpJnYkPIsRDDkefjvUfGqI5aZBw7s
CAaCnfsed3wQJ8VOHh5gc2r6pkz4w33PnnAXwXZ8IvFCMvgpI5zjt0gs2T3nxBaU
cNVQ+S9lWNVvnyLi0LfljBYK1TcMW3pEHaVQuFqmaXWyEIenSCycEbrmgbiHwel/
8JfL8QIDAQABo1kwVzAOBgNVHQ8BAf8EBAMCBaAwEwYDVR0lBAwwCgYIKwYBBQUH
AwEwHwYDVR0jBBgwFoAUMIzofuOOjzQ//lYWLQtt1wX5JAcwDwYDVR0RBAgwBocE
fwAAATAKBggqhkjOPQQDAwNoADBlAjEAuIFtLP2m3orLXSCL4WQHKdm3BvDQ2KTl
2Cj3pyst/3VXLdI9i8rQ9NEhcU8Jq9X8AjALCglPHNavdyQefHy/oOqgfRfUYwzZ
BfeKq6bK9gmo8fv78iSiIgcLrkE1R44EWYw=
-----END CERTIFICATE-----
This is quite an unusual response for a GET <filename>.txt
request. We can further confirm
this by changing the .txt
file requested and observing the same output, e.g.
https://127.0.0.1:8000/blah/blag/more_blahg.txt?_=0
.woff
The .woff
endpoint is used to serve Metepreter stagers,
usually shellcode. This is not enabled on default Sliver HTTP/s, however when it is the endpoint
is unauthenticated and the data is unencrypted, so we can download the stagers easily:
# Setup Stager endpoint
[server] sliver > profiles new --profile-name win-shellcode --mtls 1.1.1.1 --format shellcode
[server] sliver > stage-listener --url https://0.0.0.1:8000 --profile win-shellcode
# Use curl to get payload
$> curl --insecure --output shellcode https://127.0.0.1:8000/shellcode_pls.woff?_=0
$> hexdump shellcode | head -4
0000000 80e8 763f 8000 763f 0000 0000 0000 0000
0000010 0000 0000 0000 0000 0000 0000 0000 0000
0000030 0000 0000 a000 d252 f655 22a3 f783 159a
0000040 5a70 9a2f 795f c1df 0f77 ec2e 6aec 0383
WireGuard
Unfortunately WireGuard servers are extremely hard to fingerprint, as the servers by design do not respond to any probe that doesn’t present the proper authentication certificate, so a probe cannot tell if is the server is listening or a specified port or not.
Sliver uses a popular libary to manage the WireGuard connection, so if you managed to record a legitimate network connection from an implant to the C2, fingerprinting the traffic as Sliver would probably also be quite difficult.
The one thing worth noting for defenders is that depending on your network, WireGuard traffic (esp from specific hosts e.g. servers) may be very unlikely, and so just observing any WireGuard traffic might be enough to warrant investigation.
Anti Fingerprinting
As Sliver is open source, all the fingnerprints could be changed, either by the Sliver developers in future releases of the tool, or by other people who may want to run customised versions.
Conclusion
I hope this helps demonstrate several ways to fingerprint not only Sliver, and also give people ideas on how to discover and fingerprint other C2s.