A-MAZE-X CTF Walkthrough | Part IV
This post belongs to a series of articles dedicated to solving the DeFi challenges hosted by Secureum at Stanford University on the past 26 of August.
Additionally, this series is part of a greater series called A journey into smart contract security.
Welcome to the last one! Congrats on getting up to this point. Shall we?
Table of contents
· Table of contents
· Challenge3.borrow_system: borrow, hide and seek
∘ Challenge description
· The code
∘ First part
∘ Second part
∘ Third part
∘ Fourth part
∘ Fifth part
· Vulnerabilities & Exploit
∘ Hands-on!
∘ Some comments
· Solution
∘ Exploit contract
∘ Deployment
Challenge3.borrow_system: borrow, hide and seek
Reminder to pause, and really think every time you see this emoji near a paragraph: 💭
— By this time you already know the drill 😉
— Yes yes, I know what to do. But there's nothing much to say about today's topic. It explicitly talks about a system where you possibly borrow ether or tokens and all the functionality associated with it, like deposits and widthrawals.
— It's a good start! Let's see the challenge description.
Challenge description
Finally, as a conclusion to this not-so-secure ecosystem, the Secureum team built the
BorrowSystemInsecureOracle
lending platform where one can borrow and loan $ISEC andBoringToken
($BOR). Both tokens can be borrowed by either providing themselves or the other token as collateral.📌 Upon deployment, the
InSecureumToken
andBoringToken
contracts mint an initial supply of 30000 $ISEC and 20000 $BOR to the contract deployer.📌
BorrowSystemInsecureOracle
uses theInsecureDexLP
to compute the $ISEC/$BOR price.📌 The deployer adds an initial liquidity of 100 $ISEC and 100 $BOR to the
InsecureDexLP
.📌 Similarly,
InSecureumLenderPool
contract is funded with 10000 $ISEC by the deployer.📌 The
BorrowSystemInsecureOracle
contract has an initial amount of 10000 $ISEC and 10000 $BOR provided by the deployer.📌 Users can add collateral and take loans from
BorrowSystemInsecureOracle
.📌 Users may also get liquidated.
Will you be able to drain all the $ISEC from
BorrowSystemInsecureOracle
?
— Some of the attack vectors that I imagine are a sum of what we have talked about in the previous posts. The name BorrowSystemInsecureOracle
makes me think of price manipulation by external feed for example.
— Possibly! You can also say some of the exploits we already created may be viable in this setup.
— Yeah, most of the contracts we've seen are being used in this challenge. Damn.
— This exercise also comes with a comment on how to use the provided contracts. Pay attention to it.
The contracts that you will hack are:
> BorrowSystemInsecureOracleWhich have interactions with the following contracts:
> InSecureumLenderPool (this contract should be used by the attacker as part of the attack)
> InsecureDexLP
> InSecureumToken
> BoringToken
The code
As always, we are going to split the code into sections to be able to analyze it in chunks.
First part
Imports and interfaces.
Nothing new, and we can see as the description anticipated, that it's going to use the functionality of calcsAmountOut
from InsecureDexLP. This function calculates the exchange ratio between two tokens.
Second part
Let's see variables, types, and the constructor.
Two tokens will be used, InSecureum (token0), and Boring (token1).
They don't seem vulnerable since they are plain ERC20s using OZ's library. Nevertheless, never assume something is secure only because someone with deeper knowledge said so. Audits tend to be time-limited and mistakes can happen.
The Oracle will be InsecureDexLP, something we knew from the description. It will use calcsAmountOut
to check the exchange ratio between these tokens.
The number of deposited and borrowed units for each token will be stored under tokenXDeposited
and tokenXBorrowed
.
Third part
Deposit and borrow functionality.
— Do they seem secure enough to you? 💭
At first glance, there doesn’t seem to be a defined criterion like check-effects-interaction. I will assume it is safe for now despite how the logic is being displayed. Neither of the tokens will make this vulnerable to reentrancy.
Here we can see a new function appearing isSolvent
. This introduces a new concept: Solvency.
"Solvency portrays the ability of a business (or individual) to pay off its financial obligations". In this case, it is referring to the ability of an account to return what has been borrowed.
Further reading: Solvency by Investopedia
And with it, comes another term: Collateral.
Collateral is a very basic concept in the financial industry, it just means something you put up as a guarantee when borrowing money. If you can’t pay back, your collateral will be used to pay your debt. — from OZ's forum.
Fourth part
The logic for checking solvency over a user.
We read a comment with the acronym LTV. If you look up that term, you'll find it means loan-to-value ratio.
What is a Loan-to-Value (LTV) Ratio?
LTV is a commonly used ratio that lenders use when evaluating the risks associated with a loan opportunity. LTV is the ratio between the loan amount and the value of the underlying asset.
What is a Loan-to-Value ratio used for? Generally, LTV is used by lenders to evaluate how much collateral coverage their loan will have. Ultimately, risk is evaluated based on the likelihood that the proceeds from the sale of the collateral will be adequate to cover the outstanding principal loan balance if a lender has to sell the collateral to recoup the investment.
Further reading: LTV by YieldStreet.
It first estimates the user’s current collateral by calculating how many InSecureumTokens would have if it were to swap everything to that asset.
maxBorrow
sets the cap for the amount to borrow of one token directly influenced by the deposited amount of the other.
If you deposited 1000 InSecureumTokens for example, you can borrow up to ~1111 BoringTokens with the initial current token price.
The solvency is heavily based on InSecureumTokens. It uses tokenPrice
to calculate how many InSecureumToken equals a single BoringToken.
In short, the code tries to see if the current maximum allowed amount to borrow is higher than what the user has already borrowed. If that happens, that user becomes insolvent and has to be liquidated.
—Uhm, I have a question.
— Shoot.
—Why is it calcsAmountOut
used like that?
— What do you mean?
— Wouldn't it be better to call calcsAmountOut
directly, rather than tokenPrice
? Like calcsAmountOut(address(token1), token1Deposited[msg.sender]).
—Great catch. At this point, I think it would’ve been the same but the concept of “price oracle” would have gone amiss.
What do you think? 💭
On a side note, if we were to borrow more than we deposited, we would need to make the other tokens "less boring" (increase the value of their price), since it is the only token referenced by an external feed.
Fifth part
Liquidation means that your collateral price drops closer / is unable to support your debt value (what you borrowed), and the protocol allows others to repay your debt in exchange for your collateral 😱.
Sometimes it is even incentivized.
Further reading: Lending and Borrowing
As you can see, by "returning" (transferring back) what a user has borrowed you can claim what that same user deposited as collateral.
Vulnerabilities & Exploit
Since the borrow systems' contract seems to be in shape, and the challenge hints us to use other contracts, I'm going to start there.
Things that I know:
- We don't have the token with the approve() bug.
- We can't drain the DEX using reentrancy because there's no tokenFallback and I can't modify DEX's token addresses to force it.
- By modifying the balances, I can drain the pool to withdraw 10000 $ISEC.
- I need to get some $BOR and then spike its price to be able to borrow all the $ISEC I can get.
- It would be ideal to stay solvent throughout our attack.
Hands-on!
0) Initial stats for the entire ecosystem.
— Pool(tokens)
$ISEC: 10000— DEX(status)
liquidity: 100
reserve0 (ISEC): 100
reserve1 (BOR): 100— Challenger(tokens)
$ISEC: 0
$BOR: 0— BorrowSystem(status)
$ISEC: 10000, $BOR: 10000
Challenger solvent: yes
$ISEC -> Deposited: 0, Borrowed: 0
$BOR -> Deposited: 0, Borrowed: 0
💰 1 $BOR == 0 $ISEC
1) Ok, let's start by draining the pool.
To do this, I used the exploit from challenge 2 where I modify my balance to be able to withdraw all the available tokens from the pool.
Status after the withdrawal:
— Pool(tokens)
$ISEC: 0— DEX(status)
liquidity: 100
reserve0 (ISEC): 100
reserve1 (BOR): 100— Challenger(tokens)
$ISEC: 10000
$BOR: 0— BorrowSystem(status)
$ISEC: 10000, $BOR: 10000
Challenger solvent: yes
$ISEC -> Deposited: 0, Borrowed: 0
$BOR -> Deposited: 0, Borrowed: 0
💰 1 $BOR == 0 $ISEC
2) Now let's borrow some $BORs before we make them spike.
First, we let BorrowSystem manage tokens on my behalf. Then I deposit 1k $ISEC (arbitrary value) to be able to borrow 1k $BOR.
At this point with the current price, we have ~1111 of a maximum borrow so we are safe (still solvent).
Status after borrowing:
— Pool(tokens)
$ISEC: 0— DEX(status)
liquidity: 100
reserve0 (ISEC): 100
reserve1 (BOR): 100— Challenger(tokens)
$ISEC: 9000
$BOR: 1000— BorrowSystem(status)
$ISEC: 11000, $BOR: 9000
Challenger solvent: yes
$ISEC -> Deposited: 1000, Borrowed: 0
$BOR -> Deposited: 0.0, Borrowed: 1000
💰 1 $BOR == 0 $ISEC
3) Let's swap some $ISEC tokens for some $BORS to spike the price.
— What is the minimum amount of tokens that I can use to considerably spike up the price? 💭
To increase the price of a reserve, we have to make it scarce. So we will ask the DEX to swap 1k $ISEC (again, arbitrary) for $BOR.
Status after the swap:
— DEX(status)
reserve0 (ISEC): 1100
reserve1 (BOR): 9— Challenger(tokens)
$ISEC: 8000
$BOR: 1091— BorrowSystem(status)
$ISEC: 11000, $BOR: 9000
Challenger solvent: yes
$ISEC -> Deposited: 1000, Borrowed: 0
$BOR -> Deposited: 0.0, Borrowed: 1000
💰 1 $BOR == 120 $ISEC
As you can see, the BoringToken is not that boring anymore 😄. A single unit is now worth 120 InSecureumTokens. And the borrowing cap was updated to ~122,232 💥.
I proceed by depositing the thousand $BORs I borrowed that now are worth a lot more.
Status after depositing $BOR to the BorrowSystem:
— Challenger(tokens)
$ISEC: 8000
$BOR: 91— BorrowSystem(status)
$ISEC: 11000, $BOR: 10000
Challenger solvent: yes
$ISEC -> Deposited: 1000, Borrowed: 0
$BOR -> Deposited: 1000, Borrowed: 1000
💰 1 $BOR == 120 $ISEC
4) And last, let's wipe the $ISECs from BorrowSystem.
With the added $BOR as collateral — at its all-time high —, I can ask for the remaining $ISEC tokens the lending platform has, thus solving the challenge.
— DEX(status)
reserve0 (ISEC): 1100
reserve1 (BOR): 9— Challenger(tokens)
$ISEC: 19000
$BOR: 91— BorrowSystem(status)
$ISEC: 0, $BOR: 10000
Challenger solvent: yes
$ISEC -> Deposited: 1000, Borrowed: 11000
$BOR -> Deposited: 1000, Borrowed: 1000
💰 1 $BOR == 120 $ISEC
Some comments
It’s not silly to assume that an attacker would do all this in a single transaction. In-between transactions can interfere with its strategy.
I modified the variables of BorrowSystem from private to public to be able to access them easier.
Note: You can always access private variables using storage lots.
Not a single time we've become insolvent.
Solution
Exploit contract
Nothing new actually, we reused Exploit1.sol and then just manipulated the market 😈.
We could create a new contract that does everything for us, but that won't be necessary.
If you like, you can take that as an exercise 😉.
Deployment
And that would be all! 👏
Congratulations on following this series. I hope this walk-through helped you improve the way you see contracts and how to approach them.
Speaking about this series, what did you like more? what did resonate less with you? Let me know in the comments!
If you feel you’re lacking in DeFi knowledge, you can check out:
- 101 concepts definition from the OpenZeppelin forum
- Khan Academy unit also recommended by Christoph Michel
- Uniswap V3 Development Book (just came out of the oven)
I’m going to take a break from writing for a while, and while I do, I encourage you to try Damn Vulnerable DeFi by @tinchoabbate. I’m certain when I say that if you like this kind of exercise you’re going to love it and learn a lot more from them.
Again, thanks for reading! ❤️
Thanks for reading! My name is Matt, and I’m learning how to make Ethereum more secure. I will be sharing some things from time to time.
Follow me on twitter @mattaereal.