这篇文章深入探讨了去中心化金融(DeFi)中的抵押债务头寸(CDP)和借贷协议的主要安全问题,通过多个经典黑客案例分析了价格操控、重入攻击和输入验证不足等漏洞,并提供最佳实践以避免这些安全隐患。
全球加密货币市场今天已成为一个万亿美元的行业,在过去几年里,这个领域出现了众多创新。其中最重要的发展之一是去中心化金融 (DeFi) 的出现。DeFi 的核心是抵押债务头寸 (CDPs) 和借贷协议。这些协议使用户能够借贷加密货币,而无需依赖传统金融机构,并利用智能合约自动化流程。在这篇文章中,我们将回顾 CDPs 和借贷协议的主要黑客攻击,强调最佳实践,并提供可操作的建议,帮助避免这些安全漏洞。
CDPs 和借贷协议是区块链上 DeFi 生态系统的基本组成部分。
CDPs 是智能合约,允许用户以抵押不同资产的形式获得某个资产的贷款。所存入的抵押物在贷款偿还之前被锁定在合约中。通过这种方式,CDPs 按照请求的贷款金额铸造等量的稳定币。智能合约确保稳定币始终由足够数量的抵押品支持。当抵押品的价值低于协议决定的某一阈值时,用户的头寸可能会被清算。
另一方面,借贷协议使用户能够从一组资产池中借入或出借。提供资产进入这些池的用户会获得来自借款人的费用作为奖励。
从安全的角度来看,在创建 CDPs 或借贷协议时,可能会出现许多问题。接下来,我们将讨论最常见的漏洞以及如何预防它们。
价格操纵是 CDPs 和借贷协议中最常被利用的问题之一。这些攻击利用智能合约对外部数据源(称为预言机)的依赖,以便准确执行。预言机通过提供实时数据,如价格信息,弥补区块链与外部世界之间的差距。当攻击者人为地抬高或压低协议内某个代币的价格时,就会发生价格操纵攻击。例如,如果某个 DeFi 协议使用去中心化交易所(DEX)作为其预言机来获取特定资产的价格,攻击者可以在 DEX 上人为地抬高或压低该资产的价格。根据获取价格的方式,有许多方法可以操纵来自预言机的价格。这里我们将介绍一些主要的黑客攻击及如何避免这些问题。
现货价格操纵攻击是一种市场操纵形式,攻击者试图人为改变资产的现货价格。现货价格代表资产可以被立即买入或卖出的当前市场价格。在区块链和 DeFi 的背景下,这种操纵通常会针对 DEX(例如 Uniswap)上某个代币的价格。
损失:$500K
Visor Finance 在 2021 年 11 月由于依赖 Uniswap 的现货价格而遭到黑客攻击。这些现货价格可以通过用 token0
交换 token1
容易被操纵。这会将 token1
从 AMM 中取出,从而抬高 token1
的价格。在攻击发生期间,攻击者利用闪电贷操纵现货价格以发行股份,然后提取比预期更多的代币。
uint160 sqrtPrice = TickMath.getSqrtRatioAtTick(currentTick()); //currentTick 获取当前 tick 值
uint256 price = FullMath.mulDiv(uint256(sqrtPrice).mul(uint256(sqrtPrice)), PRECISION, 2**(96 * 2));
在这里,price
的值将是当前 tick 的代币价格。
建议使用 Chainlink 价格源或时间加权平均价格(TWAP)来获取代币价格。值得注意的是,短期 TWAP 在某些情况下可能仍然被瞬间操纵。
另请参见由于类似的现货价格依赖所导致的其他黑客攻击:
bZx↗ — 损失: $8M
大量黑客利用错误的公式计算流动性池(LP)代币的价格。尽管通过用池中锁定的代币价值除以流动性代币的总供应量来计算 LP 代币的价格似乎是显而易见的,但这一公式却导致了百万美元的黑客攻击。错误的公式为:
P_lp=p0⋅r0+p1⋅r1totalSupplyP\_{lp} = \frac{p_0\cdot r_0 + p_1\cdot r_1}{\text{totalSupply}}P_lp=totalSupplyp0⋅r0+p1⋅r1
其中 pip_ipi = 代币 i
的价格,rir_iri = 代币 i
的储备数量。
这种计算 LP 代币价格的方法容易被操纵,因为 r0r_0r0 和 r1r_1r1 可以通过闪电贷剧烈波动。
Alpha Venture 提出的 公平 Uniswap LP 代币 定价↗,可以用作上述公式的替代。公式如下:
P_lp=2r0⋅r1⋅p0′⋅p1′totalSupply=2K⋅p0′⋅p1′P\_{lp} = \frac{2 \sqrt{r_0 \cdot r_1 \cdot p_0' \cdot p_1'}}{\text{totalSupply}} = \frac{2\sqrt{K\cdot p_0' \cdot p_1'}}{\text{totalSupply}}P_lp=totalSupply2r0⋅r1⋅p0′⋅p1′=totalSupply2K⋅p0′⋅p1′
其中 pi′p_i'pi′ 是资产的真实价格;换句话说,它不容易受到现货价格操纵(例如,来自高质量预言机)的影响。
公平 LP 代币定价根据 AMM 的公平储备金额的值评估 LP 代币。公平储备金额是基于 AMM 常数 KKK 和公平价格比率计算得出的。
现在,让我们来看看一些由于错误的 LP 代币价格公式造成的黑客攻击。
损失:$7.8M
Warp Finance 由于使用错误的公式计算 LP 代币的价格而被黑客攻击。以下是导致黑客攻击的代码:
function getUnderlyingPrice(address _lpToken) public returns (uint256) {
address[] memory oracleAdds = LPAssetTracker[_lpToken];
//获取构成 LP 的每个资产的预言机合约地址
UniswapLPOracleInstance oracle1 = UniswapLPOracleInstance(
oracleAdds[0]
);
UniswapLPOracleInstance oracle2 = UniswapLPOracleInstance(
oracleAdds[1]
);
(uint256 reserveA, uint256 reserveB) = UniswapV2Library.getReserves(
factory,
instanceTracker[oracleAdds[0]],
instanceTracker[oracleAdds[1]]
);
uint256 value0 = oracle1.consult(
instanceTracker[oracleAdds[0]],
reserveA
);
uint256 value1 = oracle2.consult(
instanceTracker[oracleAdds[1]],
reserveB
);
// 获取池的总供应量
IERC20 lpToken = IERC20(_lpToken);
uint256 totalSupplyOfLP = lpToken.totalSupply();
//代码省略..
uint256 totalValue = value0 + value1;
uint16 shiftAmount = supplyDecimals;
uint256 valueShifted = totalValue * uint256(10)**shiftAmount;
uint256 supplyShifted = supply;
uint256 valuePerSupply = valueShifted / supplyShifted;
return valuePerSupply;
}
在上述代码中,变量 value0
等于 p0 * r0
,而 value1
则是 p1 * r1
。很明显,使用了错误的公式来为 LP 代币定价。
损失:$1.26M
对 Inverse Finance 的类似攻击是由于三池的 LP 代币价格操纵。
function latestAnswer() public view returns (uint256) {
uint256 crvPoolBtcVal = WBTC.balanceOf(address(CRV3CRYPTO)) * uint256(BTCFeed.latestAnswer()) * 1e2;
uint256 crvPoolWethVal = WETH.balanceOf(address(CRV3CRYPTO)) * uint256(ETHFeed.latestAnswer()) / 1e8;
uint256 crvPoolUsdtVal = USDT.balanceOf(address(CRV3CRYPTO)) * uint256(USDTFeed.latestAnswer()) * 1e4;
uint256 crvLPTokenPrice = (crvPoolBtcVal + crvPoolWethVal + crvPoolUsdtVal) * 1e18 / crv3CryptoLPToken.totalSupply();
return (crvLPTokenPrice * vault.pricePerShare()) / 1e18;
}
同样,用于计算 LP 代币价格的公式是错误的,这导致了这次攻击。
要为 LP 代币定价,应使用 Alpha Venture 提出的 公式↗。重要的是要注意,如果 LP 代币同时用作抵押品和借款代币,这个公式仍然可能被操纵。
以下是一些由于采用同一公式导致的其他黑客攻击:
Cheese Bank↗ — 损失:$3.3M
Themis Protocol↗ — 损失:$370k
NXUSD Protocol↗ — 损失:$371k
另一种常见的操纵价格或汇率的方式是直接向池中捐赠。让我们考虑一个情景,其中汇率是通过特定代币的余额与总供应量的比率来确定的。在这种情况下,当攻击者向池中捐赠代币时,会产生潜在威胁,从而改变汇率以自身利益为主。
这种漏洞的一个著名攻击是针对具有零总供应量的 Compound Fork。在 exchangeRateStoredInternal
函数中也存在一个舍入错误:
function exchangeRateStoredInternal() virtual internal view returns (uint) {
uint _totalSupply = totalSupply;
if (_totalSupply == 0) {
/*
* 如果没有代币被铸造:
* exchangeRate = initialExchangeRate
*/
return initialExchangeRateMantissa;
} else {
/*
* 否则:
* exchangeRate = (totalCash + totalBorrows - totalReserves) / totalSupply
*/
uint totalCash = getCashPrior();
uint cashPlusBorrowsMinusReserves = totalCash + totalBorrows - totalReserves;
uint exchangeRate = cashPlusBorrowsMinusReserves * expScale / _totalSupply;
return exchangeRate;
}
}
function getCashPrior() virtual override internal view returns (uint) {
EIP20Interface token = EIP20Interface(underlying);
return token.balanceOf(address(this));
}
攻击涉及向 totalSupply
为零的市场捐赠,并显著增加可借款的汇率。
由于池的 totalSupply
为零,一些基于捐赠的价格操纵攻击是成功的。通过这种方式,攻击者铸造一份股份,以将 totalSupply
提高到 1,然后向池中捐赠以抬高汇率。防止这些攻击的一种简单方法是协议管理员首先铸造一些股份,以便 totalSupply
永远不会为零。其他类似攻击也可能通过保持代币的内部账户来缓解,以核算直接捐赠到池中的代币。
以下是其他由于类似根本问题导致的黑客攻击:
C.R.E.A.M. Finance↗ — 损失:$130M
Hundred\ Finance↗ — 损失:$7M
Midas\ Capital↗ — 损失:$600k
OVIX↗ — 损失:$4M
只读重入攻击发生在 view
函数在重新进入时用于读取协议的一个不一致状态时。如果重新进入的 view
函数用于计算关键数据,例如代币价格,则可以在重新进入时操纵数据。由于 view
函数通常未使用非重入修饰符保护,它们可以在不被还原的情况下被重入。
典型的只读重入攻击流程如下:
view
函数通常没有重入保护,因此不会抛出错误。只读重入的一个常见场景是:可重入合约通常是用于另一个协议的预言机的 DeFi 原语。这个其他协议通常是受害者合约。
以下是一些过去被利用的常见只读重入漏洞:
get_virtual_price
用于利用 CDPs 和借贷协议中只读重入的最常见函数之一是 get_virtual_price
,当智能合约与 Curve 池集成以估计 LP 代币价格时。在执行 remove_liquidity
时,可以重新进入这个函数。在 raw_call
的过程中,控制流将被转移到接收方的回调函数。如果在此状态下重新进入 get_virtual_price
函数,D 的值将不一致,导致返回值不一致,因此 LP 代币的价格也会不一致。如果移除的代币是 ERC-777/ERC-677 代币,get_virtual_price
也可以重新进入。
@external
@nonreentrant('lock')
def remove_liquidity(
_amount: uint256,
_min_amounts: uint256[N_COINS],
) -> uint256[N_COINS]:
amounts: uint256[N_COINS] = self._balances()
lp_token: address = self.lp_token
total_supply: uint256 = ERC20(lp_token).totalSupply()
CurveToken(lp_token).burnFrom(msg.sender, _amount) # 开发者提醒:资金不足
for i in range(N_COINS):
value: uint256 = amounts[i] * _amount / total_supply
assert value >= _min_amounts[i], "提取导致的代币少于预期"
amounts[i] = value
if i == 0:
raw_call(msg.sender, b"", value=value)
else:
assert ERC20(self.coins[1]).transfer(msg.sender, value)
log RemoveLiquidity(msg.sender, amounts, empty(uint256[N_COINS]), total_supply - _amount)
return amounts
@view
@external
def get_virtual_price() -> uint256:
"""
@notice 当前 LP 代币的虚拟价格
@dev 用于计算利润
@return LP 代币虚拟价格规范化至 1e18
"""
D: uint256 = self.get_D(self._balances(), self._A())
token_supply: uint256 = ERC20(self.lp_token).totalSupply()
return D * PRECISION / token_supply
以下是由于 get_virtual_price
中的只读重入导致的一些黑客攻击:
dForce Protocol↗ — 损失:$3.4M
Midas Capital↗ — 损失:$660K
Sturdy Finance↗ — 损失:$880K
Jarvis Network↗ — 损失:~$660K
_joinOrExit
Balancer 的只读重入也曾是多起黑客攻击的根本原因。问题出在 _joinOrExit
函数中,其中转账(因此回调)发生在池余额更新之前,因此造成会计不一致。
预防只读重入攻击需要仔细设计和实现协议。确保只读函数不能被操纵以修改状态变量是非常重要的。在协议集成时,必须验证这些只读函数是否已经受到重入保护。如果没有,就应验证这些外部协议的重入锁在任何只读函数调用期间不会处于任何重入场景。鉴于最近由于 Vyper 编译器错误导致 Curve 池的黑客攻击,实施针对这些合约的强健测试至关重要。这将确保潜在的缺陷不会对协议产生影响。
以下是在 _joinOrExit
中造成的几起黑客攻击:
Sturdy Finance↗ — 损失:$800K
Sentiment↗ — 损失:$1M
重入在 2016 年的 DAO 黑客攻击中被有名地利用,当时价值 5000 万美金的以太坊被通过递归引发的方式夺走。重入问题发生在一个脆弱的合约调用外部合约时,而未正确管理在其执行过程中发生的状态变化。接收合约可以向发送合约发出递归调用,重复此过程多次,这样可能导致代币被排空或以意想不到的方式更改合约的状态。
在 CDPs 和借贷协议中,存在许多重入和只读重入问题。我们将在本节中讨论重入的常见情况,并在下一节深入探讨只读重入。
损失:~$80M
Rari 是一个 Compound fork,协议复合在通过 borrowFresh
函数借用 cTokens 时发生了重入攻击问题。见下:
doTransferOut(borrower, borrowAmount);
/* 我们将先前计算的值写入存储 */
accountBorrows[borrower].principal = vars.accountBorrowsNew;
accountBorrows[borrower].interestIndex = borrowIndex;
totalBorrows = vars.totalBorrowsNew;
doTransferOut
函数低级别地调用借款人的地址,然后可以使用此地址发出重入调用 exitMarket
并提取抵押物。在这里,违反了检查-效果-交互 (CEI) 模式,导致了攻击。
请查看一些由于相同根本问题导致的其他黑客攻击:
DeFiPIE Protocol↗ — 损失:$269K
Paribus↗ — 损失:$67K
在协议中使用这些代币时应保持谨慎,因为这些代币可能会导致重入攻击。一些使用 ERC-777/ERC-677 的 Compound Fork 已因两种问题的结合而遭到黑客攻击。例如,CEI 模式没有得到遵循,且协议使用的代币具有回调。
以下是由于使用 ERC-777/ERC-677 代币而被黑客攻击的一些 Compound Fork:
Hundred Finance↗ — 损失:$6.2M
Voltage Finance↗ — 损失: $4.6M
Agave DAO↗ — 损失:$5.5M
C.R.E.A.M. Finance↗ — 损失:$18M
还有一些其他(不在 Compound Fork 上)由于使用此类代币导致的黑客攻击。以下是一些例子。
损失:~$1M
漏洞的根本原因是由于 ERC-777 代币回调函数 tokensReceived
导致的重入,导致在 lend
函数中重入,如下所示。
始终遵循 CEI 模式并将非重入修饰符添加到易受重入攻击的函数是非常重要的。再次提请关注 FREI-PI↗ 模式。这一模式的主要思想是在函数结束时编写协议不变量,以便如果在交易过程中违反它们,则会被还原。
以下是由于重入问题导致的另一个贷款协议的黑客攻击:
Arcadia Finance↗ — 损失:$460k
该漏洞发生在智能合约未能正确验证且未正确筛选用户输入数据而信任输入时。根本问题是缺乏适当的输入验证机制。以下是由于这些问题导致的一些主要黑客攻击:
损失:$726K
在 3 月 29 日,Auctus 被黑客利用,导致用户损失约 726,000 美元,这些用户未撤销批准。以下是导致黑客成功的代码片段:
function write(address acoToken, uint256 collateralAmount, address exchangeAddress, bytes memory exchangeData)
nonReentrant setExchange(exchangeAddress) public payable
{
require(msg.value > 0, "ACOWriter::write: Invalid msg value");
require(collateralAmount > 0, "ACOWriter::write: Invalid collateral amount");
address _collateral = IACOToken(acoToken).collateral();
if (_isEther(_collateral)) {
IACOToken(acoToken).mintToPayable{value: collateralAmount}(msg.sender);
} else {
_transferFromERC20(_collateral, msg.sender, address(this), collateralAmount);
_approveERC20(_collateral, acoToken, collateralAmount);
IACOToken(acoToken).mintTo(msg.sender, collateralAmount);
}
_sellACOTokens(acoToken, exchangeData);
}
/**
* @dev 内部函数以出售 ACO 代币并将溢价转移给交易发送者。
* @param acoToken ACO 代币的地址。
* @param exchangeData 要发送到交易所的数据。
*/
function _sellACOTokens(address acoToken, bytes memory exchangeData) internal {
uint256 acoBalance = _balanceOfERC20(acoToken, address(this));
_approveERC20(acoToken, erc20proxy, acoBalance);
(bool success,) = _exchange.call{value: address(this).balance}(exchangeData);
require(success, "ACOWriter::_sellACOTokens: Error on call the exchange");
address token = IACOToken(acoToken).strikeAsset();
if(_isEther(token)) {
uint256 wethBalance = _balanceOfERC20(weth, address(this));
if (wethBalance > 0) {
IWETH(weth).withdraw(wethBalance);
}
} else {
_transferERC20(token, msg.sender, _balanceOfERC20(token, address(this)));
}
if (address(this).balance > 0) {
msg.sender.transfer(address(this).balance);
}
}
write
函数是公共的,且参数未经过验证。攻击者通过将 exchangeAddress
设置为 USDC 代币的地址并将 exchangeData
设置为 transferFrom
来利用这一点。所有其他条件都可以通过创建一个假的 acoToken
来轻松绕过,并将其作为输入传递到 write
函数中。
损失:$3M
Fortress Protocol 于 2022 年 5 月 9 日被黑客攻击,导致的原因有多种漏洞。在这里,我们将重点关注导致 Umbrella Network 预言机被操纵的输入验证不足的漏洞。
function submit(
uint32 _dataTimestamp,
bytes32 _root,
bytes32[] memory _keys,
uint256[] memory _values,
uint8[] memory _v,
bytes32[] memory _r,
bytes32[] memory _s
) public { // 可能被外部调用,但由于外部调用栈过深
...
...
for (; i < _v.length; i++) {
address signer = recoverSigner(affidavit, _v[i], _r[i], _s[i]);
uint256 balance = stakingBank.balanceOf(signer);
require(prevSigner < signer, "validator included more than once");
prevSigner = signer;
if (balance == 0) continue;
emit LogVoter(lastBlockId + 1, signer, balance);
power += balance; // 如果出现溢出,不需要安全数学检查
}
require(i >= requiredSignatures, "not enough signatures");
// 一旦我们有了合适的 DPoS,就开启 power
// require(power * 100 / staked >= 66, "not enough power was gathered");
squashedRoots[lastBlockId + 1] = _root.makeSquashedRoot(_dataTimestamp);
blocksCount++;
emit LogMint(msg.sender, lastBlockId + 1, staked, power);
}
submit
函数用于更新价格,可以由任何人调用。该函数仅验证签名数大于 requiredSignatures
。它并没有检查 power
,因为那一行验证被注释掉了(require(power * 100 / staked >= 66, "not enough power was gathered");
)。
在函数中传入的每个参数都必须经过验证。仅应允许函数期望的参数,并且所有其他情况都应被还原。在这里,我们还想强调函数要求–效果–交互 + 协议不变量(FREI-PI↗)的模式。
由于类似问题导致的其他黑客攻击:
Visor Finance↗ - 损失:$8.2M
Deus DAO\ 黑客攻击↗ — 损失:$6.5M
这些问题发生在对谁可以访问或编辑智能合约中的关键数据缺乏足够限制时,并且合约中的访问控制机制设计不当。可以通过实施基于角色的访问控制,确保敏感函数仅由授权地址访问来解决此问题。这是针对一个借贷协议的此类攻击示例:
损失:$1.1M
根本问题是 setOracleData
函数缺乏任何访问控制机制,并且可以由任何人调用。攻击者改变了 oracleData
映射以操纵协议的价格源。
function setOracleData(address rToken, oracleChainlink _oracle) external { **//容易受到攻击**
oracleData[rToken] = _oracle;
}
建议审查每个功能,并仔细判断是否有必要保护该功能的访问控制机制,以便仅拥有合约所有者或治理的地址能够成功访问/调用这样的功能。
价格操纵、访问控制和输入验证不足以及重入问题是 CDPs 和借贷协议的一些主要问题。然而,DeFi 生态系统处于不断变革之中,为未来可能出现的更多安全漏洞铺平了道路。忽视对抗此类黑客攻击的预防措施,是错误创建 CDP 或借贷协议的第一步。
Zellic 专注于保障新兴技术的安全。我们的安全研究人员已经揭示了从财富 500 强企业到 DeFi 巨头的最有价值目标中的漏洞。
开发者、创始人和投资者信任我们的安全评估,从而能够快速、自信地发布,而无需担心出现关键漏洞。凭借我们在现实世界攻击性安全研究中的背景,我们发现了其他人遗漏的内容。
与我们联系↗,进行比其他审计更好的审计。真实的审计,而非走过场。
- 原文链接: zellic.io/blog/how-not-t...
- 登链社区 AI 助手,为大家转译优秀英文文章,如有翻译不通的地方,还请包涵~
如果觉得我的文章对您有用,请随意打赏。你的支持将鼓励我继续创作!