Skip to content

Hysteria2

Hysteria2 is a QUIC-based proxy protocol designed for high-throughput, lossy networks. Xray-core integrates it using the apernet/quic-go library (a modified QUIC implementation) with custom congestion control ("Brutal") and QUIC datagram support for UDP proxying.

Overview

  • Direction: Inbound + Outbound
  • Transport: QUIC (over UDP)
  • Encryption: QUIC TLS 1.3 (handled by transport layer)
  • Authentication: Password-based (handled by transport layer)
  • TCP Proxying: Via QUIC streams
  • UDP Proxying: Via QUIC datagrams (unreliable) with fragmentation support
  • Congestion Control: Brutal (fixed bandwidth) or standard QUIC CC

Architecture

Hysteria2 in Xray-core is split between two layers:

  1. Transport layer (transport/internet/hysteria/): Handles QUIC connection establishment, TLS, authentication, congestion control
  2. Proxy layer (proxy/hysteria/): Handles the application-level protocol (TCP request/response framing, UDP message format)
mermaid
graph TD
    subgraph "Proxy Layer (proxy/hysteria/)"
        A[Client Handler] --> B[TCP Request/Response]
        A --> C[UDP Message Framing]
        D[Server Handler] --> B
        D --> C
    end
    subgraph "Transport Layer (transport/internet/hysteria/)"
        E[QUIC Connection] --> F[Brutal CC]
        E --> G[TLS 1.3]
        E --> H[Auth Validation]
    end
    B --> E
    C --> E

Wire Format

TCP Request

TCP proxying uses QUIC streams. Each TCP connection is a new QUIC stream with the following request format:

+-------------------+-------------------+--------------------+-------------------+
| Address Length     | Address           | Padding Length     | Padding           |
| QUIC varint       | bytes             | QUIC varint        | bytes             |
+-------------------+-------------------+--------------------+-------------------+

Source: proxy/hysteria/protocol.go:28-62

FieldEncodingConstraints
Address LengthQUIC variable-length integer1 to 2048
AddressUTF-8 string (e.g., "example.com:443")host:port format
Padding LengthQUIC variable-length integer0 to 4096
PaddingRandom bytesDiscarded on read
go
func WriteTCPRequest(w io.Writer, addr string) error {
    padding := tcpRequestPadding.String()  // 64-512 random bytes
    // varint(addrLen) + addr + varint(paddingLen) + padding
}

Source: proxy/hysteria/protocol.go:64-77

TCP Response

+--------+-------------------+-------------------+--------------------+-------------------+
| Status | Message Length    | Message           | Padding Length     | Padding           |
| 1B     | QUIC varint      | bytes             | QUIC varint        | bytes             |
+--------+-------------------+-------------------+--------------------+-------------------+

Source: proxy/hysteria/protocol.go:79-142

FieldSizeDescription
Status1 byte0x00 = OK, 0x01 = Error
Message LengthQUIC varint0 to 2048
MessagebytesError message (optional)
Padding LengthQUIC varint0 to 4096
PaddingbytesRandom padding

After the response header, the QUIC stream carries raw proxied TCP data.

Default Padding

go
var (
    tcpRequestPadding  = padding.Padding{Min: 64, Max: 512}
    tcpResponsePadding = padding.Padding{Min: 128, Max: 1024}
)

Source: proxy/hysteria/config.go:7-9

UDP Message

UDP uses QUIC datagrams (unreliable, unordered). Each datagram contains:

+------------+----------+---------+-----------+-------------------+---------+------+
| Session ID | Packet ID| Frag ID | Frag Count| Address Length    | Address | Data |
| 4B BE      | 2B BE    | 1B      | 1B        | QUIC varint       | bytes   | ...  |
+------------+----------+---------+-----------+-------------------+---------+------+

Source: proxy/hysteria/protocol.go:144-216

go
type UDPMessage struct {
    SessionID uint32  // 4 bytes, big-endian
    PacketID  uint16  // 2 bytes, big-endian
    FragID    uint8   // Fragment index (0-based)
    FragCount uint8   // Total fragments (1 = no fragmentation)
    Addr      string  // "host:port" with varint length prefix
    Data      []byte  // Remaining bytes
}

Source: proxy/hysteria/protocol.go:153-160

FieldSizeDescription
Session ID4 bytesIdentifies the UDP session (currently set to 0)
Packet ID2 bytesIdentifies the packet for fragmentation reassembly
Fragment ID1 byteFragment index (0 to FragCount-1)
Fragment Count1 byteTotal number of fragments (1 = unfragmented)
Address LengthQUIC varintLength of address string
AddressbytesDestination in "host:port" format
Dataremaining bytesThe actual UDP payload

QUIC Variable-Length Integer Encoding

Hysteria uses QUIC's variable-length integer encoding (RFC 9000):

RangePrefix BitsBytes
0-63001
64-16383012
16384-1073741823104
1073741824-4611686018427387903118
go
func varintPut(b []byte, i uint64) int {
    if i <= 63         { b[0] = uint8(i); return 1 }
    if i <= 16383      { b[0] = uint8(i>>8) | 0x40; b[1] = uint8(i); return 2 }
    if i <= 1073741823 { b[0] = uint8(i>>24) | 0x80; /* ... */ return 4 }
    // ...8-byte encoding
}

Source: proxy/hysteria/protocol.go:220-249

UDP Fragmentation

When a UDP message exceeds the QUIC datagram MTU, it is split into fragments:

go
func FragUDPMessage(m *UDPMessage, maxSize int) []UDPMessage {
    if m.Size() <= maxSize {
        return []UDPMessage{*m}
    }
    maxPayloadSize := maxSize - m.HeaderSize()
    fragCount := (len(fullPayload) + maxPayloadSize - 1) / maxPayloadSize
    // Split data across fragments, sharing same PacketID
}

Source: proxy/hysteria/frag.go:3-27

Defragger

The Defragger reassembles fragmented UDP messages. It handles one packet ID at a time -- if a new packet arrives before all fragments of the previous packet, the previous state is discarded:

go
type Defragger struct {
    pktID uint16
    frags []*UDPMessage
    count uint8
    size  int
}

func (d *Defragger) Feed(m *UDPMessage) *UDPMessage {
    if m.FragCount <= 1 { return m }  // No fragmentation
    if m.PacketID != d.pktID || m.FragCount != uint8(len(d.frags)) {
        // New packet, reset state
        d.pktID = m.PacketID
        d.frags = make([]*UDPMessage, m.FragCount)
    }
    // Collect fragment, assemble when complete
}

Source: proxy/hysteria/frag.go:29-73

Outbound Handler (Client)

File: proxy/hysteria/client.go

TCP Flow

go
func (c *Client) Process(ctx context.Context, link *transport.Link, dialer internet.Dialer) error {
    // For TCP: open QUIC stream
    conn, err := dialer.Dial(hyCtx.ContextWithRequireDatagram(ctx, isUDP), c.server.Destination)

    if target.Network == net.Network_TCP {
        // Write TCP request header
        WriteTCPRequest(bufferedWriter, target.NetAddr())
        // Read TCP response header
        ok, msg, err := ReadTCPResponse(conn)
        // Bidirectional copy
    }
}

Source: proxy/hysteria/client.go:49-117

UDP Flow

For UDP, the client uses QUIC datagrams. The InterUdpConn type (from the hysteria transport) is required:

go
if target.Network == net.Network_UDP {
    iConn := stat.TryUnwrapStatsConn(conn)
    _, ok := iConn.(*hysteria.InterUdpConn)
    if !ok {
        return errors.New("udp requires hysteria udp transport")
    }

    writer := &UDPWriter{Writer: conn, buf: make([]byte, MaxUDPSize), addr: target.NetAddr()}
    reader := &UDPReader{Reader: conn, buf: make([]byte, MaxUDPSize), df: &Defragger{}}
}

Source: proxy/hysteria/client.go:119-164

UDPWriter handles fragmentation automatically:

go
func (w *UDPWriter) WriteMultiBuffer(mb buf.MultiBuffer) error {
    msg := &UDPMessage{SessionID: 0, FragCount: 1, Addr: addr, Data: b.Bytes()}
    err := w.sendMsg(msg)
    var errTooLarge *quic.DatagramTooLargeError
    if go_errors.As(err, &errTooLarge) {
        msg.PacketID = uint16(rand.Intn(0xFFFF)) + 1
        fMsgs := FragUDPMessage(msg, int(errTooLarge.MaxDatagramPayloadSize))
        for _, fMsg := range fMsgs {
            w.sendMsg(&fMsg)
        }
    }
}

Source: proxy/hysteria/client.go:190-235

Inbound Handler (Server)

File: proxy/hysteria/server.go

The server handles both QUIC stream connections (TCP) and QUIC datagram connections (UDP):

go
func (s *Server) Process(ctx context.Context, network net.Network,
    conn stat.Connection, dispatcher routing.Dispatcher) error {

    iConn := stat.TryUnwrapStatsConn(conn)
    if _, ok := iConn.(*hysteria.InterUdpConn); ok {
        // UDP mode: read datagrams, defragger, dispatch
        for {
            msg, _ := ParseUDPMessage(b[:n])
            dfMsg := df.Feed(msg)
            if dfMsg != nil {
                dest, _ := net.ParseDestination("udp:" + dfMsg.Addr)
                // Dispatch via DispatchLink
            }
        }
    } else {
        // TCP mode: read request, dispatch, write response
        addr, _ := ReadTCPRequest(conn)
        dest, _ := net.ParseDestination("tcp:" + addr)
        WriteTCPResponse(bufferedWriter, true, "")
        dispatcher.DispatchLink(ctx, dest, &transport.Link{...})
    }
}

Source: proxy/hysteria/server.go:81-192

User Authentication

Users are validated at the transport layer. The server extracts user info from the connection:

go
type User interface{ User() *protocol.MemoryUser }
if v, ok := conn.(User); ok {
    inbound.User = v.User()
}

Source: proxy/hysteria/server.go:88-95

The account.Validator supports runtime user management (add/remove/get).

Source: proxy/hysteria/server.go:57-75

Constants

go
const (
    MaxAddressLength = 2048
    MaxMessageLength = 2048
    MaxPaddingLength = 4096
    MaxUDPSize       = 4096
)

Source: proxy/hysteria/protocol.go:13-17

Implementation Notes

  1. QUIC dependency: Hysteria2 depends on github.com/apernet/quic-go, a fork of quic-go with modifications for Brutal congestion control and other Hysteria-specific features.

  2. Transport-protocol separation: Unlike most Xray protocols where the proxy layer handles encryption, Hysteria delegates all cryptographic operations to the QUIC/TLS transport layer. The proxy layer only deals with request/response framing.

  3. Network type as TCP: The inbound reports its network as TCP (net.Network_TCP), even though the underlying transport is UDP/QUIC. This is because from Xray's perspective, QUIC streams behave like TCP connections.

Source: proxy/hysteria/server.go:77-79

  1. Datagram requirement: For UDP proxying, the client passes ContextWithRequireDatagram(ctx, true) to signal the transport layer that QUIC datagram support is needed.

Source: proxy/hysteria/client.go:59

  1. Simple defragmentation: The Defragger only tracks one packet at a time. If fragments from different packets are interleaved, reassembly fails. This is a deliberate simplicity trade-off -- in practice, QUIC datagrams are typically not reordered within a single connection.

  2. Address as string: Unlike binary-encoded addresses in VMess/Trojan/Shadowsocks, Hysteria uses plain text "host:port" strings for addresses, with QUIC varint length prefixes. This simplifies implementation at the cost of slightly more overhead.

  3. Session ID: Currently hardcoded to 0 in the client. The field exists for future multi-session multiplexing but is not used.

Source: proxy/hysteria/client.go:204

  1. Padding: Both request and response include random-length padding to resist traffic fingerprinting. Request padding is 64-512 bytes, response padding is 128-1024 bytes.

Technical analysis for re-implementation purposes.