From d78828fd8172884038cbb441e0fef1b60ee39626 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=B8=96=E7=95=8C?= Date: Thu, 1 Jan 2026 16:56:54 +0800 Subject: [PATCH] Fix quic sniffer --- common/sniff/quic.go | 4 +- common/sniff/quic_blacklist.go | 34 +++--- common/sniff/quic_capture_test.go | 188 ++++++++++++++++++++++++++++++ common/sniff/quic_test.go | 4 +- 4 files changed, 212 insertions(+), 18 deletions(-) create mode 100644 common/sniff/quic_capture_test.go diff --git a/common/sniff/quic.go b/common/sniff/quic.go index 4c2e667c..049bd2c1 100644 --- a/common/sniff/quic.go +++ b/common/sniff/quic.go @@ -303,8 +303,6 @@ find: metadata.Protocol = C.ProtocolQUIC fingerprint, err := ja3.Compute(buffer.Bytes()) if err != nil { - metadata.Protocol = C.ProtocolQUIC - metadata.Client = C.ClientChromium metadata.SniffContext = fragments return E.Cause1(ErrNeedMoreData, err) } @@ -334,7 +332,7 @@ find: } if count(frameTypeList, frameTypeCrypto) > 1 || count(frameTypeList, frameTypePing) > 0 { - if maybeUQUIC(fingerprint) { + if isQUICGo(fingerprint) { metadata.Client = C.ClientQUICGo } else { metadata.Client = C.ClientChromium diff --git a/common/sniff/quic_blacklist.go b/common/sniff/quic_blacklist.go index d5ff7bcf..56a15152 100644 --- a/common/sniff/quic_blacklist.go +++ b/common/sniff/quic_blacklist.go @@ -1,21 +1,29 @@ package sniff import ( - "crypto/tls" - "github.com/sagernet/sing-box/common/ja3" ) -// Chromium sends separate client hello packets, but UQUIC has not yet implemented this behavior -// The cronet without this behavior does not have version 115 -var uQUICChrome115 = &ja3.ClientHello{ - Version: tls.VersionTLS12, - CipherSuites: []uint16{4865, 4866, 4867}, - Extensions: []uint16{0, 10, 13, 16, 27, 43, 45, 51, 57, 17513}, - EllipticCurves: []uint16{29, 23, 24}, - SignatureAlgorithms: []uint16{1027, 2052, 1025, 1283, 2053, 1281, 2054, 1537, 513}, -} +const ( + // X25519Kyber768Draft00 - post-quantum curve used by Go crypto/tls + x25519Kyber768Draft00 uint16 = 0x11EC // 4588 + // renegotiation_info extension used by Go crypto/tls + extensionRenegotiationInfo uint16 = 0xFF01 // 65281 +) -func maybeUQUIC(fingerprint *ja3.ClientHello) bool { - return !uQUICChrome115.Equals(fingerprint, true) +// isQUICGo detects native quic-go by checking for Go crypto/tls specific features. +// Note: uQUIC with Chromium mimicry cannot be reliably distinguished from real Chromium +// since it uses the same TLS fingerprint, so it will be identified as Chromium. +func isQUICGo(fingerprint *ja3.ClientHello) bool { + for _, curve := range fingerprint.EllipticCurves { + if curve == x25519Kyber768Draft00 { + return true + } + } + for _, ext := range fingerprint.Extensions { + if ext == extensionRenegotiationInfo { + return true + } + } + return false } diff --git a/common/sniff/quic_capture_test.go b/common/sniff/quic_capture_test.go new file mode 100644 index 00000000..4c9eb838 --- /dev/null +++ b/common/sniff/quic_capture_test.go @@ -0,0 +1,188 @@ +package sniff_test + +import ( + "context" + "crypto/tls" + "encoding/hex" + "errors" + "net" + "testing" + "time" + + "github.com/sagernet/quic-go" + "github.com/sagernet/sing-box/adapter" + "github.com/sagernet/sing-box/common/sniff" + + "github.com/stretchr/testify/require" +) + +func TestSniffQUICQuicGoFingerprint(t *testing.T) { + t.Parallel() + const testSNI = "test.example.com" + + udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + defer udpConn.Close() + + serverAddr := udpConn.LocalAddr().(*net.UDPAddr) + packetsChan := make(chan [][]byte, 1) + + go func() { + var packets [][]byte + udpConn.SetReadDeadline(time.Now().Add(3 * time.Second)) + for i := 0; i < 10; i++ { + buf := make([]byte, 2048) + n, _, err := udpConn.ReadFromUDP(buf) + if err != nil { + break + } + packets = append(packets, buf[:n]) + } + packetsChan <- packets + }() + + clientConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + defer clientConn.Close() + + tlsConfig := &tls.Config{ + ServerName: testSNI, + InsecureSkipVerify: true, + NextProtos: []string{"h3"}, + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + _, _ = quic.Dial(ctx, clientConn, serverAddr, tlsConfig, &quic.Config{}) + + select { + case packets := <-packetsChan: + t.Logf("Captured %d packets", len(packets)) + + var metadata adapter.InboundContext + for i, pkt := range packets { + err := sniff.QUICClientHello(context.Background(), &metadata, pkt) + t.Logf("Packet %d: err=%v, domain=%s, client=%s", i, err, metadata.Domain, metadata.Client) + if metadata.Domain != "" { + break + } + } + + t.Logf("\n=== quic-go TLS Fingerprint Analysis ===") + t.Logf("Domain: %s", metadata.Domain) + t.Logf("Client: %s", metadata.Client) + t.Logf("Protocol: %s", metadata.Protocol) + + // The client should be identified as quic-go, not chromium + // Current issue: it's being identified as chromium + if metadata.Client == "chromium" { + t.Log("WARNING: quic-go is being misidentified as chromium!") + } + + case <-time.After(5 * time.Second): + t.Fatal("Timeout") + } +} + +func TestSniffQUICInitialFromQuicGo(t *testing.T) { + t.Parallel() + + const testSNI = "test.example.com" + + // Create UDP listener to capture ALL initial packets + udpConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + defer udpConn.Close() + + serverAddr := udpConn.LocalAddr().(*net.UDPAddr) + + // Channel to receive captured packets + packetsChan := make(chan [][]byte, 1) + + // Start goroutine to capture packets + go func() { + var packets [][]byte + udpConn.SetReadDeadline(time.Now().Add(3 * time.Second)) + for i := 0; i < 5; i++ { // Capture up to 5 packets + buf := make([]byte, 2048) + n, _, err := udpConn.ReadFromUDP(buf) + if err != nil { + break + } + packets = append(packets, buf[:n]) + } + packetsChan <- packets + }() + + // Create QUIC client connection (will fail but we capture the initial packet) + clientConn, err := net.ListenUDP("udp", &net.UDPAddr{IP: net.IPv4(127, 0, 0, 1), Port: 0}) + require.NoError(t, err) + defer clientConn.Close() + + tlsConfig := &tls.Config{ + ServerName: testSNI, + InsecureSkipVerify: true, + NextProtos: []string{"h3"}, + } + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + // This will fail (no server) but sends initial packet + _, _ = quic.Dial(ctx, clientConn, serverAddr, tlsConfig, &quic.Config{}) + + // Wait for captured packets + select { + case packets := <-packetsChan: + t.Logf("Captured %d QUIC packets", len(packets)) + + for i, packet := range packets { + t.Logf("Packet %d: length=%d, first 30 bytes: %x", i, len(packet), packet[:min(30, len(packet))]) + } + + // Test sniffer with first packet + if len(packets) > 0 { + var metadata adapter.InboundContext + err := sniff.QUICClientHello(context.Background(), &metadata, packets[0]) + + t.Logf("First packet sniff error: %v", err) + t.Logf("Protocol: %s", metadata.Protocol) + t.Logf("Domain: %s", metadata.Domain) + t.Logf("Client: %s", metadata.Client) + + // If first packet needs more data, try with subsequent packets + // IMPORTANT: reuse metadata to accumulate CRYPTO fragments via SniffContext + if errors.Is(err, sniff.ErrNeedMoreData) && len(packets) > 1 { + t.Log("First packet needs more data, trying subsequent packets with shared context...") + for i := 1; i < len(packets); i++ { + // Reuse same metadata to accumulate fragments + err = sniff.QUICClientHello(context.Background(), &metadata, packets[i]) + t.Logf("Packet %d sniff result: err=%v, domain=%s, sniffCtx=%v", i, err, metadata.Domain, metadata.SniffContext != nil) + if metadata.Domain != "" || (err != nil && !errors.Is(err, sniff.ErrNeedMoreData)) { + break + } + } + } + + // Print hex dump for debugging + t.Logf("First packet hex:\n%s", hex.Dump(packets[0][:min(256, len(packets[0]))])) + + // Log final results + t.Logf("Final: Protocol=%s, Domain=%s, Client=%s", metadata.Protocol, metadata.Domain, metadata.Client) + + // Verify SNI extraction + if metadata.Domain == "" { + t.Errorf("Failed to extract SNI, expected: %s", testSNI) + } else { + require.Equal(t, testSNI, metadata.Domain, "SNI should match") + } + + // Check client identification - quic-go should be identified as quic-go, not chromium + t.Logf("Client identified as: %s (expected: quic-go)", metadata.Client) + } + + case <-time.After(5 * time.Second): + t.Fatal("Timeout waiting for QUIC packets") + } +} diff --git a/common/sniff/quic_test.go b/common/sniff/quic_test.go index 27243dd3..e2f53724 100644 --- a/common/sniff/quic_test.go +++ b/common/sniff/quic_test.go @@ -19,7 +19,7 @@ func TestSniffQUICChromeNew(t *testing.T) { var metadata adapter.InboundContext err = sniff.QUICClientHello(context.Background(), &metadata, pkt) require.Equal(t, metadata.Protocol, C.ProtocolQUIC) - require.Equal(t, metadata.Client, C.ClientChromium) + require.Empty(t, metadata.Client) require.ErrorIs(t, err, sniff.ErrNeedMoreData) pkt, err = hex.DecodeString("cc0000000108e241a0c601413b4f004046006d8f15dae9999edf39d58df6762822b9a2ab996d7f6a10044338af3b51b1814bc4ac0fa5a87c34c6ae604af8cabc5957c5240174deefc8e378719ffdab2ae4e15bf4514bea44894b626c685cd5d5c965f7e97b3a1bdc520b75813e747f37a3ae83ad38b9ca2acb0de4fc9424839a50c8fb815a62b498609fbbc59145698860e0509cc08a04d1b119daef844ba2f09c16e2665e5cc0b47624b71f7b950c54fd56b4a1fbb826cba44eeeee3949ced8f5de60d4c81b19ee59f75aa1abb33f22c6b13c27095eb1e99cff01fdc93e6e88da2622ee18c08a79f508befd7e33e99bca60e64bef9a47b764384bd93823daeeb6fcb4d7cfbc4ab53eff59b3636f6dcaaf229b5a94941b5712807166b9bd5e82cb4a9708a71451c4cd6f6e33fb2fe40c8c70dd51a30b37ff9c5e35783debde0093fde19ce074b4887b3c90980b107b9c0f32cf61a66f37c251b789abc4d27fc421207966846c8cc7faa42d9af6ad355a6bc94cb78223b612be8b3e2a4df61fee83a674a0ceb8b7c3a29b97102cda22fecdf6a4628e5b612bc17eab64d6f75feedd0b106c0419e484e66725759964cb5935ac5125e5ae920cd280bd40df57c1d7ae1845700bd4eb7b7ab12bc0850950bfe6e69edd6ac1daa5db2c2b07484327196e561c513462d72872dc6771c39f6b60d46a1f2c92343b7338450a0ef8e39f97fa70652b3a12cd04043698951627aaaa82cc95e76df92021d30e8014c984f12eea0143de8b17e5e4a36ec07bf4814251b391f168a59ef75afcd2319249aaba930f06bb7a11b9491e6f71b3d5774a6503a965e94edd0a67737282fc9cb0271779ff14151b7aa9267bb8f7d643185512515aeea513c0c98bfae782381a3317064195d8825cf8b25c17cdab5fced02612a3f2870e40df57e6ca3f08228a2b04e8de1425eb4b970118f9bbdc212223ff86a5d6b648cdf2366722f21de4b14a1014879eadb69215cdb1aa2a9f4f310ecfe3116214fe3ab0a23f4775a0a54b48d7dfd8f7283ed687b3ac7e1a7e42a0bdc3478aba8651c03e1e9cc9df17d106b8130afe854269b0103b7a696f452721887b19d8181830073c9f10684c65f96d3a6c6efbae044eec03d6399e001fa44d54635dc72f9b8ea6b87d0f452cad1e1e32273e2b47c40f2730235adcae8523b8282f86b8cf1ab63ae54aaa06130df3bbf6ecac7d7d1d43d2a87aea837267ff8ccfaa4b7e47b7ded909e6603d0b928a304f8915c839153598adc4178eb48bc0e98ad7793d7980275e1e491ba4847a4a04ae30fe7f5cc7d4b6f4f63a525e9964d72245860ca76a668a4654adb6619f16e9db79131e5675b93cafb96c92f1da8464d4fef2a22e7f9db695965fe2cc27ea30974629c8fe17cfa2f860179e1eb9faaa88a91ec9ce6da28c1a2894c3b932b5e1c807146718cc77ca13c61eaae00c7c99e019f599772064b198c5c2c5e863336367673630b417ac845ddb7c93b0856317e5d64bab208c5730abc2c63536784fbeaaec139dffc917e775715f1e42164ddef5138d4d163609ab3fbdcab968f8738385c0e7e34ff3cf7771a1dc5ba25a8850fdf96dabafa21f9065f307457ce9af4b7a73450c9d20a3b46fa8d3a1163d22bd01a7d17f0ec274181bf9640fa941427694bfeb1346089f7a851efe0fbb7a2041fa6bb6541ccbad77dd3e1a97999fc05f1fef070e7b5c4b385b8b2a8cc32483fdeba6a373970de2fa4139ba18e5916f949aab0aab2894") require.NoError(t, err) @@ -39,7 +39,7 @@ func TestSniffQUICChromium(t *testing.T) { var metadata adapter.InboundContext err = sniff.QUICClientHello(context.Background(), &metadata, pkt) require.Equal(t, metadata.Protocol, C.ProtocolQUIC) - require.Equal(t, metadata.Client, C.ClientChromium) + require.Empty(t, metadata.Client) require.ErrorIs(t, err, sniff.ErrNeedMoreData) pkt, err = hex.DecodeString("c90000000108f40d654cc09b27f5000044d073eb38807026d4088455e650e7ccf750d01a72f15f9bfc8ff40d223499db1a485cff14dbd45b9be118172834dc35dca3cf62f61a1266f40b92faf3d28d67a466cfdca678ddced15cd606d31959cf441828467857b226d1a241847c82c57312cefe68ba5042d929919bcd4403b39e5699fe87dda05df1b3801e048edee792458e9b1a9b1d4039df05847bcee3be567494b5876e3bd4c3220fe9dfdb2c07d77410f907f744251ef15536cc03b267d3668d5b75bc1ad2fe735cd3bb73519dd9f1625a49e17ad27bdeccf706c83b5ea339a0a05dd0072f4a8f162bd29926b4997f05613c6e4b0270b0c02805ca0543f27c1ff8505a5750bdd33529ee73c491050a10c6903f53c1121dbe0380e84c007c8df74a1b02443ed80ba7766aef5549e618d4fd249844ee28565142005369869299e8c3035ecef3d799f6cada8549e75b4ce4cbf4c85ef071fd7ff067b1ca9b5968dc41d13d011f6d7843823bac97acb1eb8ee45883f0f254b5f9bd4c763b67e2d8c70a7618a0ef0de304cf597a485126e09f8b2fd795b394c0b4bc4cd2634c2057970da2c798c5e8af7aed4f76f5e25d04e3f8c9c5a5b150d17e0d4c74229898c69b8dc7b8bcc9d359eb441de75c68fbdebec62fb669dcccfb1aad03e3fa073adb2ccf7bb14cbaf99e307d2c903ee71a8f028102eb510caee7e7397512086a78d1f95635c7d06845b5a708652dc4e5cd61245aae5b3c05b84815d84d367bce9b9e3f6d6b90701ac3679233c14d5ce2a1eff26469c966266dc6284bdb95c9c6158934c413a872ce22101e4163e3293d236b301592ca4ccacc1fd4c37066e79c2d9857c8a2560dcf0b33b19163c4240c471b19907476e7e25c65f7eb37276594a0f6b4c33c340cc3284178f17ac5e34dbe7509db890e4ddfd0540fbf9deb32a0101d24fe58b26c5f81c627db9d6ae59d7a111a3d5d1f6109f4eec0d0234e6d73c73a44f50999462724b51ce0fd8283535d70d9e83872c79c59897407a0736741011ae5c64862eb0712f9e7b07aa1d5418ca3fde8626257c6fe418f3c5479055bb2b0ab4c25f649923fc2a41c79aaa7d0f3af6d8b8cf06f61f0230d09bbb60bb49b9e49cc5973748a6cf7ffdee7804d424f9423c63e7ff22f4bd24e4867636ef9fe8dd37f59941a8a47c27765caa8e875a30b62834f17c569227e5e6ed15d58e05d36e76332befad065a2cd4079e66d5af189b0337624c89b1560c3b1b0befd5c1f20e6de8e3d664b3ac06b3d154b488983e14aa93266f5f8b621d2a9bb7ccce509eb26e025c9c45f7cccc09ce85b3103af0c93ce9822f82ecb168ca3177829afb2ea0da2c380e7b1728add55a5d42632e2290363d4cbe432b67e13691648e1acfab22cf0d551eee857709b428bb78e27a45aff6eca301c02e4d13cf36cc2494fdd1aef8dede6e18febd79dca4c6964d09b91c25a08f0947c76ab5104de9404459c2edf5f4adb9dfd771be83656f77fbbafb1ad3281717066010be8778952495383c9f2cf0a38527228c662a35171c5981731f1af09bab842fe6c3162ad4152a4221f560eb6f9bea66b294ffbd3643da2fe34096da13c246505452540177a2a0a1a69106e5cfc279a4890fc3be2952f26be245f930e6c2d9e7e26ee960481e72b99594a1185b46b94b6436d00ba6c70ffe135d43907c92c6f1c09fb9453f103730714f5700fa4347f9715c774cb04a7218dacc66d9c2fade18b14e684aa7fc9ebda0a28") require.NoError(t, err)