A deep-dive security audit of Base's B-20 token precompile implementations (base/base repository), focusing on the privileged flag semantics and pause/policy guard consistency.
During analysis of Base's B-20 token operations, two critical inconsistencies were identified in how the privileged flag (used during factory bootstrap) interacts with pause and policy guards across different token operations.
Severity: Medium-High
File: crates/common/precompiles/src/common/ops/burnable.rs
Function: burn_blocked()
Description:
The burn_blocked function calls self.burn(caller, from, amount, true) with privileged = true. Since burn() skips the pause check when privileged, this means burn_blocked can execute even when the BURN feature is paused.
This contradicts the explicit comment in the code:
// Intentional asymmetry: BURN_BLOCKED_ROLE replaces BURN_ROLE, but emergency burn pauses
// still halt every burn path, including burnBlocked.Code flow:
burn_blocked(caller, from, amount, privileged=true)
→ B20Guards::ensure_blocked(self, from)?; // ✅ checked
→ self.burn(caller, from, amount, true) // ⚠️ privileged=true
→ // Role check SKIPPED (privileged=true)
→ // Pause check SKIPPED (privileged=true) ← BUG
→ // Policy check: none for burn
→ // Balance & supply accounting
Impact:
- A paused
BURNfeature is intended to halt ALL burn paths (per the comment) - But
burn_blockedbypasses this pause when called withprivileged=true - During factory bootstrap (
initCalls), a privilegedburnBlockedcall would succeed even ifBURNwas paused via an earlierinitCall - This breaks the invariant that "emergency burn pauses halt every burn path"
Proof:
The test burn_reverts_when_burn_feature_paused in the same file confirms that a direct privileged burn() call reverts when BURN is paused. But burn_blocked → burn(privileged=true) would NOT revert, because the inner burn call uses privileged=true unconditionally.
Recommended Fix: Either:
- Pass
privileged(nottrue) to the innerburncall:self.burn(caller, from, amount, privileged)?; - Or add an explicit pause check in
burn_blockedbefore callingburn:B20Guards::ensure_not_paused::<Self>(self, IB20::PausableFeature::BURN)?;
Reference: Issue #2914
Severity: Medium
The privileged flag has different semantics across operations:
| Operation | Role Check | Pause Check | Policy Check |
|---|---|---|---|
transfer |
N/A | ❌ Skipped | ❌ Skipped |
mint |
❌ Skipped | ✅ Enforced | ✅ Enforced |
burn |
❌ Skipped | ✅ Enforced | N/A |
pause |
❌ Skipped | N/A | N/A |
burn_blocked |
❌ Skipped | ❌ Skipped (via burn) | ✅ Enforced (ensure_blocked) |
This means:
- Factory bootstrap can transfer tokens freely (bypassing pause/policy)
- Factory bootstrap cannot mint if MINT is paused (even with privileged)
- Factory bootstrap cannot burn if BURN is paused (even with privileged)
- Factory bootstrap CAN burnBlocked even if BURN is paused (Finding 1)
File: crates/common/precompiles/src/common/ops/burnable.rs
The burn function does not validate that from != Address::ZERO. While balance_of(Address::ZERO) would typically return 0 (making the balance check fail), explicitly rejecting zero-address burns would be consistent with transfer (which checks from == Address::ZERO) and provide clearer error messages.
base-audit/
├── README.md # This file
├── FINDINGS.md # Detailed technical findings
├── PRIVILEGED_FLAG_ANALYSIS.md # Deep-dive on privileged semantics
└── PROOF_OF_CONCEPT.md # Step-by-step PoC for Finding 1
- 2026-05-28: Initial audit of B-20 precompile operations
- 2026-05-28: Finding 1 (burnBlocked pause bypass) identified
- 2026-05-28: Finding 2 confirmed as already reported (#2914)
- 2026-05-28: Finding 3 (missing zero-address check) noted
This audit was conducted as a code review exercise. Findings are reported responsibly through GitHub Issues on the base/base repository.
MIT © 2026 Sourav Joy