Skip to main content
  1. Blog Posts/

Tunneling over QUIC

·14 mins

Introduction #

QUIC (Quick UDP Internet Connections) is a modern network protocol developed by Google in 2012 and first used in 2013. In the world of networking, QUIC is rapidly replacing good old TCP.
Let’s take a closer look at the key differences between these network protocols:

  1. One-Step Handshake Traditional TCP requires a three-way handshake (SYN, SYN-ACK, ACK) to establish a connection, which creates significant latency, especially when connecting for the first time or in networks with high RTT (Round-Trip Time). QUIC solves this problem by utilizing a one-step handshake. In this process, the client and server simultaneously initialize the connection, exchange encryption keys, and begin data transfer. This dramatically reduces connection setup time and ensures that data transmission begins instantly.
    tcp vs quic
    tcp vs quic
  2. Multistream Transmission TCP manages data as a single stream, which means that the loss of a single packet can slow down the entire transmission process as the receiver must wait for the lost packet to be retransmitted. In contrast, QUIC uses multi-stream transmission, where data is split into independent streams. Packet loss in one stream does not affect the other streams, making QUIC more resilient to packet loss and improving overall throughput.
  3. Built-in Encryption TCP uses TLS (Transport Layer Security) protocol for security, which requires additional handshaking and key exchange, increasing latency. QUIC integrates encryption directly into its protocol, inheriting and enhancing the security of TLS 1.3. All packets are transmitted encrypted, protecting them from interception and modification and reducing the risk of MITM (Man-In-The-Middle) attacks.
  4. UDP as Transport Protocol QUIC works on top of UDP (User Datagram Protocol) to avoid the connection establishment and acknowledgment delays associated with TCP. UDP provides high data rates and flexibility because it does not require acknowledgements of receipt of each packet. QUIC builds its own transmission control mechanisms on top of UDP, providing reliability and efficiency, which is especially useful for low latency applications such as video streaming, online gaming, etc.

Tunneling #

Now a few words about tunneling and why we need it. Tunneling is a way of transferring data from one network to another using a process called encapsulation. Tunneling requires repackaging the traffic data along with service fields into the payload area of a carrier protocol packet, which may also include an encryption step and therefore hide the nature of the traffic being tunneled to the underlying network.

Most often, situations in which it is necessary to use tunneling arise among developers, pentesters or system administrators. Let’s consider several situations in which it is necessary to use tunneling.
There is a remote server (to which we have physical access, or access via ssh or via some other technologies) on which various services are running, but public access to them is closed (i.e. ports are not forwarded out to the public Internet):

  1. A test database is running on the server in an isolated docker container, which should be used for testing and developing the application on your local host.
  2. There is a web application running on the server and you need to get access for testing or demonstration.
  3. During pentesting, it became necessary to gain access to a service running on a remote server on local host, etc.

Development #

Now let’s start implementing a CLI application that covers the most basic functionality of tunneling over the QUIC protocol. For this, we will use the GO programming language. And in order not to reinvent the wheel from scratch, we will use the already excellent quic-go library for working with the QUIC protocol.

Before we begin, let’s look at a preliminary diagram of how our application works.

application workflow diagram
application workflow diagram
Proxy – an application that will be launched on our localhost and, in turn, will launch QUIC and http servers, and requests to the http server will be redirected through the QUIC server to the agent.
Agent – an application that will be launched on a remote server and establish a connection with the QUIC server, and redirect the received data to the isolated address that we want to access.

Let’s look at the application’s operation step by step in the diagram:

  1. http server will accept requests and convert them into data for transmission via QUIC.
  2. QUICK-server, in turn, sends this data via the connection that agent established with it.
  3. agent will receive this data via QUIC, convert it into an http request and send it to the address of the isolated service.
  4. agent will receive the response, convert it back for transmission via QUIC and send it to the QUIC server.
  5. QUIC server will receive data from this connection, convert it into http response and pass it to http server.

