Туннелирование поверх QUIC
Содержание
Введение #
QUIC (Quick UDP Internet Connections) – это современный сетевой протокол, разработанный Google в 2012 году и впервые использованный в 2013 году. В мире сетевых технологий QUIC стремительно заменяет старый добрый TCP.
Рассмотрим ключевые отличия этих сетевых протоколов подробнее:
- Одноэтапное Рукопожатие В традиционном TCP установление соединения требует выполнения трёхступенчатого рукопожатия (SYN, SYN-ACK, ACK), что создает значительные задержки, особенно при первом подключении или в сетях с высоким RTT (Round-Trip Time). QUIC решает эту проблему, используя одноэтапное рукопожатие. В этом процессе клиент и сервер одновременно инициализируют соединение, обмениваются ключами шифрования и начинают передачу данных. Это позволяет значительно сократить время установки соединения и обеспечить мгновенный старт передачи данных.
- Многопотоковая Передача TCP управляет данными как единым потоком, что означает, что потеря одного пакета может замедлить весь процесс передачи, так как приемник должен дождаться повторной передачи потерянного пакета. В отличие от этого, QUIC использует многопотоковую передачу, где данные разбиваются на независимые потоки. Потеря пакета в одном потоке не влияет на другие, что делает QUIC более устойчивым к потере пакетов и улучшает общую пропускную способность.
- Встроенное Шифрование В TCP для обеспечения безопасности используется протокол TLS (Transport Layer Security), что требует дополнительного рукопожатия и обмена ключами, увеличивая задержки. QUIC интегрирует шифрование непосредственно в свой протокол, наследуя и улучшая безопасность TLS 1.3. Все пакеты передаются в зашифрованном виде, что защищает их от перехвата и модификации и снижает риск атак типа MITM (Man-In-The-Middle).
- UDP как Транспортный Протокол QUIC работает поверх UDP (User Datagram Protocol), что позволяет избежать задержек, связанных с установлением соединения и подтверждениями, характерных для TCP. UDP обеспечивает высокую скорость передачи данных и гибкость, так как не требует подтверждений получения каждого пакета. QUIC строит свои собственные механизмы контроля передачи данных поверх UDP, обеспечивая надежность и эффективность, что особенно полезно для приложений, где важны низкие задержки, таких как потоковое видео, онлайн-игры и т. д.
Туннелирование #
Теперь несколько слов про туннелирование и зачем это нужно. Туннелирование – это способ передачи данных из одной сети в другую посредством процесса, называемого инкапсуляцией. Туннелирование требует переупаковки данных трафика вместе со служебными полями, в область полезной нагрузки пакета несущего протокола, которая также может включать этап шифрования и, следовательно, может скрывать характер трафика, отправляемого через туннель в базовую сеть.
Чаще всего ситуации, в которых приходится использовать туннелирование, появляются у разработчиков, пентестеров или системных администраторов. Рассмотрим несколько ситуаций, в которых возникает необходимость использования туннелирования.
Есть удаленный сервер, (к которому у нас есть физический доступ, либо доступ через ssh или же через какие-то другие технологии) на котором запущены разные сервисы, но публичные доступы к ним закрыты (то-есть порты не пробрасываются наружу в свободный интернет):
- На сервере в изолированном docker-контейнере запущена тестовая база данных, которую нужно использовать для тестирования и разработки приложения у себя на локалхосте.
- На сервере запущено веб-приложение и нужно получить доступ для тестирования или же демонстрации.
- Во время пентестинга появилась необходимость получить доступ у себя на локалхосте к какому-то сервису, запущенному на удаленном сервере и т. д.
Разработка #
Теперь приступим к реализации CLI-приложения, которая покрывает самый базовый функционал туннелирования поверх протокола QUIC. Для этого будем использовать язык программирования GO. Ну а чтобы с нуля не изобретать велосипед, воспользуемся уже готовой прекрасной библиотекой quic-go для работы с протоколом QUIC.
Перед началом работы посмотрим на предварительную схему работы нашего приложения.Proxy – приложение, которое будет запущено на нашем локалхосте и, в свою очередь, будет запускать QUIC- и http-сервера, и запросы на http-сервер будет перенаправлять через QUIC-сервер на agent.
Agent – приложение, которое будет запускаться на удаленном сервере и устанавливать соединение с QUIC-сервером, и перенаправлять полученные данные на тот изолированный адрес, к которому мы хотим получить доступ.
Разберем работу приложения на схеме пошагово:
- http-сервер будет принимать запросы и конвертировать их в данные для передачи через QUIC.
- QUIC-сервер, в свою очередь, эти данные отправит по соединению, которое установил с ним agent.
- agent получит эти данные через QUIC, конвертирует их в http-запрос и отправит на адрес изолированного сервиса.
- agent получит ответ, конвертирует его обратно для передачи через QUIC и отправит на QUIC-сервер.
- 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
, которая у нас будет одновременно выполнять несколько действий.
- Запускать QUIC-сервер и слушать по указанному адресу
listenQUIC
. - Запускать 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()
// Dial the forward address
conn, err := net.Dial("tcp", forwardAddress)
if err != nil {
log.Printf("Failed to open connection: %v", err)
return
}
// Start relaying data between the QUIC stream and the TCP connection
if err := StartRelay(conn, stream); err != nil {
log.Printf("Failed to relay connectoins: %v", err)
return
}
}
Будем рассматривать только протокол TCP (HTTP1/HTTP2). Для этого откроем соединение с назначенным адресом с помощью net.Dial
.
И далее с помощью функции StartRelay
ретранслируем данные между соединениями.
func relay(src io.ReadCloser, dst io.Writer, stop chan error) {
defer src.Close()
_, err := io.Copy(dst, src)
stop <- err
return
}
func StartRelay(src io.ReadWriteCloser, dst io.ReadWriteCloser) error {
stop := make(chan error, 2)
go relay(src, dst, stop)
go relay(dst, src, stop)
select {
case err := <-stop:
return err
}
}
Результат #
Запустим наше приложение.
Для этого подключимся к удаленному серверу и запустим простой Python-сервер из терминала по адресу 127.0.0.1:9090
.Затем развернем приложение на удалённом сервере. Для этого запустим аналогичный простой Python-сервер у себя, а на удалённом сервере загрузим само приложение.Далее запустим proxy у себя на локалхосте, QUIC-сервер по адресу :3333
, а http-сервер по адресу 127.0.0.1:8000
․И на удаленном сервере подключимся к QUIC-серверу и сделаем перенаправление на адрес, на котором запущен Python-сервер – 127.0.0.1:9090
.Теперь всё готово, при обращении по адресу http://127.0.0.1:8000
наш трафик перенаправится к адресу 127.0.0.1:9090
на удаленном сервере.
Примечание:
Если при запуске приложение показывает предупреждение 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"
"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()
// Dial the forward address
conn, err := net.Dial("tcp", forwardAddress)
if err != nil {
log.Printf("Failed to open connection: %v", err)
return
}
// Start relaying data between the QUIC stream and the TCP connection
if err := StartRelay(conn, stream); err != nil {
log.Printf("Failed to relay connectoins: %v", err)
return
}
}
func relay(src io.ReadCloser, dst io.Writer, stop chan error) {
defer src.Close()
_, err := io.Copy(dst, src)
stop <- err
return
}
func StartRelay(src io.ReadWriteCloser, dst io.ReadWriteCloser) error {
stop := make(chan error, 2)
go relay(src, dst, stop)
go relay(dst, src, stop)
select {
case err := <-stop:
return err
}
}
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
}