Research
One Emoji Is Enough to Crash Ruby
A single 4-byte emoji overflows a fixed stack buffer in Prism, Ruby's default parser since 3.4. What breaks, why the safety check causes it, and the fix.
· Cantina
Apex found Seagate's first three listed vulnerabilities of 2026: memory-safety flaws across openSeaChest's SCSI and NVMe operations.
Artwork by Cantina
Apex, Cantina’s autonomous OffSec agent, autonomously found three memory-safety vulnerabilities in Seagate’s openSeaChest storage utilities. One SCSI path could read and write beyond an undersized allocation, while two NVMe paths could write past allocated memory. Seagate fixed the affected code.
As of August 20, 2026, these findings are the only CVEs dated 2026 on Seagate’s public product-security list.
The bugs reached memory in different ways. A device-reported SCSI defect count could overflow a 32-bit allocation calculation. An NVMe deallocate loop admitted one descriptor too many. A second NVMe path treated a format index as valid even when it exceeded the number of format entries that had been allocated.
Each bug allowed an externally influenced or derived number, whether a device-reported count, a requested range, or a decoded index, to govern a memory operation without a final proof that the destination bytes existed. That shared pattern matters because openSeaChest runs with elevated device access and parses responses from hardware that may be faulty, malformed, or controlled by an attacker with local access.
openSeaChest is Seagate’s cross-platform collection of command-line storage utilities. It supports SATA, SAS, NVMe, and USB devices and exposes operations for diagnostics, configuration, firmware management, sanitization, and media inspection. The affected component, opensea-operations, implements higher-level drive operations on top of the project’s transport libraries.
Direct access to storage devices normally requires elevated permissions. These findings therefore describe local, privileged attack surfaces rather than remotely reachable vulnerabilities. Two also depend on untrusted or inconsistent values returned by a storage device. They should not be presented as remote code execution.
Seagate’s CNA records score the SCSI defect-list issue at 1.8 Low, the NVMe deallocate issue at 4.6 Medium, and the NVMe format-index issue at 1.8 Low.
| Identifier | Vulnerable operation | Memory-safety failure | Publicly listed affected versions | Fixed version |
|---|---|---|---|---|
| CVE-2026-10717 | --showSCSIDefects |
Heap out-of-bounds write, followed by possible heap out-of-bounds reads | CVE record: through 25.05.3, plus 26.03.0. Seagate advisory: 25.05.3, 26.03.0, and 26.03.1 | 26.03.2 |
| CVE-2026-10718 | NVMe Trim/Deallocate | Attempted 16-byte stack out-of-bounds write beyond a 4,096-byte buffer | CVE record: through 26.03.0. Seagate advisory: 26.03.0 | 26.03.1 |
| CVE-2026-10719 | --showSupportedFormats |
One-byte heap out-of-bounds write setting currentFormat |
CVE record: through 25.05.3. Seagate advisory: 25.05.3 | 26.03.0, with further checks in 26.03.1 |
Storage software has to turn protocol fields into array indexes, allocation sizes, and loop limits. Those translations need two independent checks:
The second question was missing or answered incorrectly in each vulnerable path. A value can fit its protocol field and still be unsafe for the current allocation. A buffer can also have a known capacity while an inclusive loop writes one element beyond it.
The reliable invariant is physical, not semantic. An overflow-safe C formulation is:
offset <= capacity && write_size <= capacity - offset
Every device-reported count, decoded index, and loop cursor must eventually reduce to that comparison.
The three CLI operations enter different functions inside opensea-operations:
--showSCSIDefects
-> get_SCSI_Defect_List()
-> scsi_Read_Defect_Data_12() reads the initial header
-> allocate scsiDefectList
-> read the list in 64 KiB chunks
-> populate descriptor array
-> print_SCSI_Defect_List()
--trim / --provision on an NVMe device
-> trim_Unmap_Range()
-> nvme_Deallocate_Range()
-> build 4,096-byte descriptor buffer
-> nvme_Dataset_Management()
--showSupportedFormats on an NVMe device
-> get_Number_Of_Supported_Sector_Sizes()
-> allocate supportedFormats
-> get_Supported_Formats()
-> nvme_Get_Supported_Formats()
-> mark sectorSizes[flbas] as current
The openSeaChest CLI source confirms that --showSCSIDefects passes the returned object directly to print_SCSI_Defect_List(). The format CLI allocates the supportedFormats object before passing it into the operations library. The Trim/Unmap caller forwards the requested LBA range to trim_Unmap_Range(). Those caller relationships explain why the internal counts must remain consistent with the original allocation.
The 12-byte SCSI response exposes a four-byte defect-list length. openSeaChest reads it from response bytes 4 through 7, divides by the protocol descriptor width, and uses the result as numberOfElements.
The affected code then stored the allocation product in a uint32_t. These are the critical lines from src/defect.c in 25.05.3:
// src/defect.c:83-104 (openSeaChest 25.05.3)
uint32_t numberOfElements = UINT32_C(0);
uint32_t defectAlloc = UINT32_C(0); // vulnerable width
numberOfElements = defectListLength / 4;
defectAlloc = numberOfElements * sizeof(blockFormatAddress);
The protocol descriptor is four bytes in the short-block format, but openSeaChest expands each entry into a larger in-memory blockFormatAddress. That struct contains a uint64_t union member and padding. The output allocation can therefore overflow 32 bits even though defectListLength itself fits in 32 bits.
On the 64-bit build used by the original SCSI reproducer, sizeof(blockFormatAddress) == 16. Its crafted response reports defectListLength = 0x40000000, which produces this arithmetic:
numberOfElements = 0x40000000 / 4
= 0x10000000
= 268,435,456
required payload = 0x10000000 * 16
= 0x100000000
= 4,294,967,296
stored uint32_t = 0x00000000
The multiplication wraps, but numberOfElements remains 268,435,456. The program allocates sizeof(scsiDefectList) + defectAlloc, not merely defectAlloc. Because scsiDefectList already contains an embedded one-element array, element 0 remains inside the returned object when the payload wraps to zero. Element 1 is the first overflowing write.
The reproducible path is the 12-byte, multi-command branch. It allocates with the wrapped payload size, retains the original logical element count, and fills the list in 64 KiB chunks. The allocation occurs at src/defect.c:292-303, while the chunked descriptor loop begins at src/defect.c:348-359:
*defects = M_REINTERPRET_CAST(ptrSCSIDefectList,
safe_malloc(sizeof(scsiDefectList) + defectAlloc));
ptrDefects->numberOfElements = numberOfElements;
while (elementNumber < numberOfElements) {
// READ DEFECT DATA (12), using a 64 KiB chunk
ret = scsi_Read_Defect_Data_12(..., elementNumber * increment,
dataLength, defectData);
for (; elementNumber < numberOfElements &&
offset < defectListLength &&
offset < (dataLength + 8);
++elementNumber, offset += increment) {
ptrDefects->block[elementNumber].shortBlockAddress =
M_BytesTo4ByteValue(defectData[offset + 0], defectData[offset + 1],
defectData[offset + 2], defectData[offset + 3]);
// ^ bounded by the logical count, not allocated bytes
}
}
The reproducer avoids any multi-gigabyte transfer. Its initial header supplies 0x40000000, then it forces the multi-command path and returns two descriptors in the first chunk. The wrapped payload leaves room only for the structure’s embedded first element, so the write to element 1 crosses the heap allocation. AddressSanitizer reports the overflow at the assignment above.
One resulting out-of-bounds read path appears in print_SCSI_Defect_List(). It iterates to defects->numberOfElements and reads defects->block[iter], reusing the oversized logical count after parsing.
The CVE record identifies a bad drive or malicious SCSI device returning a very large response length as the trigger. The arithmetic above illustrates the overflow condition; it is not a claim that a normal drive would report that defect count.
Seagate’s primary patch widens defectAlloc, stores the allocation length in the returned object, and checks the byte offset before every descriptor write:
- uint32_t defectAlloc;
+ size_t defectAlloc;
+ if ((M_STATIC_CAST(uint64_t, *elementID) *
+ M_STATIC_CAST(uint64_t, sizeof(blockFormatAddress))) < ptrDefects->defectAlloc) {
+ fill_block_address(...);
+ } else {
+ ptrDefects->overflow = true;
+ }
The March patch corrects the payload calculation and adds write-site bounds checks on 64-bit builds. Its commit states that the overflow flag is intended to stop further writes and return the entries that fit. That was the patch’s intended behavior, not yet a complete cross-platform guarantee, because one 32-bit allocation edge case remained.
The first fix still had to form sizeof(scsiDefectList) + defectAlloc. On 32-bit systems, the converted payload size can saturate at SIZE_MAX, and adding the structure header can wrap. Seagate’s May 6 follow-up rejects that sum before allocation:
+ if (sizeInfo.defectAlloc > (SIZE_MAX - sizeof(scsiDefectList))) {
+ return MEMORY_FAILURE;
+ }
Version 26.03.2 completed the fix for 32-bit builds. Together, the two patches protect the multiplication, the header-plus-payload addition, and each individual descriptor write.
trim_Unmap_Range() dispatches NVMe devices to nvme_Deallocate_Range(). For the native NVMe path, is_Trim_Or_Unmap_Supported() sets a maximum of 256 descriptors. Each range descriptor is 16 bytes, and the command buffer is 4,096 bytes.
The relevant code in src/trim_unmap.c from 26.03.0 contains an inclusive comparison:
DECLARE_ZERO_INIT_ARRAY(uint8_t, deallocate, 4096);
for (uint64_t deallocateLBA = startLBA, offset = 0;
deallocateLBA < finalLBA && descriptorCount <= maxTrimOrUnmapBlockDescriptors;
deallocateLBA += deallocateRange, offset += 16) {
deallocate[offset + 0] = M_Byte3(contextAttributes);
deallocate[offset + 15] = M_Byte0(deallocateLBA);
++descriptorCount;
}
The buffer has indexes 0..4095. After 256 successful iterations, descriptorCount == 256 and offset == 4096. The <= 256 test still passes if the requested LBA range has more work remaining. That iteration writes a full descriptor to deallocate[4096..4111].
The range calculation makes the trigger concrete. On the native NVMe path, one descriptor can cover at most UINT32_MAX LBAs. Reaching the invalid iteration requires more than:
256 * 4,294,967,295 = 1,099,511,627,520 LBAs
With startLBA = 0, the native NVMe limit of 256 descriptors, maxLBACount = UINT32_MAX, and no uint64_t addition overflow, a range of 1,099,511,627,521 LBAs reaches descriptor 257. With 512-byte logical blocks, that is approximately 512 TiB. The exact device and operating-system limits still apply, which is consistent with the local, privileged attack conditions in the public record.
The CVE record describes the result as a 16-byte write outside allocated space in openSeaChest 26.03.0. The source identifies the exact bytes and the iteration that writes them.
Seagate’s patch makes the descriptor comparison strict and adds a byte-level guard:
- descriptorCount <= maxTrimOrUnmapBlockDescriptors
+ descriptorCount < maxTrimOrUnmapBlockDescriptors
+ && offset < NVME_DEALLOCATE_BUFFER_SIZE
The first condition stops at 256 descriptors. The second ties the next iteration directly to the 4,096-byte allocation. If the device limit or descriptor-count calculation changes, the physical buffer bound still remains in force.
The supportedFormats object ends in an any-size array. Its definition in include/format.h makes the allocation contract explicit:
uint32_t numberOfSectorSizes;
sectorSize sectorSizes[1]; // caller must over-allocate for the reported count
The format CLI derives numberOfSectorSizes from the NVMe namespace’s nlbaf field and allocates the object from that count. The relevant caller code is:
uint32_t numberOfSectorSizes = get_Number_Of_Supported_Sector_Sizes(&deviceList[deviceIter]);
uint32_t memSize = sizeof(supportedFormats) + sizeof(sectorSize) * numberOfSectorSizes;
ptrSupportedFormats formats = M_REINTERPRET_CAST(ptrSupportedFormats, safe_malloc(memSize));
nvme_Get_Supported_Formats() later resets formats->numberOfSectorSizes, fills the available format descriptors, and marks the active format. In 25.05.3, the final step indexed sectorSizes from the low nibble of FLBAS without comparing it with the allocated count:
// src/format.c:1327-1328 (openSeaChest 25.05.3)
formats->sectorSizes[M_Nibble0(device->drive_info.IdentifyData.nvme.ns.flbas)]
.currentFormat = true; // no allocation-bound check
nlbaf controls how many entries the caller allocates. FLBAS independently selects the current entry. A malicious device can report a short format list while placing a larger value in FLBAS. For example, nlbaf = 0 describes one available format, while a low FLBAS nibble of 0xF selects sectorSizes[15]. The small caller allocation does not contain slot 15.
The assignment changes only currentFormat, so the CVE record describes a one-byte out-of-bounds write. “One byte” describes the width of the Boolean write, not a guarantee that the target byte sits immediately after the allocation.
Seagate’s patch notes say the condition should not occur on a valid device. That matches the CVE’s malicious-device requirement. Parser safety cannot depend on a device obeying the protocol field relationship that the parser is meant to verify.
The remediation landed in two steps. Version 26.03.0 shipped an API change that replaced the caller-sized one-element array with a fixed 64-entry array:
- sectorSize sectorSizes[1];
+ #define MAX_SECTOR_SIZES_ARRAY (64)
+ sectorSize sectorSizes[MAX_SECTOR_SIZES_ARRAY];
Version 26.03.1 then added the follow-up FLBAS fix. It decodes FLBAS according to the NVMe version’s field layout and checks the index against both the fixed array limit and the number of formats actually reported and parsed:
+ if (flbas < MAX_SECTOR_SIZES_ARRAY
+ && flbas < formats->numberOfSectorSizes) {
+ formats->sectorSizes[flbas].currentFormat = true;
+ }
The fixed array in 26.03.0 removed the caller-sized array contract that made the original one-byte overflow possible. The 26.03.1 checks then enforced the semantic relationship between FLBAS, the array’s 64-entry physical capacity, and the formats reported by the device. Both checks must pass before the write.
Across the three fixes, Seagate moved the checks closer to the writes they protect:
That placement is important. Upstream validation can establish that a device response is structurally plausible, but the write site is where the program knows the allocation, element width, and exact offset at the same time.
Users should install the newest available openSeaChest release rather than stopping at the first release associated with an individual fix.
Seagate’s current product-security table contains a version inconsistency for the SCSI issue. Its affected-version field includes 26.03.1 and its fixed-version field names 26.03.2, while the remediation text also says to update to 26.03.1. Installing 26.03.2 or later resolves that ambiguity and includes the follow-up allocation guard.
The relevant releases are openSeaChest 26.03.1 and openSeaChest 26.03.2. Operators should follow Seagate’s product-security page for the vendor’s current guidance.
A storage device sits below the operating system, but its responses should receive the same treatment as a network packet or file format. Devices fail. Firmware lies. Virtual devices and test harnesses can synthesize values that physical hardware rarely produces. A protocol field becomes trustworthy only after the software proves that it is consistent with the allocation it will address.
The supported-format bug had two notions of size: the range FLBAS could encode and the number of entries allocated from the response. Only the second bounded the array. Code that allocates from one field and indexes from another should make their relationship explicit before dereferencing either value.
The deallocate patch kept a logical descriptor check and added a direct buffer-offset check. That is a useful pattern in low-level C. A valid element count can still be turned into an invalid byte range through an off-by-one comparison, integer conversion, or later change in element size.
For a 256-entry buffer, tests should exercise 255, 256, and 257 requested entries. For a variable-length array, they should cover an index equal to the allocated count, not only the largest value the protocol can encode. For allocation arithmetic, tests should include the first count that cannot be represented in the destination type.
Those cases are small, deterministic, and far more likely to catch this class of bug than another happy-path device response.
Apex is Cantina’s agentic OffSec engineer. It investigates real software and authorized targets, develops exploit hypotheses, and produces reproducible evidence for human review and coordinated disclosure.
These openSeaChest findings show why low-level security testing still needs to follow values all the way to the memory operation. The unsafe write was not visible from the command name or protocol field alone. It appeared where a count, index, or loop boundary stopped matching the allocation beneath it.
Want Apex to test the boundaries in your own environment? Book a demo.