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: 1/88
Findings: 20
Award: $7,670.49
🌟 Selected for report: 5
🚀 Solo Findings: 1
🌟 Selected for report: kirk-baird
Also found by: 0x52, WatchPug, cccz, csanuragjain, kenzo
https://github.com/code-423n4/2022-06-illuminate/blob/main/lender/Lender.sol#L411
To buy Pendle principal tokens, Illuminate executes a swap on a Sushiswap pool.
The lend
methods gets the number of tokens received using swapExactTokensForTokens
's return value, but it accesses the amount of tokens sent, not received.
User will be minted wrong amount of tokens; will be minted the amountIn
instead of the amountOut
.
This is how the Pendle lend
method exchanges underlying for Pendle principal, and mints accordingingly iPTs to the user:
returned = IPendle(pendleAddr).swapExactTokensForTokens(a - fee, r, path, address(this), d)[0]; address illuminateToken = principalToken(u, m); IERC5095(illuminateToken).mint(msg.sender, returned);
This is a call to a Sushiswap pool (discussed with dev).
The problem is that swapExactTokensForTokens
will return an array where the last element is the amountOut
, but the lend
method is accessing the first element, and minting that amount to the user.
Therefore, the contract accounting will break and user will get wrong amount of tokens.
We can see in the UniswapV2Router that the last element is the amountOut
:
require(amounts[amounts.length - 1] >= amountOutMin, 'UniswapV2Router: INSUFFICIENT_OUTPUT_AMOUNT');
Access the second element of the array, not the first.
#0 - KenzoAgada
2022-06-28T10:45:41Z
Duplicate of #94
🌟 Selected for report: Lambda
Also found by: 0x29A, Chom, cryptphi, itsmeSTYJ, kenzo, kirk-baird, sashik_eth
https://github.com/code-423n4/2022-06-illuminate/blob/main/marketplace/ERC5095.sol#L116
The redeem
function is checking wrongly whether msg.sender
is allowed to spend holder's
tokens.
Anybody can redeem anybody's token to himself. Therefore user funds will be lost.
The redeem
function is taking principalAmount
and parameter and returning underlyingAmount
.
It checks whether the sender is allowed to spend holder's tokens like so:
require(_allowance[holder][msg.sender] >= underlyingAmount, 'not enough approvals');
And then proceeds to send principalAmount
to the Redeemer:
return IRedeemer(redeemer).authRedeem(underlying, maturity, holder, receiver, principalAmount);
Since underlyingAmount
is 0, the sender can redeem anybody's tokens and set himself as the receiver of underlying.
Check whether the sender's allowance is >= principalAmount
, not underlyingAmount
.
#0 - KenzoAgada
2022-06-28T06:18:44Z
Duplicate of #173
https://github.com/code-423n4/2022-06-illuminate/blob/main/redeemer/Redeemer.sol#L187
When Redeemer redeems Element, it sends the underlying tokens to Marketplace.
All Element underlying will be lost.
The method is sending marketplace
as parameter to Element:
IElementToken(principal).withdrawPrincipal(amount, marketPlace);
This second parameter is the destination where the funds would be sent to.
Therefore, all Element underlying will be sent to the Marketplace, which has no function to retrieve them.
Send the underlying to the Redeemer instead of the Marketplace.
#0 - KenzoAgada
2022-06-28T08:47:10Z
Duplicate of #182
🌟 Selected for report: kenzo
Also found by: Metatron, WatchPug, cccz, unforgiven
496.2962 USDC - $496.30
https://github.com/code-423n4/2022-06-illuminate/blob/main/lender/Lender.sol#L465:#L466
The Tempus lend
method calculates the amount of tokens to mint as amountReturnedFromTempus - lenderBalanceOfMetaPrincipalToken
.
This seems wrong as there's no connection between the two items. Tempus has no relation to the iPT token.
Wrong amount of iPT will be minted to the user. If the Lender contract has iPT balance, the function will revert, otherwise, user will get minted 0 iPT tokes.
This is how the lend
method calculates the amount of iPT tokens to mint:
uint256 returned = ITempus(tempusAddr).depositAndFix(Any(x), Any(t), a - fee, true, r, d) - illuminateToken.balanceOf(address(this)); illuminateToken.mint(msg.sender, returned);
The Tempus depositAndFix
method does not return anything.
Therefore this calculation will revert if illuminateToken.balanceOf(address(this)) > 0
, or will return 0 if the balance is 0.
[Note: there's another issue here where the depositAndFix sends wrong parameters - I will submit it in another issue.]
I believe that what you intended to do is to check how many Tempus principal tokens the contract received.
So you need to check Lender's x.tempusPool().principalShare()
before and after the swap, and the delta is the amount received.
🌟 Selected for report: kenzo
3782.1688 USDC - $3,782.17
https://github.com/code-423n4/2022-06-illuminate/blob/main/redeemer/Redeemer.sol#L262
Sense's redeem
can be totally DOSd due to user supplied input.
Using this attack, Sense market can not be redeemed.
This is how Sense market is being redeemed:
IERC20 token = IERC20(IMarketPlace(marketPlace).markets(u, m, p)); uint256 amount = token.balanceOf(lender); Safe.transferFrom(token, lender, address(this), amount); ISense(d).redeem(o, m, amount);
The problem is that d
is user supplied input and the function only tries to redeem the amount that was transferred from Lender.
A user can supply malicious d
contract which does nothing on redeem(o, m, amount)
.
The user will then call Redeemer's redeem
with his malicious contract.
Redeemer will transfer all the prinicipal from Lender to itself, will call d
(noop), and finish.
Sense market has not been redeemed.
Now if somebody tries to call Sense market's redeem
again, the amount
variable will be 0, and Redeemer will try to redeem 0 from Sense.
All the original principal is locked and lost in the contract, like tears in rain.
I think you should either use a whitelisted Sense address, or send to ISense(d).redeem
Redeemer's whole principal balance.
🌟 Selected for report: kenzo
Also found by: 0x29A, GimelSec, Lambda, StErMi, cccz, csanuragjain, kirk-baird, sashik_eth, shenwilly
146.529 USDC - $146.53
https://github.com/code-423n4/2022-06-illuminate/blob/main/marketplace/ERC5095.sol#L100
ERC5095's redeem
/withdraw
allows an ERC20-approved account to redeem user's tokens, but does not update the allowance after burning.
User Mal can burn more tokens than Alice allowed him to. He can set himself to be the receiver of the underlying, therefore Alice will lose funds.
withdraw
and redeem
functions check that the msg.sender has enough approvals to redeem the tokens:
require(_allowance[holder][msg.sender] >= underlyingAmount, 'not enough approvals');
But they do not update the allowances.
They then call authRedeem
, which also does not update the allowances.
Therefore, an approved user could "re-use his approval" again and again and redeem whole of approver's funds to himself.
Update the allowances upon spending.
#0 - KenzoAgada
2022-06-28T06:27:53Z
(Although this is my issue I am referring dups to it as I find it has the best descriptive title and brevity for final report and right mitigation unlike 174. Obviously judge is free to change it later.)
#1 - sourabhmarathe
2022-06-28T19:53:09Z
While we did not actually intend to audit the 5095 itself, as 5095 itself is not yet final, we did describe its purpose in our codebase in the initial readme, and didn't specify that it was not in scope.
With that context, we will leave it up to the judges whether or not to accept issues related to the ERC5095 token.
🌟 Selected for report: kenzo
Also found by: IllIllI, bardamu, csanuragjain
689.3003 USDC - $689.30
https://github.com/code-423n4/2022-06-illuminate/blob/main/lender/Lender.sol#L172
Lender's mint
function does not check whether the supplied market is paused.
Even if a market is paused due to insolvency/bugs, an attacker can issue iPTs. This renders the whole pause and insolvency protection mechanism ineffective. See POC.
Let's say market P has become insolvent, and Illuminate pauses that market, as it doesn't want to create further bad debt. Let's say P's principal tokens's value has declined severely in the market because of the insolvency. An attacker can buy many worthless P principal tokens for cheap, then call Lender and mint from them iPT. The attacker is now owed underlying which belongs to the legitimate users. There won't be enough funds to repay everybody.
Check in mint
that the market is not paused.
29.8781 USDC - $29.88
https://github.com/code-423n4/2022-06-illuminate/blob/main/lender/Lender.sol#L219 https://github.com/code-423n4/2022-06-illuminate/blob/main/lender/Lender.sol#L229 https://github.com/code-423n4/2022-06-illuminate/blob/main/lender/Lender.sol#L362 https://github.com/code-423n4/2022-06-illuminate/blob/main/lender/Lender.sol#L524 https://github.com/code-423n4/2022-06-illuminate/blob/main/lender/Lender.sol#L582
Some of the lend
methods accept the protocol pool to swap in as a user parameter (eg. Yield, Sense).
The lend
methods then calculate the amount of iPT tokens to mint based on a function call to these user supplied contracts.
User can supply his own malicious contract to these methods. This contract will tell the methods to mint 1 trillion billion
iPT tokens. The user can sell those on the market or later redeem them. Therefore user funds are at risk.
Let's look at APWine's lend
method.
It is taking as parameter from the user pool
, the address of the APWine pool.
The only place this pool is used in the function is to execute a swap, and mint to the user the amount of tokens the swap returned:
// Swap on the APWine Pool using the provided market and params uint256 returned = IAPWineRouter(pool).swapExactAmountIn(i, 1, lent, 0, r, address(this)); // Mint Illuminate zero coupons IERC5095(principalToken(u, m)).mint(msg.sender, returned);
So the user can supply his own contract as the pool
, which will return 1 trillion billion
tokens when somebody calls swapExactAmountIn
.
This will be the returned
value, so the lend
method will mint 1 trillion billion
tokens to the user.
This issue (user supplied malicious external contract) is present for principals Yield (malicious y
), Illuminate (malicious y
), Element (malicious e
), Sense (malicious x
), APWine (malicious pool
).
I think Illuminate should used whitelisted addresses for these pools. Either query the external protocol for the relevant pool, or query with it whether a supplied pool is indeed registered, or create a registry in Illuminate for which pools should be used per markets[u][m][p]
.
#0 - sourabhmarathe
2022-06-29T12:44:01Z
Duplicate of #349.
🌟 Selected for report: Metatron
Also found by: 0x52, WatchPug, auditor0517, cccz, datapunk, hansfriese, hyh, kenzo, kirk-baird, shenwilly, unforgiven
https://github.com/code-423n4/2022-06-illuminate/blob/main/lender/Lender.sol#L300 https://github.com/code-423n4/2022-06-illuminate/blob/main/lender/Lender.sol#L364
The Swivel and Element lend
methods pull funds from the user, get the Swivel/Element principal tokens, but do not mint iPT to the user.
User will not receive his iPTs and lose his funds.
I've linked above to the Swivel and Element functions.
We can see that the functions conduct the swaps but do not mint iPTs to the user, unlike other lend
methods.
Mint iPT to the user.
#0 - sourabhmarathe
2022-06-29T13:40:02Z
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
https://github.com/code-423n4/2022-06-illuminate/blob/main/redeemer/Redeemer.sol#L128
When redeeming iPTs via redeem
, Redeemer doesn't send to the user back his funds.
Main redeem
function will revert.
While the user can still redeem via ERC5095's redeem
, this is core functionality to the project so I believe high severity is justified.
After burning user's iPTs, redeem
is doing the following transfer:
Safe.transferFrom(IERC20(u), lender, address(this), amount);
This is wrong. The lender does not hold any underlying (other than the fees). Rather, at this point the market's PTs should have already been redeemed for the underlying which should now belong to Redeemer. Plus, the transfer is transferring these funds to Redeemer, not to the user.
Function should transfer underlying from the Redeemer to the msg.sender.
#0 - sourabhmarathe
2022-06-29T14:14:53Z
Duplicate of #384.
🌟 Selected for report: hyh
Also found by: 0x1f8b, 0x29A, Chom, Soosh, cccz, csanuragjain, hansfriese, itsmeSTYJ, kenzo, pashov, shenwilly, unforgiven
https://github.com/code-423n4/2022-06-illuminate/blob/main/redeemer/Redeemer.sol#L126
Redeemer's Illuminate redeem
function takes as user supplied parameter the address to burn the tokens of.
Redeemer's Illuminate redeem
method takes the account to burn from as user supplied input:
token.burn(o, amount);
It does check that msg.sender's balance is bigger than the amount requested to burn, but it then burns these tokens from o
.
Therefore the attacker can supply any address as o
.
If he supplies a regular user's address, the user's assets would be redeemed (+ generating a tax event).
If he supplies a Uniswap pool address (eg. iPT-yieldPrincipalToken pair), user's assets (liquidity provided) in the pool will be lost.
Additionally, the pool's price would be manipulated (as the balances changed), allowing the attacker for example to buy the yieldPrincipalToken from the pool at a miniscule price and dump it at another pool.
Only allow to burn the msg.sender's tokens, or whoever o
ERC20-approved his tokens for.
#0 - sourabhmarathe
2022-06-28T20:39:21Z
Duplicate of #387.
287.1428 USDC - $287.14
https://github.com/code-423n4/2022-06-illuminate/blob/main/redeemer/Redeemer.sol#L134:#L136
When globally-redeeming Tempus and APWine, redeem
is trying to pull from the Lender the underlying token instead of the principal token.
Function will fail, markets can not be redeemed.
The functions is checking Lender's balance of principal, but then proceeds to try to pull underlying:
// 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);
Therefore the function will revert due to insufficient funds.
Pull from Lender the principal instead of the underlying.
#0 - KenzoAgada
2022-06-28T14:03:05Z
Duplicate of #268
https://github.com/code-423n4/2022-06-illuminate/blob/main/redeemer/Redeemer.sol#L193
When trying to redeem Notional market, the redeemer doesn't call the correct function.
Notional market can't be redeemed.
When redeeming Notional, Redeemer calls Notional's maxRedeem
:
amount = INotional(principal).maxRedeem(address(this));
That is only a view function which does not redeem the market:
function maxRedeem(address owner) public view override returns (uint256) {
Therefore the market can't be redeemed for the underlying.
Call Notional's redeem
instead.
#0 - KenzoAgada
2022-06-28T08:28:28Z
Duplicate of #341
🌟 Selected for report: 0x52
Also found by: datapunk, kenzo, kirk-baird, pashov
148.8889 USDC - $148.89
Judge has assessed an item in Issue #333 as Medium risk. The relevant finding follows:
#0 - gzeoneth
2022-07-29T17:08:48Z
Duplicate of #21
🌟 Selected for report: kirk-baird
Also found by: 0xDjango, GalloDaSballo, Kumpa, kenzo, pashov, shenwilly, tintin, unforgiven
https://github.com/code-423n4/2022-06-illuminate/blob/main/lender/Lender.sol#L78 https://github.com/code-423n4/2022-06-illuminate/blob/main/lender/Lender.sol#L93
Lender contract's approve
method allows the owner to approve any address to transfer the principal tokens held by the Lender.
This is a rug vector - compromised admin can approve itself and steal all the tokens.
Lender has a scheduleWithdrawal
mechanism to allow the owner to transfer out tokens only after certain timelock.
The issue I'm describing allows the owner to transfer the principal tokens (which belong to the users) immediately. Thereby rendering the timelock ineffective.
The approve
method gives max allowance for the admin supplied address (r
) for all the principal tokens.
It is supposed to give max allowance to the Redeemer (as the comment says), but the admin can supply any address there.
Therefore the admin can rug and this renders the withdrawal timelock ineffective.
Instead of allowing any arbitrary address as input, read the Redeemer address from the saved marketplace
and approve only that address.
#0 - sourabhmarathe
2022-06-29T16:42:25Z
Duplicate of #44.
🌟 Selected for report: kenzo
Also found by: 0x52, 0xkowloon, GalloDaSballo, Metatron, WatchPug, cccz, hansfriese, kirk-baird
https://github.com/code-423n4/2022-06-illuminate/blob/main/lender/Lender.sol#L297
The Swivel lend
method adds to fees[u]
the order fee, but does not pull that fee from the user. It only pulls the order-post-fee amount.
withdrawFee
will fail, as it tries to transfer more tokens than are in the contract.
The Swivel lend
method sums up the fees to totalFee
, and the amount to send to Swivel in lent
:
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;
It then increments fees[u]
by totalFee
, but only pulls from the user lent
:
fees[u] += totalFee; // transfer underlying tokens from user to illuminate Safe.transferFrom(IERC20(u), msg.sender, address(this), lent);
Therefore, totalFee
has not been pulled from the user.
The fees
variable now includes tokens which are not in the contract, and withdrawFee
will fail as it tries to transfer fees[u]
.
Pull lent + totalFee
from the user.
#0 - sourabhmarathe
2022-06-30T18:47:14Z
There were several issues that marked this ticket as a high-severity issue. Because the current code does not put user funds at risk (that otherwise would not be spent), we believe this issue is a Med Risk
severity issue.
🌟 Selected for report: Kumpa
Also found by: Metatron, cccz, cryptphi, hansfriese, jah, kenzo, kirk-baird, pashov, poirots
https://github.com/code-423n4/2022-06-illuminate/blob/main/lender/Lender.sol#L219 https://github.com/code-423n4/2022-06-illuminate/blob/main/lender/Lender.sol#L229
The lend
method for Yield and Illuminate deducts the protocol fee from the user, but does not increment the fees
variable.
Wrong accounting, admin won't be able to properly pull the fee using withdrawFee
.
(Admin can still withdraw using scheduleWithdrawal
, but obviously the fee accounting is implemented wrongly in this function.)
withdrawFee
sends to the admin only whatever amount is saved in fees[u]
.
The Illuminate\Yield lend
method deducts the fee ((1), (2)) when sending tokens to Yield, but does not increment the fees
variable.
Therefore the fees are not accounted for.
Like is being done in the rest of the functions, this function should set fees[u] += calculateFee(a)
.
#0 - KenzoAgada
2022-06-28T06:44:00Z
Duplicate of #208
🌟 Selected for report: kirk-baird
Also found by: cccz, kenzo, unforgiven
206.7901 USDC - $206.79
https://github.com/code-423n4/2022-06-illuminate/blob/main/lender/Lender.sol#L174
Lender's mint
function allows a user to re-wrap/re-mint an already wrapped iPT.
Extra iPTs will be minted which have no corresponding underlying. The original iPT will be pulled to Lender.sol (and can be pulled by the admin). This creates "bad debt"/liabilities for iPT - the supply does not match the underlying.
This is the mint
method:
function mint(uint8 p, address u, uint256 m, uint256 a) public returns (bool) { address principal = IMarketPlace(marketPlace).markets(u, m, p); Safe.transferFrom(IERC20(principal), msg.sender, address(this), a); IERC5095(principalToken(u, m)).mint(msg.sender, a); emit Mint(p, u, m, a); return true; }
So if a user sends principal 0 (iPT), Lender will pull the user's iPT, and will mint him brand new iPT. Now iPT's supply does not match the liabilities any more.
Do not accept principal 0 in the mint function.
#0 - JTraversa
2022-07-06T17:15:00Z
I'd actually dispute this under the claim that while minting iPTs by wrapping iPTs may increase the total supply
, it does not increase the circulating supply
which is the metric that could otherwise lead to risk for iPT holders / the protocol.
For example, if 1000 iPTs are circulating (with 1000 underlying in backing), and someone mints 1000 iPTs with that 1000, yes, 2000 iPTs are technically in the totalSupply, but lender.sol
would be the owner of 1000, leaving the circulating supply static.
This means that should any circulating iPT holder then want to redeem iPTs, they will always have a 1-1 amount of underlying available.
#1 - gzeoneth
2022-07-16T18:07:21Z
Duplicate of #42
https://github.com/code-423n4/2022-06-illuminate/blob/main/lender/Lender.sol#L255
A user can use Swivel's lend
method to issue iPTs from Swivel even if the Swivel market is paused.
Pause functionality for Swivel is ineffective and can cause for loss of funds for users in case of Swivel insolvency/bugs.
Swivel's lend
function has unpaused(p)
modifier, but does not use p
anywhere else, and does not check whether the p
supplied is Swivel.
Therefore, a user could pass a different p
to the function and mint iPTs through Swivel even if Swivel is set to be paused.
Then when the market is matured, even if Swivel can not be fully redeemed, the user is still due his allotment of underlying from the unified iPT. So the user can redeem his iPT before other users, thereby taking their tokens. iPT's balance sheet would be off.
Add a check that reverts if p
is not Swivel.
#0 - KenzoAgada
2022-06-28T06:33:10Z
Duplicate of #343
🌟 Selected for report: defsec
Also found by: 0x1f8b, 0x29A, 0xDjango, 0xNazgul, 0xNineDec, 0xf15ers, 0xkowloon, 0xmint, Bnke0x0, BowTiedWardens, Chom, ElKu, Funen, GalloDaSballo, GimelSec, IllIllI, JC, Kenshin, Kulk0, Lambda, Limbooo, MadWookie, Metatron, Picodes, Soosh, StErMi, TomJ, WatchPug, Waze, Yiko, _Adam, ak1, asutorufos, aysha, bardamu, catchup, datapunk, delfin454000, dipp, fatherOfBlocks, grGred, hake, hansfriese, hyh, joestakey, kebabsec, kenzo, kirk-baird, oyc_109, pashov, poirots, rfa, robee, saian, sashik_eth, shenwilly, simon135, slywaters, z3s, zeesaw, zer0dot
64.2748 USDC - $64.27
Description: Redeemer's redeem
method for Tempus/APWine emits wrong prinicipal (always emits 0) on the log.
Impact: Wrong log emitted, perhaps front-ends confused.
Code reference: here
Mitigation: emit p
instead of 0
.
Description: The Marketplace looks like a convienance router contract for the lenders to easily interact with Illuminate on chain. However, in methods sellPrincipalToken
, buyPrincipalToken
, sellUnderlying
, buyUnderlying
, it assumes the user has already transferred the tokens to the Marketplace.
This is a bit unusual as usually router-type contracts pull the funds from the user using transferFrom.
This is a valid design, but I'm just bringing it to your attention.
Code reference: here
Impact/Mitigation: Make sure this is documented thoroughly and users are aware they should send their funds atomically to prevent an MEVr from claiming their tokens to themselves.
Also, consider changing the implementation to pull the funds from the user.
Description: The various contracts can change an admin instantly, without the new account having to claim it's ownership. Impact: If there was an (admittedly unlikely) error in the setting of the new admin, this error is irreversible and parts of the protocol would be completely bricked. Code ref: Redeemer, Marketplace, Lender Mitigation: Consider changing the functions to a two-step process, where a new admin address is proposed, and then the new address has to accept the ownership transfer.
Description: The admin can change instantly the fee. There is no cap on the fee amount. Impact: Admin may set the fee to be 100% and steal user's funds. Code ref: here Mitigation: Consider capping the fee to a reasonable amount, eg. 5%.