Skip to content

Fuzz Testing với smart contract

Posted on:December 7, 2023

Những thiệt hại lớn nhất thường gây ra bởi những lỗi ít gặp nhất.

Intro

Như đã chia sẻ trong bài viết này, dù việc code và audit có cẩn thận đến đâu, thì vẫn có thể có những lỗi tiềm tàng xảy ra do những kịch bản mà ta không hề hoặc chưa từng nghĩ tới khi viết test.

Fuzzing, hay còn gọi là Fuzz Testing giúp ta giảm thiểu một lượng lớn những case đó xảy ra bằng cách test với số lượng rất lớn những random input để mô phỏng hầu hết các kịch bản trong thực tế có thể xảy ra, đặc biệt các trường hợp biên.

qa-walk-into-bar

Fuzzing dùng để test property-based của hệ thống.

property-based là những thuộc tính không đổi trong một hệ thống (gọi là Invariant) ví dụ như:

Fuzzing với Foundry

hãy xem một ví dụ rất đơn giản sau đây:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract MyContract {
    uint256 public shouldAlwaysBeZero = 0;

    uint256 private hiddenValue = 0;

    function doStuff(uint256 data) public {
        if (data == 2) {
            shouldAlwaysBeZero = 1;
        }
        if (hiddenValue == 7) {
            shouldAlwaysBeZero = 1;
        }
        hiddenValue = data;
    }
}

thuộc tính không đổi mà ta mong muốn ở đây là:

biến shouldAlwaysBeZero luôn luôn là 0.

Ta viết thử unit test:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {MyContract} from "../src/MyContract.sol";
import {Test} from "forge-std/Test.sol";

contract MyContractTest is Test {
    MyContract exampleContract;

    function setUp() public {
        exampleContract = new MyContract();
    }

    function test_IsAlwaysZero() public {
        uint256 data = 0;
        exampleContract.doStuff(data);
        assert(exampleContract.shouldAlwaysBeZero() == 0);
    }
}

chạy unit test:

$ forge test --match-contract MyContract
[⠒] Compiling...
[⠒] Compiling 26 files with 0.8.17
[⠘] Solc 0.8.17 finished in 3.60s
Compiler run successful!

Running 1 test for test/MyContract.t.sol:MyContractTest
[PASS] testIsAlwaysZeroUnit() (gas: 10409)
Test result: ok. 1 passed; 0 failed; 0 skipped; finished in 422.83µs

Ran 1 test suites: 1 tests passed, 0 failed, 0 skipped (1 total tests)

test passed. Tức với unit test, đoạn code trên không có lỗi? không, tất nhiên đoạn code trên dùng để làm ví dụ có lỗi, ta dễ dàng tìm ra rằng nếu đưa data = 2 vào thì sẽ thay đổi được biến shouldAlwaysBeZero. Hoặc lần đầu ta đưa data là 7 để thay đổi hiddenValue, và gọi tiếp lần sau với data bất kì thì ta cũng có thể có được kết quả tương tự.

Tuy nhiên không phải lúc nào mọi thứ cũng rõ ràng và đơn giản như vậy, ví dụ một hàm trông như thế này thì sao? liệu nó có lỗi gì không?

