fix: F-2026-18822 | [Dual Defense] Native ERC20 Convert Skips Approval Log Check — Unbacked Mint on Malicious Pair - #44
Open
0xNilesh wants to merge 1 commit into
Open
fix: F-2026-18822 | [Dual Defense] Native ERC20 Convert Skips Approval Log Check — Unbacked Mint on Malicious Pair#440xNilesh wants to merge 1 commit into
0xNilesh wants to merge 1 commit into
Conversation
…ted Approval event Wire the orphaned validateApprovalEventDoesNotExist guard into both native-ERC20 convert directions.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
The invariant
x/erc20bridges an externally-owned ERC-20 to a bank denom by escrow-and-mint:convertERC20IntoCoinsForNativeToken): the usertransfers tokens to the module's EVMaddress, the module mints
erc20:<token>bank coins.ConvertCoinNativeERC20): the module burns the coins andtransfers the tokens back.The invariant is module escrow balance == bank supply of that denom.
How it broke
After the
transferthe keeper checked only that (a) the call reported success and (b) themodule's token balance rose by exactly the amount. Both hold for a malicious registered token
whose
transferalso grants a third party an allowance over the recipient — i.e. over theescrow module:
Register → convert 100 in (balance check passes, 100 coins minted) → later call
transferFrom(module, thief, 100)→ escrow empty, 100 bank coins still outstanding, unbacked.The balance check is a snapshot comparison taken before the allowance is ever used; nothing
looks wrong at the instant it is taken.
The intended defence was designed and never wired up.
validateApprovalEventDoesNotExist(
x/erc20/keeper/util.go) is defined, unit-tested (x/erc20/keeper/util_test.go), and promisedby both convert doc-comments (
msg_server.go— "check for unexpectedApprovalevent inlogs") — but called from nowhere. The integration case even read
"pass - delayed malicious contract"withexpPass: true, so the suite asserted the vulnerable behaviour was correct.Changes
1. Wire the guard into both convert directions (
x/erc20/keeper/msg_server.go) — after thetransfer return-value validation, before the balance comparison:
No false-positive risk: both paths call plain
transfer, nevertransferFrom, so a compliantERC-20 emits only
Transferhere. The legitimate mid-transferApproval(an allowancedecrement) cannot arise on these paths.
2. Fix the tests that encoded the bug (
tests/integration/x/erc20/test_msg_server.go) —"pass - delayed malicious contract"becomes"fail - delayed malicious contract"withexpPass: false, and two new tests assert the invariant rather than just the error:TestConvertERC20MaliciousApprovalKeepsEscrowInvariant— ERC-20 → Coin. Runs the rejectedmessage both directly (to pin the error) and through a real committed tx (to get baseapp's
real rollback), then asserts the module's escrow balance is unchanged, the sender's token
balance is unchanged, and the denom's bank supply is still zero — no unbacked coins minted.
TestConvertCoinMaliciousApprovalKeepsEscrowInvariant— Coin → ERC-20, the direction that hadno malicious-token coverage at all. Asserts nothing leaves escrow, the receiver gets nothing,
and no coins are burned.
Both new tests fail on
audit-fixeswithout change 1 (verified by stashing the keeper diff).Small extra, worth flagging:
validateApprovalEventDoesNotExistindexeslog.Topics[0]without a length check. That was harmless while the helper was dead and is still narrow in
validateTransferEventExists(only reached when the token returns no value), but the new call isunconditional, so any registered token emitting an anonymous log (
LOG0) fromtransferwouldhave panicked in the keeper. Added a
len(log.Topics) == 0 { continue }guard plus a unit-testcase rather than introduce a new panic path. The helper itself is otherwise untouched.
Known limitation — this is defence-in-depth, not a security boundary
The check is log-based and therefore bypassable. ERC-20 does not force an event on an
allowance write, so a malicious token can set
allowance[module][thief]directly in storage,emit nothing, and sail through. This restores the documented intent and catches the naive case;
it is not a boundary. The only real boundary is not letting arbitrary contracts become pairs
(
PermissionlessRegistration), which is deliberately out of scope here — it is a productdecision, not a bug fix, and
DefaultParams()is untouched by this PR.Hacken's second remediation ("also assert module
allowance(module, *) == 0") is notimplementable:
allowance(owner, spender)requires a known spender, and the EVM offers no wayto enumerate the keys of a mapping. You can check "not this address", never "no address".
Provenance — upstream cosmos/evm gap, not Push-authored
v0.1.0andv0.2.0.v0.3.0(2025-07-16) throughv0.6.2andv0.7.2(both2026-08-19) — ~13 months, across both current release lines. There is no patched upstream
version to upgrade to.
upstream/main@3e646c43(2026-08-21) still carries the orphaned helper, its unit test andboth doc-comments, and still asserts
expPass: trueon theERC20MaliciousDelayedfixture.Shape of the removal (swapping
monitorApprovalEvent→validateTransferEventExistsin oneedit, leaving helper, test and doc-comments behind) reads as a consolidation where the
Approvalhalf was dropped and never re-wired — not a deliberate decision that the check was unnecessary.
Testing
x/erc20/keeper/ibc_callbacks.goalso callsConvertCoinNativeERC20(OnRecvPacket /OnAcknowledgementPacket), which is why the IBC suite is included above.
Audit finding: F-2026-18822 (Low, Impact 2 / Likelihood 2) · tracker RC-25 / PSHL1DDA-489