Перейти к содержанию
  1. Посты/

Туннелирование поверх QUIC

·12 минут

Введение #

QUIC (Quick UDP Internet Connections) – это современный сетевой протокол, разработанный Google в 2012 году и впервые использованный в 2013 году. В мире сетевых технологий QUIC стремительно заменяет старый добрый TCP.
Рассмотрим ключевые отличия этих сетевых протоколов подробнее:

  1. Одноэтапное Рукопожатие В традиционном TCP установление соединения требует выполнения трёхступенчатого рукопожатия (SYN, SYN-ACK, ACK), что создает значительные задержки, особенно при первом подключении или в сетях с высоким RTT (Round-Trip Time). QUIC решает эту проблему, используя одноэтапное рукопожатие. В этом процессе клиент и сервер одновременно инициализируют соединение, обмениваются ключами шифрования и начинают передачу данных. Это позволяет значительно сократить время установки соединения и обеспечить мгновенный старт передачи данных.
    tcp vs quic
    tcp vs quic
  2. Многопотоковая Передача TCP управляет данными как единым потоком, что означает, что потеря одного пакета может замедлить весь процесс передачи, так как приемник должен дождаться повторной передачи потерянного пакета. В отличие от этого, QUIC использует многопотоковую передачу, где данные разбиваются на независимые потоки. Потеря пакета в одном потоке не влияет на другие, что делает QUIC более устойчивым к потере пакетов и улучшает общую пропускную способность.
  3. Встроенное Шифрование В TCP для обеспечения безопасности используется протокол TLS (Transport Layer Security), что требует дополнительного рукопожатия и обмена ключами, увеличивая задержки. QUIC интегрирует шифрование непосредственно в свой протокол, наследуя и улучшая безопасность TLS 1.3. Все пакеты передаются в зашифрованном виде, что защищает их от перехвата и модификации и снижает риск атак типа MITM (Man-In-The-Middle).
  4. UDP как Транспортный Протокол QUIC работает поверх UDP (User Datagram Protocol), что позволяет избежать задержек, связанных с установлением соединения и подтверждениями, характерных для TCP. UDP обеспечивает высокую скорость передачи данных и гибкость, так как не требует подтверждений получения каждого пакета. QUIC строит свои собственные механизмы контроля передачи данных поверх UDP, обеспечивая надежность и эффективность, что особенно полезно для приложений, где важны низкие задержки, таких как потоковое видео, онлайн-игры и т. д.

Туннелирование #

Теперь несколько слов про туннелирование и зачем это нужно. Туннелирование – это способ передачи данных из одной сети в другую посредством процесса, называемого инкапсуляцией. Туннелирование требует переупаковки данных трафика вместе со служебными полями, в область полезной нагрузки пакета несущего протокола, которая также может включать этап шифрования и, следовательно, может скрывать характер трафика, отправляемого через туннель в базовую сеть.

Чаще всего ситуации, в которых приходится использовать туннелирование, появляются у разработчиков, пентестеров или системных администраторов. Рассмотрим несколько ситуаций, в которых возникает необходимость использования туннелирования.
Есть удаленный сервер, (к которому у нас есть физический доступ, либо доступ через ssh или же через какие-то другие технологии) на котором запущены разные сервисы, но публичные доступы к ним закрыты (то-есть порты не пробрасываются наружу в свободный интернет):

  1. На сервере в изолированном docker-контейнере запущена тестовая база данных, которую нужно использовать для тестирования и разработки приложения у себя на локалхосте.
  2. На сервере запущено веб-приложение и нужно получить доступ для тестирования или же демонстрации.
  3. Во время пентестинга появилась необходимость получить доступ у себя на локалхосте к какому-то сервису, запущенному на удаленном сервере и т. д.

Разработка #

Теперь приступим к реализации CLI-приложения, которая покрывает самый базовый функционал туннелирования поверх протокола QUIC. Для этого будем использовать язык программирования GO. Ну а чтобы с нуля не изобретать велосипед, воспользуемся уже готовой прекрасной библиотекой quic-go для работы с протоколом QUIC.

Перед началом работы посмотрим на предварительную схему работы нашего приложения.

схема работы приложения
схема работы приложения
Proxy – приложение, которое будет запущено на нашем локалхосте и, в свою очередь, будет запускать QUIC- и http-сервера, и запросы на http-сервер будет перенаправлять через QUIC-сервер на agent.
Agent – приложение, которое будет запускаться на удаленном сервере и устанавливать соединение с QUIC-сервером, и перенаправлять полученные данные на тот изолированный адрес, к которому мы хотим получить доступ.

Разберем работу приложения на схеме пошагово:

  1. http-сервер будет принимать запросы и конвертировать их в данные для передачи через QUIC.
  2. QUIC-сервер, в свою очередь, эти данные отправит по соединению, которое установил с ним agent.
  3. agent получит эти данные через QUIC, конвертирует их в http-запрос и отправит на адрес изолированного сервиса.
  4. agent получит ответ, конвертирует его обратно для передачи через QUIC и отправит на QUIC-сервер.
  5. QUIC-сервер получит данные из этого соединения, конвертирует их в http-ответ и передаст http-серверу.

Перед написанием кода давайте придумаем как будем запускать наше приложение.
Для запуска proxy будем использовать такую команду:

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

А для agent соответственно:

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

Для начала реализуем функционал парсинга параметров приложения, используя стандартный пакет flag:

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()
}

