Where
-Infinity
0
Severity
2.3
EPSS
0.03%
CVSS:4.0/AV:N/AC:H/AT:P/PR:L/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

vyper is a Pythonic Smart Contract Language for the EVM. Vyper sqrt() builtin uses the babylonian method to calculate square roots of decimals. Unfortunately, improper handling of the oscillating final states may lead to sqrt incorrectly returning rounded up results. This issue is being addressed and a fix is expected in version 0.4.1. Users are advised to upgrade as soon as the patched release is available. There are no known workarounds for this vulnerability.

1 / 2
Source: MITRE
First published (updated )
Severity
2.3
EPSS
0.05%
CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:L/VI:L/VA:N/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Multiple evaluation of a single expression is possible in the iterator target of a for loop. While the iterator expression cannot produce multiple writes, it can consume side effects produced in the loop body (e.g. read a storage variable updated in the loop body) and thus lead to unexpected program behavior. Specifically, reads in iterators which contain an ifexp (e.g. for s: uint256 in ([read(), read()] if True else [])) may interleave reads with writes in the loop body.

The fix is tracked in https://github.com/vyperlang/vyper/pull/4488.

Vulnerability Details

Vyper for loops allow two kinds of iterator targets, namely the range() builtin and an iterable type, like SArray and DArray.

During codegen, iterable lists are required to not produce any side-effects (in the following code, rangescope forces iterlist to be parsed in a constant context, which is checked against isconstant).

python def parseForlist(self): with self.context.rangescope(): iterlist = Expr(self.stmt.iter, self.context).irnode ...

def rangescope(self): prevvalue = self.inrangeexpr self.inrangeexpr = True yield self.inrangeexpr = prevvalue

def isconstant(self): return self.constancy is Constancy.Constant or self.inrangeexpr

However, this does not prevent the iterator from consuming side effects provided by the body of the loop. For dynamic arrays, the compiler simply panics: vyper x: DynArray[uint256, 3]

@external def test(): for i: uint256 in (self.usesideeffect() if True else self.usesideeffect()): pass

@view def usesideeffect() -> DynArray[uint256, 3]: return self.x

For SArrays on the other hand, iterlist is instantiated in the body of a repeat ir, so it can be evaluated several times.

Here are three illustrating examples. In the first example, the following test case pre-evaluates the iter list and stores the result to a temporary list in memory. So the list is only evaluated once, before entry into the loop body, and the log output will be 0, 0, 0. vyper event I: i: uint256

x: uint256

@deploy def init(): self.x = 0

@external def test(): for i: uint256 in [self.usesideeffect(), self.usesideeffect(), self.usesideeffect()]: self.x += 1 log I(i)

@view def usesideeffect() -> uint256: return self.x

However, in the next two examples, because the iterator target is not a list literal, it will be evaluated in the loop body. In the second example, iterlist is an ifexp, thus it will be evaluated lazily in the loop body. The log output will be 0, 1, 2 due to consumption of side effects.

vyper event I: i: uint256

x: uint256

@deploy def init(): self.x = 0

@external def test(): for i: uint256 in ([self.usesideeffect(), self.usesideeffect(), self.usesideeffect()] if True else self.otherclause()): self.x += 1 log I(i)

@view def usesideeffect() -> uint256: return self.x

@view def otherclause() -> uint256[3]: return [0, 0, 0]

In the third example, iterlist is also an ifexp, thus it will only be evaluated in the loop body. The log output will be 0, 1, 2 due to consumption of side effects.

vyper event I: i: uint256

x: uint256[3]

@deploy def init(): self.x = [0, 0, 0]

@external def test(): for i: uint256 in (self.usesideeffect() if True else self.otherclause()): self.x[0] += 1 self.x[1] += 1 self.x[2] += 1 log I(i)

@view def usesideeffect() -> uint256[3]: return self.x

@view def otherclause() -> uint256[3]: return [0, 0, 0]

