클라우드 Mac에서 iOS 바이너리 디코더의 경계 조건 검증하기
원격 빌드는 통과했는데, 기기가 수신한 바이너리 메시지 때문에 앱이 크래시할 수 있습니다. 이때 곧바로 네트워크 탓으로 돌리지 마세요. 길이 필드가 실제 내용보다 클 수도 있고, 데이터를 읽는 위치가 정렬되지 않았을 수도 있으며, 빅 엔디언 정수를 호스트 바이트 순서로 해석했을 수도 있습니다. VpsKeep 클라우드 Mac에서 먼저 명령줄 Swift로 이러한 입력을 고정된 테스트 샘플로 만들고, 같은 샘플을 iOS 단위 테스트에 적용할 수 있습니다. 검증의 목표는 모든 입력에서 데이터를 반환하는 것이 아니라, 잘못된 메시지를 거부하는 것입니다.
먼저 프레임 형식과 제한을 명확히 정의하기
이 글에서는 최소한의 프로토콜을 사용합니다. 첫 바이트는 버전, 다음 네 바이트는 빅 엔디언 부호 없는 페이로드 길이, 마지막은 원시 페이로드입니다. 버전은 1만 허용하며, 길이는 0일 수 있고 상한은 1 MiB입니다. 한 번의 디코딩으로 입력 전체를 소비해야 하며, 뒤에 불필요한 바이트가 남아 있어도 오류로 처리합니다.
이 단일 프레임 규약은 스트리밍 전송 규약과 다릅니다. 네트워크에서 한 번 읽은 데이터에는 프레임의 절반만 들어 있을 수도 있고, 프레임 두 개가 들어 있을 수도 있습니다. 데이터를 누적하고 프레임을 분리하는 일은 수신 계층의 책임입니다. 파서는 경계가 이미 확정된 프레임 하나만 입력받습니다. 불완전한 프레임에서 발생한 오류를 곧바로 연결 손상으로 판단해서는 안 됩니다.
길이 필드는 신뢰할 수 없는 입력입니다. 슬라이스를 만들거나 메모리를 할당하기 전에 상한과 남은 바이트 수부터 확인하세요. 상대가 선언한 길이대로 버퍼를 바로 생성해서는 안 됩니다.
먼저 xcrun swift --version을 실행하고 툴체인 출력 결과를 보관하세요. 예제는 Foundation만 사용하므로 시뮬레이터나 서명 설정이 필요하지 않습니다.
포인터에 대한 가정 대신 명시적인 커서 사용하기
아래 코드를 packet-check.swift로 저장하세요. 리더는 내부적으로 바이트 배열을 사용하며, 읽기에 성공한 경우에만 커서를 이동합니다. 검사할 때는 “요청한 양이 남은 양 이하인지”를 확인합니다. 이렇게 하면 신뢰할 수 없는 두 수를 더한 뒤 비교하는 방식을 피할 수 있습니다.
import Foundation
enum PacketError: Error {
case truncated, oversized, version, trailing
}
struct Reader {
let bytes: [UInt8]
private(set) var offset = 0
mutating func take(_ count: Int) throws -> [UInt8] {
guard count >= 0, count <= bytes.count - offset else {
throw PacketError.truncated
}
let start = offset
offset += count
return Array(bytes[start..<offset])
}
mutating func u32BE() throws -> UInt32 {
let b = try take(4)
return b.reduce(UInt32(0)) { ($0 << 8) | UInt32($1) }
}
}
func decode(_ data: Data) throws -> [UInt8] {
let limit = 1_048_576
guard data.count <= limit + 5 else {
throw PacketError.oversized
}
var reader = Reader(bytes: Array(data))
guard try reader.take(1)[0] == 1 else {
throw PacketError.version
}
let declared = try reader.u32BE()
guard let length = Int(exactly: declared), length <= limit else {
throw PacketError.oversized
}
let payload = try reader.take(length)
guard reader.offset == reader.bytes.count else {
throw PacketError.trailing
}
return payload
}
정수를 바이트 단위로 조합하면 빅 엔디언 순서를 명확히 표현하면서도 주소 정렬에 의존하지 않을 수 있습니다. 길이 검사를 강제 언래핑으로 대신하지 마세요. 오류를 무시하고 빈 페이로드를 반환해서도 안 됩니다. 그렇게 하면 정상적인 빈 프레임과 손상된 메시지를 구분할 수 없습니다.
배열로 변환할 때는 복사가 발생합니다. 이 글에서는 먼저 전체 프레임 크기를 제한해 복사 비용에도 상한을 둡니다. 대용량 파일을 다루는 프로토콜이라면 상한을 늘리고 프레임 전체를 계속 복사하는 대신, 크기가 제한된 청크 단위로 읽어야 합니다. 수신 계층에서도 누적 버퍼의 크기를 제한해야 합니다. 그렇지 않으면 파서에 도달하기 전에 메모리가 고갈될 수 있습니다.
정상 메시지를 바이트 단위로 잘라 테스트하기
다음 코드를 같은 파일에 추가하세요. 고정 페이로드에는 0 바이트와 최상위 비트가 설정된 바이트가 들어 있어, 테스트가 텍스트 데이터에만 한정되지 않습니다. 정상 프레임의 모든 불완전한 접두 부분을 순회합니다. 바이트 하나만 부족해도 반드시 오류를 던져야 하며, 크래시하거나 부분 결과를 반환해서는 안 됩니다.
func expectRejected(_ data: Data) {
do {
_ = try decode(data)
} catch {
return
}
fatalError("Invalid frame accepted")
}
let frame = Data([1, 0, 0, 0, 3, 0, 128, 255])
let payload = try decode(frame)
precondition(payload == [0, 128, 255])
for end in 0..<frame.count {
expectRejected(Data(frame.prefix(end)))
}
let empty = try decode(Data([1, 0, 0, 0, 0]))
precondition(empty.isEmpty)
expectRejected(Data([2, 0, 0, 0, 0]))
expectRejected(Data([1, 255, 255, 255, 255]))
expectRejected(Data([1, 0, 0, 0, 0, 7]))
xcrun swift packet-check.swift를 실행하세요. 정상 결과는 출력이 없고 종료 코드가 0인 상태입니다. 잘못된 입력을 의도치 않게 허용하면 프로그램이 종료됩니다. 파이프라인에서는 종료 상태를 직접 확인하면 되므로, 현지화된 로그를 파싱할 필요가 없습니다.
여기서는 최적화 빌드에서 검사를 비활성화하는 옵션을 사용하지 않습니다. 테스트 타깃으로 옮긴 뒤에는 성공 검증을 XCTAssertEqual로, 거부 검증을 XCTAssertThrowsError로 바꾸고, 구체적인 오류 유형까지 확인하세요.
길이와 바이트 순서에 대한 반례 보강하기
절단 테스트만으로는 바이트 순서가 올바르다는 사실을 입증할 수 없습니다. 선언된 길이가 256이고 실제 페이로드도 256인 고정 프레임을 추가하세요. 길이 헤더는 00 00 01 00이어야 합니다. 올바른 파서는 페이로드 전체를 반환해야 하며, 리틀 엔디언을 잘못 적용한 구현은 길이를 다른 값으로 해석하게 됩니다.
상한값과 상한값에 1을 더한 값도 함께 테스트하세요. 전자는 페이로드가 완전하면 성공해야 하고, 후자는 헤더만 있어도 즉시 거부해야 합니다. 여기에 “선언된 길이가 실제 내용보다 작은” 입력을 추가해, 엄격한 단일 프레임 모드에서 뒤에 남은 데이터를 조용히 무시하지 않는지 확인하세요.
테스트의 모든 헤더를 실제 서비스용 인코더로 생성하지는 마세요. 인코더와 디코더가 같은 오류를 공유하면 인코딩 후 디코딩하는 왕복 테스트도 통과할 수 있습니다. 최소한 몇 가지는 직접 검토한 바이트 샘플로 남겨 두고, 각 필드의 의미를 명시하세요.
오류 동작을 검증 기준에 포함하기
병합 전에 네 가지를 확인하세요. 완전한 정상 프레임은 모두 동일한 바이트를 복원해야 하고, 절단된 모든 접두 부분은 명확하게 실패해야 하며, 알 수 없는 버전과 상한을 초과한 길이는 거부되어야 하고, 뒤에 붙은 데이터가 단일 프레임 경계 검사를 우회해서는 안 됩니다. 테스트 기록에는 커밋 식별자, 툴체인 버전, 샘플 이름을 포함하되 실제 사용자 페이로드는 기록하지 마세요.
실패를 발견하면 먼저 원시 바이트를 보관한 뒤, 실패를 재현하는 가장 짧은 반례로 줄이세요. 테스트를 통과시키려고 상한부터 바꾸면 안 됩니다. 서비스 요구사항에 압축이나 중첩 필드가 추가되면 압축 해제 후의 크기와 중첩 깊이도 각각 제한해야 합니다. 바깥쪽 길이 제한만으로는 이러한 규칙을 대신할 수 없습니다.
이 검증은 프레임 구조만 다루며, 페이로드가 비즈니스 규칙상 유효한지는 판단하지 않습니다. 구조 파싱, 텍스트 디코딩, 비즈니스 검증을 분리해 두면 나중에 필드를 추가할 때 어느 계층에서 입력 거부 동작이 달라졌는지 파악할 수 있습니다.
자주 묻는 질문
Data 포인터에서 UInt32를 바로 읽지 않는 이유는 무엇인가요?
임의의 바이트 위치는 UInt32의 정렬 조건을 만족하지 않을 수 있으며 바이트 순서도 별도로 처리해야 합니다. 바이트를 하나씩 조합하면 이런 가정을 피할 수 있습니다.
빈 페이로드도 오류로 처리해야 하나요?
프로토콜 계약에 따라 결정합니다. 예제는 길이 0을 허용합니다. 내용이 필수라면 명시적인 검증과 별도 고정 테스트를 추가해야 합니다.
모든 절단 위치를 검사하면 퍼징이 필요 없나요?
아닙니다. 특정 정상 프레임의 접두 구간만 검사합니다. 필드 조합과 여러 프레임의 상태는 자원 상한을 둔 추가 테스트로 확인해야 합니다.
단계를 전용 클라우드 Mac에서 실행하세요
가상 머신이 아닌 Mac mini 한 대 전체를 임대하세요. 일·주·월·분기 단위로 기간을 선택하고, 모델과 리전을 확인한 뒤 주문을 확정하세요. 실제 이용 가능 여부는 콘솔에 표시되는 실시간 상태를 기준으로 합니다.