function hellFunc(uint128 numberr) public view onlyOwner returns (uint256) {
        uint256 numberrr = uint256(numberr);
        Int number = Int.wrap(numberrr);
        if (Int.unwrap(number) == 1) {
            if (numbr < 3) {
                return Int.unwrap((Int.wrap(2) - number) * Int.wrap(100) / (number + Int.wrap(2)));
            }
            if (Int.unwrap(number) < 3) {
                return Int.unwrap((Int.wrap(numbr) - number) * Int.wrap(92) / (number + Int.wrap(3)));
            }
            if (Int.unwrap(Int.wrap(Int.unwrap(Int.wrap(Int.unwrap(Int.wrap(1)) / Int.unwrap(Int.wrap(Int.unwrap(Int.wrap(Int.unwrap(Int.wrap(numbr)))))))))) == 9) {
                return 1654;
            }
            return 5 - Int.unwrap(number);
        }
        if (Int.unwrap(number) > 100) {
            _numbaar(Int.unwrap(number));
            uint256 dog = _numbaar(Int.unwrap(number) + 50);
            return (dog + numbr - (numbr / numbir) * numbor) - numbir;
        }
        if (Int.unwrap(number) > 1) {
            if (Int.unwrap(number) < 3) {
                return Int.unwrap((Int.wrap(2) - number) * Int.wrap(100) / (number + Int.wrap(2)));
            }
            if (numbr < 3) {
                return (2 / Int.unwrap(number)) + 100 - (Int.unwrap(number) * 2);
            }
            if (Int.unwrap(number) < 12) {
                if (Int.unwrap(number) > 6) {
                    return Int.unwrap((Int.wrap(2) - number) * Int.wrap(100) / (number + Int.wrap(2)));
                }
            }
            if (Int.unwrap(number) < 154) {
                if (Int.unwrap(number) > 100) {
                    if (Int.unwrap(number) < 120) {
                        return (76 / Int.unwrap(number)) + 100 - Int.unwrap(Int.wrap(uint256(uint256(uint256(uint256(uint256(uint256(uint256(uint256(uint256(uint256(uint256(uint256(numbr))))))))))))) + Int.wrap(uint256(2)));
                    }
                }
                if (Int.unwrap(number) > 95) {
                    return Int.unwrap(Int.wrap((Int.unwrap(number) % 99)) / Int.wrap(1));
                }
                if (Int.unwrap(number) > 88) {
                    return Int.unwrap((Int.wrap((Int.unwrap(number) % 99) + 3)) / Int.wrap(1));
                }
                if (Int.unwrap(number) > 80) {
                    return (Int.unwrap(number) + 19) - (numbr * 10);
                }
                return Int.unwrap(number) + numbr - Int.unwrap(Int.wrap(nunber) / Int.wrap(1));
            }
            if (Int.unwrap(number) < 7654) {
                if (Int.unwrap(number) > 100000) {
                    if (Int.unwrap(number) < 1200000) {
                        return (2 / Int.unwrap(number)) + 100 - (Int.unwrap(number) * 2);
                    }
                }
                if (Int.unwrap(number) > 200) {
                    if (Int.unwrap(number) < 300) {
                        return (2 / Int.unwrap(number)) + Int.unwrap(Int.wrap(100) / (number + Int.wrap(2)));
                    }
                }
            }
        }
        if (Int.unwrap(number) == 0) {
            if (Int.unwrap(number) < 3) {
                return Int.unwrap((Int.wrap(2) - (number * Int.wrap(2))) * Int.wrap(100) / (Int.wrap(Int.unwrap(number)) + Int.wrap(2)));
            }
            if (numbr < 3) {
                return (Int.unwrap(Int.wrap(2) - (number * Int.wrap(3)))) + 100 - (Int.unwrap(number) * 2);
            }
            if (numbr == 10) {
                return Int.unwrap(Int.wrap(10));
            }
            return (236 * 24) / Int.unwrap(Int.wrap(Int.unwrap(Int.wrap(Int.unwrap(Int.wrap(Int.unwrap(number)))))));
        }
        return numbr + nunber - mumber - mumber;
    }

Ta sẽ viết unit test thế nào để cover được hết các trường hợp có thể xảy ra cho hàm trên? Thực sự quá khó để tìm ra giá trị như thế nào sẽ có thể gây lỗi cho một hàm như vậy.

Có hai phương pháp phổ biến để tìm ra những case ngoại lai trong trường hợp này:

  1. Fuzzing (Fuzz Testing, Invariant Testing)
  2. Formal Verification / Symbolic Execution

Trong bài này ta đang nói về Fuzzing.

Ta sẽ viết thêm fuzz test cho ví dụ bên trên để tìm ra case có thể gây lỗi hệ thống:

function testFuzz_IsAlwaysZero(uint256 randomData) public {
    exampleContract.doStuff(randomData);
    assert(exampleContract.shouldAlwaysBeZero() == 0);
}

chạy lại test:

$ forge test --match-contract MyContract
[⠢] Compiling...
[⠆] Compiling 1 files with 0.8.17
[⠰] Solc 0.8.17 finished in 1.08s
Compiler run successful!

Running 2 tests for test/MyContract.t.sol:MyContractTest
[FAIL. Reason: panic: assertion failed (0x01); counterexample: calldata=0x8f8d14b70000000000000000000000000000000000000000000000000000000000000002 args=[2]] testFuzz_IsAlwaysZero(uint256) (runs: 15, μ: 29038, ~: 30365)
[PASS] test_IsAlwaysZero() (gas: 10442)
Test result: FAILED. 1 passed; 1 failed; 0 skipped; finished in 1.34ms

Ran 1 test suites: 1 tests passed, 1 failed, 0 skipped (2 total tests)

Failing tests:
Encountered 1 failing test in test/MyContract.t.sol:MyContractTest
[FAIL. Reason: panic: assertion failed (0x01); counterexample: calldata=0x8f8d14b70000000000000000000000000000000000000000000000000000000000000002 args=[2]] testFuzz_IsAlwaysZero(uint256) (runs: 15, μ: 29038, ~: 30365)

Encountered a total of 1 failing tests, 1 tests succeeded

Bằng việc thực hiện fuzz test, Foundry đã giúp ta tìm ra được một trường hợp fail của hệ thống, là khi randomData là 2, đúng như những gì ta đã nói bên trên.

Việc random data này thực chất là semi-random, tức là random một cách thông minh để làm sao để có thể nhanh nhất đưa ra được những giá trị có liên quan nhất đến đoạn code của chúng ta, trong trường hợp này là cho đoạn if data == 2, nó sẽ biết cách dùng 2 làm input.

Stateless Fuzzing với Stateful Fuzzing

Ta nhận thấy rằng Foundry đã giúp chúng ta tìm ra được trường hợp với input là 2 thì sẽ gây lỗi hệ thống. Nhưng lại không thể tìm ra trường hợp khi input là 7 và gọi thêm một lần nữa với input bất kì.

