Platform: Code4rena
Start Date: 27/05/2021
Pot Size: $100,000 USDC
Total HM: 12
Participants: 7
Period: 7 days
Judge: LSDan
Total Solo HM: 10
Id: 12
League: ETH
Rank: 1/7
Findings: 7
Award: $39,207.69
🌟 Selected for report: 6
🚀 Solo Findings: 3
6443.3912 USDC - $6,443.39
gpersoon
The function stir of Cauldron.sol can be manipulated when from == to. In that case the balance of "to" is increased while the balance of "from" isn't decreased. This is due to the fact that a temporary variable is used and the balance of "to" overwrites the balance of "from".
Below is proof of concept with a simplified version of stir which shows the issue. The initial balance of 20 is increased to 25 without decreasing another balance.
// https://github.com/code-423n4/2021-05-yield/blob/main/contracts/Cauldron.sol#L268 contract test {
struct Balances { uint128 art; // Debt amount uint128 ink; // Collateral amount } mapping (uint => Balances) public balances; constructor() { balances[0]=Balances(20,20); } function stir(uint from, uint to, uint128 ink, uint128 art) public { Balances memory balancesFrom = balances[from]; Balances memory balancesTo = balances[to]; if (ink > 0) { balancesFrom.ink -= ink; balancesTo.ink += ink; } if (art > 0) { balancesFrom.art -= art; balancesTo.art += art; } balances[from] = balancesFrom; balances[to] = balancesTo;
}
function TestStir() public returns(Balances memory) {
stir(0,0,5,5);
return balances[0]; // returns (25,25)
}
}
Editor
Add a check to prevent that to and from are the same, for example require (to != from, "To and From should be different");
#0 - alcueca
2021-06-01T10:21:28Z
Ok, I got spooked with this one. This is a very serious issue, and we are most grateful that you found it.
#1 - uivlis
2021-06-09T12:28:55Z
Finding mitigated in https://github.com/yieldprotocol/vault-v2/pull/212.
#2 - dmvt
2021-06-14T20:50:57Z
duplicate of #16
🌟 Selected for report: gpersoon
14318.6472 USDC - $14,318.65
gpersoon
The auth mechanism of AccessControl.sol uses function selectors (msg.sig) as a (unique) role definition. Also the _moduleCall allows the code to be extended. Suppose an attacker wants to add the innocent looking function "left_branch_block(uint32)" in an new module. Suppose this module is added via _moduleCall and the attacker gets authorization for the innocent function. This functions happens to have a signature of 0x00000000, which is equal to the root authorization. This way the attacker could get authorization for the entire project.
Note: it's pretty straightforward to generate function names for any signature value, you can just brute force it because it's only 4 bytes.
// https://github.com/code-423n4/2021-05-yield/blob/main/contracts/utils/access/AccessControl.sol#L90 modifier auth() { require (_hasRole(msg.sig, msg.sender), "Access denied"); _; }
// https://github.com/code-423n4/2021-05-yield/blob/main/contracts/Ladle.sol#L588 function _moduleCall(address module, bytes memory moduleCall) private returns (bool success, bytes memory result) { require (modules[module], "Unregistered module"); (success, result) = module.delegatecall(moduleCall); if (!success) revert(RevertMsgExtractor.getRevertMsg(result)); } }
// https://www.4byte.directory/signatures/?bytes4_signature=0x00000000 Text Signature Bytes Signature left_branch_block(uint32) 0x00000000
// https://github.com/code-423n4/2021-05-yield/blob/main/contracts/utils/access/AccessControl.sol#L47 bytes4 public constant ROOT = 0x00000000;
Do not allow third parties to define or suggest new modules Double check the function signatures of new functions of a new module for collisions
#0 - alcueca
2021-06-01T11:04:20Z
The execution of any auth
function will only happen after a governance process, or by a contract that has gone through a thorough review and governance process.
We are aware that new modules can have complete control of the Ladle, and for that reason the addition of new modules would be subject to the highest level of scrutiny. Checking for signature collisions is a good item to add to that process.
In addition to that, I would implement two changes in AccessControl.sol
so that giving ROOT access is explicit.
function grantRole(bytes4 role, address account) external virtual admin(role) { require(role != ROOT, "Not ROOT role"); _grantRole(role, account); } function grantRoot(address account) external virtual admin(ROOT) { _grantRole(ROOT, account); }
However, given that this could be exploited only through a malicious governance exploit, I would reduce the risk to "Low"
#1 - alcueca
2021-06-21T12:32:35Z
After further thinking, instead of preventing auth collisions in the smart contracts, we will add CI checks for this specific issue instead.
🌟 Selected for report: gpersoon
14318.6472 USDC - $14,318.65
gpersoon
The V1 version of YieldMath.sol contains ">=" (larger or equal), while the V2 version of YieldMath.sol containt ">" (larger) in the log_2 function. This change doesn't seem logical and might lead to miss calculations. The difference is present in a number of adjacent lines.
// https://github.com/yieldprotocol/yieldspace-v1/blob/master/contracts/YieldMath.sol#L217 function log_2 (uint128 x) ... b = b * b >> 127; if (b >= 0x100000000000000000000000000000000) {b >>= 1; l |= 0x1000000000000000000000000000000;}
//https://github.com/code-423n4/2021-05-yield/blob/main/contracts/yieldspace/YieldMath.sol#L58 function log_2(uint128 x) ... b = b * b >> 127; if(b > 0x100000000000000000000000000000000) {b >>= 1; l |= 0x1000000000000000000000000000000;}
diff
Check which version is the correct version and fix the incorrect version.
#0 - alcueca
2021-06-01T11:10:57Z
That's entirely my fault, and this is a scary one. We might be having a slightly different or impredictable curve in Pool.sol, and we might notice only after a long while with the Pools being slowly drained. We might never even have found this was the issue.
I would suggest increasing the severity of this issue to High.
#1 - alcueca
2021-06-04T11:07:32Z
1933.0174 USDC - $1,933.02
gpersoon
The witch.sol contract gets access to a vault via the grab function, in case of liquidation. If the witch.sol contract can't sell the debt within a certain amount of time, a second grab can occur.
After the second grab, the information of the original owner of the vault is lost and the vault can't be returned to the original owner once the debt has been sold.
The grab function stores the previous owner in vaultOwners[vaultId] and then the contract itself is the new owner (via cauldron.grab and cauldron._give). The vaultOwners[vaultId] is overwritten at the second grab
The function buy of Witch.sol tried to give the vault back to the original owner, which won't succeed after a second grab.
// https://github.com/code-423n4/2021-05-yield/blob/main/contracts/Witch.sol#L50 function grab(bytes12 vaultId) public { DataTypes.Vault memory vault = cauldron.vaults(vaultId); vaultOwners[vaultId] = vault.owner; cauldron.grab(vaultId, address(this)); }
// https://github.com/code-423n4/2021-05-yield/blob/main/contracts/Cauldron.sol#L349 function grab(bytes12 vaultId, address receiver) external auth { ... _give(vaultId, receiver);
// https://github.com/code-423n4/2021-05-yield/blob/main/contracts/Cauldron.sol#L349 function _give(bytes12 vaultId, address receiver) internal returns(DataTypes.Vault memory vault) { ... vault.owner = receiver; vaults[vaultId] = vault;
// https://github.com/code-423n4/2021-05-yield/blob/main/contracts/Witch.sol#L57 function buy(bytes12 vaultId, uint128 art, uint128 min) public { .... cauldron.give(vaultId, vaultOwners[vaultId]);
Editor
Assuming it's useful to give back to vault to the original owner: Make a stack/array of previous owners if multiple instances of the witch.sol contract would be used. Or check if the witch is already the owner (in the grab function) and keep the vaultOwners[vaultId] if that is the case
#0 - alcueca
2021-06-01T10:20:33Z
This is a good finding, and a vulnerability that we will fix. I anticipate that we will store the original owner in Cauldron.auctions
along with the time at which the auction was started.
#1 - alcueca
2021-06-02T21:47:44Z
Actually, we might remove some overengineering by taking out the feature of allowing multiple competing liquidation engines.
That would allow us to:
auctions
mapping to the Witch
auctionInterval
settinggrab
grab
🌟 Selected for report: gpersoon
1933.0174 USDC - $1,933.02
gpersoon
The auth modifier of AccessControl.sol doesn't work as you would expect. It checks if you are authorized for "msg.sig", however msg.sig is the signature of the first function you have called, not of the current function. So if you call function A, which calls function B, the "auth" modifier of function B checks if you are authorized for function A!
There is a difference between external an public functions. For external functions this works as expected because a fresh call (with a new msg.sig) is always made. However with a public functions, which are called from within the same contract, this doesn't happen and the problem described above occurs. See in the proof of concept for a piece of code which shows the problem. In the code there are several functions which have public and auth combined, see also in the proof of concept .
In the current codebase I couldn't find a problem situation, however this could be accidentally introduced with future changes. If could also be introduced via the _moduleCall of Ladle.sol, which allows functions to be defined which might call the public functions.
// https://github.com/code-423n4/2021-05-yield/blob/main/contracts/utils/access/AccessControl.sol#L90 modifier auth() { require (_hasRole(msg.sig, msg.sender), "Access denied"); _; }
pragma solidity ^0.8.0; contract TestMsgSig { event log(bytes4);
function setFeePublic(uint256) public { emit log(this.setFeePublic.selector); emit log(msg.sig); } function setFeeExternal(uint256) external { emit log(this.setFeeExternal.selector); emit log(msg.sig); } function TestPublic() public { setFeePublic(2); } function TestExternal() public { this.setFeeExternal(2); }
}
Wand.sol: function addAsset(bytes6 assetId,address asset) public auth {
Wand.sol: function makeBase(bytes6 assetId, IMultiOracleGov oracle, address rateSource, address chiSource) public auth {
Wand.sol: function makeIlk(bytes6 baseId, bytes6 ilkId, IMultiOracleGov oracle, address spotSource, uint32 ratio, uint96 max, uint24 min, uint8 dec) public auth {
Wand.sol: function addSeries(... ) public auth {
Witch.sol: function setAuctionTime(uint128 auctionTime_) public auth {
Witch.sol: function setInitialProportion(uint128 initialProportion_) public auth {
Ladle.sol: function setFee(uint256 fee) public auth
Join.sol: function setFlashFeeFactor(uint256 flashFeeFactor_) public auth {
oracles\chainlink\ChainlinkMultiOracle.sol: function setSource(bytes6 base, bytes6 quote, address source) public auth {
oracles\chainlink\ChainlinkMultiOracle.sol: function setSources(bytes6[] memory bases, bytes6[] memory quotes, address[] memory sources_) public auth {
oracles\compound\CompoundMultiOracle.sol: function setSource(bytes6 base, bytes6 kind, address source) public auth {
oracles\compound\CompoundMultiOracle.sol: function setSources(bytes6[] memory bases, bytes6[] memory kinds, address[] memory sources_) public auth {
oracles\uniswap\UniswapV3Oracle.sol: function setSecondsAgo(uint32 secondsAgo_) public auth {
oracles\uniswap\UniswapV3Oracle.sol: function setSource(bytes6 base, bytes6 quote, address source) public auth {
oracles\uniswap\UniswapV3Oracle.sol: function setSources(bytes6[] memory bases, bytes6[] memory quotes, address[] memory sources_) public auth {
fytoken.sol: function setOracle(IOracle oracle_) public auth {
grep
make sure all auth functions use external (still error prone) or change the modifier to something like:
modifier auth(bytes4 fs) { require (msg.sig == fs,"Wrong selector"); require (_hasRole(msg.sig, msg.sender), "Access denied"); _; }
function setFee(uint256) public auth(this.setFee.selector) { ..... }
#0 - alcueca
2021-06-01T11:17:30Z
While a number of governance functions have been marked public
instead of external
throughout the code, they are never called from any other function in the same contract, and they should never be.
In all contracts of the protocol, auth
functions are only called by other contracts or by EOAs, with the latter being governance actions.
The suggestion of changing all public auth
functions to external auth
will be applied, however no changes will be made to AccessControl.sol, and the suggested severity is 1.
🌟 Selected for report: gpersoon
0 USDC - $0.00
gpersoon
The function toAsciiString can be improved the be easier to read and use less gas. // https://github.com/code-423n4/2021-05-yield/blob/main/contracts/utils/AddressStringUtil.sol
pragma solidity >=0.5.0;
contract test { function toAsciiString(address addr, uint256 len) public pure returns (string memory) { require(len % 2 == 0 && len > 0 && len <= 40, 'AddressStringUtil: INVALID_LEN'); bytes memory s = new bytes(len); uint256 addrNum = uint256(uint160(addr)); for (uint256 ii = 0; ii < len ; ii +=2) { uint8 b = uint8(addrNum >> (4 * (38 - ii))); s[ii] = char(b >> 4); s[ii + 1] = char(b & 0x0f); } return string(s); }
function char(uint8 b) private pure returns (bytes1 c) { if (b < 10) { return bytes1(b + 0x30); } else { return bytes1(b + 0x37); } }
}
See proof of concept above for improved version
🌟 Selected for report: gpersoon
0 USDC - $0.00
gpersoon
In the function batch of Ladle.sol, at the operation GIVE, the value of vault is stored and is deleted directly afterwards. So storing is unnecessary. Maybe the solidity compiler already optimizes this.
// https://github.com/code-423n4/2021-05-yield/blob/main/contracts/Ladle.sol#L228
function batch( } else if (operation == Operation.GIVE) { ... vault = _give(vaultId, to); delete vault; // Clear the cache, since the vault doesn't necessarily belong to msg.sender anymore
Remove the " vault = "
#0 - alcueca
2021-06-02T15:22:06Z
Thanks!