Bounds-check AbstractMemory#__copy_from__ - #1193
Open
Watson1978 wants to merge 1 commit into
Open
Watson1978 wants to merge 1 commit into
Watson1978 wants to merge 1 commit into
Conversation
__copy_from__ memcpy'd without checking either operand, so any length larger than the destination was an out-of-bounds write and any length larger than the source an out-of-bounds read. The length was only narrowed to int; nothing compared it against dst->size or src->size. This is reachable without calling __copy_from__ at all. Assigning a struct to a nested struct field goes through StructLayout::InnerStruct#put, which copies the declared field size out of value.pointer without checking that the source struct is backed by that many bytes, so `outer[:inner] = inner` read past the end of the source allocation. Guarding the sink covers that caller and any other, so struct_layout.rb needs no change. Validate both operands with the same helpers the other accessors use. checkBounds tests (off | len | (off + len) | (size - (off + len))) < 0, so it rejects negative lengths as well as over-long ones. Pointers created from a raw address carry size == LONG_MAX and still pass, so the deliberately unchecked raw-pointer primitives are unaffected. The length conversion widens from NUM2INT to NUM2LONG so that range validation happens in checkBounds rather than being split between the conversion and the check, matching every other length in this file. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
Summary
AbstractMemory#__copy_from__performed amemcpywith no bounds check on either operand, so any length larger than the destination was an out-of-bounds write and any length larger than the source was an out-of-bounds read.The length was only narrowed to
int; nothing validated it againstdst->sizeorsrc->size. A plain positive length was enough — no raw pointer, no manual memory management and noattach_functionwere involved.This also reached user code that never calls
__copy_from__directly.FFI::StructLayout::InnerStruct#put(lib/ffi/struct_layout.rb:77) copies the declared field size out ofvalue.pointerwithout checking that the source struct is actually backed by that many bytes, so a nested struct assignmentouter[:inner] = innerread past the end of the source allocation.Reproduction
Before this change, on Linux x86-64 with Ruby 4.0.6 this segfaulted. Under AddressSanitizer:
The nested struct path, which needs no direct
__copy_from__call:Note that this second case did not crash in an uninstrumented build — it silently read adjacent heap memory.
Change
memory_copy_fromnow validates both operands with the same helpers the other accessors use:checkBoundstests(off | len | (off + len) | (size - (off + len))) < 0, so it rejects negative lengths as well as over-long ones. Pointers created from a raw address keepsize == LONG_MAX, so they still pass and the deliberately unchecked raw-pointer primitives are unaffected.The length conversion changed from
NUM2INTtoNUM2LONGso that range validation is handled uniformly bycheckBoundsrather than split between the conversion and the check, matching every other offset and length inAbstractMemory.c.lib/ffi/struct_layout.rbis deliberately left alone. The legitimate nested-struct path slices the destination to exactly the field size and passes a source that is at least that large, so it satisfies both checks unchanged; guarding the sink rather than that one caller also covers anything else that reaches__copy_from__.Both cases above now raise
IndexError: Memory access offset=0 size=... is out of bounds.Behaviour changes
IndexErrorinstead of corrupting memory.INT_MAXnow raisesIndexErrorrather thanRangeError, because the conversion widened toNUM2LONG.__copy_from__on a frozen (freezed, i.e.MEM_WRcleared) memory object now raises, consistent withput_bytesand the other writers.Tests
__copy_from__had no spec coverage at all before this. Added tospec/ffi/pointer_spec.rb:IndexErrorwhen the length exceeds the destinationIndexErrorwhen the length exceeds the sourceIndexErrorfor a negative lengthAdded to
spec/ffi/struct_spec.rb:IndexErrorwhen a struct assigned to a nested field is backed by too little memoryCross-engine results
Each example was run on all three engines before deciding whether to guard it, rather than assuming C-extension-only. The two ends came out differently:
__copy_from__at all (NoMethodError), so the whole#__copy_from__block is skipped there. Separately, JRuby already rejects the undersized nested struct — but at construction time, withArgumentError: memory object has insufficient space, not at assignment — so the nested-struct example does not fit it either.__copy_from__and the plain copy works, so that example runs there. But it performs no bounds check: an 8-byte destination accepts a 4096-byte copy silently, the undersized nested struct assignment silently reads out of bounds, and a negative length reachesUnsafeand escapes as a JavaIllegalArgumentExceptionthat terminates the interpreter. Those three are skipped there.Suite results on this branch:
master, plus the 6 added here)The single JRuby failure is
library_spec.rb:105("interprets INPUT() in linker scripts"), which fails identically on a tree without this change — a pre-existing environment-dependent failure in my local setup, not a regression.Verification
Verified on Linux x86-64, Ruby 4.0.6, system libffi 3.7.1, with an AddressSanitizer/UndefinedBehaviorSanitizer build of the extension. Both reproductions above report
heap-buffer-overflowbefore the change and raiseIndexErrorwith no sanitizer output after it. The only remaining sanitizer output is the pre-existing misaligned-load report atMethodHandle.c:281, which fires on anyattach_functioncall and is unrelated.The trigger is data-model independent — a small concrete length is enough — so LLP64 needs no separate derivation.
Notes for review
NUM2INTtoNUM2LONGmeans a length aboveINT_MAXnow raisesIndexErrorinstead ofRangeError.__copy_from__is effectively internal —struct_layout.rbis its only in-tree caller — but it is a public method, so this is worth a conscious nod.checkWriteon frozen memory.__copy_from__on a frozen memory object now raises, matching the other writers. Called out in case the omission was deliberate.InnerStruct#putcould have been fixed in Ruby instead. Guarding__copy_from__was chosen so the check applies to every caller; happy to add a Ruby-side check as well if you would rather have a clearer error message at that layer.__copy_from__performs no bounds check and silently writes out of bounds. Not this PR's to fix, but it seems worth reporting upstream — I have not filed anything.🤖 Generated with Claude Code