fix(jsvm,wasm): normalize eth addresses in execTransfer bindings behind dapp forks - #1310
Open
33cn wants to merge 2 commits into
Open
fix(jsvm,wasm): normalize eth addresses in execTransfer bindings behind dapp forks#131033cn wants to merge 2 commits into
33cn wants to merge 2 commits into
Conversation
added 2 commits
August 31, 2026 11:02
…nd dapp forks The account layer ExecTransfer/ExecTransferFrozen self-transfer check compares raw from/to strings, while storage keys are normalized to lowercase via address.FormatAddrKey. A mixed-case variant of the same eth address bypasses the from==to check but maps to the same storage key, so a self transfer double-writes the same account and mints coins out of thin air. The root cause was fixed in chain33 (33cn/chain33#1377, 33cn/chain33#1379). This change adds defense in depth at the dapp entry layer: - js dapp: execTransferFunc (plugin/dapp/js/executor/account.go) normalizes from/to with common.FmtEthAddressWithFork before calling the account layer, gated by the new dapp fork ForkJSFixAddrNormalize (jsvm, height 0). - wasm dapp: execTransfer/execTransferFrozen host bindings (plugin/dapp/wasm/executor/callback.go) do the same, gated by the new dapp fork ForkWasmFixAddrNormalize (wasm, height 0). Before the fork height the legacy behavior is preserved for on-chain consensus compatibility. After the fork, case variants converge to the canonical lowercase form, so the account layer self-transfer check always hits and returns ErrSendSameToRecv. Adds executor-level regression tests (vuln_fix_test.go) for both dapps: mixed-case self transfer is rejected with balances unchanged post-fork, normal transfers between different addresses are unaffected, and pre-fork behavior is unchanged.
chain33 validates that every registered dapp fork has a config entry on non-local titles; missing entries crash the node at startup, which broke ci_cross2eth/ci_parachain_rollup/ci_paracross/ci_rgbx.
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.
Vulnerability
The chain33 account layer
ExecTransfer/ExecTransferFrozenself-transfer check compared rawfrom == tostrings, while storage keys are normalized viaaddress.FormatAddrKey(eth hex addresses are lowercased,ForkEthAddressFormat/ForkFormatAddressKey, active at height 0). Two string forms of the same eth address — all-lowercase vs mixed-case — are string-unequal (bypassing thefrom == tocheck) but map to the same DB key, soSaveExecAccountdouble-writes the same account and the balance increases byamountinstead of staying constant: coins are minted out of thin air.Reachable on-chain entry points (defense-in-depth hardening was missing even though the account-layer root cause was fixed in 33cn/chain33#1377 and 33cn/chain33#1379):
execTransferFunc(plugin/dapp/js/executor/account.go,exec_transferbinding) only runsaddress.CheckAddresson contract-suppliedfrom/to— a mixed-case eth address passes validation — then callsacc.ExecTransfer(from, to, ...)directly.execTransfer/execTransferFrozen(plugin/dapp/wasm/executor/resolver.go->plugin/dapp/wasm/executor/callback.go) do no validation/normalization at all before callingacc.ExecTransfer/acc.ExecTransferFrozen.Fix
At both dapp entry layers, before calling the account layer, normalize
from/towith the existingcommon.FmtEthAddressWithFork(same pattern asplugin/dapp/retrieve/executor/retrievedb.go; it respects theForkEthAddressFormatsystem fork and only touches eth-format addresses):plugin/dapp/js/executor/account.go(execTransferFunc; note: jsvm exposes noexecTransferFrozenbinding)plugin/dapp/wasm/executor/callback.go(execTransferandexecTransferFrozen)After normalization, case variants converge to the canonical lowercase string, so the account layer's
from == toself-transfer check always hits and returnsErrSendSameToRecv.Fork gating (consensus compatibility)
Following the
ForkEVMFixOverflowpattern, two dapp forks are registered at height 0 and gate the behavior change:ForkJSFixAddrNormalizeregistered inplugin/dapp/js/types/js.go(InitFork), checked viacfg.IsDappFork(height, jsvm, ...)ForkWasmFixAddrNormalizeregistered inplugin/dapp/wasm/types/wasm.go(InitFork), checked viacfg.IsDappFork(height, wasm, ...)Chains that need the legacy behavior can override the fork height in their chain config; before the fork height the code path is byte-for-byte the old behavior.
Tests
New executor-level regression tests (no testnode needed):
plugin/dapp/js/executor/vuln_fix_test.go: deploys a js contract whosemintcallsexecTransfer. Post-fork: self transfer withfrom=lowercase,to=mixed-case variant is rejected withErrSendSameToRecvand balances are unchanged; a normal transfer to a different address (mixed-caseto) still works and lands on the canonical account; pre-fork (SetDappForkto a future height) the legacy behavior is preserved.plugin/dapp/wasm/executor/vuln_fix_test.go: exercises theexecTransfer/execTransferFrozenhost callbacks directly with the same three assertions.Verification:
All pre-existing tests in both packages pass. New/changed lines introduce no new gofmt/goimports issues (remaining gofmt findings in the touched files pre-date this change).