From fd131a96a7bd686a591499f76a61cc9e5439c52b Mon Sep 17 00:00:00 2001 From: kappaone Date: Wed, 12 Aug 2026 16:13:48 +0300 Subject: [PATCH 1/3] fix(ble): reject negative and overlapping offsets in inbound write buffer A malicious BLE central can send ATT writes with a negative offset, which reaches Data.replaceSubrange via BLEInboundWriteBuffer.append and traps at runtime, crashing any peripheral that receives the write. Validate each chunk before mutating the buffer: reject negative offsets, non-monotonic sequences, and overlapping chunks, and drop the offending per-central buffer. Add an AppendResult.invalid case, handle it in the peripheral-role write handler, and cover the rejection paths with tests. --- .../Services/BLE/BLEInboundWriteBuffer.swift | 19 ++++ .../BLEService+LinkLayerPeripheralRole.swift | 5 + .../Services/BLEInboundWriteBufferTests.swift | 99 +++++++++++++++++++ 3 files changed, 123 insertions(+) diff --git a/bitchat/Services/BLE/BLEInboundWriteBuffer.swift b/bitchat/Services/BLE/BLEInboundWriteBuffer.swift index 155a16d6..8174f76c 100644 --- a/bitchat/Services/BLE/BLEInboundWriteBuffer.swift +++ b/bitchat/Services/BLE/BLEInboundWriteBuffer.swift @@ -18,6 +18,7 @@ struct BLEInboundWriteBuffer { case decoded(packet: BitchatPacket, metadata: BLEInboundWriteAppendMetadata) case waiting(metadata: BLEInboundWriteAppendMetadata) case oversized(metadata: BLEInboundWriteAppendMetadata) + case invalid(metadata: BLEInboundWriteAppendMetadata) } private var buffersByCentralID: [String: Data] = [:] @@ -34,10 +35,28 @@ struct BLEInboundWriteBuffer { var combined = buffersByCentralID[centralID] ?? Data() var appendedBytes = 0 var offsets: [Int] = [] + // New chunks must not overlap bytes already buffered for this central. + var lastEnd = combined.count for chunk in chunks where !chunk.data.isEmpty { offsets.append(chunk.offset) + + // Reject malformed writes before touching the buffer: a negative + // offset traps `Data.replaceSubrange`, and non-monotonic or + // overlapping offsets corrupt previously written bytes. + guard chunk.offset >= 0, chunk.offset >= lastEnd else { + let metadata = BLEInboundWriteAppendMetadata( + accumulatedBytes: combined.count, + appendedBytes: appendedBytes, + offsets: offsets, + packetType: combined.count >= 2 ? combined[1] : nil + ) + buffersByCentralID.removeValue(forKey: centralID) + return .invalid(metadata: metadata) + } + let end = chunk.offset + chunk.data.count + lastEnd = end if combined.count < end { combined.append(Data(repeating: 0, count: end - combined.count)) diff --git a/bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift b/bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift index c55c30e4..452db260 100644 --- a/bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift +++ b/bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift @@ -284,6 +284,11 @@ extension BLEService: CBPeripheralManagerDelegate { logAccumulatedCentralWrite(metadata, centralUUID: centralUUID) SecureLogger.warning("⚠️ Dropping oversized pending write buffer (\(metadata.accumulatedBytes) bytes) for central \(centralUUID.prefix(8))…", category: .session) logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted) + + case let .invalid(metadata): + logAccumulatedCentralWrite(metadata, centralUUID: centralUUID) + SecureLogger.warning("⚠️ Dropping malformed pending write (offsets=\(metadata.offsets)) for central \(centralUUID.prefix(8))…", category: .session) + logFailedSingleWriteIfNeeded(hasMultiple: hasMultiple, sortedRequests: sorted) } } } diff --git a/bitchatTests/Services/BLEInboundWriteBufferTests.swift b/bitchatTests/Services/BLEInboundWriteBufferTests.swift index 636e2abf..3e1fd44b 100644 --- a/bitchatTests/Services/BLEInboundWriteBufferTests.swift +++ b/bitchatTests/Services/BLEInboundWriteBufferTests.swift @@ -126,6 +126,105 @@ struct BLEInboundWriteBufferTests { } } + @Test + func appendRejectsNegativeOffsetAndClearsBuffer() throws { + var buffer = BLEInboundWriteBuffer() + _ = buffer.append( + chunks: [BLEInboundWriteChunk(offset: 0, data: Data(repeating: 0x01, count: 4))], + for: "central-1", + capBytes: 1024 + ) + + let result = buffer.append( + chunks: [BLEInboundWriteChunk(offset: -1, data: Data([0x02]))], + for: "central-1", + capBytes: 1024 + ) + + if case let .invalid(metadata) = result { + #expect(metadata.offsets == [-1]) + } else { + Issue.record("Expected negative offset to be rejected") + } + + // The poisoned buffer was cleared, so a clean full frame still decodes. + let packet = makePacket(timestamp: 0x123) + let frame = try #require(packet.toBinaryData(padding: false)) + let decoded = buffer.append( + chunks: [BLEInboundWriteChunk(offset: 0, data: frame)], + for: "central-1", + capBytes: 1024 + ) + + if case let .decoded(decodedPacket, _) = decoded { + #expect(decodedPacket.timestamp == packet.timestamp) + } else { + Issue.record("Expected clean frame to decode after invalid write reset the buffer") + } + } + + @Test + func appendRejectsOverlappingChunks() { + var buffer = BLEInboundWriteBuffer() + + let result = buffer.append( + chunks: [ + BLEInboundWriteChunk(offset: 0, data: Data(repeating: 0x01, count: 8)), + BLEInboundWriteChunk(offset: 4, data: Data(repeating: 0x02, count: 4)) + ], + for: "central-1", + capBytes: 1024 + ) + + if case let .invalid(metadata) = result { + #expect(metadata.offsets == [0, 4]) + } else { + Issue.record("Expected overlapping chunks to be rejected") + } + } + + @Test + func appendRejectsNonMonotonicChunks() { + var buffer = BLEInboundWriteBuffer() + + let result = buffer.append( + chunks: [ + BLEInboundWriteChunk(offset: 8, data: Data(repeating: 0x01, count: 4)), + BLEInboundWriteChunk(offset: 0, data: Data(repeating: 0x02, count: 4)) + ], + for: "central-1", + capBytes: 1024 + ) + + if case let .invalid(metadata) = result { + #expect(metadata.offsets == [8, 0]) + } else { + Issue.record("Expected non-monotonic chunks to be rejected") + } + } + + @Test + func appendRejectsOverlapWithPreviouslyBufferedBytes() { + var buffer = BLEInboundWriteBuffer() + _ = buffer.append( + chunks: [BLEInboundWriteChunk(offset: 0, data: Data(repeating: 0x01, count: 8))], + for: "central-1", + capBytes: 1024 + ) + + let result = buffer.append( + chunks: [BLEInboundWriteChunk(offset: 4, data: Data(repeating: 0x02, count: 4))], + for: "central-1", + capBytes: 1024 + ) + + if case let .invalid(metadata) = result { + #expect(metadata.offsets == [4]) + } else { + Issue.record("Expected overlap with buffered bytes to be rejected") + } + } + private func makePacket(timestamp: UInt64 = 0x0102030405) -> BitchatPacket { BitchatPacket( type: MessageType.message.rawValue, From b74e92958d1e8dcd58d6c2ef5750754d68cc4464 Mon Sep 17 00:00:00 2001 From: kappaone Date: Sun, 23 Aug 2026 18:20:01 +0300 Subject: [PATCH 2/3] fix(ble): treat offset-0 write on stale buffer as restart Per review: the central role writes with .withoutResponse, so legit inbound writes always land at offset 0. With a stale partial frame pending, the previous validation rejected the fresh write as invalid and dropped its bytes; clear the buffer and apply instead. Non-monotonic test now uses [8, 4] since offset 0 restarts; adds a test covering the stale-buffer restart path. --- .../Services/BLE/BLEInboundWriteBuffer.swift | 8 +++++ .../Services/BLEInboundWriteBufferTests.swift | 34 +++++++++++++++++-- 2 files changed, 40 insertions(+), 2 deletions(-) diff --git a/bitchat/Services/BLE/BLEInboundWriteBuffer.swift b/bitchat/Services/BLE/BLEInboundWriteBuffer.swift index 8174f76c..1a305405 100644 --- a/bitchat/Services/BLE/BLEInboundWriteBuffer.swift +++ b/bitchat/Services/BLE/BLEInboundWriteBuffer.swift @@ -41,6 +41,14 @@ struct BLEInboundWriteBuffer { for chunk in chunks where !chunk.data.isEmpty { offsets.append(chunk.offset) + // `.withoutResponse` writes always land at offset 0, so an + // offset-0 chunk arriving on a non-empty buffer is a fresh frame + // replacing stale partial bytes, not an overlap. + if chunk.offset == 0, !combined.isEmpty { + combined.removeAll() + lastEnd = 0 + } + // Reject malformed writes before touching the buffer: a negative // offset traps `Data.replaceSubrange`, and non-monotonic or // overlapping offsets corrupt previously written bytes. diff --git a/bitchatTests/Services/BLEInboundWriteBufferTests.swift b/bitchatTests/Services/BLEInboundWriteBufferTests.swift index 3e1fd44b..8a8ae55b 100644 --- a/bitchatTests/Services/BLEInboundWriteBufferTests.swift +++ b/bitchatTests/Services/BLEInboundWriteBufferTests.swift @@ -190,19 +190,49 @@ struct BLEInboundWriteBufferTests { let result = buffer.append( chunks: [ BLEInboundWriteChunk(offset: 8, data: Data(repeating: 0x01, count: 4)), - BLEInboundWriteChunk(offset: 0, data: Data(repeating: 0x02, count: 4)) + BLEInboundWriteChunk(offset: 4, data: Data(repeating: 0x02, count: 4)) ], for: "central-1", capBytes: 1024 ) if case let .invalid(metadata) = result { - #expect(metadata.offsets == [8, 0]) + #expect(metadata.offsets == [8, 4]) } else { Issue.record("Expected non-monotonic chunks to be rejected") } } + @Test + func appendTreatsOffsetZeroOnStaleBufferAsRestart() throws { + var buffer = BLEInboundWriteBuffer() + let packet = makePacket(timestamp: 0x456) + let frame = try #require(packet.toBinaryData(padding: false)) + let splitIndex = max(1, frame.count / 2) + + // Stale partial frame left pending from an earlier write. + _ = buffer.append( + chunks: [BLEInboundWriteChunk(offset: 0, data: frame.prefix(splitIndex))], + for: "central-1", + capBytes: 1024 + ) + + // A fresh `.withoutResponse` write lands at offset 0 and replaces it + // instead of being rejected as an overlap with the stale bytes. + let result = buffer.append( + chunks: [BLEInboundWriteChunk(offset: 0, data: frame)], + for: "central-1", + capBytes: 1024 + ) + + if case let .decoded(decoded, metadata) = result { + #expect(decoded.timestamp == packet.timestamp) + #expect(metadata.offsets == [0]) + } else { + Issue.record("Expected offset-0 write to restart the stale buffer") + } + } + @Test func appendRejectsOverlapWithPreviouslyBufferedBytes() { var buffer = BLEInboundWriteBuffer() From 101db180f4e38373ad5ac0c392f7a2eb2c3dc5a7 Mon Sep 17 00:00:00 2001 From: kappaone Date: Mon, 31 Aug 2026 17:27:53 +0300 Subject: [PATCH 3/3] fix(ble): ack malformed inbound writes with ATT invalidOffset error Responding .success to a poisoned write let the central believe the bytes were accepted; with the buffer cleared on .invalid, it would keep sending subsequent chunks into a fresh buffer. Respond with CBATTError.Code.invalidOffset for requests that fail offset validation so the central aborts the transfer. Responses still go out within the same callback, preserving the sub-millisecond ATT response requirement. --- .../BLEService+LinkLayerPeripheralRole.swift | 30 ++++++++++++++----- 1 file changed, 23 insertions(+), 7 deletions(-) diff --git a/bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift b/bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift index 452db260..b9af3d4f 100644 --- a/bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift +++ b/bitchat/Services/BLE/BLEService+LinkLayerPeripheralRole.swift @@ -244,14 +244,16 @@ extension BLEService: CBPeripheralManagerDelegate { if requests.count > 1 { SecureLogger.debug("📥 Received \(requests.count) write requests from central", category: .session) } - - // IMPORTANT: Respond immediately to prevent timeouts! - // We must respond within a few milliseconds or the central will timeout - for request in requests { - peripheral.respond(to: request, withResult: .success) + + // While panic-suspended we drop everything; still ACK so centrals + // do not stall waiting on a response. + guard !isPanicSuspended else { + for request in requests { + peripheral.respond(to: request, withResult: .success) + } + return } - guard !isPanicSuspended else { return } - + // Process writes. For long writes, CoreBluetooth may deliver multiple CBATTRequest values with offsets. // Combine per-central request values by offset before decoding. // Process directly on our message queue to match transport context @@ -271,6 +273,20 @@ extension BLEService: CBPeripheralManagerDelegate { capBytes: TransportConfig.blePendingWriteBufferCapBytes ) + // IMPORTANT: respond within a few milliseconds or the central will + // time out. Reject malformed writes with an ATT error instead of a + // silent success, so a central sending poisoned offsets learns the + // write failed instead of retrying into a cleared buffer. + if case .invalid = result { + for request in group { + peripheral.respond(to: request, withResult: .invalidOffset) + } + } else { + for request in group { + peripheral.respond(to: request, withResult: .success) + } + } + switch result { case let .decoded(packet, metadata): logAccumulatedCentralWrite(metadata, centralUUID: centralUUID)