Skip to content

Ackee audit feb 2026#57

Open
arnigudj wants to merge 10 commits intomainfrom
ackee-audit-feb-2026
Open

Ackee audit feb 2026#57
arnigudj wants to merge 10 commits intomainfrom
ackee-audit-feb-2026

Conversation

@arnigudj
Copy link
Copy Markdown
Member

@arnigudj arnigudj commented Feb 2, 2026

Audit for #55 and #54

@arnigudj
Copy link
Copy Markdown
Member Author

arnigudj commented Feb 5, 2026

Critical Bug Fix: V1_BLOCKED Validation Bypass

🔴 Critical Finding

Our auditors identified a critical vulnerability in the Validator.sol contract where the V1_BLOCKED check could be bypassed. The validator was checking msg.sender to determine if a call originated from a V1 Frontend, but msg.sender was always the ControllerToken proxy, never the actual frontend.

Impact: V1_BLOCKED addresses could successfully transfer tokens through V1 frontends when they should be rejected with a "blocked in V1" error.


🔍 Root Cause

The issue stems from the call chain architecture:

V1 Frontend → ControllerToken (proxy) → Validator.validate()
                                         ↑
                                    msg.sender = ControllerToken
                                    (NOT Frontend)

The original code checked:

if (isV1Frontend(msg.sender)) {  // Always false - msg.sender is ControllerToken
    if (isV1Blocked(from)) { revert(...); }
}

Since msg.sender in validate() is always the ControllerToken contract (not the frontend), the isV1Frontend(msg.sender) check always returned false, causing the V1_BLOCKED logic to be skipped entirely.


✅ Solution

We fixed this by querying the caller (ControllerToken) for its configured frontend address using the existing getFrontend() function:

function validate(address from, address to, uint256 amount) external override returns (bool valid) {
    if (v1BlockedCount > 0) {  // Gas optimization
        // Query the caller to get the actual frontend address
        address frontend = address(0);
        try IControllerToken(msg.sender).getFrontend() returns (address _frontend) {
            frontend = _frontend;
        } catch {
            frontend = msg.sender;  // Backwards compatibility for direct calls
        }

        // Now check if this frontend is a V1 frontend
        if (isV1Frontend(frontend)) {
            if (isV1Blocked(from)) { revert("...blocked in V1..."); }
            if (isV1Blocked(to)) { revert("...blocked in V1..."); }
        }
    }
    // Blacklist checks...
}

Key improvements:

  1. ✅ No interface changes - maintains backwards compatibility
  2. ✅ Correctly identifies V1 frontend calls
  3. ✅ Gas optimized - only queries frontend if blocked addresses exist (saves gas on Arbitrum and other V2-only chains)
  4. ✅ Graceful fallback with try/catch for backwards compatibility

🧪 Tests Added

Complete test suite rewrite in test/Validator.t.sol to test the full integration (not just isolated validator calls):

  1. testV1FrontendBlocksV1BlockedAddresses() - ⭐ Primary test proving the bug is fixed

    • Verifies V1 frontend correctly blocks V1_BLOCKED addresses from transferring
    • This test FAILED before the fix, PASSES after
  2. testV1FrontendBlocksBlacklistedFrom() - Verifies V1 frontend blocks blacklisted senders

  3. testV1FrontendBlocksBlacklistedTo() - Verifies V1 frontend blocks blacklisted recipients

  4. testV1FrontendAllowsNormalTransfers() - Verifies normal users can still transfer via V1 frontend

  5. testGasOptimizationNoBlockedAddresses() - Verifies gas optimization works on chains without V1 frontends

  6. testV1BlockedCounter() - Verifies the blocked address counter increments/decrements correctly

Setup changes:

  • Deploys full integration stack: Validator + ControllerToken + ERC1967Proxy
  • Tests through realistic call chain: frontend → token.transfer_withCaller() → validator.validate()
  • Previous tests only called validator directly, missing the bug

🚀 Gas Optimization Bonus

Added v1BlockedCount counter to skip expensive getFrontend() calls on chains without V1 frontends:

uint256 private v1BlockedCount;  // Tracks number of blocked addresses

function validate(...) external override returns (bool valid) {
    if (v1BlockedCount > 0) {  // Only check if blocked addresses exist
        // ... getFrontend() logic ...
    }
}

Impact: Zero gas overhead on Arbitrum and other V2-only chains that have no blocked addresses.


🧪 How to Test

Run the full test suite:

forge test --match-contract ValidatorTest -vv

Expected output: 17 tests pass (includes 6 new integration tests)

Run specific critical test:

forge test --match-test testV1FrontendBlocksV1BlockedAddresses -vvv

Test gas optimization:

forge test --match-test testGasOptimizationNoBlockedAddresses -vv

📊 Test Results

Ran 17 tests for test/Validator.t.sol:ValidatorTest
[PASS] testV1FrontendBlocksV1BlockedAddresses() ✅
[PASS] testV1FrontendBlocksBlacklistedFrom() ✅
[PASS] testV1FrontendBlocksBlacklistedTo() ✅
[PASS] testV1FrontendAllowsNormalTransfers() ✅
[PASS] testGasOptimizationNoBlockedAddresses() ✅
[PASS] testV1BlockedCounter() ✅
... (11 other tests)
Suite result: ok. 17 passed; 0 failed

📝 Files Changed

  • src/Validator.sol - Fixed validate() logic, added gas optimization
  • test/Validator.t.sol - Complete rewrite with integration tests

✅ Checklist

  • Bug verified and reproduced with failing test
  • Fix implemented without interface changes
  • All tests passing (38/38)
  • Gas optimization for V2-only chains
  • Backwards compatible with existing deployments
  • No breaking changes to external contracts

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant