From e859b12ad497b238db5289615b1ec6cbd49fe918 Mon Sep 17 00:00:00 2001 From: Tatiana Bradley Date: Thu, 1 Aug 2019 14:58:29 +0000 Subject: [PATCH] added successful pre-handshake injection attacks --- client_test.go | 49 ++++++++ integrationtests/self/mitm_test.go | 178 ++++++++++++++++++++++++++++- internal/testutils/testutils.go | 156 +++++++++++++++++++++++++ session_test.go | 97 ++++++++++++++++ 4 files changed, 479 insertions(+), 1 deletion(-) create mode 100644 internal/testutils/testutils.go diff --git a/client_test.go b/client_test.go index 8c4cfcbd..6b2086f7 100644 --- a/client_test.go +++ b/client_test.go @@ -663,4 +663,53 @@ var _ = Describe("Client", func() { Expect(cl.version).ToNot(BeZero()) Expect(cl.GetVersion()).To(Equal(cl.version)) }) + + Context("handling potentially injected packets", func() { + // NOTE: We hope these tests as written will fail once mitigations for injection adversaries are put in place. + + // Illustrates that adversary who injects any packet quickly can + // cause a real version negotiation packet to be ignored. + It("version negotiation packets ignored if any other packet is received", func() { + // Copy of existing test "recognizes that a non Version Negotiation packet means that the server accepted the suggested version" + sess := NewMockQuicSession(mockCtrl) + sess.EXPECT().handlePacket(gomock.Any()) + cl.session = sess + cl.config = &Config{} + buf := &bytes.Buffer{} + Expect((&wire.ExtendedHeader{ + Header: wire.Header{ + DestConnectionID: connID, + SrcConnectionID: connID, + Version: cl.version, + }, + PacketNumberLen: protocol.PacketNumberLen3, + }).Write(buf, protocol.VersionTLS)).To(Succeed()) + cl.handlePacket(&receivedPacket{data: buf.Bytes()}) + + // Version negotiation is now ignored + cl.config = &Config{} + ver := cl.version + cl.handlePacket(composeVersionNegotiationPacket(connID, []protocol.VersionNumber{1234})) + Expect(cl.version).To(Equal(ver)) + }) + + // Illustrates that adversary that injects a version negotiation packet + // with no supported versions can break a connection. + It("connection fails if no supported versions are found in version negotation packet", func() { + // Copy of existing test "errors if no matching version is found" + sess := NewMockQuicSession(mockCtrl) + done := make(chan struct{}) + sess.EXPECT().destroy(gomock.Any()).Do(func(err error) { + defer GinkgoRecover() + Expect(err).To(HaveOccurred()) + Expect(err.Error()).To(ContainSubstring("No compatible QUIC version found.")) + close(done) + }) + cl.session = sess + cl.config = &Config{Versions: protocol.SupportedVersions} + cl.handlePacket(composeVersionNegotiationPacket(connID, []protocol.VersionNumber{1337})) + Eventually(done).Should(BeClosed()) + }) + + }) }) diff --git a/integrationtests/self/mitm_test.go b/integrationtests/self/mitm_test.go index 62abdcca..a8fe91f3 100644 --- a/integrationtests/self/mitm_test.go +++ b/integrationtests/self/mitm_test.go @@ -15,6 +15,7 @@ import ( quicproxy "github.com/lucas-clemente/quic-go/integrationtests/tools/proxy" "github.com/lucas-clemente/quic-go/integrationtests/tools/testserver" "github.com/lucas-clemente/quic-go/internal/protocol" + "github.com/lucas-clemente/quic-go/internal/testutils" "github.com/lucas-clemente/quic-go/internal/wire" . "github.com/onsi/ginkgo" . "github.com/onsi/gomega" @@ -269,7 +270,182 @@ var _ = Describe("MITM test", func() { }) Context("successful injection attacks", func() { - // TODO(tatianab): add successful injection attacks + // These tests demonstrate that the QUIC protocol is vulnerable to injection attacks before the handshake + // finishes. In particular, an adversary who can intercept packets coming from one endpoint and send a reply + // that arrives before the real reply can tear down the connection in multiple ways. + + const rtt = 20 * time.Millisecond + + // AfterEach closes the proxy, but each function is responsible + // for closing client and server connections + AfterEach(func() { + // Test shutdown is tricky due to the proxy. Just wait for a bit. + time.Sleep(50 * time.Millisecond) + Expect(proxy.Close()).To(Succeed()) + }) + + // sendForgedVersionNegotiationPacket sends a fake VN packet with no supported versions + // from serverConn to client's remoteAddr + // expects hdr from an Initial packet intercepted from client + sendForgedVersionNegotationPacket := func(serverConn net.PacketConn, remoteAddr net.Addr, hdr *wire.Header) { + defer GinkgoRecover() + + // Create fake version negotiation packet with no supported versions + versions := []protocol.VersionNumber{} + packet, _ := wire.ComposeVersionNegotiation(hdr.SrcConnectionID, hdr.DestConnectionID, versions) + + // Send the packet + if _, err := serverConn.WriteTo(packet, remoteAddr); err != nil { + return + } + } + + // sendForgedRetryPacket sends a fake Retry packet with a modified srcConnID + // from serverConn to client's remoteAddr + // expects hdr from an Initial packet intercepted from client + sendForgedRetryPacket := func(serverConn net.PacketConn, remoteAddr net.Addr, hdr *wire.Header) { + defer GinkgoRecover() + + var x byte = 0x12 + fakeSrcConnID := protocol.ConnectionID{x, x, x, x, x, x, x, x} + retryPacket := testutils.ComposeRetryPacket(fakeSrcConnID, hdr.SrcConnectionID, hdr.DestConnectionID, []byte("token"), hdr.Version) + + if _, err := serverConn.WriteTo(retryPacket, remoteAddr); err != nil { + return + } + } + + // Send a forged Initial packet with no frames to client + // expects hdr from an Initial packet intercepted from client + sendForgedInitialPacket := func(conn net.PacketConn, remoteAddr net.Addr, hdr *wire.Header) { + defer GinkgoRecover() + + initialPacket := testutils.ComposeInitialPacket(hdr.DestConnectionID, hdr.SrcConnectionID, hdr.Version, hdr.DestConnectionID, testutils.NoFrame) + if _, err := conn.WriteTo(initialPacket, remoteAddr); err != nil { + return + } + } + + // Send a forged Initial packet with ACK for random packet to client + // expects hdr from an Initial packet intercepted from client + sendForgedInitialPacketWithAck := func(conn net.PacketConn, remoteAddr net.Addr, hdr *wire.Header) { + defer GinkgoRecover() + + // Fake Initial with ACK for packet 2 (unsent) + initialPacket := testutils.ComposeInitialPacket(hdr.DestConnectionID, hdr.SrcConnectionID, hdr.Version, hdr.DestConnectionID, testutils.AckFrame) + if _, err := conn.WriteTo(initialPacket, remoteAddr); err != nil { + return + } + } + + // runTestFail succeeds if an error occurs in dialing + // expects a proxy delay function that runs every time a packet is received + runTestFail := func(delayCb quicproxy.DelayCallback) { + startServerAndProxy(delayCb, nil) + raddr, err := net.ResolveUDPAddr("udp", fmt.Sprintf("localhost:%d", proxy.LocalPort())) + Expect(err).ToNot(HaveOccurred()) + _, err = quic.Dial( + clientConn, + raddr, + fmt.Sprintf("localhost:%d", proxy.LocalPort()), + getTLSClientConfig(), + &quic.Config{ + Versions: []protocol.VersionNumber{version}, + ConnectionIDLength: connIDLen, + }, + ) + Expect(err).To(HaveOccurred()) + } + + // fails immediately because client connection closes when it can't find compatible version + It("fails when a forged version negotiation packet is sent to client", func() { + delayCb := func(dir quicproxy.Direction, raw []byte) time.Duration { + fmt.Println() + if dir == quicproxy.DirectionIncoming { + defer GinkgoRecover() + + hdr, _, _, err := wire.ParsePacket(raw, connIDLen) + Expect(err).ToNot(HaveOccurred()) + + if !(hdr.Type == protocol.PacketTypeInitial) { + return 0 + } + + go sendForgedVersionNegotationPacket(serverConn, clientConn.LocalAddr(), hdr) + } + return rtt / 2 + } + runTestFail(delayCb) + }) + + // times out, because client doesn't accept subsequent real retry packets from server + // as it has already accepted a retry. + // TODO: determine behavior when server does not send Retry packets + initialPacketIntercepted := false + It("fails when a forged retry packet with modified srcConnID is sent to client", func() { + // serverConfig.AcceptToken = func(net.Addr, *quic.Token) bool { return true } + delayCb := func(dir quicproxy.Direction, raw []byte) time.Duration { + if dir == quicproxy.DirectionIncoming && !initialPacketIntercepted { + defer GinkgoRecover() + + hdr, _, _, err := wire.ParsePacket(raw, connIDLen) + Expect(err).ToNot(HaveOccurred()) + + if hdr.Type != protocol.PacketTypeInitial { + return 0 + } + + initialPacketIntercepted = true + go sendForgedRetryPacket(serverConn, clientConn.LocalAddr(), hdr) + } + return rtt / 2 + } + runTestFail(delayCb) + }) + + // times out, because client doesn't accept real retry packets from server because + // it has already accepted an initial. + // TODO: determine behavior when server does not send Retry packets + It("fails when a forged initial packet is sent to client", func() { + // serverConfig.AcceptToken = func(net.Addr, *quic.Token) bool { return true } + delayCb := func(dir quicproxy.Direction, raw []byte) time.Duration { + if dir == quicproxy.DirectionIncoming { + defer GinkgoRecover() + + hdr, _, _, err := wire.ParsePacket(raw, connIDLen) + Expect(err).ToNot(HaveOccurred()) + + if !(hdr.Type == protocol.PacketTypeInitial) { + return 0 + } + + go sendForgedInitialPacket(serverConn, clientConn.LocalAddr(), hdr) + } + return rtt + } + runTestFail(delayCb) + }) + + // client connection closes immediately on receiving ack for unsent packet + It("fails when a forged initial packet with ack for unsent packet is sent to client", func() { + delayCb := func(dir quicproxy.Direction, raw []byte) time.Duration { + if dir == quicproxy.DirectionIncoming { + defer GinkgoRecover() + + hdr, _, _, err := wire.ParsePacket(raw, connIDLen) + Expect(err).ToNot(HaveOccurred()) + + if !(hdr.Type == protocol.PacketTypeInitial) { + return 0 + } + + go sendForgedInitialPacketWithAck(serverConn, clientConn.LocalAddr(), hdr) + } + return rtt + } + runTestFail(delayCb) + }) + }) }) } diff --git a/internal/testutils/testutils.go b/internal/testutils/testutils.go new file mode 100644 index 00000000..3cb8bdce --- /dev/null +++ b/internal/testutils/testutils.go @@ -0,0 +1,156 @@ +package testutils + +import ( + "bytes" + + "github.com/lucas-clemente/quic-go/internal/handshake" + "github.com/lucas-clemente/quic-go/internal/protocol" + "github.com/lucas-clemente/quic-go/internal/wire" +) + +// Utilities for simulating packet injection and man-in-the-middle (MITM) attacker tests. +// Do not use for non-testing purposes. + +// CryptoFrameType types copied from unexported messageType in crypto_setup.go +type CryptoFrameType uint8 + +const ( + //typeClientHello CryptoFrameType = 1 + typeServerHello CryptoFrameType = 2 + //typeNewSessionTicket CryptoFrameType = 4 + //typeEncryptedExtensions CryptoFrameType = 8 + //typeCertificate CryptoFrameType = 11 + //typeCertificateRequest CryptoFrameType = 13 + //typeCertificateVerify CryptoFrameType = 15 + //typeFinished CryptoFrameType = 20 +) + +// getRawPacket returns a new raw packet with the specified header and payload +func getRawPacket(hdr *wire.ExtendedHeader, data []byte) []byte { + buf := &bytes.Buffer{} + hdr.Write(buf, protocol.VersionTLS) + return append(buf.Bytes(), data...) +} + +// packRawPayload returns a new raw payload containing given frames +func packRawPayload(version protocol.VersionNumber, frames ...wire.Frame) []byte { + buf := new(bytes.Buffer) + for _, cf := range frames { + cf.Write(buf, version) + } + return buf.Bytes() +} + +// ComposeCryptoFrame returns a new empty crypto frame of the specified +// type padded to size bytes with zeroes +func ComposeCryptoFrame(cft CryptoFrameType, size int) *wire.CryptoFrame { + data := make([]byte, size) + data[0] = byte(cft) + return &wire.CryptoFrame{ + Offset: 0, + Data: data, + } +} + +// ComposeConnCloseFrame returns a new Connection Close frame with a generic error +func ComposeConnCloseFrame() *wire.ConnectionCloseFrame { + return &wire.ConnectionCloseFrame{ + IsApplicationError: true, + ErrorCode: 0, + ReasonPhrase: "mitm attacker", + } +} + +// ComposeAckFrame returns a new Ack Frame that ACKs packet 0 +func ComposeAckFrame(smallest protocol.PacketNumber, largest protocol.PacketNumber) *wire.AckFrame { + ackRange := wire.AckRange{ + Smallest: smallest, + Largest: largest, + } + return &wire.AckFrame{ + AckRanges: []wire.AckRange{ackRange}, + DelayTime: 0, + } +} + +// InitialContents enumerates possible frames to include in forged Initial packets +type InitialContents int + +const ( + ServerHelloFrame InitialContents = iota + 1 + AckFrame + ConnectionCloseFrame + NoFrame +) + +// ComposeInitialPacket returns an Initial packet encrypted under key +// (the original destination connection ID) +// contains frame of specified type +func ComposeInitialPacket(srcConnID protocol.ConnectionID, destConnID protocol.ConnectionID, version protocol.VersionNumber, key protocol.ConnectionID, frameType InitialContents) []byte { + sealer, _, _ := handshake.NewInitialAEAD(key, protocol.PerspectiveServer) + + // compose payload + var payload []byte + switch frameType { + case ServerHelloFrame: + cf := ComposeCryptoFrame(typeServerHello, 20) + payload = packRawPayload(version, cf) + case AckFrame: + ack := ComposeAckFrame(2, 2) // ack packet 2 + payload = packRawPayload(version, ack) + case ConnectionCloseFrame: + ccf := ComposeConnCloseFrame() + payload = packRawPayload(version, ccf) + case NoFrame: + payload = make([]byte, protocol.MinInitialPacketSize) + } + + // compose Initial header + payloadSize := len(payload) + pnLength := protocol.PacketNumberLen4 + length := payloadSize + int(pnLength) + sealer.Overhead() + hdr := &wire.ExtendedHeader{ + Header: wire.Header{ + IsLongHeader: true, + Type: protocol.PacketTypeInitial, + SrcConnectionID: srcConnID, + DestConnectionID: destConnID, + Length: protocol.ByteCount(length), + Version: version, + }, + PacketNumberLen: pnLength, + PacketNumber: 0x0, + } + + raw := getRawPacket(hdr, payload) + + // encrypt payload and header + payloadOffset := len(raw) - payloadSize + var encrypted []byte + encrypted = sealer.Seal(encrypted, payload, hdr.PacketNumber, raw[:payloadOffset]) + hdrBytes := raw[0:payloadOffset] + encrypted = append(hdrBytes, encrypted...) + pnOffset := payloadOffset - int(pnLength) // packet number offset + sealer.EncryptHeader( + encrypted[payloadOffset:payloadOffset+16], // first 16 bytes of payload (sample) + &encrypted[0], // first byte of header + encrypted[pnOffset:payloadOffset], // packet number bytes + ) + return encrypted +} + +// ComposeRetryPacket returns a new raw Retry Packet +func ComposeRetryPacket(srcConnID protocol.ConnectionID, destConnID protocol.ConnectionID, origDestConnID protocol.ConnectionID, token []byte, version protocol.VersionNumber) []byte { + hdr := &wire.ExtendedHeader{ + Header: wire.Header{ + IsLongHeader: true, + Type: protocol.PacketTypeRetry, + SrcConnectionID: srcConnID, + DestConnectionID: destConnID, + OrigDestConnectionID: origDestConnID, + Token: token, + Version: version, + }, + } + return getRawPacket(hdr, nil) +} diff --git a/session_test.go b/session_test.go index 6ede1d1d..099ec3c7 100644 --- a/session_test.go +++ b/session_test.go @@ -20,6 +20,7 @@ import ( mockackhandler "github.com/lucas-clemente/quic-go/internal/mocks/ackhandler" "github.com/lucas-clemente/quic-go/internal/protocol" "github.com/lucas-clemente/quic-go/internal/qerr" + "github.com/lucas-clemente/quic-go/internal/testutils" "github.com/lucas-clemente/quic-go/internal/utils" "github.com/lucas-clemente/quic-go/internal/wire" ) @@ -1681,4 +1682,100 @@ var _ = Describe("Client Session", func() { Expect(err).To(MatchError("expected original_connection_id to equal 0xdeadbeef, is 0xdecafbad")) }) }) + + Context("handling potentially injected packets", func() { + var unpacker *MockUnpacker + + getPacket := func(extHdr *wire.ExtendedHeader, data []byte) *receivedPacket { + buf := &bytes.Buffer{} + Expect(extHdr.Write(buf, sess.version)).To(Succeed()) + return &receivedPacket{ + data: append(buf.Bytes(), data...), + buffer: getPacketBuffer(), + } + } + + // Convert an already packed raw packet into a receivedPacket + wrapPacket := func(packet []byte) *receivedPacket { + return &receivedPacket{ + data: packet, + buffer: getPacketBuffer(), + } + } + + // Illustrates that attacker may inject an Initial packet with a different + // source connection ID, causing endpoint to ignore a subsequent real Initial packets. + It("ignores Initial packets with a different source connection ID", func() { + // Modified from test "ignores packets with a different source connection ID" + unpacker = NewMockUnpacker(mockCtrl) + sess.unpacker = unpacker + + hdr1 := &wire.ExtendedHeader{ + Header: wire.Header{ + IsLongHeader: true, + Type: protocol.PacketTypeInitial, + DestConnectionID: sess.destConnID, + SrcConnectionID: sess.srcConnID, + Length: 1, + Version: sess.version, + }, + PacketNumberLen: protocol.PacketNumberLen1, + PacketNumber: 1, + } + hdr2 := &wire.ExtendedHeader{ + Header: wire.Header{ + IsLongHeader: true, + Type: protocol.PacketTypeInitial, + DestConnectionID: sess.destConnID, + SrcConnectionID: protocol.ConnectionID{0xde, 0xad, 0xbe, 0xef}, + Length: 1, + Version: sess.version, + }, + PacketNumberLen: protocol.PacketNumberLen1, + PacketNumber: 2, + } + Expect(sess.srcConnID).ToNot(Equal(hdr2.SrcConnectionID)) + // Send one packet, which might change the connection ID. + packer.EXPECT().ChangeDestConnectionID(sess.srcConnID).MaxTimes(1) + // only EXPECT one call to the unpacker + unpacker.EXPECT().Unpack(gomock.Any(), gomock.Any()).Return(&unpackedPacket{ + encryptionLevel: protocol.EncryptionInitial, + hdr: hdr1, + data: []byte{0}, // one PADDING frame + }, nil) + Expect(sess.handlePacketImpl(getPacket(hdr1, nil))).To(BeTrue()) + // The next packet has to be ignored, since the source connection ID doesn't match. + Expect(sess.handlePacketImpl(getPacket(hdr2, nil))).To(BeFalse()) + }) + + // Illustrates that an injected Initial with an ACK frame for an unsent causes + // the connection to immediately break down + It("fails on Initial-level ACK for unsent packet", func() { + sessionRunner.EXPECT().Retire(gomock.Any()) + initialPacket := testutils.ComposeInitialPacket(sess.destConnID, sess.srcConnID, sess.version, sess.destConnID, testutils.AckFrame) + Expect(sess.handlePacketImpl(wrapPacket(initialPacket))).To(BeFalse()) + }) + + // Illustrates that an injected Initial with a CONNECTION_CLOSE frame causes + // the connection to immediately break down + It("fails on Initial-level CONNECTION_CLOSE frame", func() { + sessionRunner.EXPECT().Remove(gomock.Any()) + initialPacket := testutils.ComposeInitialPacket(sess.destConnID, sess.srcConnID, sess.version, sess.destConnID, testutils.ConnectionCloseFrame) + Expect(sess.handlePacketImpl(wrapPacket(initialPacket))).To(BeTrue()) + }) + + // Illustrates that attacker who injects a Retry packet and changes the connection ID + // can cause subsequent real Initial packets to be ignored + It("ignores Initial packets which use original source id, after accepting a Retry", func() { + newSrcConnID := protocol.ConnectionID{0xde, 0xad, 0xbe, 0xef} + cryptoSetup.EXPECT().ChangeConnectionID(newSrcConnID) + packer.EXPECT().SetToken([]byte("foobar")) + packer.EXPECT().ChangeDestConnectionID(newSrcConnID) + + sess.handlePacketImpl(wrapPacket(testutils.ComposeRetryPacket(newSrcConnID, sess.destConnID, sess.destConnID, []byte("foobar"), sess.version))) + initialPacket := testutils.ComposeInitialPacket(sess.destConnID, sess.srcConnID, sess.version, sess.destConnID, testutils.NoFrame) + Expect(sess.handlePacketImpl(wrapPacket(initialPacket))).To(BeFalse()) + }) + + }) })