1 / 2
Source: GitHub
First published (updated )
Severity
2.3
EPSS
0.05%
CVSS:4.0/AV:N/AC:L/AT:P/PR:L/UI:N/VC:L/VI:L/VA:L/SC:N/SI:N/SA:N/E:X/CR:X/IR:X/AR:X/MAV:X/MAC:X/MAT:X/MPR:X/MUI:X/MVC:X/MVI:X/MVA:X/MSC:X/MSI:X/MSA:X/S:X/AU:X/R:X/V:X/RE:X/U:X

Vyper handles AugAssign statements by first caching the target location to avoid double evaluation. However, in the case when target is an access to a DynArray and the rhs modifies the array, the cached target will evaluate first, and the bounds check will not be re-evaluated during the write portion of the statement. In other words, the following code

vyper def poc(): a: DynArray[uint256, 2] = [1, 2] a[1] += a.pop()

is equivalent to: vyper def poc(): a: DynArray[uint256, 2] = [1, 2] a[1] += a[len(a) - 1] a.pop() rather than: vyper def poc(): a: DynArray[uint256, 2] = [1, 2] s: uint256 = a[1] t: uint256 = a.pop() a[1] = s + t # reverts due to oob access

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
EPSS
0.04%
AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N

Summary

When using the built-in extract32(b, start), if the start index provided has for side effect to update b, the byte array to extract 32 bytes from, it could be that some dirty memory is read and returned by extract32.

