Web3借贷DeFi安全问题
问题:
传统金融系统的核心问题:中心化、不透明、高门槛、低效率。权力集中在少数机构手中,用户对自己的资产和数据没有绝对控制权。
变革:
Web3的浪潮正是在这样的背景下涌现,它承诺构建一个更加开放、透明且用户自主的互联网。而实现这一愿景的核心建筑模块,就是DApp。
DApp,即去中心化应用(Decentralized Application),可以理解为运行在公共电脑网络(即区块链)上的应用程序。与手机上由特定公司(如京东、腾讯、字节跳动)控制的App不同,DApp不由任何单一实体控制。
DApp特性:
•抗审查:没有中央机构可以关闭它。
•数据透明:大部分操作记录在公共账本上,可供查证。
•用户主权:用户通过自己的加密钱包与DApp交互,掌握着私钥,从而控制自己的资产。
DeFi的一些功能:
•借贷:用户可以无需许可地抵押一种加密资产,借出另一种资产(例如,抵押以太坊借出稳定币USDC)。这正是本文要探讨的核心领域。
•交易:在去中心化交易所(DEX)上,用户可以直接进行点对点的代币交换,资产始终在自己钱包里。
•理财:通过提供流动性来赚取交易费或利息。
DeFi的革命性:
DeFi的目标是创建一个无需信任中介、全球互通、24/7不间断运行的金融系统。这一切之所以能实现,都依赖于其背后的核心引擎——智能合约。
在传统App中,其核心业务逻辑都是用Java、C++、Python等语言编写的。这些代码运行在公司控制的私有服务器上。
智能合约本质上就是DApp的去中心化后端逻辑。它同样是用编程语言(主要是Solidity、Rust等)编写的一段代码,规定了一系列的规则和条件,例如“如果账户A抵押了价值足够的资产,那么就允许它借出X数量的稳定币”。与传统后端最大的不同在于,智能合约并非部署在某个公司的私有服务器上,而是部署在公开透明的区块链网络(如以太坊)上。
借贷DeFi概述简介
借贷市场基本角色:
Borrower (借款者): 是指那些希望在不出售自己持有的加密资产的情况下获得流动性的用户。在 DeFi 借贷平台中,借款者通过抵押一种加密资产(如 ETH),来借入另一种加密资产(如稳定币 DAI)。
•特点:超额抵押、维持健康度、支付利息
Lender (贷款者):称为出借人或流动性提供者,是指那些将自己闲置的加密资产存入借贷协议资金池中以赚取利息收益的用户。
•特点:赚取被动收入、获得生息代币
Liquidator(清算者): 当借款人的抵押品价值下降,导致其贷款健康度低于清算门槛时,清算者会介入对这些借款者对抵押资产进行清算。
•特点:维持系统偿付能力、偿还债务并获得折扣抵押品、盈利动机
借贷市场基本功能:
Supply(存入):用户(即贷款者或流动性提供者)将自己持有的加密资产存入平台的借贷池中。作为回报,协议会给予存款用户计息代币(例如在Aave平台存入DAI会收到aDAI),这些代币代表了他们在资金池中的份额,并会随着时间累积利息。
作用:为市场提供流动性以赚取利息。
Borrow(借款):用户(即借款者)在存入一种被接受的加密资产作为抵押品后,就可以从资金池中借出另一种资产。为了防范抵押品价格下跌的风险,所有借款都必须是超额抵押的,即抵押品的价值需要高于借款的价值。
作用:通过抵押资产来获取流动性。
Redeem(赎回): 存款用户可以随时执行“赎回”操作,将他们手中的计息代币换回最初存入的加密资产,并同时获得在此期间累积的利息。
作用:取回之前存入的资产及产生的利息。
Liquidate(清算):这是一项关键的风险管理机制。当市场行情波动导致借款者抵押品的价值下降,使其贷款健康度(抵押品价值/借款价值)低于预设的“清算门槛”时,该笔贷款就面临被清算的风险。此时,任何人(即清算者)都可以介入,代借款者偿还部分或全部债务。作为回报,清算者能以一个低于市场价的折扣购买到借款者的部分抵押品。这个带有激励的机制确保了有风险的债务能够被及时清理,从而防止协议产生坏账,保护了存款人的资金安全。
作用:保障协议的偿付能力和资金安全。
漏洞场景一:货币兑换
小数位数未统一导致价格计算错误
前置知识:货币位数
区块链货币位数:当你看到一个钱包显示你有1ETH代币时,它区块链上实际存储的数字是1,000,000,000,000,000,000 Wei(也就是 1 后面跟 18 个零,即10^18Wei)。
避免浮点数的不精确性
在标准的计算机编程中,浮点数(如float或double)存在精度问题。例如,0.1 + 0.2 在很多编程语言中并不精确等于 0.3,而是一个非常接近的数字,如0.30000000000000004。
在金融领域,尤其是像区块链这样每一笔交易都必须绝对精确、不可篡改的系统中,这种微小的误差是致命的。为了保证所有计算在全世界所有节点上都得到完全相同、确定性的结果,区块链虚拟机(如以太坊的EVM)从设计上就彻底禁用了浮点数运算,只支持整数。
解决方案:“位数(Decimals)”
就是约定一个换算单位。
但是又出现了一个新问题,因为没有统一标准,所以出现了decimals为6位、8位、18位的各种虚拟货币。
6位小数(The Stablecoin Standard)
•来源:主要由像USDC、USDT(在某些链上)这样的主流稳定币采用。
•原因:
1.贴近传统金融:传统金融系统(如美元)通常只需要2到4位小数。6位小数对于模拟法币交易来说绰绰有余,同时又比18位小数处理起来更简单。
2.计算效率和成本:处理一个非常大的数字(如18位小数对应的整数)在某些计算中可能会消耗更多的Gas费。6位小数的数字相对较小,处理起来更高效。
3.可读性:对于主要用于支付和交易的稳定币来说,6位小数在前端显示和用户理解上更为直观。
8位小数(The Bitcoin Legacy)
•来源:源于比特币(BTC)。比特币的最小单位是“聪”(Satoshi)。1 BTC = 100,000,000 Satoshis(也就是10^8Satoshis)。
•原因:
1.历史先例:作为第一个加密货币,比特币的设计影响了很多后续项目。一些早期的代币或与比特币生态相关的代币(如WBTC - Wrapped Bitcoin)会沿用8位小数的设定。
2.Chainlink预言机:著名的预言机项目Chainlink为其大多数价格源(Price Feeds)也选择了8位小数作为标准,这进一步巩固了8位小数在特定场景下的使用。
18位小数(The Ethereum Standard)
•来源:这是以太坊(ETH)自身的标准。ETH的最小单位叫做Wei。1 ETH = 1,000,000,000,000,000,000 Wei(也就是 1 后面跟 18 个零,即10^18Wei)。
•原因:
1.兼容性与惯例:由于ETH是智能合约平台的先驱,后来在其上发行的ERC-20代币为了方便与ETH本身进行交互和计算,就大量沿用了18位小数的约定。这几乎成了DeFi领域的“默认标准”。
2.极高的精度:18位小数提供了非常高的精度,足以应对极其复杂的金融计算,比如计算微小的利息或交易费率,可以有效避免四舍五入带来的误差累积。
漏洞详情
https://github.com/sherlock-audit/2022-08-sentiment-judging/tree/main/019-H
因为在DeFi借贷场景中会出现各种虚拟货币互相换算,所以极易出现换算漏洞。这里换算相对于ETH的价值时,直接获取了不同虚拟货币的位数(Decimals)然后直接^18次方进行计算。
function getPrice(address token) external view virtual returns (uint) {
(, int answer,,,) =
feed[token].latestRoundData();
if (answer < 0)
revert Errors.NegativePrice(token, address(feed[token]));
return (
- (uint(answer)*1e18)/getEthPrice()
);
}
暴露的问题
不同的Token预言机返回的answer(price)位数不一定都是8位,就会导致计算相对于ETH价格的时候出现错误。导致1个不值钱的虚拟货币因为位数很大,最后可以兑换几百个ETH。
漏洞场景二:Supply存入
汇率使用错误
前置知识:汇率
汇率(Exchange Rate)代表了1个生息代币(lToken)可以兑换多少底层资产(如DAI)。随着借款利息的不断累积,这个汇率应该是只增不减的。
•exchangeRateStored(): 这个函数直接读取当前存储在状态变量中的汇率值。这个值只有在某些状态变更操作(如mint,redeem,borrow,repay)发生时,触发了利息计算(accrueInterest)后才会被更新。它是一个历史快照。
•exchangeRateCurrent(): 这个函数会先调用利息计算函数(accrueInterest),将从上一次更新到当前区块所产生的所有利息都累加上,然后再返回最新的汇率。它是一个实时、精确的值。
汇率计算方式:
存款份额 = (现金额度 + 借出去存款 + 利息)
汇率 = 存款金额 / 份额
漏洞详情
https://github.com/sherlock-audit/2025-05-lend-audit-contest-judging/issues/628
漏洞代码实现:
function supply(uint256 _amount, address _token) external {
address _lToken = lendStorage.underlyingTolToken(_token);
require(_lToken != address(0), "Unsupported Token");
require(_amount > 0, "Zero supply amount");
// Transfer tokens from the user to the contract
IERC20(_token).safeTransferFrom(msg.sender, address(this), _amount);
_approveToken(_token, _lToken, _amount);
// Get exchange rate before mint
- uint256 exchangeRateBefore = LTokenInterface(_lToken).exchangeRateStored();
+ uint256 exchangeRateBefore = LTokenInterface(_lToken).exchangeRateCurrent();
// Mint lTokens
require(LErc20Interface(_lToken).mint(_amount) == 0, "Mint failed");
// Calculate actual minted tokens using exchangeRate from before mint
uint256 mintTokens = (_amount * 1e18) / exchangeRateBefore;
lendStorage.addUserSuppliedAsset(msg.sender, _lToken);
lendStorage.distributeSupplierLend(_lToken, msg.sender);
// Update total investment using calculated mintTokens
lendStorage.updateTotalInvestment(
msg.sender, _lToken, lendStorage.totalInvestment(msg.sender, _lToken) + mintTokens
);
emit SupplySuccess(msg.sender, _lToken, _amount, mintTokens);
}
问题暴露
这里错误使用了汇率,如果在上一次交易和当前这笔supply交易之间,经过了很长一段时间,那么系统中已经累积了大量的“未决利息”。
exchangeRateStored(); ️ 过时的汇率,是否考虑到未决利息
exchangeRateCurrent(); 当前汇率
攻击场景示例
1.前提条件:一个借贷池已经运行了一段时间,并且自从上一次有人与该池交互后,已经过去了一段较长的时间(例如几个小时或几天)。这段时间内,借款利息在持续累积。
2.攻击者操作:攻击者此时调用supply()函数存入一笔资金。
3.合约内部的错误流程:
◦合约首先调用exchangeRateStored(),获得一个较低的旧汇率。
◦接着,mint()被调用,合约内部的利息被正确计算,存储的汇率也被更新为正确的、较高的现行汇率。
◦最后,合约使用那个较低的旧汇率为攻击者计算份额。
4.结果:攻击者因此获得了比他应得的更多的lToken份额。他可以立即调用redeem函数,用这些超发的份额换回比他存入时更多的底层资产,从而盗走了那段“空窗期”内本应属于所有存款人的累积利息。
漏洞影响
稀释现有存款人价值:攻击者凭空获得了不属于他的份额,这直接稀释了池中其他所有诚实存款人的资产价值。本应分配给所有人的利息被攻击者一人独占。
捐赠攻击
前置知识:份额计算
汇率 = 总存款金额 / 份额
份额数量 = 存入货币数量 / 汇率
攻击者通过向智能合约地址直接发送(即“捐赠”)资产,恶意操纵合约内部关键的计算逻辑(如资产价格或兑换率),从而为自己创造不公平的优势,进而盗取协议资金。
攻击场景示例
1.锁定目标:攻击者找到一个刚刚部署、池内几乎没有资金的借贷金库(Vault)。
2.首次存款并获得份额:攻击者通过正常的supply()功能存入一笔极小的资金(例如,1 wei,即token的最小单位),从而成为该金库的第一个也是唯一的份额持有人。
3.实施“捐赠”:攻击者绕过supply()功能,直接向该金库的智能合约地址转入一笔数量可观的同种代币(这笔资金通常来自闪电贷)
4.操纵价格:此时,金库合约地址的代币余额因为这笔“捐赠”而大幅增加,但总份额(由攻击者在第2步持有)数量极低。根据上述错误的计算公式,每份份额的价值被不成比例地极度拉高。
5.受害者入场:一个正常用户向该金库存入资金。由于此刻每份份额的“价格”已经被操纵得非常高,用户的存款根据计算可能只能换取到少于1 wei的份额。因为智能合约通常会进行向下取整,用户的存款最终换算为0份份额。用户的资金被捐赠给了资金池,而这些价值被现有份额持有人(即攻击者)所吸收。
漏洞影响:窃取资金
攻击者赎回自己最初存入的极小份额,但由于池内已经包含了受害者的存款,攻击者实际上取走了自己最初的存款加上所有后续受害者的存款。
漏洞场景三:Borrow借款
创建债务头寸没有最低限制
前置知识:尘埃债务
Web3中的尘埃债务(Dust Debt)指的是在去中心化金融(DeFi)借贷协议中,一笔金额极小、几乎可以忽略不计的借款头寸。
它的核心特征是:这笔债务的价值,通常远低于偿还它所需要支付的交易费用(Gas Fee)
漏洞详情
https://github.com/sherlock-audit/2025-05-lend-audit-contest-judging/issues/592
协议允许用户创建或维持极小额的债务(比如 1 wei),而这种“尘埃债务”在经济上是无法被有效清算的,从而可能逐渐累积成协议的坏账。
清算的经济学原理
一个理性的清算人(liquidator)只有在满足以下条件时,才会去执行一笔清算:
清算奖励 (美元价值) > 他付出的 Gas 费 (美元价值)
•清算奖励: (被清算的债务价值) * (清算奖金率)。例如,清算一笔 $100 的债务,奖金率为 8%,那么奖励就是 $8。
•Gas 费: 在以太坊主网上,执行一笔清算交易的 Gas 成本可能从几美元到几十美元不等,取决于网络拥堵情况和交易的复杂性。
问题所在:当债务过小时
现在,考虑报告中描述的场景:
1.创建小额债务:
◦一个用户(可能是恶意的,也可能是无意的)只借了价值 $0.01 的资产。或者,他原本有大额债务,但通过部分还款,最后只剩下价值 $0.01 的债务。
◦协议的 borrow 和 repayBorrow 函数目前是允许这种操作的。
2.小额债务变得不健康:
◦由于市场波动或利息累积,这个价值 $0.01 的小额债务仓位变得不健康了(资不抵债),技术上进入了可清算状态。
3.清算人视角:
◦一个清算人看到了这个可清算的仓位。他开始计算收益:
▪可清算的债务价值 = $0.01
▪清算奖励 = $0.01 * 8% = $0.0008
▪Gas 成本 = $5.00 (举例)
◦计算结果: 收益 ($0.0008) < 成本 ($5.00)。
◦结论: 没有任何理性的清算人会去清算这笔债务,因为这是在亏钱做慈善。
影响 (Impact)
报告指出的影响是:“Such positions will accrue the protocol bad debt, which can lead to insolvency.” (这样的头寸将累积协议坏账,这可能导致破产。)
•坏账累积 (Bad Debt Accrual):
◦这些无法被清算的“尘埃债务”会一直留在协议里。
◦由于它们是资不抵债的,这意味着与这些债务对应的存款人的资金,有一小部分是没有足额抵押品覆盖的。
◦虽然单个“尘埃债务”的金额微不足道,但如果有成千上万个这样的头寸被故意创建或无意中留下,它们累积起来的总坏账金额就可能变得不容忽视。
•导致破产 (Insolvency):
◦这是一个比较严重的说法,但理论上是可能的。
◦如果累积的坏账总额超过了协议的准备金(Reserves),那么协议就进入了事实上的破产状态。这意味着,如果所有存款人同时来提款,协议将没有足够的资金来兑付,最后提款的存款人会遭受损失。
漏洞场景四:Redeem赎回
赎回机制是否公平
漏洞详情
https://github.com/sherlock-audit/2025-05-lend-audit-contest-judging/issues/454
在web3世界,虚拟货币出现坏账的情况比较常见。当出现坏账情况的时候,如何保证所有存款人的损失程度是公平的是非常重要的一个问题。
在该漏洞详情中,当出现坏账情况时,采取的是先到先得的方案,这会导致以下的问题存在:
漏洞场景
1.Alice 和 Bob 各向 CoreRouter 提供 1500 个 DAI,代币存入 LToken ;
2.借款人直接从 借入 LToken 1000 DAI,则还剩下 2000 DAI;
3.Bob 打电话全额赎回他的投资, CoreRouter 1500 DAI 从 LToken 赎回给 Bob;
4.Alice 打电话全额赎回她的投资,但是,因为只 LToken 剩下 500 个 DAI,交易会失败;
5.Alice 只能兑换 500 DAI;虽然这在正常情况下运作良好,但在坏账的情况下,即借款人的抵押代币价格暴跌导致无利可图的清算,那么提前赎回的用户可以避免损失,而最后赎回的用户遭受的损失比预期的要多。
漏洞影响
在 LToken 出现坏账的情况下,提前赎回的 CoreRouter 用户可以避免损失,而其他用户遭受的损失更大。
漏洞场景五:Liquidate清算
超额清算
前置知识:清算流程
第一步:设置安全线 (超额抵押)
当你去DeFi协议借钱时,你不能空手去。你必须先存入一种资产作为抵押品,并且抵押品的价值必须高于你借款的价值。这就是超额抵押。
•例如:你存入了价值$2000的以太坊(ETH)。
•协议设定了一个借贷率(LTV),比如75%。这意味着你最多只能借走价值$1500的稳定币(DAI)。
此时,你的贷款是健康的。协议还会设定一条“清算阈值”(比如80%),这是一条红线。只要你的(债务价值 / 抵押品价值)低于这条红线,你就安全。为了方便理解,大多数协议会给你一个健康因子(Health Factor),只要它大于1,你就安全。
第二步:触发警报 (健康因子 < 1)
市场是波动的。如果你的抵押品(ETH)价格下跌了,会发生什么?
•市场变化:ETH价格从2000跌到了1850。
•抵押品价值缩水:你抵押的ETH现在只值$1850。
•触及红线:你的债务仍然是$1500。此时,你的抵押品价值已经非常接近债务价值了。健康因子计算后跌破了1。
此时,你的贷款头寸就像在悬崖边上亮起了红灯,它现在“可以被清算了”。
第三步:清算者出动 (有利可图的机器人)
谁来执行清算?不是协议本身,而是一群被称为清算者(Liquidators)的参与者。他们通常是运行着复杂算法的机器人(Bots),24*7不间断地监控着区块链上所有借贷协议的贷款状况。
当清算机器人发现你的健康因子小于1时,它看到了一个赚钱的机会。
1.偿还你的部分债务:清算机器人会调用协议的liquidate函数,替你偿还一部分债务。比如,它替你还了$750的DAI。
2.获得打折的抵押品:作为替你还债的奖励,协议允许清算机器人以一个折扣价(比如5%的折扣)拿走你的一部分抵押品(ETH)。
◦它偿还了价值$750的债务。
◦它能拿走价值 ` $750 * (1 + 5%) = $787.5 ` 的ETH。
◦清算机器人支付了$750,得到了$787.5的资产,瞬间获利$37.5。
这个折扣被称为清算罚金(Liquidation Penalty),对你来说是惩罚,对清算者来说是利润。正是这个利润激励,确保了总会有人来清理这些危险的贷款。
第四步:校验清算结果
所以在清算过程中需要关注一个特别重要的问题:是否超额清算。过多的清算会导致清算人额外获利及贷款者的资产损失。
漏洞详情
https://github.com/sherlock-audit/2025-05-lend-audit-contest-judging/issues/363
场景还原:
•初始状态:
◦Alice 欠 1000 USDC。
◦抵押品价值 950 USDC。
◦真实缺口 (Shortfall): 1000 - 950 = 50 USDC。
◦要让 Alice 的账户恢复健康,理论上只需要偿还 50 USDC 的债务即可。
•清算人 Bob 的行为:
◦协议的 closeFactor方法允许他最多可以偿还(比如)400 USDC。
◦Bob 选择了偿还 200 USDC (repayAmount = 200)。这个行为是被协议规则允许的。
•liquidateBorrowInternal 执行:
◦第一步:还款
▪repayBorrowInternal 被调用,Alice 的债务从 1000 USDC 减少到 800 USDC。
▪此时,Alice 的账户已经变得非常健康了(抵押品 950 > 债务 800)。
◦第二步:计算奖励并扣押
▪liquidateSeizeUpdate 被调用,它接收到的 repayAmount 参数仍然是 Bob 最初输入的 200。
▪它调用 liquidateCalculateSeizeTokens(…, 200)。
▪这个函数根据“偿还了 200 USDC”这个事实,来计算应得的抵押品奖励。比如,它会计算出价值 200 * 1.08 = 216 美元的抵押品并进行扣押。
•问题暴露:
◦Alice 的账户本来只需要 50 USDC 就能“得救”。
◦Bob 偿还了 200 USDC,这笔钱确实减少了 Alice 的债务,这部分是公平的。
◦但 Bob 却因为偿还了 200 USDC 而获得了基于 200 USDC 计算出的全额清算奖励。
◦协议扣押了价值 $216 的抵押品,而实际上,为了弥补50的缺口,最多只应该扣押价值50的缺口。
◦50 * 1.08 = $54的抵押品。
◦Alice 被超额扣押了价值 $216 - $54 = $162 的抵押品。
清算的时候不能直接用liquidator的repaymount进行计算,而是应该用真实缺口差值计算。
注:在大部分的借贷市场中,一旦健康度过低是允许直接扣押50%的抵押品。