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.
This commit is contained in:
kappaone 2026-08-12 16:13:48 +03:00
parent 9b84b36122
commit fd131a96a7
No known key found for this signature in database
3 changed files with 123 additions and 0 deletions

View File

@ -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))

View File

@ -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)
}
}
}

View File

@ -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,