As of v0.4.0 (specifically, commit https://github.com/vyperlang/vyper/commit/3d9c537142fb99b2672f21e2057f5f202cde194f), the compiler will panic instead of generating bytecode.

Details

Before evaluating start, the function Extract32.buildIR caches only:

- The pointer in memory/storage to b: https://github.com/vyperlang/vyper/blob/10564dcc37756f3d3684b7a91fd8f4325a38c4d8/vyper/builtins/functions.py#L916-L918 - The length of b: https://github.com/vyperlang/vyper/blob/10564dcc37756f3d3684b7a91fd8f4325a38c4d8/vyper/builtins/functions.py#L920-L922

but do not cache the actual content of b. This means that if the evaluation of start changes b's content and length, an outdated length will be used with the new content when extracting 32 bytes from b.

PoC

Calling the function foo of the following contract returns b'uuuuuuuuuuuuuuuuuuuuuuuuuuu\x00\x00789' meaning that extract32 accessed some dirty memory.

Vyper var:Bytes[96]

@internal def bar() -> uint256: self.var = b'uuuuuuuuuuuuuuuuuuuuuuuuuuuuuu' self.var = b'' return 3

@external def foo() -> bytes32: self.var = b'abcdefghijklmnopqrstuvwxyz123456789' return extract32(self.var, self.bar(), outputtype=bytes32) # returns b'uuuuuuuuuuuuuuuuuuuuuuuuuuu\x00\x00789'

Impact

For contracts that are affected, it means that calling extract32 returns dirty memory bytes instead of some expected output.

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
EPSS
0.04%
Buffer Overflow
CVSS:3.1/AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N

Summary

If an excessively large value is specified as the starting index for an array in abidecode, it can cause the read position to overflow. This results in the decoding of values outside the intended array bounds, potentially leading to bugs in contracts that use arrays within abidecode. The advisory has been assigned low severity, because it is only observable if there is a memory write between two invocations of abidecode on the same input.

Proof of Concept

vyper event Pwn: pass

@external def f(x: Bytes[32 3]): a: Bytes[32] = b"foo" y: Bytes[32 3] = x

decodedy1: Bytes[32] = abidecode(y, Bytes[32]) a = b"bar" decodedy2: Bytes[32] = abidecode(y, Bytes[32])

if decodedy1 != decodedy2: log Pwn()

Sending the following calldata results in Pwn being emitted.

0xd45754f8 0000000000000000000000000000000000000000000000000000000000000020 0000000000000000000000000000000000000000000000000000000000000060 ffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffffa0

Patches Patched in https://github.com/vyperlang/vyper/pull/3925, https://github.com/vyperlang/vyper/pull/4091, https://github.com/vyperlang/vyper/pull/4144, https://github.com/vyperlang/vyper/pull/4060.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
EPSS
0.07%
Out-of-bounds Read
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary Arrays can be keyed by a signed integer, while they are defined for unsigned integers only. The typechecker doesn't throw when spotting the usage of an int as an index for an array. Typically, negative integers are filtered out at runtime by the bounds checker, but small enough (i.e. large in magnitude, ex. -2255 + 5) quantities combined with large enough arrays (at least 2255 in length) can pass the bounds checker, resulting in unexpected behavior.

A contract search was performed, and no production contracts were found to be impacted.

Details The typechecker allows the usage of signed integers to be used as indexes to arrays. The vulnerability is present in different forms in all versions. Here is an example from 0.3.10: https://github.com/vyperlang/vyper/blob/c150fc49ee9375a930d177044559b83cb95f7963/vyper/semantics/types/subscriptable.py#L127-L137

As can be seen, the validation is performed against IntegerT.any().

PoC If the array is sufficiently large, it can be indexed with a negative value: python arr: public(uint256[MAXUINT256])

@external def set(idx: int256, num: uint256): self.arr[idx] = num For signed integers, the 2's complement representation is used. Because the array was declared very large, the bounds checking will pass (negative values will simply be represented as very large numbers): https://github.com/vyperlang/vyper/blob/a1fd228cb9936c3e4bbca6f3ee3fb4426ef45490/vyper/codegen/core.py#L534-L541

Patches Patched in https://github.com/vyperlang/vyper/pull/3817.

Impact There are two potential vulnerability classes: unpredictable behavior and accessing inaccessible elements.

1. If it is possible to index an array with a negative integer without reverting, this is most likely not anticipated by the developer and such accesses can cause unpredictable behavior for the contract.

2. If a contract has an invariant in the form assert index < x where both index and x are signed integers, the developer might suppose that no elements on indexes y | y >= x are accessible. However, by using negative indexes this can be bypassed.

The contract search found no production contracts impacted by these two classes of issues.

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
EPSS
0.05%
AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N

Summary There is an error in the stack management when compiling the IR for sha364. Concretely, the height variable is miscalculated. The vulnerability can't be triggered without writing the IR by hand. That is, it cannot be triggered from regular vyper code, it can only be triggered by using the fang binary directly (this binary used to be called vyper-ir prior to v0.3.4).

Details To compile sha364, the arg[0] and arg[1] have to be compiled: https://github.com/vyperlang/vyper/blob/c150fc49ee9375a930d177044559b83cb95f7963/vyper/ir/compileir.py#L585-L586

As can be seen, after compiling the 0th arg, the height variable isn't increased. If new withargs are defined in the inner scope, they are manipulated correctly, because both their height is off and also the global height is off and thus their placement on the stack is computed correctly.

sha364 is used for retrieval in mappings. No flow that would cache the key was found, the issue shouldn't be possible to trigger when compiling the compiler-generated IR.

PoC Suppose the following hand-written IR: lisp (with loc (with val 1 (with key 2 (sha364 val key))) (seq (sstore loc (with x (sload loc) (with ans (add x 1) (seq (assert (ge ans x)) ans)))))) after compilation: the generated bytecode: 6001600281806020525f5260405f2090509050805460018101818110610026579050815550005b5f80fd

0000 60 PUSH1 0x01 0002 60 PUSH1 0x02 0004 81 DUP2 0005 80 DUP1 bad code here!!!!!! 0006 60 PUSH1 0x20 0008 52 MSTORE

It can be seen that the second DUP will dup the item on the top of the stack which is incorrect.

Patches Patched in https://github.com/vyperlang/vyper/pull/4063.

Impact Versions v0.2.0-v0.3.10 were evaluated, and access of the variable with the invalid height is not reachable from IR generated by the vyper front-end. Because the issue isn't triggered during normal compilation of vyper code, the impact is considered low.

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
EPSS
0.05%
Buffer Overflow
AV:N/AC:H/PR:N/UI:N/S:U/C:L/I:N/A:N

Summary

When calls to external contracts are made, we write the input buffer starting at byte 28, and allocate the return buffer to start at byte 0 (overlapping with the input buffer). When checking RETURNDATASIZE for dynamic types, the size is compared only to the minimum allowed size for that type, and not to the returned value's length. As a result, malformed return data can cause the contract to mistake data from the input buffer for returndata.

This advisory is given a severity of "Low" because when the called contract returns invalid ABIv2 encoded data, the calling contract can read different invalid data (from the dirty buffer) than the called contract returned.

Details

When arguments are packed for an external call, we create a buffer of size max(args, returndata) + 32. The input buffer is placed in this buffer (starting at byte 28), and the return buffer is allocated to start at byte 0. The assumption is that we can reuse the memory becase we will not be able to read past RETURNDATASIZE.

python if fntype.returntype is not None: returnabit = calculatetypeforexternalreturn(fntype.returntype).abitype

# we use the same buffer for args and returndata, # so allocate enough space here for the returndata too. buflen = max(argsabit.sizebound(), returnabit.sizebound()) else: buflen = argsabit.sizebound()

buflen += 32 # padding for the method id

When data is returned, we unpack the return data by starting at byte 0. We check that RETURNDATASIZE is greater than the minimum allowed for the returned type: python if not callkwargs.skipcontractcheck: assertion = IRnode.fromlist( ["assert", ["ge", "returndatasize", minreturnsize]], errormsg="returndatasize too small", ) unpacker.append(assertion)

This check ensures that any dynamic types returned will have a size of at least 64. However, it does not verify that RETURNDATASIZE is as large as the length word of the dynamic type.

As a result, if a contract expects a dynamic type to be returned, and the part of the return data that is read as length includes a size that is larger than the actual RETURNDATASIZE, the return data read from the buffer will overrun the actual return data size and read from the input buffer.

Proof of Concept

This contract calls an external contract with two arguments. As the call is made, the buffer includes: - byte 28: methodid - byte 32: first argument (0) - byte 64: second argument (hash)

The return data buffer begins at byte 0, and will return the returned bytestring, up to a maximum length of 96 bytes.

python interface Zero: def sneaky(a: uint256, b: bytes32) -> Bytes[96]: view

@external def testsneaky(z: address) -> Bytes[96]: return Zero(z).sneaky(0, keccak256("oops")) On the other side, imagine a simple contract that does not, in fact, return a bytestring, but instead returns two uint256s. I've implemented it in Solidity for ease of use with Foundry: solidity function sneaky(uint a, bytes32 b) external pure returns (uint, uint) { return (32, 32); }

The return data will be parsed as a bytestring. The first 32 will point us to byte 32 to read the length. The second 32 will be perceived as the length. It will then read the next 32 bytes from the return data buffer, even though those weren't a part of the return data.

Since these bytes will come from byte 64, we can see above that the hash was placed there in the input buffer.

If we run the following Foundry test, we can see that this does in fact happen: solidity function testsneakyZeroReturn() public { ZeroReturn z = new ZeroReturn(); c = SuperContract(deployer.deploy("src/loose/", "retoverflow", "")); console.logBytes(c.testsneaky(address(z))); }

md Logs: 0xd54c03ccbc84dd6002c98c6df5a828e42272fc54b512ca20694392ca89c4d2c6

Patches Patched in https://github.com/vyperlang/vyper/pull/3925, https://github.com/vyperlang/vyper/pull/4091, https://github.com/vyperlang/vyper/pull/4144, https://github.com/vyperlang/vyper/pull/4060.

Impact

Malicious or mistaken contracts returning the malformed data can result in overrunning the returned data and reading return data from the input buffer.

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
EPSS
0.07%
Buffer Overflow
AV:N/AC:L/PR:N/UI:N/S:U/C:H/I:H/A:H

Summary

The bounds check for slices does not account for the ability for start + length to overflow when the values aren't literals.

If a slice() function uses a non-literal argument for the start or length variable, this creates the ability for an attacker to overflow the bounds check.

This issue can be used to do OOB access to storage, memory or calldata addresses. It can also be used to corrupt the length slot of the respective array.

A contract search was performed and no vulnerable contracts were found in production.

tracking in issue https://github.com/vyperlang/vyper/issues/3756. patched in https://github.com/vyperlang/vyper/pull/3818.

Details Here the flow for storage is supposed, but it is generalizable also for the other locations.

When calling slice() on a storage value, there are compile time bounds checks if the start and length values are literals, but of course this cannot happen if they are passed values:

python if not isadhocslice: if lengthliteral is not None: if lengthliteral < 1: raise ArgumentException("Length cannot be less than 1", lengthexpr)

if lengthliteral > argtype.length: raise ArgumentException(f"slice out of bounds for {argtype}", lengthexpr)

if startliteral is not None: if startliteral > argtype.length: raise ArgumentException(f"slice out of bounds for {argtype}", startexpr) if lengthliteral is not None and startliteral + lengthliteral > argtype.length: raise ArgumentException(f"slice out of bounds for {argtype}", node)

At runtime, we perform the following equivalent check, but the runtime check does not account for overflows: python ["assert", ["le", ["add", start, length], srclen]], # bounds check

The storage slice() function copies bytes directly from storage into memory and returns the memory value of the resulting slice. This means that, if a user is able to input the start or length value, they can force an overflow and access an unrelated storage slot.

In 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.

POC: OOB access

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.

python @version ^0.3.9

x: public(Bytes[64]) secret: uint256

@external def init(): self.x = empty(Bytes[64]) self.secret = 42

@external def sliceit(start: uint256) -> Bytes[64]: return slice(self.x, start, 64)

We can use the following manual storage to demonstrate the vulnerability: json {"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 + (2256 - 63) / 32, which is what was set in the above storage layout: solidity function testsliceerror() public { c = SuperContract(deployer.deploywithcustomstorage("src/loose/", "sliceerror", "sliceerrorstorage")); bytes memory result = c.sliceit(115792089237316195423570985008687907853269984665640564039457584007913129639872); // max - 63 console.logBytes(result); }

The result is that we return the secret value from storage: Logs: 0x0000...00002a POC: length corruption OOG 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.

python d: public(Bytes[256]) @external def test(): x : uint256 = 115792089237316195423570985008687907853269984665640564039457584007913129639935 # 2256-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: python @external def test(): x: uint256 = 115792089237316195423570985008687907853269984665640564039457584007913129639935 # 2256 - 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"

Impact

The built-in slice() method can be used for OOB accesses or the corruption of the length slot.

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
EPSS
0.05%
AV:N/AC:H/PR:N/UI:N/S:U/C:N/I:L/A:L

Summary Vyper compiler allows passing a value in builtin rawcall even if the call is a delegatecall or a staticcall. But in the context of delegatecall and staticcall the handling of value is not possible due to the semantics of the respective opcodes, and vyper will silently ignore the value= argument.

A contract search was performed and no vulnerable contracts were found in production.

Details The IR for rawcall is built in the RawCall class: https://github.com/vyperlang/vyper/blob/9136169468f317a53b4e7448389aa315f90b95ba/vyper/builtins/functions.py#L1100

However, the compiler doesn't validate that if either delegatecall or staticall are provided as kwargs, that value wasn't set. For example, the following compiles without errors: python rawcall(self, calldata2, maxoutsize=255, isdelegatecall=True, value=msg.value/2)

Impact If the semantics of the EVM are unknown to the developer, he could suspect that by specifying the value kwarg, exactly the given amount will be sent along to the target. However in fact, no value will be sent.

Here is an example of an potentially problematic implementation of multicall utilizing the rawcall built-in: python valueaccumulator: uint256 = empty(uint256) results: DynArray[Result, maxvalue(uint8)] = [] returndata: Bytes[maxvalue(uint8)] = b"" success: bool = empty(bool) for batch in data: msgvalue: uint256 = batch.value valueaccumulator = unsafeadd(valueaccumulator, msgvalue) if (batch.allowfailure == False): returndata = rawcall(self, batch.calldata, maxoutsize=255, value=msgvalue, isdelegatecall=True) success = True results.append(Result({success: success, returndata: returndata})) else: success, returndata = \ rawcall(self, batch.calldata, maxoutsize=255, value=msgvalue, isdelegatecall=True, revertonfailure=False) results.append(Result({success: success, returndata: returndata})) assert msg.value == valueaccumulator, "Multicall: value mismatch" return results

Patches Fixed in https://github.com/vyperlang/vyper/pull/3755 Workarounds Is there a way for users to fix or remediate the vulnerability without upgrading?

References Are there any links users can visit to find out more?

1 / 2
Source: GitHub
First published (updated )
Severity
9.8
EPSS
0.10%
Buffer Overflow
AV:N/AC:L/PR:N/UI:N/S:U/C:L/I:L/A:L

Summary concat built-in can write over the bounds of the memory buffer that was allocated for it and thus overwrite existing valid data. The root cause is that the buildIR for concat doesn't properly adhere to the API of copy functions (for >=0.3.2 the copybytes function).

A contract search was performed and no vulnerable contracts were found in production.

Tracked in issue https://github.com/vyperlang/vyper/issues/3737

Details The buildIR allocates a new internal variable for the concatenation: https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/builtins/functions.py#L534-L550

Notice that the buffer is allocated for the maxlen + 1 word to actually hold the length of the array.

Later the copybytes function is used to copy the actual source arguments to the destination: https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/builtins/functions.py#L569-L572

The dstdata is defined via: - data ptr - to skip the 1 word that holds the length - offset - to skip the source arguments that were already written to the buffer - the offset is increased via: ["set", ofst, ["add", ofst, arglen]], ie it is increased by the length of the source argument

Now, the copybytes function has multiple control flow paths, the following ones are of interest: 1) https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/codegen/core.py#L270-L273 2) https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/codegen/core.py#L301-L320

Note that the function itself contains the following note: https://github.com/vyperlang/vyper/blob/3b310d5292c4d1448e673d7b3adb223f9353260e/vyper/codegen/core.py#L245-L247

That is we can ask for a copy of 1B yet a whole word is copied.

Consider the first interesting path - if the dstdata's distance to the end of the concat data buffer is < 32B, the copyop = STORE(dst, LOAD(src)) from copybytes will result in buffer overflow as it essentially will mstore to dstdata the mload of the source (mload will load whole word and the distance of the dstdata to the word boundary is <32B).

From the two mentioned paths in copybytes it can be seen that both sources from memory and storage can cause the corruption.

PoC The main attack vector that was found was when the concat is inside an internal function. Suppose we have an external function that calls internal one. In such case the address space is divided such that the memory for the internal function is in lower portion of the adr space. As such the buffer overflow can overwrite valid data of the caller.

Here is a simple example: python #@version ^0.3.9

@internal def bar() -> uint256: sss: String[2] = concat("a", "b") return 1

@external def foo() -> int256: a: int256 = -1 b: uint256 = self.bar() return a

foo should clearly return -1, but it returns 452312848583266388373324160190187140051835877600158453279131187530910662655

-1 was used intentionally due to its bit structure but the value here is fairly irelevant. In this example during the second iteration of the for loop in the buildIR mload to dst+1 will be executed (because len('a') == 1), thus the function will write 1B over the bounds of the buffer. The string 'b' is stored such that its right-most byte is a zero byte. So a zero byte will be written over the bounds. So when -1 is considered it's left-most B will be overwritten to all 0. Therefore it can be seen: 452312848583266388373324160190187140051835877600158453279131187530910662655 == (2248-1) will output True.

IR If we look at the contract's IR (vyper --no optimize -f ir), we see: Line 30 / a: int256 = -1 / [mstore, 320, -1 <-1>], And for the second iteration of the loop in concat: len, [mload, arg], [seq, [with, src, [add, arg, 32], [with, dst, [add, [add, 256 <concat destination>, 32], concatofst], [mstore, dst, [mload, src]]]], [set, concatofst, [add, concatofst, len]]]]], [mstore, 256 <concat destination>, concatofst], 256 <concat destination>]], So the address of the int is 320.

The dst is defined as: [add, [add, 256 <concat destination>, 32], concatofst],. In the second iteration the concatofst will be 1 because len('a)==1 so 256+32+1 = 289. Now this address will be mstored to - so the last mstored B will have the address 289+32=320 which clearly overlaps with the address of the int a.

PoC 2 Due to how immutables are handled, they can be corrupted too: python #@version ^0.3.9

i: immutable(int256)

@external def init(): i = -1 s: String[2] = concat("a", "b")

@external def foo() -> int256: return i

Output of calling foo() = 452312848583266388373324160190187140051835877600158453279131187530910662655.

Impact The buffer overflow can result in the change of semantics of the contract. The overflow is length-dependent and thus it might go unnoticed during contract testing.

However, certainly not all usages of concat will result in overwritten valid data as we require it to be in an internal function and close to the return statement where other memory allocations don't occur.

Concluding remarks The bug based on the fast path in copybytes was likely introduced in: 548d35d720fb6fd8efbdc0ce525bed259a73f0b9. git bisect was used between v0.3.1 and v0.3.2, forge test was run and the test asserted that the function indeed returns -1.

For the general case, 0.3.0 and 0.3.1 are also affected.

1 / 2
Source: GitHub
First published (updated )
Severity
5.3
CVSS:3.1/AV:N/AC:L/PR:N/UI:N/S:U/C:N/I:L/A:N

Impact the ecrecover precompile does not fill the output buffer if the signature does not verify, see https://github.com/ethereum/go-ethereum/blob/b058cf454b3bdc7e770e2b3cec83a0bcb48f55ee/core/vm/contracts.go#L188. however, the ecrecover builtin will still return whatever is at memory location 0.

this means that the if the compiler has been convinced to write to the 0 memory location with specially crafted data (generally, this can happen with a hashmap access or immutable read) just before the ecrecover, a signature check might pass on an invalid signature.

A contract search was performed. Most uses of ecrecover are used for erc2612-style permit implementations, which typically look like:

vyper assert owner != empty(address) assert block.timestamp <= deadline nonce: uint256 = self.nonces[owner] digest: bytes32 = keccak256( concat( b"\x19\x01", self.DOMAINSEPARATOR, keccak256(abiencode(PERMITTYPEHASH, owner, spender, value, nonce, deadline)) ) ) assert ecrecover(digest, convert(v, uint256), convert(r, uint256), convert(s, uint256)) == owner

in this case, the immutable PERMITTYPEHASH is loaded into ecrecover's output buffer right before ecrecover(), and so the output of ecrecover() here when the signature is invalid will be the value of PERMITTYPEHASH. in this case, since PERMITTYPEHASH is not a valid address, it will never compare == to owner, and so the behaviour is exactly the same as if ecrecover() returned 0 in this case.

in general, a contract could have unexpected behavior (i.e. mistakenly pass this style of signature check) if an immutable representing a real address (ex. OWNER) was read right before the ecrecover operation.

Patches v0.3.10 (with 019a37ab98ff53f04fecfadf602b6cd5ac748f7f and #3586)

Workarounds Is there a way for users to fix or remediate the vulnerability without upgrading?

References Are there any links users can visit to find out more?

1 / 2
Source: GitHub
First published (updated )

Contact

SecAlerts Pty Ltd.
132 Wickham Terrace
Fortitude Valley,
QLD 4006, Australia
info@secalerts.co
By using SecAlerts services, you agree to our services end-user license agreement. This website is safeguarded by reCAPTCHA and governed by the Google Privacy Policy and Terms of Service. All names, logos, and brands of products are owned by their respective owners, and any usage of these names, logos, and brands for identification purposes only does not imply endorsement. If you possess any content that requires removal, please get in touch with us.
© 2026 SecAlerts Pty Ltd.
ABN: 70 645 966 203, ACN: 645 966 203