Platform: Code4rena
Start Date: 21/06/2022
Pot Size: $55,000 USDC
Total HM: 29
Participants: 88
Period: 5 days
Judge: gzeon
Total Solo HM: 7
Id: 134
League: ETH
Rank: 10/88
Findings: 9
Award: $1,544.42
🌟 Selected for report: 0
🚀 Solo Findings: 0
🌟 Selected for report: kenzo
Also found by: Metatron, WatchPug, cccz, unforgiven
Functions lend(tempus)
in Lender
convert user's underlying
token to PT
tokens and mint iPT
token for user based on PT
token amount. the code should subtract tempus PT
token balance of Lender
address from the return value of Tempus.depositAndFix()
call but it subtracts iPT
balance of Lender
contract. and the code should store the balance before calling depositAndFix()
. because of this bug code will mint wrong number of iPT
tokens for user which can be huge number of unbacked iPT
.
function depositAndFix
in Tempus
project executes the swap from Tempus' principal token for the underlying asset. code should subtract the balance of the lender's tempus principal token to get the amount transacted in this particular transaction -- otherwise the caller would be minted all tokens deposited. By subtracting the balance, code can get the number of tokens the user has deposited in this particular call.
This is the related code in lend(tempus)
:
// Swap on the Tempus Router using the provided market and params IERC5095 illuminateToken = IERC5095(principalToken(u, m)); uint256 returned = ITempus(tempusAddr).depositAndFix(Any(x), Any(t), a - fee, true, r, d) - illuminateToken.balanceOf(address(this)); // Mint Illuminate zero coupons illuminateToken.mint(msg.sender, returned);
As you can see it uses IERC5095(principalToken(u, m));
balance which is iPT
balance of Lender
address instead of using IMarketPlace(marketPlace).markets(u, m, p)
token balance of Lender
address. also the code calcualtes balance after calling depositAndFix()
which is another bug. it should store the balance of tempus PT
token of Lender
then call depositAndFix()
and subtract stored balance from the return value to find exact amount of minted tempus PT
tokens, and mint iPT
token based on that amount.
now the logic is wrong and it's gonna mint a lot of unbacked iPT
tokens and other users funds would be lost.
VIM
fix the logic based what was intended.
#0 - KenzoAgada
2022-06-28T10:33:15Z
Duplicate of #222
29.8781 USDC - $29.88
https://github.com/code-423n4/2022-06-illuminate/blob/912be2a90ded4a557f121fe565d12ec48d0c4684/lender/Lender.sol#L219-L222 https://github.com/code-423n4/2022-06-illuminate/blob/912be2a90ded4a557f121fe565d12ec48d0c4684/lender/Lender.sol#L301 https://github.com/code-423n4/2022-06-illuminate/blob/912be2a90ded4a557f121fe565d12ec48d0c4684/lender/Lender.sol#L524-L530 https://github.com/code-423n4/2022-06-illuminate/blob/912be2a90ded4a557f121fe565d12ec48d0c4684/lender/Lender.sol#L362-L365 https://github.com/code-423n4/2022-06-illuminate/blob/912be2a90ded4a557f121fe565d12ec48d0c4684/lender/Lender.sol#L641-L657
Functions lend()
in Lender
convert user's underlying
token to PT
tokens and mint iPT
token for user based on PT
token amount. but in some of lend()
functions (lend(yield)
, lend(swivel)
, lend(element)
and lend(sense)
), code calls a external address that is specified by user and use the return value of that call to mint iPT
tokens. there is no whitelist for that external address so attacker can specify malicious contract's address when Lender
calls that address, contract would return a big amount and those lender functions would mint big amount of iPT
token for attacker.
All the lend
functions: (lend(yield)
, lend(swivel)
, lend(element)
and lend(sense)
) have this bug which trusts and mints iPT
token based on return value of external call to user specified address(in some of the logic for minting iPT
is missing which is another bug). so for simplicity we show only how this works for lend(yield)
in details.
This is lend(yeild)
code:
/// @notice lend method signature for both illuminate and yield /// @param p value of a specific principal according to the MarketPlace Principals Enum /// @param u address of an underlying asset /// @param m maturity (timestamp) of the market /// @param a amount of underlying tokens to lend /// @param y yieldspace pool that will execute the swap for the principal token /// @return uint256 the amount of principal tokens lent out function lend( uint8 p, address u, uint256 m, uint256 a, address y ) public unpaused(p) returns (uint256) { // check the principal is illuminate or yield if (p != uint8(MarketPlace.Principals.Illuminate) && p != uint8(MarketPlace.Principals.Yield)) { revert Invalid('principal'); } // uses yield token interface... IYield pool = IYield(y); // the yield token must match the market pair if (address(pool.base()) != u) { revert NotEqual('underlying'); } else if (pool.maturity() > m) { revert NotEqual('maturity'); } // transfer from user to illuminate Safe.transferFrom(IERC20(u), msg.sender, address(this), a); if (p == uint8(MarketPlace.Principals.Yield)) { // Purchase yield PTs to lender.sol (address(this)) uint256 returned = yield(u, y, a - calculateFee(a), address(this)); // Mint and distribute equivalent illuminate PTs IERC5095(principalToken(u, m)).mint(msg.sender, returned); emit Lend(p, u, m, returned); return returned; } else { // Purchase illuminate PTs directly to msg.sender uint256 returned = yield(u, y, a - calculateFee(a), msg.sender); emit Lend(p, u, m, returned); return returned; } }
As you can see it calls yield(u, y, a - calculateFee(a), address(this));
with y
which is defined by user. the return value of yield
call is stored in returned
which is used in minting iPT
token in line IERC5095(principalToken(u, m)).mint(msg.sender, returned);
.
This is yield()
code:
function yield( address u, address y, uint256 a, address r ) internal returns (uint256) { // preview exact swap slippage on yield uint128 returned = IYield(y).sellBasePreview(Cast.u128(a)); // send the remaing amount to the given yield pool Safe.transfer(IERC20(u), y, a); // lend out the remaining tokens in the yield pool IYield(y).sellBase(r, returned); return returned; }
As you can see it makes external calls to y
and return the value of those calls. and the value of y
was defined by caller. to exploit this attacker would do this steps:
Lender
to spend underlying token of attacker address.lend(yield)
with yield
enum id, and set y
(the yield pool to lend to) to attacker control address which is a malicious contract.y
to swap attacker underlying tokens(in yeild()
function), and malicious contract in y
address would return a huge number.iPT
token for attacker address based on that huge number.the root cause of the bug is that code is trusting that y
is valid address but it is specified by caller and code trusts the return value of external call to arbitrary address.
This is bug exists in other lend()
functions lend(swivel)
, lend(element)
and lend(sense)
. they make external call to user specified address and use the return value to mint iPT
token. this is their codes related to this bug:
https://github.com/code-423n4/2022-06-illuminate/blob/912be2a90ded4a557f121fe565d12ec48d0c4684/lender/Lender.sol#L301
VIM
check the user specified address, or check the change of PT
balance of contract for minting iPT
token.
#0 - sourabhmarathe
2022-06-29T12:41:41Z
Duplicate of #349.
🌟 Selected for report: Metatron
Also found by: 0x52, WatchPug, auditor0517, cccz, datapunk, hansfriese, hyh, kenzo, kirk-baird, shenwilly, unforgiven
98.9071 USDC - $98.91
https://github.com/code-423n4/2022-06-illuminate/blob/912be2a90ded4a557f121fe565d12ec48d0c4684/lender/Lender.sol#L247-L305 https://github.com/code-423n4/2022-06-illuminate/blob/912be2a90ded4a557f121fe565d12ec48d0c4684/lender/Lender.sol#L317-L367
lend()
functions in Lender
are supposed to transfer underlying token from user and lend it to receive PT
tokens and then mint the same amount of iPT
token for user. but in lend(swivel)
and lend(element)
code don't mint iPT
token for user, so user funds would be lost if s/he calls those functions.
This is lend(swivel)
and lend(element)
code:
function lend( uint8 p, address u, uint256 m, uint256 a, uint256 r, uint256 d, address e, bytes32 i ) public unpaused(p) returns (uint256) { // Get the principal token for this market for element address principal = IMarketPlace(marketPlace).markets(u, m, p); // the element token must match the market pair if (IElementToken(principal).underlying() != u) { revert NotEqual('underlying'); } else if (IElementToken(principal).unlockTimestamp() > m) { revert NotEqual('maturity'); } // Transfer underlying token from user to illuminate Safe.transferFrom(IERC20(u), msg.sender, address(this), a); // Track the accumulated fees fees[u] += calculateFee(a); uint256 purchased; { // Create the variables needed to execute an element swap Element.FundManagement memory fund = Element.FundManagement({ sender: address(this), recipient: payable(address(this)), fromInternalBalance: false, toInternalBalance: false }); Element.SingleSwap memory swap = Element.SingleSwap({ userData: '0x00000000000000000000000000000000000000000000000000000000000000', poolId: i, amount: a - calculateFee(a), kind: Element.SwapKind.In, assetIn: Any(u), assetOut: Any(principal) }); // Conduct the swap on element purchased = IElement(e).swap(swap, fund, r, d); } emit Lend(p, u, m, purchased); return purchased; }
function lend( uint8 p, address u, uint256 m, uint256[] memory a, address y, Swivel.Order[] calldata o, Swivel.Components[] calldata s ) public unpaused(p) returns (uint256) { // lent represents the number of underlying tokens lent uint256 lent; { // returned represents the number of underlying tokens to lend to yield uint256 returned; uint256 totalFee; // iterate through each order a calculate the total lent and returned for (uint256 i = 0; i < o.length; ) { Swivel.Order memory order = o[i]; // Require the Swivel order provided matches the underlying and maturity market provided if (order.underlying != u) { revert NotEqual('underlying'); } else if (order.maturity > m) { revert NotEqual('maturity'); } { uint256 amount = a[i]; // Determine the fee uint256 fee = calculateFee(amount); // Track accumulated fees totalFee += fee; // Amount lent for this order uint256 amountLent = amount - fee; // Sum the total amount lent to Swivel (amount of ERC5095 tokens to mint) minus fees lent += amountLent; // Sum the total amount of premium paid from Swivel (amount of underlying to lend to yield) returned += amountLent * order.premium / order.principal; } unchecked { i++; } } // Track accumulated fee fees[u] += totalFee; // transfer underlying tokens from user to illuminate Safe.transferFrom(IERC20(u), msg.sender, address(this), lent); // fill the orders on swivel protocol ISwivel(swivelAddr).initiate(o, a, s); yield(u, y, returned, address(this)); } emit Lend(p, u, m, lent); return lent; }
As you can see there is no logic to mint iPT
tokens for msg.sender
in them similar to IERC5095(principalToken(u, m)).mint(msg.sender, returned)
which is in other lend()
functions.
so if user calls these functions he is gonna lose his underlying tokens and no iPT
token is gonna mint for user.
VIM
fix the logic and min iPT
token for user.
#0 - sourabhmarathe
2022-06-29T13:39:53Z
Duplicate of #391.
🌟 Selected for report: Picodes
Also found by: Chom, Lambda, auditor0517, cryptphi, csanuragjain, hansfriese, hyh, kenzo, kirk-baird, pashov, unforgiven, zer0dot
82.1689 USDC - $82.17
function redeem(for Illuminate, APWine and Tempus protocols)
suppose to redeems underlying token for Illuminate, APWine and Tempus protocols. when it is called with Illuminate
principle token enum id, it should burn iPT tokens and transfer underlying to user(or perform those actions for o
, address of the controller or contract that manages the underlying token) but in the code, it burns iPT tokens but it doesn't transfer underlying tokens to user. so it's not possible to withdraw illuminate iPT tokens from contract.
This is part of redeem(for Illuminate, APWine and Tempus protocols)
code which is responsible for MarketPlace.Principals.Illuminate
:
function redeem( uint8 p, address u, uint256 m, address o ) public returns (bool) { // Get the address of the principal token being redeemed address principal = IMarketPlace(marketPlace).markets(u, m, p); if (p == uint8(MarketPlace.Principals.Illuminate)) { // Get Illuminate's principal token IERC5095 token = IERC5095(principal); // Get the amount of tokens to be redeemed from the sender uint256 amount = token.balanceOf(msg.sender); // Make sure the market has matured if (block.timestamp < token.maturity()) { revert Invalid('not matured'); } // Burn the prinicipal token from Illuminate token.burn(o, amount); // Transfer the original underlying token back to the user Safe.transferFrom(IERC20(u), lender, address(this), amount); emit Redeem(0, u, m, amount); }
As you can see in the line token.burn(o, amount)
it burns that amount from o
address which is specified in function arguments. and then in line Safe.transferFrom(IERC20(u), lender, address(this), amount);
it transfers underlying from lender
to redeemer
. there is no logic to transfer underlying to msg.sender
or o
address.
So even so the iPT balance of user has been burned the user don't receive any tokens and his funds are lost. (to my understanding, underlying token should be already in Redeemer
after maturity and contract only need to send them from Redeemer
to user)
VIM
fix the line and send user underlying token to user address
#0 - sourabhmarathe
2022-06-29T14:16:08Z
Duplicate of #384.
🌟 Selected for report: hyh
Also found by: 0x1f8b, 0x29A, Chom, Soosh, cccz, csanuragjain, hansfriese, itsmeSTYJ, kenzo, pashov, shenwilly, unforgiven
function redeem(for Illuminate, APWine and Tempus protocols)
suppose to redeems underlying token for Illuminate, APWine and Tempus protocols. when it is called with Illuminate
principle token enum id, it should burn iPT tokens and transfer underlying to user(or perform those actions for o
, address of the controller or contract that manages the underlying token) but in the code, it takes iPT balance of msg.sender
and burn that amount from iPT balance of o
.
This is part of redeem(for Illuminate, APWine and Tempus protocols)
code which is responsible for MarketPlace.Principals.Illuminate
:
function redeem( uint8 p, address u, uint256 m, address o ) public returns (bool) { // Get the address of the principal token being redeemed address principal = IMarketPlace(marketPlace).markets(u, m, p); if (p == uint8(MarketPlace.Principals.Illuminate)) { // Get Illuminate's principal token IERC5095 token = IERC5095(principal); // Get the amount of tokens to be redeemed from the sender uint256 amount = token.balanceOf(msg.sender); // Make sure the market has matured if (block.timestamp < token.maturity()) { revert Invalid('not matured'); } // Burn the prinicipal token from Illuminate token.burn(o, amount); // Transfer the original underlying token back to the user Safe.transferFrom(IERC20(u), lender, address(this), amount); emit Redeem(0, u, m, amount); }
As you can see in the line uint256 amount = token.balanceOf(msg.sender)
it takes msg.sender
's iPT balances amount and in the line token.burn(o, amount)
it burns that amount from o
address which is specified in function arguments. it should burn amount from msg.sender
or it should take o
balance amount to burn from o
, based on what was intended here. but here it's a critical bug which gives one permission to burn other user's iPT tokens.
VIM
either take o
balance amount and burn that amount from o
balance or burn msg.sender
iPT balance amount from msg.sender
balance.
#0 - sourabhmarathe
2022-06-28T20:40:50Z
Duplicate of #387.
287.1428 USDC - $287.14
function redeem(for Illuminate, APWine and Tempus protocols)
suppose to redeems underlying token for Illuminate, APWine and Tempus protocols. when it is called with APWine and Tempus
principle token enum id, it should transfer the related PT
token from Lender
to Redeemer
then call those protocols to withdraw underlying. the bug is in the code, it gets PT
token balance amount of Lender
and then it tries to transfer underlying
token from Lender
to Redeemer
. so the logic is broken and redeem()
will not work for APWine
and Tempus
and user fund in those protocols would be locked.
This is part of redeem(or Illuminate, APWine and Tempus protocols)
code that is responsible for APWine and Tempus protocols
:
else { // Get the amount of tokens to be redeemed from the principal token uint256 amount = IERC20(principal).balanceOf(lender); // Transfer the principal token from the lender contract to here Safe.transferFrom(IERC20(u), lender, address(this), amount); if (p == uint8(MarketPlace.Principals.Apwine)) { // Redeem the underlying token from APWine to Illuminate IAPWine(apwineAddr).withdraw(o, amount); } else if (p == uint8(MarketPlace.Principals.Tempus)) { // Redeem the tokens from the Tempus contract to Illuminate ITempus(tempusAddr).redeemToBacking(o, amount, 0, address(this)); } else { revert Invalid('principal'); } emit Redeem(0, u, m, amount); }
as you can see in the line uint256 amount = IERC20(principal).balanceOf(lender);
it gets the balance of lender
in PT
token then in the line Safe.transferFrom(IERC20(u), lender, address(this), amount);
it transfers that amount of underlying
token from lender
to redeemer
instead of PT
token, so it is going to revert and logic is wrong and users could never redeem PT
token of APWine and Tempus
protocols and protocol funds and users fund would be lock forever.
VIM
fix the transfer and change it to PT
token.
#0 - KenzoAgada
2022-06-28T14:03:10Z
Duplicate of #268
🌟 Selected for report: kirk-baird
Also found by: 0xDjango, GalloDaSballo, Kumpa, kenzo, pashov, shenwilly, tintin, unforgiven
This is no limit or check in approve()
functions in Lender
and admin
has ability to withdraw all the contract funds immediately. if admin
private key leaks or admin
make some mistake users fund would be lost.
This is approve()
functions in Lender
:
/// @notice approves the redeemer contract to spend the principal tokens held by the lender contract. /// @param u underlying token's address, used to define the market being approved /// @param m maturity of the underlying token, used to define the market being approved /// @param r the address being approved, in this case the redeemer contract /// @return bool true if the approval was successful, false otherwise function approve( address u, uint256 m, address r ) external authorized(admin) returns (bool) { // max is the maximum integer value for a 256 unsighed integer uint256 max = 2**256 - 1; // approve the underlying for max per given principal for (uint8 i; i < 9; ) { // get the principal token's address address token = IMarketPlace(marketPlace).markets(u, m, i); // check that the token is defined for this particular market if (token != address(0)) { // max approve the token Safe.approve(IERC20(token), r, max); } unchecked { i++; } } return true; } /// @notice bulk approves the usage of addresses at the given ERC20 addresses. /// @dev the lengths of the inputs must match because the arrays are paired by index /// @param u array of ERC20 token addresses that will be approved on /// @param a array of addresses that will be approved /// @return true if successful function approve(address[] calldata u, address[] calldata a) external authorized(admin) returns (bool) { uint256 len = u.length; if (len != a.length) { revert NotEqual('array length'); } uint256 max = 2**256 - 1; for (uint256 i; i < len; ) { IERC20 uToken = IERC20(u[i]); if (address(0) != (address(uToken))) { Safe.approve(uToken, a[i], max); } unchecked { i++; } } return true; }
As you can see admin can give any address permission to spend all of this contract tokens without any time delay or limit or any whitelist check. this is dangerous for users and protocol because they can lose all their funds immediately in just one transaction. if admin make some mistake or the private key leaks then funds would be lost.
VIM
add some propose+delay mechanism for adding new approvals. removing all approvals can be done immediately
#0 - sourabhmarathe
2022-06-29T16:38:26Z
Duplicate of #44.
🌟 Selected for report: kirk-baird
Also found by: cccz, kenzo, unforgiven
Function mint()
in Lender
is supposed mint swaps the sender's principal tokens for illuminate's ERC5095 tokens. but it's possible to provide iPT
token and mint new iPT
tokens. those iPT
tokens would stuck in Lender
address and can cause serious problems because they are not backed by PT
or underlying
tokens. one problems is that it would be possible to call redeem(Illuminate)
in Redeemer
for Lender
address to redeem Lender
iPT
balance, and it would cause underlying token to be transferred to Lender
address and other users can't Redeem
their iPT
tokens.
This is mint()
code in Lender
:
/// @notice mint swaps the sender's principal tokens for illuminate's ERC5095 tokens in effect, this opens a new fixed rate position for the sender on illuminate /// @param p value of a specific principal according to the MarketPlace Principals Enum /// @param u address of an underlying asset /// @param m maturity (timestamp) of the market /// @param a amount being minted /// @return bool true if the mint was successful, false otherwise function mint( uint8 p, address u, uint256 m, uint256 a ) public returns (bool) { //use market interface to fetch the market for the given market pair address principal = IMarketPlace(marketPlace).markets(u, m, p); //use safe transfer lib and ERC interface... Safe.transferFrom(IERC20(principal), msg.sender, address(this), a); //use ERC5095 interface... IERC5095(principalToken(u, m)).mint(msg.sender, a); emit Mint(p, u, m, a); return true; }
As you can see there is no check that p != 0
so it's possible to wrap iPT
token and mint new iPT
tokens. those iPT
tokens would be transferred to Lender
address and by this process attacker could create unbacked iPT
tokens. because total amount of iPT
tokens would be larger than total amount of PT
tokens. to further exploit this attacker can call redeem(illuminate)
in Redeemer
for Lender
address. the redeem()
would burn iPT
balance of Lender
and transfer underlying
token to Lender
address. so underlying
token would be used to burn unbacked iPT
token and those underlying
token would stuck in Lender
address and other users couldn't withdraw redeem
their iPT
tokens because of lack of underlying
token.
There may be other scenarios that this wraping iPT
with iPT
could cause serious problems too.
P.S: of course because some unlimited functionality and access in Lender
for admin
it's possible to recover the lost fund but contract shouldn't relay on admin
action to work correct and the real serious bug is that the 1:1 relation between iPT
and PT
would be broken and totalSupply()
of iPT
token wouldn't be equal to total amount of PT
tokens.
VIM
make sure it's not possible to mint iPT
tokens by wrapping iPT
tokens.
#0 - sourabhmarathe
2022-06-29T14:26:29Z
Duplicate of #273.
#1 - gzeoneth
2022-07-16T18:07:23Z
Duplicate of #42
🌟 Selected for report: hyh
Also found by: datapunk, unforgiven
functions buyPrincipalToken(), sellPrincipalToken(), sellUnderlying() and buyUnderlying()
swaps user tokens and don't specify any slippage so it's possible to perform frontrun / sandwich attack and cause fund lose for users and it's possible to perform it through MEV
This is buyPrincipalToken(), sellPrincipalToken(), sellUnderlying() and buyUnderlying()
functions code:
/// @notice sells the PT for the PT via the pool /// @param u address of the underlying asset /// @param m maturity (timestamp) of the market /// @param a amount of PT to swap /// @return uint128 amount of PT bought function sellPrincipalToken( address u, uint256 m, uint128 a ) external returns (uint128) { IPool pool = IPool(pools[u][m]); Safe.transfer(IERC20(address(pool.fyToken())), address(pool), a); return pool.sellFYToken(msg.sender, pool.sellFYTokenPreview(a)); } /// @notice buys the underlying for the PT via the pool /// @param u address of the underlying asset /// @param m maturity (timestamp) of the market /// @param a amount of underlying tokens to sell /// @return uint128 amount of PT received function buyPrincipalToken( address u, uint256 m, uint128 a ) external returns (uint128) { IPool pool = IPool(pools[u][m]); Safe.transfer(IERC20(address(pool.base())), address(pool), a); return pool.buyFYToken(msg.sender, pool.buyFYTokenPreview(a), a); } /// @notice sells the underlying for the PT via the pool /// @param u address of the underlying asset /// @param m maturity (timestamp) of the market /// @param a amount of underlying to swap /// @return uint128 amount of underlying sold function sellUnderlying( address u, uint256 m, uint128 a ) external returns (uint128) { IPool pool = IPool(pools[u][m]); Safe.transfer(IERC20(address(pool.base())), address(pool), a); return pool.sellBase(msg.sender, pool.sellBasePreview(a)); } /// @notice buys the underlying for the PT via the pool /// @param u address of the underlying asset /// @param m maturity (timestamp) of the market /// @param a amount of PT to swap /// @return uint128 amount of underlying bought function buyUnderlying( address u, uint256 m, uint128 a ) external returns (uint128) { IPool pool = IPool(pools[u][m]); Safe.transfer(IERC20(address(pool.fyToken())), address(pool), a); return pool.buyBase(msg.sender, pool.buyBasePreview(a), a); }
As you can see there is no slippage passed when calling pool
and user can't specify slippage.
VIM
check for slippage when swapping like other mintWithUnderlying()
and burnForUnderlying()
functions.
#0 - sourabhmarathe
2022-06-28T21:08:09Z
Duplicate of #389.