Skip to content

fix(jsvm,wasm): normalize eth addresses in execTransfer bindings behind dapp forks - #1310

Open
33cn wants to merge 2 commits into
masterfrom
fix-wasm-js-addr-normalize
Open

fix(jsvm,wasm): normalize eth addresses in execTransfer bindings behind dapp forks#1310
33cn wants to merge 2 commits into
masterfrom
fix-wasm-js-addr-normalize

Conversation

@33cn

@33cn 33cn commented Aug 31, 2026

Copy link
Copy Markdown
Owner

Vulnerability

The chain33 account layer ExecTransfer/ExecTransferFrozen self-transfer check compared raw from == to strings, while storage keys are normalized via address.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 the from == to check) but map to the same DB key, so SaveExecAccount double-writes the same account and the balance increases by amount instead 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):

  • js dapp (jsvm): execTransferFunc (plugin/dapp/js/executor/account.go, exec_transfer binding) only runs address.CheckAddress on contract-supplied from/to — a mixed-case eth address passes validation — then calls acc.ExecTransfer(from, to, ...) directly.
  • wasm dapp: host bindings execTransfer/execTransferFrozen (plugin/dapp/wasm/executor/resolver.go -> plugin/dapp/wasm/executor/callback.go) do no validation/normalization at all before calling acc.ExecTransfer/acc.ExecTransferFrozen.

Fix

At both dapp entry layers, before calling the account layer, normalize from/to with the existing common.FmtEthAddressWithFork (same pattern as plugin/dapp/retrieve/executor/retrievedb.go; it respects the ForkEthAddressFormat system fork and only touches eth-format addresses):

  • plugin/dapp/js/executor/account.go (execTransferFunc; note: jsvm exposes no execTransferFrozen binding)
  • plugin/dapp/wasm/executor/callback.go (execTransfer and execTransferFrozen)

After normalization, case variants converge to the canonical lowercase string, so the account layer's from == to self-transfer check always hits and returns ErrSendSameToRecv.

Fork gating (consensus compatibility)

Following the ForkEVMFixOverflow pattern, two dapp forks are registered at height 0 and gate the behavior change:

  • ForkJSFixAddrNormalize registered in plugin/dapp/js/types/js.go (InitFork), checked via cfg.IsDappFork(height, jsvm, ...)
  • ForkWasmFixAddrNormalize registered in plugin/dapp/wasm/types/wasm.go (InitFork), checked via cfg.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 whose mint calls execTransfer. Post-fork: self transfer with from=lowercase, to=mixed-case variant is rejected with ErrSendSameToRecv and balances are unchanged; a normal transfer to a different address (mixed-case to) still works and lands on the canonical account; pre-fork (SetDappFork to a future height) the legacy behavior is preserved.
  • plugin/dapp/wasm/executor/vuln_fix_test.go: exercises the execTransfer/execTransferFrozen host callbacks directly with the same three assertions.

Verification:

go test -ldflags=-checklinkname=0 ./plugin/dapp/js/executor/ ./plugin/dapp/wasm/executor/
ok  github.com/33cn/plugin/plugin/dapp/js/executor   4.003s
ok  github.com/33cn/plugin/plugin/dapp/wasm/executor 0.378s

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).

king 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.
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