Repository navigation
Description
Summary
Ruby arithmetic reaches Go's make() with a size it rejects, and the resulting panic is then mangled by the top-level recover, so the cause is destroyed and Run() reports nil. Three separable defects in one trace.
Found during the security audit tracked in #768.
(a) The bad allocation
| expression | rbgo | MRI 4.0.5 |
|---|---|---|
[1] * (2**62) |
panic: runtime error: makeslice: cap out of range [recovered, repanicked] |
ArgumentError: argument too big |
[1,2] * (2**62) |
panic: runtime error: makeslice: cap out of range [recovered, repanicked] |
ArgumentError: argument too big |
"x" * (2**62) |
panic: runtime error: makeslice: cap out of range [recovered, repanicked] |
NoMemoryError |
"ab" * (2**62) |
ArgumentError: argument too big |
ArgumentError: argument too big |
The fourth row is the one that localises it. "ab" is handled correctly and "x" is not, with the only difference being the length of the receiver — which points straight at the guard in stringTimes (internal/vm/arith.go):
if n > int64(math.MaxInt/len(src))This tests for arithmetic overflow of the product, but make additionally rejects any capacity above maxAlloc (~2^48 on arm64). At len(src) == 1 the guard is vacuous: MaxInt/1 is the largest int64, so no n can exceed it, and every value sails through to make. The bound being tested is not the bound that fails. The doc comment above it says it raises ArgumentError rather than allocating.
arrayOp's OpMul arm has no overflow guard at all — only n < 0 is checked — and multiplies len(a.Elems) * int(n) straight into make.
The fix pattern already exists in the tree: Array.new(2**62) recovers the same makeslice panic into an ArgumentError (ArgumentError: new: runtime error: makeslice: cap out of range — it leaks the Go text, which is worth tidying, but it does not crash). So the binary-operator path is the gap, not the capability.
A third regime is worth checking when fixing, because it fails differently and I have not reproduced it myself — the audit that found it reports [1,2,3,4] * (2**62) wrapping the product to exactly 0, so make succeeds with cap 0 and the fill loop then attempts 2^62 appends: unbounded memory growth, no panic and no exception. That is worse than a crash, and a guard written only against negative or over-max products would not catch it. I am reporting it as the auditor's measurement rather than mine, and it should be confirmed under a memory cap before being relied on.
(b) The top-level recover destroys the cause — a distinct defect
internal/vm/vm.go has an unchecked type assertion inside the deferred recover():
rerr := r.(RubyError)Any panic value that is not a RubyError makes the assertion itself panic inside the handler, which is what produces [recovered, repanicked]. A host that does recover gets:
returned err : <nil>
escaped panic : interface conversion: interface {} is runtime.errorString, not vm.RubyError
makeslice: cap out of range — the only text naming the real cause — is gone. The handler's cleanup (resetting vm.frameNames and friends) never runs either, so a host that survives does so on a VM in an undefined state.
This is independent of (a): fixing the guards removes this trigger, but any future non-RubyError panic will be laundered the same way. A type switch with a default arm that wraps the value in a RubyError would make every such panic legible instead of one of them.
(c) Run() returns nil for a program that did not complete
Note returned err : <nil> above. Run's documented contract is "a parse, compile or runtime error, or nil on success", so a host that recovers in a wrapper and checks only the error reads a crashed program as a success.
This corroborates #773 from a second, independent direction: there exit/abort/exit! return nil because the named return is set deliberately; here it is nil because the deferred handler panicked before reaching result, err = nil, rerr. Same false success, two unrelated triggers — which argues the problem is the contract, not either trigger.
Severity depends on the host, and both ends are worth stating: for cmd/rbgo this is fatal (no recover; exit 2 with a stack dump). For a careful embedder it is recoverable — a Go host calling ruby.Run inside its own defer recover() survived and went on to run more Ruby — but blind, because of (b).
🤖 Generated with Claude Code
Activity
test(vm): a witness per allocation-bound regime, and the re-panic (#777)
fix(vm): check the SIGN at the entropy sites, not in the backstop (#777)
fix(vm): preserve the cause and the cleanup, but keep propagating (#777)
fix(vm): guard Ruby-sized allocations against the bound that fails, a…