Soundness: Violation of I/O Safety via Unwound Double-Close in `ReadReady::num_ready_bytes`
Nobody has claimed this yet.
Assessment
- Difficulty
- 2/5
- Estimated time
- 1-3 hours
- Newbie friendliness
- 68/100
- Issue type
- Bug
- Clarity
- Mostly clear
- Activity status
- Quiet
- Tech stack
- rust
- Domain
- operating-systems
Research direction
Read src/io/read_ready.rs around lines 75-78, starting at ReadReady::num_ready_bytes and its temporary File wrapper. Check the surrounding implementation and existing test coverage, then verify that unwinding during the seek path cannot close the descriptor still owned by the original file. Run the relevant Rust tests and confirm normal remaining-byte behavior is unchanged.
Written by the indexing model from the issue text.
Description
[!NOTE]
This finding was identified during an agentic unsafe Rust code review performed by Gemini AI, followed by human review and verification.
The Issue
ReadReady::num_ready_bytes for std::fs::File constructs an owned std::fs::File wrapper directly from self's raw OS file descriptor or handle via from_raw_filelike to determine remaining bytes without modifying current stream position: https://github.com/bytecodealliance/system-interface/blob/8ee8c825df4c183f06d48ec5d10556a5bf11655b/src/io/read_ready.rs#L75-L78
According to RFC 3128, std::fs::File assumes exclusive ownership over the lifecycle of its underlying OS file descriptor or handle.
The implementation invokes tmp.seek(...) on line 76 and only afterwards calls std::mem::forget(tmp) on line 77. If tmp.seek (or any thread profiling or hooking mechanism) panics or unwinds before line 77 is reached, tmp will be dropped during stack unwinding. Its destructor (File::drop) will close the underlying OS file descriptor or handle while self (and any cloned File instances or borrowed handles across concurrent threads) remains alive and believes it owns the open descriptor.
This is probably minor, but worth fixing.
Suggested Fix
Wrap the owned file wrapper immediately in std::mem::ManuallyDrop upon creation, ensuring its destructor can never execute regardless of unwinding:
- let mut tmp = unsafe { std::fs::File::from_raw_filelike(self.as_raw_filelike()) };
+ let mut tmp = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_filelike(self.as_raw_filelike()) });
let current = tmp.seek(SeekFrom::Current(0));
- std::mem::forget(tmp);
return Ok(metadata.len() - current?);
[!NOTE]
The full audit report below also contains additional minor findings (such as missing safety comments or undocumented FFI assumptions) that are probably worth fixing as well but not the primary goal of this issue. The audit report has not been human-reviewed, it may contain misleading claims.
Full Gemini Codebase Audit Report Appendix
Unsafe Rust Review: system_interface (v0_27)
Overall Safety Assessment
system_interface (v0_27) provides extension traits (FileIoExt, IoExt, ReadReady, IsReadWrite) aimed at extending standard library I/O types (std::fs::File, std::net::TcpStream, Stdin, etc.) with POSIX/Windows-specific capabilities like vectored reads at offsets, immediate read readiness queries, and peek operations.
The crate contains a moderate density of unsafe code distributed across Windows FFI calls (PeekNamedPipe, ioctlsocket, send, recv), OS handle conversions (from_raw_filelike), and raw pointer slicing polyfills (advance and advance_mut).
From a safety documentation perspective, the crate exhibits limited safety documentation: unsafe blocks generally lack formal // SAFETY: proof comments, and private unsafe fn helpers omit # Safety docstrings detailing their contracts. More significantly, auditing revealed two Critical findings: (1) an I/O Safety violation where unwinding during File::seek causes a double-close vulnerability on borrowed file descriptors, and (2) an intentional bad pointer dereference (usize::MAX) passed to Windows recv() to test socket shutdown state.
Critical Findings
1. Violation of I/O Safety (RFC 3128) via Unwound Double-Close in ReadReady::num_ready_bytes 🔴 🧪
-
Priority: 🔴 High
-
Threat Vector: 🧪 Contrived Setup
-
Bug Type: I/O Safety Violation
-
Location:
src/io/read_ready.rs:75-77 -
Code:
let mut tmp = unsafe { std::fs::File::from_raw_filelike(self.as_raw_filelike()) }; let current = tmp.seek(SeekFrom::Current(0)); std::mem::forget(tmp); return Ok(metadata.len() - current?); -
Description: To determine the remaining bytes in a regular file without modifying its current stream position, the implementation constructs an owned
std::fs::Filewrapper (tmp) directly fromself's raw OS file descriptor/handle viafrom_raw_filelike. It subsequently invokestmp.seek(...)on line 76 and only afterwards callsstd::mem::forget(tmp)on line 77. Under Rust's I/O safety conventions (RFC 3128),std::fs::Fileassumes exclusive ownership over the lifecycle of its underlying file descriptor/handle. Iftmp.seek(or any thread interruption/hooking mechanism) panics or unwinds before line 77 is reached,tmpwill be dropped during stack unwinding. Its destructor (File::drop) will close the underlying OS file descriptor/handle whileself(and any clonedFileinstances or borrowed handles across concurrent threads) remains alive and believes it owns the open descriptor. This leads to use-after-close or file descriptor misdelivery vulnerabilities when the OS reassigns the closed descriptor number to a subsequent open file in another thread. -
Remediation: Wrap the owned file wrapper immediately in
std::mem::ManuallyDropupon creation, ensuring its destructor can never execute regardless of unwinding:let mut tmp = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_filelike(self.as_raw_filelike()) }); let current = tmp.seek(SeekFrom::Current(0)); return Ok(metadata.len() - current?);
2. ASan-Fatal Bad Pointer Dereference and Brittle Exception Trap Reliance in raw_socket_is_read_write 🔴 ⚠️
-
Priority: 🔴 High
-
Threat Vector: ⚠️ Accidental Misuse
-
Bug Type: Invalid Pointer Dereference
-
Location:
src/io/is_read_write.rs:144 -
Code:
// Detect read shutdown. A normal zero-length `recv` does block, so // use deliberately invalid pointer, as we get different error codes in // the case of a shut-down stream. let read_result = unsafe { recv(socket, usize::MAX as *mut _, 1, MSG_PEEK) }; -
Description: To distinguish between an open blocking socket with no pending network packets and a socket shut down for reading (
WSAESHUTDOWN), the implementation passes an unallocated, unaligned bogus address (usize::MAX as *mut _/0xFFFFFFFFFFFFFFFF) with length1to the Windowsrecv()Winsock system call. The author included a helpful code comment explaining the intent: a normal zero-lengthrecvwould block, so an invalid pointer is intentionally passed to leverage error code differences (WSAEFAULT, 10014) on shut-down streams. However, relying on the OS/kernel to safely catch invalid pointer access violations presents serious issues:
- Dynamic Analysis & Sanitizer Incompatibility: Under AddressSanitizer (ASan), Valgrind, Dr. Memory, or API-hooking endpoint detection and response (EDR) agents, passing
0xFFFFFFFFFFFFFFFFwithlen = 1torecv()is immediately trapped as an illegal memory access or heap buffer overflow attempt, crashing the process with a fatal sanitizer abort. - Undefined Behavior in Foreign Function Contracts: In C / POSIX / Winsock specifications, passing an invalid pointer to
recvwhen length > 0 is undefined behavior under foreign call contracts. Assuming foreign syscall wrappers will safely convert bad pointers intoEFAULT/WSAEFAULTrather than exhibiting undefined behavior or corrupting state violates safety preconditions. - Indefinite Thread Hanging: If layered service providers (LSPs) or network drivers block waiting for network packets before validating user-mode buffer pointers, calling
is_read_write()on any idle open socket will hang the calling thread indefinitely.
- Remediation: Allocate a valid 1-byte stack buffer (
let mut buf = [0u8; 1];) and passbuf.as_mut_ptr()withMSG_PEEK. Alternatively, use non-blocking socket queries (ioctlsocketwithFIONREADorWSAPoll/select).
Fishy Findings
1. Undocumented Preconditions on unsafe fn _reopen Helpers and Unsound Safe Abstraction 🟠 ⚠️
-
Priority: 🟠 Medium
-
Threat Vector: ⚠️ Accidental Misuse
-
Bug Type: Unsound Safe Abstraction
-
Location:
src/fs/file_io_ext.rs:1005-1037 -
Code:
fn reopen<Filelike: AsFilelike>(filelike: &Filelike) -> io::Result<fs::File> { let file = filelike.as_filelike_view::<std::fs::File>(); unsafe { _reopen(&file) } } unsafe fn _reopen(file: &fs::File) -> io::Result<fs::File> { file.reopen(cap_fs_ext::OpenOptions::new().read(true)) } -
Description: The functions
_reopen,_reopen_write, and_reopen_appendare markedunsafe fnbecause they delegate tocap_fs_ext::Reopen::reopen, which is anunsafe fnon Windows. Reopening a file handle by object ID or path on Windows requires strict assumptions regarding filesystem capabilities (e.g.,OpenFileByIdsupport) and absence of concurrent handle redirection or sharing mode conflicts. However, none of theseunsafe fndeclarations possess# Safetydocstrings defining their safety obligations. Furthermore, the private helperfn reopenwraps_reopenin a completely safe function accepting anyFilelike: AsFilelike. Callingreopenfrom safe trait methods (read_at,read_exact_at,read_vectored_at) without documenting or proving whycap_fs_ext's safety obligations are unconditionally satisfied for allstd::fs::Fileobjects leaves an unverified gap in the crate's safety architecture.
2. Code Duplication of Subtle Unsafe Pointer Slicing Polyfills 🟡 ⚠️
-
Priority: 🟡 Low
-
Threat Vector: ⚠️ Accidental Misuse
-
Bug Type: Code Duplication
-
Location:
src/fs/file_io_ext.rs:362, 394andsrc/io/io_ext.rs:168, 200 -
Description: The polyfill functions
advanceandadvance_mut—which perform raw pointer arithmetic (ptr.add) and lifetime reconstruction (slice::from_raw_parts) onIoSliceandIoSliceMut—are duplicated line-for-line across the filesystem (fs) and I/O (io) modules. Maintaining duplicate copies of non-trivialunsafepointer manipulation increases auditing burden and creates risk of divergence if soundness patches are applied to only one module.
Missing Safety Comments
The codebase contains 18 locations where unsafe operations lack formal safety comments or docstrings. Below are the exact file:line locations along with rigorous proposed proof obligations:
src/fs/file_io_ext.rs:362🔴
-
Context:
unsafe { ptr = ptr.add(advance_by); ... }insideadvance. -
Proposed Proof Comment:
// SAFETY: `first` is a valid `IoSlice` whose length satisfies `accumulated_len + first.len() > n`, ensuring `advance_by < first.len()`. `ptr` points to the contiguous slice memory, so `ptr.add(advance_by)` remains within the same allocated object. `len` is `first.len() - advance_by > 0`. `slice::from_raw_parts` preserves the valid lifetime `'a` and alignment (1 byte for `u8`) of the original slice.
src/fs/file_io_ext.rs:394🔴
-
Context:
unsafe { ptr = ptr.add(advance_by); ... }insideadvance_mut. -
Proposed Proof Comment:
// SAFETY: `first` is uniquely borrowed (`&mut`) and `advance_by < first.len()`. `ptr.add(advance_by)` remains within the bounds of the allocated buffer. `slice::from_raw_parts_mut` creates a non-aliasing mutable subslice of length `len = first.len() - advance_by` preserving lifetime `'a`.
src/fs/file_io_ext.rs:1007🔴
-
Context:
unsafe { _reopen(&file) }insidereopen. -
Proposed Proof Comment:
// SAFETY: `file` is a valid open `std::fs::File` handle. Reopening it via `_reopen` is safe because `file` represents a regular filesystem object supporting reopening by object ID.
src/fs/file_io_ext.rs:1011🔴
-
Context:
unsafe fn _reopen(file: &fs::File) -> io::Result<fs::File> -
Proposed Docstring:
/// # Safety /// `file` must be a valid open file handle pointing to a filesystem object that supports reopening by object ID or path without causing undefined handle redirection behavior.
src/fs/file_io_ext.rs:1019🔴
-
Context:
unsafe { _reopen_write(&file) }insidereopen_write. -
Proposed Proof Comment:
// SAFETY: `file` is a valid open `std::fs::File` handle supporting reopening for write access.
src/fs/file_io_ext.rs:1023🔴
-
Context:
unsafe fn _reopen_write(file: &fs::File) -> io::Result<fs::File> -
Proposed Docstring:
/// # Safety /// `file` must be a valid open file handle pointing to a filesystem object that supports reopening for write access without violating OS sharing constraints.
src/fs/file_io_ext.rs:1031🔴
-
Context:
unsafe { _reopen_append(&file) }insidereopen_append. -
Proposed Proof Comment:
// SAFETY: `file` is a valid open `std::fs::File` handle supporting reopening for append access.
src/fs/file_io_ext.rs:1035🔴
-
Context:
unsafe fn _reopen_append(file: &fs::File) -> io::Result<fs::File> -
Proposed Docstring:
/// # Safety /// `file` must be a valid open file handle pointing to a filesystem object that supports reopening for append access.
src/io/io_ext.rs:168🔴
-
Context:
unsafe { ptr = ptr.add(advance_by); ... }insideadvance. -
Proposed Proof Comment:
// SAFETY: `first` is a valid `IoSlice` and `advance_by < first.len()`. `ptr.add(advance_by)` remains within the allocated memory object. `slice::from_raw_parts` reconstructs a valid subslice for lifetime `'a`.
src/io/io_ext.rs:200🔴
-
Context:
unsafe { ptr = ptr.add(advance_by); ... }insideadvance_mut. -
Proposed Proof Comment:
```rust // SAFETY: `first` is uniquely borrowed and `advance_by < first.len()`. `ptr.add(advance_by)` remains within the allocated buffer. `slice::from_raw_parts_mut` creates a non-aliasing mutable subslice for lifetime `'a`. ```
src/io/io_ext.rs:313🔴
-
Context:
unsafe { PeekNamedPipe(...) }insideIoExt::peekforstd::fs::Fileon Windows. -
Proposed Proof Comment:
```rust // SAFETY: `self.as_raw_handle()` is a valid OS handle. `buf.as_mut_ptr()` points to a buffer of at least `buf.len()` bytes, and `len` is clamped to `min(buf.len(), u32::MAX)`. `bytes_read.as_mut_ptr()` points to a valid allocated `MaybeUninit<u32>` on the stack. All null pointers passed for optional parameters are accepted by `PeekNamedPipe`. ```
src/io/io_ext.rs:326🔴
-
Context:
unsafe { bytes_read.assume_init() }insideIoExt::peek. -
Proposed Proof Comment:
```rust // SAFETY: `res != 0` indicates that `PeekNamedPipe` succeeded, which guarantees that the number of bytes read was written into `bytes_read`. ```
src/io/read_ready.rs:75🔴
-
Context:
unsafe { std::fs::File::from_raw_filelike(...) }insideReadReady::num_ready_bytesforstd::fs::File. -
Proposed Proof Comment:
```rust // SAFETY: `self.as_raw_filelike()` is a valid open file descriptor/handle. (Note: To prevent I/O safety violations on unwinding before `mem::forget`, this instance must be wrapped in `ManuallyDrop`). ```
src/io/read_ready.rs:240🔴
-
Context:
unsafe { ioctlsocket(...) }insideReadReady::num_ready_bytesforTcpStreamon Windows. -
Proposed Proof Comment:
```rust // SAFETY: `self.as_raw_socket()` is a valid Winsock `SOCKET`. `arg.as_mut_ptr()` points to a valid allocated `MaybeUninit<c_ulong>` on the stack. `FIONREAD` is a valid ioctl command that writes the readable byte count into `arg`. ```
src/io/read_ready.rs:241🔴
-
Context:
unsafe { arg.assume_init() }insideReadReady::num_ready_bytesforTcpStreamon Windows. -
Proposed Proof Comment:
```rust // SAFETY: `ioctlsocket` returning 0 indicates success, guaranteeing that `arg` was initialized with the readable byte count. ```
src/io/is_read_write.rs:131🟡
-
Context:
unsafe { send(...) }insideraw_socket_is_read_write. -
Proposed Proof Comment:
```rust // SAFETY: `socket` is passed by value. Passing a null buffer pointer with length 0 to `send` is permitted by Winsock to probe socket write status without sending data. ```
src/io/is_read_write.rs:144🟡
-
Context:
unsafe { recv(...) }insideraw_socket_is_read_write. -
Proposed Proof Comment:
```rust // SAFETY: (Note: Unsound as written due to `usize::MAX` pointer). When passing a valid 1-byte buffer pointer `buf.as_mut_ptr()`, calling `recv` with `MSG_PEEK` safely probes socket read availability without consuming data. ```
tests/sys_common/io.rs:9🟡
-
Context:
unsafe { tempdir(...) }insidetmpdir. -
Proposed Proof Comment:
```rust // SAFETY: Called within test execution context where ambient filesystem authority is available and permitted. ```
- Dominant language
- Rust
- Stars
- 55
- Forks
- 20
- PR merge metrics
- No merged PRs in 30d
Contributor guide
No contributing guide indexed for this repository
First steps
- Read the whole issue, then the project's contributing guide.
- Comment on the issue to say you are picking it up — it saves two people doing the same work.
- Fork the repository and make your change on a branch.
- Open a pull request that references the issue number.
More from bytecodealliance/system-interface
-
Difficulty 4/5 3-5 days Newbie friendliness 35/100
All issues in bytecodealliance/system-interface
Similar issues
-
Difficulty 2/5 1-3 hours Newbie friendliness 84/100
Eynzof/Hermes-CN-Desktop#616 ·
-
bug rules
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
-
app bug
Difficulty 2/5 1-3 hours Newbie friendliness 72/100
-
Difficulty 2/5 1-3 hours Newbie friendliness 88/100
IronCoreLabs/ironcore-alloy#346 ·
-
good first issue
Difficulty 2/5 1-3 hours Newbie friendliness 65/100