Request an Audit
Back
  • 07/03/2024

The First Attack on Bitcoin DeFi Smart Contract

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.

1.What’s the Clarity?

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

  1. Eliminates semantic inconsistencies during source code to bytecode compilation, preventing semantic confusion and unpredictable security risks.(a bug in vyper is a semantic inconsistencies issue: https://hackmd.io/@vyperlang/HJUgNMhs2)
  2. bytecode is not human-readable, which makes it very hard to verify what the smart contract is actually doing,With Clarity, what you see is what you get.

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 :

https://clarity-lang.org

Title Page – Clarity Book

This book is free and open source, available online at https://book.clarity-lang.org. It is licensed under a Creative…

book.clarity-lang.org

2.Zest Protocol Borrow Pool Introduction

Zest Protocol features two types of pools:

  • Earn pools, where users earn a yield on their BTC
  • Borrow pools, where users borrow against their BTC

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:

  1. Supply: Users can supply assets to the pool, which may then be used as collateral depending on the asset’s configuration within the system.
  2. Withdraw: Allows users to withdraw their supplied assets, subject to the constraints imposed by their current borrowing status and the pool’s available liquidity.
  3. Borrow: Users can borrow assets from the pool, with the amount being determined by their collateral value and the asset’s borrowing conditions.
  4. Repay: Borrowers can repay their loans to adjust their borrowing balance and overall health factor.

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.

3.Core Attack process analysis

here we use this tx as a example to analsis the attack:

STX Address – SP3M7…5CY2B

Explore transactions and accounts on the Stacks blockchain. Clone any contract and experiment in your browser with the…

explorer.hiro.so

This is the attack process:

  1. The attacker first swap btc to stc (https://explorer.hiro.so/txid/0x47905e09753d47e9934df36e925a8277db5dc8801c00e4b555045283971caf72?chain=mainnet)

0.0034btc with 73 STX.

use bridge finalize-peg-in1

(https://explorer.hiro.so/txid/0x6ebbed26a19fd096f13ff50a7fac4865db5e16775ea24a4ecff4150a83421c27?chain=mainnet)

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

STX Transaction – 0xeb23d…510d5

Explore transactions and accounts on the Stacks blockchain. Clone any contract and experiment in your browser with the…

explorer.hiro.so

attack tx, supply function argument
mint lp token: onchain/contracts/borrow/pool/pool-borrow-v1–1.clar

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

assets list 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 price
  • asset-to-borrow: The fungible token to be borrowed.
  • lp: The liquidity provider’s fungible token.
  • assets: A list of 100 assets,User provided collateral asset
  • amount-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:

  1. It should be noted that although the asset list will be verified here and whether the asset exists in the list, it does not determine whether the entered data is repeated or not.
(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)
  • The first one is to determine whether the value of the pledged assets is greater than 0
  • The second one determines whether the lent assets are less than or equal to the collateral value. Since the hacker’s real collateral here is 200 STX, but due to no verification, the collateral assets are amplified 98 times, so the collateral value here is 19,600 STX. , is much larger than the 9000STX lent by the hacker, so this verification canbe bypass.
  • The third one is to determine whether the lending pool has enough assets.

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))

The attackers attacked a total of five times

https://explorer.hiro.so/txid/0x8c76170d1740cc70ff65f50262d12b9a28ae23702274825225d31e1639e95906?chain=mainnet
https://explorer.hiro.so/txid/0x03233c5112391647518c0a0ec69d7cb3cbffe9c917a18727007c43f0291b9dd3?chain=mainnet‍
https://explorer.hiro.so/txid/0xc08c2255c08575d7ba7b8a872e71c7e15c86e3e32887a844e1cea7f494c26b85?chain=mainnet
https://explorer.hiro.so/txid/0xc63c9955d659aebcc09751489032183ac33e6a35009808d3883843fe58d3f7e7?chain=mainnet
https://explorer.hiro.so/txid/0xc573c6b61d3fd14a5bdeda028d9ce25ffed6150573bda35389b0aaaf4b63b3c6?chain=mainnet

Caused 322k STX losses.

4.How to fix the bug?

There are two options here:

  1. Only one collateral asset is allowed at a time, should not use asset list
  2. When calculating collateral, determine whether there is duplicate data in the asset list, but this will lead to a cyclic comparison process, which will cost a lot of gas costs.

5.SUMMARY

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)

6.Reference

  1. https://www.zestprotocol.com/blog/zest-protocol-security-update
  2. https://twitter.com/ZestProtocol/status/1778533657961288096