Before writing the code, let’s think about how we will launch our application.
To launch proxy we will use the following command:

tunnel -lh 127.0.0.1:8080 -lq :3333
-lh listen http
-lq listen quic

And for agent, respectively:

tunnel -qa 10.10.15.5:3333 -fa web-app:5432
-lq quic address
-fa forward address

First, let’s implement the functionality of parsing command line parameters using the standard flag package:

func main() {
  listenHTTP := flag.String("lh", "", "Address to listen for HTTP (e.g., :8000)")
  listenQUIC := flag.String("lq", "", "Address to listen for QUIC (e.g., :3333)")
  quicAddress := flag.String("qa", "", "QUIC address to connect to (e.g., 10.10.15.5:3333)")
  forwardAddress := flag.String("fa", "", "Address to forward to (e.g., 127.0.0.1:4444)")
  flag.Parse()
}

Next, depending on the parameters entered, we will launch the corresponding functions:

func main() {
  // Flags parsing logic...
  if *listenHTTP != "" && *listenQUIC != "" {
    go startProxy(*listenHTTP, *listenQUIC)
  }

  if *quicAddress != "" && *forwardAddress != "" {
    startAgent(*quicAddress, *forwardAddress)
  }

  select {}
}

Let’s start by implementing the startProxy function, which will perform several actions simultaneously.

  1. Start QUIC server and listen at the specified address listenQUIC.
  2. Start HTTP server and listen at the specified address listenHTTP.
func startProxy(httpAddr, quicAddr string) {
  log.Printf("QUIC server listening on %s\n", quicAddr)
  quicListener, err := startQUICListener(quicAddr)
  if err != nil {
    log.Fatalf("Failed to start QUIC listener: %v", err)
  }
}

Now let’s write an implementation of the startQUICListener function, which creates a QUIC server and returns listener:

func startQUICListener(addr string) (*quic.Listener, error) {
  tlsConf, err := generateTLSConfig()
  if err != nil {
    return nil, err
  }
  return quic.ListenAddr(addr, tlsConf, &quic.Config{
    MaxIdleTimeout:  20 * time.Second,
    KeepAlivePeriod: 10 * time.Second,
  })
}

Let me say a few words about MaxIdleTimeout and KeepAlivePeriod.
MaxIdleTimeout is the maximum time that can pass without any incoming network activity after the handshake is complete, which is 30 seconds by default. If this time is exceeded, the connection is closed.
KeepAlivePeriod is the period for sending a packet to maintain the connection. By default, no packets are sent.

Don’t forget that QUIC implies mandatory traffic encryption, and uses TLS 1.3 for this. Let’s write a function that generates and returns *tls.Config to start the server:

func generateTLSConfig() (*tls.Config, error) {
  key, err := rsa.GenerateKey(rand.Reader, 1024)
  if err != nil {
    return nil, fmt.Errorf("GenerateKey error: %w", err)
  }
  template := x509.Certificate{SerialNumber: big.NewInt(1)}
  certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
  if err != nil {
    return nil, fmt.Errorf("CreateCertificate error: %w", err)
  }
  keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
  certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})

  tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
  if err != nil {
    return nil, fmt.Errorf("X509KeyPair error: %w", err)
  }
  return &tls.Config{
    Certificates: []tls.Certificate{tlsCert},
    NextProtos:   []string{"quic-tunnel"},
  }, nil
}

Next, we will write a handler for the http server:

func startProxy() {
  // Start QUIC listener logic...
  http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
    log.Printf("Received HTTP request for %s\n", r.URL.Path)

    session, err := quicListener.Accept(context.Background())
    if err != nil {
      http.Error(w, "Failed to accept QUIC session", http.StatusInternalServerError)
      return
    }

    stream, err := session.OpenStreamSync(context.Background())
    if err != nil {
      http.Error(w, "Failed to open QUIC stream", http.StatusInternalServerError)
      return
    }
    defer stream.Close()

    // Forward the HTTP request over the QUIC stream
    err = r.Write(stream)
    if err != nil {
      http.Error(w, "Failed to forward request over QUIC", http.StatusInternalServerError)
      return
    }

    // Read the response from the QUIC stream
    resp, err := http.ReadResponse(bufio.NewReader(stream), r)
    if err != nil {
      http.Error(w, "Failed to read response from QUIC", http.StatusInternalServerError)
      return
    }

    // Write the response back to the original HTTP client
    for key, val := range resp.Header {
      w.Header().Set(key, strings.Join(val, ";"))
    }
    w.WriteHeader(resp.StatusCode)
    io.Copy(w, resp.Body)
    resp.Body.Close()
    return
  })
}

Let me remind you that in this article we will not consider such things as managing different connections, private protocol and handshake, fault tolerance, load balancing and other things between proxy and agent. The purpose of the article is to analyze the minimal implementation of tunneling via QUIC.
In the handler, we will accept the connection from the agent side, and open a bidirectional data stream using session.OpenStreamSync. Next, we will redirect the request from the http server to the bidirectional data stream of the QUIC connection using r.Write(stream). Then we will get the response from the agent using http.ReadResponse(bufio.NewReader(stream), r).
Next, using io.Copy, we will redirect the data from the request to the response of our server, and don’t forget about the headers either.

If we leave the code as is, the first request will succeed, but subsequent requests will hang. This is because in the handler we try to get a QUIC session using quicListener.Accept, which blocks the application until a new session is obtained. Here we could create a structure to store and manage different sessions. However, since the goal of the article is to consider a minimal implementation of tunneling over QUIC, we will choose a different approach: we will reuse the same session without creating a new one each time.
To do this, we will create two variables for the mutex and the QUIC session itself:

  // Start QUIC listener logic...
  var session quic.Connection
  var sessionMu sync.Mutex
  // HTTP handler logic...

And in the handler we will reuse the existing session while it is valid, and we will open a new session only when necessary:

http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
  // ...
  sessionMu.Lock()
  if session == nil || session.Context().Err() != nil {
    var err error
    session, err = quicListener.Accept(context.Background())
    if err != nil {
      sessionMu.Unlock()
      http.Error(w, "Failed to accept QUIC session", http.StatusInternalServerError)
      return
    }
  }
  sessionMu.Unlock()
  // ...
})

All that remains is to launch our http-server to receive and redirect requests:

func startProxy(httpAddr, quicAddr string) {
  // Start QUIC listener and http handler logic...
  log.Info().Msgf("HTTP started on %s", s.httpAddress)
  return http.ListenAndServe(s.httpAddress, nil)
}

Now let’s write an implementation of the startAgent function.
It should connect to the proxy via the QUIC protocol and open a connection for a bidirectional data flow.
Then receive the redirected request through this flow, forward it to the final address specified when the agent was started, and return a response:

func startAgent(quicAddr, forwardAddress string) {
  tlsConf := &tls.Config{
    InsecureSkipVerify: true,
    NextProtos:         []string{"quic-tunnel"},
  }

  session, err := quic.DialAddr(context.Background(), quicAddr, tlsConf, &quic.Config{
    MaxIdleTimeout:  20 * time.Second,
    KeepAlivePeriod: 10 * time.Second,
  })
  if err != nil {
    log.Fatalf("Failed to dial QUIC address %s: %v", quicAddr, err)
  }
  defer session.CloseWithError(0, "")
}

Let’s set the value to InsecureSkipVerify։ true so that our agent will accept any certificate provided by the QUIC server and any server name in that certificate.
Next, let’s open a connection and start processing incoming data:

func startAgent(quicAddr, forwardAddress string) {
  // Dial proxy server logic...

  for {
    stream, err := session.AcceptStream(context.Background())
    if err != nil {
      log.Printf("Failed to accept QUIC stream: %v", err)
      return
    }

    go handleQUICStream(stream, forwardAddress)
  }
}

Inside the handleQUICStream function, we’ll write the logic for redirecting data, and run it all in a goroutine:

