Cantina Threat Discovery: swift-crypto X-Wing HPKE Overread

TL;DR
CVE-2026-28815 (GHSA-9m44-rr2w-ppp7) is a Moderate-severity memory-safety bug in Apple's swift-crypto, the canonical TLS and cryptography library for Swift on Apple platforms and Linux. The X-Wing HPKE decapsulation path in versions 4.0.0 through 4.3.0 forwards an attacker-controlled Data value into a BoringSSL C function that expects a compile-time fixed 1120-byte buffer, with no runtime length check. A 1-byte encapsulated key reaches the same code path as a 1120-byte one. The C decap reads up to 1119 bytes past the end of the Swift buffer. AddressSanitizer flags it as dynamic-stack-buffer-overflow. The bug was found by Apex, Cantina's AppSec agent, and patched in swift-crypto 4.3.1.
Introduction
Swift is a memory-safe language. C is not. swift-crypto's X-Wing HPKE decapsulation runs in Swift and forwards its input to a C function from BoringSSL. The Swift caller accepted any Data length. The C callee assumed exactly 1120 bytes. The check that should have lived in between didn't exist.
Apex flagged the gap while reviewing swift-crypto's post-quantum HPKE wiring. A 1-byte X-Wing encapsulated key, passed through HPKE.Recipient initialization, reaches OpenSSLXWingPrivateKeyImpl.decapsulate(_:), which hands the raw pointer to XWING_decap. The C function reads XWING_CIPHERTEXT_BYTES (1120) bytes starting at that pointer. The Swift buffer is 1 byte long. The remaining 1119 reads land wherever the stack puts them.
CVE-2026-28815 is reachable from any application that initializes an HPKE.Recipient with externally provided encapsulated keys using the X-Wing post-quantum ciphersuite, which is exactly the class of code that handles untrusted client-to-server key encapsulations. The impact range is "remote crash" on the safe end and "remote memory disclosure" on the unsafe end, depending on what the runtime's memory protections are doing that day.
1 byte in. 1119 bytes out. Patched in swift-crypto 4.3.1.
Background
What is swift-crypto?
swift-crypto is Apple's open-source cross-platform port of CryptoKit for Linux and server-side Swift. It ships the same public API as the platform CryptoKit on Apple OSes and is the de facto standard cryptography stack for Swift applications outside the iOS/macOS sandbox. Under the hood, the heavy lifting on Linux is delegated to a vendored BoringSSL via a C bridge (CCryptoBoringSSL_* symbols).
What is X-Wing HPKE?
X-Wing is a hybrid Key Encapsulation Mechanism (KEM) that combines the classical X25519 elliptic-curve key exchange with the post-quantum ML-KEM-768 lattice scheme. swift-crypto exposes it through HPKE (Hybrid Public Key Encryption, RFC 9180) as the ciphersuite XWingMLKEM768X25519_SHA256_AES_GCM_256. A peer encrypts a session by producing an "encapsulated key" (the ciphertext output of the KEM) and sending it to the recipient. The recipient runs decapsulate(encapsulatedKey) to recover the shared secret.
The X-Wing encapsulated key is a fixed-size byte string. The BoringSSL build that backs swift-crypto defines its size as:
#define XWING_CIPHERTEXT_BYTES 1120
The C decapsulation function takes a pointer to that buffer and no length argument:
OPENSSL_EXPORT int XWING_decap(
uint8_t out_shared_secret[XWING_SHARED_SECRET_BYTES],
const uint8_t ciphertext[XWING_CIPHERTEXT_BYTES],
const struct XWING_private_key *private_key);
The contract is: the caller is responsible for passing a pointer to exactly 1120 readable bytes. There is no way for the C function to validate that contract at runtime. It just reads.
The FFI boundary
The Swift wrapper sits between an HPKE caller passing Data of unknown length and a C function that reads 1120 bytes unconditionally. Everything good about Swift's memory model stops at that boundary. The wrapper is the only place where a length check can live.
Discovery
Apex was reviewing swift-crypto's HPKE wiring for post-quantum ciphersuites, with a focus on the Swift-to-C bridge. The starting question was concrete: which paths through swift-crypto forward attacker-controlled byte buffers into BoringSSL C functions, and which of those C functions take fixed-size buffers without runtime length parameters?
Step one was enumerating the C functions. A grep for CCryptoBoringSSL_XWING_decap returned exactly one Swift caller: OpenSSLXWingPrivateKeyImpl.decapsulate(_:). Step two was checking what the C signature expects. The BoringSSL header in the vendored fork declares the ciphertext parameter as const uint8_t ciphertext[XWING_CIPHERTEXT_BYTES], where XWING_CIPHERTEXT_BYTES is the compile-time constant 1120.
Step three was the test that mattered: does the Swift wrapper enforce that length before calling XWING_decap? Apex traced decapsulate(_:) line by line. The function takes a Data parameter named encapsulated. It has bound it to an unsafe pointer with withUnsafeBytes. It hands the base address to XWING_decap. There was no length check.
Step four was the propagation trace: how does an attacker reach decapsulate(_:)? The public surface that touches it is HPKE.Recipient. The initializer HPKE.Context.init(recipientRoleWithCiphersuite:mode:enc:psk:pskID:skR:info:pkS:) calls skR.decapsulate(enc) directly with the caller-provided enc: Data.
That enc is the bytes a peer sends over the wire to initiate an HPKE session. It is attacker-controlled by definition.
Apex built the minimal PoC: a 1-byte encapsulatedKey, an X-Wing recipient, an HPKE.Recipient init call. The PoC reached the vulnerable decap. Under AddressSanitizer, the same PoC produced a dynamic-stack-buffer-overflow read. Memory-unsafe behavior, confirmed end to end.

