2024/4/11, The Zest Proctol was attacked which lead to ~1M USD lost.
Zest Protocol is a Bitcoin lending protocol which living on Stacks ( Bitcoin L2 protocol)
and the smart contract language is Clarity.
In here I will analysis the first bitcoin defi smart contract attack.
Clarity is a smart contract development by Hiro PBC, Algorand, and various other stakeholders,and Now used in Stacks blockchain,
Clarity takes inspiration from LISP, and you can think of everything in Clarity as a list inside of a list, or an expression inside of an expression. Everything in Clarity is wrapped in parentheses, function definitions, variable declarations, function parameters, etc.
Let’s expand on this concept a bit by deleting this and writing a new function.
(define-data-var count int 0)
(define-public (add-number (number int))
(let
(
(current-count count)
)
(var-set count (+ 1 number))
(ok (var-get count))
)
)
(add-number 5)
What’s the different from Clarity and Solidity?
1.Clarity is interpreted, not compiled
Clarity code will not be compiled to bytecode,which is different from solidity and other smart contract.
the source code will send to Stacks chain directly and then interprete execution, this is a huge different from evm.in evm the source code will first compiled to bytecode and the send to the chain
and the evm will verify the bytecode and dispatch the routine of the opcode.There are always trade-offs.
2.Clarity no dynamic dispatch
Clarity is not turning complete and no dynamic dispatch, so that there is no reentrcy vulnerability in Clarity.
Some of the advantages
Disadvantages is:
1.Interpreted execution generally has lower performance compared to bytecode execution.
Interpreters directly translate and execute high-level code, leading to overhead and potential performance bottlenecks.
2.because the source code will be directly execute on chain,Anyone can submit huge invalid code causing the contract execution efficiency of the entire chain to decrease.
if you want know more abou the clartity language you can get the information :
Zest Protocol features two types of pools:
source code: https://github.com/Zest-Protocol/zest-contracts/tree/main
The pool-borrow.clar
contract handles the core functionalities related to borrowing, supplying, withdrawing, and repaying assets. It includes functions to:
When users borrow, they need to provide collateral tokens.
The entrypoint function of the borrow asset is below
onchain/contracts/borrow/wrappers/borrow-helper.clar
(define-public (borrow
(pool-reserve principal)
(oracle <oracle-trait>)
(asset-to-borrow <ft>)
(lp <ft>)
(assets (list 100 { asset: <ft>, lp-token: <ft>, oracle: <oracle-trait> }))
(amount-to-be-borrowed uint)
(fee-calculator principal)
(interest-rate-mode uint)
(owner principal))
(let (
(asset-principal (contract-of asset-to-borrow))
)
(try! (contract-call? .pool-borrow-v1-1 borrow pool-reserve oracle asset-to-borrow lp assets amount-to-be-borrowed fee-calculator interest-rate-mode owner))
(print { type: "borrow-call", payload: { key: owner, data: {
reserve-state: (try! (contract-call? .pool-0-reserve get-reserve-state asset-principal)),
user-reserve-state: (contract-call? .pool-0-reserve get-user-reserve-data owner asset-principal),
user-index: (contract-call? .pool-0-reserve get-user-index owner asset-principal),
user-assets: (contract-call? .pool-0-reserve get-user-assets owner),
asset: asset-to-borrow,
amount: amount-to-be-borrowed,
}}})
(ok true)
)
)
We can see that the function parameters of borrow are:
(pool-reserve principal)//THIS IS THE POOL ADDRESS
(oracle <oracle-trait>) // PRICE ORACLE ADDRESS
(asset-to-borrow <ft>)
(lp <ft>)
(assets (list 100 { asset: <ft>, lp-token: <ft>, oracle: <oracle-trait> }))
(amount-to-be-borrowed uint)
(fee-calculator principal)
(interest-rate-mode uint)
(owner principal)
The parameter worth noting here is assets
. The function here is the information of the assets used by the borrower as collateral, provided by the borrower. assets is an array of length 100, <ft> in here means fungible token.
here we use this tx as a example to analsis the attack:
This is the attack process:
0.0034btc with 73 STX.
use bridge finalize-peg-in1
exchange 302 STX
2.Through the supply
function, deposit 200STX in the pool, which corresponds to wstx
token here, and then the pool will mint the corresponding zwstx
token. These stx are recorded in the lending pool as zwstx
2.The attacker calls the borrow function and starts lending STX. At this time, the asset list in the parameter assets array is used as collateral to calculate the assets that can be borrowed. This data is provided by the attacker(borrower), and this is where the attack occurs. Let’s look at the attacker’s assets data in attack tx
Among them, lp token appears in 3 types:
‘SP2VCQJGH7PHP2DJK7Z0V48AGBHQAW3R3ZW1QF4N.zststx-v1–0oracle’
‘SP2VCQJGH7PHP2DJK7Z0V48AGBHQAW3R3ZW1QF4N.zaeusdc-v1–0’
‘’SP2VCQJGH7PHP2DJK7Z0V48AGBHQAW3R3ZW1QF4N.zwstx-v1′
The attacker does not have the first two assets. What the attacker has is ‘SP2VCQJGH7PHP2DJK7Z0V48AGBHQAW3R3ZW1QF4N.zwstx-v1. That is, when calling supply, the pool gives him mint’s lp token. You can see that except for the first two assets, the remaining 98 lp token in the list is SP2VCQJGH7PHP2DJK7Z0V48AGBHQAW3R3ZW1QF4N.zwstx-v1
(Since the length of this parameter in the contract is 100, the attack here sets all remaining array elements to )
{
asset: 'SP2VCQJGH7PHP2DJK7Z0V48AGBHQAW3R3ZW1QF4N.wstx'
lp: 'SP2VCQJGH7PHP2DJK7Z0V48AGBHQAW3R3ZW1QF4N.zwstx-v1'
Oracle: 'SP2VCQJGH7PHP2DJK7Z0V48AGBHQAW3R3ZW1QF4N.stx-oracle-v1-3'
}
Then calculate the amount of stx that can be lent out through the user’s collateral. The main logic here is in the borrow function of onchain/contracts/borrow/pool/pool-borrow-v1–1.clar.
(define-public (borrow
(pool-reserve principal)
(oracle <oracle-trait>)
(asset-to-borrow <ft>)
(lp <ft>)
(assets (list 100 { asset: <ft>, lp-token: <ft>, oracle: <oracle-trait> }))
(amount-to-be-borrowed uint)
(fee-calculator principal)
(interest-rate-mode uint)
(owner principal))
(let (
(asset (contract-of asset-to-borrow))
(available-liquidity (try! (contract-call? .pool-0-reserve get-reserve-available-liquidity asset-to-borrow)))
(reserve-state (try! (contract-call? .pool-0-reserve get-reserve-state asset)))
(is-in-isolation-mode (contract-call? .pool-0-reserve is-in-isolation-mode owner))
(user-assets (contract-call? .pool-0-reserve get-user-assets owner))
)
(try! (is-approved-contract contract-caller))
(asserts! (get borrowing-enabled reserve-state) ERR_BORROWING_DISABLED)
(asserts! (get is-active reserve-state) ERR_FROZEN)
(asserts! (not (get is-frozen reserve-state)) ERR_FROZEN)
(asserts! (>= available-liquidity amount-to-be-borrowed) ERR_EXCEEDED_LIQ)
(asserts! (is-eq tx-sender owner) ERR_UNAUTHORIZED)
(asserts! (> amount-to-be-borrowed u0) ERR_NOT_ZERO)
(try! (validate-assets assets))
(if (is-some is-in-isolation-mode)
(asserts! (contract-call? .pool-0-reserve is-borroweable-isolated asset) ERR_NOT_SILOED_ASSET)
true)
(asserts! (is-eq (get a-token-address reserve-state) (contract-of lp)) ERR_INVALID_Z_TOKEN)
(asserts! (is-eq (get oracle reserve-state) (contract-of oracle)) ERR_INVALID_ORACLE)
(let (
(user-global-data (try! (contract-call? .pool-0-reserve calculate-user-global-data owner assets)))
(borrow-balance (try! (contract-call? .pool-0-reserve get-user-balance-reserve-data lp asset-to-borrow owner oracle)))
(amount-collateral-needed
(contract-call? .pool-0-reserve calculate-collateral-needed-in-USD
asset-to-borrow
amount-to-be-borrowed
(get decimals reserve-state)
(try! (contract-call? oracle get-asset-price asset-to-borrow))
u0
(get total-borrow-balanceUSD user-global-data)
(get user-total-feesUSD user-global-data)
(get current-ltv user-global-data))))
(asserts! (> (get total-collateral-balanceUSD user-global-data) u0) ERR_NOT_ZERO)
(asserts! (<= (get collateral-needed-in-USD amount-collateral-needed) (get total-collateral-balanceUSD user-global-data)) ERR_NOT_ENOUGH_COLLATERAL)
(asserts! (>= (get borrow-cap reserve-state) (+ (get total-borrows-variable reserve-state) u0 amount-to-be-borrowed)) ERR_EXCEED_BORROW_CAP)
(match is-in-isolation-mode
isolated-asset
(let (
(isolated-reserve (try! (contract-call? .pool-0-reserve get-reserve-state isolated-asset)))
(amount-to-be-borrowed-in-base-currency (contract-call? .pool-0-reserve mul-to-fixed-precision
amount-to-be-borrowed
(get decimals reserve-state)
(try! (contract-call? oracle get-asset-price asset-to-borrow))))
(total-isolated-debt (try! (contract-call? .pool-0-reserve sum-total-debt-in-base-currency assets)))
)
(if (> (get debt-ceiling isolated-reserve) u0)
(asserts! (<= (+ amount-to-be-borrowed-in-base-currency total-isolated-debt) (get debt-ceiling isolated-reserve)) ERR_EXCEED_DEBT_CEIL)
true
)
)
true
)
;; conditions passed, can borrow
(try! (contract-call? .pool-0-reserve update-state-on-borrow asset-to-borrow owner amount-to-be-borrowed u0))
(try! (contract-call? .pool-0-reserve transfer-to-user asset-to-borrow owner amount-to-be-borrowed))
(print { type: "borrow", payload: { key: owner, data: { amount-to-be-borrowed: amount-to-be-borrowed, asset-to-borrow: asset-to-borrow, lp: lp } } })
(ok amount-to-be-borrowed))))
The main logic of the Borrow function is:
The borrow
function takes several parameters:
pool-reserve
: The principal (address) of the pool reserve.oracle
: The oracle trait which used to calculate the priceasset-to-borrow
: The fungible token to be borrowed.lp
: The liquidity provider’s fungible token.assets
: A list of 100 assets,User provided collateral assetamount-to-be-borrowed
: The amount of the asset to be borrowed.fee-calculator
: The principal (address) of the fee calculator.interest-rate-mode
: The mode of interest rate.owner
: The principal (address) of the owner.The function begins by defining several local variables ,These variables include the contract of the asset to be borrowed, the available liquidity of the asset, the state of the reserve, whether the reserve is in isolation mode, and the assets of the user.
The function then checks several conditions using the asserts!
keyword. These checks include whether borrowing is enabled, whether the reserve is active and not frozen, whether there is enough liquidity to cover the amount to be borrowed, whether the transaction sender is the owner, and whether the amount to be borrowed is greater than zero.
If the reserve is in isolation mode, the function checks whether the asset is borrowable in isolation. It also checks whether the a-token address and the oracle of the reserve state match the contract of the liquidity provider token and the oracle, respectively.
The function then calculates the user’s global data, the user’s balance reserve data, and the amount of collateral needed in USD. It checks whether the total collateral balance in USD is greater than zero, whether the collateral needed in USD is less than or equal to the total collateral balance in USD, and whether the borrow cap of the reserve state is greater than or equal to the sum of the total borrows variable of the reserve state and the amount to be borrowed.
If the reserve is in isolation mode, the function calculates the amount to be borrowed in base currency and the total isolated debt. It checks whether the debt ceiling of the isolated reserve is greater than zero and, if so, whether the sum of the amount to be borrowed in base currency and the total isolated debt is less than or equal to the debt ceiling of the isolated reserve.
If all conditions are met, the function updates the state on borrow, transfers the asset to the user, prints a message, and returns the amount to be borrowed.
Here we mainly explain the logic related to the vulnerability:
(assets-to-calculate (list 100 { asset: <ft>, lp-token: <ft>, oracle: <oracle-trait> })))
(let ((assets-used (get-assets)))
(fold check-assets assets-used (ok { idx: u0, assets: assets-to-calculate }))
)
)
(define-read-only (check-assets
(asset-to-validate principal)
(ret (response { idx: uint, assets: (list 100 { asset: <ft>, lp-token: <ft>, oracle: <oracle-trait> })} uint)))
(let (
(agg (try! ret))
(asset-principal (get asset (unwrap! (element-at? (get assets agg) (get idx agg)) ERR_NON_CORRESPONDING_ASSETS))))
(asserts! (is-eq asset-to-validate (contract-of asset-principal)) ERR_NON_CORRESPONDING_ASSETS)
(ok { idx: (+ u1 (get idx agg)), assets: (get assets agg) })
)
)
2.Then calculate the actual assets in the asset list provided by the user through calculate-user-global-data
. fold
will call aggregate-user-data
in a loop. This function will calculate the asset data of each item. Here, since the attacker provides the list is repeated 98 times, so the LP actually provided by the attacker will be recalculated 98 times. (Note: We can see that there is no verification whether the asset’s LP data is repeated or not here too).
(define-public (calculate-user-global-data
(user principal)
(assets-to-calculate (list 100 { asset: <ft>, lp-token: <ft>, oracle: <oracle-trait> })))
(begin
(try! (validate-assets-order assets-to-calculate))
(let (
(aggregate (try!
(fold
aggregate-user-data
assets-to-calculate
(ok
{
total-liquidity-balanceUSD: u0,
total-collateral-balanceUSD: u0,
total-borrow-balanceUSD: u0,
user-total-feesUSD: u0,
current-ltv: u0,
current-liquidation-threshold: u0,
user: user
}))))
3.Collateral price calculation. The value of the collateral is calculated here through oracle. Since it has nothing to do with vulnerability, I will not go into details here. If you are interested, you can analyze it by yourself.
(define-public (sum-total-debt-in-base-currency
(assets-to-calculate (list 100 { asset: <ft>, lp-token: <ft>, oracle: <oracle-trait> })))
(begin
(try! (validate-assets-order assets-to-calculate))
(fold aggregate-debt
assets-to-calculate
(ok u0)
)
)
)
4.There are 3 judgment statements here.
(asserts! (> (get total-collateral-balanceUSD user-global-data) u0) ERR_NOT_ZERO)
(asserts! (<= (get collateral-needed-in-USD amount-collateral-needed) (get total-collateral-balanceUSD user-global-data)) ERR_NOT_ENOUGH_COLLATERAL)
(asserts! (>= (get borrow-cap reserve-state) (+ (get total-borrows-variable reserve-state) u0 amount-to-be-borrowed)) ERR_EXCEED_BORROW_CAP)
5.Update the pool status and send stx to attacker
;; conditions passed, can borrow
(try! (contract-call? .pool-0-reserve update-state-on-borrow asset-to-borrow owner amount-to-be-borrowed u0))
(try! (contract-call? .pool-0-reserve transfer-to-user asset-to-borrow owner amount-to-be-borrowed))
Caused 322k STX losses.
There are two options here:
The main reason of the attack here is that when the contract calculates the collateral assets, it does not determine whether the data passed in by the user is repeated, resulting in an LP calculation error, which allows the hacker to lend out assets hundreds of times more than the collateral.
In addition, we can also see that even the most secure language will still have security vulnerabilities. Projects must conduct security audits and on-chain monitoring before going online.
Our company EXVUL(https://twitter.com/exvulsec) has done a lot of security research on clarity smart contract. Last year I (https://twitter.com/ma1fan ) discovered several stacks of clarity language vulnerabilities and received a total reward of 230K USD. This is one of the disclosed ones: https://twitter.com/immunefi/status /1760565946463449464
If you need clarity contract security audit, please contact me nolan@exvul.com X(@ma1fan)