func handleQUICStream(stream quic.Stream, forwardAddress string) {
  defer stream.Close()

  // Forward the stream data to the HTTP server
  client := &http.Client{}
  req, err := http.ReadRequest(bufio.NewReader(stream))
  if err != nil {
    log.Printf("Failed to read HTTP request: %v", err)
    return
  }

  req.URL.Scheme = "http"
  req.URL.Host = forwardAddress
  req.RequestURI = ""

  resp, err := client.Do(req)
  if err != nil {
    log.Printf("Failed to forward request to HTTP server: %v", err)
    return
  }
  defer resp.Body.Close()

  // Send the HTTP response back over the QUIC stream
  resp.Write(stream)
}

Using http.ReadRequest we extract the http request from the data stream, and then send the request to the destination address.
And return the response to the data stream using the QUIC protocol.

Launching the application #

Let’s launch our application.
To do this, connect to a remote server and run a simple Python server from the terminal that listens at 127.0.0.1:9090.

python server on remote server
python server on remote server
Then we will get the application on a remote server. To do this, we will launch a similar simple Python server on our own host, and on the remote server we will load the application with wget.
python server on localhost
python server on localhost
get tunnel application to remote server
get tunnel application to remote server
Next, we’ll launch proxy on our localhost, QUIC server at :3333, and http server at 127.0.0.1:8000.
running proxy
running proxy
And on the remote server, we connect to the QUIC server and redirect to the address where the Python server is running – 127.0.0.1:9090.
running agent on remote server
running agent on remote server
Now everything is ready, when accessing the address http://127.0.0.1:8000 our traffic will be redirected to the address 127.0.0.1:9090 on the remote server.
request using curl
request using curl
result in browser
result in browser

Note:
If the application shows the warning failed to sufficiently increase receive buffer size (was: 208 kiB, wanted: 7168 kiB, got: 416 kiB) when it starts, you can fix it by increasing the maximum buffer size.

sudo sysctl -w net.core.rmem_max=7500000
sudo sysctl -w net.core.wmem_max=7500000

For details, see documentation.

I will add to the article that this application includes only the most basic tunneling functions, without error handling, load balancing across different sessions, session management, reconnection logic in case of connection problems, etc. If you wish, you can improve these functions yourself, using not only QUIC, but also HTTP/3.

Click here to see the source code:
package main

import (
 "bufio"
 "context"
 "crypto/rand"
 "crypto/rsa"
 "crypto/tls"
 "crypto/x509"
 "encoding/pem"
 "flag"
 "fmt"
 "io"
 "log"
 "math/big"
 "net/http"
 "strings"
 "sync"
 "time"

 "github.com/quic-go/quic-go"
)

func main() {
 listenHTTP := flag.String("lh", "", "Address to listen for HTTP (e.g., :8000)")
 listenQUIC := flag.String("lq", "", "Address to listen for QUIC (e.g., :3333)")
 quicAddress := flag.String("qa", "", "QUIC address to connect to (e.g., 10.10.15.5:3333)")
 forwardAddress := flag.String("fa", "", "Address to forward to (e.g., 127.0.0.1:4444)")
 flag.Parse()

 if *listenHTTP != "" && *listenQUIC != "" {
  go startProxy(*listenHTTP, *listenQUIC)
 }

 if *quicAddress != "" && *forwardAddress != "" {
  startAgent(*quicAddress, *forwardAddress)
 }

 select {}
}