The Vulnerability
The full vulnerable Swift function:
// swift-crypto / OpenSSLXWingPrivateKeyImpl.swift (pre-4.3.1)
func decapsulate(_ encapsulated: Data) throws -> SymmetricKey {
try SymmetricKey(unsafeUninitializedCapacity: Int(XWING_SHARED_SECRET_BYTES)) { sharedSecretBytes, count in
try encapsulated.withUnsafeBytes { encapsulatedSecretBytes in
let rc = CCryptoBoringSSL_XWING_decap(
sharedSecretBytes.baseAddress,
encapsulatedSecretBytes.baseAddress, // <-- no length check on this pointer
&self.privateKey
)
guard rc == 1 else {
throw CryptoKitError.internalBoringSSLError()
}
count = Int(XWING_SHARED_SECRET_BYTES)
}
}
}What decapsulate(_:) is for. This is the Swift-side wrapper for X-Wing HPKE key decapsulation. An HPKE recipient calls it to convert a peer-provided encapsulated key (the ciphertext output of the KEM) into the shared secret that derives session keys. It accepts the encapsulated key as a Data value, allocates a SymmetricKey to hold the output, and delegates the cryptographic work to BoringSSL through CCryptoBoringSSL_XWING_decap.
Line-by-line walkthrough.
try SymmetricKey(unsafeUninitializedCapacity: Int(XWING_SHARED_SECRET_BYTES)) { sharedSecretBytes, count in ... }allocates a 32-byte uninitialized buffer for the X-Wing shared secret. The closure receives a mutable pointer (sharedSecretBytes) and aninoutcountfor the final length.try encapsulated.withUnsafeBytes { encapsulatedSecretBytes in ... }exposes the raw bytes of theDataparameter as anUnsafeRawBufferPointer. This is the FFI bridge: the closure scope is where Swift's safety properties end and a raw pointer begins. Crucially,encapsulatedSecretByteshas a.countproperty the wrapper could consult. It does not.let rc = CCryptoBoringSSL_XWING_decap(...)calls the C function. Three pointers are passed: the output buffer, the input ciphertext, and the private key. No length is passed for the input ciphertext. The C function reads a compile-time-fixed 1,120 bytes fromencapsulatedSecretBytes.baseAddressregardless of how large the originalDatawas.guard rc == 1 else { throw ... }checks the return code.XWING_decapreturns 1 on cryptographic success and 0 on failure. The guard only fires if the cryptographic operation itself failed; a successful 1,120-byte read past the end of a 1-byte input returns 1 just like any other successful decap.count = Int(XWING_SHARED_SECRET_BYTES)finalizes the output buffer's length and returns theSymmetricKey.
Where the bug lives. Line 4 of the body, marked // <-- no length check on this pointer. Swift knows the length of the Data parameter through encapsulated.count. The C function does not, and asks for no length argument. The wrapper is the only place that can enforce the contract. It does not.
The C callee:
// vendored BoringSSL header (illustrative)
#define XWING_CIPHERTEXT_BYTES 1120
OPENSSL_EXPORT int XWING_decap(
uint8_t out_shared_secret[XWING_SHARED_SECRET_BYTES],
const uint8_t ciphertext[XWING_CIPHERTEXT_BYTES],
const struct XWING_private_key *private_key);What XWING_decap is for. This is BoringSSL's implementation of X-Wing decapsulation. It runs the hybrid KEM (X25519 elliptic-curve key exchange combined with ML-KEM-768 lattice scheme) and computes the shared secret. The function signature uses C array parameter syntax (const uint8_t ciphertext[XWING_CIPHERTEXT_BYTES]), which looks like a stronger contract than a plain pointer. It is not. At the call site, C array parameters decay to plain pointers. The compile-time size constant XWING_CIPHERTEXT_BYTES documents the contract for human readers; the compiled code does not consult it. The function body reads exactly 1,120 bytes from the pointer it is handed, with no length parameter and no way to tell the function "actually, the buffer is only 1 byte long, please bail."
The HPKE entry point that walks an attacker-controlled enc into that function:
// swift-crypto / HPKE.Context (illustrative)
init<PrivateKey: HPKEKEMPrivateKey>(
recipientRoleWithCiphersuite ciphersuite: Ciphersuite,
mode: Mode,
enc: Data, // <-- attacker-controlled bytes
psk: SymmetricKey?,
pskID: Data?,
skR: PrivateKey,
info: Data,
pkS: PrivateKey.PublicKey?
) throws {
let sharedSecret = try skR.decapsulate(enc) // <-- reaches vulnerable path
self.encapsulated = enc
self.keySchedule = try KeySchedule(...)
}
What HPKE.Context.init(recipientRoleWithCiphersuite:...) is for. This is the public entry point an HPKE recipient uses to construct its session context from peer-provided session-init data. The enc: Data parameter is the encapsulated key the peer sent over the wire to begin the HPKE handshake. The initializer accepts it without validation and calls skR.decapsulate(enc), which routes directly into the vulnerable decapsulate(_:) shown above. There is no intermediate length check between the network boundary and the C function.
The bug end to end. When enc.count < 1120, the C function reads past the end of the Swift buffer. The actual bytes returned depend on adjacent stack contents at the moment of the call.
Exploitation
The attack model is a network peer who controls the bytes of the encapsulated key sent to an HPKE recipient using the X-Wing ciphersuite. Three reachable scenarios:
- A server endpoint terminating an HPKE-protected protocol initializes
HPKE.Recipientwith the client-providedencbytes. A client sends a malformedencshorter than 1120 bytes. The server-side decap reads past the Swift buffer. Outcomes: process crash (DoS), or, with the wrong runtime protections, leaking adjacent stack bytes back into the protocol response or logs. - A peer-to-peer Swift application using X-Wing HPKE for end-to-end encrypted session establishment. Any peer that can send a session-init packet can deliver a malformed
encand crash or memory-leak the other peer. - A Swift library wrapping swift-crypto for app developers exposes an HPKE recipient API without re-validating
enclength. The library's users inherit the bug.
The PoC from the advisory, paraphrased:
let ciphersuite = HPKE.Ciphersuite.XWingMLKEM768X25519_SHA256_AES_GCM_256
let skR = try XWingMLKEM768X25519.PrivateKey.generate()
let malformedEncapsulatedKey = Data([0x00]) // 1 byte; X-Wing expects 1120
_ = try HPKE.Recipient(
privateKey: skR,
ciphersuite: ciphersuite,
info: Data(),
encapsulatedKey: malformedEncapsulatedKey
)
Run normally, the initializer returns instead of rejecting the malformed length. Run under AddressSanitizer:
ERROR: AddressSanitizer: dynamic-stack-buffer-overflow
READ of size 1
...
SUMMARY: AddressSanitizer: dynamic-stack-buffer-overflow
==...==ABORTING
Reproduction:
swift test --sanitize=address --filter XWingMalformedEncapsulationPoCTestsImpact and Resolution
Severity. Moderate. The bug is a memory-safety violation across an FFI boundary. Practical impact ranges from remote denial of service (crash on the OOB read) to remote memory disclosure (leaking stack contents adjacent to the encapsulated key buffer), depending on runtime memory protections (stack canaries, ASLR, page guards) and what happens to be allocated next to the read.
Affected Versions. swift-crypto 4.0.0 through 4.3.0.
Patched Versions. swift-crypto 4.3.1, released alongside Swift Crypto Security Release.
The Fix. The fix enforces a length check on encapsulated before forwarding the pointer to the C decap, rejecting any value whose count is not exactly XWING_CIPHERTEXT_BYTES.
// swift-crypto / OpenSSLXWingPrivateKeyImpl.swift (conceptual diff)
func decapsulate(_ encapsulated: Data) throws -> SymmetricKey {
+ guard encapsulated.count == Int(XWING_CIPHERTEXT_BYTES) else {
+ throw CryptoKitError.incorrectParameterSize
+ }
try SymmetricKey(unsafeUninitializedCapacity: Int(XWING_SHARED_SECRET_BYTES)) { sharedSecretBytes, count in
try encapsulated.withUnsafeBytes { encapsulatedSecretBytes in
let rc = CCryptoBoringSSL_XWING_decap(
sharedSecretBytes.baseAddress,
encapsulatedSecretBytes.baseAddress,
&self.privateKey
)
...
}
}
}What the three new lines do. A guard statement at the top of decapsulate(_:) checks that the input encapsulated.count is exactly 1,120 bytes before any pointer arithmetic happens. If the count is anything else, the function throws CryptoKitError.incorrectParameterSize and never reaches the FFI call. This is the minimum-viable fix: a single equality check that converts the unenforceable C-side contract into a Swift-side guarantee.
Why the fix lives in the Swift wrapper. The check belongs here because the wrapper is the last place that sees a Data value with a knowable length before the pointer disappears into a C function that has no way to ask "how many bytes can I read?". A check at the HPKE entry point would also block the bug, but it would have to be re-implemented for every KEM in the library (X-Wing, ML-KEM, future post-quantum schemes). Pushing the check into the KEM-specific wrapper keeps the contract local to the place that knows the right size constant. Future KEM additions inherit the same pattern: validate the input length at the Swift function that wraps each fixed-size C call.
Why equality, not minimum. encapsulated.count >= 1120 would prevent the under-read, but a 1,500-byte input would still pass and the C function would silently consume only the first 1,120 bytes. Equality (encapsulated.count == 1120) is the right contract for a fixed-size C buffer. The input must be exactly the size the C function expects, no more and no less. Looser checks invite future bugs if the size constant changes, or if a second C function with a slightly different expected size gets wired in and the inequality check is no longer correct.
Why this fix is sufficient. The root cause is "Swift wrapper hands an unverified-length pointer to a fixed-size C reader." The fix eliminates that exact condition at the only layer where Swift still has the length information. Defense-in-depth at the HPKE entry point or at the calling application is still good practice (the post recommends it under remediation step 3), but the guard inside decapsulate(_:) closes the bug class for every reachable path through this function.
Remediation steps for users:
- Upgrade swift-crypto to 4.3.1 or later. For Swift Package Manager users, bump the
.upToNextMajor(from: "4.0.0")pin and re-resolve. For server-side Swift on Linux, audit yourPackage.resolvedand rebuild. - If your application exposes an HPKE recipient API to network input with the X-Wing ciphersuite, treat any deployment running an affected version as a network-reachable memory-safety bug and prioritize the upgrade.
- If you wrap swift-crypto in your own SDK and forward HPKE encapsulated keys from your callers, validate
encapsulatedKey.count == 1120defensively at your API boundary regardless of the patched version. Two checks beat one.
Frequently Asked Questions
What is CVE-2026-28815?
CVE-2026-28815 is a Moderate-severity memory-safety vulnerability in Apple's swift-crypto library, specifically in the X-Wing HPKE decapsulation path. The Swift decapsulate(_:) function forwards an attacker-controlled Data value to a BoringSSL C function (XWING_decap) that expects a fixed 1120-byte buffer, with no runtime length check. A short input triggers an out-of-bounds read in C memory.
Which swift-crypto versions are affected by CVE-2026-28815?
Versions 4.0.0 through 4.3.0 are affected. The bug was introduced when X-Wing HPKE support landed in swift-crypto 4.0.0.
How do I fix CVE-2026-28815 in my project?
Upgrade swift-crypto to version 4.3.1 or later. In Package.swift, ensure the dependency pin allows 4.3.1, then re-resolve. If you cannot upgrade immediately, validate that any HPKE encapsulated key you receive over the network is exactly 1120 bytes long before passing it into HPKE.Recipient for the X-Wing ciphersuite.
What is the CVSS score for CVE-2026-28815?
The advisory rates this as Moderate severity. The bug is an unauthenticated, network-reachable out-of-bounds read across the Swift-to-C FFI boundary, with practical impact ranging from denial of service to memory disclosure depending on runtime memory protections.
Who discovered CVE-2026-28815?
The vulnerability was discovered by Apex, Cantina's AppSec agent. The advisory credit reads "Reported by Cantina."
Is CVE-2026-28815 exploitable in practice?
Yes, in any server or peer-to-peer application that initializes an HPKE.Recipient with the X-Wing ciphersuite using a network-provided encapsulatedKey. The PoC in the advisory reaches the vulnerable path with a 1-byte input and confirms the OOB read under AddressSanitizer.
Does CVE-2026-28815 affect Apple platform CryptoKit?
The advisory is scoped to the open-source swift-crypto package, which is the cross-platform port of CryptoKit. The bug lives in the BoringSSL-backed implementation that swift-crypto ships on Linux and server-side Swift. The system CryptoKit framework on iOS, macOS, watchOS, and tvOS is a separate codebase and is outside the scope of this advisory.
What is X-Wing HPKE?
X-Wing is a hybrid post-quantum Key Encapsulation Mechanism that combines X25519 (classical elliptic-curve key agreement) with ML-KEM-768 (a NIST-standardized post-quantum lattice scheme). It's exposed in swift-crypto's HPKE through the ciphersuite XWingMLKEM768X25519_SHA256_AES_GCM_256, intended for applications adopting post-quantum protections in their key exchange.
Takeaways
1. FFI boundaries need explicit length contracts at the language that has them. Swift knows the length of every Data. C does not. Any function that takes a fixed-size C array and a Swift caller wrapping it is a place where the length check must be re-asserted in Swift, because once the pointer crosses into C the information is gone. A "C function expects N bytes" comment in the header documents the contract. It does not enforce it.
2. Constant-sized C buffer parameters discard their length at the call site. const uint8_t ciphertext[XWING_CIPHERTEXT_BYTES] looks like a stronger contract than const uint8_t *ciphertext, size_t ciphertext_len. At runtime, C array parameter syntax decays to a plain pointer. The compile-time size constant exists in the header for documentation purposes; it is not consulted by the function body. Wherever a vendored C library expresses size invariants only at compile time, the binding layer that calls it owns the runtime enforcement.
3. Post-quantum key sizes are an audit surface. Classical KEMs have small encapsulated keys (X25519 is 32 bytes). Post-quantum and hybrid KEMs are an order of magnitude larger (X-Wing is 1120 bytes, raw ML-KEM-768 is 1088). The transition from classical to post-quantum cryptography in protocol stacks means a lot of fixed-size buffer constants are getting bigger, and the wrappers around them are getting written by people who learned the old sizes. Every new size is a new length check that has to be added somewhere.
How Cantina handles vulnerabilities like this
Cantina covers AppSec, SecOps, and everything in between with one agentic platform. Apex is one of our AppSec agents that found this bug, and it's built to catch FFI boundary issues like this one: a function that walks attacker-controlled bytes from a memory-safe language into a memory-unsafe one without the length check that should sit in between. Apex enumerates the C functions a codebase calls into, checks each one for fixed-size buffer parameters, and traces the wrappers to see whether any reachable public path forwards untrusted lengths.
Cantina runs Apex against open-source Python, JavaScript, Rust, Go, Swift, and Solidity codebases continuously, and the same agent is available to teams reviewing their own services. If your team ships software that links against C libraries through a higher-level language (which is almost every team), the discovery loop that produced this finding can run against your code.
Get a demo - see Cantina close the loop.