Đoạn code test trên của chúng ta được gọi là Stateless Fuzzing, tức nó chỉ chạy một lần duy nhất để tìm ra xem có lỗi gì hay không mà không mà không sử dụng tới trạng thái của những lần chạy trước đó.

Để tìm ra được trường hợp gây lỗi với input là 7 kia thì ta sẽ cần sử dụng Stateful Fuzzing, hay còn gọi là Invariant Testing. Khác với Stateless Fuzzing, Stateful Fuzzing cho phép sử dụng trạng thái của những lời gọi hàm trước đó để sử dụng cho lời gọi hàm phía sau. Bằng cách này Stateful Testing có thể kết hợp rất nhiều những lời gọi hàm liên tiếp nhau để tìm ra những kịch bản phức tạp hơn có thể gây lỗi cho hệ thống.

Thông thường khi nói đến Fuzzing là ta đang nói đến Stateless Fuzzing, còn Invariant Testing là nói đến Stateful Fuzzing.

Ta sẽ viết Invariant Testing để tìm ra trường hợp gây lỗi với input là 7 như sau:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

import {MyContract} from "../src/MyContract.sol";
import {Test} from "forge-std/Test.sol";
import {StdInvariant} from "forge-std/StdInvariant.sol";

contract MyContractTest is StdInvariant, Test {
    MyContract exampleContract;

    function setUp() public {
        exampleContract = new MyContract();
        targetContract(address(exampleContract));
    }


    function invariant_testAlwaysReturnsZero() public {
        assert(exampleContract.shouldAlwaysBeZero() == 0);
    }
}

Với Invariant Testing thay vì phải chủ động đưa vào các random data, hệ thống sẽ tự động gọi một loạt các random function với random data khác nhau, tạo nên số lượng kịch bản khổng lồ có thể diễn ra. Ta chỉ việc định nghĩa invariant là xong.

Khi này bằng vài lần chạy test thì bên cạnh trường hợp bằng 2 ta sẽ có trường hợp mới gây lỗi khi input bằng 7 ở lần gần cuối, đúng như kịch bản ta đã dự đoán:

$ forge test --match-contract MyContract
[⠢] Compiling...
No files changed, compilation skipped

Running 1 test for test/MyContract.t.sol:MyContractTest
[FAIL. Reason: panic: assertion failed (0x01)]
        [Sequence]
                sender=0x1d10A1FC0862B8f6CF2EF28f662A8072034b2f53 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[35350500055297590134862393535083679839599368862750373679432841770512088 [3.535e70]]
                sender=0x00000000000000000000000000000000000008b3 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[0]
                sender=0x0000000000000000000000000000000000000Ba5 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[6144456733000558609646156 [6.144e24]]
                sender=0xb70a64bc56381fed59844BB9C5b1A233539e38bA addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[990]
                sender=0x6FCFe386Bc13CbA2ABd36a1047F34d2dFD71eECA addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[1]
                sender=0x0000000000000000000000000000000000000160 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[4274138836023 [4.274e12]]
                sender=0x0000000000000000000000000000000000000ad2 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[7]
                sender=0x000000000000000000000000000000003F7286F4 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[286183929312458427811880886228415353614959305 [2.861e44]]
 invariant_IsAlwaysZeroUnit() (runs: 256, calls: 3834, reverts: 0)
Test result: FAILED. 0 passed; 1 failed; 0 skipped; finished in 348.43ms

Ran 1 test suites: 0 tests passed, 1 failed, 0 skipped (1 total tests)

Failing tests:
Encountered 1 failing test in test/MyContract.t.sol:MyContractTest
[FAIL. Reason: panic: assertion failed (0x01)]
        [Sequence]
                sender=0x1d10A1FC0862B8f6CF2EF28f662A8072034b2f53 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[35350500055297590134862393535083679839599368862750373679432841770512088 [3.535e70]]
                sender=0x00000000000000000000000000000000000008b3 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[0]
                sender=0x0000000000000000000000000000000000000Ba5 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[6144456733000558609646156 [6.144e24]]
                sender=0xb70a64bc56381fed59844BB9C5b1A233539e38bA addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[990]
                sender=0x6FCFe386Bc13CbA2ABd36a1047F34d2dFD71eECA addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[1]
                sender=0x0000000000000000000000000000000000000160 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[4274138836023 [4.274e12]]
                sender=0x0000000000000000000000000000000000000ad2 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[7]
                sender=0x000000000000000000000000000000003F7286F4 addr=[src/MyContract.sol:MyContract]0x5615dEB798BB3E4dFa0139dFa1b3D433Cc23b72f calldata=doStuff(uint256) args=[286183929312458427811880886228415353614959305 [2.861e44]]
 invariant_IsAlwaysZeroUnit() (runs: 256, calls: 3834, reverts: 0)

Encountered a total of 1 failing tests, 0 tests succeeded

Thực sự tuyệt vời!

Tóm lại Fuzz TestingInvariant Testing là kiến thức rất quan trọng về testing cho dev, đặc biệt là smart contract dev, nơi một lỗ hổng vô cùng nhỏ có thể gây ra thiệt hại hàng triệu đô.

Hãy nắm thật chắc và luôn luôn viết Fuzz Testing cho mọi contract.

Tham khảo