Далее в зависимости от того, какие параметры ввели, запустим соответствующие функции:

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

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

  select {}
}

Начнем с реализации функции startProxy, которая у нас будет одновременно выполнять несколько действий.

  1. Запускать QUIC-сервер и слушать по указанному адресу listenQUIC.
  2. Запускать HTTP-сервер и слушать по указанному адресу 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)
  }
}

Теперь напишем реализацию функции startQUICListener, которая создает QUIC-сервер и возвращает его:

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,
  })
}

Скажу несколько слов про MaxIdleTimeout и KeepAlivePeriod.
MaxIdleTimeout – это максимальное время, которое может пройти без какой-либо входящей сетевой активности после завершения рукопожатия, которое по умолчанию равно 30 секунд. Если это время превышено, соединение закрывается.
KeepAlivePeriod – это период отправки пакета для поддержания соединения. По умолчанию пакеты не отправляются.

Не забываем, что QUIC подразумевает обязательное шифрование трафика, и для этого использует TLS 1.3. Напишем функцию, которая генерирует и возвращает *tls.Config для запуска сервера:

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
}

Далее напишем обработчик для http-сервера:

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
  })
}

Напомню, что в статье мы не будем рассматривать такие вещи как управление разными соединениями, свой протокол и рукопожатие, отказоустойчивость, распределение нагрузки и другие вещи между proxy и agent. Цель статьи – разобрать минимальную реализацию туннелирования через QUIC.
В обработчике мы будем принимать соединение со стороны agent, и с помощью session.OpenStreamSync открывать двунаправленный поток данных. Далее перенаправим запрос из http-сервера в двунаправленный поток данных QUIC-соединения с помощью r.Write(stream). Затем получим ответ от agent с помощью http.ReadResponse(bufio.NewReader(stream), r).
Далее, с помощью io.Copy, данные из запроса перенаправим в ответ нашего сервера, и не забываем про заголовки тоже.

Если оставить код в текущем виде, то первый запрос выполнится успешно, но последующие будут зависать. Это произойдет из-за того, что в обработчике мы пытаемся получить QUIC-сеанс с помощью quicListener.Accept, который блокирует приложение до получения нового сеанса. Здесь мы могли бы создать структуру для хранения и управления различными сеансами. Однако, поскольку цель статьи – рассмотреть минимальную реализацию туннелирования поверх QUIC, мы выберем другой подход: будем переиспользовать один и тот же сеанс, не создавая новый каждый раз.
Для этого создадим две переменные для мютекса и самого QUIC-сеанса:

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

А в обработчике повторно будем использовать существующий сеанс, пока он действителен, и открывать новый сеанс будем только при необходимости:

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()
  // ...
})

Осталось запустить наш http-сервер для получения и перенаправления запросов:

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)
}

Теперь напишем реализацию функции startAgent.
Она должна подключиться к proxy по протоколу QUIC и открыть соединение для двунаправленного потока данных.
Затем через этот поток получить перенаправленный запрос, направить его к конечному адресу, указанному при запуске агента, и вернуть ответ:

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, "")
}

Укажем значение InsecureSkipVerify։ true, чтобы наш agent принимал любой сертификат, предоставленный QUIC-сервером, и любое имя сервера в этом сертификате.
Далее, откроем соединение и начнем обрабатывать входящие данные:

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)
  }
}

Внутри функции handleQUICStream напишем логику перенаправления данных, и всё это запустим в горутине:

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)
}

С помощью http.ReadRequest извлечем http-запрос из потока данных, а затем отправим запрос в назначенный адрес.
И возвратим ответ в поток данных по протоколу QUIC.

Результат #

Запустим наше приложение.
Для этого подключимся к удаленному серверу и запустим простой Python-сервер из терминала по адресу 127.0.0.1:9090.

python server на удаленном сервере
python server на удаленном сервере
Затем развернем приложение на удалённом сервере. Для этого запустим аналогичный простой Python-сервер у себя, а на удалённом сервере загрузим само приложение.
python server на локальном хосте
python server на локальном хосте
загрузка приложения tunnel на удаленный сервер
загрузка приложения tunnel на удаленный сервер
Далее запустим proxy у себя на локалхосте, QUIC-сервер по адресу :3333, а http-сервер по адресу 127.0.0.1:8000
запуск proxy
запуск proxy
И на удаленном сервере подключимся к QUIC-серверу и сделаем перенаправление на адрес, на котором запущен Python-сервер127.0.0.1:9090.
запуск agent на удаленном сервере
запуск agent на удаленном сервере
Теперь всё готово, при обращении по адресу http://127.0.0.1:8000 наш трафик перенаправится к адресу 127.0.0.1:9090 на удаленном сервере.
запрос с помощью curl
запрос с помощью curl
результат в браузере
результат в браузере

Примечание:
Если при запуске приложение показывает предупреждение failed to sufficiently increase receive buffer size (was: 208 kiB, wanted: 7168 kiB, got: 416 kiB), то это можно исправить, увеличив максимальный размер буфера.

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

Подробности читайте в документации.

Дополню статью тем, что это приложение включает в себя лишь самые базовые функции туннелирования, без обработки ошибок, распределения нагрузки на разные сессии, управления сессиями, логики повторного переподключения при помехах в соединении и т. д. При желании вы можете доработать эти функции сами, используя не только QUIC, но и HTTP/3.

Кликните сюда, чтобы увидеть исходный код:
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
}