func startProxy(httpAddr, quicAddr string) {
 log.Printf("QUIC server listening on %s\n", quicAddr)
 quicListener, err := startQUICListener(quicAddr)
 if err != nil {
  log.Fatalf("Failed to start QUIC listener: %v", err)
 }

 var session quic.Connection
 var sessionMu sync.Mutex

 http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
  log.Printf("Received HTTP request for %s\n", r.URL.Path)

  sessionMu.Lock()
  if session == nil || session.Context().Err() != nil {
   var err error
   session, err = quicListener.Accept(context.Background())
   if err != nil {
    sessionMu.Unlock()
    http.Error(w, "Failed to accept QUIC session", http.StatusInternalServerError)
    return
   }
  }
  sessionMu.Unlock()

  stream, err := session.OpenStreamSync(context.Background())
  if err != nil {
   http.Error(w, "Failed to open QUIC stream", http.StatusInternalServerError)
   return
  }
  defer stream.Close()

  // Forward the HTTP request over the QUIC stream
  err = r.Write(stream)
  if err != nil {
   http.Error(w, "Failed to forward request over QUIC", http.StatusInternalServerError)
   return
  }

  // Read the response from the QUIC stream
  resp, err := http.ReadResponse(bufio.NewReader(stream), r)
  if err != nil {
   http.Error(w, "Failed to read response from QUIC", http.StatusInternalServerError)
   return
  }

  // Write the response back to the original HTTP client
  for key, val := range resp.Header {
   w.Header().Set(key, strings.Join(val, ";"))
  }
  w.WriteHeader(resp.StatusCode)
  io.Copy(w, resp.Body)
  resp.Body.Close()
  return
 })

 log.Printf("HTTP server listening on %s\n", httpAddr)
 log.Fatal(http.ListenAndServe(httpAddr, nil))
}

func startQUICListener(addr string) (*quic.Listener, error) {
 tlsConf, err := generateTLSConfig()
 if err != nil {
  return nil, err
 }
 return quic.ListenAddr(addr, tlsConf, &quic.Config{
  MaxIdleTimeout:  20 * time.Second,
  KeepAlivePeriod: 10 * time.Second,
 })
}

func startAgent(quicAddr, forwardAddress string) {
 tlsConf := &tls.Config{
  InsecureSkipVerify: true,
  NextProtos:         []string{"quic-tunnel"},
 }

 session, err := quic.DialAddr(context.Background(), quicAddr, tlsConf, &quic.Config{
  MaxIdleTimeout:  20 * time.Second,
  KeepAlivePeriod: 10 * time.Second,
 })
 if err != nil {
  log.Fatalf("Failed to dial QUIC address %s: %v", quicAddr, err)
 }
 defer session.CloseWithError(0, "")

 for {
  stream, err := session.AcceptStream(context.Background())
  if err != nil {
   log.Printf("Failed to accept QUIC stream: %v", err)
   return
  }

  go handleQUICStream(stream, forwardAddress)
 }
}

func handleQUICStream(stream quic.Stream, forwardAddress string) {
 defer stream.Close()

 // Forward the stream data to the HTTP server
 client := &http.Client{}
 req, err := http.ReadRequest(bufio.NewReader(stream))
 if err != nil {
  log.Printf("Failed to read HTTP request: %v", err)
  return
 }

 req.URL.Scheme = "http"
 req.URL.Host = forwardAddress
 req.RequestURI = ""

 resp, err := client.Do(req)
 if err != nil {
  log.Printf("Failed to forward request to HTTP server: %v", err)
  return
 }
 defer resp.Body.Close()

 // Send the HTTP response back over the QUIC stream
 resp.Write(stream)
}

func generateTLSConfig() (*tls.Config, error) {
 key, err := rsa.GenerateKey(rand.Reader, 1024)
 if err != nil {
  return nil, fmt.Errorf("GenerateKey error: %w", err)
 }
 template := x509.Certificate{SerialNumber: big.NewInt(1)}
 certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key)
 if err != nil {
  return nil, fmt.Errorf("CreateCertificate error: %w", err)
 }
 keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)})
 certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER})

 tlsCert, err := tls.X509KeyPair(certPEM, keyPEM)
 if err != nil {
  return nil, fmt.Errorf("X509KeyPair error: %w", err)
 }
 return &tls.Config{
  Certificates: []tls.Certificate{tlsCert},
  NextProtos:   []string{"quic-tunnel"},
 }, nil
}