VYPR
High severityNVD Advisory· Published Mar 25, 2022· Updated Apr 23, 2025

Denial of Service via reachable assertion in grpc-swift

CVE-2022-24777

Description

grpc-swift prior to 1.7.2 is vulnerable to a denial of service attack via a reachable assertion due to incorrect GOAWAY frame handling.

AI Insight

LLM-synthesized narrative grounded in this CVE's description and references.

grpc-swift prior to 1.7.2 is vulnerable to a denial of service attack via a reachable assertion due to incorrect GOAWAY frame handling.

Vulnerability

grpc-swift versions prior to 1.7.2 contain a vulnerability in the PingHandler component that incorrectly processes GOAWAY frames, leading to a reachable assertion. An attacker can trigger this assertion by sending a crafted sequence of frames, causing the server to crash. The issue was introduced by flawed logic when handling GOAWAY frames and is fixed in version 1.7.2 [1][2][4].

Exploitation

The attack requires no authentication or special network position; an attacker only needs to send a specific sequence of frames to the grpc-swift server. The exploit is low-effort and uses minimal resources, as the necessary frames are simple to construct. No user interaction is needed [1][4].

Impact

Successful exploitation results in a denial of service: the server crashes and drops all active connections and in-flight requests. The impact on availability is high, while confidentiality and integrity are not directly affected [1][4].

Mitigation

The vulnerability is fixed in grpc-swift version 1.7.2, released on March 25, 2022. Users should upgrade immediately to this version. There are no known workarounds. The 1.x branch is in maintenance mode, but the fix is available [1][3][4].

AI Insight generated on May 21, 2026. Synthesized from this CVE's description and the cited reference URLs; citations are validated against the source bundle.

Affected packages

Versions sourced from the GitHub Security Advisory.

PackageAffected versionsPatched versions
github.com/grpc/grpc-swiftSwiftURL
< 1.7.21.7.2

Affected products

2

Patches

1
858f977f2a51

Merge pull request from GHSA-r6ww-5963-7r95

