|
| 1 | +# Mutation Testing for Solidity with Slither (slither-mutate) |
| 2 | + |
| 3 | +{{#include ../../../banners/hacktricks-training.md}} |
| 4 | + |
| 5 | +Mutation testing "tests your tests" by systematically introducing small changes (mutants) into your Solidity code and re-running your test suite. If a test fails, the mutant is killed. If the tests still pass, the mutant survives, revealing a blind spot in your test suite that line/branch coverage cannot detect. |
| 6 | + |
| 7 | +Key idea: Coverage shows code was executed; mutation testing shows whether behavior is actually asserted. |
| 8 | + |
| 9 | +## Why coverage can deceive |
| 10 | + |
| 11 | +Consider this simple threshold check: |
| 12 | + |
| 13 | +```solidity |
| 14 | +function verifyMinimumDeposit(uint256 deposit) public returns (bool) { |
| 15 | + if (deposit >= 1 ether) { |
| 16 | + return true; |
| 17 | + } else { |
| 18 | + return false; |
| 19 | + } |
| 20 | +} |
| 21 | +``` |
| 22 | + |
| 23 | +Unit tests that only check a value below and a value above the threshold can reach 100% line/branch coverage while failing to assert the equality boundary (==). A refactor to `deposit >= 2 ether` would still pass such tests, silently breaking protocol logic. |
| 24 | + |
| 25 | +Mutation testing exposes this gap by mutating the condition and verifying your tests fail. |
| 26 | + |
| 27 | +## Common Solidity mutation operators |
| 28 | + |
| 29 | +Slither’s mutation engine applies many small, semantics-changing edits, such as: |
| 30 | +- Operator replacement: `+` ↔ `-`, `*` ↔ `/`, etc. |
| 31 | +- Assignment replacement: `+=` → `=`, `-=` → `=` |
| 32 | +- Constant replacement: non-zero → `0`, `true` ↔ `false` |
| 33 | +- Condition negation/replacement inside `if`/loops |
| 34 | +- Comment out whole lines (CR: Comment Replacement) |
| 35 | +- Replace a line with `revert()` |
| 36 | +- Data type swaps: e.g., `int128` → `int64` |
| 37 | + |
| 38 | +Goal: Kill 100% of generated mutants, or justify survivors with clear reasoning. |
| 39 | + |
| 40 | +## Running mutation testing with slither-mutate |
| 41 | + |
| 42 | +Requirements: Slither v0.10.2+. |
| 43 | + |
| 44 | +- List options and mutators: |
| 45 | + |
| 46 | +```bash |
| 47 | +slither-mutate --help |
| 48 | +slither-mutate --list-mutators |
| 49 | +``` |
| 50 | + |
| 51 | +- Foundry example (capture results and keep a full log): |
| 52 | + |
| 53 | +```bash |
| 54 | +slither-mutate ./src/contracts --test-cmd="forge test" &> >(tee mutation.results) |
| 55 | +``` |
| 56 | + |
| 57 | +- If you don’t use Foundry, replace `--test-cmd` with how you run tests (e.g., `npx hardhat test`, `npm test`). |
| 58 | + |
| 59 | +Artifacts and reports are stored in `./mutation_campaign` by default. Uncaught (surviving) mutants are copied there for inspection. |
| 60 | + |
| 61 | +### Understanding the output |
| 62 | + |
| 63 | +Report lines look like: |
| 64 | + |
| 65 | +```text |
| 66 | +INFO:Slither-Mutate:Mutating contract ContractName |
| 67 | +INFO:Slither-Mutate:[CR] Line 123: 'original line' ==> '//original line' --> UNCAUGHT |
| 68 | +``` |
| 69 | + |
| 70 | +- The tag in brackets is the mutator alias (e.g., `CR` = Comment Replacement). |
| 71 | +- `UNCAUGHT` means tests passed under the mutated behavior → missing assertion. |
| 72 | + |
| 73 | +## Reducing runtime: prioritize impactful mutants |
| 74 | + |
| 75 | +Mutation campaigns can take hours or days. Tips to reduce cost: |
| 76 | +- Scope: Start with critical contracts/directories only, then expand. |
| 77 | +- Prioritize mutators: If a high-priority mutant on a line survives (e.g., entire line commented), you can skip lower-priority variants for that line. |
| 78 | +- Parallelize tests if your runner allows it; cache dependencies/builds. |
| 79 | +- Fail-fast: stop early when a change clearly demonstrates an assertion gap. |
| 80 | + |
| 81 | +## Triage workflow for surviving mutants |
| 82 | + |
| 83 | +1) Inspect the mutated line and behavior. |
| 84 | + - Reproduce locally by applying the mutated line and running a focused test. |
| 85 | + |
| 86 | +2) Strengthen tests to assert state, not only return values. |
| 87 | + - Add equality-boundary checks (e.g., test threshold `==`). |
| 88 | + - Assert post-conditions: balances, total supply, authorization effects, and emitted events. |
| 89 | + |
| 90 | +3) Replace overly permissive mocks with realistic behavior. |
| 91 | + - Ensure mocks enforce transfers, failure paths, and event emissions that occur on-chain. |
| 92 | + |
| 93 | +4) Add invariants for fuzz tests. |
| 94 | + - E.g., conservation of value, non-negative balances, authorization invariants, monotonic supply where applicable. |
| 95 | + |
| 96 | +5) Re-run slither-mutate until survivors are killed or explicitly justified. |
| 97 | + |
| 98 | +## Case study: revealing missing state assertions (Arkis protocol) |
| 99 | + |
| 100 | +A mutation campaign during an audit of the Arkis DeFi protocol surfaced survivors like: |
| 101 | + |
| 102 | +```text |
| 103 | +INFO:Slither-Mutate:[CR] Line 33: 'cmdsToExecute.last().value = _cmd.value' ==> '//cmdsToExecute.last().value = _cmd.value' --> UNCAUGHT |
| 104 | +``` |
| 105 | + |
| 106 | +Commenting out the assignment didn’t break the tests, proving missing post-state assertions. Root cause: code trusted a user-controlled `_cmd.value` instead of validating actual token transfers. An attacker could desynchronize expected vs. actual transfers to drain funds. Result: high severity risk to protocol solvency. |
| 107 | + |
| 108 | +Guidance: Treat survivors that affect value transfers, accounting, or access control as high-risk until killed. |
| 109 | + |
| 110 | +## Practical checklist |
| 111 | + |
| 112 | +- Run a targeted campaign: |
| 113 | + - `slither-mutate ./src/contracts --test-cmd="forge test"` |
| 114 | +- Triage survivors and write tests/invariants that would fail under the mutated behavior. |
| 115 | +- Assert balances, supply, authorizations, and events. |
| 116 | +- Add boundary tests (`==`, overflows/underflows, zero-address, zero-amount, empty arrays). |
| 117 | +- Replace unrealistic mocks; simulate failure modes. |
| 118 | +- Iterate until all mutants are killed or justified with comments and rationale. |
| 119 | + |
| 120 | +## References |
| 121 | + |
| 122 | +- [Use mutation testing to find the bugs your tests don't catch (Trail of Bits)](https://blog.trailofbits.com/2025/09/18/use-mutation-testing-to-find-the-bugs-your-tests-dont-catch/) |
| 123 | +- [Arkis DeFi Prime Brokerage Security Review (Appendix C)](https://github.com/trailofbits/publications/blob/master/reviews/2024-12-arkis-defi-prime-brokerage-securityreview.pdf) |
| 124 | +- [Slither (GitHub)](https://github.com/crytic/slither) |
| 125 | + |
| 126 | +{{#include ../../../banners/hacktricks-training.md}} |
0 commit comments