March 31, 2020·4 min read·engineeringrubysecuritysocketsmemory

Ruby socket reads stop exposing uninitialized buffer bytes

CVE-2020-10933 comes from changing a reusable string buffer before a nonblocking read succeeds. The fix separates capacity from the visible string length.

A nonblocking read can fail without receiving any data, but its output buffer can still have changed. That is the important detail in CVE-2020-10933, disclosed on March 31. Ruby's socket library resized a caller-supplied string before attempting certain reads. When the operation would block, the string could expose uninitialized heap bytes even though the read had not produced a message.

The advisory identifies BasicSocket#recv_nonblock and BasicSocket#read_nonblock when called with size and buffer arguments. It limits exploitability to Linux and says the issue dates to Ruby 2.5.0. Ruby 2.4 is not affected. The affected maintained series are 2.5 through 2.5.7, 2.6 through 2.6.5 and 2.7.0.

I find Ruby's readable interfaces useful, but this case shows why an exception does not always mean an operation left its arguments untouched. The buffer is an object the caller already holds. Its state remains observable after control leaves the read method, including through a failure path.

Capacity and length mean different things

The fixing commit changes ext/socket/init.c. Its stated purpose is to resize the string only when data has been received. The distinction is between reserving memory for a possible read and declaring that the string contains that many meaningful bytes.

The helper preparing the buffer previously set its visible length to the requested size. Expanding storage is useful because the operating-system call needs somewhere to place data. Increasing the visible string length at that moment is premature, since the system call may return without writing any bytes.

A Ruby string has both storage and a length describing its accessible contents. If the length includes newly allocated memory that has not been initialized by the read, ordinary string operations can observe bytes outside the received data. The language-level object remains usable, which is what makes this a disclosure problem rather than merely an internal allocation detail.

The patch removes the early rb_str_set_len from the preparation helper. It then sets the length based on the actual result after a successful receive. That changes when the object begins claiming that its extra storage contains data.

The system call still needs the requested size

Removing the early length change has another consequence. The receive operation previously used RSTRING_LEN to determine how much data it could write. Once length no longer means prepared capacity, the system call needs a separate value for the requested amount.

The patch adds a length field to the receive argument structure. The call to recvfrom uses that field, and the setup code assigns the requested buffer length to it. This preserves the amount of memory made available to the system call while separating it from the string's current logical contents.

That is the central implementation change. Merely deleting the resize without changing the call's length argument would alter what the read attempts. The revised code carries both pieces of information explicitly, so it can prepare writable capacity without publishing it as received content.

After the receive completes, the code sets the string length to the number of bytes returned. A short read therefore describes the bytes actually received, even when the caller requested more. The value governing a system call's maximum write and the value governing a string's visible contents now come from the appropriate stages.

The would-block path is normal control flow

Nonblocking I/O exists so a caller can attempt work without waiting for data indefinitely. A result indicating that the operation would block is therefore an expected event in a program using the interface. It cannot be treated as an exceptional corner case in the implementation's memory-state reasoning.

The advisory describes the vulnerable path as returning without copying data after the buffer was resized. A caller may then wait for readiness and try again. During the interval, any code still holding the buffer can observe its contents. The security boundary depends on not making unwritten bytes visible during that ordinary retry cycle.

This also explains why looking only at a successful read is insufficient to understand the defect. A successful receive overwrites some of the prepared memory and updates the resulting length. The failure path leaves no received bytes to justify the earlier expansion. Testing the successful case alone can therefore miss the state change that matters.

The patch also handles a zero-length request explicitly by setting the string length to zero before returning it. That case has a defined result without needing a receive operation. Keeping it explicit avoids making the general buffer-preparation helper publish a requested length for all callers.

The read path updates length after obtaining its result

For read_nonblock, the patch compares the received count against the string's actual length before updating it. It also removes an extra resize of a newly created string in that section. The resulting code follows the same principle of deriving visible data length from the completed operation.

I prefer this separation because it makes the invariant apparent in the implementation. Memory can be reserved in advance, while only initialized bytes count as content. That is a useful property for an API reusing caller-owned objects, where state can outlive both successful and failed invocations.

Ruby publishes fixes through 2.5.8, 2.6.6 and 2.7.1. Each release announcement identifies the socket advisory among its security fixes. The vulnerability is in the interpreter's socket extension, so the relevant corrective release is the Ruby runtime used by the process, rather than a change to an application's socket-call spelling.

The advisory credits Samuel Williams with discovering the issue. Its affected-method description gives a focused way to understand exposure in application code, but the repair belongs below those calls. Keeping a buffer's length tied to initialized data ensures that ordinary failure handling does not reveal unrelated bytes from the interpreter's heap.