https://github.com/grpc/grpc-swiftGeorge BarnettMar 23, 2022via ghsa
7 files changed · +139 17
  • FuzzTesting/FailCases/clusterfuzz-testcase-minimized-ServerFuzzer-release-4739158818553856+0 0 added
  • Sources/GRPC/GRPCIdleHandlerStateMachine.swift+39 7 modified
    @@ -189,10 +189,17 @@ struct GRPCIdleHandlerStateMachine {
         /// Whether the channel should be closed.
         private(set) var shouldCloseChannel: Bool
     
    +    /// Whether a ping should be sent after a GOAWAY frame.
    +    private(set) var shouldPingAfterGoAway: Bool
    +
         fileprivate static let none = Operations()
     
    -    fileprivate mutating func sendGoAwayFrame(lastPeerInitiatedStreamID streamID: HTTP2StreamID) {
    +    fileprivate mutating func sendGoAwayFrame(
    +      lastPeerInitiatedStreamID streamID: HTTP2StreamID,
    +      followWithPing: Bool = false
    +    ) {
           self.sendGoAwayWithLastPeerInitiatedStreamID = streamID
    +      self.shouldPingAfterGoAway = followWithPing
         }
     
         fileprivate mutating func cancelIdleTask(_ task: Scheduled<Void>) {
    @@ -220,6 +227,7 @@ struct GRPCIdleHandlerStateMachine {
           self.idleTask = nil
           self.sendGoAwayWithLastPeerInitiatedStreamID = nil
           self.shouldCloseChannel = false
    +      self.shouldPingAfterGoAway = false
         }
       }
     
    @@ -267,12 +275,7 @@ struct GRPCIdleHandlerStateMachine {
           operations.cancelIdleTask(state.idleTask)
     
         case var .quiescing(state):
    -      precondition(state.initiatedByUs)
    -      precondition(state.role == .client)
    -      // If we're a client and we initiated shutdown then it's possible for streams to be created in
    -      // the quiescing state as there's a delay between stream channels (i.e. `HTTP2StreamChannel`)
    -      // being created and us being notified about their creation (via a user event fired by
    -      // the `HTTP2Handler`).
    +      state.lastPeerInitiatedStreamID = streamID
           state.openStreams += 1
           self.state = .quiescing(state)
     
    @@ -466,6 +469,18 @@ struct GRPCIdleHandlerStateMachine {
     
           if state.hasOpenStreams {
             operations.notifyConnectionManager(about: .quiescing)
    +        switch state.role {
    +        case .client:
    +          // The server sent us a GOAWAY we'll just stop opening new streams and will send a GOAWAY
    +          // frame before we close later.
    +          ()
    +        case .server:
    +          // Client sent us a GOAWAY frame; we'll let the streams drain and then close. We'll tell
    +          // the client that we're going away and send them a ping. When we receive the pong we will
    +          // send another GOAWAY frame with a lower stream ID. In this case, the pong acts as an ack
    +          // for the GOAWAY.
    +          operations.sendGoAwayFrame(lastPeerInitiatedStreamID: .maxID, followWithPing: true)
    +        }
             self.state = .quiescing(.init(fromOperating: state, initiatedByUs: false))
           } else {
             // No open streams, we can close as well.
    @@ -494,6 +509,23 @@ struct GRPCIdleHandlerStateMachine {
         return operations
       }
     
    +  mutating func ratchetDownGoAwayStreamID() -> Operations {
    +    var operations: Operations = .none
    +
    +    switch self.state {
    +    case let .quiescing(state):
    +      let streamID = state.lastPeerInitiatedStreamID
    +      operations.sendGoAwayFrame(lastPeerInitiatedStreamID: streamID)
    +    case .operating, .waitingToIdle:
    +      // We can only ratchet down the stream ID if we're already quiescing.
    +      preconditionFailure()
    +    case .closing, .closed:
    +      ()
    +    }
    +
    +    return operations
    +  }
    +
       mutating func receiveSettings(_ settings: HTTP2Settings) -> Operations {
         // Log the change in settings.
         self.logger.debug(
    
  • Sources/GRPC/GRPCIdleHandler.swift+16 1 modified
    @@ -153,7 +153,19 @@ internal final class GRPCIdleHandler: ChannelInboundHandler {
             streamID: .rootStream,
             payload: .goAway(lastStreamID: streamID, errorCode: .noError, opaqueData: nil)
           )
    -      self.context?.writeAndFlush(self.wrapOutboundOut(goAwayFrame), promise: nil)
    +
    +      self.context?.write(self.wrapOutboundOut(goAwayFrame), promise: nil)
    +
    +      // We emit a ping after some GOAWAY frames.
    +      if operations.shouldPingAfterGoAway {
    +        let pingFrame = HTTP2Frame(
    +          streamID: .rootStream,
    +          payload: .ping(self.pingHandler.pingDataGoAway, ack: false)
    +        )
    +        self.context?.write(self.wrapOutboundOut(pingFrame), promise: nil)
    +      }
    +
    +      self.context?.flush()
         }
     
         // Close the channel, if necessary.
    @@ -181,6 +193,9 @@ internal final class GRPCIdleHandler: ChannelInboundHandler {
         case let .reply(framePayload):
           let frame = HTTP2Frame(streamID: .rootStream, payload: framePayload)
           self.context?.writeAndFlush(self.wrapOutboundOut(frame), promise: nil)
    +
    +    case .ratchetDownLastSeenStreamID:
    +      self.perform(operations: self.stateMachine.ratchetDownGoAwayStreamID())
         }
       }
     
    
  • Sources/GRPC/GRPCKeepaliveHandlers.swift+21 9 modified
    @@ -17,8 +17,11 @@ import NIOCore
     import NIOHTTP2
     
     struct PingHandler {
    -  /// Code for ping
    -  private let pingCode: UInt64
    +  /// Opaque ping data used for keep-alive pings.
    +  private let pingData: HTTP2PingData
    +
    +  /// Opaque ping data used for a ping sent after a GOAWAY frame.
    +  internal let pingDataGoAway: HTTP2PingData
     
       /// The amount of time to wait before sending a keepalive ping.
       private let interval: TimeAmount
    @@ -90,6 +93,7 @@ struct PingHandler {
         case schedulePing(delay: TimeAmount, timeout: TimeAmount)
         case cancelScheduledTimeout
         case reply(HTTP2Frame.FramePayload)
    +    case ratchetDownLastSeenStreamID
       }
     
       init(
    @@ -102,7 +106,8 @@ struct PingHandler {
         minimumReceivedPingIntervalWithoutData: TimeAmount? = nil,
         maximumPingStrikes: UInt? = nil
       ) {
    -    self.pingCode = pingCode
    +    self.pingData = HTTP2PingData(withInteger: pingCode)
    +    self.pingDataGoAway = HTTP2PingData(withInteger: ~pingCode)
         self.interval = interval
         self.timeout = timeout
         self.permitWithoutCalls = permitWithoutCalls
    @@ -137,8 +142,12 @@ struct PingHandler {
       }
     
       private func handlePong(_ pingData: HTTP2PingData) -> Action {
    -    if pingData.integer == self.pingCode {
    +    if pingData == self.pingData {
           return .cancelScheduledTimeout
    +    } else if pingData == self.pingDataGoAway {
    +      // We received a pong for a ping we sent to trail a GOAWAY frame: this means we can now
    +      // send another GOAWAY frame with a (possibly) lower stream ID.
    +      return .ratchetDownLastSeenStreamID
         } else {
           return .none
         }
    @@ -161,32 +170,35 @@ struct PingHandler {
             // This is a valid ping, reset our strike count and reply with a pong.
             self.pingStrikes = 0
             self.lastReceivedPingDate = self.now()
    -        return .reply(self.generatePingFrame(code: pingData.integer, ack: true))
    +        return .reply(self.generatePingFrame(data: pingData, ack: true))
           }
         } else {
           // We don't support ping strikes. We'll just reply with a pong.
           //
           // Note: we don't need to update `pingStrikes` or `lastReceivedPingDate` as we don't
           // support ping strikes.
    -      return .reply(self.generatePingFrame(code: pingData.integer, ack: true))
    +      return .reply(self.generatePingFrame(data: pingData, ack: true))
         }
       }
     
       mutating func pingFired() -> Action {
         if self.shouldBlockPing {
           return .none
         } else {
    -      return .reply(self.generatePingFrame(code: self.pingCode, ack: false))
    +      return .reply(self.generatePingFrame(data: self.pingData, ack: false))
         }
       }
     
    -  private mutating func generatePingFrame(code: UInt64, ack: Bool) -> HTTP2Frame.FramePayload {
    +  private mutating func generatePingFrame(
    +    data: HTTP2PingData,
    +    ack: Bool
    +  ) -> HTTP2Frame.FramePayload {
         if self.activeStreams == 0 {
           self.sentPingsWithoutData += 1
         }
     
         self.lastSentPingDate = self.now()
    -    return HTTP2Frame.FramePayload.ping(HTTP2PingData(withInteger: code), ack: ack)
    +    return HTTP2Frame.FramePayload.ping(data, ack: ack)
       }
     
       /// Returns true if, on receipt of a ping, the ping should be regarded as a ping strike.
    
  • Tests/GRPCTests/GRPCIdleHandlerStateMachineTests.swift+50 0 modified
    @@ -24,6 +24,10 @@ class GRPCIdleHandlerStateMachineTests: GRPCTestCase {
         return GRPCIdleHandlerStateMachine(role: .client, logger: self.clientLogger)
       }
     
    +  private func makeServerStateMachine() -> GRPCIdleHandlerStateMachine {
    +    return GRPCIdleHandlerStateMachine(role: .server, logger: self.serverLogger)
    +  }
    +
       private func makeNoOpScheduled() -> Scheduled<Void> {
         let loop = EmbeddedEventLoop()
         return loop.scheduleTask(deadline: .distantFuture) { return () }
    @@ -469,6 +473,43 @@ class GRPCIdleHandlerStateMachineTests: GRPCTestCase {
         // The peer initiated shutdown by sending GOAWAY, we'll idle.
         op6.assertConnectionManager(.idle)
       }
    +
    +  func testClientSendsGoAwayAndOpensStream() {
    +    var stateMachine = self.makeServerStateMachine()
    +
    +    let op1 = stateMachine.receiveSettings([])
    +    op1.assertConnectionManager(.ready)
    +    op1.assertScheduleIdleTimeout()
    +
    +    // Schedule the idle timeout.
    +    let op2 = stateMachine.scheduledIdleTimeoutTask(self.makeNoOpScheduled())
    +    op2.assertDoNothing()
    +
    +    // Create a stream to cancel the task.
    +    let op3 = stateMachine.streamCreated(withID: 1)
    +    op3.assertCancelIdleTimeout()
    +
    +    // Receive a GOAWAY frame from the client.
    +    let op4 = stateMachine.receiveGoAway()
    +    op4.assertGoAway(streamID: .maxID)
    +    op4.assertShouldPingAfterGoAway()
    +
    +    // Create another stream. This is fine, the client hasn't ack'd the ping yet.
    +    let op5 = stateMachine.streamCreated(withID: 7)
    +    op5.assertDoNothing()
    +
    +    // Receiving the ping is handled by a different state machine which will tell us to ratchet
    +    // down the go away stream ID.
    +    let op6 = stateMachine.ratchetDownGoAwayStreamID()
    +    op6.assertGoAway(streamID: 7)
    +    op6.assertShouldNotPingAfterGoAway()
    +
    +    let op7 = stateMachine.streamClosed(withID: 7)
    +    op7.assertDoNothing()
    +
    +    let op8 = stateMachine.streamClosed(withID: 1)
    +    op8.assertShouldClose()
    +  }
     }
     
     extension GRPCIdleHandlerStateMachine.Operations {
    @@ -477,6 +518,7 @@ extension GRPCIdleHandlerStateMachine.Operations {
         XCTAssertNil(self.idleTask)
         XCTAssertNil(self.sendGoAwayWithLastPeerInitiatedStreamID)
         XCTAssertFalse(self.shouldCloseChannel)
    +    XCTAssertFalse(self.shouldPingAfterGoAway)
       }
     
       func assertGoAway(streamID: HTTP2StreamID) {
    @@ -524,4 +566,12 @@ extension GRPCIdleHandlerStateMachine.Operations {
       func assertShouldNotClose() {
         XCTAssertFalse(self.shouldCloseChannel)
       }
    +
    +  func assertShouldPingAfterGoAway() {
    +    XCTAssert(self.shouldPingAfterGoAway)
    +  }
    +
    +  func assertShouldNotPingAfterGoAway() {
    +    XCTAssertFalse(self.shouldPingAfterGoAway)
    +  }
     }
    
  • Tests/GRPCTests/GRPCPingHandlerTests.swift+8 0 modified
    @@ -347,6 +347,12 @@ class GRPCPingHandlerTests: GRPCTestCase {
         )
       }
     
    +  func testPongWithGoAwayPingData() {
    +    self.setupPingHandler()
    +    let response = self.pingHandler.read(pingData: self.pingHandler.pingDataGoAway, ack: true)
    +    XCTAssertEqual(response, .ratchetDownLastSeenStreamID)
    +  }
    +
       private func setupPingHandler(
         pingCode: UInt64 = 1,
         interval: TimeAmount = .seconds(15),
    @@ -379,6 +385,8 @@ extension PingHandler.Action: Equatable {
           return lhsDelay == rhsDelay && lhsTimeout == rhsTimeout
         case (.cancelScheduledTimeout, .cancelScheduledTimeout):
           return true
    +    case (.ratchetDownLastSeenStreamID, .ratchetDownLastSeenStreamID):
    +      return true
         case let (.reply(lhsPayload), .reply(rhsPayload)):
           switch (lhsPayload, rhsPayload) {
           case (let .ping(lhsData, ack: lhsAck), let .ping(rhsData, ack: rhsAck)):
    
  • Tests/GRPCTests/ServerFuzzingRegressionTests.swift+5 0 modified
    @@ -83,4 +83,9 @@ final class ServerFuzzingRegressionTests: GRPCTestCase {
         let name = "clusterfuzz-testcase-minimized-ServerFuzzer-release-5285159577452544"
         XCTAssertNoThrow(try self.runTest(withInputNamed: name))
       }
    +
    +  func testFuzzCase_release_4739158818553856() {
    +    let name = "clusterfuzz-testcase-minimized-ServerFuzzer-release-4739158818553856"
    +    XCTAssertNoThrow(try self.runTest(withInputNamed: name))
    +  }
     }
    

Vulnerability mechanics

Generated on May 9, 2026. Inputs: CWE entries + fix-commit diffs from this CVE's patches. Citations validated against bundle.

References

4

News mentions

0

No linked articles in our index yet.